diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index ab8294ad5..bd05fba52 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -4,6 +4,6 @@ ## Checklist -- [ ] The PR title is a conventional commit with a workspace scope, such as `feat(inference): ...`. It becomes the squash commit and the changelog entry. +- [ ] The PR title is a conventional commit with a workspace scope, such as `feat(reasoning): ...`. It becomes the squash commit and the changelog entry. - [ ] `pnpm check` passes locally, with every file at 100% coverage. - [ ] I've signed the Contributor License Agreement (the CLA bot asks on your first PR). diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 95ec55396..9be99d0a8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -212,13 +212,13 @@ jobs: grep --quiet '"structuredContent":{"id":"smoke","name":"Smoke test"' read.txt call_mcp /mcp '{"jsonrpc":"2.0","id":4,"method":"tools/list"}' > all-tools.txt test "$(grep --only-matching '"name":"[a-z_]*","title"' all-tools.txt | wc -l)" -eq 25 - for tool in list_models list_executions get_execution_history get_brain_analytics list_brain_events publish_event list_tool_servers test_tool_call list_interactions answer_interaction send_execution_event cancel_execution get_guide; do + for tool in list_models list_runs get_run_history get_brain_analytics list_brain_events publish_event list_tool_servers test_tool_call list_interactions answer_interaction send_run_event cancel_run get_guide; do grep --quiet "\"name\":\"$tool\"" all-tools.txt done - call_mcp /mcp '{"jsonrpc":"2.0","id":5,"method":"tools/call","params":{"name":"list_specs","arguments":{"brain":"smoke","primitive":"inference"}}}' > specs.txt - grep --quiet '"structuredContent":{"specs":\[\]}' specs.txt - call_mcp /mcp '{"jsonrpc":"2.0","id":6,"method":"tools/call","params":{"name":"list_executions","arguments":{"brain":"smoke"}}}' > executions.txt - grep --quiet '"structuredContent":{"executions":\[\],"has_more":false,"next_cursor":null}' executions.txt + call_mcp /mcp '{"jsonrpc":"2.0","id":5,"method":"tools/call","params":{"name":"list_definitions","arguments":{"brain":"smoke","type":"reasoning"}}}' > definitions.txt + grep --quiet '"structuredContent":{"definitions":\[\]}' definitions.txt + call_mcp /mcp '{"jsonrpc":"2.0","id":6,"method":"tools/call","params":{"name":"list_runs","arguments":{"brain":"smoke"}}}' > runs.txt + grep --quiet '"structuredContent":{"runs":\[\],"has_more":false,"next_cursor":null}' runs.txt call_mcp /mcp '{"jsonrpc":"2.0","id":7,"method":"tools/call","params":{"name":"list_tool_servers","arguments":{"brain":"smoke"}}}' > tool-servers.txt grep --quiet '"structuredContent":{"tool_servers":\[\]}' tool-servers.txt call_mcp /mcp '{"jsonrpc":"2.0","id":15,"method":"tools/call","params":{"name":"list_tool_servers","arguments":{}}}' > org-tool-servers.txt @@ -256,7 +256,7 @@ jobs: echo "::add-mask::$key" API_KEYS="[$(printf '%s\n' "$issued" | sed --quiet 's/^API_KEYS entry: //p')]" export API_KEYS - docker run --detach --name inference --publish 8080:8080 --env API_KEYS auto-brain:ci + docker run --detach --name reasoning --publish 8080:8080 --env API_KEYS auto-brain:ci for attempt in $(seq 1 30); do curl --silent --fail http://127.0.0.1:8080/health > /dev/null && break sleep 1 @@ -267,17 +267,17 @@ jobs: } brain=http://127.0.0.1:8080/v1/orgs/demo/brains/smoke source="$(printf '%s\n' '---' 'model: anthropic/claude-sonnet-4-5' '---' 'Say hello to {{ input.name }}.')" - spec="$(jq --null-input --arg source "$source" '{name: "hello", source: $source}')" + definition="$(jq --null-input --arg source "$source" '{name: "hello", source: $source}')" test "$(call brain.json --data '{"brain":"smoke","name":"Smoke test"}' http://127.0.0.1:8080/v1/orgs/demo/brains)" = 201 - test "$(call created.json --data "$spec" "$brain/specs/inference")" = 201 - test "$(call read.json "$brain/specs/inference/hello")" = 200 + test "$(call created.json --data "$definition" "$brain/definitions/reasoning")" = 201 + test "$(call read.json "$brain/definitions/reasoning/hello")" = 200 test "$(jq --raw-output .source read.json)" = "$source" - test "$(call executed.json --data '{"input":{"name":"CI"}}' "$brain/specs/inference/hello/execute")" = 503 - test "$(jq --raw-output .reason executed.json)" = unavailable - test "$(jq --raw-output .detail executed.json)" = \ + test "$(call ran.json --data '{"input":{"name":"CI"}}' "$brain/definitions/reasoning/hello/run")" = 503 + test "$(jq --raw-output .reason ran.json)" = unavailable + test "$(jq --raw-output .detail ran.json)" = \ 'anthropic is not configured. No model provider is configured' - docker stop inference - test "$(docker inspect inference --format '{{.State.ExitCode}}')" = 0 + docker stop reasoning + test "$(docker inspect reasoning --format '{{.State.ExitCode}}')" = 0 - name: Run a computation function run: | issued="$(docker run --rm auto-brain:ci node packages/identity/src/key-command.ts --org demo)" @@ -300,13 +300,13 @@ jobs: raising="$(printf '%s\n' '---' 'language: jq' '---' 'error("no rows")')" test "$(call brain.json --data '{"brain":"smoke","name":"Smoke test"}' http://127.0.0.1:8080/v1/orgs/demo/brains)" = 201 for name in total raising; do - spec="$(jq --null-input --arg name "$name" --arg source "${!name}" '{name: $name, source: $source}')" - test "$(call created.json --data "$spec" "$brain/specs/computation")" = 201 + definition="$(jq --null-input --arg name "$name" --arg source "${!name}" '{name: $name, source: $source}')" + test "$(call created.json --data "$definition" "$brain/definitions/computation")" = 201 done test "$(call totalled.json --data '{"input":{"rows":[{"cost_cents":1050},{"cost_cents":2075}]}}' \ - "$brain/specs/computation/total/execute")" = 200 + "$brain/definitions/computation/total/run")" = 200 test "$(jq --compact-output '[.status, .output]' totalled.json)" = '["succeeded",{"total_cents":3125,"rows":2}]' - test "$(call raised.json --data '{"input":{}}' "$brain/specs/computation/raising/execute")" = 409 + test "$(call raised.json --data '{"input":{}}' "$brain/definitions/computation/raising/run")" = 409 test "$(jq --compact-output '[.reason, .kind, .detail]' raised.json)" = \ '["conflict","unworkable","The program raised an error on line 4: no rows"]' docker stop computation @@ -332,20 +332,20 @@ jobs: source="$(printf '%s\n' '---' "to: '{{ input.owner }}'" 'expires: P1D' 'output:' \ ' schema: {type: object, required: [choice], properties: {choice: {type: string, enum: [approve, reject]}}}' \ '---' 'Approve the brief for {{ input.campaign }}?')" - spec="$(jq --null-input --arg source "$source" '{name: "approve-brief", source: $source}')" + definition="$(jq --null-input --arg source "$source" '{name: "approve-brief", source: $source}')" test "$(call brain.json --data '{"brain":"smoke","name":"Smoke test"}' http://127.0.0.1:8080/v1/orgs/demo/brains)" = 201 - test "$(call created.json --data "$spec" "$brain/specs/interaction")" = 201 + test "$(call created.json --data "$definition" "$brain/definitions/interaction")" = 201 test "$(call asked.json --data '{"input":{"owner":"ada","campaign":"Spring"}}' \ - "$brain/specs/interaction/approve-brief/execute")" = 200 + "$brain/definitions/interaction/approve-brief/run")" = 200 test "$(jq --raw-output .status asked.json)" = started - asked="$(jq --raw-output .execution_id asked.json)" + asked="$(jq --raw-output .run_id asked.json)" test "$(call inbox.json "$brain/interactions?to=ada")" = 200 - test "$(jq --compact-output '[.interactions[] | [.execution_id, .message]]' inbox.json)" = \ + test "$(jq --compact-output '[.interactions[] | [.run_id, .message]]' inbox.json)" = \ "[[\"$asked\",\"Approve the brief for Spring?\"]]" - test "$(call answered.json --data '{"answer":{"choice":"approve"}}' "$brain/executions/$asked/answer")" = 200 - test "$(call run.json "$brain/executions/$asked")" = 200 + test "$(call answered.json --data '{"answer":{"choice":"approve"}}' "$brain/runs/$asked/answer")" = 200 + test "$(call run.json "$brain/runs/$asked")" = 200 test "$(jq --compact-output '[.status, .output]' run.json)" = '["succeeded",{"choice":"approve"}]' - test "$(call again.json --data '{"answer":{"choice":"reject"}}' "$brain/executions/$asked/answer")" = 409 + test "$(call again.json --data '{"answer":{"choice":"reject"}}' "$brain/runs/$asked/answer")" = 409 docker stop interaction test "$(docker inspect interaction --format '{{.State.ExitCode}}')" = 0 - name: Run workflows across a restart, and read the steps of a run and their causes @@ -378,7 +378,7 @@ jobs: brain=http://127.0.0.1:8080/v1/orgs/demo/brains/smoke settled() { for attempt in $(seq 1 30); do - test "$(call "$1" "$brain/executions/$2")" = 200 + test "$(call "$1" "$brain/runs/$2")" = 200 test "$(jq --raw-output .status "$1")" != started && return 0 sleep 1 done @@ -393,27 +393,27 @@ jobs: test "$(docker logs workflows-first 2>&1 | grep --count '"message":"Workflows run in this server: ')" = 1 test "$(call brain.json --data '{"brain":"smoke","name":"Smoke test"}' http://127.0.0.1:8080/v1/orgs/demo/brains)" = 201 for name in greet approval; do - spec="$(jq --null-input --arg name "$name" --arg source "${!name}" '{name: $name, source: $source}')" - test "$(call created.json --data "$spec" "$brain/specs/orchestration")" = 201 + definition="$(jq --null-input --arg name "$name" --arg source "${!name}" '{name: $name, source: $source}')" + test "$(call created.json --data "$definition" "$brain/definitions/workflow")" = 201 done - test "$(call greeting.json --data '{"input":{"name":"CI"}}' "$brain/specs/orchestration/greet/execute")" = 200 - settled greeted.json "$(jq --raw-output .execution_id greeting.json)" + test "$(call greeting.json --data '{"input":{"name":"CI"}}' "$brain/definitions/workflow/greet/run")" = 200 + settled greeted.json "$(jq --raw-output .run_id greeting.json)" test "$(jq --compact-output '[.status, .output]' greeted.json)" = '["succeeded",{"greeting":"Hello, CI"}]' - test "$(call waiting.json --data '{}' "$brain/specs/orchestration/approval/execute")" = 200 - waiting="$(jq --raw-output .execution_id waiting.json)" + test "$(call waiting.json --data '{}' "$brain/definitions/workflow/approval/run")" = 200 + waiting="$(jq --raw-output .run_id waiting.json)" stop_server workflows-first start_server workflows-second test "$(call sent.json --data '{"event":{"type":"com.example.approval.decided","data":{"approved":true}}}' \ - "$brain/executions/$waiting/events")" = 200 + "$brain/runs/$waiting/events")" = 200 settled approved.json "$waiting" test "$(jq --compact-output '[.status, .output]' approved.json)" = '["succeeded",[{"approved":true}]]' - test "$(call history.json "$brain/executions/$waiting/history")" = 200 + test "$(call history.json "$brain/runs/$waiting/history")" = 200 jq --exit-status '(.events | INDEX(.id) as $by | [.[] | [.type, (.data.name // .data.input.kind), $by[.causation_id // ""].type]] | sort) == [ - ["execution_started", "approval", null], ["execution_succeeded", null, "step_finished"], + ["run_started", "approval", null], ["run_succeeded", null, "step_finished"], ["step_finished", "decide", "step_waiting"], ["step_waiting", "decide", "workflow_input_applied"], ["workflow_input_applied", "event_received", "step_waiting"], - ["workflow_input_applied", "started", "execution_started"]]' history.json + ["workflow_input_applied", "started", "run_started"]]' history.json stop_server workflows-second - name: Read the analytics of a brain timeout-minutes: 2 @@ -434,14 +434,14 @@ jobs: } brain=http://127.0.0.1:8080/v1/orgs/demo/brains/smoke source="$(printf '%s\n' '---' 'model: anthropic/claude-sonnet-4-5' '---' 'Say hello to {{ input.name }}.')" - spec="$(jq --null-input --arg source "$source" '{name: "hello", source: $source}')" - test "$(call created.json --data "$spec" "$brain/specs/inference")" = 201 - test "$(call executed.json --data '{"input":{"name":"CI"}}' "$brain/specs/inference/hello/execute")" = 503 + definition="$(jq --null-input --arg source "$source" '{name: "hello", source: $source}')" + test "$(call created.json --data "$definition" "$brain/definitions/reasoning")" = 201 + test "$(call ran.json --data '{"input":{"name":"CI"}}' "$brain/definitions/reasoning/hello/run")" = 503 test "$(call analytics.json "$brain/analytics")" = 200 test "$(jq --compact-output '[.days, .runs.succeeded, .runs.rejected, .by_day[-1].day, .by_day[-1].runs.total]' \ analytics.json)" = "[7,2,1,\"$(date --utc +%F)\",3]" - test "$(jq --compact-output '[.by_function[] | [.primitive, .name, .runs]]' analytics.json)" = \ - '[["inference","hello",1],["orchestration","approval",1],["orchestration","greet",1]]' + test "$(jq --compact-output '[.by_function[] | [.type, .name, .runs]]' analytics.json)" = \ + '[["reasoning","hello",1],["workflow","approval",1],["workflow","greet",1]]' docker stop analytics test "$(docker inspect analytics --format '{{.State.ExitCode}}')" = 0 - name: Recall what the smoke workflows answered @@ -462,19 +462,19 @@ jobs: --header "authorization: Bearer $key" --header 'content-type: application/json' "${@:2}" } brain=http://127.0.0.1:8080/v1/orgs/demo/brains/smoke - source="$(printf '%s\n' '---' 'language: jq' 'source:' ' events:' ' - type: execution_succeeded' \ - ' subject: orchestration/greet' 'view:' ' initial: []' '---' '. + [$event.data.output.greeting? // "none"]')" - spec="$(jq --null-input --arg source "$source" '{name: "greetings", source: $source}')" - test "$(call created.json --data "$spec" "$brain/specs/recollection")" = 201 + source="$(printf '%s\n' '---' 'language: jq' 'source:' ' events:' ' - type: run_succeeded' \ + ' subject: workflow/greet' 'view:' ' initial: []' '---' '. + [$event.data.output.greeting? // "none"]')" + definition="$(jq --null-input --arg source "$source" '{name: "greetings", source: $source}')" + test "$(call created.json --data "$definition" "$brain/definitions/recall")" = 201 for attempt in $(seq 1 30); do - test "$(call standing.json "$brain/specs/recollection/greetings")" = 200 + test "$(call standing.json "$brain/definitions/recall/greetings")" = 200 test "$(jq --compact-output '[.standing.state, .standing.folded]' standing.json)" = '["live",1]' && break sleep 1 done test "$(jq --compact-output '[.standing.state, .standing.folded]' standing.json)" = '["live",1]' - test "$(call recalled.json --data '{}' "$brain/specs/recollection/greetings/execute")" = 200 + test "$(call recalled.json --data '{}' "$brain/definitions/recall/greetings/run")" = 200 test "$(jq --compact-output '[.status, .output]' recalled.json)" = '["succeeded",["Hello, CI"]]' - test "$(call run.json "$brain/executions/$(jq --raw-output .execution_id recalled.json)")" = 200 + test "$(call run.json "$brain/runs/$(jq --raw-output .run_id recalled.json)")" = 200 test "$(jq --compact-output '[.record.view.version, .record.view.folded]' run.json)" = '[1,1]' docker stop recall test "$(docker inspect recall --format '{{.State.ExitCode}}')" = 0 @@ -499,8 +499,8 @@ jobs: brain=http://127.0.0.1:8080/v1/orgs/demo/brains/smoke ended_runs() { for attempt in $(seq 1 "$3"); do - test "$(call runs.json "$brain/executions?primitive=orchestration&name=$1")" = 200 - jq --exit-status --argjson count "$2" '[.executions[] | select(.status != "started")] | length >= $count' \ + test "$(call runs.json "$brain/runs?type=workflow&name=$1")" = 200 + jq --exit-status --argjson count "$2" '[.runs[] | select(.status != "started")] | length >= $count' \ runs.json > /dev/null && return 0 sleep 1 done @@ -510,19 +510,19 @@ jobs: 'schedule: { on: { one: { with: { type: com.example.ledger.closed } } }, every: PT1M }' 'do:' \ " - total: { set: { input: '\${ . }' } }")" test "$(call brain.json --data '{"brain":"smoke","name":"Smoke test"}' http://127.0.0.1:8080/v1/orgs/demo/brains)" = 201 - spec="$(jq --null-input --arg source "$closing" '{name: "closing", source: $source}')" - test "$(call created.json --data "$spec" "$brain/specs/orchestration")" = 201 + definition="$(jq --null-input --arg source "$closing" '{name: "closing", source: $source}')" + test "$(call created.json --data "$definition" "$brain/definitions/workflow")" = 201 test "$(jq --compact-output '[.triggers[] | [.kind, .reference]]' created.json)" = \ '[["event","/schedule/on"],["every","/schedule/every"]]' test "$(call published.json \ --data '{"event":{"source":"/ledger","type":"com.example.ledger.closed","data":{"month":"september"}}}' \ "$brain/events")" = 200 ended_runs closing 2 150 - test "$(jq --compact-output '[.executions[] | [.status, .started_by]]' runs.json)" = \ + test "$(jq --compact-output '[.runs[] | [.status, .started_by]]' runs.json)" = \ '[["succeeded","brain:smoke"],["succeeded","brain:smoke"]]' - for run in $(jq --raw-output '.executions[].execution_id' runs.json); do - test "$(call "history-$run.json" "$brain/executions/$run/history")" = 200 - test "$(call "run-$run.json" "$brain/executions/$run")" = 200 + for run in $(jq --raw-output '.runs[].run_id' runs.json); do + test "$(call "history-$run.json" "$brain/runs/$run/history")" = 200 + test "$(call "run-$run.json" "$brain/runs/$run")" = 200 case "$(jq --raw-output '.events[0].data.trigger.kind' "history-$run.json")" in event) test "$(jq --raw-output '.output.input[0].data.month' "run-$run.json")" = september ;; every) jq --exit-status '.output.input.schedule.due | test("^[0-9]{4}-[0-9]{2}-[0-9]{2}T")' \ diff --git a/.oxlintrc.json b/.oxlintrc.json index 7836674ed..62903c47d 100644 --- a/.oxlintrc.json +++ b/.oxlintrc.json @@ -106,7 +106,7 @@ }, "overrides": [ { - "files": ["packages/workflow-engine/src/program-pool/**", "primitives/computation/measure/**"], + "files": ["packages/workflow-engine/src/program-pool/**", "capabilities/computation/measure/**"], "rules": { "no-restricted-imports": "off" } diff --git a/CLAUDE.md b/CLAUDE.md index 34650e26e..88a8cc827 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -8,14 +8,14 @@ - `packages/api`: the API (`@beonauto/api`), a Hono app that answers every request, with problem documents, the `Origin` and `Host` checks, authentication and the operation routes - `packages/identity`: API keys, the local-mode rule and the key command (`@beonauto/identity`) - `packages/*`: the server's libraries (`@beonauto/*`), including the ledger -- `primitives/*`: runtime adapters and planned capabilities. `inference` implements reasoning functions, `orchestration` implements workflows and `computation` implements computation functions. Interaction, prediction, recall (in `recollection`) and Dream currently have design notes only. +- `capabilities/*`: the runtime adapters of the brain's capabilities. `reasoning` implements reasoning functions, `interaction` interaction functions, `computation` computation functions, `recall` recall functions and `coordination` workflows; `prediction` has a design note only. - `TODO.md`: setup work that is still outstanding Use the vocabulary in [Brain terminology](docs/concepts/terminology.md) in product text and domain code. A brain reasons, interacts, predicts, recalls and computes. Workflows coordinate those functions. The five function categories are Reasoning, Interaction, Prediction, Recall and Computation, in that order; coordination is a capability, not a sixth function type. A reasoning function has a prompt. A workflow or function definition is reusable; a run executes it against particular inputs. -The product is not live and has no stored data, so nothing keeps an old name beside a new one: no alias, no mapping, no anchor for an old heading and no test that an old name or an old record still works. Brain terminology is the one vocabulary. The API's wire names today, `primitive` with the values `inference`, `computation`, `recollection` and `orchestration`, `spec` and `execution`, are what the code says until they are renamed in one pass. The workflow engine's run-log formats are internal replay formats and follow their own rule, which its README sets out under [State formats](packages/workflow-engine/README.md#state-formats). `Primitive` is the contract a capability package implements for the runtime, and is named for what it is. +The product is not live and has no stored data, so nothing keeps an old name beside a new one: no alias, no mapping, no anchor for an old heading and no test that an old name or an old record still works, and a local database made before a rename is deleted. Brain terminology is the one vocabulary, on the wire, in the ledger, in the package names and in the code: a definition has a `type`, `reasoning`, `interaction`, `computation`, `recall` or `workflow`, and a run has a `run_id`; a record whose own `type` names it, such as a ledger event, carries `definition_type`. The workflow engine's run-log formats are internal replay formats and follow their own rule, which its README sets out under [State formats](packages/workflow-engine/README.md#state-formats). `Capability` is the contract a capability package implements for the runtime, and is named for what it is. -Domain code names a definition for its resource: `WorkflowDefinition`, and `ReasoningFunctionDefinition`, `InteractionFunctionDefinition`, `PredictionFunctionDefinition`, `RecallFunctionDefinition` or `ComputationFunctionDefinition` once that function type is implemented. The function kinds are `reason`, `interact`, `predict`, `recall` and `compute`; a workflow is not one of them. A parsed source document is a `...DefinitionDocument`, and a factory of a runtime adapter is `make...Adapter`. Apart from the wire names and the packages named after them, **inference** names model execution and provider terms, **orchestration** the coordination machinery, **agent** an actual actor, such as an external coding agent, and **memory** the retention of information. +Domain code names a definition for its resource: `WorkflowDefinition`, and `ReasoningFunctionDefinition`, `InteractionFunctionDefinition`, `PredictionFunctionDefinition`, `RecallFunctionDefinition` or `ComputationFunctionDefinition` once that function type is implemented. A definition's `type` is its one discriminator: `reasoning`, `interaction`, `prediction`, `recall` and `computation` for the five function types, and `workflow`, which is not a function type. A parsed source document is a `...DefinitionDocument`, and a factory of a runtime adapter is `make...Adapter`. **inference** names a model call and its provider's terms, **agent** an actual actor, such as an external coding agent, and **memory** the retention of information. ## Commands @@ -47,6 +47,6 @@ Run one package's gate with `pnpm turbo run lint typecheck test --filter @beonau - Do not write comments. Make the code read like English through names and ordering. - Tests live next to the code as `*.test.ts`, test behaviour through the public interface, and prefer injected fakes over mocks. - A package with more than about 12 source files groups them one level deep, in folders named after concepts that hold fewer than about 15 files each, with tests beside the code they test. Nothing new goes directly under `src`, and there are no barrel files: a package's entry points are the few paths its `exports` map names (`src/index.ts`, `src/testing/index.ts` and, where its README says why, a subpath) and its commands (the server's `src/main.ts` and `dev.ts`, the identity package's `src/key-command.ts`). -- Commits are conventional with a scope named after a package or primitive folder (`feat(server): ...`, `docs(inference): ...`); `global`, `deps`, `ci` and `release` are the other scopes. +- Commits are conventional with a scope named after a package or capability folder (`feat(server): ...`, `docs(reasoning): ...`); `global`, `deps`, `ci` and `release` are the other scopes. - `pnpm check` must pass before you finish. Fixing a problem is a change, so rerun it. - When something fails, assume your change broke it. What is on `main` passed the same gate. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 7a1bb5cc1..b21779bae 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -45,15 +45,15 @@ The preview opens under `/docs/`. Read [Documentation contributions](docs/contri ## Commits and pull requests -- **Commits are [conventional](https://www.conventionalcommits.org) with a scope,** for example `feat(inference): render the liquid body against the brain`. - - The scope is a package or primitive folder name (`server`, `config`, `ledger`, `inference`, ...), or one of `global`, `deps`, `ci` and `release`. +- **Commits are [conventional](https://www.conventionalcommits.org) with a scope,** for example `feat(reasoning): render the liquid body against the brain`. + - The scope is a package or capability folder name (`server`, `config`, `ledger`, `reasoning`, ...), or one of `global`, `deps`, `ci` and `release`. - `pnpm commit` walks you through it, and a git hook checks every message. - **The pull request title matters most.** Pull requests are squash-merged, so the title becomes the commit on `main` and the changelog entry. CI checks it too. - **CI must pass, then the pull request merges through the merge queue.** CI runs the full `pnpm check`, builds and smoke-tests the container, and audits the workflows. Keep each pull request to one change. ## Adding a function or workflow adapter -Function and workflow implementations live in `primitives/`. The shared `Primitive` interface is their low-level runtime adapter contract, including custom extension adapters. It is not a product category. Each implemented adapter is a workspace package: +Function and workflow implementations live in `capabilities/`. The shared `Capability` interface is their low-level runtime adapter contract, including custom extension adapters. It is not a product category. Each implemented adapter is a workspace package: - `package.json` named `@beonauto/`, with `"exports": { ".": "./src/index.ts" }` and `lint`, `typecheck` and `test` scripts. Copy them from `packages/config`. - `README.md` saying what the adapter runs and how it reads and writes the ledger. diff --git a/TODO.md b/TODO.md index e42b1a357..3f8fe36a9 100644 --- a/TODO.md +++ b/TODO.md @@ -36,8 +36,7 @@ Setup work that couldn't be finished yet, and why. ## Local databases -- [ ] **Delete every local ledger made before several triggers.** The host keeps one row a trigger now, in a `workflow_subscriptions` keyed by brain, workflow and trigger, and does not migrate the old table, since nothing is live. Delete `packages/server/.data/ledger.db` for `pnpm dev`, or the file `LEDGER_FILE` names, or the PostgreSQL database `DATABASE_URL` names. Kept, such a database fails every round of the host's follower: no trigger starts a run, no listening run is offered an event and no waiting call is answered. -- [ ] **Delete every local ledger made before an interaction function named the tool it sends through.** A request's record and its delivery facts changed shape, the open requests moved to `open_requests_4` and the conversations the brain reads have a table of their own, and nothing reads the old shape, since nothing is live. Delete `packages/server/.data/ledger.db` for `pnpm dev`, or the file `LEDGER_FILE` names, or the PostgreSQL database `DATABASE_URL` names. Kept, such a database holds runs that wait on requests no attempt delivers and no listing shows. +- [ ] **Delete every local ledger made before the one vocabulary.** The streams, event types and fields of definitions, runs and run logs took the product's words, the projections of runs took their next versions, and the host's tables key a run by `run_key` and its reaction backlog by `run_id`, and nothing reads the old names, since nothing is live. Delete `packages/server/.data/ledger.db` for `pnpm dev`, or the file `LEDGER_FILE` names, or the PostgreSQL database `DATABASE_URL` names. Kept, such a database shows no definition and reads its run logs as runs; the host's tables, made with `CREATE TABLE IF NOT EXISTS`, keep their old `run_id` columns, so the server starts and then every read and write of the host's run tables fails and no workflow run can be recorded, and the due, timer and deferred-start sweeps die on every pass. ## Tests diff --git a/primitives/computation/README.md b/capabilities/computation/README.md similarity index 83% rename from primitives/computation/README.md rename to capabilities/computation/README.md index 03e028d13..6d485b724 100644 --- a/primitives/computation/README.md +++ b/capabilities/computation/README.md @@ -1,22 +1,22 @@ # @beonauto/computation -The implementation of computation functions. A computation function is a program in jq with schemas for its input and output; a run applies the program to its input and answers with exactly one output, the same for the same input on every host of the same image, within bounds on its work, the values it builds, its time and its memory. Its API identifier and package name are `computation`. [Decision 0005](../../docs/decisions/0005-computation-functions.md) says why it exists and how it is bounded. +The implementation of computation functions. A computation function is a program in jq with schemas for its input and output; a run applies the program to its input and answers with exactly one output, the same for the same input on every host of the same image, within bounds on its work, the values it builds, its time and its memory. [Decision 0005](../../docs/decisions/0005-computation-functions.md) says why it exists and how it is bounded. User documentation is [Computation function format](../../docs/reference/computation-format.md), published at [on.auto/docs](https://on.auto/docs/): the document, the dialect, the number rules, the endings and the bounds. Update it alongside behaviour changes. ## The document -`parseComputationDocument(source)` reads a document with the shared reader of [`@beonauto/specs/document`](../../packages/specs/README.md#reading-function-documents) and gives a `ComputationFunctionDefinitionDocument`: an optional `description`, the `language`, `jq`, the compiled `input.schema` and `output.schema` when the document has them, the `program` and the line it starts on. The front matter's keys are `description`, `language`, `input.schema` and `output.schema`; every other key, `model`, `config`, `tools`, `output.format` and `input.default` among them, is an issue at its line, and the message for empty front matter names `the language`. A schema is compiled with the reader's compiler and validates values nested at most 512 levels. +`parseComputationDocument(source)` reads a document with the shared reader of [`@beonauto/definitions/document`](../../packages/definitions/README.md#reading-function-documents) and gives a `ComputationFunctionDefinitionDocument`: an optional `description`, the `language`, `jq`, the compiled `input.schema` and `output.schema` when the document has them, the `program` and the line it starts on. The front matter's keys are `description`, `language`, `input.schema` and `output.schema`; every other key, `model`, `config`, `tools`, `output.format` and `input.default` among them, is an issue at its line, and the message for empty front matter names `the language`. A schema is compiled with the reader's compiler and validates values nested at most 512 levels. The program is compiled with the evaluator of [`@beonauto/workflow-engine/dsl`](../../packages/workflow-engine/README.md#programs) and the dialect of `src/document/program-dialect.ts`, which refuses, each with its reason, `now`, `env`, `$ENV`, `input`, `inputs`, `input_filename`, `input_line_number`, `$__loc__`, `builtins`, `localtime`, `strflocaltime`, `debug`, `stderr`, `halt`, `halt_error`, `label` and `break`, and binds no variable, so every `$name` the program does not bind is refused too. The evaluator's issues carry spans into the body, which become lines of the document: `Line 4: The program nests more than 128 levels deep`. Parsing checks everything a run can be refused for without its input. ## A run -`makeComputationFunctionAdapter({ pool, deadlineMs })` makes the primitive for `makeSpecOperations`; the server gives it a `ProgramPool` of the engine, with `COMPUTATION_WORKERS` workers. `deadlineMs` is 10,000 unless a test gives less, and is the primitive's `longestExecutionMs`. The primitive reaches nothing outside and changes nothing there, calls no tools, and is stopped when its call is cancelled. An execution: +`makeComputationFunctionAdapter({ pool, deadlineMs })` makes the capability for `makeDefinitionOperations`; the server gives it a `ProgramPool` of the engine, with `COMPUTATION_WORKERS` workers. `deadlineMs` is 10,000 unless a test gives less, and is the capability's `longestAnyRunMs`. The capability reaches nothing outside and changes nothing there, calls no tools, and is stopped when its call is cancelled. A run: 1. refuses an input nested deeper than 512 levels, and one its input schema refuses, as `invalid_input` with the schema's pointers; 2. asks the pool to run the program on the input with `computationLimits`, `liftedLimits` of the engine with the work bound, in `exactly one` mode, with the deadline counted from the start of the run and an output of at most `mostOutputBytes`, 1 MiB less 256 bytes for the record; -3. names the checked worker, `checkedWorker` of `@beonauto/specs/json-schema`, for every run, with the output schema as the request's context or `null` when the definition has none, so every run of computation and recall functions is served by one worker module and the pool never lets go of a warm worker to start another module's (200 jobs at once on 4 permits took 118 to 197 ms on one module, against 3,495 to 5,103 ms alternating between two; see [the engine](../../packages/workflow-engine/README.md#the-workers-of-the-pool-measured)); with a schema, the worker that runs the program also checks the output against the schema, under the run's deadline, and the thread that serves requests never waits on the check: a 1 MiB output against a recursive schema had taken 1,038 ms there; +3. names the checked worker, `checkedWorker` of `@beonauto/definitions/json-schema`, for every run, with the output schema as the request's context or `null` when the definition has none, so every run of computation and recall functions is served by one worker module and the pool never lets go of a warm worker to start another module's (200 jobs at once on 4 permits took 118 to 197 ms on one module, against 3,495 to 5,103 ms alternating between two; see [the engine](../../packages/workflow-engine/README.md#the-workers-of-the-pool-measured)); with a schema, the worker that runs the program also checks the output against the schema, under the run's deadline, and the thread that serves requests never waits on the check: a 1 MiB output against a recursive schema had taken 1,038 ms there; 4. turns the outcome into the run's ending (`src/run/run-outcome.ts`): | Outcome of the pool | Ending | @@ -64,4 +64,4 @@ The pool keeps a worker between runs, so a run costs its work and the copies of ## Source -`src/index.ts` is the entry point and `src/testing/index.ts` the entry point of the test support. `src/document` holds the document: its type, the front matter's keys, the dialect and parsing. `src/run` holds a run: its bounds, the input's checks, the call of the pool and the endings. `src/primitive` holds the primitive, whose guide is the public reference page, served to agents as `computation-function`. `src/testing` holds the example and what the tests share. +`src/index.ts` is the entry point and `src/testing/index.ts` the entry point of the test support. `src/document` holds the document: its type, the front matter's keys, the dialect and parsing. `src/run` holds a run: its bounds, the input's checks, the call of the pool and the endings. `src/capability` holds the capability, whose guide is the public reference page, served to agents as `computation-function`. `src/testing` holds the example and what the tests share. diff --git a/primitives/computation/measure.ts b/capabilities/computation/measure.ts similarity index 100% rename from primitives/computation/measure.ts rename to capabilities/computation/measure.ts diff --git a/primitives/computation/measure/burst.ts b/capabilities/computation/measure/burst.ts similarity index 96% rename from primitives/computation/measure/burst.ts rename to capabilities/computation/measure/burst.ts index 28b94e5b3..5f97fcf1d 100644 --- a/primitives/computation/measure/burst.ts +++ b/capabilities/computation/measure/burst.ts @@ -1,4 +1,4 @@ -import { checkedWorker } from '@beonauto/specs/json-schema'; +import { checkedWorker } from '@beonauto/definitions/json-schema'; import { programPool, type ProgramRequest } from '@beonauto/workflow-engine/dsl'; import { computationBounds } from '../src/run/run-bounds.ts'; diff --git a/primitives/computation/measure/common.ts b/capabilities/computation/measure/common.ts similarity index 100% rename from primitives/computation/measure/common.ts rename to capabilities/computation/measure/common.ts diff --git a/primitives/computation/measure/constructs.ts b/capabilities/computation/measure/constructs.ts similarity index 100% rename from primitives/computation/measure/constructs.ts rename to capabilities/computation/measure/constructs.ts diff --git a/primitives/computation/measure/example.ts b/capabilities/computation/measure/example.ts similarity index 96% rename from primitives/computation/measure/example.ts rename to capabilities/computation/measure/example.ts index f028e9353..cf87e7a72 100644 --- a/primitives/computation/measure/example.ts +++ b/capabilities/computation/measure/example.ts @@ -1,4 +1,4 @@ -import { mostInputBytes } from '@beonauto/specs'; +import { mostInputBytes } from '@beonauto/definitions'; import { jsonBytesOf } from '@beonauto/workflow-engine/dsl'; import { Result } from 'effect'; diff --git a/primitives/computation/measure/heap-worker.ts b/capabilities/computation/measure/heap-worker.ts similarity index 100% rename from primitives/computation/measure/heap-worker.ts rename to capabilities/computation/measure/heap-worker.ts diff --git a/primitives/computation/measure/runs.ts b/capabilities/computation/measure/runs.ts similarity index 98% rename from primitives/computation/measure/runs.ts rename to capabilities/computation/measure/runs.ts index d4828b54c..b5df727dd 100644 --- a/primitives/computation/measure/runs.ts +++ b/capabilities/computation/measure/runs.ts @@ -1,6 +1,6 @@ import { setTimeout } from 'node:timers/promises'; -import { checkedWorker } from '@beonauto/specs/json-schema'; +import { checkedWorker } from '@beonauto/definitions/json-schema'; import { idleWorkerMs, programPool, type ProgramPool, type ProgramRequest } from '@beonauto/workflow-engine/dsl'; import { computationDialect } from '../src/document/program-dialect.ts'; diff --git a/primitives/computation/measure/workers.ts b/capabilities/computation/measure/workers.ts similarity index 100% rename from primitives/computation/measure/workers.ts rename to capabilities/computation/measure/workers.ts diff --git a/primitives/computation/package.json b/capabilities/computation/package.json similarity index 93% rename from primitives/computation/package.json rename to capabilities/computation/package.json index 5e130caae..c6ab5894f 100644 --- a/primitives/computation/package.json +++ b/capabilities/computation/package.json @@ -15,8 +15,8 @@ "measure": "node measure.ts" }, "dependencies": { + "@beonauto/definitions": "workspace:*", "@beonauto/operations": "workspace:*", - "@beonauto/specs": "workspace:*", "@beonauto/workflow-engine": "workspace:*", "effect": "catalog:" }, diff --git a/primitives/computation/src/primitive/computation-function.test.ts b/capabilities/computation/src/capability/computation-function.test.ts similarity index 81% rename from primitives/computation/src/primitive/computation-function.test.ts rename to capabilities/computation/src/capability/computation-function.test.ts index 2e091fe64..2abb5081e 100644 --- a/primitives/computation/src/primitive/computation-function.test.ts +++ b/capabilities/computation/src/capability/computation-function.test.ts @@ -7,7 +7,7 @@ import { poolOf, programDocument, workerTestTimeoutMs, - type Execution, + type Run, } from '../testing/computation-runs.ts'; const decodeFinished = Schema.decodeUnknownSync( @@ -68,7 +68,7 @@ function paceInBigIntegers(input: unknown) { }; } -async function succeeded(running: () => Promise): Promise { +async function succeeded(running: () => Promise): Promise { return Option.getOrThrow(Exit.getSuccess(await running())); } @@ -76,7 +76,7 @@ describe('a run of a computation function', { timeout: workerTestTimeoutMs }, () it('applies its program to its input and answers its output exactly, in cents, with what the run took', async () => { const input = campaignRows(1000); - const { output, record } = decodeFinished(await succeeded(() => computationWith().executing(campaignPace, input))); + const { output, record } = decodeFinished(await succeeded(() => computationWith().running(campaignPace, input))); expect(output).toEqual(paceInBigIntegers(input)); expect(output.campaigns.map(({ spend_cents }) => spend_cents)).toEqual([ @@ -91,10 +91,10 @@ describe('a run of a computation function', { timeout: workerTestTimeoutMs }, () it('computes with doubles: integers exactly, and decimal fractions as doubles do', async () => { const run = computationWith(); - expect(await succeeded(() => run.executing(programDocument('[0.1, 0.2, 0.3] | add')))).toMatchObject({ + expect(await succeeded(() => run.running(programDocument('[0.1, 0.2, 0.3] | add')))).toMatchObject({ output: 0.6000000000000001, }); - expect(await succeeded(() => run.executing(programDocument('9007199254740992 + 1')))).toMatchObject({ + expect(await succeeded(() => run.running(programDocument('9007199254740992 + 1')))).toMatchObject({ output: 9_007_199_254_740_992, }); }); @@ -102,10 +102,10 @@ describe('a run of a computation function', { timeout: workerTestTimeoutMs }, () describe('a computation function', () => { it('describes its result in words', () => { - const { primitive } = computationWith(poolOf({ workers: 1 })); + const { capability } = computationWith(poolOf({ workers: 1 })); - expect(primitive.describeOutput({ total_spend_cents: 17_628 })).toBe('Its result: total spend cents: 17628.'); - expect(primitive.describeOutput('x'.repeat(5000))).toBe( + expect(capability.describeOutput({ total_spend_cents: 17_628 })).toBe('Its result: total spend cents: 17628.'); + expect(capability.describeOutput('x'.repeat(5000))).toBe( 'Its result is too long to repeat here; the whole of it is in the details below.', ); }); @@ -113,7 +113,7 @@ describe('a computation function', () => { describe('the summary of a computation function', () => { it('is its description and its schemas, and it reaches nothing outside', () => { - const { primitive, prepared } = computationWith(poolOf({ workers: 1 })); + const { capability, prepared } = computationWith(poolOf({ workers: 1 })); expect(prepared(campaignPace).summary).toMatchObject({ description: 'Spend, pace and projection per campaign, in cents, for a reporting period', @@ -121,23 +121,23 @@ describe('the summary of a computation function', () => { outputSchema: { type: 'object', required: ['campaigns', 'total_spend_cents'] }, }); expect(prepared(programDocument('.')).summary).toEqual({}); - expect(primitive).toMatchObject({ - name: 'computation', + expect(capability).toMatchObject({ + type: 'computation', title: 'Computation', noun: { one: 'computation function', other: 'computation functions' }, mediaType: 'text/markdown', reachesOutside: false, mayChangeOutside: false, - longestExecutionMs: 10_000, + longestAnyRunMs: 10_000, }); expect(prepared(campaignPace).callsTools).toBe(false); }); it('refuses a definition with its problems, each with its line', async () => { - const { primitive } = computationWith(poolOf({ workers: 1 })); + const { capability } = computationWith(poolOf({ workers: 1 })); expect( - await Effect.runPromise(Effect.flip(primitive.prepare(programDocument('now', 'language: python')))), + await Effect.runPromise(Effect.flip(capability.prepare(programDocument('now', 'language: python')))), ).toMatchObject({ detail: 'The computation function definition has 2 problems', issues: [ @@ -152,7 +152,7 @@ describe('the summary of a computation function', () => { }, ], }); - expect(await Effect.runPromise(Effect.flip(primitive.prepare(programDocument('.a +'))))).toMatchObject({ + expect(await Effect.runPromise(Effect.flip(capability.prepare(programDocument('.a +'))))).toMatchObject({ detail: 'The computation function definition has a problem', }); }); diff --git a/primitives/computation/src/primitive/computation-function.ts b/capabilities/computation/src/capability/computation-function.ts similarity index 81% rename from primitives/computation/src/primitive/computation-function.ts rename to capabilities/computation/src/capability/computation-function.ts index 397d8dab8..38c302100 100644 --- a/primitives/computation/src/primitive/computation-function.ts +++ b/capabilities/computation/src/capability/computation-function.ts @@ -1,13 +1,13 @@ -import { asSentence, InvalidInput } from '@beonauto/operations'; import { - definePrimitive, + defineCapability, functionCategoryLabels, functionResourceLabels, inWords, type DefinitionSummary, - type Primitive, -} from '@beonauto/specs'; -import { issueText } from '@beonauto/specs/document'; + type Capability, +} from '@beonauto/definitions'; +import { issueText } from '@beonauto/definitions/document'; +import { asSentence, InvalidInput } from '@beonauto/operations'; import type { ProgramPool } from '@beonauto/workflow-engine/dsl'; import { Effect, Result, type Schema } from 'effect'; @@ -52,18 +52,18 @@ function describeResult(output: Schema.Json): string { export function makeComputationFunctionAdapter({ pool, deadlineMs = computationBounds.deadlineMs, -}: ComputationFunctionAdapterOptions): Primitive { +}: ComputationFunctionAdapterOptions): Capability { const run = computationRun({ pool, deadlineMs }); - return definePrimitive({ - name: 'computation', - title: functionCategoryLabels.compute, + return defineCapability({ + type: 'computation', + title: functionCategoryLabels.computation, guide: { name: 'computation-function' }, - noun: { one: functionResourceLabels.compute.singular, other: functionResourceLabels.compute.plural }, + noun: { one: functionResourceLabels.computation.singular, other: functionResourceLabels.computation.plural }, describeOutput: describeResult, mediaType: 'text/markdown', parse, summarize, - execute: (document, input) => run(document, input), - longestExecutionMs: deadlineMs, + run: (document, input) => run(document, input), + longestAnyRunMs: deadlineMs, }); } diff --git a/primitives/computation/src/document/computation-document.ts b/capabilities/computation/src/document/computation-document.ts similarity index 82% rename from primitives/computation/src/document/computation-document.ts rename to capabilities/computation/src/document/computation-document.ts index d0d5c8818..d29fc46ef 100644 --- a/primitives/computation/src/document/computation-document.ts +++ b/capabilities/computation/src/document/computation-document.ts @@ -1,4 +1,4 @@ -import type { CompiledSchema } from '@beonauto/specs/document'; +import type { CompiledSchema } from '@beonauto/definitions/document'; export interface ValueContract { readonly schema?: CompiledSchema; diff --git a/primitives/computation/src/document/document-parsing.test.ts b/capabilities/computation/src/document/document-parsing.test.ts similarity index 99% rename from primitives/computation/src/document/document-parsing.test.ts rename to capabilities/computation/src/document/document-parsing.test.ts index d0ad541fb..e927e1707 100644 --- a/primitives/computation/src/document/document-parsing.test.ts +++ b/capabilities/computation/src/document/document-parsing.test.ts @@ -1,4 +1,4 @@ -import { issueText } from '@beonauto/specs/document'; +import { issueText } from '@beonauto/definitions/document'; import { Result } from 'effect'; import { describe, expect, it } from 'vitest'; diff --git a/primitives/computation/src/document/document-parsing.ts b/capabilities/computation/src/document/document-parsing.ts similarity index 98% rename from primitives/computation/src/document/document-parsing.ts rename to capabilities/computation/src/document/document-parsing.ts index c5fafb5b5..ec0c12f6e 100644 --- a/primitives/computation/src/document/document-parsing.ts +++ b/capabilities/computation/src/document/document-parsing.ts @@ -8,7 +8,7 @@ import { type DocumentParts, type ReadFrontMatter, type SourceLines, -} from '@beonauto/specs/document'; +} from '@beonauto/definitions/document'; import { compileProgram, lineOf, mostValueDepth } from '@beonauto/workflow-engine/dsl'; import { Result, type Schema } from 'effect'; diff --git a/primitives/computation/src/document/front-matter.ts b/capabilities/computation/src/document/front-matter.ts similarity index 97% rename from primitives/computation/src/document/front-matter.ts rename to capabilities/computation/src/document/front-matter.ts index 019607275..480bec397 100644 --- a/primitives/computation/src/document/front-matter.ts +++ b/capabilities/computation/src/document/front-matter.ts @@ -1,4 +1,4 @@ -import type { FrontMatterSection, FrontMatterShape } from '@beonauto/specs/document'; +import type { FrontMatterSection, FrontMatterShape } from '@beonauto/definitions/document'; import { Schema } from 'effect'; const descriptionLength = 1000; diff --git a/primitives/computation/src/document/program-dialect.ts b/capabilities/computation/src/document/program-dialect.ts similarity index 100% rename from primitives/computation/src/document/program-dialect.ts rename to capabilities/computation/src/document/program-dialect.ts diff --git a/primitives/computation/src/document/reference-bounds.test.ts b/capabilities/computation/src/document/reference-bounds.test.ts similarity index 100% rename from primitives/computation/src/document/reference-bounds.test.ts rename to capabilities/computation/src/document/reference-bounds.test.ts diff --git a/primitives/computation/src/document/reference-example.test.ts b/capabilities/computation/src/document/reference-example.test.ts similarity index 93% rename from primitives/computation/src/document/reference-example.test.ts rename to capabilities/computation/src/document/reference-example.test.ts index 0f0935cec..b725ae65a 100644 --- a/primitives/computation/src/document/reference-example.test.ts +++ b/capabilities/computation/src/document/reference-example.test.ts @@ -30,7 +30,7 @@ describe('the example on the reference page of computation functions', { timeout expect([input?.language, output?.language]).toEqual(['json', 'json']); expect(referencePage).toContain('spent 19,756 units of work'); - expect(await computationWith().executing(campaignPace, decodeJson(input?.body))).toMatchObject( + expect(await computationWith().running(campaignPace, decodeJson(input?.body))).toMatchObject( Exit.succeed({ output: decodeJson(output?.body), record: { work: 19_756 } }), ); }); diff --git a/primitives/computation/src/index.ts b/capabilities/computation/src/index.ts similarity index 83% rename from primitives/computation/src/index.ts rename to capabilities/computation/src/index.ts index cd600a405..b7b24093c 100644 --- a/primitives/computation/src/index.ts +++ b/capabilities/computation/src/index.ts @@ -2,5 +2,5 @@ export type { ComputationFunctionDefinitionDocument } from './document/computati export { makeComputationFunctionAdapter, type ComputationFunctionAdapterOptions, -} from './primitive/computation-function.ts'; +} from './capability/computation-function.ts'; export { computationBounds } from './run/run-bounds.ts'; diff --git a/primitives/computation/src/run/computation-run.ts b/capabilities/computation/src/run/computation-run.ts similarity index 85% rename from primitives/computation/src/run/computation-run.ts rename to capabilities/computation/src/run/computation-run.ts index 3038be237..a6c68e26e 100644 --- a/primitives/computation/src/run/computation-run.ts +++ b/capabilities/computation/src/run/computation-run.ts @@ -1,6 +1,6 @@ +import type { CapabilityAnswer, CapabilityRejection } from '@beonauto/definitions'; +import { checkedWorker } from '@beonauto/definitions/json-schema'; import type { InvalidInput } from '@beonauto/operations'; -import type { Executed, PrimitiveRejection } from '@beonauto/specs'; -import { checkedWorker } from '@beonauto/specs/json-schema'; import { jsonBytesOf, type ProgramPool, type ProgramRequest } from '@beonauto/workflow-engine/dsl'; import { Effect, type Schema } from 'effect'; @@ -18,7 +18,7 @@ export interface ComputationRunOptions { export type ComputationRun = ( document: ComputationFunctionDefinitionDocument, input: Schema.Json, -) => Effect.Effect; +) => Effect.Effect; function checkedBy({ output }: ComputationFunctionDefinitionDocument): Pick { return { worker: checkedWorker, context: output.schema?.document ?? null }; @@ -27,7 +27,7 @@ function checkedBy({ output }: ComputationFunctionDefinitionDocument): Pick preparedInput(input, document.input).pipe( - Effect.flatMap((admitted): Effect.Effect => + Effect.flatMap((admitted): Effect.Effect => Effect.promise((signal) => pool.run( { diff --git a/primitives/computation/src/run/output-checking.test.ts b/capabilities/computation/src/run/output-checking.test.ts similarity index 86% rename from primitives/computation/src/run/output-checking.test.ts rename to capabilities/computation/src/run/output-checking.test.ts index 1a446ea8f..dced7b172 100644 --- a/primitives/computation/src/run/output-checking.test.ts +++ b/capabilities/computation/src/run/output-checking.test.ts @@ -29,8 +29,8 @@ describe('the check of an output against the output schema', { timeout: workerTe const { pool, requests } = recording(poolOf()); const run = computationWith(pool); - expect(await run.executing(campaignPace, campaignRows(10))).toMatchObject(Exit.succeed({})); - expect(await run.executing(programDocument('.'), 1)).toMatchObject(Exit.succeed({ output: 1 })); + expect(await run.running(campaignPace, campaignRows(10))).toMatchObject(Exit.succeed({})); + expect(await run.running(programDocument('.'), 1)).toMatchObject(Exit.succeed({ output: 1 })); expect(requests.map(({ worker, context }) => ({ worker: worker?.pathname.split('/').at(-1), context }))).toEqual([ { worker: 'checked-worker.ts', context: run.prepared(campaignPace).summary.outputSchema }, { worker: 'checked-worker.ts', context: null }, @@ -40,7 +40,7 @@ describe('the check of an output against the output schema', { timeout: workerTe it('refuses an output the schema refuses, naming at most three of its issues in the one wording of them', async () => { const run = computationWith(); - expect(await run.executing(programDocument('[1, 2, 3, 4]', outputSchema))).toMatchObject( + expect(await run.running(programDocument('[1, 2, 3, 4]', outputSchema))).toMatchObject( Exit.fail({ kind: 'unworkable', detail: diff --git a/primitives/computation/src/run/run-bounds.ts b/capabilities/computation/src/run/run-bounds.ts similarity index 89% rename from primitives/computation/src/run/run-bounds.ts rename to capabilities/computation/src/run/run-bounds.ts index f7849bfa2..e2eecaa43 100644 --- a/primitives/computation/src/run/run-bounds.ts +++ b/capabilities/computation/src/run/run-bounds.ts @@ -1,4 +1,4 @@ -import { mostResultBytes } from '@beonauto/specs'; +import { mostResultBytes } from '@beonauto/definitions'; import { liftedLimits, mostEvaluationDepth, mostValueDepth, type ProgramLimits } from '@beonauto/workflow-engine/dsl'; export const computationBounds = { diff --git a/primitives/computation/src/run/run-endings.test.ts b/capabilities/computation/src/run/run-endings.test.ts similarity index 88% rename from primitives/computation/src/run/run-endings.test.ts rename to capabilities/computation/src/run/run-endings.test.ts index 7c7b836e2..ce022c4ac 100644 --- a/primitives/computation/src/run/run-endings.test.ts +++ b/capabilities/computation/src/run/run-endings.test.ts @@ -16,7 +16,7 @@ function unworkable(detail: string): Exit.Exit { } function ended(program: string, frontMatter?: string) { - return computationWith().executing(programDocument(program, frontMatter), null); + return computationWith().running(programDocument(program, frontMatter), null); } describe('a run whose program cannot work as written', { timeout: workerTestTimeoutMs }, () => { @@ -59,10 +59,10 @@ describe('a run whose program cannot work as written', { timeout: workerTestTime it('measures an output before writing it, so one that would take 240 MB as JSON ends in conflict and the pool runs on', async () => { const run = computationWith(); - expect(await run.executing(programDocument('("\\u0001Ā" * 15000000) | [., .]'), null)).toEqual( + expect(await run.running(programDocument('("\\u0001Ā" * 15000000) | [., .]'), null)).toEqual( unworkable("The program's output takes more than the 1048320 bytes as JSON a run can record"), ); - expect(await run.executing(programDocument('. + 1'), 1)).toMatchObject(Exit.succeed({ output: 2 })); + expect(await run.running(programDocument('. + 1'), 1)).toMatchObject(Exit.succeed({ output: 2 })); }); }); @@ -116,11 +116,11 @@ describe('a run that reaches a bound of its program', { timeout: workerTestTimeo const recursion = 'def g: if . == 0 then 0 else (. - 1 | g) end; g'; const run = computationWith(); - expect(await run.executing(programDocument(recursion), 500)).toMatchObject(Exit.succeed({ output: 0 })); - expect(await run.executing(programDocument(recursion), 3000)).toEqual( + expect(await run.running(programDocument(recursion), 500)).toMatchObject(Exit.succeed({ output: 0 })); + expect(await run.running(programDocument(recursion), 3000)).toEqual( unworkable('The program recursed deeper than the 10000 levels of evaluation a run may nest, on line 4'), ); - expect(await run.executing(programDocument('error("Max depth exceeded")'), null)).toEqual( + expect(await run.running(programDocument('error("Max depth exceeded")'), null)).toEqual( unworkable('The program raised an error on line 4: Max depth exceeded'), ); }); @@ -152,7 +152,7 @@ describe('a run that would depend on the stack of its host', { timeout: workerTe poolOf(), ); - expect(await computationWith(overflowing).executing(programDocument('.'), null)).toEqual( + expect(await computationWith(overflowing).running(programDocument('.'), null)).toEqual( unworkable('The program went deeper than the 64 MiB stack of a run allows'), ); }); @@ -162,7 +162,7 @@ describe('a run of the server that cannot finish', { timeout: workerTestTimeoutM it('is unavailable when it runs past its deadline', async () => { const slow = computationWith(poolOf(), 50); - expect(await slow.executing(programDocument('[range(100000000)] | length'), null)).toEqual( + expect(await slow.running(programDocument('[range(100000000)] | length'), null)).toEqual( Exit.fail( new Unavailable({ detail: 'The run took longer than the 50 ms a computation function may run, and was stopped', @@ -174,7 +174,7 @@ describe('a run of the server that cannot finish', { timeout: workerTestTimeoutM it('is unavailable when it takes more memory than its worker has', async () => { const small = computationWith(poolOf({ heapMegabytes: 16 })); - expect(await small.executing(programDocument('[range(1000000) | {a: .}] | length'), null)).toEqual( + expect(await small.running(programDocument('[range(1000000) | {a: .}] | length'), null)).toEqual( Exit.fail( new Unavailable({ detail: 'The run took more than the 16 MiB of memory a computation function may use, and was stopped', @@ -209,7 +209,7 @@ describe('a run that the pool stops', { timeout: workerTestTimeoutMs }, () => { ])('is unavailable when the pool answers %j', async (outcome, detail) => { const run = computationWith(scriptedPool([outcome], poolOf())); - expect(await run.executing(programDocument('.'), null)).toEqual(Exit.fail(new Unavailable({ detail }))); + expect(await run.running(programDocument('.'), null)).toEqual(Exit.fail(new Unavailable({ detail }))); }); it.each([ @@ -217,7 +217,7 @@ describe('a run that the pool stops', { timeout: workerTestTimeoutMs }, () => { [{ ran: 'refused', issues: [], milliseconds: 1 }, 'The worker refused a program the definition was accepted with'], ])('fails, as the server breaks, when the pool answers %j', async (outcome, defect) => { const run = computationWith(scriptedPool([outcome], poolOf())); - const exit = await run.executing(programDocument('.'), null); + const exit = await run.running(programDocument('.'), null); expect(Exit.hasDies(exit)).toBe(true); expect(String(Exit.findDefect(exit))).toContain(defect); @@ -228,15 +228,15 @@ describe('a run that was unavailable', { timeout: workerTestTimeoutMs }, () => { it('runs when it is tried again, since nothing in it changed', async () => { const run = computationWith(scriptedPool([{ ran: 'stopped', because: 'busy', milliseconds: 10_000 }], poolOf())); - expect(await run.executing(programDocument('. + 1'), 1)).toMatchObject(Exit.fail({ _tag: 'unavailable' })); - expect(await run.executing(programDocument('. + 1'), 1)).toMatchObject(Exit.succeed({ output: 2 })); + expect(await run.running(programDocument('. + 1'), 1)).toMatchObject(Exit.fail({ _tag: 'unavailable' })); + expect(await run.running(programDocument('. + 1'), 1)).toMatchObject(Exit.succeed({ output: 2 })); }); }); describe('the input of a run', { timeout: workerTestTimeoutMs }, () => { it('is checked against the input schema, with a pointer to what does not fit', async () => { expect( - await computationWith().executing(campaignPace, { + await computationWith().running(campaignPace, { period: { days_elapsed: 12, days_total: 31 }, rows: [{ campaign: 'a', cost_cents: 'ten', budget_cents: 1 }], }), @@ -253,7 +253,7 @@ describe('the input of a run', { timeout: workerTestTimeoutMs }, () => { it('nests at most 512 levels', async () => { const deep = Array.from({ length: 600 }).reduce((inner) => [inner], null); - expect(await computationWith().executing(programDocument('.'), deep)).toMatchObject( + expect(await computationWith().running(programDocument('.'), deep)).toMatchObject( Exit.fail({ _tag: 'invalid_input', detail: 'The input nests more than the 512 levels a computation function takes', @@ -268,7 +268,7 @@ describe('the output of a run', { timeout: workerTestTimeoutMs }, () => { const [first, second] = await Promise.all( [poolOf(), poolOf()].map(async (pool) => - decodeRun(Option.getOrThrow(Exit.getSuccess(await computationWith(pool).executing(campaignPace, input)))), + decodeRun(Option.getOrThrow(Exit.getSuccess(await computationWith(pool).running(campaignPace, input)))), ), ); diff --git a/primitives/computation/src/run/run-input.ts b/capabilities/computation/src/run/run-input.ts similarity index 100% rename from primitives/computation/src/run/run-input.ts rename to capabilities/computation/src/run/run-input.ts diff --git a/primitives/computation/src/run/run-outcome.ts b/capabilities/computation/src/run/run-outcome.ts similarity index 97% rename from primitives/computation/src/run/run-outcome.ts rename to capabilities/computation/src/run/run-outcome.ts index 2c88707ad..164452995 100644 --- a/primitives/computation/src/run/run-outcome.ts +++ b/capabilities/computation/src/run/run-outcome.ts @@ -1,6 +1,6 @@ +import type { Finished } from '@beonauto/definitions'; +import { issuesDetail } from '@beonauto/definitions/json-schema'; import { Conflict, Unavailable } from '@beonauto/operations'; -import type { Finished } from '@beonauto/specs'; -import { issuesDetail } from '@beonauto/specs/json-schema'; import { lineOf, type PoolOutcome, diff --git a/primitives/computation/src/run/warm-isolation.test.ts b/capabilities/computation/src/run/warm-isolation.test.ts similarity index 100% rename from primitives/computation/src/run/warm-isolation.test.ts rename to capabilities/computation/src/run/warm-isolation.test.ts diff --git a/primitives/computation/src/testing/campaign-pace.ts b/capabilities/computation/src/testing/campaign-pace.ts similarity index 100% rename from primitives/computation/src/testing/campaign-pace.ts rename to capabilities/computation/src/testing/campaign-pace.ts diff --git a/primitives/computation/src/testing/computation-runs.ts b/capabilities/computation/src/testing/computation-runs.ts similarity index 70% rename from primitives/computation/src/testing/computation-runs.ts rename to capabilities/computation/src/testing/computation-runs.ts index a5c94bfa0..19a8c1887 100644 --- a/primitives/computation/src/testing/computation-runs.ts +++ b/capabilities/computation/src/testing/computation-runs.ts @@ -1,21 +1,21 @@ +import type { CapabilityAnswer, PreparedDefinition, Capability, RunContext } from '@beonauto/definitions'; +import { noLongestRuns, recordingJournal } from '@beonauto/definitions/testing'; import { allPermissions, type Conflict, type InvalidInput, type Unavailable } from '@beonauto/operations'; -import type { Executed, PreparedDefinition, Primitive, RunContext } from '@beonauto/specs'; -import { noLongestRuns, recordingJournal } from '@beonauto/specs/testing'; import { programPool, type PoolSettings, type ProgramPool } from '@beonauto/workflow-engine/dsl'; import { Effect, type Exit, type Schema } from 'effect'; import { afterEach } from 'vitest'; -import { makeComputationFunctionAdapter } from '../primitive/computation-function.ts'; +import { makeComputationFunctionAdapter } from '../capability/computation-function.ts'; import { computationBounds } from '../run/run-bounds.ts'; export const workerTestTimeoutMs = 30_000; -const execution: RunContext = { +const run: RunContext = { id: '0199a3c4-7d2e-7c1a-9b3f-2f1e0d9c8b7a', org: 'acme', brain: 'alpha', caller: { id: 'acme-admin', org: 'acme', permissions: allPermissions, brains: '*' }, - spec: { name: 'pace', version: 1 }, + definition: { name: 'pace', version: 1 }, journal: recordingJournal(), lineage: { startId: '5d0e9f6a-1b2c-5d3e-8f4a-6b7c8d9e0f1a', correlationId: '0199a3c4-7d2e-7c1a-9b3f-2f1e0d9c8b7a' }, depth: 0, @@ -23,12 +23,12 @@ const execution: RunContext = { longestRunOf: noLongestRuns, }; -export type Execution = Exit.Exit; +export type Run = Exit.Exit; export interface ComputationRuns { - readonly primitive: Primitive; + readonly capability: Capability; readonly prepared: (source: string) => PreparedDefinition; - readonly executing: (source: string, input?: Schema.Json) => Promise; + readonly running: (source: string, input?: Schema.Json) => Promise; } const pools: ProgramPool[] = []; @@ -55,12 +55,12 @@ export function poolOf(settings: Partial = {}): ProgramPool { } export function computationWith(pool: ProgramPool = poolOf(), deadlineMs?: number): ComputationRuns { - const primitive = makeComputationFunctionAdapter({ pool, ...(deadlineMs === undefined ? {} : { deadlineMs }) }); - const prepared = (source: string): PreparedDefinition => Effect.runSync(primitive.prepare(source)); + const capability = makeComputationFunctionAdapter({ pool, ...(deadlineMs === undefined ? {} : { deadlineMs }) }); + const prepared = (source: string): PreparedDefinition => Effect.runSync(capability.prepare(source)); return { - primitive, + capability, prepared, - executing: (source, input = {}) => Effect.runPromiseExit(prepared(source).execute(input, execution)), + running: (source, input = {}) => Effect.runPromiseExit(prepared(source).run(input, run)), }; } diff --git a/primitives/computation/src/testing/index.ts b/capabilities/computation/src/testing/index.ts similarity index 100% rename from primitives/computation/src/testing/index.ts rename to capabilities/computation/src/testing/index.ts diff --git a/packages/specs/tsconfig.json b/capabilities/computation/tsconfig.json similarity index 100% rename from packages/specs/tsconfig.json rename to capabilities/computation/tsconfig.json diff --git a/primitives/computation/vitest.config.ts b/capabilities/computation/vitest.config.ts similarity index 100% rename from primitives/computation/vitest.config.ts rename to capabilities/computation/vitest.config.ts diff --git a/capabilities/coordination/README.md b/capabilities/coordination/README.md new file mode 100644 index 000000000..1b9bfc0e6 --- /dev/null +++ b/capabilities/coordination/README.md @@ -0,0 +1,54 @@ +# @beonauto/coordination + +The workflow adapter parses a `WorkflowDefinitionDocument` from YAML in the Open Workflow Specification DSL. This document type is an alias for `JsonObject`; the parser applies the existing DSL checks. It is separate from the named, versioned definition stored in the registry. The adapter runs the workflow machine of [`@beonauto/workflow-engine`](../../packages/workflow-engine), hosted in Node by [`@beonauto/workflow-host`](../../packages/workflow-host). Workflows coordinate functions and control steps; they are not another function type. A workflow is a definition of the type `workflow`. + +Public documentation explains [workflows and their availability](../../docs/concepts/workflows.md) and [the workflow format](../../docs/reference/workflow-format.md), published at [on.auto/docs](https://on.auto/docs/). The repository-only [workflow run reference](../../docs/engineering/reference/workflow-format.md) and [workflow operations guide](../../docs/engineering/self-host/workflows.md) hold the implementation details, and [decision 0001](../../docs/decisions/0001-workflow-engine-on-the-ledger.md) why workflows run on an engine on the ledger. + +## Entry + +`src/index.ts` exports: + +- `makeWorkflowAdapter({ runs, mostDurationMs, longestCallMs })`: the workflow adapter, with `WorkflowAdapterDependencies` as its options type. `runs` is the host that starts runs; `mostDurationMs` is the most a run may last, which `create_definition` checks every duration of a document against and a run is stopped at exactly; `longestCallMs` the most a call of a run may take before its `call_deadline` timer fails the task, when the call's definition is not named as written. The adapter states that its runs finish later and may take `mostDurationMs`. +- `defineSendRunEvent(runs)`: `send_run_event`, which gives a running workflow an event. +- `workflowMachineOptions`: the machine's options, the functions a workflow may call (`run_definition`) and the runtime its expressions see as `$runtime`. +- `definitionCalls(runDefinition)`: calls a saved definition from a workflow. This adapter accepts reasoning functions, workflows and custom definition types; it does not classify every extension as a brain function. +- `callResultOfEnding(ending)`: the answer of a call for the ending of the run it waits for, which the workflow host is given as its result mapping. +- `callMarginMs`: the minute a call is given beyond the longest its definition may run. +- `runPresenter`: the presenter of the run log, for the history of a run and the events of a brain. +- `definitionRunResultOf`, `RunDefinition`, `DefinitionRunRequest` and `DefinitionRunResult`, for the server, which runs a definition called by a workflow through its operations. + +## A run of a workflow + +`run_definition` of a workflow starts its run with the `started` input: the document, the input, the run's limits, a random seed for the draws of the run, and as attributes the org, the brain, the run id, the definition's name and version, the caller who started it, its reaction depth, `depth`, its call depth, `call_depth`, and `lineage`: the id of the `run_started` that began the run and the run at the top of its tree, which the workflow host writes with each record of the run (`@beonauto/workflow-host`). The run log is `run-logs/` under the brain, beside the run's own stream, `runs/`, which records that the run finishes later, with an empty record, so the run stays `started` until its workflow settles it; a workflow that ends in its first input settles the run before the deferral, and `run_definition` answers its ending. The limits name, for each task that calls `run_definition` with a `type` and a `name` written out, the longest a run of that definition may take, plus `callMarginMs` (`src/runs/call-limits.ts`), read through the run context's `longestRunOf` when the run starts; every other call has `longestCallMs`. A retry with the run id of a run that is going answers the run as it stands. A run never starts twice in one log, so a retry with the run id of a run that ended without a final result, `unavailable` or `failed`, is rejected with `conflict`: run the workflow again under a new run id. While the server is stopping, `run_definition` of a workflow is rejected with `unavailable`. + +`send_run_event` gives the run an `event_received` input, with an id made when the event has none, the time it was sent, and, when it has no source, `/callers/` of the caller who sent it, a source under a prefix the brain keeps for itself, so that a sent event always says where it came from and no event from outside poses as one. An event the run took before is answered as delivered, since an event is taken once by its id; a run that has not started yet, or has ended, is `not_found`; and a run that cannot take the event at that moment, because its log kept changing or the server is stopping, is `unavailable`. Since a `listen` filter matches on the event's attributes, an event may not take a type or a source of what the brain records itself: `refusingTheBrainsOwnAttributes` of `@beonauto/definitions` refuses them with `invalid_input` at `/event/type` and `/event/source`, as `publish_event` does. Its `source` is a URI reference that is not empty, `EventSourceSchema`, and its text follows the rules of a published event too, through `refusingForbiddenCharacters` and `refusingBlankText` of `@beonauto/definitions`: no control character, lone surrogate or noncharacter, and a type, id and subject that hold a character that is not a space. + +A call of a workflow, `call: run_definition`, executes the definition it names through the operations for the caller who started the run, under a run id derived from the workflow's run id, the task and the run of the task (`src/calls/nested-run-id.ts`), so a call started again is the same run. The machine names that run on the call's `waiting` entry through `childOf` (`src/calls/child-run.ts`), which derives it the same way and names nothing for arguments the call would refuse. The call starts it with a lineage, the `step_waiting` event of its step as the cause and the workflow's tree as the correlation, with the reaction depth of the run, and with a call depth one more than the run's and the call it answers, which the server passes in the brain request of the in-process start, never in its input. A call of a run that finishes later, such as one of another workflow, answers the host that it waits for that run, by its run id; the host gives the call the ending of that run, mapped by `callResultOfEnding`. Arguments that name no definition are rejected as `invalid_arguments`, a `validation` error of the task. A rejection keeps its issues in its detail and its `kind` and `because`, which the error of the task carries for a `catch` to read, and an output of more than 1 MiB fails the call. + +## Triggers + +A document's `schedule` names its triggers ([decision 0015](../../docs/decisions/0015-several-triggers.md)), `on`, `cron` and `every`, any one, two or three of them, each a trigger of its own, checked on its own when the definition is saved by `scheduleRejections` (`src/document/workflow-schedule.ts`), which the policy of the engine calls: + +- `on`: `one` filter, or `any` of a list of at least one and at most 64, `mostTriggerFilters`, each a literal event filter of the engine (`literalFilterOf`): its `with` names the type of the events it takes as text, and its source and subject as text too when it names them; a `data` expression that uses a variable such as `$workflow` is refused, since no run exists when the trigger is matched, and so is a filter of `any` whose type and attributes an earlier one has, in any order of keys. `all` and `until` are refused. +- `cron`: five fields, minute, hour, day of month and month, and day of week, read in UTC by `cronRejectionOf` of `@beonauto/workflow-host`, which refuses an expression that cannot be read or names no time that comes. +- `every`: a duration of the DSL of at least a minute. + +`after` is refused, and so is a schedule that names none; two keys of one name cannot be written, since the YAML reader refuses them. `triggersOfDocument` reads the triggers once, at save, in the order the schedule names them, each identified by its kind, `event`, `cron` or `every`, and its reference in the document, `/schedule/on`, `/schedule/cron` or `/schedule/every`: an event trigger with each filter's reference, type and attributes, a cron with its expression and an every with its period in milliseconds. The summary carries them as `triggers`, the `TriggerSchema` of `@beonauto/definitions`, so they travel on the definition's record to the workflow host, which keeps one row a trigger and starts the runs (see `@beonauto/workflow-host`); nothing reads a saved source again for them. `cronRejectionOf` stays in the host, which owns the time arithmetic the check must agree with. + +## Emitting an event + +An `emit` task publishes an event to the brain. Its `event.with` takes a `type` and a `source`, and no `id`, which the engine gives it from its call key; a type or a source written out that the brain records itself is refused when the definition is saved (`emitRejections`), and an event computed at run time that the brain would not record, `emittedEventRefusal` of `@beonauto/definitions`, fails the task with a `validation` error (`emitRefusal`). + +## Histories + +`runPresenter` presents each event of a run log, `input_applied`, first as `workflow_input_applied`: the kind and key of the input (the run id for a start or a cancel, the timer id, the call key or the event id), the status of an answer, with the kind and because of a rejection that names them as `rejection`, which the summary also says in words, and the type of an event, the count of the steps the input moved and the first five, each with its task, run and outcome, and the kinds of output it made. Its `cursor` is the record's cursor with the index 0, the event's own place in the record, so a read on from it goes on with the record's step events, oldest first, and with the records before it, newest first. The engine records one entry for each run of a task the input moved, with the outcome it had when the input ended, so the steps are counted once for each run. The patch is never shown. Keys, types and task references are cut at 256 bytes, so the data stays within 4 KiB at the largest event a run stores (`src/presenting/run-presenter.test.ts`). + +It then presents each step entry of the record as an event of its own, in the entry's order (`src/presenting/step-events.ts`): `step_started`, `step_waiting`, `step_finished` for `completed`, `step_failed` for `raised`, `timed_out` and `cancelled`, and `step_skipped`. Each has the record's `at`, its `data` the step's `name`, `reference` cut at 256 bytes, `run` and `times`, with `waits_for` on a wait, the child's `run_id` on the wait of a call, and on a failure its `outcome` and its error's `type` and `title`, cut at 256 bytes and 1 KiB; its summary names the step by its name in words. Its `id` is `stepEventIdOf` of the run id and the entry's reference, run, outcome and times, so it is the same on every read; its `causation_id` is the id of the step event its `caused_by` names, or the record's id for `input`; its `cursor` is the record's cursor with the index of the event, so a read on from it goes on with the next step. Records of earlier formats show no step events. + +## Testing + +`src/workflows` holds the tests of how a workflow runs, each a document run through the memory driver of the engine (`src/testing/workflows.ts`, `interpret`), with the commands a run gave its ports and the settlement it ended with. `src/input-logs` holds fifteen recorded paths, whose input logs in `input-logs/` are the replay corpus: each replays through the machine to the events it recorded, and each runs today as it was recorded (`RECORD_INPUT_LOGS=1` records them again). The capability and `send_run_event` are tested on a brain whose workflows run on a host over a private SQLite database in memory (`src/testing/workflow-brain.ts`). + +## Source + +`src/document` parses a definition document: YAML, the DSL schema and graph, the policy, the schedule and its triggers, the issues and the summary. `src/capability` holds the capability, whose guide is the public reference page, served to agents as `workflow`, `src/events` the event operation, `src/calls` what a call of a workflow does, `src/runs` the machine's options and a run's attributes, `src/presenting` the presenter of a run's log, `src/workflows` the tests of how a workflow runs, `src/input-logs` the recorded paths and their corpus, and `src/testing` what the tests share. diff --git a/primitives/orchestration/input-logs/cancelled.json b/capabilities/coordination/input-logs/cancelled.json similarity index 92% rename from primitives/orchestration/input-logs/cancelled.json rename to capabilities/coordination/input-logs/cancelled.json index 394b13341..abb177102 100644 --- a/primitives/orchestration/input-logs/cancelled.json +++ b/capabilities/coordination/input-logs/cancelled.json @@ -3,7 +3,7 @@ "inputs": [ { "kind": "started", - "executionId": "0199a3c4-7d2e-7c1a-9b3f-000000000300", + "runId": "0199a3c4-7d2e-7c1a-9b3f-000000000300", "at": 1790845200000, "document": { "document": { @@ -30,7 +30,7 @@ }, { "kind": "cancel_requested", - "executionId": "0199a3c4-7d2e-7c1a-9b3f-000000000300", + "runId": "0199a3c4-7d2e-7c1a-9b3f-000000000300", "at": 1790845200010, "cancel": { "by": "tester", @@ -42,7 +42,7 @@ "events": [ { "type": "input_applied", - "format": 6, + "format": 7, "receipt": { "kind": "started", "key": "0199a3c4-7d2e-7c1a-9b3f-000000000300", @@ -63,7 +63,7 @@ "patch": [ { "op": "replace", - "path": "/executionId", + "path": "/runId", "value": "0199a3c4-7d2e-7c1a-9b3f-000000000300" }, { @@ -214,13 +214,13 @@ { "op": "replace", "path": "/historyBytes", - "value": 2325 + "value": 2307 } ], "outputs": [ { "kind": "arm_timer", - "executionId": "0199a3c4-7d2e-7c1a-9b3f-000000000300", + "runId": "0199a3c4-7d2e-7c1a-9b3f-000000000300", "timerId": "1", "dueAt": 1793437200000, "purpose": "deadline", @@ -228,7 +228,7 @@ }, { "kind": "arm_timer", - "executionId": "0199a3c4-7d2e-7c1a-9b3f-000000000300", + "runId": "0199a3c4-7d2e-7c1a-9b3f-000000000300", "timerId": "2", "dueAt": 1790848800000, "purpose": "wait", @@ -238,7 +238,7 @@ }, { "type": "input_applied", - "format": 6, + "format": 7, "receipt": { "kind": "cancel_requested", "key": "0199a3c4-7d2e-7c1a-9b3f-000000000300", @@ -304,23 +304,23 @@ { "op": "replace", "path": "/historyBytes", - "value": 3528 + "value": 3492 } ], "outputs": [ { "kind": "cancel_timer", - "executionId": "0199a3c4-7d2e-7c1a-9b3f-000000000300", + "runId": "0199a3c4-7d2e-7c1a-9b3f-000000000300", "timerId": "1" }, { "kind": "cancel_timer", - "executionId": "0199a3c4-7d2e-7c1a-9b3f-000000000300", + "runId": "0199a3c4-7d2e-7c1a-9b3f-000000000300", "timerId": "2" }, { "kind": "settle", - "executionId": "0199a3c4-7d2e-7c1a-9b3f-000000000300", + "runId": "0199a3c4-7d2e-7c1a-9b3f-000000000300", "settlement": { "status": "rejected", "detail": "The test cancelled the run", diff --git a/primitives/orchestration/input-logs/catch-do-recovery.json b/capabilities/coordination/input-logs/catch-do-recovery.json similarity index 96% rename from primitives/orchestration/input-logs/catch-do-recovery.json rename to capabilities/coordination/input-logs/catch-do-recovery.json index 13b683a30..65e0dabe4 100644 --- a/primitives/orchestration/input-logs/catch-do-recovery.json +++ b/capabilities/coordination/input-logs/catch-do-recovery.json @@ -3,7 +3,7 @@ "inputs": [ { "kind": "started", - "executionId": "0199a3c4-7d2e-7c1a-9b3f-000000000301", + "runId": "0199a3c4-7d2e-7c1a-9b3f-000000000301", "at": 1790845200000, "document": { "document": { @@ -59,7 +59,7 @@ "events": [ { "type": "input_applied", - "format": 6, + "format": 7, "receipt": { "kind": "started", "key": "0199a3c4-7d2e-7c1a-9b3f-000000000301", @@ -108,7 +108,7 @@ "patch": [ { "op": "replace", - "path": "/executionId", + "path": "/runId", "value": "0199a3c4-7d2e-7c1a-9b3f-000000000301" }, { @@ -251,13 +251,13 @@ { "op": "replace", "path": "/historyBytes", - "value": 2296 + "value": 2284 } ], "outputs": [ { "kind": "settle", - "executionId": "0199a3c4-7d2e-7c1a-9b3f-000000000301", + "runId": "0199a3c4-7d2e-7c1a-9b3f-000000000301", "settlement": { "status": "succeeded", "output": { diff --git a/primitives/orchestration/input-logs/for-with-wait.json b/capabilities/coordination/input-logs/for-with-wait.json similarity index 96% rename from primitives/orchestration/input-logs/for-with-wait.json rename to capabilities/coordination/input-logs/for-with-wait.json index ad7d849b3..c1a9f14d7 100644 --- a/primitives/orchestration/input-logs/for-with-wait.json +++ b/capabilities/coordination/input-logs/for-with-wait.json @@ -3,7 +3,7 @@ "inputs": [ { "kind": "started", - "executionId": "0199a3c4-7d2e-7c1a-9b3f-000000000303", + "runId": "0199a3c4-7d2e-7c1a-9b3f-000000000303", "at": 1790845200000, "document": { "document": { @@ -48,13 +48,13 @@ }, { "kind": "timer_fired", - "executionId": "0199a3c4-7d2e-7c1a-9b3f-000000000303", + "runId": "0199a3c4-7d2e-7c1a-9b3f-000000000303", "at": 1790845200200, "timerId": "2" }, { "kind": "timer_fired", - "executionId": "0199a3c4-7d2e-7c1a-9b3f-000000000303", + "runId": "0199a3c4-7d2e-7c1a-9b3f-000000000303", "at": 1790845200400, "timerId": "3" } @@ -62,7 +62,7 @@ "events": [ { "type": "input_applied", - "format": 6, + "format": 7, "receipt": { "kind": "started", "key": "0199a3c4-7d2e-7c1a-9b3f-000000000303", @@ -96,7 +96,7 @@ "patch": [ { "op": "replace", - "path": "/executionId", + "path": "/runId", "value": "0199a3c4-7d2e-7c1a-9b3f-000000000303" }, { @@ -325,13 +325,13 @@ { "op": "replace", "path": "/historyBytes", - "value": 3236 + "value": 3218 } ], "outputs": [ { "kind": "arm_timer", - "executionId": "0199a3c4-7d2e-7c1a-9b3f-000000000303", + "runId": "0199a3c4-7d2e-7c1a-9b3f-000000000303", "timerId": "1", "dueAt": 1793437200000, "purpose": "deadline", @@ -339,7 +339,7 @@ }, { "kind": "arm_timer", - "executionId": "0199a3c4-7d2e-7c1a-9b3f-000000000303", + "runId": "0199a3c4-7d2e-7c1a-9b3f-000000000303", "timerId": "2", "dueAt": 1790845200200, "purpose": "wait", @@ -349,7 +349,7 @@ }, { "type": "input_applied", - "format": 6, + "format": 7, "receipt": { "kind": "timer_fired", "key": "2", @@ -549,13 +549,13 @@ { "op": "replace", "path": "/historyBytes", - "value": 6300 + "value": 6276 } ], "outputs": [ { "kind": "arm_timer", - "executionId": "0199a3c4-7d2e-7c1a-9b3f-000000000303", + "runId": "0199a3c4-7d2e-7c1a-9b3f-000000000303", "timerId": "3", "dueAt": 1790845200400, "purpose": "wait", @@ -565,7 +565,7 @@ }, { "type": "input_applied", - "format": 6, + "format": 7, "receipt": { "kind": "timer_fired", "key": "3", @@ -695,18 +695,18 @@ { "op": "replace", "path": "/historyBytes", - "value": 8061 + "value": 8025 } ], "outputs": [ { "kind": "cancel_timer", - "executionId": "0199a3c4-7d2e-7c1a-9b3f-000000000303", + "runId": "0199a3c4-7d2e-7c1a-9b3f-000000000303", "timerId": "1" }, { "kind": "settle", - "executionId": "0199a3c4-7d2e-7c1a-9b3f-000000000303", + "runId": "0199a3c4-7d2e-7c1a-9b3f-000000000303", "settlement": { "status": "succeeded", "output": { diff --git a/primitives/orchestration/input-logs/fork-compete.json b/capabilities/coordination/input-logs/fork-compete.json similarity index 97% rename from primitives/orchestration/input-logs/fork-compete.json rename to capabilities/coordination/input-logs/fork-compete.json index bf1d7db87..ae30f5ef7 100644 --- a/primitives/orchestration/input-logs/fork-compete.json +++ b/capabilities/coordination/input-logs/fork-compete.json @@ -3,7 +3,7 @@ "inputs": [ { "kind": "started", - "executionId": "0199a3c4-7d2e-7c1a-9b3f-000000000304", + "runId": "0199a3c4-7d2e-7c1a-9b3f-000000000304", "at": 1790845200000, "document": { "document": { @@ -61,7 +61,7 @@ "events": [ { "type": "input_applied", - "format": 6, + "format": 7, "receipt": { "kind": "started", "key": "0199a3c4-7d2e-7c1a-9b3f-000000000304", @@ -120,7 +120,7 @@ "patch": [ { "op": "replace", - "path": "/executionId", + "path": "/runId", "value": "0199a3c4-7d2e-7c1a-9b3f-000000000304" }, { @@ -270,13 +270,13 @@ { "op": "replace", "path": "/historyBytes", - "value": 2499 + "value": 2487 } ], "outputs": [ { "kind": "settle", - "executionId": "0199a3c4-7d2e-7c1a-9b3f-000000000304", + "runId": "0199a3c4-7d2e-7c1a-9b3f-000000000304", "settlement": { "status": "succeeded", "output": { diff --git a/primitives/orchestration/input-logs/listen-signals.json b/capabilities/coordination/input-logs/listen-signals.json similarity index 92% rename from primitives/orchestration/input-logs/listen-signals.json rename to capabilities/coordination/input-logs/listen-signals.json index 51538cdaf..a6e6d2895 100644 --- a/primitives/orchestration/input-logs/listen-signals.json +++ b/capabilities/coordination/input-logs/listen-signals.json @@ -3,7 +3,7 @@ "inputs": [ { "kind": "started", - "executionId": "0199a3c4-7d2e-7c1a-9b3f-000000000305", + "runId": "0199a3c4-7d2e-7c1a-9b3f-000000000305", "at": 1790845200000, "document": { "document": { @@ -38,7 +38,7 @@ }, { "kind": "event_received", - "executionId": "0199a3c4-7d2e-7c1a-9b3f-000000000305", + "runId": "0199a3c4-7d2e-7c1a-9b3f-000000000305", "at": 1790845200000, "event": { "id": "e1", @@ -48,7 +48,7 @@ }, { "kind": "event_received", - "executionId": "0199a3c4-7d2e-7c1a-9b3f-000000000305", + "runId": "0199a3c4-7d2e-7c1a-9b3f-000000000305", "at": 1790845200000, "event": { "id": "e2", @@ -60,7 +60,7 @@ "events": [ { "type": "input_applied", - "format": 6, + "format": 7, "receipt": { "kind": "started", "key": "0199a3c4-7d2e-7c1a-9b3f-000000000305", @@ -81,7 +81,7 @@ "patch": [ { "op": "replace", - "path": "/executionId", + "path": "/runId", "value": "0199a3c4-7d2e-7c1a-9b3f-000000000305" }, { @@ -173,7 +173,7 @@ "op": "add", "path": "/listeners/[\"0199a3c4-7d2e-7c1a-9b3f-000000000305\",\"~1do~10~1await\",1]", "value": { - "executionId": "0199a3c4-7d2e-7c1a-9b3f-000000000305", + "runId": "0199a3c4-7d2e-7c1a-9b3f-000000000305", "reference": "/do/0/await", "run": 1 } @@ -240,13 +240,13 @@ { "op": "replace", "path": "/historyBytes", - "value": 2438 + "value": 2414 } ], "outputs": [ { "kind": "arm_timer", - "executionId": "0199a3c4-7d2e-7c1a-9b3f-000000000305", + "runId": "0199a3c4-7d2e-7c1a-9b3f-000000000305", "timerId": "1", "dueAt": 1793437200000, "purpose": "deadline", @@ -255,7 +255,7 @@ { "kind": "arm_listener", "key": { - "executionId": "0199a3c4-7d2e-7c1a-9b3f-000000000305", + "runId": "0199a3c4-7d2e-7c1a-9b3f-000000000305", "reference": "/do/0/await", "run": 1 }, @@ -269,7 +269,7 @@ }, { "type": "input_applied", - "format": 6, + "format": 7, "receipt": { "kind": "event_received", "key": "e1", @@ -319,14 +319,14 @@ { "op": "replace", "path": "/historyBytes", - "value": 3041 + "value": 3017 } ], "outputs": [] }, { "type": "input_applied", - "format": 6, + "format": 7, "receipt": { "kind": "event_received", "key": "e2", @@ -423,26 +423,26 @@ { "op": "replace", "path": "/historyBytes", - "value": 4551 + "value": 4509 } ], "outputs": [ { "kind": "cancel_listener", "key": { - "executionId": "0199a3c4-7d2e-7c1a-9b3f-000000000305", + "runId": "0199a3c4-7d2e-7c1a-9b3f-000000000305", "reference": "/do/0/await", "run": 1 } }, { "kind": "cancel_timer", - "executionId": "0199a3c4-7d2e-7c1a-9b3f-000000000305", + "runId": "0199a3c4-7d2e-7c1a-9b3f-000000000305", "timerId": "1" }, { "kind": "settle", - "executionId": "0199a3c4-7d2e-7c1a-9b3f-000000000305", + "runId": "0199a3c4-7d2e-7c1a-9b3f-000000000305", "settlement": { "status": "succeeded", "output": ["yes"] diff --git a/primitives/orchestration/input-logs/parallel-fork.json b/capabilities/coordination/input-logs/parallel-fork.json similarity index 88% rename from primitives/orchestration/input-logs/parallel-fork.json rename to capabilities/coordination/input-logs/parallel-fork.json index c782084ef..4ab6182c7 100644 --- a/primitives/orchestration/input-logs/parallel-fork.json +++ b/capabilities/coordination/input-logs/parallel-fork.json @@ -3,7 +3,7 @@ "inputs": [ { "kind": "started", - "executionId": "0199a3c4-7d2e-7c1a-9b3f-000000000306", + "runId": "0199a3c4-7d2e-7c1a-9b3f-000000000306", "at": 1790845200000, "document": { "document": { @@ -19,18 +19,18 @@ "branches": [ { "left": { - "call": "execute_spec", + "call": "run_definition", "with": { - "primitive": "inference", + "type": "reasoning", "name": "left" } } }, { "right": { - "call": "execute_spec", + "call": "run_definition", "with": { - "primitive": "inference", + "type": "reasoning", "name": "right" } } @@ -51,10 +51,10 @@ }, { "kind": "call_answered", - "executionId": "0199a3c4-7d2e-7c1a-9b3f-000000000306", + "runId": "0199a3c4-7d2e-7c1a-9b3f-000000000306", "at": 1790845200050, "key": { - "executionId": "0199a3c4-7d2e-7c1a-9b3f-000000000306", + "runId": "0199a3c4-7d2e-7c1a-9b3f-000000000306", "reference": "/do/0/both/fork/branches/0/left", "run": 1 }, @@ -67,10 +67,10 @@ }, { "kind": "call_answered", - "executionId": "0199a3c4-7d2e-7c1a-9b3f-000000000306", + "runId": "0199a3c4-7d2e-7c1a-9b3f-000000000306", "at": 1790845200050, "key": { - "executionId": "0199a3c4-7d2e-7c1a-9b3f-000000000306", + "runId": "0199a3c4-7d2e-7c1a-9b3f-000000000306", "reference": "/do/0/both/fork/branches/1/right", "run": 1 }, @@ -85,7 +85,7 @@ "events": [ { "type": "input_applied", - "format": 6, + "format": 7, "receipt": { "kind": "started", "key": "0199a3c4-7d2e-7c1a-9b3f-000000000306", @@ -133,7 +133,7 @@ "patch": [ { "op": "replace", - "path": "/executionId", + "path": "/runId", "value": "0199a3c4-7d2e-7c1a-9b3f-000000000306" }, { @@ -159,18 +159,18 @@ "branches": [ { "left": { - "call": "execute_spec", + "call": "run_definition", "with": { - "primitive": "inference", + "type": "reasoning", "name": "left" } } }, { "right": { - "call": "execute_spec", + "call": "run_definition", "with": { - "primitive": "inference", + "type": "reasoning", "name": "right" } } @@ -268,7 +268,7 @@ "op": "add", "path": "/calls/[\"0199a3c4-7d2e-7c1a-9b3f-000000000306\",\"~1do~10~1both~1fork~1branches~10~1left\",1]", "value": { - "executionId": "0199a3c4-7d2e-7c1a-9b3f-000000000306", + "runId": "0199a3c4-7d2e-7c1a-9b3f-000000000306", "reference": "/do/0/both/fork/branches/0/left", "run": 1 } @@ -277,7 +277,7 @@ "op": "add", "path": "/calls/[\"0199a3c4-7d2e-7c1a-9b3f-000000000306\",\"~1do~10~1both~1fork~1branches~11~1right\",1]", "value": { - "executionId": "0199a3c4-7d2e-7c1a-9b3f-000000000306", + "runId": "0199a3c4-7d2e-7c1a-9b3f-000000000306", "reference": "/do/0/both/fork/branches/1/right", "run": 1 } @@ -285,7 +285,7 @@ { "op": "replace", "path": "/heldBytes", - "value": 16748 + "value": 16732 }, { "op": "add", @@ -300,10 +300,10 @@ "path": "/machine/values/2", "value": { "value": { - "primitive": "inference", + "type": "reasoning", "name": "left" }, - "bytes": 39 + "bytes": 34 } }, { @@ -311,10 +311,10 @@ "path": "/machine/values/3", "value": { "value": { - "primitive": "inference", + "type": "reasoning", "name": "right" }, - "bytes": 40 + "bytes": 35 } }, { @@ -370,11 +370,11 @@ "body": { "kind": "call", "key": { - "executionId": "0199a3c4-7d2e-7c1a-9b3f-000000000306", + "runId": "0199a3c4-7d2e-7c1a-9b3f-000000000306", "reference": "/do/0/both/fork/branches/0/left", "run": 1 }, - "function": "execute_spec", + "function": "run_definition", "arguments": 2, "label": "the reasoning function left", "deadline": "2" @@ -395,11 +395,11 @@ "body": { "kind": "call", "key": { - "executionId": "0199a3c4-7d2e-7c1a-9b3f-000000000306", + "runId": "0199a3c4-7d2e-7c1a-9b3f-000000000306", "reference": "/do/0/both/fork/branches/1/right", "run": 1 }, - "function": "execute_spec", + "function": "run_definition", "arguments": 3, "label": "the reasoning function right", "deadline": "3" @@ -417,13 +417,13 @@ { "op": "replace", "path": "/historyBytes", - "value": 5431 + "value": 5353 } ], "outputs": [ { "kind": "arm_timer", - "executionId": "0199a3c4-7d2e-7c1a-9b3f-000000000306", + "runId": "0199a3c4-7d2e-7c1a-9b3f-000000000306", "timerId": "1", "dueAt": 1793437200000, "purpose": "deadline", @@ -432,20 +432,20 @@ { "kind": "start_call", "key": { - "executionId": "0199a3c4-7d2e-7c1a-9b3f-000000000306", + "runId": "0199a3c4-7d2e-7c1a-9b3f-000000000306", "reference": "/do/0/both/fork/branches/0/left", "run": 1 }, - "function": "execute_spec", + "function": "run_definition", "arguments": { - "primitive": "inference", + "type": "reasoning", "name": "left" }, "longestMs": 600000 }, { "kind": "arm_timer", - "executionId": "0199a3c4-7d2e-7c1a-9b3f-000000000306", + "runId": "0199a3c4-7d2e-7c1a-9b3f-000000000306", "timerId": "2", "dueAt": 1790845800000, "purpose": "call_deadline", @@ -454,20 +454,20 @@ { "kind": "start_call", "key": { - "executionId": "0199a3c4-7d2e-7c1a-9b3f-000000000306", + "runId": "0199a3c4-7d2e-7c1a-9b3f-000000000306", "reference": "/do/0/both/fork/branches/1/right", "run": 1 }, - "function": "execute_spec", + "function": "run_definition", "arguments": { - "primitive": "inference", + "type": "reasoning", "name": "right" }, "longestMs": 600000 }, { "kind": "arm_timer", - "executionId": "0199a3c4-7d2e-7c1a-9b3f-000000000306", + "runId": "0199a3c4-7d2e-7c1a-9b3f-000000000306", "timerId": "3", "dueAt": 1790845800000, "purpose": "call_deadline", @@ -477,7 +477,7 @@ }, { "type": "input_applied", - "format": 6, + "format": 7, "receipt": { "kind": "call_answered", "key": "[\"0199a3c4-7d2e-7c1a-9b3f-000000000306\",\"/do/0/both/fork/branches/0/left\",1]", @@ -526,7 +526,7 @@ { "op": "replace", "path": "/heldBytes", - "value": 12637 + "value": 12626 }, { "op": "remove", @@ -569,20 +569,20 @@ { "op": "replace", "path": "/historyBytes", - "value": 6969 + "value": 6885 } ], "outputs": [ { "kind": "cancel_timer", - "executionId": "0199a3c4-7d2e-7c1a-9b3f-000000000306", + "runId": "0199a3c4-7d2e-7c1a-9b3f-000000000306", "timerId": "2" } ] }, { "type": "input_applied", - "format": 6, + "format": 7, "receipt": { "kind": "call_answered", "key": "[\"0199a3c4-7d2e-7c1a-9b3f-000000000306\",\"/do/0/both/fork/branches/1/right\",1]", @@ -648,7 +648,7 @@ { "op": "replace", "path": "/heldBytes", - "value": 285 + "value": 279 }, { "op": "remove", @@ -686,23 +686,23 @@ { "op": "replace", "path": "/historyBytes", - "value": 8709 + "value": 8607 } ], "outputs": [ { "kind": "cancel_timer", - "executionId": "0199a3c4-7d2e-7c1a-9b3f-000000000306", + "runId": "0199a3c4-7d2e-7c1a-9b3f-000000000306", "timerId": "3" }, { "kind": "cancel_timer", - "executionId": "0199a3c4-7d2e-7c1a-9b3f-000000000306", + "runId": "0199a3c4-7d2e-7c1a-9b3f-000000000306", "timerId": "1" }, { "kind": "settle", - "executionId": "0199a3c4-7d2e-7c1a-9b3f-000000000306", + "runId": "0199a3c4-7d2e-7c1a-9b3f-000000000306", "settlement": { "status": "succeeded", "output": [ diff --git a/primitives/orchestration/input-logs/retry-with-backoff.json b/capabilities/coordination/input-logs/retry-with-backoff.json similarity index 90% rename from primitives/orchestration/input-logs/retry-with-backoff.json rename to capabilities/coordination/input-logs/retry-with-backoff.json index 0edef50e4..1dffb01ae 100644 --- a/primitives/orchestration/input-logs/retry-with-backoff.json +++ b/capabilities/coordination/input-logs/retry-with-backoff.json @@ -3,7 +3,7 @@ "inputs": [ { "kind": "started", - "executionId": "0199a3c4-7d2e-7c1a-9b3f-000000000307", + "runId": "0199a3c4-7d2e-7c1a-9b3f-000000000307", "at": 1790845200000, "document": { "document": { @@ -18,9 +18,9 @@ "try": [ { "fetch": { - "call": "execute_spec", + "call": "run_definition", "with": { - "primitive": "inference", + "type": "reasoning", "name": "flaky", "input": { "key": "backoff" @@ -63,10 +63,10 @@ }, { "kind": "call_answered", - "executionId": "0199a3c4-7d2e-7c1a-9b3f-000000000307", + "runId": "0199a3c4-7d2e-7c1a-9b3f-000000000307", "at": 1790845200050, "key": { - "executionId": "0199a3c4-7d2e-7c1a-9b3f-000000000307", + "runId": "0199a3c4-7d2e-7c1a-9b3f-000000000307", "reference": "/do/0/guarded/try/0/fetch", "run": 1 }, @@ -78,16 +78,16 @@ }, { "kind": "timer_fired", - "executionId": "0199a3c4-7d2e-7c1a-9b3f-000000000307", + "runId": "0199a3c4-7d2e-7c1a-9b3f-000000000307", "at": 1790845200550, "timerId": "3" }, { "kind": "call_answered", - "executionId": "0199a3c4-7d2e-7c1a-9b3f-000000000307", + "runId": "0199a3c4-7d2e-7c1a-9b3f-000000000307", "at": 1790845200600, "key": { - "executionId": "0199a3c4-7d2e-7c1a-9b3f-000000000307", + "runId": "0199a3c4-7d2e-7c1a-9b3f-000000000307", "reference": "/do/0/guarded/try/0/fetch", "run": 2 }, @@ -99,16 +99,16 @@ }, { "kind": "timer_fired", - "executionId": "0199a3c4-7d2e-7c1a-9b3f-000000000307", + "runId": "0199a3c4-7d2e-7c1a-9b3f-000000000307", "at": 1790845201600, "timerId": "5" }, { "kind": "call_answered", - "executionId": "0199a3c4-7d2e-7c1a-9b3f-000000000307", + "runId": "0199a3c4-7d2e-7c1a-9b3f-000000000307", "at": 1790845201650, "key": { - "executionId": "0199a3c4-7d2e-7c1a-9b3f-000000000307", + "runId": "0199a3c4-7d2e-7c1a-9b3f-000000000307", "reference": "/do/0/guarded/try/0/fetch", "run": 3 }, @@ -123,7 +123,7 @@ "events": [ { "type": "input_applied", - "format": 6, + "format": 7, "receipt": { "kind": "started", "key": "0199a3c4-7d2e-7c1a-9b3f-000000000307", @@ -157,7 +157,7 @@ "patch": [ { "op": "replace", - "path": "/executionId", + "path": "/runId", "value": "0199a3c4-7d2e-7c1a-9b3f-000000000307" }, { @@ -182,9 +182,9 @@ "try": [ { "fetch": { - "call": "execute_spec", + "call": "run_definition", "with": { - "primitive": "inference", + "type": "reasoning", "name": "flaky", "input": { "key": "backoff" @@ -289,7 +289,7 @@ "op": "add", "path": "/calls/[\"0199a3c4-7d2e-7c1a-9b3f-000000000307\",\"~1do~10~1guarded~1try~10~1fetch\",1]", "value": { - "executionId": "0199a3c4-7d2e-7c1a-9b3f-000000000307", + "runId": "0199a3c4-7d2e-7c1a-9b3f-000000000307", "reference": "/do/0/guarded/try/0/fetch", "run": 1 } @@ -297,7 +297,7 @@ { "op": "replace", "path": "/heldBytes", - "value": 12717 + "value": 12709 }, { "op": "add", @@ -312,13 +312,13 @@ "path": "/machine/values/2", "value": { "value": { - "primitive": "inference", + "type": "reasoning", "name": "flaky", "input": { "key": "backoff" } }, - "bytes": 66 + "bytes": 61 } }, { @@ -381,11 +381,11 @@ "body": { "kind": "call", "key": { - "executionId": "0199a3c4-7d2e-7c1a-9b3f-000000000307", + "runId": "0199a3c4-7d2e-7c1a-9b3f-000000000307", "reference": "/do/0/guarded/try/0/fetch", "run": 1 }, - "function": "execute_spec", + "function": "run_definition", "arguments": 2, "label": "the reasoning function flaky", "deadline": "2" @@ -405,13 +405,13 @@ { "op": "replace", "path": "/historyBytes", - "value": 4042 + "value": 3997 } ], "outputs": [ { "kind": "arm_timer", - "executionId": "0199a3c4-7d2e-7c1a-9b3f-000000000307", + "runId": "0199a3c4-7d2e-7c1a-9b3f-000000000307", "timerId": "1", "dueAt": 1793437200000, "purpose": "deadline", @@ -420,13 +420,13 @@ { "kind": "start_call", "key": { - "executionId": "0199a3c4-7d2e-7c1a-9b3f-000000000307", + "runId": "0199a3c4-7d2e-7c1a-9b3f-000000000307", "reference": "/do/0/guarded/try/0/fetch", "run": 1 }, - "function": "execute_spec", + "function": "run_definition", "arguments": { - "primitive": "inference", + "type": "reasoning", "name": "flaky", "input": { "key": "backoff" @@ -436,7 +436,7 @@ }, { "kind": "arm_timer", - "executionId": "0199a3c4-7d2e-7c1a-9b3f-000000000307", + "runId": "0199a3c4-7d2e-7c1a-9b3f-000000000307", "timerId": "2", "dueAt": 1790845800000, "purpose": "call_deadline", @@ -446,7 +446,7 @@ }, { "type": "input_applied", - "format": 6, + "format": 7, "receipt": { "kind": "call_answered", "key": "[\"0199a3c4-7d2e-7c1a-9b3f-000000000307\",\"/do/0/guarded/try/0/fetch\",1]", @@ -468,7 +468,7 @@ }, "error": { "type": "https://open-workflow-specification.org/spec/1.0.0/errors/communication", - "title": "The reasoning function flaky rejected the execution with unavailable" + "title": "The reasoning function flaky rejected the run with unavailable" } } ], @@ -514,7 +514,7 @@ { "op": "replace", "path": "/heldBytes", - "value": 8555 + "value": 8552 }, { "op": "remove", @@ -544,7 +544,7 @@ "value": { "type": "https://open-workflow-specification.org/spec/1.0.0/errors/communication", "status": 503, - "title": "The reasoning function flaky rejected the execution with unavailable", + "title": "The reasoning function flaky rejected the run with unavailable", "detail": "busy on call 1", "instance": "/do/0/guarded/try/0/fetch" } @@ -562,18 +562,18 @@ { "op": "replace", "path": "/historyBytes", - "value": 6390 + "value": 6321 } ], "outputs": [ { "kind": "cancel_timer", - "executionId": "0199a3c4-7d2e-7c1a-9b3f-000000000307", + "runId": "0199a3c4-7d2e-7c1a-9b3f-000000000307", "timerId": "2" }, { "kind": "arm_timer", - "executionId": "0199a3c4-7d2e-7c1a-9b3f-000000000307", + "runId": "0199a3c4-7d2e-7c1a-9b3f-000000000307", "timerId": "3", "dueAt": 1790845200550, "purpose": "retry_delay", @@ -583,7 +583,7 @@ }, { "type": "input_applied", - "format": 6, + "format": 7, "receipt": { "kind": "timer_fired", "key": "3", @@ -645,7 +645,7 @@ "op": "add", "path": "/calls/[\"0199a3c4-7d2e-7c1a-9b3f-000000000307\",\"~1do~10~1guarded~1try~10~1fetch\",2]", "value": { - "executionId": "0199a3c4-7d2e-7c1a-9b3f-000000000307", + "runId": "0199a3c4-7d2e-7c1a-9b3f-000000000307", "reference": "/do/0/guarded/try/0/fetch", "run": 2 } @@ -653,20 +653,20 @@ { "op": "replace", "path": "/heldBytes", - "value": 12717 + "value": 12709 }, { "op": "add", "path": "/machine/values/3", "value": { "value": { - "primitive": "inference", + "type": "reasoning", "name": "flaky", "input": { "key": "backoff" } }, - "bytes": 66 + "bytes": 61 } }, { @@ -718,11 +718,11 @@ "body": { "kind": "call", "key": { - "executionId": "0199a3c4-7d2e-7c1a-9b3f-000000000307", + "runId": "0199a3c4-7d2e-7c1a-9b3f-000000000307", "reference": "/do/0/guarded/try/0/fetch", "run": 2 }, - "function": "execute_spec", + "function": "run_definition", "arguments": 3, "label": "the reasoning function flaky", "deadline": "4" @@ -739,20 +739,20 @@ { "op": "replace", "path": "/historyBytes", - "value": 9191 + "value": 9092 } ], "outputs": [ { "kind": "start_call", "key": { - "executionId": "0199a3c4-7d2e-7c1a-9b3f-000000000307", + "runId": "0199a3c4-7d2e-7c1a-9b3f-000000000307", "reference": "/do/0/guarded/try/0/fetch", "run": 2 }, - "function": "execute_spec", + "function": "run_definition", "arguments": { - "primitive": "inference", + "type": "reasoning", "name": "flaky", "input": { "key": "backoff" @@ -762,7 +762,7 @@ }, { "kind": "arm_timer", - "executionId": "0199a3c4-7d2e-7c1a-9b3f-000000000307", + "runId": "0199a3c4-7d2e-7c1a-9b3f-000000000307", "timerId": "4", "dueAt": 1790845800550, "purpose": "call_deadline", @@ -772,7 +772,7 @@ }, { "type": "input_applied", - "format": 6, + "format": 7, "receipt": { "kind": "call_answered", "key": "[\"0199a3c4-7d2e-7c1a-9b3f-000000000307\",\"/do/0/guarded/try/0/fetch\",2]", @@ -794,7 +794,7 @@ }, "error": { "type": "https://open-workflow-specification.org/spec/1.0.0/errors/communication", - "title": "The reasoning function flaky rejected the execution with unavailable" + "title": "The reasoning function flaky rejected the run with unavailable" } } ], @@ -840,7 +840,7 @@ { "op": "replace", "path": "/heldBytes", - "value": 8555 + "value": 8552 }, { "op": "remove", @@ -870,7 +870,7 @@ "value": { "type": "https://open-workflow-specification.org/spec/1.0.0/errors/communication", "status": 503, - "title": "The reasoning function flaky rejected the execution with unavailable", + "title": "The reasoning function flaky rejected the run with unavailable", "detail": "busy on call 2", "instance": "/do/0/guarded/try/0/fetch" } @@ -888,18 +888,18 @@ { "op": "replace", "path": "/historyBytes", - "value": 11540 + "value": 11417 } ], "outputs": [ { "kind": "cancel_timer", - "executionId": "0199a3c4-7d2e-7c1a-9b3f-000000000307", + "runId": "0199a3c4-7d2e-7c1a-9b3f-000000000307", "timerId": "4" }, { "kind": "arm_timer", - "executionId": "0199a3c4-7d2e-7c1a-9b3f-000000000307", + "runId": "0199a3c4-7d2e-7c1a-9b3f-000000000307", "timerId": "5", "dueAt": 1790845201600, "purpose": "retry_delay", @@ -909,7 +909,7 @@ }, { "type": "input_applied", - "format": 6, + "format": 7, "receipt": { "kind": "timer_fired", "key": "5", @@ -971,7 +971,7 @@ "op": "add", "path": "/calls/[\"0199a3c4-7d2e-7c1a-9b3f-000000000307\",\"~1do~10~1guarded~1try~10~1fetch\",3]", "value": { - "executionId": "0199a3c4-7d2e-7c1a-9b3f-000000000307", + "runId": "0199a3c4-7d2e-7c1a-9b3f-000000000307", "reference": "/do/0/guarded/try/0/fetch", "run": 3 } @@ -979,20 +979,20 @@ { "op": "replace", "path": "/heldBytes", - "value": 12717 + "value": 12709 }, { "op": "add", "path": "/machine/values/4", "value": { "value": { - "primitive": "inference", + "type": "reasoning", "name": "flaky", "input": { "key": "backoff" } }, - "bytes": 66 + "bytes": 61 } }, { @@ -1044,11 +1044,11 @@ "body": { "kind": "call", "key": { - "executionId": "0199a3c4-7d2e-7c1a-9b3f-000000000307", + "runId": "0199a3c4-7d2e-7c1a-9b3f-000000000307", "reference": "/do/0/guarded/try/0/fetch", "run": 3 }, - "function": "execute_spec", + "function": "run_definition", "arguments": 4, "label": "the reasoning function flaky", "deadline": "6" @@ -1065,20 +1065,20 @@ { "op": "replace", "path": "/historyBytes", - "value": 14342 + "value": 14189 } ], "outputs": [ { "kind": "start_call", "key": { - "executionId": "0199a3c4-7d2e-7c1a-9b3f-000000000307", + "runId": "0199a3c4-7d2e-7c1a-9b3f-000000000307", "reference": "/do/0/guarded/try/0/fetch", "run": 3 }, - "function": "execute_spec", + "function": "run_definition", "arguments": { - "primitive": "inference", + "type": "reasoning", "name": "flaky", "input": { "key": "backoff" @@ -1088,7 +1088,7 @@ }, { "kind": "arm_timer", - "executionId": "0199a3c4-7d2e-7c1a-9b3f-000000000307", + "runId": "0199a3c4-7d2e-7c1a-9b3f-000000000307", "timerId": "6", "dueAt": 1790845801600, "purpose": "call_deadline", @@ -1098,7 +1098,7 @@ }, { "type": "input_applied", - "format": 6, + "format": 7, "receipt": { "kind": "call_answered", "key": "[\"0199a3c4-7d2e-7c1a-9b3f-000000000307\",\"/do/0/guarded/try/0/fetch\",3]", @@ -1169,7 +1169,7 @@ { "op": "replace", "path": "/heldBytes", - "value": 363 + "value": 360 }, { "op": "remove", @@ -1198,23 +1198,23 @@ { "op": "replace", "path": "/historyBytes", - "value": 15992 + "value": 15821 } ], "outputs": [ { "kind": "cancel_timer", - "executionId": "0199a3c4-7d2e-7c1a-9b3f-000000000307", + "runId": "0199a3c4-7d2e-7c1a-9b3f-000000000307", "timerId": "6" }, { "kind": "cancel_timer", - "executionId": "0199a3c4-7d2e-7c1a-9b3f-000000000307", + "runId": "0199a3c4-7d2e-7c1a-9b3f-000000000307", "timerId": "1" }, { "kind": "settle", - "executionId": "0199a3c4-7d2e-7c1a-9b3f-000000000307", + "runId": "0199a3c4-7d2e-7c1a-9b3f-000000000307", "settlement": { "status": "succeeded", "output": { diff --git a/primitives/orchestration/input-logs/execute-spec.json b/capabilities/coordination/input-logs/run-definition.json similarity index 89% rename from primitives/orchestration/input-logs/execute-spec.json rename to capabilities/coordination/input-logs/run-definition.json index d74e0cc6c..ac27333e8 100644 --- a/primitives/orchestration/input-logs/execute-spec.json +++ b/capabilities/coordination/input-logs/run-definition.json @@ -1,9 +1,9 @@ { - "name": "execute-spec", + "name": "run-definition", "inputs": [ { "kind": "started", - "executionId": "0199a3c4-7d2e-7c1a-9b3f-000000000302", + "runId": "0199a3c4-7d2e-7c1a-9b3f-000000000302", "at": 1790845200000, "document": { "document": { @@ -15,9 +15,9 @@ "do": [ { "summarize": { - "call": "execute_spec", + "call": "run_definition", "with": { - "primitive": "inference", + "type": "reasoning", "name": "summarize", "input": { "text": "${ .text }" @@ -47,10 +47,10 @@ }, { "kind": "call_answered", - "executionId": "0199a3c4-7d2e-7c1a-9b3f-000000000302", + "runId": "0199a3c4-7d2e-7c1a-9b3f-000000000302", "at": 1790845200050, "key": { - "executionId": "0199a3c4-7d2e-7c1a-9b3f-000000000302", + "runId": "0199a3c4-7d2e-7c1a-9b3f-000000000302", "reference": "/do/0/summarize", "run": 1 }, @@ -65,7 +65,7 @@ "events": [ { "type": "input_applied", - "format": 6, + "format": 7, "receipt": { "kind": "started", "key": "0199a3c4-7d2e-7c1a-9b3f-000000000302", @@ -86,7 +86,7 @@ "patch": [ { "op": "replace", - "path": "/executionId", + "path": "/runId", "value": "0199a3c4-7d2e-7c1a-9b3f-000000000302" }, { @@ -108,9 +108,9 @@ "do": [ { "summarize": { - "call": "execute_spec", + "call": "run_definition", "with": { - "primitive": "inference", + "type": "reasoning", "name": "summarize", "input": { "text": "${ .text }" @@ -195,7 +195,7 @@ "op": "add", "path": "/calls/[\"0199a3c4-7d2e-7c1a-9b3f-000000000302\",\"~1do~10~1summarize\",1]", "value": { - "executionId": "0199a3c4-7d2e-7c1a-9b3f-000000000302", + "runId": "0199a3c4-7d2e-7c1a-9b3f-000000000302", "reference": "/do/0/summarize", "run": 1 } @@ -203,7 +203,7 @@ { "op": "replace", "path": "/heldBytes", - "value": 8563 + "value": 8555 }, { "op": "add", @@ -220,13 +220,13 @@ "path": "/machine/values/2", "value": { "value": { - "primitive": "inference", + "type": "reasoning", "name": "summarize", "input": { "text": "hello" } }, - "bytes": 69 + "bytes": 64 } }, { @@ -267,11 +267,11 @@ "body": { "kind": "call", "key": { - "executionId": "0199a3c4-7d2e-7c1a-9b3f-000000000302", + "runId": "0199a3c4-7d2e-7c1a-9b3f-000000000302", "reference": "/do/0/summarize", "run": 1 }, - "function": "execute_spec", + "function": "run_definition", "arguments": 2, "label": "the reasoning function summarize", "deadline": "2" @@ -285,13 +285,13 @@ { "op": "replace", "path": "/historyBytes", - "value": 3332 + "value": 3287 } ], "outputs": [ { "kind": "arm_timer", - "executionId": "0199a3c4-7d2e-7c1a-9b3f-000000000302", + "runId": "0199a3c4-7d2e-7c1a-9b3f-000000000302", "timerId": "1", "dueAt": 1793437200000, "purpose": "deadline", @@ -300,13 +300,13 @@ { "kind": "start_call", "key": { - "executionId": "0199a3c4-7d2e-7c1a-9b3f-000000000302", + "runId": "0199a3c4-7d2e-7c1a-9b3f-000000000302", "reference": "/do/0/summarize", "run": 1 }, - "function": "execute_spec", + "function": "run_definition", "arguments": { - "primitive": "inference", + "type": "reasoning", "name": "summarize", "input": { "text": "hello" @@ -316,7 +316,7 @@ }, { "kind": "arm_timer", - "executionId": "0199a3c4-7d2e-7c1a-9b3f-000000000302", + "runId": "0199a3c4-7d2e-7c1a-9b3f-000000000302", "timerId": "2", "dueAt": 1790845800000, "purpose": "call_deadline", @@ -326,7 +326,7 @@ }, { "type": "input_applied", - "format": 6, + "format": 7, "receipt": { "kind": "call_answered", "key": "[\"0199a3c4-7d2e-7c1a-9b3f-000000000302\",\"/do/0/summarize\",1]", @@ -402,7 +402,7 @@ { "op": "replace", "path": "/heldBytes", - "value": 302 + "value": 299 }, { "op": "replace", @@ -437,23 +437,23 @@ { "op": "replace", "path": "/historyBytes", - "value": 5163 + "value": 5100 } ], "outputs": [ { "kind": "cancel_timer", - "executionId": "0199a3c4-7d2e-7c1a-9b3f-000000000302", + "runId": "0199a3c4-7d2e-7c1a-9b3f-000000000302", "timerId": "2" }, { "kind": "cancel_timer", - "executionId": "0199a3c4-7d2e-7c1a-9b3f-000000000302", + "runId": "0199a3c4-7d2e-7c1a-9b3f-000000000302", "timerId": "1" }, { "kind": "settle", - "executionId": "0199a3c4-7d2e-7c1a-9b3f-000000000302", + "runId": "0199a3c4-7d2e-7c1a-9b3f-000000000302", "settlement": { "status": "succeeded", "output": { diff --git a/primitives/orchestration/input-logs/switch.json b/capabilities/coordination/input-logs/switch.json similarity index 96% rename from primitives/orchestration/input-logs/switch.json rename to capabilities/coordination/input-logs/switch.json index 0dbed7fa5..af613877f 100644 --- a/primitives/orchestration/input-logs/switch.json +++ b/capabilities/coordination/input-logs/switch.json @@ -3,7 +3,7 @@ "inputs": [ { "kind": "started", - "executionId": "0199a3c4-7d2e-7c1a-9b3f-000000000308", + "runId": "0199a3c4-7d2e-7c1a-9b3f-000000000308", "at": 1790845200000, "document": { "document": { @@ -59,7 +59,7 @@ "events": [ { "type": "input_applied", - "format": 6, + "format": 7, "receipt": { "kind": "started", "key": "0199a3c4-7d2e-7c1a-9b3f-000000000308", @@ -92,7 +92,7 @@ "patch": [ { "op": "replace", - "path": "/executionId", + "path": "/runId", "value": "0199a3c4-7d2e-7c1a-9b3f-000000000308" }, { @@ -230,13 +230,13 @@ { "op": "replace", "path": "/historyBytes", - "value": 1925 + "value": 1913 } ], "outputs": [ { "kind": "settle", - "executionId": "0199a3c4-7d2e-7c1a-9b3f-000000000308", + "runId": "0199a3c4-7d2e-7c1a-9b3f-000000000308", "settlement": { "status": "succeeded", "output": { diff --git a/primitives/orchestration/input-logs/temporal-global.json b/capabilities/coordination/input-logs/temporal-global.json similarity index 95% rename from primitives/orchestration/input-logs/temporal-global.json rename to capabilities/coordination/input-logs/temporal-global.json index bbad42409..97b3c46a8 100644 --- a/primitives/orchestration/input-logs/temporal-global.json +++ b/capabilities/coordination/input-logs/temporal-global.json @@ -3,7 +3,7 @@ "inputs": [ { "kind": "started", - "executionId": "0199a3c4-7d2e-7c1a-9b3f-000000000309", + "runId": "0199a3c4-7d2e-7c1a-9b3f-000000000309", "at": 1790845200000, "document": { "document": { @@ -36,7 +36,7 @@ "events": [ { "type": "input_applied", - "format": 6, + "format": 7, "receipt": { "kind": "started", "key": "0199a3c4-7d2e-7c1a-9b3f-000000000309", @@ -56,7 +56,7 @@ "patch": [ { "op": "replace", - "path": "/executionId", + "path": "/runId", "value": "0199a3c4-7d2e-7c1a-9b3f-000000000309" }, { @@ -168,13 +168,13 @@ { "op": "replace", "path": "/historyBytes", - "value": 1806 + "value": 1794 } ], "outputs": [ { "kind": "settle", - "executionId": "0199a3c4-7d2e-7c1a-9b3f-000000000309", + "runId": "0199a3c4-7d2e-7c1a-9b3f-000000000309", "settlement": { "status": "succeeded", "output": { diff --git a/primitives/orchestration/input-logs/then-jump-back.json b/capabilities/coordination/input-logs/then-jump-back.json similarity index 95% rename from primitives/orchestration/input-logs/then-jump-back.json rename to capabilities/coordination/input-logs/then-jump-back.json index 075088587..a8533ca31 100644 --- a/primitives/orchestration/input-logs/then-jump-back.json +++ b/capabilities/coordination/input-logs/then-jump-back.json @@ -3,7 +3,7 @@ "inputs": [ { "kind": "started", - "executionId": "0199a3c4-7d2e-7c1a-9b3f-000000000310", + "runId": "0199a3c4-7d2e-7c1a-9b3f-000000000310", "at": 1790845200000, "document": { "document": { @@ -41,13 +41,13 @@ }, { "kind": "timer_fired", - "executionId": "0199a3c4-7d2e-7c1a-9b3f-000000000310", + "runId": "0199a3c4-7d2e-7c1a-9b3f-000000000310", "at": 1790845200100, "timerId": "2" }, { "kind": "timer_fired", - "executionId": "0199a3c4-7d2e-7c1a-9b3f-000000000310", + "runId": "0199a3c4-7d2e-7c1a-9b3f-000000000310", "at": 1790845200200, "timerId": "3" } @@ -55,7 +55,7 @@ "events": [ { "type": "input_applied", - "format": 6, + "format": 7, "receipt": { "kind": "started", "key": "0199a3c4-7d2e-7c1a-9b3f-000000000310", @@ -89,7 +89,7 @@ "patch": [ { "op": "replace", - "path": "/executionId", + "path": "/runId", "value": "0199a3c4-7d2e-7c1a-9b3f-000000000310" }, { @@ -266,13 +266,13 @@ { "op": "replace", "path": "/historyBytes", - "value": 2710 + "value": 2692 } ], "outputs": [ { "kind": "arm_timer", - "executionId": "0199a3c4-7d2e-7c1a-9b3f-000000000310", + "runId": "0199a3c4-7d2e-7c1a-9b3f-000000000310", "timerId": "1", "dueAt": 1793437200000, "purpose": "deadline", @@ -280,7 +280,7 @@ }, { "kind": "arm_timer", - "executionId": "0199a3c4-7d2e-7c1a-9b3f-000000000310", + "runId": "0199a3c4-7d2e-7c1a-9b3f-000000000310", "timerId": "2", "dueAt": 1790845200100, "purpose": "wait", @@ -290,7 +290,7 @@ }, { "type": "input_applied", - "format": 6, + "format": 7, "receipt": { "kind": "timer_fired", "key": "2", @@ -435,13 +435,13 @@ { "op": "replace", "path": "/historyBytes", - "value": 4706 + "value": 4682 } ], "outputs": [ { "kind": "arm_timer", - "executionId": "0199a3c4-7d2e-7c1a-9b3f-000000000310", + "runId": "0199a3c4-7d2e-7c1a-9b3f-000000000310", "timerId": "3", "dueAt": 1790845200200, "purpose": "wait", @@ -451,7 +451,7 @@ }, { "type": "input_applied", - "format": 6, + "format": 7, "receipt": { "kind": "timer_fired", "key": "3", @@ -574,18 +574,18 @@ { "op": "replace", "path": "/historyBytes", - "value": 6333 + "value": 6297 } ], "outputs": [ { "kind": "cancel_timer", - "executionId": "0199a3c4-7d2e-7c1a-9b3f-000000000310", + "runId": "0199a3c4-7d2e-7c1a-9b3f-000000000310", "timerId": "1" }, { "kind": "settle", - "executionId": "0199a3c4-7d2e-7c1a-9b3f-000000000310", + "runId": "0199a3c4-7d2e-7c1a-9b3f-000000000310", "settlement": { "status": "succeeded", "output": { diff --git a/primitives/orchestration/input-logs/timeout-fires.json b/capabilities/coordination/input-logs/timeout-fires.json similarity index 95% rename from primitives/orchestration/input-logs/timeout-fires.json rename to capabilities/coordination/input-logs/timeout-fires.json index 249406bc1..e05102e36 100644 --- a/primitives/orchestration/input-logs/timeout-fires.json +++ b/capabilities/coordination/input-logs/timeout-fires.json @@ -3,7 +3,7 @@ "inputs": [ { "kind": "started", - "executionId": "0199a3c4-7d2e-7c1a-9b3f-000000000311", + "runId": "0199a3c4-7d2e-7c1a-9b3f-000000000311", "at": 1790845200000, "document": { "document": { @@ -57,7 +57,7 @@ }, { "kind": "timer_fired", - "executionId": "0199a3c4-7d2e-7c1a-9b3f-000000000311", + "runId": "0199a3c4-7d2e-7c1a-9b3f-000000000311", "at": 1790845200300, "timerId": "2" } @@ -65,7 +65,7 @@ "events": [ { "type": "input_applied", - "format": 6, + "format": 7, "receipt": { "kind": "started", "key": "0199a3c4-7d2e-7c1a-9b3f-000000000311", @@ -99,7 +99,7 @@ "patch": [ { "op": "replace", - "path": "/executionId", + "path": "/runId", "value": "0199a3c4-7d2e-7c1a-9b3f-000000000311" }, { @@ -320,13 +320,13 @@ { "op": "replace", "path": "/historyBytes", - "value": 3432 + "value": 3408 } ], "outputs": [ { "kind": "arm_timer", - "executionId": "0199a3c4-7d2e-7c1a-9b3f-000000000311", + "runId": "0199a3c4-7d2e-7c1a-9b3f-000000000311", "timerId": "1", "dueAt": 1793437200000, "purpose": "deadline", @@ -334,7 +334,7 @@ }, { "kind": "arm_timer", - "executionId": "0199a3c4-7d2e-7c1a-9b3f-000000000311", + "runId": "0199a3c4-7d2e-7c1a-9b3f-000000000311", "timerId": "2", "dueAt": 1790845200300, "purpose": "timeout", @@ -342,7 +342,7 @@ }, { "kind": "arm_timer", - "executionId": "0199a3c4-7d2e-7c1a-9b3f-000000000311", + "runId": "0199a3c4-7d2e-7c1a-9b3f-000000000311", "timerId": "3", "dueAt": 1790845205000, "purpose": "wait", @@ -352,7 +352,7 @@ }, { "type": "input_applied", - "format": 6, + "format": 7, "receipt": { "kind": "timer_fired", "key": "2", @@ -470,23 +470,23 @@ { "op": "replace", "path": "/historyBytes", - "value": 5282 + "value": 5240 } ], "outputs": [ { "kind": "cancel_timer", - "executionId": "0199a3c4-7d2e-7c1a-9b3f-000000000311", + "runId": "0199a3c4-7d2e-7c1a-9b3f-000000000311", "timerId": "3" }, { "kind": "cancel_timer", - "executionId": "0199a3c4-7d2e-7c1a-9b3f-000000000311", + "runId": "0199a3c4-7d2e-7c1a-9b3f-000000000311", "timerId": "1" }, { "kind": "settle", - "executionId": "0199a3c4-7d2e-7c1a-9b3f-000000000311", + "runId": "0199a3c4-7d2e-7c1a-9b3f-000000000311", "settlement": { "status": "succeeded", "output": { diff --git a/primitives/orchestration/input-logs/timer.json b/capabilities/coordination/input-logs/timer.json similarity index 93% rename from primitives/orchestration/input-logs/timer.json rename to capabilities/coordination/input-logs/timer.json index fa1384bef..55d6eaefc 100644 --- a/primitives/orchestration/input-logs/timer.json +++ b/capabilities/coordination/input-logs/timer.json @@ -3,7 +3,7 @@ "inputs": [ { "kind": "started", - "executionId": "0199a3c4-7d2e-7c1a-9b3f-000000000312", + "runId": "0199a3c4-7d2e-7c1a-9b3f-000000000312", "at": 1790845200000, "document": { "document": { @@ -34,7 +34,7 @@ }, { "kind": "timer_fired", - "executionId": "0199a3c4-7d2e-7c1a-9b3f-000000000312", + "runId": "0199a3c4-7d2e-7c1a-9b3f-000000000312", "at": 1790845201200, "timerId": "2" } @@ -42,7 +42,7 @@ "events": [ { "type": "input_applied", - "format": 6, + "format": 7, "receipt": { "kind": "started", "key": "0199a3c4-7d2e-7c1a-9b3f-000000000312", @@ -63,7 +63,7 @@ "patch": [ { "op": "replace", - "path": "/executionId", + "path": "/runId", "value": "0199a3c4-7d2e-7c1a-9b3f-000000000312" }, { @@ -218,13 +218,13 @@ { "op": "replace", "path": "/historyBytes", - "value": 2354 + "value": 2336 } ], "outputs": [ { "kind": "arm_timer", - "executionId": "0199a3c4-7d2e-7c1a-9b3f-000000000312", + "runId": "0199a3c4-7d2e-7c1a-9b3f-000000000312", "timerId": "1", "dueAt": 1793437200000, "purpose": "deadline", @@ -232,7 +232,7 @@ }, { "kind": "arm_timer", - "executionId": "0199a3c4-7d2e-7c1a-9b3f-000000000312", + "runId": "0199a3c4-7d2e-7c1a-9b3f-000000000312", "timerId": "2", "dueAt": 1790845201200, "purpose": "wait", @@ -242,7 +242,7 @@ }, { "type": "input_applied", - "format": 6, + "format": 7, "receipt": { "kind": "timer_fired", "key": "2", @@ -315,18 +315,18 @@ { "op": "replace", "path": "/historyBytes", - "value": 3404 + "value": 3374 } ], "outputs": [ { "kind": "cancel_timer", - "executionId": "0199a3c4-7d2e-7c1a-9b3f-000000000312", + "runId": "0199a3c4-7d2e-7c1a-9b3f-000000000312", "timerId": "1" }, { "kind": "settle", - "executionId": "0199a3c4-7d2e-7c1a-9b3f-000000000312", + "runId": "0199a3c4-7d2e-7c1a-9b3f-000000000312", "settlement": { "status": "succeeded", "output": { diff --git a/primitives/orchestration/input-logs/uncaught-error.json b/capabilities/coordination/input-logs/uncaught-error.json similarity index 95% rename from primitives/orchestration/input-logs/uncaught-error.json rename to capabilities/coordination/input-logs/uncaught-error.json index f003f038c..b31485d5b 100644 --- a/primitives/orchestration/input-logs/uncaught-error.json +++ b/capabilities/coordination/input-logs/uncaught-error.json @@ -3,7 +3,7 @@ "inputs": [ { "kind": "started", - "executionId": "0199a3c4-7d2e-7c1a-9b3f-000000000313", + "runId": "0199a3c4-7d2e-7c1a-9b3f-000000000313", "at": 1790845200000, "document": { "document": { @@ -38,7 +38,7 @@ "events": [ { "type": "input_applied", - "format": 6, + "format": 7, "receipt": { "kind": "started", "key": "0199a3c4-7d2e-7c1a-9b3f-000000000313", @@ -62,7 +62,7 @@ "patch": [ { "op": "replace", - "path": "/executionId", + "path": "/runId", "value": "0199a3c4-7d2e-7c1a-9b3f-000000000313" }, { @@ -177,13 +177,13 @@ { "op": "replace", "path": "/historyBytes", - "value": 1763 + "value": 1751 } ], "outputs": [ { "kind": "settle", - "executionId": "0199a3c4-7d2e-7c1a-9b3f-000000000313", + "runId": "0199a3c4-7d2e-7c1a-9b3f-000000000313", "settlement": { "status": "rejected", "detail": "No (at /do/0/reject)", diff --git a/primitives/orchestration/input-logs/worker-restart.json b/capabilities/coordination/input-logs/worker-restart.json similarity index 94% rename from primitives/orchestration/input-logs/worker-restart.json rename to capabilities/coordination/input-logs/worker-restart.json index c3b10a494..12759e0e9 100644 --- a/primitives/orchestration/input-logs/worker-restart.json +++ b/capabilities/coordination/input-logs/worker-restart.json @@ -3,7 +3,7 @@ "inputs": [ { "kind": "started", - "executionId": "0199a3c4-7d2e-7c1a-9b3f-000000000314", + "runId": "0199a3c4-7d2e-7c1a-9b3f-000000000314", "at": 1790845200000, "document": { "document": { @@ -44,7 +44,7 @@ }, { "kind": "timer_fired", - "executionId": "0199a3c4-7d2e-7c1a-9b3f-000000000314", + "runId": "0199a3c4-7d2e-7c1a-9b3f-000000000314", "at": 1790845202000, "timerId": "2" } @@ -52,7 +52,7 @@ "events": [ { "type": "input_applied", - "format": 6, + "format": 7, "receipt": { "kind": "started", "key": "0199a3c4-7d2e-7c1a-9b3f-000000000314", @@ -86,7 +86,7 @@ "patch": [ { "op": "replace", - "path": "/executionId", + "path": "/runId", "value": "0199a3c4-7d2e-7c1a-9b3f-000000000314" }, { @@ -266,13 +266,13 @@ { "op": "replace", "path": "/historyBytes", - "value": 2719 + "value": 2701 } ], "outputs": [ { "kind": "arm_timer", - "executionId": "0199a3c4-7d2e-7c1a-9b3f-000000000314", + "runId": "0199a3c4-7d2e-7c1a-9b3f-000000000314", "timerId": "1", "dueAt": 1793437200000, "purpose": "deadline", @@ -280,7 +280,7 @@ }, { "kind": "arm_timer", - "executionId": "0199a3c4-7d2e-7c1a-9b3f-000000000314", + "runId": "0199a3c4-7d2e-7c1a-9b3f-000000000314", "timerId": "2", "dueAt": 1790845202000, "purpose": "wait", @@ -290,7 +290,7 @@ }, { "type": "input_applied", - "format": 6, + "format": 7, "receipt": { "kind": "timer_fired", "key": "2", @@ -395,18 +395,18 @@ { "op": "replace", "path": "/historyBytes", - "value": 4161 + "value": 4131 } ], "outputs": [ { "kind": "cancel_timer", - "executionId": "0199a3c4-7d2e-7c1a-9b3f-000000000314", + "runId": "0199a3c4-7d2e-7c1a-9b3f-000000000314", "timerId": "1" }, { "kind": "settle", - "executionId": "0199a3c4-7d2e-7c1a-9b3f-000000000314", + "runId": "0199a3c4-7d2e-7c1a-9b3f-000000000314", "settlement": { "status": "succeeded", "output": { diff --git a/primitives/orchestration/package.json b/capabilities/coordination/package.json similarity index 89% rename from primitives/orchestration/package.json rename to capabilities/coordination/package.json index 6b73aca04..bf09331b6 100644 --- a/primitives/orchestration/package.json +++ b/capabilities/coordination/package.json @@ -1,5 +1,5 @@ { - "name": "@beonauto/orchestration", + "name": "@beonauto/coordination", "version": "0.0.0", "private": true, "license": "Elastic-2.0", @@ -14,8 +14,8 @@ }, "dependencies": { "@beonauto/config": "workspace:*", + "@beonauto/definitions": "workspace:*", "@beonauto/operations": "workspace:*", - "@beonauto/specs": "workspace:*", "@beonauto/workflow-engine": "workspace:*", "@beonauto/workflow-host": "workspace:*", "@openworkflowspec/sdk": "1.0.3-alpha8", diff --git a/primitives/orchestration/src/calls/child-run.test.ts b/capabilities/coordination/src/calls/child-run.test.ts similarity index 58% rename from primitives/orchestration/src/calls/child-run.test.ts rename to capabilities/coordination/src/calls/child-run.test.ts index 81e177275..8aca153d0 100644 --- a/primitives/orchestration/src/calls/child-run.test.ts +++ b/capabilities/coordination/src/calls/child-run.test.ts @@ -1,26 +1,26 @@ import { describe, expect, it } from 'vitest'; import { childRunOf } from './child-run.ts'; -import { nestedExecutionId } from './nested-execution-id.ts'; +import { nestedRunId } from './nested-run-id.ts'; -const workflowExecution = '0199a3c4-7d2e-7c1a-9b3f-2f1e0d9c8b7a'; +const workflowRun = '0199a3c4-7d2e-7c1a-9b3f-2f1e0d9c8b7a'; const call = { - function: 'execute_spec', + function: 'run_definition', reference: '/do/0/classify', run: 2, - arguments: { primitive: 'inference', name: 'classify', input: {} }, - attributes: { execution_id: workflowExecution }, + arguments: { type: 'reasoning', name: 'classify', input: {} }, + attributes: { run_id: workflowRun }, }; describe('the run a call of a workflow starts', () => { it('is the run derived from the workflow, the reference and the run of the call, as its call will start', () => { - expect(childRunOf(call)).toBe(nestedExecutionId(workflowExecution, '/do/0/classify', 2)); + expect(childRunOf(call)).toBe(nestedRunId(workflowRun, '/do/0/classify', 2)); }); it('is the run of a workflow the call starts, derived the same way', () => { - expect(childRunOf({ ...call, arguments: { primitive: 'orchestration', name: 'other' } })).toBe( - nestedExecutionId(workflowExecution, '/do/0/classify', 2), + expect(childRunOf({ ...call, arguments: { type: 'workflow', name: 'other' } })).toBe( + nestedRunId(workflowRun, '/do/0/classify', 2), ); }); diff --git a/capabilities/coordination/src/calls/child-run.ts b/capabilities/coordination/src/calls/child-run.ts new file mode 100644 index 000000000..49b7982f2 --- /dev/null +++ b/capabilities/coordination/src/calls/child-run.ts @@ -0,0 +1,19 @@ +import { textField, type ChildCall } from '@beonauto/workflow-engine'; + +import { isArgumentsProblem, definitionArgumentsOf } from '../document/definition-arguments.ts'; +import { runDefinitionFunction } from '../document/workflow-functions.ts'; +import { nestedRunId } from './nested-run-id.ts'; + +export function childRunOf({ + function: name, + reference, + run, + arguments: given, + attributes, +}: ChildCall): string | undefined { + const workflow = textField(attributes, 'run_id'); + if (name !== runDefinitionFunction || workflow === undefined || isArgumentsProblem(definitionArgumentsOf(given))) { + return undefined; + } + return nestedRunId(workflow, reference, run); +} diff --git a/primitives/orchestration/src/calls/function-calls.test.ts b/capabilities/coordination/src/calls/function-calls.test.ts similarity index 78% rename from primitives/orchestration/src/calls/function-calls.test.ts rename to capabilities/coordination/src/calls/function-calls.test.ts index 04eaa8bf1..20dd67c53 100644 --- a/primitives/orchestration/src/calls/function-calls.test.ts +++ b/capabilities/coordination/src/calls/function-calls.test.ts @@ -5,17 +5,17 @@ import { describe, expect, it } from 'vitest'; import { acmeCaller } from '../testing/workflows.ts'; import { definitionCalls } from './function-calls.ts'; import type { DefinitionRunRequest, DefinitionRunResult } from './function-run.ts'; -import { nestedExecutionId } from './nested-execution-id.ts'; +import { nestedRunId } from './nested-run-id.ts'; -const workflowExecution = '0199a3c4-7d2e-7c1a-9b3f-2f1e0d9c8b7a'; +const workflowRun = '0199a3c4-7d2e-7c1a-9b3f-2f1e0d9c8b7a'; const run = { - executionId: `acme/alpha/${workflowExecution}`, + runId: `acme/alpha/${workflowRun}`, attributes: { org: 'acme', brain: 'alpha', - execution_id: workflowExecution, - spec: { name: 'triage', version: 2 }, + run_id: workflowRun, + definition: { name: 'triage', version: 2 }, caller: { id: 'acme-admin', org: 'acme', permissions: ['brain:read', 'brain:write'], brains: '*' }, }, }; @@ -23,16 +23,16 @@ const run = { function callWith(arguments_: StartCall['arguments']): StartCall { return { kind: 'start_call', - key: { executionId: run.executionId, reference: '/do/0/classify', run: 1 }, - function: 'execute_spec', + key: { runId: run.runId, reference: '/do/0/classify', run: 1 }, + function: 'run_definition', arguments: arguments_, longestMs: 60_000, }; } -const classify = callWith({ primitive: 'inference', name: 'classify', input: { ticket: 7 } }); +const classify = callWith({ type: 'reasoning', name: 'classify', input: { ticket: 7 } }); -const waitingOfTheCall = stepEventIdOf(workflowExecution, { +const waitingOfTheCall = stepEventIdOf(workflowRun, { reference: '/do/0/classify', run: 1, outcome: 'waiting', @@ -41,9 +41,9 @@ const waitingOfTheCall = stepEventIdOf(workflowExecution, { function answering(result: DefinitionRunResult) { const asked: DefinitionRunRequest[] = []; - const perform = definitionCalls((execution) => + const perform = definitionCalls((request) => Effect.sync(() => { - asked.push(execution); + asked.push(request); return result; }), ); @@ -51,14 +51,12 @@ function answering(result: DefinitionRunResult) { } describe('a workflow call to a saved definition', () => { - it.each(['inference', 'custom-operation'])( + it.each(['reasoning', 'custom-operation'])( 'executes a %s definition for the original caller under a derived run id', - async (primitive) => { + async (type) => { const { perform, asked } = answering({ status: 'succeeded', output: { urgency: 'high' } }); - const result = await Effect.runPromise( - perform(callWith({ primitive, name: 'classify', input: { ticket: 7 } }), run), - ); + const result = await Effect.runPromise(perform(callWith({ type, name: 'classify', input: { ticket: 7 } }), run)); expect(result).toEqual({ status: 'succeeded', output: { urgency: 'high' } }); expect(asked).toEqual([ @@ -66,14 +64,14 @@ describe('a workflow call to a saved definition', () => { org: 'acme', brain: 'alpha', caller: acmeCaller, - primitive, + type, name: 'classify', input: { ticket: 7 }, - executionId: nestedExecutionId(workflowExecution, '/do/0/classify', 1), - lineage: { causationId: waitingOfTheCall, correlationId: workflowExecution }, + runId: nestedRunId(workflowRun, '/do/0/classify', 1), + lineage: { causationId: waitingOfTheCall, correlationId: workflowRun }, depth: 0, callDepth: 1, - calledBy: { execution_id: workflowExecution, reference: '/do/0/classify', run: 1 }, + calledBy: { run_id: workflowRun, reference: '/do/0/classify', run: 1 }, }, ]); }, @@ -107,7 +105,7 @@ describe('a workflow call with invalid arguments', () => { expect(result).toEqual({ status: 'rejected', reason: 'invalid_arguments', - detail: 'execute_spec takes with: { primitive, name, input }', + detail: 'run_definition takes with: { type, name, input }', }); expect(asked).toEqual([]); }); @@ -124,7 +122,7 @@ describe('a call of a workflow whose run names no caller', () => { }); }); -describe('the answer of a spec a workflow called', () => { +describe('the answer of a definition a workflow called', () => { it('carries a rejection with its issues folded into its detail', async () => { const { perform } = answering({ status: 'rejected', @@ -141,12 +139,12 @@ describe('the answer of a spec a workflow called', () => { }); it('carries a failure, and fails an output larger than a workflow takes', async () => { - const failed = answering({ status: 'failed', detail: 'The execution failed with incident i-1' }); + const failed = answering({ status: 'failed', detail: 'The run failed with incident i-1' }); const large = answering({ status: 'succeeded', output: 'x'.repeat(1_048_576) }); expect(await Effect.runPromise(failed.perform(classify, run))).toEqual({ status: 'failed', - detail: 'The execution failed with incident i-1', + detail: 'The run failed with incident i-1', }); expect(await Effect.runPromise(large.perform(classify, run))).toEqual({ status: 'failed', @@ -165,7 +163,7 @@ describe('the answer of a spec a workflow called', () => { }); }); -describe('a call whose spec is rejected with a kind and because', () => { +describe('a call whose definition is rejected with a kind and because', () => { it('carries the kind and because of a rejection', async () => { const { perform } = answering({ status: 'rejected', diff --git a/primitives/orchestration/src/calls/function-calls.ts b/capabilities/coordination/src/calls/function-calls.ts similarity index 65% rename from primitives/orchestration/src/calls/function-calls.ts rename to capabilities/coordination/src/calls/function-calls.ts index 02d47b228..f763dd657 100644 --- a/primitives/orchestration/src/calls/function-calls.ts +++ b/capabilities/coordination/src/calls/function-calls.ts @@ -1,16 +1,16 @@ +import type { RunEnding } from '@beonauto/definitions'; import { invalidArguments, type CallResult } from '@beonauto/operations'; -import type { RunEnding } from '@beonauto/specs'; import { jsonBytesOf, stepEventIdOf } from '@beonauto/workflow-engine'; import type { CallAnswer, Perform } from '@beonauto/workflow-host'; import { Effect, Option } from 'effect'; -import { isArgumentsProblem, specArgumentsOf } from '../document/spec-arguments.ts'; +import { isArgumentsProblem, definitionArgumentsOf } from '../document/definition-arguments.ts'; import { attributesOfRun } from '../runs/run-attributes.ts'; import { endedRunResultOf } from './function-results.ts'; import type { DefinitionRunResult, EndedRunResult, RunDefinition } from './function-run.ts'; -import { nestedExecutionId } from './nested-execution-id.ts'; +import { nestedRunId } from './nested-run-id.ts'; -const mostSpecOutputBytes = 1_048_576; +const mostDefinitionOutputBytes = 1_048_576; const noCaller: CallAnswer = { status: 'failed', @@ -41,10 +41,10 @@ function callResultOf(result: EndedRunResult): CallResult { return result; } const bytes = jsonBytesOf(result.output); - return bytes > mostSpecOutputBytes + return bytes > mostDefinitionOutputBytes ? { status: 'failed', - detail: `The run returned ${bytes} bytes as JSON, more than the ${mostSpecOutputBytes} a workflow takes`, + detail: `The run returned ${bytes} bytes as JSON, more than the ${mostDefinitionOutputBytes} a workflow takes`, } : result; } @@ -57,30 +57,30 @@ function callAnswerOf(result: DefinitionRunResult, child: string): CallAnswer { return result.status === 'waiting' ? { status: 'waiting', child } : callResultOf(result); } -export function definitionCalls(executeSpec: RunDefinition): Perform { +export function definitionCalls(runDefinition: RunDefinition): Perform { return (call, run) => Option.match(attributesOfRun(run.attributes), { onNone: () => Effect.succeed(noCaller), - onSome: ({ org, brain, execution_id: workflowExecution, caller, lineage, depth = 0, call_depth = 0 }) => { - const spec = specArgumentsOf(call.arguments); - if (isArgumentsProblem(spec)) { - return Effect.succeed({ status: 'rejected', reason: invalidArguments, detail: spec.title }); + onSome: ({ org, brain, run_id: workflowRun, caller, lineage, depth = 0, call_depth = 0 }) => { + const definition = definitionArgumentsOf(call.arguments); + if (isArgumentsProblem(definition)) { + return Effect.succeed({ status: 'rejected', reason: invalidArguments, detail: definition.title }); } const { reference, run: count } = call.key; - const executionId = nestedExecutionId(workflowExecution, reference, count); - const waiting = stepEventIdOf(workflowExecution, { reference, run: count, outcome: 'waiting', times: 1 }); - const correlationId = lineage?.correlation ?? workflowExecution; - return executeSpec({ + const runId = nestedRunId(workflowRun, reference, count); + const waiting = stepEventIdOf(workflowRun, { reference, run: count, outcome: 'waiting', times: 1 }); + const correlationId = lineage?.correlation ?? workflowRun; + return runDefinition({ org, brain, caller, - ...spec, - executionId, + ...definition, + runId, lineage: { causationId: waiting, correlationId }, depth, callDepth: call_depth + 1, - calledBy: { execution_id: workflowExecution, reference, run: count }, - }).pipe(Effect.map((result) => callAnswerOf(result, executionId))); + calledBy: { run_id: workflowRun, reference, run: count }, + }).pipe(Effect.map((result) => callAnswerOf(result, runId))); }, }); } diff --git a/primitives/orchestration/src/calls/function-results.test.ts b/capabilities/coordination/src/calls/function-results.test.ts similarity index 87% rename from primitives/orchestration/src/calls/function-results.test.ts rename to capabilities/coordination/src/calls/function-results.test.ts index 2e3487b2b..ae544de1f 100644 --- a/primitives/orchestration/src/calls/function-results.test.ts +++ b/capabilities/coordination/src/calls/function-results.test.ts @@ -2,8 +2,8 @@ import { describe, expect, it } from 'vitest'; import { definitionRunResultOf } from './function-results.ts'; -describe('the result of executing a spec through the operations', () => { - it('is the output of the execution that succeeded', () => { +describe('the result of running a definition through the operations', () => { + it('is the output of the run that succeeded', () => { expect(definitionRunResultOf({ status: 'succeeded', output: { status: 'succeeded', output: { a: 1 } } })).toEqual({ status: 'succeeded', output: { a: 1 }, @@ -14,7 +14,7 @@ describe('the result of executing a spec through the operations', () => { }); }); - it('is a wait for an execution that finishes later', () => { + it('is a wait for a run that finishes later', () => { expect(definitionRunResultOf({ status: 'succeeded', output: { status: 'started' } })).toEqual({ status: 'waiting', }); @@ -49,7 +49,7 @@ describe('the result of executing a spec through the operations', () => { }); }); -describe('the rejection of executing a spec that names its kind and because', () => { +describe('the rejection of running a definition that names its kind and because', () => { it('is the rejection with its kind and because, which a workflow reads in the error it catches', () => { expect( definitionRunResultOf({ diff --git a/primitives/orchestration/src/calls/function-results.ts b/capabilities/coordination/src/calls/function-results.ts similarity index 89% rename from primitives/orchestration/src/calls/function-results.ts rename to capabilities/coordination/src/calls/function-results.ts index 81b42a242..61b9945c2 100644 --- a/primitives/orchestration/src/calls/function-results.ts +++ b/capabilities/coordination/src/calls/function-results.ts @@ -1,5 +1,5 @@ +import type { RunEnding } from '@beonauto/definitions'; import type { Outcome } from '@beonauto/operations'; -import type { RunEnding } from '@beonauto/specs'; import { field, textField } from '@beonauto/workflow-engine'; import type { DefinitionRunResult, EndedRunResult } from './function-run.ts'; @@ -25,10 +25,10 @@ export function definitionRunResultOf(outcome: Outcome): DefinitionRunResult { } export function endedRunResultOf(ending: RunEnding): EndedRunResult { - if (ending.type === 'execution_succeeded') { + if (ending.type === 'run_succeeded') { return { status: 'succeeded', output: ending.output }; } - if (ending.type === 'execution_failed') { + if (ending.type === 'run_failed') { return { status: 'failed', detail: ending.incident === undefined ? 'The run failed' : `The run failed with incident ${ending.incident}`, diff --git a/primitives/orchestration/src/calls/function-run.ts b/capabilities/coordination/src/calls/function-run.ts similarity index 85% rename from primitives/orchestration/src/calls/function-run.ts rename to capabilities/coordination/src/calls/function-run.ts index 71264e103..73d3a146e 100644 --- a/primitives/orchestration/src/calls/function-run.ts +++ b/capabilities/coordination/src/calls/function-run.ts @@ -13,10 +13,10 @@ export interface DefinitionRunRequest { readonly org: string; readonly brain: string; readonly caller: CallerIdentity; - readonly primitive: string; + readonly type: string; readonly name: string; readonly input: Schema.Json; - readonly executionId: string; + readonly runId: string; readonly lineage: Lineage; readonly depth: number; readonly callDepth: number; @@ -37,4 +37,4 @@ export type EndedRunResult = export type DefinitionRunResult = EndedRunResult | { readonly status: 'waiting' }; -export type RunDefinition = (execution: DefinitionRunRequest) => Effect.Effect; +export type RunDefinition = (run: DefinitionRunRequest) => Effect.Effect; diff --git a/primitives/orchestration/src/calls/nested-execution-id.test.ts b/capabilities/coordination/src/calls/nested-run-id.test.ts similarity index 50% rename from primitives/orchestration/src/calls/nested-execution-id.test.ts rename to capabilities/coordination/src/calls/nested-run-id.test.ts index 4c79207ab..31a3845e8 100644 --- a/primitives/orchestration/src/calls/nested-execution-id.test.ts +++ b/capabilities/coordination/src/calls/nested-run-id.test.ts @@ -1,22 +1,22 @@ import { describe, expect, it } from 'vitest'; -import { nestedExecutionId } from './nested-execution-id.ts'; +import { nestedRunId } from './nested-run-id.ts'; const nameBasedUuid = /^[0-9a-f]{8}-[0-9a-f]{4}-5[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/u; -describe('the id of an execution a workflow runs', () => { +describe('the id of a run a workflow runs', () => { it('is a name-based UUID, the same for the same run, task and run of the task', () => { - const id = nestedExecutionId('run-1', '/do/0/ask', 1); + const id = nestedRunId('run-1', '/do/0/ask', 1); expect(id).toMatch(nameBasedUuid); - expect(nestedExecutionId('run-1', '/do/0/ask', 1)).toBe(id); + expect(nestedRunId('run-1', '/do/0/ask', 1)).toBe(id); }); - it('is the id the runs already started were given, so a call started again is the same execution', () => { + it('is the id the runs already started were given, so a call started again is the same run', () => { expect([ - nestedExecutionId('run-1', '/do/0/ask', 1), - nestedExecutionId('0199a3c4-7d2e-7c1a-9b3f-2f1e0d9c8b7a', '/do/0/judge', 2), - nestedExecutionId('run-1', '/do/0/naïve', 1), + nestedRunId('run-1', '/do/0/ask', 1), + nestedRunId('0199a3c4-7d2e-7c1a-9b3f-2f1e0d9c8b7a', '/do/0/judge', 2), + nestedRunId('run-1', '/do/0/naïve', 1), ]).toEqual([ 'ae7e2bc1-745e-50af-805b-41d801887d11', 'fa69afbd-33e5-52d8-9b80-0074bb97e33c', @@ -26,10 +26,10 @@ describe('the id of an execution a workflow runs', () => { it('differs for another run, another task or another run of the task', () => { const ids = new Set([ - nestedExecutionId('run-1', '/do/0/ask', 1), - nestedExecutionId('run-2', '/do/0/ask', 1), - nestedExecutionId('run-1', '/do/1/ask', 1), - nestedExecutionId('run-1', '/do/0/ask', 2), + nestedRunId('run-1', '/do/0/ask', 1), + nestedRunId('run-2', '/do/0/ask', 1), + nestedRunId('run-1', '/do/1/ask', 1), + nestedRunId('run-1', '/do/0/ask', 2), ]); expect(ids.size).toBe(4); diff --git a/capabilities/coordination/src/calls/nested-run-id.ts b/capabilities/coordination/src/calls/nested-run-id.ts new file mode 100644 index 000000000..cee07fcd3 --- /dev/null +++ b/capabilities/coordination/src/calls/nested-run-id.ts @@ -0,0 +1,7 @@ +import { uuidV5 } from '@beonauto/operations'; + +const nestedRuns = '9b1f3a52-6c0d-4b8e-9f27-3e5d1c7a2b40'; + +export function nestedRunId(runId: string, reference: string, run: number): string { + return uuidV5(nestedRuns, `${runId}${reference}#${run}`); +} diff --git a/primitives/orchestration/src/calls/waiting-calls.test.ts b/capabilities/coordination/src/calls/waiting-calls.test.ts similarity index 66% rename from primitives/orchestration/src/calls/waiting-calls.test.ts rename to capabilities/coordination/src/calls/waiting-calls.test.ts index 692832851..d2bd4906f 100644 --- a/primitives/orchestration/src/calls/waiting-calls.test.ts +++ b/capabilities/coordination/src/calls/waiting-calls.test.ts @@ -4,17 +4,17 @@ import { describe, expect, it } from 'vitest'; import { callResultOfEnding, definitionCalls } from './function-calls.ts'; import type { DefinitionRunRequest, DefinitionRunResult } from './function-run.ts'; -import { nestedExecutionId } from './nested-execution-id.ts'; +import { nestedRunId } from './nested-run-id.ts'; -const workflowExecution = '0199a3c4-7d2e-7c1a-9b3f-2f1e0d9c8b7a'; +const workflowRun = '0199a3c4-7d2e-7c1a-9b3f-2f1e0d9c8b7a'; const run = { - executionId: `acme/alpha/${workflowExecution}`, + runId: `acme/alpha/${workflowRun}`, attributes: { org: 'acme', brain: 'alpha', - execution_id: workflowExecution, - spec: { name: 'triage', version: 2 }, + run_id: workflowRun, + definition: { name: 'triage', version: 2 }, caller: { id: 'acme-admin', org: 'acme', permissions: ['brain:read', 'brain:write'], brains: '*' }, call_depth: 3, }, @@ -22,19 +22,19 @@ const run = { const review: StartCall = { kind: 'start_call', - key: { executionId: run.executionId, reference: '/do/0/review', run: 2 }, - function: 'execute_spec', - arguments: { primitive: 'orchestration', name: 'review', input: { ticket: 7 } }, + key: { runId: run.runId, reference: '/do/0/review', run: 2 }, + function: 'run_definition', + arguments: { type: 'workflow', name: 'review', input: { ticket: 7 } }, longestMs: 60_000, }; -const ofTheChild = { primitive: 'orchestration', name: 'review', spec_version: 1, by: 'brain:alpha', at: 'now' }; +const ofTheChild = { definition_type: 'workflow', name: 'review', definition_version: 1, by: 'brain:alpha', at: 'now' }; function answering(result: DefinitionRunResult) { const asked: DefinitionRunRequest[] = []; - const perform = definitionCalls((execution) => + const perform = definitionCalls((request) => Effect.sync(() => { - asked.push(execution); + asked.push(request); return result; }), ); @@ -47,13 +47,13 @@ describe('a workflow call of a workflow', () => { const answer = await Effect.runPromise(perform(review, run)); - expect(answer).toEqual({ status: 'waiting', child: nestedExecutionId(workflowExecution, '/do/0/review', 2) }); + expect(answer).toEqual({ status: 'waiting', child: nestedRunId(workflowRun, '/do/0/review', 2) }); expect(asked()).toMatchObject([ { - primitive: 'orchestration', + type: 'workflow', name: 'review', callDepth: 4, - calledBy: { execution_id: workflowExecution, reference: '/do/0/review', run: 2 }, + calledBy: { run_id: workflowRun, reference: '/do/0/review', run: 2 }, }, ]); }); @@ -62,8 +62,8 @@ describe('a workflow call of a workflow', () => { describe('the ending of a run a step waits for', () => { it('answers the step with the output of a run that succeeded, unless the output is larger than a workflow takes', () => { expect([ - callResultOfEnding({ type: 'execution_succeeded', output: { done: true }, record: {}, ...ofTheChild }), - callResultOfEnding({ type: 'execution_succeeded', output: 'x'.repeat(1_048_576), record: {}, ...ofTheChild }), + callResultOfEnding({ type: 'run_succeeded', output: { done: true }, record: {}, ...ofTheChild }), + callResultOfEnding({ type: 'run_succeeded', output: 'x'.repeat(1_048_576), record: {}, ...ofTheChild }), ]).toEqual([ { status: 'succeeded', output: { done: true } }, { status: 'failed', detail: 'The run returned 1048578 bytes as JSON, more than the 1048576 a workflow takes' }, @@ -73,12 +73,12 @@ describe('the ending of a run a step waits for', () => { it('answers the step with the rejection of a run, its kind kept and its issues folded into its detail', () => { expect([ callResultOfEnding({ - type: 'execution_rejected', + type: 'run_rejected', rejection: { reason: 'cancelled', kind: 'deadline', detail: 'Out of time' }, ...ofTheChild, }), callResultOfEnding({ - type: 'execution_rejected', + type: 'run_rejected', rejection: { reason: 'invalid_input', detail: 'Wrong', @@ -94,8 +94,8 @@ describe('the ending of a run a step waits for', () => { it('answers the step with a failure that names the incident of a run that broke down, when it has one', () => { expect([ - callResultOfEnding({ type: 'execution_failed', incident: 'i-1', ...ofTheChild }), - callResultOfEnding({ type: 'execution_failed', ...ofTheChild }), + callResultOfEnding({ type: 'run_failed', incident: 'i-1', ...ofTheChild }), + callResultOfEnding({ type: 'run_failed', ...ofTheChild }), ]).toEqual([ { status: 'failed', detail: 'The run failed with incident i-1' }, { status: 'failed', detail: 'The run failed' }, diff --git a/primitives/orchestration/src/primitive/cancelled-start.test.ts b/capabilities/coordination/src/capability/cancelled-start.test.ts similarity index 61% rename from primitives/orchestration/src/primitive/cancelled-start.test.ts rename to capabilities/coordination/src/capability/cancelled-start.test.ts index a4d5f3e64..2c9bd23a3 100644 --- a/primitives/orchestration/src/primitive/cancelled-start.test.ts +++ b/capabilities/coordination/src/capability/cancelled-start.test.ts @@ -8,7 +8,7 @@ import { describe, expect, it } from 'vitest'; import { brainOn } from '../testing/brain.ts'; import { makeWorkflowAdapter } from './workflow.ts'; -const executionId = '0199a3c4-7d2e-7c1a-9b3f-555555555551'; +const runId = '0199a3c4-7d2e-7c1a-9b3f-555555555551'; const flow = "document: { dsl: '1.0.3', namespace: acme, name: flow, version: '1.0.0' }\ndo: []\n"; @@ -22,24 +22,24 @@ const slowlyStarting: Pick = { }).pipe(Effect.as('started' as const)), }; -describe('an execution whose call is cancelled while its workflow starts', () => { - it('waits for the start and records the execution waiting for the workflow it started', async () => { - const orchestration = makeWorkflowAdapter({ +describe('a run whose call is cancelled while its workflow starts', () => { + it('waits for the start and records the run waiting for the workflow it started', async () => { + const workflow = makeWorkflowAdapter({ runs: slowlyStarting, mostDurationMs: 2_592_000_000, longestCallMs: 1000, }); - const brain = brainOn(memoryLedger(), [orchestration]); - await brain.call(brain.createSpec, { primitive: 'orchestration', name: 'flow', source: flow }); + const brain = brainOn(memoryLedger(), [workflow]); + await brain.call(brain.createDefinition, { type: 'workflow', name: 'flow', source: flow }); - const answered = await brain.callCancelledWhen(startBegan.promise, brain.executeSpec, { - primitive: 'orchestration', + const answered = await brain.callCancelledWhen(startBegan.promise, brain.runDefinition, { + type: 'workflow', name: 'flow', - execution_id: executionId, + run_id: runId, }); expect(answered).toStrictEqual({ status: 'cancelled' }); - expect(await brain.call(brain.getExecution, { execution_id: executionId })).toMatchObject({ + expect(await brain.call(brain.getRun, { run_id: runId })).toMatchObject({ output: { status: 'started', record: {} }, }); }); diff --git a/primitives/orchestration/src/primitive/execution-input.test.ts b/capabilities/coordination/src/capability/run-input.test.ts similarity index 67% rename from primitives/orchestration/src/primitive/execution-input.test.ts rename to capabilities/coordination/src/capability/run-input.test.ts index 9b8c7ff39..fce7e7e5d 100644 --- a/primitives/orchestration/src/primitive/execution-input.test.ts +++ b/capabilities/coordination/src/capability/run-input.test.ts @@ -19,15 +19,15 @@ function nested(depth: number): Json { return depth === 0 ? 'bottom' : [nested(depth - 1)]; } -async function executing(input: Json) { +async function running(input: Json) { const brain = brainOn(memoryLedger(), [makeWorkflowAdapter(neverStarted)]); - await brain.call(brain.createSpec, { primitive: 'orchestration', name: 'flow', source: flow }); - return brain.call(brain.executeSpec, { primitive: 'orchestration', name: 'flow', input }); + await brain.call(brain.createDefinition, { type: 'workflow', name: 'flow', source: flow }); + return brain.call(brain.runDefinition, { type: 'workflow', name: 'flow', input }); } -describe('executing a workflow spec with an input a workflow may not hold', () => { +describe('running a workflow definition with an input a workflow may not hold', () => { it('is rejected for an input that nests more than 512 levels deep, before a workflow starts', async () => { - expect(await executing({ deep: nested(512) })).toMatchObject({ + expect(await running({ deep: nested(512) })).toMatchObject({ status: 'rejected', reason: 'invalid_input', issues: [{ detail: 'Expected an input that nests at most 512 levels deep', pointer: '/input' }], @@ -38,7 +38,7 @@ describe('executing a workflow spec with an input a workflow may not hold', () = const brain = brainOn(memoryLedger(), [makeWorkflowAdapter({ ...neverStarted, mostDurationMs: 10_800_000 })]); const source = `document: { dsl: '1.0.3', namespace: acme, name: flow, version: '1.0.0' }\ndo: [{ pause: { wait: PT4H } }]\n`; - expect(await brain.call(brain.createSpec, { primitive: 'orchestration', name: 'flow', source })).toMatchObject({ + expect(await brain.call(brain.createDefinition, { type: 'workflow', name: 'flow', source })).toMatchObject({ status: 'rejected', issues: [ { @@ -50,11 +50,11 @@ describe('executing a workflow spec with an input a workflow may not hold', () = }); it('starts a workflow for an input that nests 512 levels deep', async () => { - expect(await executing({ deep: nested(511) })).toMatchObject({ status: 'failed' }); + expect(await running({ deep: nested(511) })).toMatchObject({ status: 'failed' }); }); }); -describe('executing a workflow spec while another server runs the workflows of the database', () => { +describe('running a workflow definition while another server runs the workflows of the database', () => { it('is rejected as unavailable with the words of the host', async () => { const detail = 'The workflows of this database run in another server'; const elsewhere = makeWorkflowAdapter({ @@ -62,9 +62,9 @@ describe('executing a workflow spec while another server runs the workflows of t runs: { start: () => Effect.fail(new HostElsewhere({ detail })) }, }); const brain = brainOn(memoryLedger(), [elsewhere]); - await brain.call(brain.createSpec, { primitive: 'orchestration', name: 'flow', source: flow }); + await brain.call(brain.createDefinition, { type: 'workflow', name: 'flow', source: flow }); - expect(await brain.call(brain.executeSpec, { primitive: 'orchestration', name: 'flow' })).toMatchObject({ + expect(await brain.call(brain.runDefinition, { type: 'workflow', name: 'flow' })).toMatchObject({ status: 'rejected', reason: 'unavailable', detail, @@ -73,16 +73,16 @@ describe('executing a workflow spec while another server runs the workflows of t }); describe('the workflow wording', () => { - const orchestration = makeWorkflowAdapter(neverStarted); + const workflow = makeWorkflowAdapter(neverStarted); - it('calls a spec a workflow', () => { - expect(orchestration.noun).toEqual({ one: 'workflow', other: 'workflows' }); + it('calls a definition a workflow', () => { + expect(workflow.noun).toEqual({ one: 'workflow', other: 'workflows' }); }); it('renders a small result, and points to the details for a large one', () => { expect([ - orchestration.describeOutput({ greeting: 'Hello, Ada.', reply: 'Thank you!' }), - orchestration.describeOutput({ text: 'a'.repeat(400) }), + workflow.describeOutput({ greeting: 'Hello, Ada.', reply: 'Thank you!' }), + workflow.describeOutput({ text: 'a'.repeat(400) }), ]).toEqual([ 'Its result: greeting: “Hello, Ada.” and reply: “Thank you!”', 'Its result is too long to repeat here; the whole of it is in the details below.', diff --git a/primitives/orchestration/src/primitive/started-run.test.ts b/capabilities/coordination/src/capability/started-run.test.ts similarity index 60% rename from primitives/orchestration/src/primitive/started-run.test.ts rename to capabilities/coordination/src/capability/started-run.test.ts index cc8ee69e3..dce6671f9 100644 --- a/primitives/orchestration/src/primitive/started-run.test.ts +++ b/capabilities/coordination/src/capability/started-run.test.ts @@ -1,5 +1,5 @@ +import { echo } from '@beonauto/definitions/testing'; import { memoryLedger } from '@beonauto/operations/testing'; -import { echo } from '@beonauto/specs/testing'; import type { RunStart, WorkflowHost } from '@beonauto/workflow-host'; import { Effect } from 'effect'; import { describe, expect, it } from 'vitest'; @@ -8,15 +8,15 @@ import { brainOn } from '../testing/brain.ts'; import { acmeCaller } from '../testing/workflows.ts'; import { makeWorkflowAdapter } from './workflow.ts'; -const executionId = '0199a3c4-7d2e-7c1a-9b3f-555555555552'; +const runId = '0199a3c4-7d2e-7c1a-9b3f-555555555552'; const flow = "document: { dsl: '1.0.3', namespace: acme, name: flow, version: '1.0.0' }\ndo: []\n"; const calling = [ "document: { dsl: '1.0.3', namespace: acme, name: calling, version: '1.0.0' }", 'do:', - ' - greet: { call: execute_spec, with: { primitive: echo, name: greet } }', - ' - nested: { call: execute_spec, with: { primitive: orchestration, name: flow } }', + ' - greet: { call: run_definition, with: { type: echo, name: greet } }', + ' - nested: { call: run_definition, with: { type: workflow, name: flow } }', ].join('\n'); function recordingStarts() { @@ -31,26 +31,26 @@ function recordingStarts() { return { starts, recording }; } -describe('the run an execution of a workflow starts', () => { - it('is known by its brain, its execution, its version, its caller, its reaction depth and its lineage', async () => { +describe('the run a run of a workflow starts', () => { + it('is known by its brain, its run, its version, its caller, its reaction depth and its lineage', async () => { const { starts, recording } = recordingStarts(); const brain = brainOn(memoryLedger(), [ makeWorkflowAdapter({ runs: recording, mostDurationMs: 2_592_000_000, longestCallMs: 1000 }), ]); - await brain.call(brain.createSpec, { primitive: 'orchestration', name: 'flow', source: flow }); + await brain.call(brain.createDefinition, { type: 'workflow', name: 'flow', source: flow }); - await brain.call(brain.executeSpec, { primitive: 'orchestration', name: 'flow', execution_id: executionId }); + await brain.call(brain.runDefinition, { type: 'workflow', name: 'flow', run_id: runId }); expect(starts.map(({ attributes }) => attributes)).toMatchObject([ { org: 'acme', brain: 'alpha', - execution_id: executionId, - spec: { name: 'flow', version: 1 }, + run_id: runId, + definition: { name: 'flow', version: 1 }, caller: acmeCaller, depth: 0, call_depth: 0, - lineage: { correlation: executionId }, + lineage: { correlation: runId }, }, ]); }); @@ -61,11 +61,11 @@ describe('the run an execution of a workflow starts', () => { makeWorkflowAdapter({ runs: recording, mostDurationMs: 2_592_000_000, longestCallMs: 1000 }), echo, ]); - await brain.call(brain.createSpec, { primitive: 'echo', name: 'greet', source: '{"greeting": "Hello"}' }); - await brain.call(brain.createSpec, { primitive: 'orchestration', name: 'flow', source: flow }); - await brain.call(brain.createSpec, { primitive: 'orchestration', name: 'calling', source: calling }); + await brain.call(brain.createDefinition, { type: 'echo', name: 'greet', source: '{"greeting": "Hello"}' }); + await brain.call(brain.createDefinition, { type: 'workflow', name: 'flow', source: flow }); + await brain.call(brain.createDefinition, { type: 'workflow', name: 'calling', source: calling }); - await brain.call(brain.executeSpec, { primitive: 'orchestration', name: 'calling', execution_id: executionId }); + await brain.call(brain.runDefinition, { type: 'workflow', name: 'calling', run_id: runId }); expect(starts.map(({ limits }) => limits)).toEqual([ { diff --git a/primitives/orchestration/src/primitive/triage-example.test.ts b/capabilities/coordination/src/capability/triage-example.test.ts similarity index 88% rename from primitives/orchestration/src/primitive/triage-example.test.ts rename to capabilities/coordination/src/capability/triage-example.test.ts index d33ac2a9e..2b479387b 100644 --- a/primitives/orchestration/src/primitive/triage-example.test.ts +++ b/capabilities/coordination/src/capability/triage-example.test.ts @@ -2,12 +2,12 @@ import { Effect } from 'effect'; import { describe, expect, it } from 'vitest'; import { parseWorkflowDocument } from '../document/workflow-document.ts'; -import type { SpecCall, SpecCallResult } from '../testing/run-terms.ts'; +import type { DefinitionCall, DefinitionCallResult } from '../testing/run-terms.ts'; import { interpret, workflowExample, yamlObject } from '../testing/workflows.ts'; const example = yamlObject(workflowExample); -function triagedAs(urgency: string): (call: SpecCall) => SpecCallResult { +function triagedAs(urgency: string): (call: DefinitionCall) => DefinitionCallResult { return ({ name }) => name === 'classify-ticket' ? { status: 'succeeded', output: { category: 'billing', urgency } } @@ -15,7 +15,7 @@ function triagedAs(urgency: string): (call: SpecCall) => SpecCallResult { } describe('the example of a workflow that triages a ticket', () => { - it('stores as a valid spec document', async () => { + it('stores as a valid definition document', async () => { await expect(Effect.runPromise(parseWorkflowDocument(workflowExample))).resolves.toEqual(example); }); diff --git a/primitives/orchestration/src/primitive/workflow-execution.test.ts b/capabilities/coordination/src/capability/workflow-run.test.ts similarity index 50% rename from primitives/orchestration/src/primitive/workflow-execution.test.ts rename to capabilities/coordination/src/capability/workflow-run.test.ts index 17c0a36c2..d5a5860ae 100644 --- a/primitives/orchestration/src/primitive/workflow-execution.test.ts +++ b/capabilities/coordination/src/capability/workflow-run.test.ts @@ -1,8 +1,8 @@ import { afterAll, beforeAll, describe, expect, it } from 'vitest'; -import { orchestratedBrain, type OrchestratedBrain } from '../testing/orchestrated-brain.ts'; +import { workflowBrain, type WorkflowBrain } from '../testing/workflow-brain.ts'; -let brain: OrchestratedBrain; +let brain: WorkflowBrain; const flow = `document: dsl: '1.0.3' @@ -12,8 +12,8 @@ const flow = `document: do: - pause: { wait: { milliseconds: 200 } } - greet: - call: execute_spec - with: { primitive: echo, name: greet, input: { name: '\${ .name }' } } + call: run_definition + with: { type: echo, name: greet, input: { name: '\${ .name }' } } `; const failing = `document: @@ -28,40 +28,40 @@ do: `; beforeAll(async () => { - brain = await orchestratedBrain(); - await brain.call(brain.createSpec, { primitive: 'echo', name: 'greet', source: '{"greeting": "Hello"}' }); - await brain.call(brain.createSpec, { primitive: 'orchestration', name: 'slow-greeting', source: flow }); - await brain.call(brain.createSpec, { primitive: 'orchestration', name: 'failing', source: failing }); + brain = await workflowBrain(); + await brain.call(brain.createDefinition, { type: 'echo', name: 'greet', source: '{"greeting": "Hello"}' }); + await brain.call(brain.createDefinition, { type: 'workflow', name: 'slow-greeting', source: flow }); + await brain.call(brain.createDefinition, { type: 'workflow', name: 'failing', source: failing }); }); afterAll(async () => { await brain.close(); }); -const executionId = '0199a3c4-7d2e-7c1a-9b3f-3333333333a1'; +const runId = '0199a3c4-7d2e-7c1a-9b3f-3333333333a1'; -function executing(name = 'slow-greeting', id = executionId) { - return brain.call(brain.executeSpec, { - primitive: 'orchestration', +function running(name = 'slow-greeting', id = runId) { + return brain.call(brain.runDefinition, { + type: 'workflow', name, input: { name: 'Grace' }, - execution_id: id, + run_id: id, }); } -describe('executing a workflow spec', () => { +describe('running a workflow definition', () => { it('starts its run and answers started, recording that it finishes later', async () => { - expect(await executing()).toMatchObject({ + expect(await running()).toMatchObject({ status: 'succeeded', - output: { execution_id: executionId, status: 'started' }, + output: { run_id: runId, status: 'started' }, }); - expect(await brain.call(brain.getExecution, { execution_id: executionId })).toMatchObject({ + expect(await brain.call(brain.getRun, { run_id: runId })).toMatchObject({ output: { status: 'started', record: {} }, }); }); - it('answers the execution as it stands when the call is retried while the run goes on', async () => { - expect(await executing()).toMatchObject({ output: { status: 'started' } }); + it('answers the run as it stands when the call is retried while the run goes on', async () => { + expect(await running()).toMatchObject({ output: { status: 'started' } }); }); it('is settled with the output when the run ends, which a retry then answers', async () => { @@ -70,19 +70,19 @@ describe('executing a workflow spec', () => { output: { status: 'succeeded', output: { greeting: 'Hello', input: { name: 'Grace' } } }, }; - expect(await brain.settled(executionId)).toMatchObject(settled); - expect(await executing()).toMatchObject(settled); + expect(await brain.settled(runId)).toMatchObject(settled); + expect(await running()).toMatchObject(settled); }); }); -describe('executing again a workflow whose run ended without a final result', () => { +describe('running again a workflow whose run ended without a final result', () => { it('is rejected as a conflict, since a workflow runs once for a run ID, and the rejection is recorded', async () => { const failed = '0199a3c4-7d2e-7c1a-9b3f-3333333333a2'; - await executing('failing', failed); + await running('failing', failed); const ended = await brain.settled(failed); expect(ended).toMatchObject({ output: { status: 'rejected', rejection: { reason: 'unavailable' } } }); - expect(await executing('failing', failed)).toMatchObject({ + expect(await running('failing', failed)).toMatchObject({ status: 'rejected', reason: 'conflict', detail: @@ -91,17 +91,17 @@ describe('executing again a workflow whose run ended without a final result', () }); }); -describe('executing a workflow spec while the server stops', () => { +describe('running a workflow definition while the server stops', () => { it('is rejected as unavailable, and the rejection is recorded', async () => { - const stopping = await orchestratedBrain(); - await stopping.call(stopping.createSpec, { primitive: 'orchestration', name: 'failing', source: failing }); + const stopping = await workflowBrain(); + await stopping.call(stopping.createDefinition, { type: 'workflow', name: 'failing', source: failing }); await stopping.close(); const id = '0199a3c4-7d2e-7c1a-9b3f-3333333333a3'; - const outcome = await stopping.call(stopping.executeSpec, { - primitive: 'orchestration', + const outcome = await stopping.call(stopping.runDefinition, { + type: 'workflow', name: 'failing', - execution_id: id, + run_id: id, }); expect(outcome).toMatchObject({ @@ -109,7 +109,7 @@ describe('executing a workflow spec while the server stops', () => { reason: 'unavailable', detail: 'The workflow cannot start now; try again shortly', }); - expect(await stopping.call(stopping.getExecution, { execution_id: id })).toMatchObject({ + expect(await stopping.call(stopping.getRun, { run_id: id })).toMatchObject({ output: { status: 'rejected', rejection: { reason: 'unavailable' } }, }); }); diff --git a/primitives/orchestration/src/primitive/workflow.test.ts b/capabilities/coordination/src/capability/workflow.test.ts similarity index 76% rename from primitives/orchestration/src/primitive/workflow.test.ts rename to capabilities/coordination/src/capability/workflow.test.ts index fceffbcdb..db3cd8f0a 100644 --- a/primitives/orchestration/src/primitive/workflow.test.ts +++ b/capabilities/coordination/src/capability/workflow.test.ts @@ -1,8 +1,8 @@ import { afterAll, beforeAll, describe, expect, it } from 'vitest'; -import { orchestratedBrain, type OrchestratedBrain } from '../testing/orchestrated-brain.ts'; +import { workflowBrain, type WorkflowBrain } from '../testing/workflow-brain.ts'; -let brain: OrchestratedBrain; +let brain: WorkflowBrain; const header = `document: dsl: '1.0.3' @@ -11,7 +11,7 @@ const header = `document: version: '1.0.0' `; -const greetingFlow = `${header} summary: Greets someone through the echo spec, louder for friends +const greetingFlow = `${header} summary: Greets someone through the echo definition, louder for friends input: schema: document: @@ -20,9 +20,9 @@ input: name: { type: string } do: - greet: - call: execute_spec + call: run_definition with: - primitive: echo + type: echo name: greet input: name: \${ .name } @@ -39,9 +39,9 @@ do: `; beforeAll(async () => { - brain = await orchestratedBrain(); - await brain.call(brain.createSpec, { primitive: 'echo', name: 'greet', source: '{"greeting": "Hello"}' }); - await brain.call(brain.createSpec, { primitive: 'orchestration', name: 'greeting-flow', source: greetingFlow }); + brain = await workflowBrain(); + await brain.call(brain.createDefinition, { type: 'echo', name: 'greet', source: '{"greeting": "Hello"}' }); + await brain.call(brain.createDefinition, { type: 'workflow', name: 'greeting-flow', source: greetingFlow }); }); afterAll(async () => { @@ -49,19 +49,19 @@ afterAll(async () => { }); function creating(name: string, source: string) { - return brain.call(brain.createSpec, { primitive: 'orchestration', name, source }); + return brain.call(brain.createDefinition, { type: 'workflow', name, source }); } -describe('creating a workflow spec', () => { +describe('creating a workflow definition', () => { it('stores its YAML document with its summary and input schema', async () => { expect(await creating('other-flow', greetingFlow)).toMatchObject({ status: 'succeeded', output: { - primitive: 'orchestration', + type: 'workflow', name: 'other-flow', version: 1, media_type: 'application/yaml', - description: 'Greets someone through the echo spec, louder for friends', + description: 'Greets someone through the echo definition, louder for friends', input_schema: { type: 'object', properties: { name: { type: 'string' } } }, }, }); @@ -79,7 +79,7 @@ describe('creating a workflow spec', () => { }); }); -describe('creating a workflow spec the runtime does not run', () => { +describe('creating a workflow definition the runtime does not run', () => { it('is rejected for steps that do not connect', async () => { expect( await creating('lost-flow', `${header}do:\n - s: { switch: [{ always: { then: nowhere } }] }\n`), @@ -108,7 +108,7 @@ describe('creating a workflow spec the runtime does not run', () => { issues: [ { detail: - 'Line 7, column 20: at /do/0/fetch/call: call: http is not allowed: a workflow reaches the world only through its brain functions; call execute_spec', + 'Line 7, column 20: at /do/0/fetch/call: call: http is not allowed: a workflow reaches the world only through its brain functions; call run_definition', pointer: '/source', }, ], diff --git a/primitives/orchestration/src/primitive/workflow.ts b/capabilities/coordination/src/capability/workflow.ts similarity index 84% rename from primitives/orchestration/src/primitive/workflow.ts rename to capabilities/coordination/src/capability/workflow.ts index f3c2fd3b4..7e48361b9 100644 --- a/primitives/orchestration/src/primitive/workflow.ts +++ b/capabilities/coordination/src/capability/workflow.ts @@ -1,5 +1,5 @@ +import { defineCapability, inWords, type RunContext, type FinishesLater, type Capability } from '@beonauto/definitions'; import { asSentence, Conflict, type InvalidInput, type Unavailable } from '@beonauto/operations'; -import { definePrimitive, inWords, type RunContext, type FinishesLater, type Primitive } from '@beonauto/specs'; import type { StartAnswer, WorkflowHost } from '@beonauto/workflow-host'; import { Effect, Random, type Schema } from 'effect'; @@ -39,15 +39,15 @@ function started( { runs, mostDurationMs, longestCallMs }: WorkflowAdapterDependencies, document: WorkflowDefinitionDocument, input: Schema.Json, - { id, org, brain, caller, spec, lineage, depth, callDepth, longestRunOf }: RunContext, + { id, org, brain, caller, definition, lineage, depth, callDepth, longestRunOf }: RunContext, ): Effect.Effect { return Effect.gen(function* () { const seed = yield* Random.nextIntBetween(0, mostSeed); const attributes: RunAttributes = { org, brain, - execution_id: id, - spec, + run_id: id, + definition, caller, depth, call_depth: callDepth, @@ -56,15 +56,15 @@ function started( const longestCallMsByTask = yield* longestCallsOf(document, longestRunOf); const limits = { mostDurationMs, longestCallMs, longestCallMsByTask }; const answer = yield* runs - .start({ org, brain, executionId: id }, { document, input, limits, attributes, seed }) + .start({ org, brain, runId: id }, { document, input, limits, attributes, seed }) .pipe(Effect.mapError(unavailableUnless(notNow))); return yield* finishedLaterOr(answer); }); } -export function makeWorkflowAdapter(dependencies: WorkflowAdapterDependencies): Primitive { - return definePrimitive({ - name: 'orchestration', +export function makeWorkflowAdapter(dependencies: WorkflowAdapterDependencies): Capability { + return defineCapability({ + type: 'workflow', title: 'Workflow', guide: { name: 'workflow' }, noun: { one: 'workflow', other: 'workflows' }, @@ -73,7 +73,7 @@ export function makeWorkflowAdapter(dependencies: WorkflowAdapterDependencies): parse: (source: string): Effect.Effect => parseWorkflowDocument(source, dependencies.mostDurationMs), summarize: summaryOf, - execute: (document, input, execution) => started(dependencies, document, input, execution), + run: (document, input, run) => started(dependencies, document, input, run), whenCancelled: 'finish', finishesLater: true, longestRunOf: () => dependencies.mostDurationMs, diff --git a/capabilities/coordination/src/document/definition-arguments.ts b/capabilities/coordination/src/document/definition-arguments.ts new file mode 100644 index 000000000..df0de352c --- /dev/null +++ b/capabilities/coordination/src/document/definition-arguments.ts @@ -0,0 +1,45 @@ +import { type ErrorKind, field, isObject, type Json, jsonBytesOf, textField } from '@beonauto/workflow-engine'; + +import { runDefinitionFunction } from './workflow-functions.ts'; + +export interface DefinitionArguments { + readonly type: string; + readonly name: string; + readonly input: Json; +} + +export interface ArgumentsProblem { + readonly kind: ErrorKind; + readonly title: string; +} + +const mostDefinitionInputBytes = 262_144; + +export function definitionArgumentsOf(arguments_: Json): DefinitionArguments | ArgumentsProblem { + if (!isObject(arguments_)) { + return { kind: 'validation', title: `${runDefinitionFunction} takes with: { type, name, input }` }; + } + const type = textField(arguments_, 'type'); + const name = textField(arguments_, 'name'); + if (type === undefined || name === undefined) { + return { kind: 'validation', title: `${runDefinitionFunction} needs a string type and a string name` }; + } + const input = field(arguments_, 'input') ?? {}; + const bytes = jsonBytesOf(input); + return bytes > mostDefinitionInputBytes + ? { + kind: 'validation', + title: `The input of ${runDefinitionFunction} takes ${bytes} bytes as JSON, more than the ${mostDefinitionInputBytes} a run takes`, + } + : { type, name, input }; +} + +export function isArgumentsProblem(value: DefinitionArguments | ArgumentsProblem): value is ArgumentsProblem { + return 'title' in value; +} + +export function failureChain(error: unknown): string { + return error instanceof Error && error.cause !== undefined + ? `${String(error)}: ${failureChain(error.cause)}` + : String(error); +} diff --git a/primitives/orchestration/src/document/dsl-validation.test.ts b/capabilities/coordination/src/document/dsl-validation.test.ts similarity index 100% rename from primitives/orchestration/src/document/dsl-validation.test.ts rename to capabilities/coordination/src/document/dsl-validation.test.ts diff --git a/primitives/orchestration/src/document/dsl-validation.ts b/capabilities/coordination/src/document/dsl-validation.ts similarity index 100% rename from primitives/orchestration/src/document/dsl-validation.ts rename to capabilities/coordination/src/document/dsl-validation.ts diff --git a/primitives/orchestration/src/document/workflow-document.test.ts b/capabilities/coordination/src/document/workflow-document.test.ts similarity index 99% rename from primitives/orchestration/src/document/workflow-document.test.ts rename to capabilities/coordination/src/document/workflow-document.test.ts index 138ce2ab7..5aa18946e 100644 --- a/primitives/orchestration/src/document/workflow-document.test.ts +++ b/capabilities/coordination/src/document/workflow-document.test.ts @@ -119,7 +119,7 @@ describe('a workflow document that nests too deeply', () => { }); }); -describe('the summary of a workflow spec', () => { +describe('the summary of a workflow definition', () => { it('is the summary or title of the document, and its inline input and output schemas', () => { expect( summaryOf({ diff --git a/primitives/orchestration/src/document/workflow-document.ts b/capabilities/coordination/src/document/workflow-document.ts similarity index 100% rename from primitives/orchestration/src/document/workflow-document.ts rename to capabilities/coordination/src/document/workflow-document.ts diff --git a/primitives/orchestration/src/document/workflow-functions.test.ts b/capabilities/coordination/src/document/workflow-functions.test.ts similarity index 53% rename from primitives/orchestration/src/document/workflow-functions.test.ts rename to capabilities/coordination/src/document/workflow-functions.test.ts index 09b5ec9be..df2643879 100644 --- a/primitives/orchestration/src/document/workflow-functions.test.ts +++ b/capabilities/coordination/src/document/workflow-functions.test.ts @@ -8,15 +8,15 @@ function rejectedIn(tasks: string): readonly string[] { } describe('the functions a workflow calls', () => { - it('are execute_spec alone: a workflow reaches the world through the specs of its brain', () => { + it('are run_definition alone: a workflow reaches the world through the definitions of its brain', () => { expect( rejectedIn(` - fetch: { call: http, with: {} } - log: { call: log, with: {} } `), ).toEqual([ - '/do/0/fetch/call: call: http is not allowed: a workflow reaches the world only through its brain functions; call execute_spec', - '/do/1/log/call: call: log names no function; the one function is execute_spec', + '/do/0/fetch/call: call: http is not allowed: a workflow reaches the world only through its brain functions; call run_definition', + '/do/1/log/call: call: log names no function; the one function is run_definition', ]); }); @@ -24,30 +24,30 @@ describe('the functions a workflow calls', () => { expect( workflowPolicy(workflow('use: { catalogs: {}, functions: {} }\ndo: []')).map(({ detail }) => detail), ).toEqual([ - 'catalogs are not supported in this version: a workflow calls only execute_spec', - 'reusable functions are not supported in this version: call execute_spec directly', + 'catalogs are not supported in this version: a workflow calls only run_definition', + 'reusable functions are not supported in this version: call run_definition directly', ]); }); }); -describe('a call of execute_spec', () => { +describe('a call of run_definition', () => { it('is described by the definition it runs, or by its name when that cannot be read', () => { expect([ - workflowFunctions.describe('execute_spec', { primitive: 'inference', name: 'summarize' }), - workflowFunctions.describe('execute_spec', { primitive: 'echo', name: 'greet' }), - workflowFunctions.describe('execute_spec', { name: 'summarize' }), - workflowFunctions.describe('execute_spec', 'summarize'), - ]).toEqual(['the reasoning function summarize', 'the echo definition greet', 'execute_spec', 'execute_spec']); + workflowFunctions.describe('run_definition', { type: 'reasoning', name: 'summarize' }), + workflowFunctions.describe('run_definition', { type: 'echo', name: 'greet' }), + workflowFunctions.describe('run_definition', { name: 'summarize' }), + workflowFunctions.describe('run_definition', 'summarize'), + ]).toEqual(['the reasoning function summarize', 'the echo definition greet', 'run_definition', 'run_definition']); }); }); -describe('the policy of execute_spec', () => { - it('takes a primitive, a name and an input, as expressions or literals', () => { +describe('the policy of run_definition', () => { + it('takes a type, a name and an input, as expressions or literals', () => { expect( rejectedIn(` - summarize: - call: execute_spec - with: { primitive: inference, name: '\${ .spec }', input: { text: '\${ .text }' } } + call: run_definition + with: { type: reasoning, name: '\${ .definition }', input: { text: '\${ .text }' } } `), ).toEqual([]); }); @@ -55,21 +55,21 @@ describe('the policy of execute_spec', () => { it('rejects arguments it does not take and arguments it lacks', () => { expect( rejectedIn(` - - nothing: { call: execute_spec } - - partial: { call: execute_spec, with: { name: 3, model: big } } + - nothing: { call: run_definition } + - partial: { call: run_definition, with: { name: 3, model: big } } `), ).toEqual([ - '/do/0/nothing/with: execute_spec takes with: { primitive, name, input }', - '/do/1/partial/with/model: execute_spec takes no argument model', - '/do/1/partial/with/primitive: execute_spec needs a string primitive', - '/do/1/partial/with/name: execute_spec needs a string name', + '/do/0/nothing/with: run_definition takes with: { type, name, input }', + '/do/1/partial/with/model: run_definition takes no argument model', + '/do/1/partial/with/type: run_definition needs a string type', + '/do/1/partial/with/name: run_definition needs a string name', ]); }); it('takes a call of another workflow, and rejects broken expressions in its arguments', () => { expect( rejectedIn(` - - nested: { call: execute_spec, with: { primitive: orchestration, name: other, input: ['\${ .a + }'] } } + - nested: { call: run_definition, with: { type: workflow, name: other, input: ['\${ .a + }'] } } `), ).toEqual([expect.stringMatching(/^\/do\/0\/nested\/with\/input\/0: /u)]); }); diff --git a/primitives/orchestration/src/document/workflow-functions.ts b/capabilities/coordination/src/document/workflow-functions.ts similarity index 58% rename from primitives/orchestration/src/document/workflow-functions.ts rename to capabilities/coordination/src/document/workflow-functions.ts index 6361c8dae..e0409f0aa 100644 --- a/primitives/orchestration/src/document/workflow-functions.ts +++ b/capabilities/coordination/src/document/workflow-functions.ts @@ -1,4 +1,9 @@ -import { definitionResourceLabel, emittedEventRefusal, isReservedSource, reservedEventTypes } from '@beonauto/specs'; +import { + definitionResourceLabel, + emittedEventRefusal, + isReservedSource, + reservedEventTypes, +} from '@beonauto/definitions'; import { type CallFunctions, field, @@ -15,27 +20,27 @@ import { import { scheduleRejections } from './workflow-schedule.ts'; -export const executeSpecFunction = 'execute_spec'; +export const runDefinitionFunction = 'run_definition'; -const executeSpecArguments = new Set(['primitive', 'name', 'input']); +const runDefinitionArguments = new Set(['type', 'name', 'input']); -function executeSpecRejections(arguments_: Json | undefined, pointer: string): readonly Rejection[] { +function runDefinitionRejections(arguments_: Json | undefined, pointer: string): readonly Rejection[] { if (!isObject(arguments_)) { - return [rejection(pointer, `${executeSpecFunction} takes with: { primitive, name, input }`)]; + return [rejection(pointer, `${runDefinitionFunction} takes with: { type, name, input }`)]; } const unknown = Object.keys(arguments_) - .filter((key) => !executeSpecArguments.has(key)) - .map((key) => rejection(pointerTo(pointer, key), `${executeSpecFunction} takes no argument ${key}`)); - const missing = ['primitive', 'name'] + .filter((key) => !runDefinitionArguments.has(key)) + .map((key) => rejection(pointerTo(pointer, key), `${runDefinitionFunction} takes no argument ${key}`)); + const missing = ['type', 'name'] .filter((key) => typeof field(arguments_, key) !== 'string') - .map((key) => rejection(pointerTo(pointer, key), `${executeSpecFunction} needs a string ${key}`)); + .map((key) => rejection(pointerTo(pointer, key), `${runDefinitionFunction} needs a string ${key}`)); return unknown.concat(missing, templateRejections(arguments_, pointer)); } -function specDescribed(name: string, arguments_: Json): string { - const primitive = isObject(arguments_) ? textField(arguments_, 'primitive') : undefined; - const spec = isObject(arguments_) ? textField(arguments_, 'name') : undefined; - return primitive === undefined || spec === undefined ? name : `the ${definitionResourceLabel(primitive)} ${spec}`; +function definitionDescribed(name: string, arguments_: Json): string { + const type = isObject(arguments_) ? textField(arguments_, 'type') : undefined; + const definition = isObject(arguments_) ? textField(arguments_, 'name') : undefined; + return type === undefined || definition === undefined ? name : `the ${definitionResourceLabel(type)} ${definition}`; } function emitRejections(attributes: JsonObject, pointer: string): readonly Rejection[] { @@ -68,13 +73,13 @@ function emitRefusal(event: JsonObject): string | undefined { } export const workflowFunctions: CallFunctions = { - argumentChecks: { [executeSpecFunction]: executeSpecRejections }, - describe: specDescribed, + argumentChecks: { [runDefinitionFunction]: runDefinitionRejections }, + describe: definitionDescribed, emitRejections, emitRefusal, scheduleRejections, howAWorkflowReachesTheWorld: 'a workflow reaches the world only through its brain functions', - howAWorkflowStarts: 'run the workflow with execute_spec', + howAWorkflowStarts: 'run the workflow with run_definition', }; export const workflowPolicy = policyOf(workflowFunctions); diff --git a/primitives/orchestration/src/document/workflow-schedule.test.ts b/capabilities/coordination/src/document/workflow-schedule.test.ts similarity index 100% rename from primitives/orchestration/src/document/workflow-schedule.test.ts rename to capabilities/coordination/src/document/workflow-schedule.test.ts diff --git a/primitives/orchestration/src/document/workflow-schedule.ts b/capabilities/coordination/src/document/workflow-schedule.ts similarity index 99% rename from primitives/orchestration/src/document/workflow-schedule.ts rename to capabilities/coordination/src/document/workflow-schedule.ts index c7aefd4ad..8d56fbf95 100644 --- a/primitives/orchestration/src/document/workflow-schedule.ts +++ b/capabilities/coordination/src/document/workflow-schedule.ts @@ -1,4 +1,4 @@ -import type { Trigger, TriggerFilter } from '@beonauto/specs'; +import type { Trigger, TriggerFilter } from '@beonauto/definitions'; import { field, forbidden, diff --git a/primitives/orchestration/src/document/workflow-summary.ts b/capabilities/coordination/src/document/workflow-summary.ts similarity index 93% rename from primitives/orchestration/src/document/workflow-summary.ts rename to capabilities/coordination/src/document/workflow-summary.ts index b9acb29d2..8ad8cf170 100644 --- a/primitives/orchestration/src/document/workflow-summary.ts +++ b/capabilities/coordination/src/document/workflow-summary.ts @@ -1,4 +1,4 @@ -import type { DefinitionSummary } from '@beonauto/specs'; +import type { DefinitionSummary } from '@beonauto/definitions'; import { type JsonObject, objectField, textField } from '@beonauto/workflow-engine'; import { triggersOfDocument } from './workflow-schedule.ts'; diff --git a/primitives/orchestration/src/events/send-execution-event.test.ts b/capabilities/coordination/src/events/send-run-event.test.ts similarity index 59% rename from primitives/orchestration/src/events/send-execution-event.test.ts rename to capabilities/coordination/src/events/send-run-event.test.ts index 641d59cb9..c28bba5f1 100644 --- a/primitives/orchestration/src/events/send-execution-event.test.ts +++ b/capabilities/coordination/src/events/send-run-event.test.ts @@ -1,16 +1,16 @@ -import { mostEventDataDepth, mostInputDepth } from '@beonauto/specs'; +import { mostEventDataDepth, mostInputDepth } from '@beonauto/definitions'; import { mostValueDepth } from '@beonauto/workflow-engine'; import { HostElsewhere, HostStopped } from '@beonauto/workflow-host'; import { Effect, type Schema } from 'effect'; import { afterAll, beforeAll, describe, expect, it } from 'vitest'; -import { orchestratedBrain, type OrchestratedBrain } from '../testing/orchestrated-brain.ts'; +import { workflowBrain, type WorkflowBrain } from '../testing/workflow-brain.ts'; import { acmeCaller } from '../testing/workflows.ts'; -import { defineSendExecutionEvent } from './send-execution-event.ts'; +import { defineSendRunEvent } from './send-run-event.ts'; -let brain: OrchestratedBrain; +let brain: WorkflowBrain; -let sendEvent: ReturnType; +let sendEvent: ReturnType; const approval = `document: dsl: '1.0.3' @@ -32,11 +32,11 @@ function nested(levels: number): Schema.Json { } beforeAll(async () => { - brain = await orchestratedBrain(); - sendEvent = defineSendExecutionEvent(brain.host); - await brain.call(brain.createSpec, { primitive: 'orchestration', name: 'approval', source: approval }); - await brain.call(brain.createSpec, { primitive: 'orchestration', name: 'envelope', source: envelope }); - await brain.call(brain.createSpec, { primitive: 'echo', name: 'greet', source: '{"greeting": "Hello"}' }); + brain = await workflowBrain(); + sendEvent = defineSendRunEvent(brain.host); + await brain.call(brain.createDefinition, { type: 'workflow', name: 'approval', source: approval }); + await brain.call(brain.createDefinition, { type: 'workflow', name: 'envelope', source: envelope }); + await brain.call(brain.createDefinition, { type: 'echo', name: 'greet', source: '{"greeting": "Hello"}' }); }); afterAll(async () => { @@ -54,43 +54,43 @@ function idOf(number: number): string { return `0199a3c4-7d2e-7c1a-9b3f-${String(number).padStart(12, '4')}`; } -async function startedApproval(executionId: string): Promise { - await brain.call(brain.executeSpec, { primitive: 'orchestration', name: 'approval', execution_id: executionId }); +async function startedApproval(runId: string): Promise { + await brain.call(brain.runDefinition, { type: 'workflow', name: 'approval', run_id: runId }); } -describe('send_execution_event', () => { - it('is a brain command at POST /executions/{execution_id}/events', () => { +describe('send_run_event', () => { + it('is a brain command at POST /runs/{run_id}/events', () => { expect(sendEvent.registration).toMatchObject({ scope: 'brain', kind: 'command', - route: { method: 'POST', path: '/executions/{execution_id}/events' }, + route: { method: 'POST', path: '/runs/{run_id}/events' }, reasons: ['not_found', 'unavailable'], }); }); it('delivers an event to the running workflow, which listens for it and ends', async () => { - const executionId = idOf(1); - await startedApproval(executionId); + const runId = idOf(1); + await startedApproval(runId); const sent = await brain.call(sendEvent, { - execution_id: executionId.toUpperCase(), + run_id: runId.toUpperCase(), event: { id: 'decision-1', type: 'com.acme.approval.decided', data: { approved: true } }, }); expect(sent).toMatchObject({ status: 'succeeded', - output: { execution_id: executionId, event: { id: 'decision-1', type: 'com.acme.approval.decided' } }, + output: { run_id: runId, event: { id: 'decision-1', type: 'com.acme.approval.decided' } }, }); - expect(await brain.settled(executionId)).toMatchObject({ + expect(await brain.settled(runId)).toMatchObject({ output: { status: 'succeeded', output: [{ approved: true }] }, }); }); it('gives an event an id, the caller who sent it as its source, and the time it was sent', async () => { - const executionId = idOf(2); - await startedApproval(executionId); + const runId = idOf(2); + await startedApproval(runId); - const sent = await brain.call(sendEvent, { execution_id: executionId, event: { type: 'com.acme.other' } }); + const sent = await brain.call(sendEvent, { run_id: runId, event: { type: 'com.acme.other' } }); expect(sent).toMatchObject({ status: 'succeeded', @@ -100,27 +100,27 @@ describe('send_execution_event', () => { }); }); -describe('send_execution_event to no running workflow', () => { - it('is rejected as not found for an unknown execution or one of another primitive', async () => { +describe('send_run_event to no running workflow', () => { + it('is rejected as not found for an unknown run or one of another type', async () => { const greeted = idOf(3); - await brain.call(brain.executeSpec, { primitive: 'echo', name: 'greet', execution_id: greeted }); + await brain.call(brain.runDefinition, { type: 'echo', name: 'greet', run_id: greeted }); const notFound = { status: 'rejected', reason: 'not_found' }; - expect(await brain.call(sendEvent, { execution_id: idOf(4), event: { type: 'x' } })).toMatchObject(notFound); - expect(await brain.call(sendEvent, { execution_id: greeted, event: { type: 'x' } })).toMatchObject({ + expect(await brain.call(sendEvent, { run_id: idOf(4), event: { type: 'x' } })).toMatchObject(notFound); + expect(await brain.call(sendEvent, { run_id: greeted, event: { type: 'x' } })).toMatchObject({ ...notFound, detail: 'The brain has no active workflow run with that id', }); }); it.each(['not_started', 'ended'] as const)( - 'is rejected as not found when the run of the execution answers %s', + 'is rejected as not found when the run on the host answers %s', async (answer) => { - const executionId = idOf(5); - const answering = defineSendExecutionEvent({ deliver: () => Effect.succeed(answer) }); - await startedApproval(executionId); + const runId = idOf(5); + const answering = defineSendRunEvent({ deliver: () => Effect.succeed(answer) }); + await startedApproval(runId); - expect(await brain.call(answering, { execution_id: executionId, event: { type: 'x' } })).toEqual({ + expect(await brain.call(answering, { run_id: runId, event: { type: 'x' } })).toEqual({ status: 'rejected', reason: 'not_found', detail: 'The brain has no active workflow run with that id', @@ -129,15 +129,15 @@ describe('send_execution_event to no running workflow', () => { ); }); -describe('send_execution_event that cannot be delivered', () => { +describe('send_run_event that cannot be delivered', () => { it('is rejected as unavailable when the workflow cannot take it at that moment', async () => { - const executionId = idOf(6); - await startedApproval(executionId); - const stopping = defineSendExecutionEvent({ + const runId = idOf(6); + await startedApproval(runId); + const stopping = defineSendRunEvent({ deliver: () => Effect.fail(new HostStopped({ detail: 'The server is stopping' })), }); - expect(await brain.call(stopping, { execution_id: executionId, event: { type: 'x' } })).toEqual({ + expect(await brain.call(stopping, { run_id: runId, event: { type: 'x' } })).toEqual({ status: 'rejected', reason: 'unavailable', detail: 'The workflow cannot take the event now; try again shortly', @@ -145,12 +145,12 @@ describe('send_execution_event that cannot be delivered', () => { }); it('is rejected as unavailable, saying so, when another server runs the workflows of the database', async () => { - const executionId = idOf(19); - await startedApproval(executionId); + const runId = idOf(19); + await startedApproval(runId); const detail = 'The workflows of this database run in another server'; - const elsewhere = defineSendExecutionEvent({ deliver: () => Effect.fail(new HostElsewhere({ detail })) }); + const elsewhere = defineSendRunEvent({ deliver: () => Effect.fail(new HostElsewhere({ detail })) }); - expect(await brain.call(elsewhere, { execution_id: executionId, event: { type: 'x' } })).toEqual({ + expect(await brain.call(elsewhere, { run_id: runId, event: { type: 'x' } })).toEqual({ status: 'rejected', reason: 'unavailable', detail, @@ -159,7 +159,7 @@ describe('send_execution_event that cannot be delivered', () => { it('is rejected as invalid input when its data is larger than an event carries', async () => { expect( - await brain.call(sendEvent, { execution_id: idOf(7), event: { type: 'x', data: 'x'.repeat(262_200) } }), + await brain.call(sendEvent, { run_id: idOf(7), event: { type: 'x', data: 'x'.repeat(262_200) } }), ).toMatchObject({ status: 'rejected', reason: 'invalid_input' }); }); }); @@ -168,7 +168,7 @@ describe('an event that does not fit', () => { it.each(tooLong)( 'is rejected as invalid input when %s is longer than an event carries', async (_, event, pointer) => { - expect(await brain.call(sendEvent, { execution_id: idOf(8), event })).toMatchObject({ + expect(await brain.call(sendEvent, { run_id: idOf(8), event })).toMatchObject({ status: 'rejected', reason: 'invalid_input', issues: [expect.objectContaining({ pointer })], @@ -184,7 +184,7 @@ describe('an event that does not fit', () => { data: 'd'.repeat(260_000), }; - expect(await brain.call(sendEvent, { execution_id: idOf(9), event })).toMatchObject({ + expect(await brain.call(sendEvent, { run_id: idOf(9), event })).toMatchObject({ status: 'rejected', reason: 'invalid_input', issues: [expect.objectContaining({ pointer: '/event' })], @@ -194,12 +194,12 @@ describe('an event that does not fit', () => { describe('an event that poses as what the brain records itself', () => { it('is rejected as invalid input, as one published to the brain, and leaves the run waiting', async () => { - const executionId = idOf(22); - await startedApproval(executionId); + const runId = idOf(22); + await startedApproval(runId); const forged = await brain.call(sendEvent, { - execution_id: executionId, - event: { type: 'execution_succeeded', source: `/executions/${executionId}`, data: { approved: true } }, + run_id: runId, + event: { type: 'run_succeeded', source: `/runs/${runId}`, data: { approved: true } }, }); expect(forged).toMatchObject({ @@ -207,18 +207,18 @@ describe('an event that poses as what the brain records itself', () => { reason: 'invalid_input', issues: [{ pointer: '/event/type' }, { pointer: '/event/source' }], }); - expect(await brain.call(brain.getExecution, { execution_id: executionId })).toMatchObject({ + expect(await brain.call(brain.getRun, { run_id: runId })).toMatchObject({ output: { status: 'started' }, }); }); it('is rejected when it claims the lineage the brain gives its own records', async () => { - const executionId = idOf(23); - await startedApproval(executionId); + const runId = idOf(23); + await startedApproval(runId); const forged = await brain.call(sendEvent, { - execution_id: executionId, - event: { type: 'com.acme.approved', causationid: 'request-1', correlationid: executionId }, + run_id: runId, + event: { type: 'com.acme.approved', causationid: 'request-1', correlationid: runId }, }); expect(forged).toMatchObject({ @@ -231,22 +231,20 @@ describe('an event that poses as what the brain records itself', () => { describe('the data of an event', () => { it('may nest as deep as a workflow holds the whole event in a list, which it takes', async () => { - const executionId = idOf(20); - await brain.call(brain.executeSpec, { primitive: 'orchestration', name: 'envelope', execution_id: executionId }); + const runId = idOf(20); + await brain.call(brain.runDefinition, { type: 'workflow', name: 'envelope', run_id: runId }); const sent = await brain.call(sendEvent, { - execution_id: executionId, + run_id: runId, event: { type: 'com.acme.approval.decided', data: nested(510) }, }); expect(sent).toMatchObject({ status: 'succeeded' }); - expect(await brain.settled(executionId)).toMatchObject({ output: { status: 'succeeded' } }); + expect(await brain.settled(runId)).toMatchObject({ output: { status: 'succeeded' } }); }); it('is rejected as invalid input deeper than that, as the data of an event published to the brain', async () => { - expect( - await brain.call(sendEvent, { execution_id: idOf(21), event: { type: 'x', data: nested(511) } }), - ).toMatchObject({ + expect(await brain.call(sendEvent, { run_id: idOf(21), event: { type: 'x', data: nested(511) } })).toMatchObject({ status: 'rejected', reason: 'invalid_input', issues: [ @@ -260,8 +258,8 @@ describe('the data of an event', () => { }); }); -describe('the plain language of send_execution_event', () => { - const executionId = '0199a3c4-7d2e-7c1a-9b3f-2f1e0d9c8b7a'; +describe('the plain language of send_run_event', () => { + const runId = '0199a3c4-7d2e-7c1a-9b3f-2f1e0d9c8b7a'; it('says which event reached the running workflow', () => { const delivered = { @@ -273,8 +271,8 @@ describe('the plain language of send_execution_event', () => { expect( sendEvent.registration.plainLanguage?.outcome( - { execution_id: executionId, event: delivered }, - { execution_id: executionId, event: { type: delivered.type } }, + { run_id: runId, event: delivered }, + { run_id: runId, event: { type: delivered.type } }, ), ).toBe( 'Delivered the event “com.acme.approval.decided” to the running workflow. The workflow uses it as soon as it is waiting for it.', @@ -283,7 +281,7 @@ describe('the plain language of send_execution_event', () => { it('names the event it tried to send, or what it tried', () => { expect([ - sendEvent.registration.plainLanguage?.attempt({ execution_id: executionId, event: { type: 'com.acme.ping' } }), + sendEvent.registration.plainLanguage?.attempt({ run_id: runId, event: { type: 'com.acme.ping' } }), sendEvent.registration.plainLanguage?.attempt({}), ]).toEqual(['send the event “com.acme.ping” to a running workflow', 'send an event to a running workflow']); }); diff --git a/primitives/orchestration/src/events/send-execution-event.ts b/capabilities/coordination/src/events/send-run-event.ts similarity index 84% rename from primitives/orchestration/src/events/send-execution-event.ts rename to capabilities/coordination/src/events/send-run-event.ts index f5e9c54aa..297316638 100644 --- a/primitives/orchestration/src/events/send-execution-event.ts +++ b/capabilities/coordination/src/events/send-run-event.ts @@ -1,15 +1,15 @@ import { randomUUID } from 'node:crypto'; -import { BrainContext, Caller, defineCommand, NotFound, quoted } from '@beonauto/operations'; import { callerSourcePrefix, EventSourceSchema, - getExecution, + getRun, isWorkflowRun, refusingBlankText, refusingForbiddenCharacters, refusingTheBrainsOwnAttributes, -} from '@beonauto/specs'; +} from '@beonauto/definitions'; +import { BrainContext, Caller, defineCommand, NotFound, quoted } from '@beonauto/operations'; import { jsonBytesOf, measureOf, mostValueDepth } from '@beonauto/workflow-engine'; import type { WorkflowHost } from '@beonauto/workflow-host'; import { DateTime, Effect, Schema, SchemaTransformation } from 'effect'; @@ -28,7 +28,7 @@ const noRunningWorkflow = 'The brain has no active workflow run with that id'; const notNow = 'The workflow cannot take the event now; try again shortly'; -const ExecutionIdField = Schema.String.annotate({ +const RunIdInputField = Schema.String.annotate({ description: 'The id of the workflow run, a UUID in any case, kept in lowercase', }) .check(Schema.isUUID()) @@ -91,27 +91,27 @@ const DeliveredEventSchema = Schema.Struct({ const description = [ 'Gives an event to one workflow run that is still going, for a step that listens for it, such as an approval, and returns the event with its id.', 'Use it when the person answers what a run waits for; it does not start a run, and publish_event gives an event to the brain as a whole.', - "`execution_id` is the workflow run's id and `event` has a `type` and an optional `source`, `subject`, `data` and `id`;", + "`run_id` is the workflow run's id and `event` has a `type` and an optional `source`, `subject`, `data` and `id`;", 'an event whose id the run already received is ignored, so a call can be retried with its id.', 'An event no step takes yet waits in the run.', "The brain's own types and sources, and the lineage attributes it gives its own records, are refused.", ].join(' '); -export function defineSendExecutionEvent(runs: Pick) { +export function defineSendRunEvent(runs: Pick) { return defineCommand('brain', { - name: 'send_execution_event', + name: 'send_run_event', title: 'Send event to workflow run', description, - route: { method: 'POST', path: '/executions/{execution_id}/events' }, - inputSchema: Schema.Struct({ execution_id: ExecutionIdField, event: EventSchema }), + route: { method: 'POST', path: '/runs/{run_id}/events' }, + inputSchema: Schema.Struct({ run_id: RunIdInputField, event: EventSchema }), outputSchema: Schema.Struct({ - execution_id: Schema.String.annotate({ description: 'The id of the workflow run' }), + run_id: Schema.String.annotate({ description: 'The id of the workflow run' }), event: DeliveredEventSchema, }), reasons: ['not_found', 'unavailable'], - handle: Effect.fnUntraced(function* ({ execution_id: executionId, event }) { - const execution = yield* getExecution.call({ execution_id: executionId }); - if (!isWorkflowRun(execution) || execution.status !== 'started') { + handle: Effect.fnUntraced(function* ({ run_id: runId, event }) { + const run = yield* getRun.call({ run_id: runId }); + if (!isWorkflowRun(run) || run.status !== 'started') { return yield* new NotFound({ detail: noRunningWorkflow }); } const { org, brain } = yield* BrainContext; @@ -123,12 +123,12 @@ export function defineSendExecutionEvent(runs: Pick) { time: DateTime.formatIso(yield* DateTime.now), }; const answer = yield* runs - .deliver({ org, brain, executionId }, delivered) + .deliver({ org, brain, runId }, delivered) .pipe(Effect.mapError(unavailableUnless(notNow))); if (answer !== 'delivered') { return yield* new NotFound({ detail: noRunningWorkflow }); } - return { execution_id: executionId, event: delivered }; + return { run_id: runId, event: delivered }; }), plainLanguage: { task: 'send an event to a running workflow', diff --git a/primitives/orchestration/src/events/sent-event-text.test.ts b/capabilities/coordination/src/events/sent-event-text.test.ts similarity index 87% rename from primitives/orchestration/src/events/sent-event-text.test.ts rename to capabilities/coordination/src/events/sent-event-text.test.ts index ee938f855..5bf5ed4ac 100644 --- a/primitives/orchestration/src/events/sent-event-text.test.ts +++ b/capabilities/coordination/src/events/sent-event-text.test.ts @@ -1,16 +1,16 @@ +import { echo } from '@beonauto/definitions/testing'; import { memoryLedger } from '@beonauto/operations/testing'; -import { echo } from '@beonauto/specs/testing'; import { Effect, Schema } from 'effect'; import { describe, expect, it } from 'vitest'; import { brainOn } from '../testing/brain.ts'; -import { defineSendExecutionEvent } from './send-execution-event.ts'; +import { defineSendRunEvent } from './send-run-event.ts'; const brain = brainOn(memoryLedger(), [echo]); -const sendEvent = defineSendExecutionEvent({ deliver: () => Effect.die('An event was delivered') }); +const sendEvent = defineSendRunEvent({ deliver: () => Effect.die('An event was delivered') }); -const executionId = '0199a3c4-7d2e-7c1a-9b3f-2f1e0d9c8b7a'; +const runId = '0199a3c4-7d2e-7c1a-9b3f-2f1e0d9c8b7a'; const unspeakable = ['\u0000', '\u001F', '\u007F', '\u009F', '\uD800', '\uDC00', '\uFFFE', '\uFDD0']; @@ -21,7 +21,7 @@ function pointersOf(outcome: unknown): readonly string[] { } function sending(event: object) { - return brain.call(sendEvent, { execution_id: executionId, event }); + return brain.call(sendEvent, { run_id: runId, event }); } describe('the text of an event sent to a run', () => { diff --git a/primitives/orchestration/src/index.ts b/capabilities/coordination/src/index.ts similarity index 74% rename from primitives/orchestration/src/index.ts rename to capabilities/coordination/src/index.ts index d566ccd3d..d7db34aa4 100644 --- a/primitives/orchestration/src/index.ts +++ b/capabilities/coordination/src/index.ts @@ -1,9 +1,9 @@ export { callResultOfEnding, definitionCalls } from './calls/function-calls.ts'; export type { RunDefinition, DefinitionRunRequest, DefinitionRunResult } from './calls/function-run.ts'; export { definitionRunResultOf } from './calls/function-results.ts'; -export { defineSendExecutionEvent } from './events/send-execution-event.ts'; +export { defineSendRunEvent } from './events/send-run-event.ts'; export { runPresenter } from './presenting/run-presenter.ts'; -export { makeWorkflowAdapter, type WorkflowAdapterDependencies } from './primitive/workflow.ts'; +export { makeWorkflowAdapter, type WorkflowAdapterDependencies } from './capability/workflow.ts'; export type { WorkflowDefinitionDocument } from './document/workflow-document.ts'; export { callMarginMs } from './runs/call-limits.ts'; -export { orchestrationMachine } from './runs/orchestration-machine.ts'; +export { workflowMachineOptions } from './runs/workflow-machine-options.ts'; diff --git a/primitives/orchestration/src/input-logs/input-log-corpus.test.ts b/capabilities/coordination/src/input-logs/input-log-corpus.test.ts similarity index 94% rename from primitives/orchestration/src/input-logs/input-log-corpus.test.ts rename to capabilities/coordination/src/input-logs/input-log-corpus.test.ts index 1f67a8aa4..d812a32a8 100644 --- a/primitives/orchestration/src/input-logs/input-log-corpus.test.ts +++ b/capabilities/coordination/src/input-logs/input-log-corpus.test.ts @@ -11,7 +11,7 @@ const log = { inputs: [ { kind: 'cancel_requested', - executionId: '0199a3c4-7d2e-7c1a-9b3f-000000000300', + runId: '0199a3c4-7d2e-7c1a-9b3f-000000000300', at: 1, cancel: { by: 'tester', kind: 'requested', reason: 'The test cancelled the run' }, }, diff --git a/primitives/orchestration/src/input-logs/input-log-corpus.ts b/capabilities/coordination/src/input-logs/input-log-corpus.ts similarity index 86% rename from primitives/orchestration/src/input-logs/input-log-corpus.ts rename to capabilities/coordination/src/input-logs/input-log-corpus.ts index 7689ec0e8..39c36181f 100644 --- a/primitives/orchestration/src/input-logs/input-log-corpus.ts +++ b/capabilities/coordination/src/input-logs/input-log-corpus.ts @@ -1,13 +1,13 @@ import { mkdirSync, readdirSync, readFileSync, writeFileSync } from 'node:fs'; import { fileURLToPath } from 'node:url'; -import { RunEventSchema, RunInputSchema, type RunEvent, type RunInput } from '@beonauto/workflow-engine'; +import { RunLogEventSchema, RunInputSchema, type RunLogEvent, type RunInput } from '@beonauto/workflow-engine'; import { Schema } from 'effect'; export interface InputLog { readonly name: string; readonly inputs: readonly RunInput[]; - readonly events: readonly RunEvent[]; + readonly events: readonly RunLogEvent[]; } export type Environment = Readonly>; @@ -15,7 +15,7 @@ export type Environment = Readonly>; const InputLogSchema = Schema.Struct({ name: Schema.String, inputs: Schema.Array(RunInputSchema), - events: Schema.Array(RunEventSchema), + events: Schema.Array(RunLogEventSchema), }); const InputLogTextSchema = Schema.fromJsonString(Schema.toCodecJson(InputLogSchema)); diff --git a/primitives/orchestration/src/input-logs/input-log-paths.test.ts b/capabilities/coordination/src/input-logs/input-log-paths.test.ts similarity index 54% rename from primitives/orchestration/src/input-logs/input-log-paths.test.ts rename to capabilities/coordination/src/input-logs/input-log-paths.test.ts index fc847decb..912c0054b 100644 --- a/primitives/orchestration/src/input-logs/input-log-paths.test.ts +++ b/capabilities/coordination/src/input-logs/input-log-paths.test.ts @@ -6,13 +6,15 @@ import { inputLogOf, responderOf } from './input-log-paths.ts'; const answered: CallResult = { status: 'succeeded', output: 'answered' }; describe('the paths of the input logs', () => { - it('answer a call whose arguments name no spec as the executor does, rejecting its arguments', () => { + it('answer a call whose arguments name no definition as the executor does, rejecting its arguments', () => { const respond = responderOf(() => answered); - const key = { executionId: '0199a3c4-7d2e-7c1a-9b3f-000000000300', reference: '/do/0/ask', run: 1 }; + const key = { runId: '0199a3c4-7d2e-7c1a-9b3f-000000000300', reference: '/do/0/ask', run: 1 }; - expect(respond({ kind: 'start_call', key, function: 'execute_spec', arguments: {}, longestMs: 1 })).toMatchObject({ - result: { status: 'rejected', reason: 'invalid_arguments' }, - }); + expect(respond({ kind: 'start_call', key, function: 'run_definition', arguments: {}, longestMs: 1 })).toMatchObject( + { + result: { status: 'rejected', reason: 'invalid_arguments' }, + }, + ); }); it('have no path of a name they do not know', () => { diff --git a/primitives/orchestration/src/input-logs/input-log-paths.ts b/capabilities/coordination/src/input-logs/input-log-paths.ts similarity index 76% rename from primitives/orchestration/src/input-logs/input-log-paths.ts rename to capabilities/coordination/src/input-logs/input-log-paths.ts index f5f1aa735..31c781088 100644 --- a/primitives/orchestration/src/input-logs/input-log-paths.ts +++ b/capabilities/coordination/src/input-logs/input-log-paths.ts @@ -2,12 +2,16 @@ import type { CallResult } from '@beonauto/operations'; import type { JsonObject, RunOutcome } from '@beonauto/workflow-engine'; import { memoryDriver, type MemoryDriver, type Responder } from '@beonauto/workflow-engine/testing'; -import { isArgumentsProblem, specArgumentsOf, type SpecArguments } from '../document/spec-arguments.ts'; -import { orchestrationMachine } from '../runs/orchestration-machine.ts'; +import { + isArgumentsProblem, + definitionArgumentsOf, + type DefinitionArguments, +} from '../document/definition-arguments.ts'; +import { workflowMachineOptions } from '../runs/workflow-machine-options.ts'; import { workflow } from '../testing/workflows.ts'; import type { InputLog } from './input-log-corpus.ts'; -type Answer = (spec: SpecArguments, run: number) => CallResult; +type Answer = (definition: DefinitionArguments, run: number) => CallResult; export interface InputLogPath { readonly name: string; @@ -15,31 +19,31 @@ export interface InputLogPath { readonly ends: RunOutcome; readonly input?: JsonObject; readonly answer?: Answer; - readonly meanwhile?: (driver: MemoryDriver, executionId: string) => void; + readonly meanwhile?: (driver: MemoryDriver, runId: string) => void; } -function summarizing({ name, input }: SpecArguments): CallResult { +function summarizing({ name, input }: DefinitionArguments): CallResult { return { status: 'succeeded', output: { summary: `${name} of ${JSON.stringify(input)}` } }; } -function flakyTwice(_spec: SpecArguments, run: number): CallResult { +function flakyTwice(_definition: DefinitionArguments, run: number): CallResult { return run < 3 ? { status: 'rejected', reason: 'unavailable', detail: `busy on call ${run}` } : { status: 'succeeded', output: { calls: run } }; } function delivering(...events: readonly { readonly id: string; readonly type: string; readonly data: string }[]) { - return (driver: MemoryDriver, executionId: string): void => { + return (driver: MemoryDriver, runId: string): void => { for (const event of events) { - driver.deliver(executionId, event); + driver.deliver(runId, event); } }; } function cancellingAfter(milliseconds: number) { - return (driver: MemoryDriver, executionId: string): void => { + return (driver: MemoryDriver, runId: string): void => { driver.at(milliseconds, () => { - driver.cancel(executionId); + driver.cancel(runId); }); }; } @@ -66,12 +70,12 @@ do: ends: { kind: 'completed', output: { recovered: true } }, }, { - name: 'execute-spec', + name: 'run-definition', source: ` do: - summarize: - call: execute_spec - with: { primitive: inference, name: summarize, input: { text: '\${ .text }' } } + call: run_definition + with: { type: reasoning, name: summarize, input: { text: '\${ .text }' } } - answer: set: { summary: '\${ .summary }', runtime: '\${ $runtime.name }' } `, @@ -117,8 +121,8 @@ do: - both: fork: branches: - - left: { call: execute_spec, with: { primitive: inference, name: left } } - - right: { call: execute_spec, with: { primitive: inference, name: right } } + - left: { call: run_definition, with: { type: reasoning, name: left } } + - right: { call: run_definition, with: { type: reasoning, name: right } } `, ends: { kind: 'completed', output: [{ summary: 'left of {}' }, { summary: 'right of {}' }] }, answer: summarizing, @@ -129,7 +133,7 @@ do: do: - guarded: try: - - fetch: { call: execute_spec, with: { primitive: inference, name: flaky, input: { key: backoff } } } + - fetch: { call: run_definition, with: { type: reasoning, name: flaky, input: { key: backoff } } } catch: errors: { with: { status: 503 } } retry: { delay: { milliseconds: 500 }, backoff: { exponential: {} }, limit: { attempt: { count: 3 } } } @@ -216,11 +220,11 @@ do: export function responderOf(answer: Answer): Responder { return (call) => { - const spec = specArgumentsOf(call.arguments); - if (isArgumentsProblem(spec)) { - return { result: { status: 'rejected', reason: 'invalid_arguments', detail: spec.title } }; + const definition = definitionArgumentsOf(call.arguments); + if (isArgumentsProblem(definition)) { + return { result: { status: 'rejected', reason: 'invalid_arguments', detail: definition.title } }; } - return { after: 50, result: answer(spec, call.key.run) }; + return { after: 50, result: answer(definition, call.key.run) }; }; } @@ -230,14 +234,14 @@ export function inputLogOf(name: string): InputLog { if (path === undefined) { throw new Error(`No input log path is named ${name}`); } - const executionId = `0199a3c4-7d2e-7c1a-9b3f-${String(300 + number).padStart(12, '0')}`; - const driver = memoryDriver({ machine: orchestrationMachine, respond: responderOf(path.answer ?? summarizing) }); - driver.start({ executionId, document: workflow(path.source), input: path.input ?? {} }); - path.meanwhile?.(driver, executionId); - driver.runUntilEnded(executionId); + const runId = `0199a3c4-7d2e-7c1a-9b3f-${String(300 + number).padStart(12, '0')}`; + const driver = memoryDriver({ machine: workflowMachineOptions, respond: responderOf(path.answer ?? summarizing) }); + driver.start({ runId, document: workflow(path.source), input: path.input ?? {} }); + path.meanwhile?.(driver, runId); + driver.runUntilEnded(runId); return { name: path.name, - inputs: driver.inputsOf(executionId), - events: driver.ports.runStore.events(executionId).map(({ event }) => event), + inputs: driver.inputsOf(runId), + events: driver.ports.runStore.events(runId).map(({ event }) => event), }; } diff --git a/primitives/orchestration/src/input-logs/input-logs.test.ts b/capabilities/coordination/src/input-logs/input-logs.test.ts similarity index 84% rename from primitives/orchestration/src/input-logs/input-logs.test.ts rename to capabilities/coordination/src/input-logs/input-logs.test.ts index 45e9ba409..09a17be4b 100644 --- a/primitives/orchestration/src/input-logs/input-logs.test.ts +++ b/capabilities/coordination/src/input-logs/input-logs.test.ts @@ -3,7 +3,7 @@ import { isRecordedStep, newRun, workflowMachine, - type RunEvent, + type RunLogEvent, type RunState, type Step, type StepCause, @@ -11,14 +11,14 @@ import { import { Result } from 'effect'; import { describe, expect, it } from 'vitest'; -import { orchestrationMachine } from '../runs/orchestration-machine.ts'; +import { workflowMachineOptions } from '../runs/workflow-machine-options.ts'; import { recordedInputLogs, recordInputLog, type InputLog } from './input-log-corpus.ts'; import { inputLogOf, inputLogPaths } from './input-log-paths.ts'; -const machine = workflowMachine(orchestrationMachine); +const machine = workflowMachine(workflowMachineOptions); -function replayed({ inputs }: InputLog): readonly RunEvent[] { - const events: RunEvent[] = []; +function replayed({ inputs }: InputLog): readonly RunLogEvent[] { + const events: RunLogEvent[] = []; let state = newRun; for (const input of inputs) { for (const event of Result.getOrThrow(machine.decide(input, state))) { @@ -33,7 +33,7 @@ const committed = new Map(recordedInputLogs().map((log) => [log.name, log])); const endings = new Map(inputLogPaths.map(({ name, ends }) => [name, ends])); -function endedFrom(events: readonly RunEvent[]): RunState { +function endedFrom(events: readonly RunLogEvent[]): RunState { return events.reduce((state, event) => evolveRun(state, event), newRun); } @@ -47,7 +47,7 @@ function isSameEntry(step: Step, cause: StepCause): boolean { ); } -function causesNotRecordedBefore(events: readonly RunEvent[]): readonly StepCause[] { +function causesNotRecordedBefore(events: readonly RunLogEvent[]): readonly StepCause[] { const entries = events.flatMap(({ steps }) => steps.filter((step) => isRecordedStep(step))); return entries.flatMap(({ caused_by: cause }, index) => cause === 'input' || entries.slice(0, index).some((step) => isSameEntry(step, cause)) ? [] : [cause], diff --git a/primitives/orchestration/src/presenting/cancelled-runs.test.ts b/capabilities/coordination/src/presenting/cancelled-runs.test.ts similarity index 78% rename from primitives/orchestration/src/presenting/cancelled-runs.test.ts rename to capabilities/coordination/src/presenting/cancelled-runs.test.ts index fa887e51f..8b6c63cf3 100644 --- a/primitives/orchestration/src/presenting/cancelled-runs.test.ts +++ b/capabilities/coordination/src/presenting/cancelled-runs.test.ts @@ -1,29 +1,29 @@ import { internalTermsIn } from '@beonauto/api/testing'; import { presentationOf, type RecordedEvent } from '@beonauto/operations'; -import { RunEventSchema, callKeyText, type InputReceipt, type RunEvent } from '@beonauto/workflow-engine'; +import { RunLogEventSchema, callKeyText, type InputReceipt, type RunLogEvent } from '@beonauto/workflow-engine'; import { Schema } from 'effect'; import { describe, expect, it } from 'vitest'; import { runPresenter } from './run-presenter.ts'; -const executionId = '0199a3c4-7d2e-7c1a-9b3f-2f1e0d9c8b7a'; +const runId = '0199a3c4-7d2e-7c1a-9b3f-2f1e0d9c8b7a'; -const runId = `acme/alpha/${executionId}`; +const runKey = `acme/alpha/${runId}`; const at = Date.parse('2026-10-05T09:00:00.000Z'); -const encodeEvent = Schema.encodeSync(Schema.toCodecJson(RunEventSchema)); +const encodeEvent = Schema.encodeSync(Schema.toCodecJson(RunLogEventSchema)); const presentation = presentationOf([runPresenter]); function presented(receipt: InputReceipt) { - const event: RunEvent = { type: 'input_applied', format: 6, receipt, steps: [], patch: [], outputs: [] }; + const event: RunLogEvent = { type: 'input_applied', format: 6, receipt, steps: [], patch: [], outputs: [] }; const record: RecordedEvent = { id: '0b1c2d3e-4f50-5a6b-8c7d-8e9fa0b1c2d3', cursor: 'WyJicmFpbi9hY21lL2FscGhhLyIsIjEiXQ', causationId: '5d0e9f6a-1b2c-5d3e-8f4a-6b7c8d9e0f1a', - correlationId: executionId, - stream: `runs/${executionId}`, + correlationId: runId, + stream: `run-logs/${runId}`, version: 1, type: event.type, data: encodeEvent(event), @@ -32,13 +32,13 @@ function presented(receipt: InputReceipt) { return presentation.present(record).at(0); } -const callKey = callKeyText({ executionId: runId, reference: '/do/0/review', run: 1 }); +const callKey = callKeyText({ runId: runKey, reference: '/do/0/review', run: 1 }); describe('a cancel a workflow took, in the history of its run', () => { it('names who cancelled it and why, in plain words and in its data', () => { const cancelled = presented({ kind: 'cancel_requested', - key: runId, + key: runKey, at, cancel: { by: 'acme-admin', kind: 'requested' }, }); @@ -47,7 +47,7 @@ describe('a cancel a workflow took, in the history of its run', () => { 'acme-admin cancelled the workflow. It was cancelled at the request of someone allowed to change the brain.', ); expect(cancelled?.data).toMatchObject({ - input: { kind: 'cancel_requested', key: executionId, cancel: { by: 'acme-admin', kind: 'requested' } }, + input: { kind: 'cancel_requested', key: runId, cancel: { by: 'acme-admin', kind: 'requested' } }, }); expect(internalTermsIn(String(cancelled?.summary))).toEqual([]); }); diff --git a/primitives/orchestration/src/presenting/cut-text.ts b/capabilities/coordination/src/presenting/cut-text.ts similarity index 100% rename from primitives/orchestration/src/presenting/cut-text.ts rename to capabilities/coordination/src/presenting/cut-text.ts diff --git a/primitives/orchestration/src/presenting/run-presenter.test.ts b/capabilities/coordination/src/presenting/run-presenter.test.ts similarity index 86% rename from primitives/orchestration/src/presenting/run-presenter.test.ts rename to capabilities/coordination/src/presenting/run-presenter.test.ts index 6e1d1306a..8c0fe59d5 100644 --- a/primitives/orchestration/src/presenting/run-presenter.test.ts +++ b/capabilities/coordination/src/presenting/run-presenter.test.ts @@ -1,4 +1,5 @@ import { internalTermsIn } from '@beonauto/api/testing'; +import { reservedEventTypes } from '@beonauto/definitions'; import { PublicEventSchema, cursorWithin, @@ -6,13 +7,12 @@ import { presentationOf, type RecordedEvent, } from '@beonauto/operations'; -import { reservedEventTypes } from '@beonauto/specs'; import { - RunEventSchema, + RunLogEventSchema, callKeyText, mostEventBytes, type InputReceipt, - type RunEvent, + type RunLogEvent, type RunOutput, } from '@beonauto/workflow-engine'; import { Result, Schema } from 'effect'; @@ -21,13 +21,13 @@ import { describe, expect, it } from 'vitest'; import { cutAtCodePoint } from './cut-text.ts'; import { runPresenter } from './run-presenter.ts'; -const executionId = '0199a3c4-7d2e-7c1a-9b3f-2f1e0d9c8b7a'; +const runId = '0199a3c4-7d2e-7c1a-9b3f-2f1e0d9c8b7a'; -const runId = `acme/alpha/${executionId}`; +const runKey = `acme/alpha/${runId}`; const at = Date.parse('2026-10-05T09:00:00.000Z'); -const encodeEvent = Schema.encodeSync(Schema.toCodecJson(RunEventSchema)); +const encodeEvent = Schema.encodeSync(Schema.toCodecJson(RunLogEventSchema)); const decodePublicEvent = Schema.decodeUnknownResult(PublicEventSchema); @@ -39,26 +39,26 @@ function present(record: RecordedEvent) { const utf8 = new TextEncoder(); -function stepsOf(count: number, reference = '/do/0/notify'): RunEvent['steps'] { +function stepsOf(count: number, reference = '/do/0/notify'): RunLogEvent['steps'] { return Array.from({ length: count }, (_, index) => ({ reference, run: index + 1, outcome: 'completed' })); } function eventOf( receipt: InputReceipt, - steps: RunEvent['steps'] = [], + steps: RunLogEvent['steps'] = [], outputs: readonly RunOutput[] = [], - patch: RunEvent['patch'] = [], -): RunEvent { + patch: RunLogEvent['patch'] = [], +): RunLogEvent { return { type: 'input_applied', format: 3, receipt, steps, patch, outputs }; } -function recordOf(event: RunEvent): RecordedEvent { +function recordOf(event: RunLogEvent): RecordedEvent { return { id: '0b1c2d3e-4f50-5a6b-8c7d-8e9fa0b1c2d3', cursor: 'WyJicmFpbi9hY21lL2FscGhhLyIsIjEiXQ', causationId: '5d0e9f6a-1b2c-5d3e-8f4a-6b7c8d9e0f1a', - correlationId: executionId, - stream: `runs/${executionId}`, + correlationId: runId, + stream: `run-logs/${runId}`, version: 1, type: event.type, data: encodeEvent(event), @@ -66,15 +66,15 @@ function recordOf(event: RunEvent): RecordedEvent { }; } -const settled: RunOutput = { kind: 'settle', executionId: runId, settlement: { status: 'succeeded', output: 1 } }; +const settled: RunOutput = { kind: 'settle', runId: runKey, settlement: { status: 'succeeded', output: 1 } }; -const armed: RunOutput = { kind: 'arm_timer', executionId: runId, timerId: '2', dueAt: at + 60_000, purpose: 'wait' }; +const armed: RunOutput = { kind: 'arm_timer', runId: runKey, timerId: '2', dueAt: at + 60_000, purpose: 'wait' }; -const callKey = { executionId: runId, reference: '/do/0/notify', run: 1 }; +const callKey = { runId: runKey, reference: '/do/0/notify', run: 1 }; describe('an input a workflow took, in the history of its run', () => { it('is one public event with the kind and key of the input, its steps, and the kinds of output it made', () => { - const event = eventOf({ kind: 'started', key: runId, at }, stepsOf(1), [armed, armed]); + const event = eventOf({ kind: 'started', key: runKey, at }, stepsOf(1), [armed, armed]); expect(present(recordOf(event))).toEqual({ id: '0b1c2d3e-4f50-5a6b-8c7d-8e9fa0b1c2d3', @@ -84,8 +84,8 @@ describe('an input a workflow took, in the history of its run', () => { type: 'workflow_input_applied', summary: 'The workflow started, and 1 step moved.', data: { - execution_id: executionId, - input: { kind: 'started', key: executionId }, + run_id: runId, + input: { kind: 'started', key: runId }, step_count: 1, steps: [{ task: '/do/0/notify', run: 1, outcome: 'completed' }], output_kinds: ['arm_timer'], @@ -106,7 +106,7 @@ describe('an input a workflow took, in the history of its run', () => { describe('the words of an input a workflow took', () => { it.each([ - [eventOf({ kind: 'started', key: runId, at }), 'The workflow started.'], + [eventOf({ kind: 'started', key: runKey, at }), 'The workflow started.'], [ eventOf({ kind: 'timer_fired', key: '2', at }, stepsOf(2)), 'A timer of the workflow went off, and 2 steps moved.', @@ -124,7 +124,7 @@ describe('the words of an input a workflow took', () => { 'The workflow received an event.', ], [ - eventOf({ kind: 'cancel_requested', key: runId, at }, [], [settled]), + eventOf({ kind: 'cancel_requested', key: runKey, at }, [], [settled]), 'The workflow was asked to stop; the workflow ended.', ], ])('says what happened in plain words: %#', (event, summary) => { @@ -208,10 +208,10 @@ describe('the largest input a workflow can take', () => { it('presents within the bound of public data', () => { const awkward = '\u0000'.repeat(256); const steps = stepsOf(100, `/do/0/${'😀'.repeat(1024)}`); - const patch: RunEvent['patch'] = [{ op: 'add', path: '/machine/values/9', value: 'x'.repeat(1_150_000) }]; + const patch: RunLogEvent['patch'] = [{ op: 'add', path: '/machine/values/9', value: 'x'.repeat(1_150_000) }]; const outputs: readonly RunOutput[] = [ armed, - { kind: 'cancel_timer', executionId: runId, timerId: '2' }, + { kind: 'cancel_timer', runId: runKey, timerId: '2' }, { kind: 'start_call', key: callKey, function: 'notify', arguments: {}, longestMs: 1000 }, { kind: 'cancel_call', key: callKey }, settled, @@ -228,7 +228,7 @@ describe('the largest input a workflow can take', () => { describe('the presenter of the runs of workflows', () => { it('decides on every stored type of the log of a run', () => { - expect(Object.keys(runPresenter.publicNames)).toEqual([RunEventSchema.fields.type.literal]); + expect(Object.keys(runPresenter.publicNames)).toEqual(['input_applied']); expect(runPresenter.publicNames['input_applied']).toEqual([ 'workflow_input_applied', 'step_started', diff --git a/primitives/orchestration/src/presenting/run-presenter.ts b/capabilities/coordination/src/presenting/run-presenter.ts similarity index 74% rename from primitives/orchestration/src/presenting/run-presenter.ts rename to capabilities/coordination/src/presenting/run-presenter.ts index ef1ba54a1..e7d8a8f3b 100644 --- a/primitives/orchestration/src/presenting/run-presenter.ts +++ b/capabilities/coordination/src/presenting/run-presenter.ts @@ -1,9 +1,9 @@ import { cursorWithin, type Presenter } from '@beonauto/operations'; import { - RunEventSchema, + RunLogEventSchema, type EarlierStep, type InputReceipt, - type RunEvent, + type RunLogEvent, type Step, } from '@beonauto/workflow-engine'; import { Schema } from 'effect'; @@ -18,9 +18,9 @@ const mostKeyBytes = 256; const mostReferenceBytes = 256; -const runsKind = 'runs'; +const runLogsKind = 'run-logs'; -const decodeRunEvent = Schema.decodeUnknownSync(Schema.toCodecJson(RunEventSchema)); +const decodeRunLogEvent = Schema.decodeUnknownSync(Schema.toCodecJson(RunLogEventSchema)); type Rejection = NonNullable['rejection']>; @@ -31,7 +31,7 @@ function rejectionShown({ kind, because }: Rejection): Schema.JsonObject { }; } -function inputShown(receipt: InputReceipt, executionId: string): Schema.JsonObject { +function inputShown(receipt: InputReceipt, runId: string): Schema.JsonObject { if (receipt.kind === 'call_answered') { const { rejection } = receipt; return { @@ -50,19 +50,19 @@ function inputShown(receipt: InputReceipt, executionId: string): Schema.JsonObje } if (receipt.kind === 'cancel_requested' && receipt.cancel !== undefined) { const { by, kind } = receipt.cancel; - return { kind: receipt.kind, key: executionId, cancel: { by: cutAtCodePoint(by, mostKeyBytes), kind } }; + return { kind: receipt.kind, key: runId, cancel: { by: cutAtCodePoint(by, mostKeyBytes), kind } }; } - return { kind: receipt.kind, key: receipt.kind === 'timer_fired' ? receipt.key : executionId }; + return { kind: receipt.kind, key: receipt.kind === 'timer_fired' ? receipt.key : runId }; } function stepShown({ reference, run, outcome }: Step | EarlierStep): Schema.JsonObject { return { task: cutAtCodePoint(reference, mostReferenceBytes), run, outcome }; } -function dataOf({ receipt, steps, outputs }: RunEvent, executionId: string): Schema.JsonObject { +function dataOf({ receipt, steps, outputs }: RunLogEvent, runId: string): Schema.JsonObject { return { - execution_id: executionId, - input: inputShown(receipt, executionId), + run_id: runId, + input: inputShown(receipt, runId), step_count: steps.length, steps: steps.slice(0, mostStepsShown).map((step) => stepShown(step)), output_kinds: [...new Set(outputs.map(({ kind }) => kind))], @@ -70,11 +70,11 @@ function dataOf({ receipt, steps, outputs }: RunEvent, executionId: string): Sch } export const runPresenter: Presenter = { - streamKind: runsKind, + streamKind: runLogsKind, publicNames: { input_applied: ['workflow_input_applied', ...stepEventTypes] }, present: (recorded) => { - const event = decodeRunEvent(recorded.data); - const executionId = recorded.stream.slice(runsKind.length + 1); + const event = decodeRunLogEvent(recorded.data); + const runId = recorded.stream.slice(runLogsKind.length + 1); const record = { id: recorded.id, cursor: cursorWithin(recorded.cursor, 0), @@ -82,8 +82,8 @@ export const runPresenter: Presenter = { at: new Date(event.receipt.at).toISOString(), type: 'workflow_input_applied', summary: summaryOf(event), - data: dataOf(event, executionId), + data: dataOf(event, runId), }; - return [record, ...stepEventsOf(recorded, event, executionId)]; + return [record, ...stepEventsOf(recorded, event, runId)]; }, }; diff --git a/primitives/orchestration/src/presenting/run-words.ts b/capabilities/coordination/src/presenting/run-words.ts similarity index 96% rename from primitives/orchestration/src/presenting/run-words.ts rename to capabilities/coordination/src/presenting/run-words.ts index abb35c5f1..4c1973722 100644 --- a/primitives/orchestration/src/presenting/run-words.ts +++ b/capabilities/coordination/src/presenting/run-words.ts @@ -10,7 +10,7 @@ import { explanationOf, quoted, } from '@beonauto/operations'; -import type { InputReceipt, RunEvent, Step } from '@beonauto/workflow-engine'; +import type { InputReceipt, RunLogEvent, Step } from '@beonauto/workflow-engine'; import { Option, Schema } from 'effect'; const step = { one: 'step', other: 'steps' }; @@ -80,7 +80,7 @@ function why(receipt: InputReceipt): string { return said === '' ? '' : ` ${asSentence(capitalized(said))}`; } -export function summaryOf({ receipt, steps, outputs }: RunEvent): string { +export function summaryOf({ receipt, steps, outputs }: RunLogEvent): string { const moved = steps.length === 0 ? '' : `, and ${counted(steps.length, step)} moved`; const ended = outputs.some(({ kind }) => kind === 'settle') ? '; the workflow ended' : ''; return `${happened(receipt)}${moved}${ended}.${why(receipt)}`; diff --git a/primitives/orchestration/src/presenting/step-events.test.ts b/capabilities/coordination/src/presenting/step-events.test.ts similarity index 92% rename from primitives/orchestration/src/presenting/step-events.test.ts rename to capabilities/coordination/src/presenting/step-events.test.ts index d6c6b172c..42ce3b5ae 100644 --- a/primitives/orchestration/src/presenting/step-events.test.ts +++ b/capabilities/coordination/src/presenting/step-events.test.ts @@ -6,17 +6,17 @@ import { presentationOf, type RecordedEvent, } from '@beonauto/operations'; -import { RunEventSchema, stepEventIdOf, type RunEvent, type Step } from '@beonauto/workflow-engine'; +import { RunLogEventSchema, stepEventIdOf, type RunLogEvent, type Step } from '@beonauto/workflow-engine'; import { Result, Schema } from 'effect'; import { describe, expect, it } from 'vitest'; import { runPresenter } from './run-presenter.ts'; -const executionId = '0199a3c4-7d2e-7c1a-9b3f-2f1e0d9c8b7a'; +const runId = '0199a3c4-7d2e-7c1a-9b3f-2f1e0d9c8b7a'; const at = Date.parse('2026-10-05T09:00:00.000Z'); -const encodeEvent = Schema.encodeSync(Schema.toCodecJson(RunEventSchema)); +const encodeEvent = Schema.encodeSync(Schema.toCodecJson(RunLogEventSchema)); const decodePublicEvent = Schema.decodeUnknownResult(PublicEventSchema); @@ -29,7 +29,7 @@ const cursor = 'WyJicmFpbi9hY21lL2FscGhhLyIsIjEiXQ'; const recordId = '0b1c2d3e-4f50-5a6b-8c7d-8e9fa0b1c2d3'; function recordOf(steps: readonly Step[]): RecordedEvent { - const event: RunEvent = { + const event: RunLogEvent = { type: 'input_applied', format: 4, receipt: { kind: 'call_answered', key: 'k', at, status: 'succeeded' }, @@ -42,8 +42,8 @@ function recordOf(steps: readonly Step[]): RecordedEvent { id: recordId, cursor, causationId: null, - correlationId: executionId, - stream: `runs/${executionId}`, + correlationId: runId, + stream: `run-logs/${runId}`, version: 1, type: event.type, data: encodeEvent(event), @@ -68,7 +68,7 @@ const waiting: Step = { const asked: readonly Step[] = [completed, waiting]; function idOf(step: Step): string { - return stepEventIdOf(executionId, { + return stepEventIdOf(runId, { reference: step.reference, run: step.run, outcome: step.outcome, @@ -99,7 +99,7 @@ const presentedSteps = [ run: 1, times: 1, waits_for: 'call', - execution_id: '5d0e9f6a-1b2c-5d3e-8f4a-6b7c8d9e0f1a', + run_id: '5d0e9f6a-1b2c-5d3e-8f4a-6b7c8d9e0f1a', }, }, ]; diff --git a/primitives/orchestration/src/presenting/step-events.ts b/capabilities/coordination/src/presenting/step-events.ts similarity index 85% rename from primitives/orchestration/src/presenting/step-events.ts rename to capabilities/coordination/src/presenting/step-events.ts index 347bf70e2..3d84ae010 100644 --- a/primitives/orchestration/src/presenting/step-events.ts +++ b/capabilities/coordination/src/presenting/step-events.ts @@ -1,5 +1,5 @@ import { cursorWithin, type PublicEvent, type RecordedEvent } from '@beonauto/operations'; -import { isRecordedStep, keyOf, stepEventIdOf, type RunEvent, type Step } from '@beonauto/workflow-engine'; +import { isRecordedStep, keyOf, stepEventIdOf, type RunLogEvent, type Step } from '@beonauto/workflow-engine'; import type { Schema } from 'effect'; import { cutAtCodePoint } from './cut-text.ts'; @@ -44,19 +44,19 @@ function dataOf({ name, reference, run, times, outcome, error, waits_for: waitsF run, times, ...(waitsFor === undefined ? {} : { waits_for: waitsFor }), - ...(child === undefined ? {} : { execution_id: child }), + ...(child === undefined ? {} : { run_id: child }), }; return publicTypes[outcome] === 'step_failed' ? { ...shown, outcome, ...errorShown(error) } : shown; } -export function stepEventsOf(recorded: RecordedEvent, event: RunEvent, executionId: string): readonly PublicEvent[] { +export function stepEventsOf(recorded: RecordedEvent, event: RunLogEvent, runId: string): readonly PublicEvent[] { const at = new Date(event.receipt.at).toISOString(); return event.steps .filter((step) => isRecordedStep(step)) .map((step, index) => ({ - id: stepEventIdOf(executionId, keyOf(step)), + id: stepEventIdOf(runId, keyOf(step)), cursor: cursorWithin(recorded.cursor, index + 1), - causation_id: step.caused_by === 'input' ? recorded.id : stepEventIdOf(executionId, step.caused_by), + causation_id: step.caused_by === 'input' ? recorded.id : stepEventIdOf(runId, step.caused_by), at, type: publicTypes[step.outcome], summary: stepSummaryOf(step), diff --git a/primitives/orchestration/src/runs/call-limits.test.ts b/capabilities/coordination/src/runs/call-limits.test.ts similarity index 61% rename from primitives/orchestration/src/runs/call-limits.test.ts rename to capabilities/coordination/src/runs/call-limits.test.ts index b58f34415..4bd8e95f4 100644 --- a/primitives/orchestration/src/runs/call-limits.test.ts +++ b/capabilities/coordination/src/runs/call-limits.test.ts @@ -5,25 +5,25 @@ import { workflow } from '../testing/workflows.ts'; import { callMarginMs, longestCallsOf } from './call-limits.ts'; const longestRuns: Readonly> = { - 'inference/classify': 85_000, - 'orchestration/review': 3_600_000, + 'reasoning/classify': 85_000, + 'workflow/review': 3_600_000, }; -function longestRunOf(primitive: string, name: string): Effect.Effect { - return Effect.succeed(longestRuns[`${primitive}/${name}`]); +function longestRunOf(type: string, name: string): Effect.Effect { + return Effect.succeed(longestRuns[`${type}/${name}`]); } const document = workflow(` do: - - classify: { call: execute_spec, with: { primitive: inference, name: classify } } + - classify: { call: run_definition, with: { type: reasoning, name: classify } } - each: for: { in: '\${ .items }' } do: - - review: { call: execute_spec, with: { primitive: orchestration, name: review } } - - computed: { call: execute_spec, with: { primitive: inference, name: '\${ .name }' } } - - unsaved: { call: execute_spec, with: { primitive: inference, name: summarize } } - - plain: { call: execute_spec, with: inference } - - other: { call: notify, with: { primitive: inference, name: classify } } + - review: { call: run_definition, with: { type: workflow, name: review } } + - computed: { call: run_definition, with: { type: reasoning, name: '\${ .name }' } } + - unsaved: { call: run_definition, with: { type: reasoning, name: summarize } } + - plain: { call: run_definition, with: reasoning } + - other: { call: notify, with: { type: reasoning, name: classify } } - pause: { wait: PT1M } `); diff --git a/primitives/orchestration/src/runs/call-limits.ts b/capabilities/coordination/src/runs/call-limits.ts similarity index 71% rename from primitives/orchestration/src/runs/call-limits.ts rename to capabilities/coordination/src/runs/call-limits.ts index 92d61650e..f3ece9df0 100644 --- a/primitives/orchestration/src/runs/call-limits.ts +++ b/capabilities/coordination/src/runs/call-limits.ts @@ -1,15 +1,15 @@ -import type { RunContext } from '@beonauto/specs'; +import type { RunContext } from '@beonauto/definitions'; import { allTaskEntries, field, isObject, type Json, type JsonObject } from '@beonauto/workflow-engine'; import { enclosedBody } from '@beonauto/workflow-engine/dsl'; import { Effect } from 'effect'; -import { executeSpecFunction } from '../document/workflow-functions.ts'; +import { runDefinitionFunction } from '../document/workflow-functions.ts'; export const callMarginMs = 60_000; interface CalledDefinition { readonly reference: string; - readonly primitive: string; + readonly type: string; readonly name: string; } @@ -19,11 +19,11 @@ function literalOf(value: Json | undefined): string | undefined { function calledDefinitionOf(task: JsonObject, reference: string): readonly CalledDefinition[] { const given = field(task, 'with'); - const primitive = isObject(given) ? literalOf(field(given, 'primitive')) : undefined; + const type = isObject(given) ? literalOf(field(given, 'type')) : undefined; const name = isObject(given) ? literalOf(field(given, 'name')) : undefined; - return field(task, 'call') !== executeSpecFunction || primitive === undefined || name === undefined + return field(task, 'call') !== runDefinitionFunction || type === undefined || name === undefined ? [] - : [{ reference, primitive, name }]; + : [{ reference, type, name }]; } type CallLimit = readonly [string, number]; @@ -40,8 +40,8 @@ export function longestCallsOf( calledDefinitionOf(task, reference), ); return Effect.map( - Effect.forEach(called, ({ reference, primitive, name }) => - Effect.map(longestRunOf(primitive, name), (longest) => limitOf(reference, longest)), + Effect.forEach(called, ({ reference, type, name }) => + Effect.map(longestRunOf(type, name), (longest) => limitOf(reference, longest)), ), (limits: readonly (readonly CallLimit[])[]) => Object.fromEntries(limits.flat()), ); diff --git a/primitives/orchestration/src/runs/host-refusals.ts b/capabilities/coordination/src/runs/host-refusals.ts similarity index 100% rename from primitives/orchestration/src/runs/host-refusals.ts rename to capabilities/coordination/src/runs/host-refusals.ts diff --git a/primitives/orchestration/src/runs/run-attributes.ts b/capabilities/coordination/src/runs/run-attributes.ts similarity index 84% rename from primitives/orchestration/src/runs/run-attributes.ts rename to capabilities/coordination/src/runs/run-attributes.ts index d36f8daac..af1112592 100644 --- a/primitives/orchestration/src/runs/run-attributes.ts +++ b/capabilities/coordination/src/runs/run-attributes.ts @@ -4,8 +4,8 @@ import { Schema } from 'effect'; const RunAttributesSchema = Schema.Struct({ org: Schema.String, brain: Schema.String, - execution_id: Schema.String, - spec: Schema.Struct({ name: Schema.String, version: Schema.Int }), + run_id: Schema.String, + definition: Schema.Struct({ name: Schema.String, version: Schema.Int }), caller: CallerIdentitySchema, depth: Schema.optionalKey(Schema.Int), call_depth: Schema.optionalKey(Schema.Int), diff --git a/primitives/orchestration/src/runs/orchestration-machine.ts b/capabilities/coordination/src/runs/workflow-machine-options.ts similarity index 79% rename from primitives/orchestration/src/runs/orchestration-machine.ts rename to capabilities/coordination/src/runs/workflow-machine-options.ts index 14782927b..682454985 100644 --- a/primitives/orchestration/src/runs/orchestration-machine.ts +++ b/capabilities/coordination/src/runs/workflow-machine-options.ts @@ -6,10 +6,10 @@ import { workflowFunctions } from '../document/workflow-functions.ts'; const runtimeDescriptor: JsonObject = { name: 'auto-brain', version: '1', - metadata: { primitive: 'orchestration' }, + metadata: { type: 'workflow' }, }; -export const orchestrationMachine: MachineOptions = { +export const workflowMachineOptions: MachineOptions = { functions: { ...workflowFunctions, childOf: childRunOf }, runtime: runtimeDescriptor, }; diff --git a/primitives/orchestration/src/testing/brain.ts b/capabilities/coordination/src/testing/brain.ts similarity index 69% rename from primitives/orchestration/src/testing/brain.ts rename to capabilities/coordination/src/testing/brain.ts index 623286ea6..c2c451a66 100644 --- a/primitives/orchestration/src/testing/brain.ts +++ b/capabilities/coordination/src/testing/brain.ts @@ -1,3 +1,11 @@ +import { + defineCreateDefinition, + defineRunDefinition, + runSettler, + getRun, + type Capability, + type SettleRun, +} from '@beonauto/definitions'; import { makeDispatcher, settle as settleCall, @@ -7,14 +15,6 @@ import { type Settled, } from '@beonauto/operations'; import { memoryBrainRegistry, recordingReporter, type MemoryLedger } from '@beonauto/operations/testing'; -import { - defineCreateSpec, - defineExecuteSpec, - executionSettler, - getExecution, - type Primitive, - type SettleExecution, -} from '@beonauto/specs'; import { Effect, Layer } from 'effect'; import { definitionRunResultOf } from '../calls/function-results.ts'; @@ -26,9 +26,9 @@ interface BrainOperation { } export interface Brain { - readonly createSpec: BrainOperation; - readonly executeSpec: BrainOperation; - readonly getExecution: BrainOperation; + readonly createDefinition: BrainOperation; + readonly runDefinition: BrainOperation; + readonly getRun: BrainOperation; readonly call: (operation: BrainOperation, input: object, caller?: CallerIdentity) => Promise; readonly callCancelledWhen: ( cancelled: Promise, @@ -36,18 +36,18 @@ export interface Brain { input: object, ) => Promise; readonly executeNested: RunDefinition; - readonly settle: SettleExecution; + readonly settle: SettleRun; } -export function brainOn(ledger: MemoryLedger, primitives: readonly Primitive[]): Brain { +export function brainOn(ledger: MemoryLedger, capabilities: readonly Capability[]): Brain { const services = Layer.mergeAll( ledger.layer, memoryBrainRegistry([{ org: 'acme', brain: 'alpha' }]), recordingReporter().layer, ); const dispatcher = makeDispatcher([]); - const createSpec = defineCreateSpec(primitives); - const executeSpec = defineExecuteSpec(primitives); + const createDefinition = defineCreateDefinition(capabilities); + const runDefinition = defineRunDefinition(capabilities); const dispatch = (operation: BrainOperation, input: object, caller: CallerIdentity): Effect.Effect => dispatcher .dispatchToBrain(operation.registration, { caller, org: 'acme', brain: 'alpha', input, encoding: 'json' }) @@ -55,9 +55,9 @@ export function brainOn(ledger: MemoryLedger, primitives: readonly Primitive[]): const call: Brain['call'] = (operation, input, caller = acmeCaller) => Effect.runPromise(dispatch(operation, input, caller)); return { - createSpec, - executeSpec, - getExecution, + createDefinition, + runDefinition, + getRun, call, callCancelledWhen: (cancelled, operation, input) => { const cancelling = new AbortController(); @@ -69,10 +69,8 @@ export function brainOn(ledger: MemoryLedger, primitives: readonly Primitive[]): settleCall(dispatch(operation, input, acmeCaller), cancelling.signal).pipe(Effect.provide(services)), ); }, - executeNested: ({ caller, primitive, name, input, executionId }) => - dispatch(executeSpec, { primitive, name, input, execution_id: executionId }, caller).pipe( - Effect.map(definitionRunResultOf), - ), - settle: executionSettler(ledger.service), + executeNested: ({ caller, type, name, input, runId }) => + dispatch(runDefinition, { type, name, input, run_id: runId }, caller).pipe(Effect.map(definitionRunResultOf)), + settle: runSettler(ledger.service), }; } diff --git a/primitives/orchestration/src/testing/endings.ts b/capabilities/coordination/src/testing/endings.ts similarity index 100% rename from primitives/orchestration/src/testing/endings.ts rename to capabilities/coordination/src/testing/endings.ts diff --git a/primitives/orchestration/src/testing/machine-commands.ts b/capabilities/coordination/src/testing/machine-commands.ts similarity index 65% rename from primitives/orchestration/src/testing/machine-commands.ts rename to capabilities/coordination/src/testing/machine-commands.ts index 41f53fd0a..7a9a5f4d6 100644 --- a/primitives/orchestration/src/testing/machine-commands.ts +++ b/capabilities/coordination/src/testing/machine-commands.ts @@ -2,33 +2,33 @@ import type { CallResult } from '@beonauto/operations'; import type { CallKey, Json, RunOutput } from '@beonauto/workflow-engine'; import type { Dispatched } from '@beonauto/workflow-engine/testing'; -import { failureChain, isArgumentsProblem, specArgumentsOf } from '../document/spec-arguments.ts'; +import { failureChain, isArgumentsProblem, definitionArgumentsOf } from '../document/definition-arguments.ts'; import type { Command } from './machine-host.ts'; -import type { SpecCall, SpecCallResult, WorkflowRun } from './run-terms.ts'; +import type { DefinitionCall, DefinitionCallResult, WorkflowRun } from './run-terms.ts'; -export type SpecResponder = (call: SpecCall) => SpecCallResult | Promise; +export type DefinitionResponder = (call: DefinitionCall) => DefinitionCallResult | Promise; -function specCallOf(run: WorkflowRun, arguments_: Json, key: CallKey): SpecCall | CallResult { - const spec = specArgumentsOf(arguments_); - if (isArgumentsProblem(spec)) { - return { status: 'rejected', reason: 'invalid_arguments', detail: spec.title }; +function definitionCallOf(run: WorkflowRun, arguments_: Json, key: CallKey): DefinitionCall | CallResult { + const definition = definitionArgumentsOf(arguments_); + if (isArgumentsProblem(definition)) { + return { status: 'rejected', reason: 'invalid_arguments', detail: definition.title }; } - const { org, brain } = run.execution; - return { org, brain, caller: run.caller, reference: key.reference, run: key.run, ...spec }; + const { org, brain } = run.run; + return { org, brain, caller: run.caller, reference: key.reference, run: key.run, ...definition }; } -function isSpecCall(value: SpecCall | CallResult): value is SpecCall { +function isDefinitionCall(value: DefinitionCall | CallResult): value is DefinitionCall { return 'reference' in value; } export function answerOf( run: WorkflowRun, - respond: SpecResponder, + respond: DefinitionResponder, key: CallKey, arguments_: Json, ): Promise { - const call = specCallOf(run, arguments_, key); - if (!isSpecCall(call)) { + const call = definitionCallOf(run, arguments_, key); + if (!isDefinitionCall(call)) { return Promise.resolve(call); } return Promise.resolve(call) @@ -64,8 +64,8 @@ function commandOf(run: WorkflowRun, labels: Map, { at, output } return summary === undefined ? [] : [{ kind: 'cancelled', summary }]; } if (output.kind === 'start_call') { - const call = specCallOf(run, output.arguments, output.key); - return isSpecCall(call) ? [{ kind: 'call', call }] : []; + const call = definitionCallOf(run, output.arguments, output.key); + return isDefinitionCall(call) ? [{ kind: 'call', call }] : []; } if (output.kind === 'cancel_call') { return [{ kind: 'cancelled', summary: output.key.reference }]; @@ -76,8 +76,10 @@ function commandOf(run: WorkflowRun, labels: Map, { at, output } if (output.kind !== 'settle') { return []; } - const { org, brain, id, spec } = run.execution; - return [{ kind: 'settle', request: { org, brain, spec: spec.name, executionId: id, settlement: output.settlement } }]; + const { org, brain, id, definition } = run.run; + return [ + { kind: 'settle', request: { org, brain, definition: definition.name, runId: id, settlement: output.settlement } }, + ]; } export function commandsOf(run: WorkflowRun, dispatched: readonly Dispatched[]): readonly Command[] { diff --git a/primitives/orchestration/src/testing/machine-host.ts b/capabilities/coordination/src/testing/machine-host.ts similarity index 84% rename from primitives/orchestration/src/testing/machine-host.ts rename to capabilities/coordination/src/testing/machine-host.ts index 31c84fdb5..d5ae9cf95 100644 --- a/primitives/orchestration/src/testing/machine-host.ts +++ b/capabilities/coordination/src/testing/machine-host.ts @@ -1,11 +1,11 @@ import type { JsonObject } from '@beonauto/workflow-engine'; -import type { SettleRequest, SpecCall } from './run-terms.ts'; +import type { SettleRequest, DefinitionCall } from './run-terms.ts'; export type Command = | { readonly kind: 'timer'; readonly milliseconds: number; readonly summary: string } | { readonly kind: 'deadline'; readonly milliseconds: number } - | { readonly kind: 'call'; readonly call: SpecCall } + | { readonly kind: 'call'; readonly call: DefinitionCall } | { readonly kind: 'cancelled'; readonly summary: string } | { readonly kind: 'emitted'; readonly event: JsonObject } | { readonly kind: 'settle'; readonly request: SettleRequest }; diff --git a/primitives/orchestration/src/testing/machine-workflows.ts b/capabilities/coordination/src/testing/machine-workflows.ts similarity index 70% rename from primitives/orchestration/src/testing/machine-workflows.ts rename to capabilities/coordination/src/testing/machine-workflows.ts index 2d235cc45..fcae96b4d 100644 --- a/primitives/orchestration/src/testing/machine-workflows.ts +++ b/capabilities/coordination/src/testing/machine-workflows.ts @@ -10,14 +10,14 @@ import { import { memoryDriver, type MemoryDriver } from '@beonauto/workflow-engine/testing'; import { Option, Result, Schema } from 'effect'; -import { orchestrationMachine } from '../runs/orchestration-machine.ts'; +import { workflowMachineOptions } from '../runs/workflow-machine-options.ts'; import { endingOf, type WorkflowEnding } from './endings.ts'; -import { answerOf, commandsOf, type SpecResponder } from './machine-commands.ts'; +import { answerOf, commandsOf, type DefinitionResponder } from './machine-commands.ts'; import type { Command, MachineHost, WorkflowStart } from './machine-host.ts'; import type { RunSettlement, WorkflowRun } from './run-terms.ts'; export interface MachineOptions { - readonly respond?: SpecResponder; + readonly respond?: DefinitionResponder; readonly started?: (start: WorkflowStart, host: MachineHost) => void; } @@ -28,13 +28,13 @@ export interface MachineInterpretation { readonly fake: MachineHost; } -const succeedWithNull: SpecResponder = () => ({ status: 'succeeded', output: null }); +const succeedWithNull: DefinitionResponder = () => ({ status: 'succeeded', output: null }); const receivedEventOf = Schema.decodeUnknownOption(ReceivedEventSchema); -function drivenRun(run: WorkflowRun, respond: SpecResponder): MemoryDriver { +function drivenRun(run: WorkflowRun, respond: DefinitionResponder): MemoryDriver { return memoryDriver({ - machine: orchestrationMachine, + machine: workflowMachineOptions, respond: (call) => ({ later: answerOf(run, respond, call.key, call.arguments) }), }); } @@ -43,12 +43,12 @@ function hooksOf( driver: MemoryDriver, run: WorkflowRun, ): { readonly start: WorkflowStart; readonly host: MachineHost } { - const executionId = run.execution.id; + const runId = run.run.id; const host: MachineHost = { commands: () => commandsOf(run, driver.ports.faults.dispatched()), now: driver.clock.now, cancelWorkflow: () => { - driver.cancel(executionId); + driver.cancel(runId); }, at: (milliseconds, action) => { driver.at(milliseconds, action); @@ -56,34 +56,34 @@ function hooksOf( }; const start: WorkflowStart = { deliver: (event) => { - Option.map(receivedEventOf(event), (received) => driver.deliver(executionId, received)); + Option.map(receivedEventOf(event), (received) => driver.deliver(runId, received)); }, }; return { start, host }; } export async function interpretOnMachine(run: WorkflowRun, options: MachineOptions): Promise { - const executionId = run.execution.id; + const runId = run.run.id; const driver = drivenRun(run, options.respond ?? succeedWithNull); driver.start({ - executionId, + runId, document: run.document, input: run.input, - limits: { mostDurationMs: run.mostDuration, longestCallMs: run.longestNestedExecutionMs }, - attributes: { org: run.execution.org, brain: run.execution.brain }, + limits: { mostDurationMs: run.mostDuration, longestCallMs: run.longestNestedRunMs }, + attributes: { org: run.run.org, brain: run.run.brain }, }); const { start, host } = hooksOf(driver, run); options.started?.(start, host); - const outcome = await driver.outcomeOf(executionId); + const outcome = await driver.outcomeOf(runId); return { ending: endingOf(outcome), - settlement: driver.ports.recordStore.settlementOf(executionId), + settlement: driver.ports.recordStore.settlementOf(runId), commands: host.commands(), fake: host, }; } -const machine = workflowMachine(orchestrationMachine); +const machine = workflowMachine(workflowMachineOptions); interface Decided { readonly state: RunState; @@ -98,29 +98,29 @@ function decidedOn({ state, outputs }: Decided, input: RunInput): Decided { }; } -function firedWhileDue(decided: Decided, executionId: string): Decided { +function firedWhileDue(decided: Decided, runId: string): Decided { const due = Object.entries(decided.state.timers.armed).find( ([, timer]: readonly [string, ArmedTimer]) => timer.dueAt <= 0, ); return due === undefined ? decided - : firedWhileDue(decidedOn(decided, { kind: 'timer_fired', executionId, at: 0, timerId: due[0] }), executionId); + : firedWhileDue(decidedOn(decided, { kind: 'timer_fired', runId, at: 0, timerId: due[0] }), runId); } export function outputsAtOnce(run: WorkflowRun): readonly RunOutput[] { - const executionId = run.execution.id; + const runId = run.run.id; const started = decidedOn( { state: newRun, outputs: [] }, { kind: 'started', - executionId, + runId, at: 0, document: run.document, input: run.input, - limits: { mostDurationMs: run.mostDuration, longestCallMs: run.longestNestedExecutionMs }, - attributes: { org: run.execution.org, brain: run.execution.brain }, + limits: { mostDurationMs: run.mostDuration, longestCallMs: run.longestNestedRunMs }, + attributes: { org: run.run.org, brain: run.run.brain }, seed: 1, }, ); - return firedWhileDue(started, executionId).outputs; + return firedWhileDue(started, runId).outputs; } diff --git a/primitives/orchestration/src/testing/run-terms.ts b/capabilities/coordination/src/testing/run-terms.ts similarity index 76% rename from primitives/orchestration/src/testing/run-terms.ts rename to capabilities/coordination/src/testing/run-terms.ts index 1f9908acd..65bfed25e 100644 --- a/primitives/orchestration/src/testing/run-terms.ts +++ b/capabilities/coordination/src/testing/run-terms.ts @@ -1,18 +1,18 @@ import type { CallerIdentity, Settlement } from '@beonauto/operations'; import type { Json, JsonObject } from '@beonauto/workflow-engine'; -export interface SpecCall { +export interface DefinitionCall { readonly org: string; readonly brain: string; readonly caller: CallerIdentity; readonly reference: string; readonly run: number; - readonly primitive: string; + readonly type: string; readonly name: string; readonly input: Json; } -export type SpecCallResult = +export type DefinitionCallResult = | { readonly status: 'succeeded'; readonly output: Json } | { readonly status: 'rejected'; @@ -28,25 +28,25 @@ export type RunSettlement = Settlement; export interface SettleRequest { readonly org: string; readonly brain: string; - readonly spec: string; - readonly executionId: string; + readonly definition: string; + readonly runId: string; readonly settlement: RunSettlement; } export interface WorkflowRun { readonly document: JsonObject; readonly input: Json; - readonly execution: { + readonly run: { readonly id: string; readonly org: string; readonly brain: string; - readonly spec: { readonly name: string; readonly version: number }; + readonly definition: { readonly name: string; readonly version: number }; }; readonly caller: CallerIdentity; readonly mostDuration: number; - readonly longestNestedExecutionMs: number; + readonly longestNestedRunMs: number; } export const defaultMostDuration = 2_592_000_000; -export const defaultLongestNestedExecutionMs = 600_000; +export const defaultLongestNestedRunMs = 600_000; diff --git a/primitives/orchestration/src/testing/test-host.ts b/capabilities/coordination/src/testing/test-host.ts similarity index 88% rename from primitives/orchestration/src/testing/test-host.ts rename to capabilities/coordination/src/testing/test-host.ts index eb9cd7421..a6c5191b6 100644 --- a/primitives/orchestration/src/testing/test-host.ts +++ b/capabilities/coordination/src/testing/test-host.ts @@ -3,13 +3,13 @@ import { recordedReactions, recordedWaiting } from '@beonauto/workflow-host/test import { Effect, Function } from 'effect'; import { callResultOfEnding, definitionCalls } from '../calls/function-calls.ts'; -import { orchestrationMachine } from '../runs/orchestration-machine.ts'; +import { workflowMachineOptions } from '../runs/workflow-machine-options.ts'; import type { Brain } from './brain.ts'; export function testHost(nested: Brain): Promise { return openWorkflowHost({ database: { store: 'sqlite', file: ':memory:' }, - machine: orchestrationMachine, + machine: workflowMachineOptions, perform: definitionCalls(nested.executeNested), settle: nested.settle, reports: { diff --git a/primitives/orchestration/src/testing/orchestrated-brain.ts b/capabilities/coordination/src/testing/workflow-brain.ts similarity index 67% rename from primitives/orchestration/src/testing/orchestrated-brain.ts rename to capabilities/coordination/src/testing/workflow-brain.ts index 5af07cb37..6ea5c1f2f 100644 --- a/primitives/orchestration/src/testing/orchestrated-brain.ts +++ b/capabilities/coordination/src/testing/workflow-brain.ts @@ -1,18 +1,18 @@ import { setTimeout } from 'node:timers/promises'; +import { echo } from '@beonauto/definitions/testing'; import type { Outcome } from '@beonauto/operations'; import { memoryLedger } from '@beonauto/operations/testing'; -import { echo } from '@beonauto/specs/testing'; import type { WorkflowHost } from '@beonauto/workflow-host'; import { Schema } from 'effect'; -import { makeWorkflowAdapter } from '../primitive/workflow.ts'; +import { makeWorkflowAdapter } from '../capability/workflow.ts'; import { brainOn, type Brain } from './brain.ts'; import { testHost } from './test-host.ts'; -export interface OrchestratedBrain extends Brain { +export interface WorkflowBrain extends Brain { readonly host: WorkflowHost; - readonly settled: (executionId: string) => Promise; + readonly settled: (runId: string) => Promise; readonly close: () => Promise; } @@ -22,18 +22,18 @@ const StartedSchema = Schema.Struct({ output: Schema.Struct({ status: Schema.Lit const isStarted = Schema.is(StartedSchema); -export async function orchestratedBrain(): Promise { +export async function workflowBrain(): Promise { const ledger = memoryLedger(); const nested = brainOn(ledger, [echo]); const host = await testHost(nested); const brain = brainOn(ledger, [makeWorkflowAdapter({ runs: host, mostDurationMs, longestCallMs: 660_000 }), echo]); - const settled = async (executionId: string): Promise => { - const outcome = await brain.call(brain.getExecution, { execution_id: executionId }); + const settled = async (runId: string): Promise => { + const outcome = await brain.call(brain.getRun, { run_id: runId }); if (!isStarted(outcome)) { return outcome; } await setTimeout(10); - return settled(executionId); + return settled(runId); }; return { ...brain, host, settled, close: () => host.stop() }; } diff --git a/primitives/orchestration/src/testing/workflows.ts b/capabilities/coordination/src/testing/workflows.ts similarity index 72% rename from primitives/orchestration/src/testing/workflows.ts rename to capabilities/coordination/src/testing/workflows.ts index d7606b1e2..88060ea18 100644 --- a/primitives/orchestration/src/testing/workflows.ts +++ b/capabilities/coordination/src/testing/workflows.ts @@ -3,23 +3,23 @@ import type { Json, JsonObject } from '@beonauto/workflow-engine'; import { Schema } from 'effect'; import { parse } from 'yaml'; -import type { SpecResponder } from './machine-commands.ts'; +import type { DefinitionResponder } from './machine-commands.ts'; import type { Command, MachineHost, WorkflowStart } from './machine-host.ts'; import { interpretOnMachine, outputsAtOnce, type MachineInterpretation } from './machine-workflows.ts'; import { - defaultLongestNestedExecutionMs, + defaultLongestNestedRunMs, defaultMostDuration, - type SpecCall, - type SpecCallResult, + type DefinitionCall, + type DefinitionCallResult, type WorkflowRun, } from './run-terms.ts'; export interface InterpretOptions { readonly input?: Json; - readonly respond?: SpecResponder; + readonly respond?: DefinitionResponder; readonly started?: (start: WorkflowStart, host: MachineHost) => void; readonly mostDuration?: number; - readonly longestNestedExecutionMs?: number; + readonly longestNestedRunMs?: number; } export const acmeCaller: CallerIdentity = { @@ -29,7 +29,7 @@ export const acmeCaller: CallerIdentity = { brains: '*', }; -export const executionId = '0199a3c4-7d2e-7c1a-9b3f-2f1e0d9c8b7a'; +export const runId = '0199a3c4-7d2e-7c1a-9b3f-2f1e0d9c8b7a'; export const header = { dsl: '1.0.3', namespace: 'acme', name: 'test', version: '1.0.0' }; @@ -47,18 +47,18 @@ function runOf(document: JsonObject, input: Json = {}): WorkflowRun { return { document, input, - execution: { id: executionId, org: 'acme', brain: 'alpha', spec: { name: 'test-flow', version: 1 } }, + run: { id: runId, org: 'acme', brain: 'alpha', definition: { name: 'test-flow', version: 1 } }, caller: acmeCaller, mostDuration: defaultMostDuration, - longestNestedExecutionMs: defaultLongestNestedExecutionMs, + longestNestedRunMs: defaultLongestNestedRunMs, }; } -export function neverAnswers(): Promise { - return Promise.withResolvers().promise; +export function neverAnswers(): Promise { + return Promise.withResolvers().promise; } -export function callsIn(commands: readonly Command[]): readonly SpecCall[] { +export function callsIn(commands: readonly Command[]): readonly DefinitionCall[] { return commands.flatMap((command) => (command.kind === 'call' ? [command.call] : [])); } @@ -68,7 +68,7 @@ export function interpret(document: JsonObject, options: InterpretOptions = {}): { ...run, mostDuration: options.mostDuration ?? run.mostDuration, - longestNestedExecutionMs: options.longestNestedExecutionMs ?? run.longestNestedExecutionMs, + longestNestedRunMs: options.longestNestedRunMs ?? run.longestNestedRunMs, }, options, ); @@ -82,13 +82,13 @@ export const workflowExample = [ "document: {dsl: '1.0.3', namespace: support, name: triage, version: '1.0.0'}", 'do:', ' - classify:', - ' call: execute_spec', - " with: {primitive: inference, name: classify-ticket, input: {ticket: '${ .ticket }'}}", + ' call: run_definition', + " with: {type: reasoning, name: classify-ticket, input: {ticket: '${ .ticket }'}}", " output: {as: '${ $input + {triage: .} }'}", ' - escalate:', ' if: .triage.urgency == "high"', - ' call: execute_spec', - " with: {primitive: inference, name: draft-escalation, input: {ticket: '${ .ticket }'}}", + ' call: run_definition', + " with: {type: reasoning, name: draft-escalation, input: {ticket: '${ .ticket }'}}", " output: {as: '${ $input + {note: .} }'}", ' - approval:', ' if: .note != null', diff --git a/primitives/orchestration/src/workflows/call-results.test.ts b/capabilities/coordination/src/workflows/call-results.test.ts similarity index 77% rename from primitives/orchestration/src/workflows/call-results.test.ts rename to capabilities/coordination/src/workflows/call-results.test.ts index aa10f690c..894836bd9 100644 --- a/primitives/orchestration/src/workflows/call-results.test.ts +++ b/capabilities/coordination/src/workflows/call-results.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from 'vitest'; import type { WorkflowEnding } from '../testing/endings.ts'; -import type { SpecCallResult } from '../testing/run-terms.ts'; +import type { DefinitionCallResult } from '../testing/run-terms.ts'; import { interpret, workflow } from '../testing/workflows.ts'; const calling = workflow(` @@ -9,8 +9,8 @@ do: - lookup: try: - fetch: - call: execute_spec - with: { primitive: inference, name: lookup, input: {} } + call: run_definition + with: { type: reasoning, name: lookup, input: {} } catch: as: failure do: @@ -18,17 +18,17 @@ do: set: '\${ $failure }' `); -async function caughtFor(respond: () => SpecCallResult | Promise): Promise { +async function caughtFor(respond: () => DefinitionCallResult | Promise): Promise { return (await interpret(calling, { respond })).ending; } const types = 'https://open-workflow-specification.org/spec/1.0.0/errors'; -function unreachable(): Promise { +function unreachable(): Promise { return Promise.reject(new Error('The call was lost', { cause: new Error('the host is gone') })); } -describe('a rejected execution', () => { +describe('a rejected run', () => { it.each([ ['invalid_input', 'validation', 400], ['forbidden', 'authorization', 403], @@ -43,7 +43,7 @@ describe('a rejected execution', () => { type: `${types}/${kind}`, status, instance: '/do/0/lookup/try/0/fetch', - title: `The reasoning function lookup rejected the execution with ${reason}`, + title: `The reasoning function lookup rejected the run with ${reason}`, detail: 'The model is busy', }, }); @@ -55,8 +55,8 @@ do: - lookup: try: - fetch: - call: execute_spec - with: { primitive: inference, name: lookup, input: {} } + call: run_definition + with: { type: reasoning, name: lookup, input: {} } catch: when: '\${ $error.kind == "tool_not_offered" and $error.because == "tool_not_allowed" }' do: @@ -64,11 +64,11 @@ do: set: { offered: false } `); -function rejectedAs(kind: string): () => SpecCallResult { +function rejectedAs(kind: string): () => DefinitionCallResult { return () => ({ status: 'rejected', reason: 'unavailable', detail: 'No', kind, because: 'tool_not_allowed' }); } -describe('a rejected execution that names its kind and because', () => { +describe('a rejected run that names its kind and because', () => { it('is an error the document can catch and branch on by its kind and because', async () => { const rejected = { status: 'rejected', @@ -84,7 +84,7 @@ describe('a rejected execution that names its kind and because', () => { type: 'https://on.auto/problems/tools_unfinished', status: 503, instance: '/do/0/lookup/try/0/fetch', - title: 'The reasoning function lookup rejected the execution with unavailable', + title: 'The reasoning function lookup rejected the run with unavailable', detail: rejected.detail, kind: 'tools_unfinished', because: 'server_failed', @@ -100,8 +100,7 @@ describe('a rejected execution that names its kind and because', () => { expect((await interpret(branching, { respond: rejectedAs('mcp_server_failed') })).ending).toEqual({ kind: 'failed', type: 'UncaughtError', - message: - 'The reasoning function lookup rejected the execution with unavailable: No (at /do/0/lookup/try/0/fetch)', + message: 'The reasoning function lookup rejected the run with unavailable: No (at /do/0/lookup/try/0/fetch)', }); }); }); @@ -111,8 +110,8 @@ do: - lookup: try: - fetch: - call: execute_spec - with: { primitive: inference, name: lookup, input: {} } + call: run_definition + with: { type: reasoning, name: lookup, input: {} } catch: errors: with: { type: https://open-workflow-specification.org/spec/1.0.0/errors/communication } @@ -133,7 +132,7 @@ describe('a run of a function that called tools and could not finish', () => { kind: 'failed', type: 'UncaughtError', message: - 'The reasoning function lookup rejected the execution with unavailable: It stopped (at /do/0/lookup/try/0/fetch)', + 'The reasoning function lookup rejected the run with unavailable: It stopped (at /do/0/lookup/try/0/fetch)', }); expect(calls).toEqual(['run 1']); }); @@ -144,8 +143,8 @@ do: - lookup: try: - fetch: - call: execute_spec - with: { primitive: inference, name: lookup, input: {} } + call: run_definition + with: { type: reasoning, name: lookup, input: {} } catch: errors: with: { type: https://open-workflow-specification.org/spec/1.0.0/errors/runtime } @@ -168,13 +167,13 @@ describe('a step that meets a run whose tools may have been called', () => { status: 'rejected', reason: 'conflict', detail: - 'The reasoning function lookup rejected the execution with conflict: It may have called tools (at /do/0/lookup/try/0/fetch)', + 'The reasoning function lookup rejected the run with conflict: It may have called tools (at /do/0/lookup/try/0/fetch)', kind: 'tools_called', }); }); }); -describe('a failed execution', () => { +describe('a failed run', () => { it('is a runtime error', async () => { expect(await caughtFor(() => ({ status: 'failed', detail: 'It failed with incident 7' }))).toEqual({ kind: 'completed', @@ -188,14 +187,14 @@ describe('a failed execution', () => { }); }); - it('is a communication error when the execution could not be reached, with the chain of causes', async () => { + it('is a communication error when the run could not be reached, with the chain of causes', async () => { expect(await caughtFor(unreachable)).toEqual({ kind: 'completed', output: { type: `${types}/communication`, status: 503, instance: '/do/0/lookup/try/0/fetch', - title: 'execute_spec could not reach the reasoning function lookup', + title: 'run_definition could not reach the reasoning function lookup', detail: 'Error: The call was lost: Error: the host is gone', }, }); diff --git a/primitives/orchestration/src/workflows/call-task.test.ts b/capabilities/coordination/src/workflows/call-task.test.ts similarity index 60% rename from primitives/orchestration/src/workflows/call-task.test.ts rename to capabilities/coordination/src/workflows/call-task.test.ts index 3c52971f9..dff59d085 100644 --- a/primitives/orchestration/src/workflows/call-task.test.ts +++ b/capabilities/coordination/src/workflows/call-task.test.ts @@ -1,25 +1,25 @@ import { describe, expect, it } from 'vitest'; -import type { SpecCallResult } from '../testing/run-terms.ts'; +import type { DefinitionCallResult } from '../testing/run-terms.ts'; import { acmeCaller, callsIn, interpret, workflow } from '../testing/workflows.ts'; const summarizing = workflow(` do: - summarize: - call: execute_spec + call: run_definition with: - primitive: inference + type: reasoning name: summarize input: text: \${ .text } `); -function answering(result: SpecCallResult) { +function answering(result: DefinitionCallResult) { return { respond: () => result }; } -describe('a call of execute_spec', () => { - it('executes the spec with the evaluated input, for the caller of the workflow, and outputs its output', async () => { +describe('a call of run_definition', () => { + it('executes the definition with the evaluated input, for the caller of the workflow, and outputs its output', async () => { const { ending, commands } = await interpret(summarizing, { input: { text: 'long' }, ...answering({ status: 'succeeded', output: { summary: 'short' } }), @@ -34,7 +34,7 @@ describe('a call of execute_spec', () => { caller: acmeCaller, reference: '/do/0/summarize', run: 1, - primitive: 'inference', + type: 'reasoning', name: 'summarize', input: { text: 'long' }, }, @@ -43,15 +43,15 @@ describe('a call of execute_spec', () => { }); describe('the runs of a call', () => { - it('are counted per task, so each run is its own execution', async () => { + it('are counted per task, so each run is its own run', async () => { const document = workflow(` do: - each: for: { in: .items } do: - summarize: - call: execute_spec - with: { primitive: inference, name: summarize, input: '\${ $item }' } + call: run_definition + with: { type: reasoning, name: summarize, input: '\${ $item }' } `); const { commands } = await interpret(document, { input: { items: ['a', 'b'] } }); @@ -64,16 +64,16 @@ do: it('calls a workflow as it calls any other function, its name computed or written out', async () => { const document = workflow( - 'do:\n - x: { call: execute_spec, with: { primitive: "${ \\"orchestration\\" }", name: triage } }', + 'do:\n - x: { call: run_definition, with: { type: "${ \\"workflow\\" }", name: triage } }', ); const { commands } = await interpret(document); - expect(callsIn(commands)).toEqual([expect.objectContaining({ primitive: 'orchestration', name: 'triage' })]); + expect(callsIn(commands)).toEqual([expect.objectContaining({ type: 'workflow', name: 'triage' })]); }); - it('gives an execution an empty object when it is given no input', async () => { - const document = workflow('do:\n - plain: { call: execute_spec, with: { primitive: echo, name: greet } }'); + it('gives a run an empty object when it is given no input', async () => { + const document = workflow('do:\n - plain: { call: run_definition, with: { type: echo, name: greet } }'); const { commands } = await interpret(document); @@ -81,18 +81,18 @@ do: }); }); -describe('the arguments of execute_spec', () => { +describe('the arguments of run_definition', () => { it.each([ - ['nothing', 'do:\n - x: { call: execute_spec }', 'execute_spec takes with: { primitive, name, input }'], + ['nothing', 'do:\n - x: { call: run_definition }', 'run_definition takes with: { type, name, input }'], [ 'no name', - 'do:\n - x: { call: execute_spec, with: { primitive: echo, name: "${ 1 }" } }', - 'execute_spec needs a string primitive and a string name', + 'do:\n - x: { call: run_definition, with: { type: echo, name: "${ 1 }" } }', + 'run_definition needs a string type and a string name', ], [ - 'no primitive', - 'do:\n - x: { call: execute_spec, with: { primitive: "${ null }", name: a } }', - 'execute_spec needs a string primitive and a string name', + 'no type', + 'do:\n - x: { call: run_definition, with: { type: "${ null }", name: a } }', + 'run_definition needs a string type and a string name', ], ])('are rejected when they evaluate to %s', async (_case, source, title) => { const { settlement, commands } = await interpret(workflow(source)); @@ -108,7 +108,7 @@ describe('the arguments of execute_spec', () => { status: 'rejected', reason: 'invalid_input', detail: - 'The input of execute_spec takes 262211 bytes as JSON, more than the 262144 a run takes (at /do/0/summarize)', + 'The input of run_definition takes 262211 bytes as JSON, more than the 262144 a run takes (at /do/0/summarize)', }); }); }); diff --git a/primitives/orchestration/src/workflows/deadline.test.ts b/capabilities/coordination/src/workflows/deadline.test.ts similarity index 100% rename from primitives/orchestration/src/workflows/deadline.test.ts rename to capabilities/coordination/src/workflows/deadline.test.ts diff --git a/primitives/orchestration/src/workflows/emit-task.test.ts b/capabilities/coordination/src/workflows/emit-task.test.ts similarity index 78% rename from primitives/orchestration/src/workflows/emit-task.test.ts rename to capabilities/coordination/src/workflows/emit-task.test.ts index 9269a56fe..0537b4cca 100644 --- a/primitives/orchestration/src/workflows/emit-task.test.ts +++ b/capabilities/coordination/src/workflows/emit-task.test.ts @@ -26,11 +26,11 @@ describe('a workflow that emits an event', () => { }); it('is refused when it is saved with a type or a source the brain records itself', () => { - const document = emitting('{ type: execution_succeeded, source: /executions/0199a3c4 }'); + const document = emitting('{ type: run_succeeded, source: /runs/0199a3c4 }'); expect(workflowPolicy(document).map(({ pointer, detail }) => `${pointer}: ${detail}`)).toEqual([ - '/do/0/announce/emit/event/with/type: The type execution_succeeded is one the brain records itself; give the event a type of your own', - '/do/0/announce/emit/event/with/source: The source /executions/0199a3c4 is one the brain records itself; give the event a source of your own', + '/do/0/announce/emit/event/with/type: The type run_succeeded is one the brain records itself; give the event a type of your own', + '/do/0/announce/emit/event/with/source: The source /runs/0199a3c4 is one the brain records itself; give the event a source of your own', ]); }); @@ -41,7 +41,7 @@ describe('a workflow that emits an event', () => { kind: 'failed', type: 'UncaughtError', message: - 'The event to emit is not one the brain takes: Expected a source of your own, not one under /executions/, /specs/ or /callers/, which the brain records itself (at source) (at /do/0/announce)', + 'The event to emit is not one the brain takes: Expected a source of your own, not one under /runs/, /definitions/ or /callers/, which the brain records itself (at source) (at /do/0/announce)', }); }); }); diff --git a/primitives/orchestration/src/workflows/error-tasks.test.ts b/capabilities/coordination/src/workflows/error-tasks.test.ts similarity index 100% rename from primitives/orchestration/src/workflows/error-tasks.test.ts rename to capabilities/coordination/src/workflows/error-tasks.test.ts diff --git a/primitives/orchestration/src/workflows/event-limits.test.ts b/capabilities/coordination/src/workflows/event-limits.test.ts similarity index 100% rename from primitives/orchestration/src/workflows/event-limits.test.ts rename to capabilities/coordination/src/workflows/event-limits.test.ts diff --git a/primitives/orchestration/src/workflows/flow-tasks.test.ts b/capabilities/coordination/src/workflows/flow-tasks.test.ts similarity index 100% rename from primitives/orchestration/src/workflows/flow-tasks.test.ts rename to capabilities/coordination/src/workflows/flow-tasks.test.ts diff --git a/primitives/orchestration/src/workflows/fork.test.ts b/capabilities/coordination/src/workflows/fork.test.ts similarity index 100% rename from primitives/orchestration/src/workflows/fork.test.ts rename to capabilities/coordination/src/workflows/fork.test.ts diff --git a/primitives/orchestration/src/workflows/limits.test.ts b/capabilities/coordination/src/workflows/limits.test.ts similarity index 90% rename from primitives/orchestration/src/workflows/limits.test.ts rename to capabilities/coordination/src/workflows/limits.test.ts index 0deb359a1..b99fcaa9e 100644 --- a/primitives/orchestration/src/workflows/limits.test.ts +++ b/capabilities/coordination/src/workflows/limits.test.ts @@ -1,7 +1,7 @@ import { mostStepsWithoutWaiting } from '@beonauto/workflow-engine'; import { describe, expect, it } from 'vitest'; -import { executionId, interpret, outputsAtTimeZero, workflow } from '../testing/workflows.ts'; +import { runId, interpret, outputsAtTimeZero, workflow } from '../testing/workflows.ts'; describe('a workflow that never waits', () => { it('is stopped once it has run too many tasks without waiting', () => { @@ -14,7 +14,7 @@ do: expect(outputsAtTimeZero(document).at(-1)).toEqual({ kind: 'settle', - executionId, + runId, settlement: { status: 'rejected', reason: 'unavailable', diff --git a/primitives/orchestration/src/workflows/listen-task.test.ts b/capabilities/coordination/src/workflows/listen-task.test.ts similarity index 100% rename from primitives/orchestration/src/workflows/listen-task.test.ts rename to capabilities/coordination/src/workflows/listen-task.test.ts diff --git a/primitives/orchestration/src/workflows/loops.test.ts b/capabilities/coordination/src/workflows/loops.test.ts similarity index 100% rename from primitives/orchestration/src/workflows/loops.test.ts rename to capabilities/coordination/src/workflows/loops.test.ts diff --git a/primitives/orchestration/src/workflows/malformed-tasks.test.ts b/capabilities/coordination/src/workflows/malformed-tasks.test.ts similarity index 100% rename from primitives/orchestration/src/workflows/malformed-tasks.test.ts rename to capabilities/coordination/src/workflows/malformed-tasks.test.ts diff --git a/primitives/orchestration/src/workflows/raised-again.test.ts b/capabilities/coordination/src/workflows/raised-again.test.ts similarity index 95% rename from primitives/orchestration/src/workflows/raised-again.test.ts rename to capabilities/coordination/src/workflows/raised-again.test.ts index 39df01a01..03ac38288 100644 --- a/primitives/orchestration/src/workflows/raised-again.test.ts +++ b/capabilities/coordination/src/workflows/raised-again.test.ts @@ -18,8 +18,8 @@ do: - lookup: try: - fetch: - call: execute_spec - with: { primitive: inference, name: lookup, input: {} } + call: run_definition + with: { type: reasoning, name: lookup, input: {} } catch: as: failure do: diff --git a/primitives/orchestration/src/workflows/refused-documents.test.ts b/capabilities/coordination/src/workflows/refused-documents.test.ts similarity index 100% rename from primitives/orchestration/src/workflows/refused-documents.test.ts rename to capabilities/coordination/src/workflows/refused-documents.test.ts diff --git a/primitives/orchestration/src/workflows/retries.test.ts b/capabilities/coordination/src/workflows/retries.test.ts similarity index 93% rename from primitives/orchestration/src/workflows/retries.test.ts rename to capabilities/coordination/src/workflows/retries.test.ts index 6b29556cc..eb5b181be 100644 --- a/primitives/orchestration/src/workflows/retries.test.ts +++ b/capabilities/coordination/src/workflows/retries.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from 'vitest'; import type { Command } from '../testing/machine-host.ts'; -import type { SpecCall, SpecCallResult } from '../testing/run-terms.ts'; +import type { DefinitionCall, DefinitionCallResult } from '../testing/run-terms.ts'; import { callsIn, interpret, neverAnswers, workflow } from '../testing/workflows.ts'; function retrying(retry: string, extra = ''): ReturnType { @@ -11,8 +11,8 @@ do: - guarded: try: - fetch: - call: execute_spec - with: { primitive: inference, name: lookup, input: {} } + call: run_definition + with: { type: reasoning, name: lookup, input: {} } catch: errors: { with: { status: 503 } } retry: ${retry} @@ -20,7 +20,7 @@ do: `); } -function failingTimes(times: number): (call: SpecCall) => SpecCallResult { +function failingTimes(times: number): (call: DefinitionCall) => DefinitionCallResult { return ({ run }) => run <= times ? { status: 'rejected', reason: 'unavailable', detail: 'busy' } @@ -97,7 +97,7 @@ describe('the limits of a retry policy', () => { const document = workflow(` do: - guarded: - try: [{ fetch: { call: execute_spec, with: { primitive: inference, name: lookup } } }] + try: [{ fetch: { call: run_definition, with: { type: reasoning, name: lookup } } }] catch: retry: { delay: PT1S, limit: { attempt: { count: 1, duration: PT5S } } } do: [{ gave_up: { set: '\${ $error.status }' } }] diff --git a/primitives/orchestration/src/workflows/task-runner.test.ts b/capabilities/coordination/src/workflows/task-runner.test.ts similarity index 94% rename from primitives/orchestration/src/workflows/task-runner.test.ts rename to capabilities/coordination/src/workflows/task-runner.test.ts index 30d28b090..968402a67 100644 --- a/primitives/orchestration/src/workflows/task-runner.test.ts +++ b/capabilities/coordination/src/workflows/task-runner.test.ts @@ -1,7 +1,7 @@ import { startedAt as workflowStartedAt } from '@beonauto/workflow-engine/testing'; import { describe, expect, it } from 'vitest'; -import { executionId, interpret, workflow } from '../testing/workflows.ts'; +import { runId, interpret, workflow } from '../testing/workflows.ts'; describe('the data of a task', () => { it('flows from the output of one task into the input of the next', async () => { @@ -89,12 +89,12 @@ do: const document = workflow(` do: - describe: - set: { id: '\${ $workflow.id }', input: '\${ $workflow.input }', runtime: '\${ $runtime.metadata.primitive }' } + set: { id: '\${ $workflow.id }', input: '\${ $workflow.input }', runtime: '\${ $runtime.metadata.type }' } `); expect((await interpret(document, { input: [1] })).ending).toEqual({ kind: 'completed', - output: { id: executionId, input: [1], runtime: 'orchestration' }, + output: { id: runId, input: [1], runtime: 'workflow' }, }); }); }); diff --git a/primitives/orchestration/src/workflows/timeouts.test.ts b/capabilities/coordination/src/workflows/timeouts.test.ts similarity index 96% rename from primitives/orchestration/src/workflows/timeouts.test.ts rename to capabilities/coordination/src/workflows/timeouts.test.ts index 8edb2a39f..298c22bb8 100644 --- a/primitives/orchestration/src/workflows/timeouts.test.ts +++ b/capabilities/coordination/src/workflows/timeouts.test.ts @@ -12,8 +12,8 @@ describe('a task timeout', () => { const document = workflow(` do: - slow: - call: execute_spec - with: { primitive: inference, name: lookup } + call: run_definition + with: { type: reasoning, name: lookup } timeout: { after: PT30S } `); @@ -47,8 +47,8 @@ describe('the timer of a timeout', () => { const document = workflow(` do: - slow: - call: execute_spec - with: { primitive: inference, name: lookup } + call: run_definition + with: { type: reasoning, name: lookup } timeout: { after: PT30S } `); diff --git a/primitives/orchestration/src/workflows/trigger-inputs.test.ts b/capabilities/coordination/src/workflows/trigger-inputs.test.ts similarity index 100% rename from primitives/orchestration/src/workflows/trigger-inputs.test.ts rename to capabilities/coordination/src/workflows/trigger-inputs.test.ts diff --git a/primitives/orchestration/src/workflows/work-limits.test.ts b/capabilities/coordination/src/workflows/work-limits.test.ts similarity index 97% rename from primitives/orchestration/src/workflows/work-limits.test.ts rename to capabilities/coordination/src/workflows/work-limits.test.ts index 3f9790115..d288ac014 100644 --- a/primitives/orchestration/src/workflows/work-limits.test.ts +++ b/capabilities/coordination/src/workflows/work-limits.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from 'vitest'; import type { RunSettlement } from '../testing/run-terms.ts'; -import { executionId, interpret, outputsAtTimeZero, workflow } from '../testing/workflows.ts'; +import { runId, interpret, outputsAtTimeZero, workflow } from '../testing/workflows.ts'; function tasks(count: number, body: (index: number) => string): string { return `do:\n${Array.from({ length: count }, (_, index) => ` - t${index}: ${body(index)}`).join('\n')}`; @@ -71,7 +71,7 @@ describe('a value a workflow holds', () => { expect(outputsAtTimeZero(workflow('do: []'), zerosJustOverTheBudget)).toEqual([ { kind: 'settle', - executionId, + runId, settlement: { status: 'rejected', reason: 'unavailable', diff --git a/primitives/orchestration/src/workflows/workflow-runs.test.ts b/capabilities/coordination/src/workflows/workflow-runs.test.ts similarity index 93% rename from primitives/orchestration/src/workflows/workflow-runs.test.ts rename to capabilities/coordination/src/workflows/workflow-runs.test.ts index 096870106..be394b067 100644 --- a/primitives/orchestration/src/workflows/workflow-runs.test.ts +++ b/capabilities/coordination/src/workflows/workflow-runs.test.ts @@ -1,7 +1,7 @@ import { startedAt as workflowStartedAt } from '@beonauto/workflow-engine/testing'; import { describe, expect, it } from 'vitest'; -import { executionId, interpret, workflow } from '../testing/workflows.ts'; +import { runId, interpret, workflow } from '../testing/workflows.ts'; const greeting = workflow(` do: @@ -17,7 +17,7 @@ do: `); describe('a workflow run that succeeds', () => { - it('runs its tasks on the input, settles the execution with the output and completes with it', async () => { + it('runs its tasks on the input, settles the run with the output and completes with it', async () => { const { ending, commands } = await interpret(greeting, { input: { name: 'Ada' } }); expect(ending).toEqual({ kind: 'completed', output: { greeting: 'Hello, Ada' } }); @@ -27,8 +27,8 @@ describe('a workflow run that succeeds', () => { request: { org: 'acme', brain: 'alpha', - spec: 'test-flow', - executionId, + definition: 'test-flow', + runId, settlement: { status: 'succeeded', output: { greeting: 'Hello, Ada' } }, }, }, @@ -64,7 +64,7 @@ do: [] expect((await interpret(document, { input: 7 })).ending).toEqual({ kind: 'completed', - output: { id: executionId, at: new Date(workflowStartedAt).toISOString(), runtime: 'auto-brain' }, + output: { id: runId, at: new Date(workflowStartedAt).toISOString(), runtime: 'auto-brain' }, }); }); }); diff --git a/primitives/inference/tsconfig.json b/capabilities/coordination/tsconfig.json similarity index 100% rename from primitives/inference/tsconfig.json rename to capabilities/coordination/tsconfig.json diff --git a/primitives/recollection/vitest.config.ts b/capabilities/coordination/vitest.config.ts similarity index 86% rename from primitives/recollection/vitest.config.ts rename to capabilities/coordination/vitest.config.ts index ec92ae462..99c3af563 100644 --- a/primitives/recollection/vitest.config.ts +++ b/capabilities/coordination/vitest.config.ts @@ -2,4 +2,4 @@ import { defineConfig, mergeConfig } from 'vitest/config'; import { sharedConfig } from '../../vitest.shared.ts'; -export default mergeConfig(sharedConfig, defineConfig({ test: { name: 'recollection' } })); +export default mergeConfig(sharedConfig, defineConfig({ test: { name: 'coordination' } })); diff --git a/primitives/interaction/README.md b/capabilities/interaction/README.md similarity index 68% rename from primitives/interaction/README.md rename to capabilities/interaction/README.md index 0157e3c4f..27f20dfa0 100644 --- a/primitives/interaction/README.md +++ b/capabilities/interaction/README.md @@ -45,19 +45,19 @@ output: Here is the draft: {{ input.draft }} ``` -`parseInteractionDocument(source)` reads it with the shared document reader of `@beonauto/specs/document` (`src/document`). The front matter takes `description`, `to`, a Liquid template that names the party, `from`, one that names the answerer, `expires`, an ISO 8601 duration from one minute to thirty days read by `readDuration` of `@beonauto/workflow-engine/dsl`, `deliver` and `replies`, `input` and `output`, each with a `schema`, and `reply`, the reply rule. Every other key, `model` and `tools` among them, is refused with its line, within the nested blocks too. Without `output.schema` the function is a notification: it takes no answer, and its run succeeds, with the output `{}`, once its delivery lands, at once without `deliver`. The body is the message, a Liquid template. `to`, `from` and the message read `input`, `today` and `now` alone, through an instance of the engine of `@beonauto/specs/template` with no registrations of its own, and a template that does not compile, reads another name, or a property a closed input schema does not have, is refused with its line. +`parseInteractionDocument(source)` reads it with the shared document reader of `@beonauto/definitions/document` (`src/document`). The front matter takes `description`, `to`, a Liquid template that names the party, `from`, one that names the answerer, `expires`, an ISO 8601 duration from one minute to thirty days read by `readDuration` of `@beonauto/workflow-engine/dsl`, `deliver` and `replies`, `input` and `output`, each with a `schema`, and `reply`, the reply rule. Every other key, `model` and `tools` among them, is refused with its line, within the nested blocks too. Without `output.schema` the function is a notification: it takes no answer, and its run succeeds, with the output `{}`, once its delivery lands, at once without `deliver`. The body is the message, a Liquid template. `to`, `from` and the message read `input`, `today` and `now` alone, through an instance of the engine of `@beonauto/definitions/template` with no registrations of its own, and a template that does not compile, reads another name, or a property a closed input schema does not have, is refused with its line. -`src/route` holds the two blocks, written once as effect schemas, `ToolDeliverySchema` and `RepliesSchema`, and `compiledRoute(written, lines, inputSchema)`, which checks them at save: the server and tool names by their shapes, `isServerName` and `isToolName` of `@beonauto/mcp`; every template of `deliver.with`, `replies.conversation`, `replies.with` and `tell.with` against the names its place reads, with no `${`; each pointer of `deliver.sent` and `replies.read` a JSON Pointer that starts with a slash, refused in one sentence; `read.order`; `wait` from `PT5S` to `PT1H`. `replies` needs `deliver`, its `sent` and `output.schema`. Arguments are typed as written (`renderedArguments`): a number, boolean, null, list or object is sent as written with the templates in its strings rendered, a string that is one `{{ expression }}` alone is sent as the value it reads, and any other string is rendered as text, so a structure inside a longer text is refused at save. `routeOf(blocks)` answers what an attempt, a read or a telling works with, `{ kind: 'inbox' }` or `{ kind: 'tool', delivery, replies? }`, and `throughWords` says it, the same sentence the specs package's `deliveryStarted` says. +`src/route` holds the two blocks, written once as effect schemas, `ToolDeliverySchema` and `RepliesSchema`, and `compiledRoute(written, lines, inputSchema)`, which checks them at save: the server and tool names by their shapes, `isServerName` and `isToolName` of `@beonauto/mcp`; every template of `deliver.with`, `replies.conversation`, `replies.with` and `tell.with` against the names its place reads, with no `${`; each pointer of `deliver.sent` and `replies.read` a JSON Pointer that starts with a slash, refused in one sentence; `read.order`; `wait` from `PT5S` to `PT1H`. `replies` needs `deliver`, its `sent` and `output.schema`. Arguments are typed as written (`renderedArguments`): a number, boolean, null, list or object is sent as written with the templates in its strings rendered, a string that is one `{{ expression }}` alone is sent as the value it reads, and any other string is rendered as text, so a structure inside a longer text is refused at save. `routeOf(blocks)` answers what an attempt, a read or a telling works with, `{ kind: 'inbox' }` or `{ kind: 'tool', delivery, replies? }`, and `throughWords` says it, the same sentence the definitions package's `deliveryStarted` says. The reply rule (`src/replies/reply-rule.ts`, checked by `rule-checks.ts`) maps a top-level string property of the answer to `word`, the first word with the `words` that mean each value, `rest` or `text`; left out, it is derived for an answer whose one required property is a string, and is none otherwise. At most 16 values, 16 words a value, 64 bytes a word. ## A run -`makeInteractionFunctionAdapter({ tools, openRequests, mostOpenRequests })` is the capability (`src/primitive`, `src/run`). Its prepared definition finishes later, but for a notification without `deliver`, and its longest run is its `expires`, so a workflow step that calls it waits that long and a minute more. It reaches outside while any tool server is configured, `tools.configured`. A run validates its input, checks by name alone that every tool its route names is offered to the brain, `tools.named`, the words of `list_tool_servers` and `unavailable`, kind `tool_not_offered`, otherwise; counts the brain's open requests through `openRequests` and refuses a run past `mostOpenRequests` as `requests_full`. It renders the party, the answerer and the message as text, and the arguments of `deliver` once to check them: an empty party or one with a control character or past 256 bytes, a message past 8 KiB, arguments past 16 KiB as JSON or one that renders no text, end the run `conflict`, kind `unworkable`, with the place in its record. The run then defers, and the deferral's record is the request: `{ to, message, answer_schema, answerer, reply, expires_at, requested_at, deliver, replies }`, the answering fields left out of a notification and the blocks out of a request in the inbox. A run that is cancelled ends `rejected` as `cancelled`, unless an answer a reply brought came first, which settles it. +`makeInteractionFunctionAdapter({ tools, openRequests, mostOpenRequests })` is the capability (`src/capability`, `src/run`). Its prepared definition finishes later, but for a notification without `deliver`, and its longest run is its `expires`, so a workflow step that calls it waits that long and a minute more. It reaches outside while any tool server is configured, `tools.configured`. A run validates its input, checks by name alone that every tool its route names is offered to the brain, `tools.named`, the words of `list_tool_servers` and `unavailable`, kind `tool_not_offered`, otherwise; counts the brain's open requests through `openRequests` and refuses a run past `mostOpenRequests` as `requests_full`. It renders the party, the answerer and the message as text, and the arguments of `deliver` once to check them: an empty party or one with a control character or past 256 bytes, a message past 8 KiB, arguments past 16 KiB as JSON or one that renders no text, end the run `conflict`, kind `unworkable`, with the place in its record. The run then defers, and the deferral's record is the request: `{ to, message, answer_schema, answerer, reply, expires_at, requested_at, deliver, replies }`, the answering fields left out of a notification and the blocks out of a request in the inbox. A run that is cancelled ends `rejected` as `cancelled`, unless an answer a reply brought came first, which settles it. ## The open requests -`openRequests` is a keyed projection of runs the server registers with the ledger (`src/requests/open-requests.ts`), table `open_requests_4`, kept in the transaction of every append. A deferral of an interaction function makes a row: the request's message id, the function and its version, the party, the `delivery` and `replies` it recorded, as JSON text, null for the inbox, the message, whether it takes an answer, the answer schema, the answerer and the reply rule, when it was asked and expires, the attempts made, when the next is due, the delivery's standing, `in_inbox`, `to_deliver`, `delivering`, `delivered`, `retrying`, `undelivered`, `answered`, once a reply answered it, or `cancelling`, whether it is open, how it ended, the conversation it is read in and what its message was delivered as, the replies refused and told, and two due times while it is open and not `cancelling`: `attempt_due_at`, while it stands `to_deliver` or `retrying`, and `ending_due_at`, the earlier of its expiry and the bound of an attempt in flight, or when it was asked for a row that settles now. Each `delivery_started` moves the next attempt a minute past it; each `delivery_ended` schedules the next attempt, or none once delivered, refused or spent, and keeps `delivered_as` and `replies_in`; `reply_taken` makes the row stand `answered` and `reply_refused` counts; a row that stands `answered`, or a notification that stands `delivered`, is due at once and settled from what was brought back, `settledFromBroughtAnswer`; `execution_cancel_requested` makes the row stand `cancelling`, unless what was brought back came first; the run's ending closes the row. Its indexes are by open and time, by party, by function, by conversation, and a partial one across brains on each due time. +`openRequests` is a keyed projection of runs the server registers with the ledger (`src/requests/open-requests.ts`), table `open_requests_5`, kept in the transaction of every append. A deferral of an interaction function makes a row: the request's message id, the function and its version, the party, the `delivery` and `replies` it recorded, as JSON text, null for the inbox, the message, whether it takes an answer, the answer schema, the answerer and the reply rule, when it was asked and expires, the attempts made, when the next is due, the delivery's standing, `in_inbox`, `to_deliver`, `delivering`, `delivered`, `retrying`, `undelivered`, `answered`, once a reply answered it, or `cancelling`, whether it is open, how it ended, the conversation it is read in and what its message was delivered as, the replies refused and told, and two due times while it is open and not `cancelling`: `attempt_due_at`, while it stands `to_deliver` or `retrying`, and `ending_due_at`, the earlier of its expiry and the bound of an attempt in flight, or when it was asked for a row that settles now. Each `delivery_started` moves the next attempt a minute past it; each `delivery_ended` schedules the next attempt, or none once delivered, refused or spent, and keeps `delivered_as` and `replies_in`; `reply_taken` makes the row stand `answered` and `reply_refused` counts; a row that stands `answered`, or a notification that stands `delivered`, is due at once and settled from what was brought back, `settledFromBroughtAnswer`; `run_cancel_requested` makes the row stand `cancelling`, unless what was brought back came first; the run's ending closes the row. Its indexes are by open and time, by party, by function, by conversation, and a partial one across brains on each due time. ## Delivering @@ -65,17 +65,17 @@ The reply rule (`src/replies/reply-rule.ts`, checked by `rule-checks.ts`) maps a - A request past its expiry is settled `rejected`, reason `unanswered`, kind `expired`, as the brain; a notification whose delivery ended undelivered is settled `unanswered`, kind `undelivered`. - An attempt still in flight a minute after it started is ended as failed, `lost`. -- Otherwise the next attempt (`src/delivery`): the arguments are rendered from the request's record, `deliveryVariablesOf`, so every attempt sends the same; `delivery_started` is recorded through `outboundCallRecorder` of `@beonauto/specs` with the next number of the run's calls and the fields `tools.startOf` gives, the server, the tool, the size and digest of the arguments and, where the server records content, the arguments; a second host's start is refused and it sends nothing. Then one call, `tools.callOnce`, answers the call whole: not offered, a failed attempt `tool_not_offered`, the request staying open; not opened, `server_failure`; or answered, with the call's fields, and for a result the message the tool names at the `sent` pointers, `delivered_as`, and where replies are read, `replies_in` (`sent-messages.ts`). `delivery_ended` follows, caused by the start. +- Otherwise the next attempt (`src/delivery`): the arguments are rendered from the request's record, `deliveryVariablesOf`, so every attempt sends the same; `delivery_started` is recorded through `outboundCallRecorder` of `@beonauto/definitions` with the next number of the run's calls and the fields `tools.startOf` gives, the server, the tool, the size and digest of the arguments and, where the server records content, the arguments; a second host's start is refused and it sends nothing. Then one call, `tools.callOnce`, answers the call whole: not offered, a failed attempt `tool_not_offered`, the request staying open; not opened, `server_failure`; or answered, with the call's fields, and for a result the message the tool names at the `sent` pointers, `delivered_as`, and where replies are read, `replies_in` (`sent-messages.ts`). `delivery_ended` follows, caused by the start. Attempts follow `attemptSchedule` (`src/schedule/attempt-schedule.ts`): five, the first at once and the others 1, 2, 4 and 8 minutes after the one before, a server's `Retry-After` honoured up to 8 minutes. Arguments past 16 KiB are refused and not tried again. A request whose attempts are spent stays open until it is answered or expires; a notification ends `undelivered`. ## Reading replies -`conversations` is the second projection (`src/conversations`), table `conversations_1`, one row per brain, server, reading tool and conversation key, keyed `server/tool/key`: made or woken by a `delivery_ended` with `replies_in`, its cursor moved by a `replies_read` on the brain's `conversation-calls` stream, and its cadence, `open`, `active_at`, `reads` and `next_read_at`, advanced by its reader through the ledger's `advanceRow`, a compare-and-set on `joined_by`, the id of the delivery that last joined the row, so a read never rests a conversation a request joined while it was in flight. `conversationsDue({ ledger, tools })` is its due work, in the outbound lane. A read advances the row a minute ahead, reads the open requests of the conversation, at most 100, and keeps those that stand `delivered` or `retrying`; with none it rests the row. Otherwise it reads the replies once for all of them, through the reading of the oldest, its recorded `replies`: the arguments rendered over its `to`, its `sent` and the cursor `since`, one `callOnce` of `replies.tool`, the list at `read.list` and each reply at the `each` pointers. A reply belongs to the request whose message it answers, or to the one open request of a conversation, and is refused as `ambiguous` when several are open; a reply from anyone but the answerer is passed over. Its words are mapped by the request's rule and checked against its answer schema (`src/replies`): an answer is `reply_taken`, through `replyRecorder` of `@beonauto/specs`, and settles the run answered by the brain, `brain:`, with the reply's identity as evidence; a reply that is no answer, does not fit, or is past 8 KiB is `reply_refused`, ten at most a request, and the party is told how to answer through `tell`, three times at most, a telling recorded as `telling_started` before it is sent and `telling_ended` after. A read that found a reply or failed is one `replies_read`, with the call's fields and the cursor; a read that found nothing writes nothing. The cadence is 5 seconds after a request joins, then 10, 20 and 40, every minute to the eighteenth read and every 5 minutes after, never sooner than `wait`, and as long as a `Retry-After` asks, an hour at most. +`conversations` is the second projection (`src/conversations`), table `conversations_2`, one row per brain, server, reading tool and conversation key, keyed `server/tool/key`: made or woken by a `delivery_ended` with `replies_in`, its cursor moved by a `replies_read` on the brain's `conversation-calls` stream, and its cadence, `open`, `active_at`, `reads` and `next_read_at`, advanced by its reader through the ledger's `advanceRow`, a compare-and-set on `joined_by`, the id of the delivery that last joined the row, so a read never rests a conversation a request joined while it was in flight. `conversationsDue({ ledger, tools })` is its due work, in the outbound lane. A read advances the row a minute ahead, reads the open requests of the conversation, at most 100, and keeps those that stand `delivered` or `retrying`; with none it rests the row. Otherwise it reads the replies once for all of them, through the reading of the oldest, its recorded `replies`: the arguments rendered over its `to`, its `sent` and the cursor `since`, one `callOnce` of `replies.tool`, the list at `read.list` and each reply at the `each` pointers. A reply belongs to the request whose message it answers, or to the one open request of a conversation, and is refused as `ambiguous` when several are open; a reply from anyone but the answerer is passed over. Its words are mapped by the request's rule and checked against its answer schema (`src/replies`): an answer is `reply_taken`, through `replyRecorder` of `@beonauto/definitions`, and settles the run answered by the brain, `brain:`, with the reply's identity as evidence; a reply that is no answer, does not fit, or is past 8 KiB is `reply_refused`, ten at most a request, and the party is told how to answer through `tell`, three times at most, a telling recorded as `telling_started` before it is sent and `telling_ended` after. A read that found a reply or failed is one `replies_read`, with the call's fields and the cursor; a read that found nothing writes nothing. The cadence is 5 seconds after a request joins, then 10, 20 and 40, every minute to the eighteenth read and every 5 minutes after, never sooner than `wait`, and as long as a `Retry-After` asks, an hour at most. ## Answering and listing -`answerInteraction` is `answer_interaction`, `POST /executions/{execution_id}/answer` under `brain:write`. The answer is checked against the answer schema the request recorded, with pointers under `/answer`, 64 KiB as JSON and 512 levels at most, and settles the run `succeeded`, the answer the output and the record `{ answered_by, claimed_for, answered_at }`, `answered_by` the caller's id, `claimed_for` what the caller says it answers for, at most 256 bytes and never checked. The settlement's key holds the answer's digest, so the same answer again answers the run as it stands and another one is `conflict`, as is an answer to a request that has ended, or one a reply answered first. `listInteractions` is `list_interactions`, `GET /interactions` under `brain:read`: the open requests of the brain, newest first, filtered by `to` and `function`, in pages, each with its `delivery`, its `answer_schema`, and for a request whose function reads replies its `conversation`, `answerer` and `reply_refusals`. +`answerInteraction` is `answer_interaction`, `POST /runs/{run_id}/answer` under `brain:write`. The answer is checked against the answer schema the request recorded, with pointers under `/answer`, 64 KiB as JSON and 512 levels at most, and settles the run `succeeded`, the answer the output and the record `{ answered_by, claimed_for, answered_at }`, `answered_by` the caller's id, `claimed_for` what the caller says it answers for, at most 256 bytes and never checked. The settlement's key holds the answer's digest, so the same answer again answers the run as it stands and another one is `conflict`, as is an answer to a request that has ended, or one a reply answered first. `listInteractions` is `list_interactions`, `GET /interactions` under `brain:read`: the open requests of the brain, newest first, filtered by `to` and `function`, in pages, each with its `delivery`, its `answer_schema`, and for a request whose function reads replies its `conversation`, `answerer` and `reply_refusals`. ## Words @@ -111,4 +111,4 @@ The capability's run words show the deferral as `interaction_requested`, a type ## Source -`src/document` reads the definition, `src/route` the delivery and the reading, `src/run` renders and defers a request, `src/requests` holds the projection of open requests and the two operations, `src/delivery` the attempt as a recorded call, `src/schedule` the due work and its schedule, `src/conversations` the projection of conversations and the reading, `src/replies` the reply rule, the taking and the tellings, `src/primitive` the capability, its guide, the public reference page served to agents as `interaction-function`, and its words, and `src/testing` what the tests share. +`src/document` reads the definition, `src/route` the delivery and the reading, `src/run` renders and defers a request, `src/requests` holds the projection of open requests and the two operations, `src/delivery` the attempt as a recorded call, `src/schedule` the due work and its schedule, `src/conversations` the projection of conversations and the reading, `src/replies` the reply rule, the taking and the tellings, `src/capability` the capability, its guide, the public reference page served to agents as `interaction-function`, and its words, and `src/testing` what the tests share. diff --git a/primitives/interaction/package.json b/capabilities/interaction/package.json similarity index 93% rename from primitives/interaction/package.json rename to capabilities/interaction/package.json index 992c3fbe8..2413ccfed 100644 --- a/primitives/interaction/package.json +++ b/capabilities/interaction/package.json @@ -14,9 +14,9 @@ "test": "vitest run --coverage" }, "dependencies": { + "@beonauto/definitions": "workspace:*", "@beonauto/mcp": "workspace:*", "@beonauto/operations": "workspace:*", - "@beonauto/specs": "workspace:*", "@beonauto/workflow-engine": "workspace:*", "effect": "catalog:", "liquidjs": "10.29.0" diff --git a/primitives/interaction/src/primitive/definition-reading.ts b/capabilities/interaction/src/capability/definition-reading.ts similarity index 92% rename from primitives/interaction/src/primitive/definition-reading.ts rename to capabilities/interaction/src/capability/definition-reading.ts index b8c2c7eb9..62f6a56f1 100644 --- a/primitives/interaction/src/primitive/definition-reading.ts +++ b/capabilities/interaction/src/capability/definition-reading.ts @@ -1,6 +1,6 @@ +import type { DefinitionSummary } from '@beonauto/definitions'; +import { issueText } from '@beonauto/definitions/document'; import { InvalidInput } from '@beonauto/operations'; -import type { DefinitionSummary } from '@beonauto/specs'; -import { issueText } from '@beonauto/specs/document'; import { Effect, Result } from 'effect'; import { parseInteractionDocument } from '../document/document-parsing.ts'; diff --git a/primitives/interaction/src/primitive/interaction-function.test.ts b/capabilities/interaction/src/capability/interaction-function.test.ts similarity index 84% rename from primitives/interaction/src/primitive/interaction-function.test.ts rename to capabilities/interaction/src/capability/interaction-function.test.ts index 6c1163201..b08c7ebf5 100644 --- a/primitives/interaction/src/primitive/interaction-function.test.ts +++ b/capabilities/interaction/src/capability/interaction-function.test.ts @@ -1,4 +1,4 @@ -import { makeSpecPresenters } from '@beonauto/specs'; +import { makeDefinitionPresenters } from '@beonauto/definitions'; import { Effect } from 'effect'; import { describe, expect, it } from 'vitest'; @@ -13,12 +13,12 @@ import { describeAnswer, interactionRunWords } from './interaction-words.ts'; describe('the interaction capability', () => { it('prepares a question to finish later within its expiry, and a notification to the inbox to finish at once', () => { - const { primitive } = interactionHarness(); + const { capability } = interactionHarness(); expect([ - Effect.runSync(primitive.prepare(approvalDocument())), - Effect.runSync(primitive.prepare(notificationDocument())), - Effect.runSync(primitive.prepare(notificationDocument(chatDelivery))), + Effect.runSync(capability.prepare(approvalDocument())), + Effect.runSync(capability.prepare(notificationDocument())), + Effect.runSync(capability.prepare(notificationDocument(chatDelivery))), ]).toMatchObject([ { finishesLater: true, @@ -35,8 +35,8 @@ describe('the interaction capability', () => { }); it('reaches outside and may change it only once the server offers a tool server', () => { - const offline = interactionHarness().primitive; - const online = interactionHarness({ tools: fakeTools() }).primitive; + const offline = interactionHarness().capability; + const online = interactionHarness({ tools: fakeTools() }).capability; expect([offline.reachesOutside, offline.mayChangeOutside, online.reachesOutside, online.mayChangeOutside]).toEqual([ false, @@ -47,11 +47,11 @@ describe('the interaction capability', () => { }); it('refuses a document it cannot read, counting its problems', () => { - const { primitive } = interactionHarness(); + const { capability } = interactionHarness(); expect([ - Effect.runSync(Effect.flip(primitive.prepare("---\nto: 'x'\n---\n"))).detail, - Effect.runSync(Effect.flip(primitive.prepare('Hello'))).detail, + Effect.runSync(Effect.flip(capability.prepare("---\nto: 'x'\n---\n"))).detail, + Effect.runSync(Effect.flip(capability.prepare('Hello'))).detail, ]).toEqual([ 'The interaction function definition has 2 problems', 'The interaction function definition has a problem', @@ -150,8 +150,8 @@ describe('the words of an answer', () => { }); it('name the deferral in the history by the type the brain reserves for requests', () => { - const presenters = makeSpecPresenters([interactionHarness().primitive]); + const presenters = makeDefinitionPresenters([interactionHarness().capability]); - expect(presenters[0]?.publicNames['execution_deferred']).toEqual(['execution_deferred', 'interaction_requested']); + expect(presenters[0]?.publicNames['run_deferred']).toEqual(['run_deferred', 'interaction_requested']); }); }); diff --git a/primitives/interaction/src/primitive/interaction-function.ts b/capabilities/interaction/src/capability/interaction-function.ts similarity index 63% rename from primitives/interaction/src/primitive/interaction-function.ts rename to capabilities/interaction/src/capability/interaction-function.ts index 58cd280d3..c8fa3c040 100644 --- a/primitives/interaction/src/primitive/interaction-function.ts +++ b/capabilities/interaction/src/capability/interaction-function.ts @@ -1,25 +1,30 @@ -import { definePrimitive, functionCategoryLabels, functionResourceLabels, type Primitive } from '@beonauto/specs'; +import { + defineCapability, + functionCategoryLabels, + functionResourceLabels, + type Capability, +} from '@beonauto/definitions'; import { finishesLater, interactionRun } from '../run/interaction-run.ts'; import type { InteractionPorts } from '../run/request-reach.ts'; import { parse, summarize } from './definition-reading.ts'; +import { interactionType } from './interaction-type.ts'; import { describeAnswer, interactionRunWords } from './interaction-words.ts'; -import { interactionPrimitive } from './primitive-name.ts'; import { cancelledRequest } from './request-cancels.ts'; -export function makeInteractionFunctionAdapter(ports: InteractionPorts): Primitive { +export function makeInteractionFunctionAdapter(ports: InteractionPorts): Capability { const run = interactionRun(ports); const reachesOutside = ports.tools.configured; - return definePrimitive({ - name: interactionPrimitive, - title: functionCategoryLabels.interact, + return defineCapability({ + type: interactionType, + title: functionCategoryLabels.interaction, guide: { name: 'interaction-function' }, - noun: { one: functionResourceLabels.interact.singular, other: functionResourceLabels.interact.plural }, + noun: { one: functionResourceLabels.interaction.singular, other: functionResourceLabels.interaction.plural }, describeOutput: describeAnswer, mediaType: 'text/markdown', parse, summarize, - execute: (document, input, context) => run(document, input, context), + run: (document, input, context) => run(document, input, context), whenCancelled: 'finish', reachesOutside, mayChangeOutside: reachesOutside, diff --git a/capabilities/interaction/src/capability/interaction-type.ts b/capabilities/interaction/src/capability/interaction-type.ts new file mode 100644 index 000000000..c3c9e5cb6 --- /dev/null +++ b/capabilities/interaction/src/capability/interaction-type.ts @@ -0,0 +1 @@ +export const interactionType = 'interaction'; diff --git a/primitives/interaction/src/primitive/interaction-words.ts b/capabilities/interaction/src/capability/interaction-words.ts similarity index 98% rename from primitives/interaction/src/primitive/interaction-words.ts rename to capabilities/interaction/src/capability/interaction-words.ts index d7049f60f..80ecbeede 100644 --- a/primitives/interaction/src/primitive/interaction-words.ts +++ b/capabilities/interaction/src/capability/interaction-words.ts @@ -1,7 +1,7 @@ import { Buffer } from 'node:buffer'; +import { defaultRunWords, inWords, type RunAccount, type RunWords } from '@beonauto/definitions'; import { asSentence } from '@beonauto/operations'; -import { defaultRunWords, inWords, type RunAccount, type RunWords } from '@beonauto/specs'; import type { Schema } from 'effect'; import { routeOf, throughWords } from '../route/routes.ts'; diff --git a/primitives/interaction/src/primitive/request-cancels.test.ts b/capabilities/interaction/src/capability/request-cancels.test.ts similarity index 91% rename from primitives/interaction/src/primitive/request-cancels.test.ts rename to capabilities/interaction/src/capability/request-cancels.test.ts index 1846fbf9d..b3ed59f59 100644 --- a/primitives/interaction/src/primitive/request-cancels.test.ts +++ b/capabilities/interaction/src/capability/request-cancels.test.ts @@ -1,5 +1,5 @@ +import { deferredCanceller, replyRecorder } from '@beonauto/definitions'; import { memoryLedger } from '@beonauto/operations/testing'; -import { deferredCanceller, replyRecorder } from '@beonauto/specs'; import { Effect } from 'effect'; import { describe, expect, it } from 'vitest'; @@ -22,7 +22,7 @@ const anyTime: unknown = expect.any(String); const address = { org: 'acme', brain: 'alpha', id: askedRunId }; -const asked = { execution: address, kind: 'requested', reason: 'Not needed any more' } as const; +const asked = { run: address, kind: 'requested', reason: 'Not needed any more' } as const; const cancelledAsAsked = { status: 'rejected', reason: 'cancelled', kind: 'requested', detail: asked.reason }; @@ -71,7 +71,7 @@ describe('a cancel asked after a reply answered and before its run was settled', await brain.cancel(askedRunId); const afterTheCancel = await brain.firstOpen(); await Effect.runPromise( - deferredCanceller([brain.primitive], brain.ledger.service)(address, { ...asked, by: 'acme-admin' }, lineage), + deferredCanceller([brain.capability], brain.ledger.service)(address, { ...asked, by: 'acme-admin' }, lineage), ); expect(afterTheCancel).toMatchObject({ standing: 'answered' }); @@ -102,7 +102,7 @@ describe('a cancel asked before a reply is read', () => { ), ); await Effect.runPromise( - deferredCanceller([brain.primitive], brain.ledger.service)(address, { ...asked, by: 'acme-admin' }, lineage), + deferredCanceller([brain.capability], brain.ledger.service)(address, { ...asked, by: 'acme-admin' }, lineage), ); expect(taken).toMatchObject({ detail: 'The run is being cancelled, so it takes no reply' }); @@ -119,7 +119,7 @@ describe('a cancel whose notification is delivered between its read of the run a await racing.started(); await delivering.brain.cancel(askedRunId); - await racing.cancelSettled(delivering.brain.primitive); + await racing.cancelSettled(delivering.brain.capability); expect(await delivering.brain.runOf(askedRunId)).toMatchObject({ output: { status: 'succeeded', output: {}, record: { delivered_at: anyTime } }, diff --git a/primitives/interaction/src/primitive/request-cancels.ts b/capabilities/interaction/src/capability/request-cancels.ts similarity index 87% rename from primitives/interaction/src/primitive/request-cancels.ts rename to capabilities/interaction/src/capability/request-cancels.ts index ddba9c0de..fc786acc0 100644 --- a/primitives/interaction/src/primitive/request-cancels.ts +++ b/capabilities/interaction/src/capability/request-cancels.ts @@ -1,4 +1,4 @@ -import { cancelledAsAsked, type CancelledRun, type Settlement } from '@beonauto/specs'; +import { cancelledAsAsked, type CancelledRun, type Settlement } from '@beonauto/definitions'; import { requestRecordOf, takesAnswer } from '../run/request-record.ts'; import { answeredSettlement, deliveredSettlement } from '../schedule/request-endings.ts'; @@ -10,7 +10,7 @@ export function cancelledRequest(run: CancelledRun): Settlement { return cancelledAsAsked(run); } if (broughtAnswer !== null) { - return answeredSettlement(run.execution, broughtAnswer); + return answeredSettlement(run.run, broughtAnswer); } return deliveredAt !== null && !takesAnswer(request) ? deliveredSettlement(deliveredAt) : cancelledAsAsked(run); } diff --git a/primitives/interaction/src/conversations/cadence.test.ts b/capabilities/interaction/src/conversations/cadence.test.ts similarity index 100% rename from primitives/interaction/src/conversations/cadence.test.ts rename to capabilities/interaction/src/conversations/cadence.test.ts diff --git a/primitives/interaction/src/conversations/cadence.ts b/capabilities/interaction/src/conversations/cadence.ts similarity index 100% rename from primitives/interaction/src/conversations/cadence.ts rename to capabilities/interaction/src/conversations/cadence.ts diff --git a/primitives/interaction/src/conversations/conversation-keys.ts b/capabilities/interaction/src/conversations/conversation-keys.ts similarity index 81% rename from primitives/interaction/src/conversations/conversation-keys.ts rename to capabilities/interaction/src/conversations/conversation-keys.ts index 62bc5f8da..cd6529441 100644 --- a/primitives/interaction/src/conversations/conversation-keys.ts +++ b/capabilities/interaction/src/conversations/conversation-keys.ts @@ -1,4 +1,4 @@ -import type { RepliesIn } from '@beonauto/specs'; +import type { RepliesIn } from '@beonauto/definitions'; export function conversationKeyOf({ server, tool, key }: RepliesIn): string { return `${server}/${tool}/${key}`; diff --git a/primitives/interaction/src/conversations/conversation-parts.ts b/capabilities/interaction/src/conversations/conversation-parts.ts similarity index 100% rename from primitives/interaction/src/conversations/conversation-parts.ts rename to capabilities/interaction/src/conversations/conversation-parts.ts diff --git a/primitives/interaction/src/conversations/conversation-reads.ts b/capabilities/interaction/src/conversations/conversation-reads.ts similarity index 100% rename from primitives/interaction/src/conversations/conversation-reads.ts rename to capabilities/interaction/src/conversations/conversation-reads.ts diff --git a/primitives/interaction/src/conversations/conversation-rows.test.ts b/capabilities/interaction/src/conversations/conversation-rows.test.ts similarity index 95% rename from primitives/interaction/src/conversations/conversation-rows.test.ts rename to capabilities/interaction/src/conversations/conversation-rows.test.ts index eebdd4bc9..f03691505 100644 --- a/primitives/interaction/src/conversations/conversation-rows.test.ts +++ b/capabilities/interaction/src/conversations/conversation-rows.test.ts @@ -3,9 +3,9 @@ import { describe, expect, it } from 'vitest'; import { conversations } from './conversation-rows.ts'; -const stream = { kind: 'executions', id: '0199a3c4-7d2e-7c1a-9b3f-2f1e0d9c8b7a' }; +const stream = { kind: 'runs', id: '0199a3c4-7d2e-7c1a-9b3f-2f1e0d9c8b7a' }; -const ofTheBrain = { primitive: 'interaction', name: 'approve-brief', spec_version: 1, by: 'brain:alpha' }; +const ofTheBrain = { definition_type: 'interaction', name: 'approve-brief', definition_version: 1, by: 'brain:alpha' }; const deliveredAs = { conversation: 'C0123', id: '1699.1' }; diff --git a/primitives/interaction/src/conversations/conversation-rows.ts b/capabilities/interaction/src/conversations/conversation-rows.ts similarity index 96% rename from primitives/interaction/src/conversations/conversation-rows.ts rename to capabilities/interaction/src/conversations/conversation-rows.ts index 16a77e01c..4bc48d90c 100644 --- a/primitives/interaction/src/conversations/conversation-rows.ts +++ b/capabilities/interaction/src/conversations/conversation-rows.ts @@ -1,3 +1,4 @@ +import { runEventOf, type RepliesIn } from '@beonauto/definitions'; import { ConversationCallEventSchema, conversationCallsKind, @@ -5,7 +6,6 @@ import { type RepliesRead, } from '@beonauto/mcp'; import type { KeyedProjection, ProjectedMessage, ProjectedRow } from '@beonauto/operations'; -import { executionEventOf, type RepliesIn } from '@beonauto/specs'; import { Option, Schema } from 'effect'; import { firstReadWaitMs } from './cadence.ts'; @@ -71,7 +71,7 @@ function readOf({ server, tool, conversation, since }: RepliesRead): Read { } function factOf(event: unknown): ConversationFact | undefined { - const fact = executionEventOf(event); + const fact = runEventOf(event); if (fact?.type === 'delivery_ended') { const place = fact.replies_in; return place === undefined ? undefined : { kind: 'join', place, at: Date.parse(fact.at) }; @@ -104,8 +104,8 @@ function rowAfter(row: ProjectedRow | undefined, event: unknown, message: Projec export const conversations: KeyedProjection = { name: conversationsName, - version: 1, - kinds: ['executions', conversationCallsKind], + version: 2, + kinds: ['runs', conversationCallsKind], types: ['delivery_ended', 'replies_read'], columns: [ { name: 'server', kind: 'text' }, diff --git a/primitives/interaction/src/conversations/conversations-due.ts b/capabilities/interaction/src/conversations/conversations-due.ts similarity index 100% rename from primitives/interaction/src/conversations/conversations-due.ts rename to capabilities/interaction/src/conversations/conversations-due.ts diff --git a/primitives/interaction/src/conversations/open-in-conversation.ts b/capabilities/interaction/src/conversations/open-in-conversation.ts similarity index 100% rename from primitives/interaction/src/conversations/open-in-conversation.ts rename to capabilities/interaction/src/conversations/open-in-conversation.ts diff --git a/primitives/interaction/src/conversations/read-answers.ts b/capabilities/interaction/src/conversations/read-answers.ts similarity index 100% rename from primitives/interaction/src/conversations/read-answers.ts rename to capabilities/interaction/src/conversations/read-answers.ts diff --git a/primitives/interaction/src/conversations/read-cadence.ts b/capabilities/interaction/src/conversations/read-cadence.ts similarity index 100% rename from primitives/interaction/src/conversations/read-cadence.ts rename to capabilities/interaction/src/conversations/read-cadence.ts diff --git a/primitives/interaction/src/conversations/read-failures.test.ts b/capabilities/interaction/src/conversations/read-failures.test.ts similarity index 97% rename from primitives/interaction/src/conversations/read-failures.test.ts rename to capabilities/interaction/src/conversations/read-failures.test.ts index da14eae6a..230fd3c08 100644 --- a/primitives/interaction/src/conversations/read-failures.test.ts +++ b/capabilities/interaction/src/conversations/read-failures.test.ts @@ -93,7 +93,7 @@ describe('a conversation whose reading tool its operator disallowed', () => { describe('a conversation with nothing open in it', () => { it('rests, with no call, once its one request was answered through answer_interaction', async () => { const brain = await deliveredInThread(); - await brain.call(answerInteraction, { execution_id: runId, answer: { choice: 'approve' } }); + await brain.call(answerInteraction, { run_id: runId, answer: { choice: 'approve' } }); await brain.performReads(Date.now() + farAhead); const [row] = await conversationRows(brain.ledger); diff --git a/primitives/interaction/src/conversations/read-records.ts b/capabilities/interaction/src/conversations/read-records.ts similarity index 100% rename from primitives/interaction/src/conversations/read-records.ts rename to capabilities/interaction/src/conversations/read-records.ts diff --git a/primitives/interaction/src/conversations/read-timing.test.ts b/capabilities/interaction/src/conversations/read-timing.test.ts similarity index 100% rename from primitives/interaction/src/conversations/read-timing.test.ts rename to capabilities/interaction/src/conversations/read-timing.test.ts diff --git a/primitives/interaction/src/conversations/reading-turns.ts b/capabilities/interaction/src/conversations/reading-turns.ts similarity index 100% rename from primitives/interaction/src/conversations/reading-turns.ts rename to capabilities/interaction/src/conversations/reading-turns.ts diff --git a/primitives/interaction/src/conversations/reading.test.ts b/capabilities/interaction/src/conversations/reading.test.ts similarity index 100% rename from primitives/interaction/src/conversations/reading.test.ts rename to capabilities/interaction/src/conversations/reading.test.ts diff --git a/primitives/interaction/src/delivery/attempt-delivery.ts b/capabilities/interaction/src/delivery/attempt-delivery.ts similarity index 94% rename from primitives/interaction/src/delivery/attempt-delivery.ts rename to capabilities/interaction/src/delivery/attempt-delivery.ts index 95d3aaa0b..a03dd4b82 100644 --- a/primitives/interaction/src/delivery/attempt-delivery.ts +++ b/capabilities/interaction/src/delivery/attempt-delivery.ts @@ -1,5 +1,5 @@ import type { DeliveryCall, StartedFields } from '@beonauto/mcp'; -import { deliveryIdKey, executionIdKey } from '@beonauto/mcp/policy'; +import { deliveryIdKey, runIdKey } from '@beonauto/mcp/policy'; import { Effect, Result, type Schema } from 'effect'; import { argumentsFailureWords, deliveryVariablesOf, renderedArguments } from '../route/rendered-arguments.ts'; @@ -39,7 +39,7 @@ function callOf({ request, record }: DeliveryPlan, input: DeliveryCall['input']) brain: address.brain, reference: { server: record.deliver.server, tool: record.deliver.tool }, input, - meta: { [executionIdKey]: address.id, [deliveryIdKey]: row.request_id }, + meta: { [runIdKey]: address.id, [deliveryIdKey]: row.request_id }, }; } diff --git a/primitives/interaction/src/delivery/attempt-end.ts b/capabilities/interaction/src/delivery/attempt-end.ts similarity index 95% rename from primitives/interaction/src/delivery/attempt-end.ts rename to capabilities/interaction/src/delivery/attempt-end.ts index 2a2c343c7..8313ec431 100644 --- a/primitives/interaction/src/delivery/attempt-end.ts +++ b/capabilities/interaction/src/delivery/attempt-end.ts @@ -1,5 +1,5 @@ +import type { DeliveryEndedFact } from '@beonauto/definitions'; import type { AnsweredOnce, CalledOnce } from '@beonauto/mcp'; -import type { DeliveryEndedFact } from '@beonauto/specs'; import type { DeliveringRecord } from '../run/request-record.ts'; import { endOfCall } from './call-ends.ts'; diff --git a/primitives/interaction/src/delivery/attempt-facts.ts b/capabilities/interaction/src/delivery/attempt-facts.ts similarity index 98% rename from primitives/interaction/src/delivery/attempt-facts.ts rename to capabilities/interaction/src/delivery/attempt-facts.ts index 46d53cc53..373ec8fb2 100644 --- a/primitives/interaction/src/delivery/attempt-facts.ts +++ b/capabilities/interaction/src/delivery/attempt-facts.ts @@ -1,5 +1,5 @@ +import type { DeliveryEndedFact, DeliveryStartedFact } from '@beonauto/definitions'; import type { StartedFields } from '@beonauto/mcp'; -import type { DeliveryEndedFact, DeliveryStartedFact } from '@beonauto/specs'; import { attemptInFlightMs } from '../requests/open-requests.ts'; import type { OpenRequestRow } from '../requests/request-rows.ts'; diff --git a/primitives/interaction/src/delivery/call-ends.test.ts b/capabilities/interaction/src/delivery/call-ends.test.ts similarity index 100% rename from primitives/interaction/src/delivery/call-ends.test.ts rename to capabilities/interaction/src/delivery/call-ends.test.ts diff --git a/primitives/interaction/src/delivery/call-ends.ts b/capabilities/interaction/src/delivery/call-ends.ts similarity index 92% rename from primitives/interaction/src/delivery/call-ends.ts rename to capabilities/interaction/src/delivery/call-ends.ts index 3620ff6e1..b00a9a670 100644 --- a/primitives/interaction/src/delivery/call-ends.ts +++ b/capabilities/interaction/src/delivery/call-ends.ts @@ -1,5 +1,5 @@ +import type { DeliveryBecause } from '@beonauto/definitions'; import type { AnsweredOnce, CallAnswer } from '@beonauto/mcp'; -import type { DeliveryBecause } from '@beonauto/specs'; const becauseOf = { tool_error: 'tool_error', diff --git a/primitives/interaction/src/delivery/delivery-schedule.test.ts b/capabilities/interaction/src/delivery/delivery-schedule.test.ts similarity index 97% rename from primitives/interaction/src/delivery/delivery-schedule.test.ts rename to capabilities/interaction/src/delivery/delivery-schedule.test.ts index 935cb1661..aa0b78273 100644 --- a/primitives/interaction/src/delivery/delivery-schedule.test.ts +++ b/capabilities/interaction/src/delivery/delivery-schedule.test.ts @@ -14,7 +14,7 @@ const failing = { outcome: 'server_failure', detail: 'The MCP server answered HT const Deferring = Schema.Struct({ type: Schema.Literal('finish'), - result: Schema.Struct({ type: Schema.Literal('execution_deferred'), record: Schema.JsonObject }), + result: Schema.Struct({ type: Schema.Literal('run_deferred'), record: Schema.JsonObject }), }); const isDeferring = Schema.is(Deferring); @@ -44,7 +44,7 @@ async function endedOf(brain: InteractionHarness): Promise { const { records } = await Effect.runPromise( brain.ledger.service.readRecorded( { org: 'acme', brain: 'alpha' }, - { kind: 'run', execution: askedRunId }, + { kind: 'run', run: askedRunId }, { order: 'asc', limit: 20, types: ['delivery_ended'], dataOf: ['delivery_ended'] }, ), ); diff --git a/primitives/interaction/src/delivery/kept-messages.test.ts b/capabilities/interaction/src/delivery/kept-messages.test.ts similarity index 94% rename from primitives/interaction/src/delivery/kept-messages.test.ts rename to capabilities/interaction/src/delivery/kept-messages.test.ts index 9618a250f..1a24b4031 100644 --- a/primitives/interaction/src/delivery/kept-messages.test.ts +++ b/capabilities/interaction/src/delivery/kept-messages.test.ts @@ -1,4 +1,4 @@ -import { executionEventOf } from '@beonauto/specs'; +import { runEventOf } from '@beonauto/definitions'; import { Effect } from 'effect'; import { describe, expect, it } from 'vitest'; @@ -19,9 +19,9 @@ const takesNoReply = ', so the request takes no reply and waits for answer_inter async function deliveryEndOf(brain: ChatHarness) { const { records } = await Effect.runPromise( - brain.ledger.service.readRecorded(alpha, { kind: 'run', execution: runId }, { order: 'asc', limit: 20 }), + brain.ledger.service.readRecorded(alpha, { kind: 'run', run: runId }, { order: 'asc', limit: 20 }), ); - return records.map(({ data }) => executionEventOf(data)).find((event) => event?.type === 'delivery_ended'); + return records.map(({ data }) => runEventOf(data)).find((event) => event?.type === 'delivery_ended'); } async function deliveredWith(answer?: FakeAnswer, document?: string) { diff --git a/primitives/interaction/src/delivery/sent-messages.test.ts b/capabilities/interaction/src/delivery/sent-messages.test.ts similarity index 100% rename from primitives/interaction/src/delivery/sent-messages.test.ts rename to capabilities/interaction/src/delivery/sent-messages.test.ts diff --git a/primitives/interaction/src/delivery/sent-messages.ts b/capabilities/interaction/src/delivery/sent-messages.ts similarity index 97% rename from primitives/interaction/src/delivery/sent-messages.ts rename to capabilities/interaction/src/delivery/sent-messages.ts index b0c0c9385..aebc026fd 100644 --- a/primitives/interaction/src/delivery/sent-messages.ts +++ b/capabilities/interaction/src/delivery/sent-messages.ts @@ -1,8 +1,8 @@ import { Buffer } from 'node:buffer'; +import { refusingForbiddenCharacters, type DeliveredAs, type RepliesIn } from '@beonauto/definitions'; +import { parsedTemplate } from '@beonauto/definitions/template'; import type { CallAnswer } from '@beonauto/mcp'; -import { refusingForbiddenCharacters, type DeliveredAs, type RepliesIn } from '@beonauto/specs'; -import { parsedTemplate } from '@beonauto/specs/template'; import { Option, Result, Schema } from 'effect'; import { interactionEngine } from '../document/request-templates.ts'; diff --git a/primitives/interaction/src/delivery/tool-deliveries.test.ts b/capabilities/interaction/src/delivery/tool-deliveries.test.ts similarity index 97% rename from primitives/interaction/src/delivery/tool-deliveries.test.ts rename to capabilities/interaction/src/delivery/tool-deliveries.test.ts index 261a48c6c..fec6fc1bd 100644 --- a/primitives/interaction/src/delivery/tool-deliveries.test.ts +++ b/capabilities/interaction/src/delivery/tool-deliveries.test.ts @@ -15,7 +15,7 @@ async function deliveryFacts(brain: InteractionHarness): Promise { brain: 'alpha', reference: { server: 'chat', tool: 'post_message' }, input: { channel: '#approvals-ada', text: 'Please review the brief for Spring.' }, - meta: { 'com.beonauto/execution_id': askedRunId, 'com.beonauto/delivery_id': anyText }, + meta: { 'com.beonauto/run_id': askedRunId, 'com.beonauto/delivery_id': anyText }, }, ]); expect(await deliveryFacts(brain)).toMatchObject([ diff --git a/primitives/interaction/src/document/document-parsing.test.ts b/capabilities/interaction/src/document/document-parsing.test.ts similarity index 98% rename from primitives/interaction/src/document/document-parsing.test.ts rename to capabilities/interaction/src/document/document-parsing.test.ts index 0512aa6ce..7e8fb7fc5 100644 --- a/primitives/interaction/src/document/document-parsing.test.ts +++ b/capabilities/interaction/src/document/document-parsing.test.ts @@ -1,4 +1,4 @@ -import { issueText } from '@beonauto/specs/document'; +import { issueText } from '@beonauto/definitions/document'; import { Result } from 'effect'; import { describe, expect, it } from 'vitest'; diff --git a/primitives/interaction/src/document/document-parsing.ts b/capabilities/interaction/src/document/document-parsing.ts similarity index 97% rename from primitives/interaction/src/document/document-parsing.ts rename to capabilities/interaction/src/document/document-parsing.ts index 0894c4cfb..3d2b74055 100644 --- a/primitives/interaction/src/document/document-parsing.ts +++ b/capabilities/interaction/src/document/document-parsing.ts @@ -5,8 +5,8 @@ import { type DocumentIssue, type DocumentParts, type ReadFrontMatter, -} from '@beonauto/specs/document'; -import type { ParsedTemplate } from '@beonauto/specs/template'; +} from '@beonauto/definitions/document'; +import type { ParsedTemplate } from '@beonauto/definitions/template'; import { Result } from 'effect'; import type { ReplyRule } from '../replies/reply-rule.ts'; diff --git a/primitives/interaction/src/document/expiry.ts b/capabilities/interaction/src/document/expiry.ts similarity index 100% rename from primitives/interaction/src/document/expiry.ts rename to capabilities/interaction/src/document/expiry.ts diff --git a/primitives/interaction/src/document/front-matter.ts b/capabilities/interaction/src/document/front-matter.ts similarity index 98% rename from primitives/interaction/src/document/front-matter.ts rename to capabilities/interaction/src/document/front-matter.ts index fdb36e826..88c7af4e0 100644 --- a/primitives/interaction/src/document/front-matter.ts +++ b/capabilities/interaction/src/document/front-matter.ts @@ -1,4 +1,4 @@ -import type { FrontMatterSection, FrontMatterShape } from '@beonauto/specs/document'; +import type { FrontMatterSection, FrontMatterShape } from '@beonauto/definitions/document'; import { Schema } from 'effect'; import { WrittenRuleSchema } from '../replies/reply-rule.ts'; diff --git a/primitives/interaction/src/document/interaction-document.ts b/capabilities/interaction/src/document/interaction-document.ts similarity index 80% rename from primitives/interaction/src/document/interaction-document.ts rename to capabilities/interaction/src/document/interaction-document.ts index b0f6f2ba2..4d333d10e 100644 --- a/primitives/interaction/src/document/interaction-document.ts +++ b/capabilities/interaction/src/document/interaction-document.ts @@ -1,5 +1,5 @@ -import type { CompiledSchema } from '@beonauto/specs/document'; -import type { ParsedTemplate } from '@beonauto/specs/template'; +import type { CompiledSchema } from '@beonauto/definitions/document'; +import type { ParsedTemplate } from '@beonauto/definitions/template'; import type { ReplyRule } from '../replies/reply-rule.ts'; import type { WrittenRoute } from '../route/compiled-route.ts'; diff --git a/primitives/interaction/src/document/reply-parts.ts b/capabilities/interaction/src/document/reply-parts.ts similarity index 87% rename from primitives/interaction/src/document/reply-parts.ts rename to capabilities/interaction/src/document/reply-parts.ts index ce82ab2f2..768a25066 100644 --- a/primitives/interaction/src/document/reply-parts.ts +++ b/capabilities/interaction/src/document/reply-parts.ts @@ -1,6 +1,6 @@ -import type { DocumentIssue, SourceLines } from '@beonauto/specs/document'; -import { issueAt } from '@beonauto/specs/document'; -import type { ParsedTemplate } from '@beonauto/specs/template'; +import type { DocumentIssue, SourceLines } from '@beonauto/definitions/document'; +import { issueAt } from '@beonauto/definitions/document'; +import type { ParsedTemplate } from '@beonauto/definitions/template'; import { Result, type Schema } from 'effect'; import type { ReplyRule, WrittenRule } from '../replies/reply-rule.ts'; diff --git a/primitives/interaction/src/document/request-templates.ts b/capabilities/interaction/src/document/request-templates.ts similarity index 83% rename from primitives/interaction/src/document/request-templates.ts rename to capabilities/interaction/src/document/request-templates.ts index f4083b624..175e73c84 100644 --- a/primitives/interaction/src/document/request-templates.ts +++ b/capabilities/interaction/src/document/request-templates.ts @@ -1,5 +1,10 @@ -import type { DocumentIssue } from '@beonauto/specs/document'; -import { inputVariableIssues, parsedTemplate, templateEngine, type ParsedTemplate } from '@beonauto/specs/template'; +import type { DocumentIssue } from '@beonauto/definitions/document'; +import { + inputVariableIssues, + parsedTemplate, + templateEngine, + type ParsedTemplate, +} from '@beonauto/definitions/template'; import { Result, type Schema } from 'effect'; export const interactionEngine = templateEngine(); diff --git a/primitives/interaction/src/document/route-parts.ts b/capabilities/interaction/src/document/route-parts.ts similarity index 98% rename from primitives/interaction/src/document/route-parts.ts rename to capabilities/interaction/src/document/route-parts.ts index b696375f9..21a4aa2eb 100644 --- a/primitives/interaction/src/document/route-parts.ts +++ b/capabilities/interaction/src/document/route-parts.ts @@ -1,4 +1,4 @@ -import { issueAt, type DocumentIssue, type SourceLines } from '@beonauto/specs/document'; +import { issueAt, type DocumentIssue, type SourceLines } from '@beonauto/definitions/document'; import { Result, type Schema } from 'effect'; import { compiledRoute, type WrittenRoute } from '../route/compiled-route.ts'; diff --git a/primitives/interaction/src/document/written-parts.ts b/capabilities/interaction/src/document/written-parts.ts similarity index 94% rename from primitives/interaction/src/document/written-parts.ts rename to capabilities/interaction/src/document/written-parts.ts index 5ba3fb674..669ea67e7 100644 --- a/primitives/interaction/src/document/written-parts.ts +++ b/capabilities/interaction/src/document/written-parts.ts @@ -4,8 +4,8 @@ import { type DocumentIssue, type DocumentParts, type SourceLines, -} from '@beonauto/specs/document'; -import type { ParsedTemplate } from '@beonauto/specs/template'; +} from '@beonauto/definitions/document'; +import type { ParsedTemplate } from '@beonauto/definitions/template'; import { mostValueDepth } from '@beonauto/workflow-engine/dsl'; import { Result, type Schema } from 'effect'; diff --git a/primitives/interaction/src/index.ts b/capabilities/interaction/src/index.ts similarity index 85% rename from primitives/interaction/src/index.ts rename to capabilities/interaction/src/index.ts index 39f05daf6..ff5d29b60 100644 --- a/primitives/interaction/src/index.ts +++ b/capabilities/interaction/src/index.ts @@ -5,8 +5,8 @@ export type { InteractionFunctionDefinitionDocument } from './document/interacti export { requestsDue, type RequestsDue } from './schedule/due-requests.ts'; export type { DeliveryParts } from './schedule/delivery-parts.ts'; export type { RequestLedger } from './schedule/request-ledger.ts'; -export { makeInteractionFunctionAdapter } from './primitive/interaction-function.ts'; -export { interactionPrimitive } from './primitive/primitive-name.ts'; +export { makeInteractionFunctionAdapter } from './capability/interaction-function.ts'; +export { interactionType } from './capability/interaction-type.ts'; export { answerInteraction } from './requests/answer-interaction.ts'; export { listInteractions } from './requests/list-interactions.ts'; export { openRequests, openRequestsName } from './requests/open-requests.ts'; diff --git a/primitives/interaction/src/replies/belonging.test.ts b/capabilities/interaction/src/replies/belonging.test.ts similarity index 100% rename from primitives/interaction/src/replies/belonging.test.ts rename to capabilities/interaction/src/replies/belonging.test.ts diff --git a/primitives/interaction/src/replies/refusals.test.ts b/capabilities/interaction/src/replies/refusals.test.ts similarity index 100% rename from primitives/interaction/src/replies/refusals.test.ts rename to capabilities/interaction/src/replies/refusals.test.ts diff --git a/primitives/interaction/src/replies/reply-handling.ts b/capabilities/interaction/src/replies/reply-handling.ts similarity index 98% rename from primitives/interaction/src/replies/reply-handling.ts rename to capabilities/interaction/src/replies/reply-handling.ts index 615974f51..11aea88a3 100644 --- a/primitives/interaction/src/replies/reply-handling.ts +++ b/capabilities/interaction/src/replies/reply-handling.ts @@ -1,5 +1,5 @@ +import { replyRecorder, type ReplyFact, type ReplyRefusal, type ReplyTakenFact } from '@beonauto/definitions'; import type { Issue } from '@beonauto/operations'; -import { replyRecorder, type ReplyFact, type ReplyRefusal, type ReplyTakenFact } from '@beonauto/specs'; import { Effect } from 'effect'; import type { ConversationParts, ConversationPlace, ReadingRoute } from '../conversations/conversation-parts.ts'; @@ -85,7 +85,7 @@ function refusedReply(reading: Reading, state: ReadingState, { request, reply, b brain: reading.place.brain, server: reading.route.server, tool: reading.route.replies.tell?.tool ?? reading.route.delivering, - executionId: request.runId, + runId: request.runId, input, lineage: { causationId: made.id, correlationId: made.correlationId }, }); diff --git a/primitives/interaction/src/replies/reply-races.test.ts b/capabilities/interaction/src/replies/reply-races.test.ts similarity index 96% rename from primitives/interaction/src/replies/reply-races.test.ts rename to capabilities/interaction/src/replies/reply-races.test.ts index 524d37820..14f207d21 100644 --- a/primitives/interaction/src/replies/reply-races.test.ts +++ b/capabilities/interaction/src/replies/reply-races.test.ts @@ -27,7 +27,7 @@ function answeredMeanwhile(brain: ChatHarness): ConversationLedger { ...ledger, readProjectedRows: (name, place, query) => Effect.tap(ledger.readProjectedRows(name, place, query), () => - Effect.promise(() => brain.call(answerInteraction, { execution_id: runId, answer: { choice: 'reject' } })), + Effect.promise(() => brain.call(answerInteraction, { run_id: runId, answer: { choice: 'reject' } })), ), }; } diff --git a/primitives/interaction/src/replies/reply-recording.ts b/capabilities/interaction/src/replies/reply-recording.ts similarity index 100% rename from primitives/interaction/src/replies/reply-recording.ts rename to capabilities/interaction/src/replies/reply-recording.ts diff --git a/primitives/interaction/src/replies/reply-rule.test.ts b/capabilities/interaction/src/replies/reply-rule.test.ts similarity index 100% rename from primitives/interaction/src/replies/reply-rule.test.ts rename to capabilities/interaction/src/replies/reply-rule.test.ts diff --git a/primitives/interaction/src/replies/reply-rule.ts b/capabilities/interaction/src/replies/reply-rule.ts similarity index 100% rename from primitives/interaction/src/replies/reply-rule.ts rename to capabilities/interaction/src/replies/reply-rule.ts diff --git a/primitives/interaction/src/replies/reply-taking.ts b/capabilities/interaction/src/replies/reply-taking.ts similarity index 97% rename from primitives/interaction/src/replies/reply-taking.ts rename to capabilities/interaction/src/replies/reply-taking.ts index 51622eba3..f573e826e 100644 --- a/primitives/interaction/src/replies/reply-taking.ts +++ b/capabilities/interaction/src/replies/reply-taking.ts @@ -1,7 +1,7 @@ import { Buffer } from 'node:buffer'; +import type { ReplyRefusal } from '@beonauto/definitions'; import type { Issue } from '@beonauto/operations'; -import type { ReplyRefusal } from '@beonauto/specs'; import { Result, type Schema } from 'effect'; import { checkedAnswer } from '../requests/answer-check.ts'; diff --git a/primitives/interaction/src/replies/rule-checks.test.ts b/capabilities/interaction/src/replies/rule-checks.test.ts similarity index 99% rename from primitives/interaction/src/replies/rule-checks.test.ts rename to capabilities/interaction/src/replies/rule-checks.test.ts index ed783b615..c2b8169ae 100644 --- a/primitives/interaction/src/replies/rule-checks.test.ts +++ b/capabilities/interaction/src/replies/rule-checks.test.ts @@ -1,4 +1,4 @@ -import { issueText } from '@beonauto/specs/document'; +import { issueText } from '@beonauto/definitions/document'; import { Result } from 'effect'; import { describe, expect, it } from 'vitest'; diff --git a/primitives/interaction/src/replies/rule-checks.ts b/capabilities/interaction/src/replies/rule-checks.ts similarity index 100% rename from primitives/interaction/src/replies/rule-checks.ts rename to capabilities/interaction/src/replies/rule-checks.ts diff --git a/primitives/interaction/src/replies/telling-words.test.ts b/capabilities/interaction/src/replies/telling-words.test.ts similarity index 100% rename from primitives/interaction/src/replies/telling-words.test.ts rename to capabilities/interaction/src/replies/telling-words.test.ts diff --git a/primitives/interaction/src/replies/telling-words.ts b/capabilities/interaction/src/replies/telling-words.ts similarity index 97% rename from primitives/interaction/src/replies/telling-words.ts rename to capabilities/interaction/src/replies/telling-words.ts index 56a41edae..83c48c95d 100644 --- a/primitives/interaction/src/replies/telling-words.ts +++ b/capabilities/interaction/src/replies/telling-words.ts @@ -1,5 +1,5 @@ +import type { ReplyRefusal } from '@beonauto/definitions'; import type { Issue } from '@beonauto/operations'; -import type { ReplyRefusal } from '@beonauto/specs'; import { interactionBounds } from '../run/run-bounds.ts'; import type { ReplyRule, RulePart } from './reply-rule.ts'; diff --git a/primitives/interaction/src/replies/tellings.ts b/capabilities/interaction/src/replies/tellings.ts similarity index 88% rename from primitives/interaction/src/replies/tellings.ts rename to capabilities/interaction/src/replies/tellings.ts index 07e9b347c..17fda2ab2 100644 --- a/primitives/interaction/src/replies/tellings.ts +++ b/capabilities/interaction/src/replies/tellings.ts @@ -21,7 +21,7 @@ export interface Telling { readonly brain: BrainAddress; readonly server: string; readonly tool: string; - readonly executionId: string; + readonly runId: string; readonly input: Readonly>; readonly lineage: Lineage; } @@ -34,13 +34,13 @@ export interface TellingParts { const moment = Effect.map(DateTime.now, DateTime.formatIso); export function told({ ledger, tools }: TellingParts, telling: Telling): Effect.Effect { - const { brain, server, tool, executionId, input, lineage } = telling; + const { brain, server, tool, runId, input, lineage } = telling; const callId = randomUUIDv7(); const stream = `${streamPrefixOfBrain(brain)}${conversationCallStreamOf(callId)}`; const by = brainCallerOf(brain).id; const call = { ...brain, reference: { server, tool }, input, meta: { [conversationCallIdKey]: callId } }; return Effect.gen(function* () { - const started = tellingStartedOf({ callId, executionId }, tools.startOf(call), { by, at: yield* moment }); + const started = tellingStartedOf({ callId, runId }, tools.startOf(call), { by, at: yield* moment }); const { version } = yield* ledger.execute(stream, conversationCallDecider, started, lineage).pipe(Effect.orDie); const called = yield* tools.callOnce(call); const ended = tellingEndedOf(callId, called, { by, at: yield* moment }); diff --git a/primitives/interaction/src/requests/answer-bounds.test.ts b/capabilities/interaction/src/requests/answer-bounds.test.ts similarity index 94% rename from primitives/interaction/src/requests/answer-bounds.test.ts rename to capabilities/interaction/src/requests/answer-bounds.test.ts index 4aaa92b5d..b86326327 100644 --- a/primitives/interaction/src/requests/answer-bounds.test.ts +++ b/capabilities/interaction/src/requests/answer-bounds.test.ts @@ -27,7 +27,7 @@ describe('the claim an answer makes', () => { it('takes 256 bytes and refuses one more, and refuses a blank claim or one with a control character', async () => { const brain = await asked(); const answering = (claimedFor: string) => - brain.call(answer, { execution_id: runId, answer: 'yes', claimed_for: claimedFor }); + brain.call(answer, { run_id: runId, answer: 'yes', claimed_for: claimedFor }); const refusals = [ await answering(`${'é'.repeat(128)}a`), @@ -50,11 +50,11 @@ describe('the depth of an answer', () => { const brain = await asked(); const deeper = await brain.call(answer, { - execution_id: runId, + run_id: runId, answer: nestedLevels(interactionBounds.answerDepth + 1), }); const deepest = await brain.call(answer, { - execution_id: runId, + run_id: runId, answer: nestedLevels(interactionBounds.answerDepth), }); diff --git a/primitives/interaction/src/requests/answer-check.ts b/capabilities/interaction/src/requests/answer-check.ts similarity index 93% rename from primitives/interaction/src/requests/answer-check.ts rename to capabilities/interaction/src/requests/answer-check.ts index d9b208f2e..d52f4173b 100644 --- a/primitives/interaction/src/requests/answer-check.ts +++ b/capabilities/interaction/src/requests/answer-check.ts @@ -1,7 +1,7 @@ import { Buffer } from 'node:buffer'; +import { compileJsonSchema } from '@beonauto/definitions/document'; import type { Issue } from '@beonauto/operations'; -import { compileJsonSchema } from '@beonauto/specs/document'; import { Result, type Schema } from 'effect'; import { interactionBounds } from '../run/run-bounds.ts'; diff --git a/primitives/interaction/src/requests/answer-interaction.test.ts b/capabilities/interaction/src/requests/answer-interaction.test.ts similarity index 84% rename from primitives/interaction/src/requests/answer-interaction.test.ts rename to capabilities/interaction/src/requests/answer-interaction.test.ts index 599ae56a9..704a1b8ae 100644 --- a/primitives/interaction/src/requests/answer-interaction.test.ts +++ b/capabilities/interaction/src/requests/answer-interaction.test.ts @@ -1,4 +1,4 @@ -import { deferredCanceller } from '@beonauto/specs'; +import { deferredCanceller } from '@beonauto/definitions'; import { Effect, Result, Schema, SchemaTransformation } from 'effect'; import { describe, expect, it } from 'vitest'; @@ -37,7 +37,7 @@ const outputOf = Schema.decodeUnknownSync( ); const interaction = { - execution_id: runId, + run_id: runId, function: 'approve-brief', version: 1, to: 'ada', @@ -67,13 +67,13 @@ describe('a request asked through the inbox', () => { it('waits as a started run, listed among the open requests of the brain', async () => { const { brain, asked } = await askedApproval(); - expect(asked).toMatchObject({ status: 'succeeded', output: { execution_id: runId, status: 'started' } }); + expect(asked).toMatchObject({ status: 'succeeded', output: { run_id: runId, status: 'started' } }); expect(await brain.call(listInteractions, {})).toMatchObject({ status: 'succeeded', output: { interactions: [ { - execution_id: runId, + run_id: runId, function: 'approve-brief', version: 1, to: 'ada', @@ -96,14 +96,14 @@ describe('an answer to a request', () => { const { brain } = await askedApproval(); const answered = await brain.call(answer, { - execution_id: runId, + run_id: runId, answer: { choice: 'approve' }, claimed_for: 'ada', }); expect(answered).toMatchObject({ status: 'succeeded', - output: { execution_id: runId, status: 'succeeded', output: { choice: 'approve' } }, + output: { run_id: runId, status: 'succeeded', output: { choice: 'approve' } }, }); expect(await brain.runOf(runId)).toMatchObject({ output: { @@ -116,11 +116,11 @@ describe('an answer to a request', () => { it('answers what landed for the same answer again, and is a conflict for another one', async () => { const { brain } = await askedApproval(); - await brain.call(answer, { execution_id: runId, answer: { choice: 'approve' } }); + await brain.call(answer, { run_id: runId, answer: { choice: 'approve' } }); expect([ - await brain.call(answer, { execution_id: runId, answer: { choice: 'approve' } }), - await brain.call(answer, { execution_id: runId, answer: { choice: 'reject' } }), + await brain.call(answer, { run_id: runId, answer: { choice: 'approve' } }), + await brain.call(answer, { run_id: runId, answer: { choice: 'reject' } }), ]).toMatchObject([ { status: 'succeeded', output: { status: 'succeeded' } }, { status: 'rejected', reason: 'conflict', detail: 'The run already ended with another result' }, @@ -132,13 +132,13 @@ describe('an answer the request does not take', () => { it('is invalid input when it does not match the answer schema, with pointers, and leaves the request open', async () => { const { brain } = await askedApproval(); - expect(await brain.call(answer, { execution_id: runId, answer: { choice: 'maybe' } })).toMatchObject({ + expect(await brain.call(answer, { run_id: runId, answer: { choice: 'maybe' } })).toMatchObject({ status: 'rejected', reason: 'invalid_input', issues: [{ pointer: '/answer/choice' }], }); expect(await brain.call(listInteractions, {})).toMatchObject({ - output: { interactions: [{ execution_id: runId }] }, + output: { interactions: [{ run_id: runId }] }, }); }); @@ -148,8 +148,8 @@ describe('an answer the request does not take', () => { await brain.ask('tell', { campaign: 'Spring', owner: 'ada' }, otherRunId); expect([ - await brain.call(answer, { execution_id: runId, answer: {} }), - await brain.call(answer, { execution_id: otherRunId, answer: {} }), + await brain.call(answer, { run_id: runId, answer: {} }), + await brain.call(answer, { run_id: otherRunId, answer: {} }), ]).toMatchObject([ { status: 'rejected', reason: 'not_found' }, { @@ -165,7 +165,7 @@ describe('an answer that is refused before it is checked', () => { it('is a conflict for a notification that waits for its delivery', async () => { const { brain } = await askedThroughChat({ notification: true }); - expect(await brain.call(answer, { execution_id: askedRunId, answer: {} })).toMatchObject({ + expect(await brain.call(answer, { run_id: askedRunId, answer: {} })).toMatchObject({ status: 'rejected', reason: 'conflict', detail: 'The request is a notification, which takes no answer', @@ -176,7 +176,7 @@ describe('an answer that is refused before it is checked', () => { const { brain } = await askedApproval(); expect([ - await brain.call(answer, { execution_id: runId, answer: { choice: 'approve', note: 'x'.repeat(70_000) } }), + await brain.call(answer, { run_id: runId, answer: { choice: 'approve', note: 'x'.repeat(70_000) } }), checkedAnswer({}, { type: 'thing' }), ]).toMatchObject([ { @@ -192,9 +192,9 @@ describe('an answer that is refused before it is checked', () => { describe('the words of answers and requests', () => { it('is told in plain words, as the requests are', async () => { const { brain } = await askedApproval(); - const answered = outputOf(await brain.call(answer, { execution_id: runId, answer: { choice: 'approve' } })); + const answered = outputOf(await brain.call(answer, { run_id: runId, answer: { choice: 'approve' } })); const listing = outputOf(await brain.call(listInteractions, {})); - const input = { execution_id: runId, answer: { choice: 'approve' } }; + const input = { run_id: runId, answer: { choice: 'approve' } }; expect([ answer.registration.plainLanguage?.attempt(input), @@ -231,7 +231,7 @@ describe('a request whose run is cancelled', () => { expect(await brain.cancel(runId)).toMatchObject({ status: 'succeeded', output: { status: 'started' } }); await Effect.runPromise( - deferredCanceller([brain.primitive], brain.ledger.service)( + deferredCanceller([brain.capability], brain.ledger.service)( { org: 'acme', brain: 'alpha', id: runId }, { kind: 'requested', reason: 'Not needed any more', by: 'acme-admin' }, { causationId: null, correlationId: runId }, @@ -245,7 +245,7 @@ describe('a request whose run is cancelled', () => { }, }); expect(await brain.call(listInteractions, {})).toMatchObject({ output: { interactions: [] } }); - expect(await brain.call(answer, { execution_id: runId, answer: { choice: 'approve' } })).toMatchObject({ + expect(await brain.call(answer, { run_id: runId, answer: { choice: 'approve' } })).toMatchObject({ reason: 'conflict', }); }); diff --git a/primitives/interaction/src/requests/answer-interaction.ts b/capabilities/interaction/src/requests/answer-interaction.ts similarity index 88% rename from primitives/interaction/src/requests/answer-interaction.ts rename to capabilities/interaction/src/requests/answer-interaction.ts index 4edbda746..66f2b979e 100644 --- a/primitives/interaction/src/requests/answer-interaction.ts +++ b/capabilities/interaction/src/requests/answer-interaction.ts @@ -1,5 +1,15 @@ import { Buffer } from 'node:buffer'; +import { + RunIdInputField, + RunSchema, + answeredByAReply, + brainBoundSettler, + recordedRunInBrain, + type RecordedRun, + refusingBlankText, + refusingForbiddenCharacters, +} from '@beonauto/definitions'; import { BrainContext, BrainReader, @@ -10,16 +20,6 @@ import { type InvalidInput, defineCommand, } from '@beonauto/operations'; -import { - ExecutionIdField, - RunSchema, - answeredByAReply, - brainBoundSettler, - recordedRunInBrain, - type RecordedRun, - refusingBlankText, - refusingForbiddenCharacters, -} from '@beonauto/specs'; import { Clock, Effect, Schema } from 'effect'; import { interactionBounds } from '../run/run-bounds.ts'; @@ -40,8 +40,8 @@ const description = [ "Answers a request an interaction function's run waits on, and settles the run for good:", 'the run succeeds with the answer as its output, which reaches the workflow step that waits for it.', 'Use it when the person approves, rejects, revises or otherwise answers a request list_interactions shows, wherever it reached them;', - 'a new run asks again and answers nothing, and send_execution_event gives an event to a waiting workflow instead.', - '`execution_id` is the run of the request, and `answer` takes the shape of the request\'s answer_schema, which list_interactions shows, such as {"decision": "approve"}.', + 'a new run asks again and answers nothing, and send_run_event gives an event to a waiting workflow instead.', + '`run_id` is the run of the request, and `answer` takes the shape of the request\'s answer_schema, which list_interactions shows, such as {"decision": "approve"}.', '`claimed_for` is whom the caller says it answers for, kept as a claim.', 'The same answer again answers the run as it stands, and an answer that does not match leaves the request open.', ].join(' '); @@ -102,17 +102,17 @@ export const answerInteraction = defineCommand('brain', { name: 'answer_interaction', title: 'Answer a request', description, - route: { method: 'POST', path: '/executions/{execution_id}/answer' }, + route: { method: 'POST', path: '/runs/{run_id}/answer' }, irreversible: true, repeatable: true, inputSchema: Schema.Struct({ - execution_id: ExecutionIdField, + run_id: RunIdInputField, answer: Schema.Json.annotate({ description: 'The answer, a JSON value the answer schema of the request takes' }), claimed_for: Schema.optionalKey(ClaimedForField), }), outputSchema: RunSchema, reasons: ['invalid_input', 'not_found', 'conflict'], - handle: ({ execution_id: id, answer, claimed_for: claimedFor }) => answered({ id, answer, claimedFor }), + handle: ({ run_id: id, answer, claimed_for: claimedFor }) => answered({ id, answer, claimedFor }), plainLanguage: { task: 'answer a request', attempt: () => 'answer the request', diff --git a/primitives/interaction/src/requests/answering.ts b/capabilities/interaction/src/requests/answering.ts similarity index 100% rename from primitives/interaction/src/requests/answering.ts rename to capabilities/interaction/src/requests/answering.ts diff --git a/primitives/interaction/src/requests/list-interactions.test.ts b/capabilities/interaction/src/requests/list-interactions.test.ts similarity index 90% rename from primitives/interaction/src/requests/list-interactions.test.ts rename to capabilities/interaction/src/requests/list-interactions.test.ts index 64dfa206f..ee983f3d2 100644 --- a/primitives/interaction/src/requests/list-interactions.test.ts +++ b/capabilities/interaction/src/requests/list-interactions.test.ts @@ -1,4 +1,4 @@ -import { defineUpdateSpec } from '@beonauto/specs'; +import { defineUpdateDefinition } from '@beonauto/definitions'; import { Schema } from 'effect'; import { describe, expect, it } from 'vitest'; @@ -15,7 +15,7 @@ const runIds = [ const PageSchema = Schema.Struct({ status: Schema.Literal('succeeded'), output: Schema.Struct({ - interactions: Schema.Array(Schema.Struct({ execution_id: Schema.String })), + interactions: Schema.Array(Schema.Struct({ run_id: Schema.String })), has_more: Schema.Boolean, next_cursor: Schema.NullOr(Schema.String), }), @@ -35,7 +35,7 @@ async function brainWithThreeRequests() { async function listed(brain: Awaited>, input: object) { const page = decodePage(await brain.call(listInteractions, input)); - return { ids: page.output.interactions.map(({ execution_id: id }) => id), page: page.output }; + return { ids: page.output.interactions.map(({ run_id: id }) => id), page: page.output }; } describe('the open requests of a brain', () => { @@ -123,8 +123,8 @@ describe('the answer shape of an open request', () => { const brain = interactionHarness(); await brain.define('approve-brief', approvalDocument()); await brain.ask('approve-brief', { campaign: 'Spring', owner: 'ada' }, String(runIds[0])); - const changed = await brain.call(defineUpdateSpec([brain.primitive]), { - primitive: 'interaction', + const changed = await brain.call(defineUpdateDefinition([brain.capability]), { + type: 'interaction', name: 'approve-brief', source: decisionDocument, }); @@ -132,11 +132,11 @@ describe('the answer shape of an open request', () => { expect(changed).toMatchObject({ status: 'succeeded', output: { version: 2 } }); expect(shapes).toEqual([{ version: 1, takes_answer: true, answer_schema: approvalSchema }]); - expect(await brain.call(answer, { execution_id: runIds[0], answer: { decision: 'approve' } })).toMatchObject({ + expect(await brain.call(answer, { run_id: runIds[0], answer: { decision: 'approve' } })).toMatchObject({ status: 'rejected', reason: 'invalid_input', }); - expect(await brain.call(answer, { execution_id: runIds[0], answer: { choice: 'approve' } })).toMatchObject({ + expect(await brain.call(answer, { run_id: runIds[0], answer: { choice: 'approve' } })).toMatchObject({ status: 'succeeded', output: { status: 'succeeded', output: { choice: 'approve' } }, }); diff --git a/primitives/interaction/src/requests/list-interactions.ts b/capabilities/interaction/src/requests/list-interactions.ts similarity index 96% rename from primitives/interaction/src/requests/list-interactions.ts rename to capabilities/interaction/src/requests/list-interactions.ts index b2a522b51..b90a8d54f 100644 --- a/primitives/interaction/src/requests/list-interactions.ts +++ b/capabilities/interaction/src/requests/list-interactions.ts @@ -20,7 +20,7 @@ import { openRequestsName } from './open-requests.ts'; import { StandingSchema, requestRowFrom, routeOfRow, type OpenRequestRow } from './request-rows.ts'; const InteractionSchema = Schema.Struct({ - execution_id: Schema.String.annotate({ description: 'The run of the request, to answer with answer_interaction' }), + run_id: Schema.String.annotate({ description: 'The run of the request, to answer with answer_interaction' }), function: Schema.String.annotate({ description: 'The interaction function that asked' }), version: Schema.Int.annotate({ description: 'The version of the function that asked' }), to: Schema.String.annotate({ description: 'The party the request goes to' }), @@ -71,9 +71,9 @@ const FilterText = Schema.String.check(Schema.isMinLength(1), Schema.isMaxLength const description = [ 'Lists the open requests of the brain, newest first: what each interaction function asked, of whom, through which tool, or in the inbox,', - 'until when and how its delivery stands, with the `execution_id` that answer_interaction takes.', + 'until when and how its delivery stands, with the `run_id` that answer_interaction takes.', 'Each carries its `answer_schema`, the shape answer_interaction checks an answer against, as recorded when it was asked,', - 'which get_spec may no longer show; null for a notification.', + 'which get_definition may no longer show; null for a notification.', 'Use it when the person asks what the brain is waiting on, or to find the request they answer.', '`to` keeps the requests to one party and `function` those of one interaction function, and `cursor` is the next_cursor of the page before.', 'Every reader of the brain sees each party and message, as a run’s input is seen.', @@ -107,7 +107,7 @@ function shownOf({ key, row }: ProjectedKeyedRow) { const kept = requestRowFrom(row); return [ { - execution_id: key, + run_id: key, function: kept.function, version: kept.version, to: kept.party, diff --git a/primitives/interaction/src/requests/open-requests.test.ts b/capabilities/interaction/src/requests/open-requests.test.ts similarity index 88% rename from primitives/interaction/src/requests/open-requests.test.ts rename to capabilities/interaction/src/requests/open-requests.test.ts index 6b7fd1993..1ff9af3f8 100644 --- a/primitives/interaction/src/requests/open-requests.test.ts +++ b/capabilities/interaction/src/requests/open-requests.test.ts @@ -5,7 +5,7 @@ import { openRequests } from './open-requests.ts'; const message = { id: '5d0e9f6a-1b2c-5d3e-8f4a-6b7c8d9e0f1a', position: 2 }; -const fact = { by: 'brain:alpha', at: '2026-10-07T09:00:00.000Z', name: 'approve-brief', spec_version: 1 }; +const fact = { by: 'brain:alpha', at: '2026-10-07T09:00:00.000Z', name: 'approve-brief', definition_version: 1 }; const deliver = { server: 'chat', tool: 'post_message', with: { text: '{{ message }}' } }; @@ -24,7 +24,7 @@ const request = { deliver, }; -const deferral = { type: 'execution_deferred', record: request, primitive: 'interaction', ...fact }; +const deferral = { type: 'run_deferred', record: request, definition_type: 'interaction', ...fact }; const open = openRequests.rowAfter(undefined, deferral, message); @@ -32,7 +32,7 @@ describe('the open request of a run', () => { it('is made by the deferral of an interaction function alone', () => { expect([ open, - openRequests.rowAfter(undefined, { ...deferral, primitive: 'orchestration' }, message), + openRequests.rowAfter(undefined, { ...deferral, definition_type: 'workflow' }, message), openRequests.rowAfter(undefined, { ...deferral, record: { run: 'x' } }, message), ]).toEqual([ { @@ -98,9 +98,9 @@ describe('the open request of a run, as its request recorded it', () => { describe('the open request of a run that ends', () => { it('closes with the ending of its run, in the words of that ending, and changes no more after', () => { const ending = (rejection: object) => ({ - type: 'execution_rejected', + type: 'run_rejected', rejection, - primitive: 'interaction', + definition_type: 'interaction', ...fact, }); const cancelled = openRequests.rowAfter( @@ -111,8 +111,8 @@ describe('the open request of a run that ends', () => { expect([ cancelled, - openRequests.rowAfter(open, { type: 'execution_failed', primitive: 'interaction', ...fact }, message), - openRequests.rowAfter(cancelled, { type: 'execution_failed', primitive: 'interaction', ...fact }, message), + openRequests.rowAfter(open, { type: 'run_failed', definition_type: 'interaction', ...fact }, message), + openRequests.rowAfter(cancelled, { type: 'run_failed', definition_type: 'interaction', ...fact }, message), ]).toMatchObject([ { open: false, attempt_due_at: null, ending_due_at: null, ended: 'cancelled' }, { open: false, ended: 'failed' }, @@ -135,13 +135,13 @@ describe('the open request of a run that ends', () => { expect([ openRequests.rowAfter(open, toolCall, message), openRequests.rowAfter(open, { type: 'nonsense' }, message), - openRequests.rowAfter(undefined, { type: 'execution_failed', primitive: 'interaction', ...fact }, message), + openRequests.rowAfter(undefined, { type: 'run_failed', definition_type: 'interaction', ...fact }, message), ]).toEqual([undefined, undefined, undefined]); }); }); -const cancelAsked = { type: 'execution_cancel_requested', kind: 'requested', reason: 'Off', ...fact }; -const ofTheRun = { primitive: 'interaction', ...fact }; +const cancelAsked = { type: 'run_cancel_requested', kind: 'requested', reason: 'Off', ...fact }; +const ofTheRun = { definition_type: 'interaction', ...fact }; const attempt = { type: 'delivery_started', number: 1, @@ -197,7 +197,7 @@ describe('the open request of a run a reply answered', () => { it('is left alone once its run has ended', () => { const closed = openRequests.rowAfter( open, - { type: 'execution_failed', primitive: 'interaction', ...fact }, + { type: 'run_failed', definition_type: 'interaction', ...fact }, message, ); diff --git a/primitives/interaction/src/requests/open-requests.ts b/capabilities/interaction/src/requests/open-requests.ts similarity index 82% rename from primitives/interaction/src/requests/open-requests.ts rename to capabilities/interaction/src/requests/open-requests.ts index 1da2f5796..7c8f83d19 100644 --- a/primitives/interaction/src/requests/open-requests.ts +++ b/capabilities/interaction/src/requests/open-requests.ts @@ -1,8 +1,8 @@ +import { runEventOf, type RunEvent } from '@beonauto/definitions'; import type { ProjectedMessage, ProjectedRow, KeyedProjection } from '@beonauto/operations'; -import { executionEventOf, type ExecutionEvent } from '@beonauto/specs'; +import { interactionType } from '../capability/interaction-type.ts'; import { conversationKeyOf } from '../conversations/conversation-keys.ts'; -import { interactionPrimitive } from '../primitive/primitive-name.ts'; import { requestRecordOf, takesAnswer } from '../run/request-record.ts'; import { attemptSchedule, nextAttemptAt } from '../schedule/attempt-schedule.ts'; import { @@ -18,14 +18,14 @@ export const openRequestsName = 'open_requests'; export const attemptInFlightMs = 60_000; -type Fact = Extract; +type Fact = Extract; function rowOf(row: UndueRequestRow): ProjectedRow { return { ...row, attempt_due_at: attemptDueAtOf(row), ending_due_at: endingDueAtOf(row) }; } -function requested(fact: Fact<'execution_deferred'>, message: ProjectedMessage): ProjectedRow | undefined { - const request = fact.primitive === interactionPrimitive ? requestRecordOf(fact.record) : undefined; +function requested(fact: Fact<'run_deferred'>, message: ProjectedMessage): ProjectedRow | undefined { + const request = fact.definition_type === interactionType ? requestRecordOf(fact.record) : undefined; if (request === undefined) { return undefined; } @@ -35,7 +35,7 @@ function requested(fact: Fact<'execution_deferred'>, message: ProjectedMessage): return rowOf({ request_id: message.id, function: fact.name, - version: fact.spec_version, + version: fact.definition_version, party: request.to, delivery: inInbox ? null : JSON.stringify({ server: deliver.server, tool: deliver.tool }), replies: replies === undefined ? null : JSON.stringify(replies), @@ -102,11 +102,11 @@ function attemptEnded(row: OpenRequestRow, fact: Fact<'delivery_ended'>): Projec return rowOf(afterFailure(row, fact, at)); } -function endingOf(fact: Fact<'execution_succeeded' | 'execution_rejected' | 'execution_failed'>): string { - if (fact.type === 'execution_succeeded') { +function endingOf(fact: Fact<'run_succeeded' | 'run_rejected' | 'run_failed'>): string { + if (fact.type === 'run_succeeded') { return 'answered'; } - if (fact.type === 'execution_failed') { + if (fact.type === 'run_failed') { return 'failed'; } const { rejection } = fact; @@ -115,12 +115,12 @@ function endingOf(fact: Fact<'execution_succeeded' | 'execution_rejected' | 'exe function closed( row: OpenRequestRow, - fact: Fact<'execution_succeeded' | 'execution_rejected' | 'execution_failed'>, + fact: Fact<'run_succeeded' | 'run_rejected' | 'run_failed'>, ): ProjectedRow | undefined { return row.open ? rowOf({ ...row, open: false, next_attempt_at: null, ended: endingOf(fact) }) : undefined; } -function worked(row: OpenRequestRow, fact: ExecutionEvent): ProjectedRow | undefined { +function worked(row: OpenRequestRow, fact: RunEvent): ProjectedRow | undefined { if (fact.type === 'delivery_started') { return attemptStarted(row, fact); } @@ -139,18 +139,18 @@ function worked(row: OpenRequestRow, fact: ExecutionEvent): ProjectedRow | undef : undefined; } -function changed(row: OpenRequestRow, fact: ExecutionEvent): ProjectedRow | undefined { - if (fact.type === 'execution_cancel_requested') { +function changed(row: OpenRequestRow, fact: RunEvent): ProjectedRow | undefined { + if (fact.type === 'run_cancel_requested') { return row.open && !settlesFromBroughtAnswer(row) ? rowOf({ ...row, standing: 'cancelling' }) : undefined; } - return fact.type === 'execution_succeeded' || fact.type === 'execution_rejected' || fact.type === 'execution_failed' + return fact.type === 'run_succeeded' || fact.type === 'run_rejected' || fact.type === 'run_failed' ? closed(row, fact) : worked(row, fact); } function rowAfter(row: ProjectedRow | undefined, event: unknown, message: ProjectedMessage): ProjectedRow | undefined { - const fact = executionEventOf(event); - if (fact?.type === 'execution_deferred') { + const fact = runEventOf(event); + if (fact?.type === 'run_deferred') { return requested(fact, message); } const kept = requestRowOf(row); @@ -159,18 +159,18 @@ function rowAfter(row: ProjectedRow | undefined, event: unknown, message: Projec export const openRequests: KeyedProjection = { name: openRequestsName, - version: 4, - kinds: ['executions'], + version: 5, + kinds: ['runs'], types: [ - 'execution_deferred', + 'run_deferred', 'delivery_started', 'delivery_ended', 'reply_taken', 'reply_refused', - 'execution_cancel_requested', - 'execution_succeeded', - 'execution_rejected', - 'execution_failed', + 'run_cancel_requested', + 'run_succeeded', + 'run_rejected', + 'run_failed', ], columns: [ { name: 'request_id', kind: 'text' }, diff --git a/primitives/interaction/src/requests/request-reads.ts b/capabilities/interaction/src/requests/request-reads.ts similarity index 90% rename from primitives/interaction/src/requests/request-reads.ts rename to capabilities/interaction/src/requests/request-reads.ts index 4f62f890b..7355789c7 100644 --- a/primitives/interaction/src/requests/request-reads.ts +++ b/capabilities/interaction/src/requests/request-reads.ts @@ -19,7 +19,7 @@ const decodeFirst = Schema.decodeUnknownSync(Schema.NonEmptyArray(Schema.Struct( export function correlationOfRun(id: string): Effect.Effect { return BrainReader.use((reader) => - reader.readRecorded({ kind: 'run', execution: id }, { order: 'asc', limit: 1, dataOf: [] }), + reader.readRecorded({ kind: 'run', run: id }, { order: 'asc', limit: 1, dataOf: [] }), ).pipe( Effect.map(({ records }) => decodeFirst(records)[0].correlationId), Effect.orDie, diff --git a/primitives/interaction/src/requests/request-rows.ts b/capabilities/interaction/src/requests/request-rows.ts similarity index 100% rename from primitives/interaction/src/requests/request-rows.ts rename to capabilities/interaction/src/requests/request-rows.ts diff --git a/primitives/interaction/src/route/compiled-route.ts b/capabilities/interaction/src/route/compiled-route.ts similarity index 99% rename from primitives/interaction/src/route/compiled-route.ts rename to capabilities/interaction/src/route/compiled-route.ts index 8891d9f40..0ee87acd7 100644 --- a/primitives/interaction/src/route/compiled-route.ts +++ b/capabilities/interaction/src/route/compiled-route.ts @@ -1,5 +1,5 @@ +import { issueAt, type DocumentIssue, type SourceLines } from '@beonauto/definitions/document'; import { isServerName, isToolName, serverNameShape, toolNameShape } from '@beonauto/mcp/policy'; -import { issueAt, type DocumentIssue, type SourceLines } from '@beonauto/specs/document'; import { readDuration } from '@beonauto/workflow-engine/dsl'; import { JsonPointer, Result, type Schema } from 'effect'; diff --git a/primitives/interaction/src/route/json-children.ts b/capabilities/interaction/src/route/json-children.ts similarity index 100% rename from primitives/interaction/src/route/json-children.ts rename to capabilities/interaction/src/route/json-children.ts diff --git a/primitives/interaction/src/route/json-pointers.test.ts b/capabilities/interaction/src/route/json-pointers.test.ts similarity index 100% rename from primitives/interaction/src/route/json-pointers.test.ts rename to capabilities/interaction/src/route/json-pointers.test.ts diff --git a/primitives/interaction/src/route/json-pointers.ts b/capabilities/interaction/src/route/json-pointers.ts similarity index 100% rename from primitives/interaction/src/route/json-pointers.ts rename to capabilities/interaction/src/route/json-pointers.ts diff --git a/primitives/interaction/src/route/rendered-arguments.ts b/capabilities/interaction/src/route/rendered-arguments.ts similarity index 98% rename from primitives/interaction/src/route/rendered-arguments.ts rename to capabilities/interaction/src/route/rendered-arguments.ts index 76453d815..c6158058b 100644 --- a/primitives/interaction/src/route/rendered-arguments.ts +++ b/capabilities/interaction/src/route/rendered-arguments.ts @@ -1,7 +1,7 @@ import { Buffer } from 'node:buffer'; +import { parsedTemplate } from '@beonauto/definitions/template'; import { toolBounds } from '@beonauto/mcp'; -import { parsedTemplate } from '@beonauto/specs/template'; import { Result, type Schema } from 'effect'; import { interactionEngine } from '../document/request-templates.ts'; diff --git a/primitives/interaction/src/route/route-parsing.test.ts b/capabilities/interaction/src/route/route-parsing.test.ts similarity index 100% rename from primitives/interaction/src/route/route-parsing.test.ts rename to capabilities/interaction/src/route/route-parsing.test.ts diff --git a/primitives/interaction/src/route/route-schemas.ts b/capabilities/interaction/src/route/route-schemas.ts similarity index 100% rename from primitives/interaction/src/route/route-schemas.ts rename to capabilities/interaction/src/route/route-schemas.ts diff --git a/primitives/interaction/src/route/route-templates.test.ts b/capabilities/interaction/src/route/route-templates.test.ts similarity index 100% rename from primitives/interaction/src/route/route-templates.test.ts rename to capabilities/interaction/src/route/route-templates.test.ts diff --git a/primitives/interaction/src/route/route-templates.ts b/capabilities/interaction/src/route/route-templates.ts similarity index 98% rename from primitives/interaction/src/route/route-templates.ts rename to capabilities/interaction/src/route/route-templates.ts index 640c9110f..c164d39cc 100644 --- a/primitives/interaction/src/route/route-templates.ts +++ b/capabilities/interaction/src/route/route-templates.ts @@ -1,5 +1,5 @@ -import { issueAt, type DocumentIssue, type SourceLines } from '@beonauto/specs/document'; -import { inputVariableIssues, parsedTemplate, type ParsedTemplate } from '@beonauto/specs/template'; +import { issueAt, type DocumentIssue, type SourceLines } from '@beonauto/definitions/document'; +import { inputVariableIssues, parsedTemplate, type ParsedTemplate } from '@beonauto/definitions/template'; import { Result, type Schema } from 'effect'; import { interactionEngine } from '../document/request-templates.ts'; diff --git a/primitives/interaction/src/route/routes.test.ts b/capabilities/interaction/src/route/routes.test.ts similarity index 98% rename from primitives/interaction/src/route/routes.test.ts rename to capabilities/interaction/src/route/routes.test.ts index 437363384..185023601 100644 --- a/primitives/interaction/src/route/routes.test.ts +++ b/capabilities/interaction/src/route/routes.test.ts @@ -1,4 +1,4 @@ -import { deliveryStarted, throughTheTool } from '@beonauto/specs'; +import { deliveryStarted, throughTheTool } from '@beonauto/definitions'; import { Result } from 'effect'; import { describe, expect, it } from 'vitest'; diff --git a/primitives/interaction/src/route/routes.ts b/capabilities/interaction/src/route/routes.ts similarity index 100% rename from primitives/interaction/src/route/routes.ts rename to capabilities/interaction/src/route/routes.ts diff --git a/primitives/interaction/src/route/template-sets.ts b/capabilities/interaction/src/route/template-sets.ts similarity index 96% rename from primitives/interaction/src/route/template-sets.ts rename to capabilities/interaction/src/route/template-sets.ts index 31902f06f..a20640b93 100644 --- a/primitives/interaction/src/route/template-sets.ts +++ b/capabilities/interaction/src/route/template-sets.ts @@ -1,4 +1,4 @@ -import type { VariableSegment } from '@beonauto/specs/template'; +import type { VariableSegment } from '@beonauto/definitions/template'; import type { TemplateVariables } from './rendered-arguments.ts'; diff --git a/primitives/interaction/src/run/answerer-runs.test.ts b/capabilities/interaction/src/run/answerer-runs.test.ts similarity index 100% rename from primitives/interaction/src/run/answerer-runs.test.ts rename to capabilities/interaction/src/run/answerer-runs.test.ts diff --git a/primitives/interaction/src/run/delivering-runs.test.ts b/capabilities/interaction/src/run/delivering-runs.test.ts similarity index 100% rename from primitives/interaction/src/run/delivering-runs.test.ts rename to capabilities/interaction/src/run/delivering-runs.test.ts diff --git a/primitives/interaction/src/run/interaction-run.test.ts b/capabilities/interaction/src/run/interaction-run.test.ts similarity index 93% rename from primitives/interaction/src/run/interaction-run.test.ts rename to capabilities/interaction/src/run/interaction-run.test.ts index 249fd524f..d413b9474 100644 --- a/primitives/interaction/src/run/interaction-run.test.ts +++ b/capabilities/interaction/src/run/interaction-run.test.ts @@ -1,6 +1,6 @@ +import type { RunContext } from '@beonauto/definitions'; +import { noLongestRuns, recordingJournal } from '@beonauto/definitions/testing'; import { allPermissions } from '@beonauto/operations'; -import type { RunContext } from '@beonauto/specs'; -import { noLongestRuns, recordingJournal } from '@beonauto/specs/testing'; import { Effect, type Schema } from 'effect'; import { describe, expect, it } from 'vitest'; @@ -22,7 +22,7 @@ const aRun: RunContext = { org: 'acme', brain: 'alpha', caller: { id: 'acme-admin', org: 'acme', permissions: allPermissions, brains: '*' }, - spec: { name: 'approve-brief', version: 1 }, + definition: { name: 'approve-brief', version: 1 }, journal: recordingJournal(), lineage: { startId: '5d0e9f6a-1b2c-5d3e-8f4a-6b7c8d9e0f1a', correlationId: runId }, depth: 0, @@ -116,11 +116,11 @@ describe('a message the request cannot carry', () => { it('rejects an input the input schema refuses, or nested deeper than a run takes', async () => { const deep = Array.from({ length: 600 }).reduce((inner) => [inner], 'x'); const brain = interactionHarness(); - const prepared = Effect.runSync(brain.primitive.prepare(approvalDocument())); + const prepared = Effect.runSync(brain.capability.prepare(approvalDocument())); expect([ await askedWith(approvalDocument(), { campaign: 'Spring' }), - await Effect.runPromise(Effect.flip(prepared.execute({ campaign: deep, owner: 'ada' }, aRun))), + await Effect.runPromise(Effect.flip(prepared.run({ campaign: deep, owner: 'ada' }, aRun))), ]).toMatchObject([ { reason: 'invalid_input', detail: 'The input does not match the interaction function’s input schema' }, { detail: 'The input nests more than the 512 levels an interaction function takes' }, diff --git a/primitives/interaction/src/run/interaction-run.ts b/capabilities/interaction/src/run/interaction-run.ts similarity index 93% rename from primitives/interaction/src/run/interaction-run.ts rename to capabilities/interaction/src/run/interaction-run.ts index 8fd09f677..a597c9b66 100644 --- a/primitives/interaction/src/run/interaction-run.ts +++ b/capabilities/interaction/src/run/interaction-run.ts @@ -1,5 +1,5 @@ +import type { CapabilityAnswer, CapabilityRejection, RunContext } from '@beonauto/definitions'; import type { Conflict, InvalidInput } from '@beonauto/operations'; -import type { Executed, PrimitiveRejection, RunContext } from '@beonauto/specs'; import { Clock, Effect, type Schema } from 'effect'; import type { InteractionFunctionDefinitionDocument } from '../document/interaction-document.ts'; @@ -63,7 +63,7 @@ export function interactionRun(ports: InteractionPorts) { document: InteractionFunctionDefinitionDocument, input: Schema.Json, context: RunContext, - ): Effect.Effect => + ): Effect.Effect => Effect.gen(function* () { const admitted = yield* preparedInput(input, document.input); const brain = { org: context.org, brain: context.brain }; @@ -74,7 +74,7 @@ export function interactionRun(ports: InteractionPorts) { } yield* roomFor(ports, brain); const record = yield* requestOf(document, admitted); - yield* checkedArguments(record, { input: admitted, runId: context.id, functionName: context.spec.name }); + yield* checkedArguments(record, { input: admitted, runId: context.id, functionName: context.definition.name }); return { finishesLater: true, record }; }); } diff --git a/primitives/interaction/src/run/request-reach.ts b/capabilities/interaction/src/run/request-reach.ts similarity index 100% rename from primitives/interaction/src/run/request-reach.ts rename to capabilities/interaction/src/run/request-reach.ts diff --git a/primitives/interaction/src/run/request-record.ts b/capabilities/interaction/src/run/request-record.ts similarity index 100% rename from primitives/interaction/src/run/request-record.ts rename to capabilities/interaction/src/run/request-record.ts diff --git a/primitives/interaction/src/run/request-rendering.ts b/capabilities/interaction/src/run/request-rendering.ts similarity index 96% rename from primitives/interaction/src/run/request-rendering.ts rename to capabilities/interaction/src/run/request-rendering.ts index f5f19f671..b639bf89f 100644 --- a/primitives/interaction/src/run/request-rendering.ts +++ b/capabilities/interaction/src/run/request-rendering.ts @@ -1,6 +1,6 @@ +import { refusingForbiddenCharacters } from '@beonauto/definitions'; +import type { ParsedTemplate } from '@beonauto/definitions/template'; import { Conflict, InvalidInput } from '@beonauto/operations'; -import { refusingForbiddenCharacters } from '@beonauto/specs'; -import type { ParsedTemplate } from '@beonauto/specs/template'; import { Effect, JsonPointer, Result, Schema } from 'effect'; import { interactionBounds } from './run-bounds.ts'; diff --git a/primitives/interaction/src/run/run-bounds.ts b/capabilities/interaction/src/run/run-bounds.ts similarity index 100% rename from primitives/interaction/src/run/run-bounds.ts rename to capabilities/interaction/src/run/run-bounds.ts diff --git a/primitives/interaction/src/run/run-input.ts b/capabilities/interaction/src/run/run-input.ts similarity index 100% rename from primitives/interaction/src/run/run-input.ts rename to capabilities/interaction/src/run/run-input.ts diff --git a/primitives/interaction/src/run/text-rendering.ts b/capabilities/interaction/src/run/text-rendering.ts similarity index 96% rename from primitives/interaction/src/run/text-rendering.ts rename to capabilities/interaction/src/run/text-rendering.ts index d95ad19fe..277f11a74 100644 --- a/primitives/interaction/src/run/text-rendering.ts +++ b/capabilities/interaction/src/run/text-rendering.ts @@ -1,6 +1,6 @@ import { Buffer } from 'node:buffer'; -import { outputText, renderedTemplate, type ParsedTemplate, type RenderFailure } from '@beonauto/specs/template'; +import { outputText, renderedTemplate, type ParsedTemplate, type RenderFailure } from '@beonauto/definitions/template'; import { Predicate, Result, type Schema } from 'effect'; import { toValue } from 'liquidjs'; diff --git a/primitives/interaction/src/run/value-rendering.ts b/capabilities/interaction/src/run/value-rendering.ts similarity index 98% rename from primitives/interaction/src/run/value-rendering.ts rename to capabilities/interaction/src/run/value-rendering.ts index 27c4a6f01..ee97260a4 100644 --- a/primitives/interaction/src/run/value-rendering.ts +++ b/capabilities/interaction/src/run/value-rendering.ts @@ -1,6 +1,6 @@ import { Buffer } from 'node:buffer'; -import { renderedTemplate, type ParsedTemplate } from '@beonauto/specs/template'; +import { renderedTemplate, type ParsedTemplate } from '@beonauto/definitions/template'; import { Result, Schema } from 'effect'; import { toValue } from 'liquidjs'; diff --git a/primitives/interaction/src/schedule/attempt-schedule.test.ts b/capabilities/interaction/src/schedule/attempt-schedule.test.ts similarity index 100% rename from primitives/interaction/src/schedule/attempt-schedule.test.ts rename to capabilities/interaction/src/schedule/attempt-schedule.test.ts diff --git a/primitives/interaction/src/schedule/attempt-schedule.ts b/capabilities/interaction/src/schedule/attempt-schedule.ts similarity index 100% rename from primitives/interaction/src/schedule/attempt-schedule.ts rename to capabilities/interaction/src/schedule/attempt-schedule.ts diff --git a/primitives/interaction/src/schedule/delivery-parts.ts b/capabilities/interaction/src/schedule/delivery-parts.ts similarity index 100% rename from primitives/interaction/src/schedule/delivery-parts.ts rename to capabilities/interaction/src/schedule/delivery-parts.ts diff --git a/primitives/interaction/src/schedule/due-requests.ts b/capabilities/interaction/src/schedule/due-requests.ts similarity index 100% rename from primitives/interaction/src/schedule/due-requests.ts rename to capabilities/interaction/src/schedule/due-requests.ts diff --git a/primitives/interaction/src/schedule/request-attempts.ts b/capabilities/interaction/src/schedule/request-attempts.ts similarity index 97% rename from primitives/interaction/src/schedule/request-attempts.ts rename to capabilities/interaction/src/schedule/request-attempts.ts index 6d85e7889..f04c6cd64 100644 --- a/primitives/interaction/src/schedule/request-attempts.ts +++ b/capabilities/interaction/src/schedule/request-attempts.ts @@ -1,4 +1,4 @@ -import { recordedRunIn, type Settlement } from '@beonauto/specs'; +import { recordedRunIn, type Settlement } from '@beonauto/definitions'; import { Clock, Effect } from 'effect'; import { deliveredOnce } from '../delivery/attempt-delivery.ts'; diff --git a/primitives/interaction/src/schedule/request-crashes.test.ts b/capabilities/interaction/src/schedule/request-crashes.test.ts similarity index 96% rename from primitives/interaction/src/schedule/request-crashes.test.ts rename to capabilities/interaction/src/schedule/request-crashes.test.ts index ff06e33cc..1d7c40d74 100644 --- a/primitives/interaction/src/schedule/request-crashes.test.ts +++ b/capabilities/interaction/src/schedule/request-crashes.test.ts @@ -82,7 +82,7 @@ describe('an answer a reply brought and another one given meanwhile', () => { const { brain } = await askedThroughChat(); await recordedReply(brain.ledger, { choice: 'approve' }); - const meanwhile = await brain.call(answerInteraction, { execution_id: askedRunId, answer: { choice: 'reject' } }); + const meanwhile = await brain.call(answerInteraction, { run_id: askedRunId, answer: { choice: 'reject' } }); await brain.performDue(Date.now()); expect(meanwhile).toMatchObject({ @@ -101,7 +101,7 @@ describe('an answer a reply brought and another one given meanwhile', () => { await racing.started(); const meanwhile = await asked.brain.call(answerInteraction, { - execution_id: askedRunId, + run_id: askedRunId, answer: { choice: 'reject' }, }); await asked.brain.performDue(Date.now()); diff --git a/primitives/interaction/src/schedule/request-edges.test.ts b/capabilities/interaction/src/schedule/request-edges.test.ts similarity index 93% rename from primitives/interaction/src/schedule/request-edges.test.ts rename to capabilities/interaction/src/schedule/request-edges.test.ts index 6ad78939f..728de3367 100644 --- a/primitives/interaction/src/schedule/request-edges.test.ts +++ b/capabilities/interaction/src/schedule/request-edges.test.ts @@ -1,4 +1,4 @@ -import { outboundCallRecorder } from '@beonauto/specs'; +import { outboundCallRecorder } from '@beonauto/definitions'; import { Effect } from 'effect'; import { describe, expect, it } from 'vitest'; @@ -93,7 +93,7 @@ describe('a due request performed out of turn', () => { const { brain, tools, askedAt } = await askedThroughChat({ expires: 'PT1H' }); const attempt = await brain.dueItems(askedAt); const expiry = await brain.dueItems(askedAt + 2 * 60 * minute); - await brain.call(answerInteraction, { execution_id: askedRunId, answer: { choice: 'approve' } }); + await brain.call(answerInteraction, { run_id: askedRunId, answer: { choice: 'approve' } }); await brain.performAll(attempt, askedAt - minute); await brain.performAll(expiry, askedAt + 2 * 60 * minute); @@ -106,13 +106,13 @@ describe('a due request performed out of turn', () => { const meanwhile = { answer: (): Promise => Promise.resolve() }; const tools = fakeTools(() => Effect.promise(() => meanwhile.answer())); const brain = interactionHarness({ tools }); - meanwhile.answer = () => brain.call(answerInteraction, { execution_id: askedRunId, answer: { choice: 'reject' } }); + meanwhile.answer = () => brain.call(answerInteraction, { run_id: askedRunId, answer: { choice: 'reject' } }); await brain.define('approve-brief', approvalDocument(chatDelivery)); await brain.ask('approve-brief', { campaign: 'Spring', owner: 'ada' }, askedRunId); await brain.performDue(Date.now()); const { records } = await Effect.runPromise( - brain.ledger.service.readRecorded(address, { kind: 'run', execution: askedRunId }, { order: 'asc', limit: 20 }), + brain.ledger.service.readRecorded(address, { kind: 'run', run: askedRunId }, { order: 'asc', limit: 20 }), ); expect(await brain.runOf(askedRunId)).toMatchObject({ diff --git a/primitives/interaction/src/schedule/request-endings.ts b/capabilities/interaction/src/schedule/request-endings.ts similarity index 94% rename from primitives/interaction/src/schedule/request-endings.ts rename to capabilities/interaction/src/schedule/request-endings.ts index 45230e014..a16fed441 100644 --- a/primitives/interaction/src/schedule/request-endings.ts +++ b/capabilities/interaction/src/schedule/request-endings.ts @@ -1,5 +1,5 @@ +import type { ReplyIdentity, Settlement } from '@beonauto/definitions'; import { brainCallerOf, type BrainAddress } from '@beonauto/operations'; -import type { ReplyIdentity, Settlement } from '@beonauto/specs'; import type { Schema } from 'effect'; import { throughWords, type Route } from '../route/routes.ts'; diff --git a/primitives/interaction/src/schedule/request-ledger.ts b/capabilities/interaction/src/schedule/request-ledger.ts similarity index 84% rename from primitives/interaction/src/schedule/request-ledger.ts rename to capabilities/interaction/src/schedule/request-ledger.ts index f067778fa..8264bf09d 100644 --- a/primitives/interaction/src/schedule/request-ledger.ts +++ b/capabilities/interaction/src/schedule/request-ledger.ts @@ -1,3 +1,10 @@ +import { + runSettler, + outboundCallRecorder, + type OutboundCallFact, + type RecordedOutboundCall, + type Settlement, +} from '@beonauto/definitions'; import type { Lineage, ProjectionReader, @@ -6,13 +13,6 @@ import type { StreamReader, StreamWriter, } from '@beonauto/operations'; -import { - executionSettler, - outboundCallRecorder, - type OutboundCallFact, - type RecordedOutboundCall, - type Settlement, -} from '@beonauto/specs'; import { Effect, Schema } from 'effect'; import type { RequestAddress } from '../delivery/attempt-end.ts'; @@ -42,12 +42,10 @@ const decodeLastBrought = Schema.decodeUnknownSync( ); export function correlationOf(ledger: RecordedReader, address: RequestAddress): Effect.Effect { - return ledger - .readRecorded(address, { kind: 'run', execution: address.id }, { order: 'asc', limit: 1, dataOf: [] }) - .pipe( - Effect.map(({ records }) => decodeFirst(records)[0].correlationId), - Effect.orDie, - ); + return ledger.readRecorded(address, { kind: 'run', run: address.id }, { order: 'asc', limit: 1, dataOf: [] }).pipe( + Effect.map(({ records }) => decodeFirst(records)[0].correlationId), + Effect.orDie, + ); } export function settled( @@ -56,7 +54,7 @@ export function settled( settlement: Settlement, lineage: Lineage, ): Effect.Effect { - return executionSettler(ledger)(address, settlement, lineage).pipe( + return runSettler(ledger)(address, settlement, lineage).pipe( Effect.asVoid, Effect.catchTags({ conflict: () => Effect.void, not_found: Effect.die }), ); @@ -79,7 +77,7 @@ const lastBrought: RecordedPageRequest = { }; export function settledFromBroughtAnswer(ledger: RequestLedger, { address, lineage }: DueRequest): Effect.Effect { - return ledger.readRecorded(address, { kind: 'run', execution: address.id }, lastBrought).pipe( + return ledger.readRecorded(address, { kind: 'run', run: address.id }, lastBrought).pipe( Effect.orDie, Effect.flatMap(({ records }) => { const [{ id, data }] = decodeLastBrought(records); diff --git a/primitives/interaction/src/schedule/request-recovery.test.ts b/capabilities/interaction/src/schedule/request-recovery.test.ts similarity index 93% rename from primitives/interaction/src/schedule/request-recovery.test.ts rename to capabilities/interaction/src/schedule/request-recovery.test.ts index 9a50499d8..6f3be6786 100644 --- a/primitives/interaction/src/schedule/request-recovery.test.ts +++ b/capabilities/interaction/src/schedule/request-recovery.test.ts @@ -1,4 +1,4 @@ -import { outboundCallRecorder } from '@beonauto/specs'; +import { outboundCallRecorder } from '@beonauto/definitions'; import { Effect } from 'effect'; import { describe, expect, it } from 'vitest'; @@ -48,7 +48,7 @@ describe('a due request whose run has ended, or whose tool is no longer allowed' it('sends nothing for a run answered since it was read as due', async () => { const { brain, tools, askedAt } = await askedThroughChat(); const items = await brain.dueItems(askedAt); - await brain.call(answerInteraction, { execution_id: askedRunId, answer: { choice: 'approve' } }); + await brain.call(answerInteraction, { run_id: askedRunId, answer: { choice: 'approve' } }); await brain.performAll(items, askedAt); @@ -62,7 +62,7 @@ describe('a due request whose run has ended, or whose tool is no longer allowed' await brain.performDue(askedAt); const open = await brain.firstOpen(); const nextDue = await Effect.runPromise(brain.due.nextDueAt(askedAt)); - const answered = await brain.call(answerInteraction, { execution_id: askedRunId, answer: { choice: 'approve' } }); + const answered = await brain.call(answerInteraction, { run_id: askedRunId, answer: { choice: 'approve' } }); expect(tools.calls()).toEqual([]); expect(open).toMatchObject({ attempts: 1, standing: 'retrying' }); diff --git a/primitives/interaction/src/testing/asked-requests.ts b/capabilities/interaction/src/testing/asked-requests.ts similarity index 100% rename from primitives/interaction/src/testing/asked-requests.ts rename to capabilities/interaction/src/testing/asked-requests.ts diff --git a/primitives/interaction/src/testing/chat-board.ts b/capabilities/interaction/src/testing/chat-board.ts similarity index 100% rename from primitives/interaction/src/testing/chat-board.ts rename to capabilities/interaction/src/testing/chat-board.ts diff --git a/primitives/interaction/src/testing/chat-harness.ts b/capabilities/interaction/src/testing/chat-harness.ts similarity index 100% rename from primitives/interaction/src/testing/chat-harness.ts rename to capabilities/interaction/src/testing/chat-harness.ts diff --git a/primitives/interaction/src/testing/documents.ts b/capabilities/interaction/src/testing/documents.ts similarity index 100% rename from primitives/interaction/src/testing/documents.ts rename to capabilities/interaction/src/testing/documents.ts diff --git a/primitives/interaction/src/testing/fake-tools.ts b/capabilities/interaction/src/testing/fake-tools.ts similarity index 100% rename from primitives/interaction/src/testing/fake-tools.ts rename to capabilities/interaction/src/testing/fake-tools.ts diff --git a/primitives/interaction/src/testing/harness-parts.ts b/capabilities/interaction/src/testing/harness-parts.ts similarity index 95% rename from primitives/interaction/src/testing/harness-parts.ts rename to capabilities/interaction/src/testing/harness-parts.ts index e9339d0ef..b720c0df9 100644 --- a/primitives/interaction/src/testing/harness-parts.ts +++ b/capabilities/interaction/src/testing/harness-parts.ts @@ -1,5 +1,5 @@ +import type { BrainOperation } from '@beonauto/definitions'; import type { Outcome } from '@beonauto/operations'; -import type { BrainOperation } from '@beonauto/specs'; import { Effect, Schema } from 'effect'; import { listInteractions } from '../requests/list-interactions.ts'; diff --git a/primitives/interaction/src/testing/index.ts b/capabilities/interaction/src/testing/index.ts similarity index 100% rename from primitives/interaction/src/testing/index.ts rename to capabilities/interaction/src/testing/index.ts diff --git a/primitives/interaction/src/testing/interaction-harness.ts b/capabilities/interaction/src/testing/interaction-harness.ts similarity index 76% rename from primitives/interaction/src/testing/interaction-harness.ts rename to capabilities/interaction/src/testing/interaction-harness.ts index f6334e699..5ab502b55 100644 --- a/primitives/interaction/src/testing/interaction-harness.ts +++ b/capabilities/interaction/src/testing/interaction-harness.ts @@ -1,3 +1,11 @@ +import { + defineCancelRun, + defineCreateDefinition, + defineRunDefinition, + defineGetRun, + type BrainOperation, + type Capability, +} from '@beonauto/definitions'; import { allPermissions, makeDispatcher, @@ -7,17 +15,9 @@ import { type Outcome, } from '@beonauto/operations'; import { memoryBrainRegistry, memoryLedger, recordingReporter } from '@beonauto/operations/testing'; -import { - defineCancelExecution, - defineCreateSpec, - defineExecuteSpec, - defineGetExecution, - type BrainOperation, - type Primitive, -} from '@beonauto/specs'; import { Effect, Layer } from 'effect'; -import { makeInteractionFunctionAdapter } from '../primitive/interaction-function.ts'; +import { makeInteractionFunctionAdapter } from '../capability/interaction-function.ts'; import { openRequests, openRequestsName } from '../requests/open-requests.ts'; import { requestsDue, type DueRequestItem, type RequestsDue } from '../schedule/due-requests.ts'; import { noTools, type ToolPorts } from './fake-tools.ts'; @@ -44,13 +44,13 @@ export interface HarnessOptions { export interface InteractionHarness { readonly ledger: HarnessLedger; - readonly primitive: Primitive; + readonly capability: Capability; readonly due: RequestsDue; readonly call: (operation: BrainOperation, input: unknown, caller?: CallerIdentity) => Promise; readonly define: (name: string, source: string) => Promise; - readonly ask: (name: string, input: unknown, executionId: string) => Promise; - readonly cancel: (executionId: string) => Promise; - readonly runOf: (executionId: string) => Promise; + readonly ask: (name: string, input: unknown, runId: string) => Promise; + readonly cancel: (runId: string) => Promise; + readonly runOf: (runId: string) => Promise; readonly performDue: (now: number) => Promise; readonly firstOpen: () => Promise; readonly dueOver: (over: RequestLedger) => RequestsDue; @@ -62,7 +62,7 @@ export interface InteractionHarness { export function interactionHarness(options: HarnessOptions = {}): InteractionHarness { const ledger: HarnessLedger = options.ledger ?? memoryLedger(undefined, [openRequests]); const tools = options.tools ?? noTools; - const primitive = makeInteractionFunctionAdapter({ + const capability = makeInteractionFunctionAdapter({ tools, openRequests: (brain) => ledger.service.countProjectedRows(openRequestsName, brain, [{ column: 'open', equals: true }]), @@ -76,7 +76,7 @@ export function interactionHarness(options: HarnessOptions = {}): InteractionHar run(dispatcher.dispatchToBrain(operation.registration, { caller, ...alpha, input, encoding: 'json' })); const dueOver = (over: RequestLedger): RequestsDue => requestsDue({ ledger: over, tools }); const due = dueOver(ledger.service); - const primitives = [primitive]; + const capabilities = [capability]; const performDue = async (now: number): Promise => { const items = await dueInBothLanes(due, now); await performedAll(items, now); @@ -84,14 +84,14 @@ export function interactionHarness(options: HarnessOptions = {}): InteractionHar }; return { ledger, - primitive, + capability, due, call, - define: (name, source) => call(defineCreateSpec(primitives), { primitive: 'interaction', name, source }), - ask: (name, input, executionId) => - call(defineExecuteSpec(primitives), { primitive: 'interaction', name, input, execution_id: executionId }), - cancel: (executionId) => call(defineCancelExecution(primitives), { execution_id: executionId }), - runOf: (executionId) => call(defineGetExecution(primitives), { execution_id: executionId }), + define: (name, source) => call(defineCreateDefinition(capabilities), { type: 'interaction', name, source }), + ask: (name, input, runId) => + call(defineRunDefinition(capabilities), { type: 'interaction', name, input, run_id: runId }), + cancel: (runId) => call(defineCancelRun(capabilities), { run_id: runId }), + runOf: (runId) => call(defineGetRun(capabilities), { run_id: runId }), performDue, dueOver, dueItems: (now, from = due) => dueInBothLanes(from, now), diff --git a/primitives/interaction/src/testing/racing-deliveries.ts b/capabilities/interaction/src/testing/racing-deliveries.ts similarity index 92% rename from primitives/interaction/src/testing/racing-deliveries.ts rename to capabilities/interaction/src/testing/racing-deliveries.ts index 6d08d3718..8be8f403e 100644 --- a/primitives/interaction/src/testing/racing-deliveries.ts +++ b/capabilities/interaction/src/testing/racing-deliveries.ts @@ -1,11 +1,11 @@ -import { Ledger } from '@beonauto/operations'; import { deferredCanceller, outboundCallRecorder, replyRecorder, - type Primitive, + type Capability, type RecordedOutboundCall, -} from '@beonauto/specs'; +} from '@beonauto/definitions'; +import { Ledger } from '@beonauto/operations'; import { Effect, Layer, Schema } from 'effect'; import { askedRunId } from './asked-requests.ts'; @@ -24,7 +24,7 @@ export const takenReply = { id: '1699.2', sender: 'ada' }; export interface RacingDelivery { readonly ledger: HarnessLedger; readonly started: () => Promise; - readonly cancelSettled: (primitive: Primitive) => Promise; + readonly cancelSettled: (capability: Capability) => Promise; } function replyTakenIn(ledger: HarnessLedger, answer: Schema.Json): Effect.Effect { @@ -68,9 +68,9 @@ export function broughtBeforeSettling(ledger: HarnessLedger, answer?: Schema.Jso }; return { ledger: { service, layer: Layer.succeed(Ledger, service) }, - cancelSettled: (primitive) => + cancelSettled: (capability) => Effect.runPromise( - deferredCanceller([primitive], service)( + deferredCanceller([capability], service)( address, { kind: 'requested', reason: 'Not needed any more', by: 'acme-admin' }, lineage, diff --git a/primitives/interaction/src/testing/reading-documents.ts b/capabilities/interaction/src/testing/reading-documents.ts similarity index 100% rename from primitives/interaction/src/testing/reading-documents.ts rename to capabilities/interaction/src/testing/reading-documents.ts diff --git a/primitives/interaction/src/testing/route-documents.ts b/capabilities/interaction/src/testing/route-documents.ts similarity index 97% rename from primitives/interaction/src/testing/route-documents.ts rename to capabilities/interaction/src/testing/route-documents.ts index 55fe64eb3..4cc046350 100644 --- a/primitives/interaction/src/testing/route-documents.ts +++ b/capabilities/interaction/src/testing/route-documents.ts @@ -1,4 +1,4 @@ -import { issueText } from '@beonauto/specs/document'; +import { issueText } from '@beonauto/definitions/document'; import { Result } from 'effect'; import { parseInteractionDocument } from '../document/document-parsing.ts'; diff --git a/primitives/interaction/src/testing/stopped-requests.ts b/capabilities/interaction/src/testing/stopped-requests.ts similarity index 100% rename from primitives/interaction/src/testing/stopped-requests.ts rename to capabilities/interaction/src/testing/stopped-requests.ts diff --git a/primitives/interaction/tsconfig.json b/capabilities/interaction/tsconfig.json similarity index 100% rename from primitives/interaction/tsconfig.json rename to capabilities/interaction/tsconfig.json diff --git a/primitives/interaction/vitest.config.ts b/capabilities/interaction/vitest.config.ts similarity index 100% rename from primitives/interaction/vitest.config.ts rename to capabilities/interaction/vitest.config.ts diff --git a/primitives/prediction/README.md b/capabilities/prediction/README.md similarity index 100% rename from primitives/prediction/README.md rename to capabilities/prediction/README.md diff --git a/primitives/inference/README.md b/capabilities/reasoning/README.md similarity index 65% rename from primitives/inference/README.md rename to capabilities/reasoning/README.md index 4f2a2fc44..d698f975a 100644 --- a/primitives/inference/README.md +++ b/capabilities/reasoning/README.md @@ -1,13 +1,13 @@ -# @beonauto/inference +# @beonauto/reasoning -The implementation of reasoning functions. It parses each source document into a `ReasoningFunctionDefinitionDocument`, including model settings and the compiled prompt template. The named, versioned `ReasoningFunctionDefinition` is stored separately by `@beonauto/specs`. Its API identifier and package name are `inference`. +The implementation of reasoning functions. It parses each source document into a `ReasoningFunctionDefinitionDocument`, including model settings and the compiled prompt template. The named, versioned `ReasoningFunctionDefinition` is stored separately by `@beonauto/definitions`. User documentation starts with [Reasoning function format](../../docs/reference/reasoning-format.md), published at [on.auto/docs](https://on.auto/docs/). The repository-only [complete format](../../docs/engineering/reference/reasoning-format.md) and [Model providers and gateways](../../docs/engineering/self-host/models.md) retain contributor details. Update the relevant files alongside behavior changes. ## Using it from code ```ts -import { compileAnswerSchema, LanguageModel, languageModelLayer } from '@beonauto/inference'; +import { compileAnswerSchema, LanguageModel, languageModelLayer } from '@beonauto/reasoning'; import { Effect, Result } from 'effect'; const schema = Result.getOrThrow( @@ -34,15 +34,15 @@ const decide = Effect.gen(function* () { Effect.runPromise(decide.pipe(Effect.provide(languageModelLayer(process.env)))); ``` -A request has `model`, optional `instructions`, `messages` (roles `user` and `assistant`, each with text parts), `output`, `settings` (`max_output_tokens`, and optionally `temperature`, `top_p`, `seed`, `stop_sequences` and `reasoning`), optional `provider_options`, `timeout_ms`, `signal` and `retries`. Some providers drop sampling settings for newer models and say so in `warnings`. `provider_options` passes options to the provider, keyed by the AI SDK's namespace for it: `anthropic`, `openai`, `azure`, `google`, `vertex`, `googleVertex`, `amazonBedrock`, `bedrock`, or the gateway's name. A call fails as `spec_invalid` before any provider is called when it holds a namespace no configured provider reads, or an option for a gateway its `allowed_provider_options` does not list; which options of the built-in providers a spec may set is checked when the spec is parsed (see [Provider options](../../docs/engineering/reference/reasoning-format.md#provider-options)). `requestIssues(request)` gives the problems of a request before it is sent, and the model's `admit(request)` everything `generate` would refuse before calling a provider: those problems, a model that resolves to no configured provider or is not allowed, and provider options that are not allowed. +A request has `model`, optional `instructions`, `messages` (roles `user` and `assistant`, each with text parts), `output`, `settings` (`max_output_tokens`, and optionally `temperature`, `top_p`, `seed`, `stop_sequences` and `reasoning`), optional `provider_options`, `timeout_ms`, `signal` and `retries`. Some providers drop sampling settings for newer models and say so in `warnings`. `provider_options` passes options to the provider, keyed by the AI SDK's namespace for it: `anthropic`, `openai`, `azure`, `google`, `vertex`, `googleVertex`, `amazonBedrock`, `bedrock`, or the gateway's name. A call fails as `definition_invalid` before any provider is called when it holds a namespace no configured provider reads, or an option for a gateway its `allowed_provider_options` does not list; which options of the built-in providers a definition may set is checked when the definition is parsed (see [Provider options](../../docs/engineering/reference/reasoning-format.md#provider-options)). `requestIssues(request)` gives the problems of a request before it is sent, and the model's `admit(request)` everything `generate` would refuse before calling a provider: those problems, a model that resolves to no configured provider or is not allowed, and provider options that are not allowed. -`makeModelAccess(settings, options)` builds the same model and also returns `status`, and the `catalog` that [`list_models`](../../docs/engineering/self-host/models.md#listing-the-models) reads, whose `list(provider?)` gives the list. Its options inject a `fetch` and credential sources (`aws`, `google`, `azure`), which is how tests run without a network and how per-tenant credentials will be added, `reportProviderMessage`, which receives the [provider messages](../../docs/engineering/self-host/models.md#provider-messages) a caller does not see, and `reportOperatorHint`, which receives the [operator hints](../../docs/engineering/self-host/models.md#operator-hints). A request may carry an `execution_id`, which only those reports use; the reasoning function adapter sets it to the execution's id. +`makeModelAccess(settings, options)` builds the same model and also returns `status`, and the `catalog` that [`list_models`](../../docs/engineering/self-host/models.md#listing-the-models) reads, whose `list(provider?)` gives the list. Its options inject a `fetch` and credential sources (`aws`, `google`, `azure`), which is how tests run without a network and how per-tenant credentials will be added, `reportProviderMessage`, which receives the [provider messages](../../docs/engineering/self-host/models.md#provider-messages) a caller does not see, and `reportOperatorHint`, which receives the [operator hints](../../docs/engineering/self-host/models.md#operator-hints). A request may carry a `run_id`, which only those reports use; the reasoning function adapter sets it to the run's id. ## Tools -A reasoning function whose front matter names `tools` (each `server/tool`, or `server/*`) runs with the tools of the MCP servers configured for its brain, as [decision 0003](../../docs/decisions/0003-mcp-servers.md) sets out. `makeReasoningFunctionAdapter` takes the server's `ToolAccess` from [`@beonauto/mcp`](../../packages/mcp) as `tools`, as a type only: it imports the helpers it needs to parse `tools` and bound a run from `@beonauto/mcp/policy`, which loads no transport code; without one, or with no server configured, a function that names tools is rejected as `unavailable`, kind `tool_not_offered`. The adapter then says it may change something outside (`mayChangeOutside`), so `execute_spec` is destructive. +A reasoning function whose front matter names `tools` (each `server/tool`, or `server/*`) runs with the tools of the MCP servers configured for its brain, as [decision 0003](../../docs/decisions/0003-mcp-servers.md) sets out. `makeReasoningFunctionAdapter` takes the server's `ToolAccess` from [`@beonauto/mcp`](../../packages/mcp) as `tools`, as a type only: it imports the helpers it needs to parse `tools` and bound a run from `@beonauto/mcp/policy`, which loads no transport code; without one, or with no server configured, a function that names tools is rejected as `unavailable`, kind `tool_not_offered`. The adapter then says it may change something outside (`mayChangeOutside`), so `run_definition` is destructive. -A function that names tools calls tools (`callsTools`), so the start of its run records that, and a run of it that is still `started` is not run again under its id. An execution first admits its request with the model's `admit`, so a run whose model is not offered reaches no server and starts no process; it then opens its tools with its id, org, brain and journal before the model is called, and rejects as `unavailable` with kind `tool_not_offered` (because `mcp_server_not_configured`, `tool_not_allowed` or `tool_not_listed`) or `mcp_server_failed` (because `unreachable`, `failing` or `rate_limited`). Otherwise the request carries `tools`: the offered tools under their model-facing names with their input schemas unchanged, whether the calls have ended, a signal that ends them, and the bound of the whole run. In the adapter, the AI SDK's loop: +A function that names tools calls tools (`callsTools`), so the start of its run records that, and a run of it that is still `started` is not run again under its id. A run first admits its request with the model's `admit`, so a run whose model is not offered reaches no server and starts no process; it then opens its tools with its id, org, brain and journal before the model is called, and rejects as `unavailable` with kind `tool_not_offered` (because `mcp_server_not_configured`, `tool_not_allowed` or `tool_not_listed`) or `mcp_server_failed` (because `unreachable`, `failing` or `rate_limited`). Otherwise the request carries `tools`: the offered tools under their model-facing names with their input schemas unchanged, whether the calls have ended, a signal that ends them, and the bound of the whole run. In the adapter, the AI SDK's loop: - gives the model the tools in the function's own output mode, text or JSON, and returns each answer to the model as a tool result, an error result for a tool error; - gives every model call its own deadline, the request's `timeout_ms`, which the time the tools take does not count against, armed when the call starts and ended however it settles, even when the AI SDK reports no end for a call that threw, and bounds the whole run by ten minutes, or one call's deadline when that is longer; @@ -56,16 +56,16 @@ A run that calls tools costs more: every step resends the conversation so far, s ## What a rejection records -Some rejections come after the model answered, and so after it spent tokens: `output_invalid`, an answer that does not match the schema or is cut off at `max_output_tokens`; `content_refused` from the answer's finish; `tools_stopped` with `no_answer`, whose last step still called tools; and an answer that leaves no room in what a run records. Each of their `InvalidInput`, `Unavailable` or `Conflict` carries a `record` of `usage`, shaped as in the record of a run that succeeded, and `duration_ms`, measured with Effect's `Clock` from the model call to the rejection, or the call's own duration for an answer too large to record; `@beonauto/specs` keeps it on the run's `execution_rejected`. A failure that knows no usage, such as a deadline, the bound of a run that calls tools or tool servers that kept failing, whose SDK call threw, carries none. +Some rejections come after the model answered, and so after it spent tokens: `output_invalid`, an answer that does not match the schema or is cut off at `max_output_tokens`; `content_refused` from the answer's finish; `tools_stopped` with `no_answer`, whose last step still called tools; and an answer that leaves no room in what a run records. Each of their `InvalidInput`, `Unavailable` or `Conflict` carries a `record` of `usage`, shaped as in the record of a run that succeeded, and `duration_ms`, measured with Effect's `Clock` from the model call to the rejection, or the call's own duration for an answer too large to record; `@beonauto/definitions` keeps it on the run's `run_rejected`. A failure that knows no usage, such as a deadline, the bound of a run that calls tools or tool servers that kept failing, whose SDK call threw, carries none. ## Testing -`makeReasoningFunctionAdapter({ languageModel, offered, clock, tools })` makes the runtime adapter for `makeSpecOperations`; the server gives it the language model and offered-model configuration from `makeModelAccess`, plus its `ToolAccess`, and a test the scripted model below. `ReasoningFunctionAdapterOptions` names its options. `clock` is optional: without it, `today` and `now` come from Effect's `Clock`. +`makeReasoningFunctionAdapter({ languageModel, offered, clock, tools })` makes the runtime adapter for `makeDefinitionOperations`; the server gives it the language model and offered-model configuration from `makeModelAccess`, plus its `ToolAccess`, and a test the scripted model below. `ReasoningFunctionAdapterOptions` names its options. `clock` is optional: without it, `today` and `now` come from Effect's `Clock`. -`@beonauto/inference/testing` exports a fake for the tests of other packages. `callingTools(calls, then)` scripts a model that calls tools before its reply, as the adapter does, with a signal that aborts when the call is interrupted. `scriptedLanguageModel(...replies)` answers with its replies in order, admits and rejects an invalid request as the real one does, records every request (`requests()`), and provides itself as a `layer`. A reply is a function of the request; `answers(textResult('Hello'))` and `answers(jsonResult({ verdict: 'approve' }))` build the usual ones, and `() => Effect.fail(new RateLimited({ ... }))` scripts a failure. +`@beonauto/reasoning/testing` exports a fake for the tests of other packages. `callingTools(calls, then)` scripts a model that calls tools before its reply, as the adapter does, with a signal that aborts when the call is interrupted. `scriptedLanguageModel(...replies)` answers with its replies in order, admits and rejects an invalid request as the real one does, records every request (`requests()`), and provides itself as a `layer`. A reply is a function of the request; `answers(textResult('Hello'))` and `answers(jsonResult({ verdict: 'approve' }))` build the usual ones, and `() => Effect.fail(new RateLimited({ ... }))` scripts a failure. No test in this package calls a model: the adapter is tested with the AI SDK's mock model and with the real provider packages against a fake `fetch` that answers with each provider's documented response shape. ## Source -`src/index.ts` is the entry point and `src/testing/index.ts` the entry point of the test support. `src/model` holds the interface: the request, the result, the `LanguageModel` service and the request checks. `src/failure` holds one class per failure. `src/schema` holds answer schemas: their limits and their compilation, made with the JSON Schema compiler of [`@beonauto/specs/document`](../../packages/specs/README.md#reading-function-documents), and `checkAnswerSchema`, the report of what in a schema is unsupported, not portable across providers or not checked. `src/settings` reads the settings from the environment with Effect `Config`. `src/adapter` and `src/tools` are the only production code that imports the AI SDK, and `src/adapter` the only code that imports the cloud credential libraries. `src/listing` reads the list of models of each provider, and `src/catalog` keeps the lists, combines them with the declared models and the aliases, applies the allow list, and defines `list_models`. `src/template` is the only code that imports liquidjs: its instance of the shared engine of [`@beonauto/specs/template`](../../packages/specs/README.md#templates-of-a-capability), with the registrations that stay this capability's own, its prompt filters and its system block, and compiling and rendering a prompt over the shared parse and render, behind readonly types of its own; `rendering-corpus.json` holds what each template of the rendering tests rendered before the engine moved into the shared factory, and `rendering-corpus.test.ts` holds the engine to it. `src/spec` parses and validates a spec document with the reader of `@beonauto/specs/document`, which splits it and reads its YAML: the keys of its front matter, the settings, the schemas and the variables a template reads. `src/tools` gives the model a run's tools: the tools as the AI SDK takes them, the last step without them, opening a run's tools and its endings. `src/primitive` is the primitive: its guide, the public reference page, served to agents as `reasoning-function` and ending with the sentence `onThisServer` makes of what this server offers, preparing the input, rendering the prompt, the request, the answer, the rejections and the record. `src/testing` holds the fake and what the tests share, including the SDK's mock model. +`src/index.ts` is the entry point and `src/testing/index.ts` the entry point of the test support. `src/model` holds the interface: the request, the result, the `LanguageModel` service and the request checks. `src/failure` holds one class per failure. `src/schema` holds answer schemas: their limits and their compilation, made with the JSON Schema compiler of [`@beonauto/definitions/document`](../../packages/definitions/README.md#reading-function-documents), and `checkAnswerSchema`, the report of what in a schema is unsupported, not portable across providers or not checked. `src/settings` reads the settings from the environment with Effect `Config`. `src/adapter` and `src/tools` are the only production code that imports the AI SDK, and `src/adapter` the only code that imports the cloud credential libraries. `src/listing` reads the list of models of each provider, and `src/catalog` keeps the lists, combines them with the declared models and the aliases, applies the allow list, and defines `list_models`. `src/template` is the only code that imports liquidjs: its instance of the shared engine of [`@beonauto/definitions/template`](../../packages/definitions/README.md#templates-of-a-capability), with the registrations that stay this capability's own, its prompt filters and its system block, and compiling and rendering a prompt over the shared parse and render, behind readonly types of its own; `rendering-corpus.json` holds what each template of the rendering tests rendered before the engine moved into the shared factory, and `rendering-corpus.test.ts` holds the engine to it. `src/definition` parses and validates a definition document with the reader of `@beonauto/definitions/document`, which splits it and reads its YAML: the keys of its front matter, the settings, the schemas and the variables a template reads. `src/tools` gives the model a run's tools: the tools as the AI SDK takes them, the last step without them, opening a run's tools and its endings. `src/capability` is the capability: its guide, the public reference page, served to agents as `reasoning-function` and ending with the sentence `onThisServer` makes of what this server offers, preparing the input, rendering the prompt, the request, the answer, the rejections and the record. `src/testing` holds the fake and what the tests share, including the SDK's mock model. diff --git a/primitives/inference/package.json b/capabilities/reasoning/package.json similarity index 93% rename from primitives/inference/package.json rename to capabilities/reasoning/package.json index 34eb1a6f8..129d81dcd 100644 --- a/primitives/inference/package.json +++ b/capabilities/reasoning/package.json @@ -1,5 +1,5 @@ { - "name": "@beonauto/inference", + "name": "@beonauto/reasoning", "version": "0.0.0", "private": true, "license": "Elastic-2.0", @@ -23,10 +23,10 @@ "@ai-sdk/openai-compatible": "3.0.61", "@aws-sdk/credential-providers": "3.1144.0", "@beonauto/config": "workspace:*", + "@beonauto/definitions": "workspace:*", "@beonauto/mcp": "workspace:*", "@beonauto/operations": "workspace:*", "@beonauto/outbound": "workspace:*", - "@beonauto/specs": "workspace:*", "ai": "7.0.124", "effect": "catalog:", "google-auth-library": "10.9.1", diff --git a/primitives/inference/src/adapter/answer-mapping.ts b/capabilities/reasoning/src/adapter/answer-mapping.ts similarity index 100% rename from primitives/inference/src/adapter/answer-mapping.ts rename to capabilities/reasoning/src/adapter/answer-mapping.ts diff --git a/primitives/inference/src/adapter/answer-mismatch.ts b/capabilities/reasoning/src/adapter/answer-mismatch.ts similarity index 79% rename from primitives/inference/src/adapter/answer-mismatch.ts rename to capabilities/reasoning/src/adapter/answer-mismatch.ts index e799e674e..7b1fd5883 100644 --- a/primitives/inference/src/adapter/answer-mismatch.ts +++ b/capabilities/reasoning/src/adapter/answer-mismatch.ts @@ -1,4 +1,4 @@ -import type { SchemaIssue } from '@beonauto/specs/document'; +import type { SchemaIssue } from '@beonauto/definitions/document'; export class AnswerMismatch extends Error { readonly issues: readonly SchemaIssue[]; diff --git a/primitives/inference/src/adapter/answer-settling.ts b/capabilities/reasoning/src/adapter/answer-settling.ts similarity index 100% rename from primitives/inference/src/adapter/answer-settling.ts rename to capabilities/reasoning/src/adapter/answer-settling.ts diff --git a/primitives/inference/src/adapter/api-call-failure.ts b/capabilities/reasoning/src/adapter/api-call-failure.ts similarity index 97% rename from primitives/inference/src/adapter/api-call-failure.ts rename to capabilities/reasoning/src/adapter/api-call-failure.ts index dd9ab0345..6650d1ad5 100644 --- a/primitives/inference/src/adapter/api-call-failure.ts +++ b/capabilities/reasoning/src/adapter/api-call-failure.ts @@ -2,10 +2,10 @@ import { Predicate } from 'effect'; import { ContentRefused } from '../failure/content-refused.ts'; import { CredentialsRejected } from '../failure/credentials-rejected.ts'; +import { DefinitionInvalid } from '../failure/definition-invalid.ts'; import type { ModelFailure } from '../failure/model-failure.ts'; import { ProviderUnavailable } from '../failure/provider-unavailable.ts'; import { RateLimited } from '../failure/rate-limited.ts'; -import { SpecInvalid } from '../failure/spec-invalid.ts'; export interface ApiCallDetails { readonly provider: string; @@ -81,7 +81,7 @@ function rejectedRequest(details: ApiCallDetails, code: number): ModelFailure { usage: null, }); } - return new SpecInvalid({ + return new DefinitionInvalid({ detail: `${provider} answered HTTP ${code}: ${statusReadings.get(code) ?? 'the request was not accepted'}`, provider, status: code, diff --git a/primitives/inference/src/adapter/azure-provider.test.ts b/capabilities/reasoning/src/adapter/azure-provider.test.ts similarity index 100% rename from primitives/inference/src/adapter/azure-provider.test.ts rename to capabilities/reasoning/src/adapter/azure-provider.test.ts diff --git a/primitives/inference/src/adapter/call-failures.test.ts b/capabilities/reasoning/src/adapter/call-failures.test.ts similarity index 100% rename from primitives/inference/src/adapter/call-failures.test.ts rename to capabilities/reasoning/src/adapter/call-failures.test.ts diff --git a/primitives/inference/src/adapter/call-options.ts b/capabilities/reasoning/src/adapter/call-options.ts similarity index 100% rename from primitives/inference/src/adapter/call-options.ts rename to capabilities/reasoning/src/adapter/call-options.ts diff --git a/primitives/inference/src/adapter/call-policy.ts b/capabilities/reasoning/src/adapter/call-policy.ts similarity index 100% rename from primitives/inference/src/adapter/call-policy.ts rename to capabilities/reasoning/src/adapter/call-policy.ts diff --git a/primitives/inference/src/adapter/call-settling.ts b/capabilities/reasoning/src/adapter/call-settling.ts similarity index 100% rename from primitives/inference/src/adapter/call-settling.ts rename to capabilities/reasoning/src/adapter/call-settling.ts diff --git a/primitives/inference/src/adapter/cloud-providers.test.ts b/capabilities/reasoning/src/adapter/cloud-providers.test.ts similarity index 100% rename from primitives/inference/src/adapter/cloud-providers.test.ts rename to capabilities/reasoning/src/adapter/cloud-providers.test.ts diff --git a/primitives/inference/src/adapter/cloud-providers.ts b/capabilities/reasoning/src/adapter/cloud-providers.ts similarity index 100% rename from primitives/inference/src/adapter/cloud-providers.ts rename to capabilities/reasoning/src/adapter/cloud-providers.ts diff --git a/primitives/inference/src/adapter/configuration-failures.test.ts b/capabilities/reasoning/src/adapter/configuration-failures.test.ts similarity index 97% rename from primitives/inference/src/adapter/configuration-failures.test.ts rename to capabilities/reasoning/src/adapter/configuration-failures.test.ts index 032d82e1f..15287b88f 100644 --- a/primitives/inference/src/adapter/configuration-failures.test.ts +++ b/capabilities/reasoning/src/adapter/configuration-failures.test.ts @@ -81,7 +81,7 @@ describe('a model reference that does not resolve', () => { }); it.each(['claude-sonnet-4-5', '/claude', 'anthropic/'])( - 'fails as spec_invalid for the bare reference %s, without sending anything', + 'fails as definition_invalid for the bare reference %s, without sending anything', async (model) => { const recording = recordingFetch(() => jsonResponse(anthropicMessage('unused'))); const access = await accessFor({ ANTHROPIC_API_KEY: 'k' }, { fetch: recording.fetch }); @@ -89,7 +89,7 @@ describe('a model reference that does not resolve', () => { const failure = await failed(access, textRequest(model)); expect(failure).toMatchObject({ - _tag: 'spec_invalid', + _tag: 'definition_invalid', provider: null, issues: [{ pointer: '/model', detail: 'Expected provider/model' }], }); diff --git a/primitives/inference/src/adapter/credential-sources.test.ts b/capabilities/reasoning/src/adapter/credential-sources.test.ts similarity index 100% rename from primitives/inference/src/adapter/credential-sources.test.ts rename to capabilities/reasoning/src/adapter/credential-sources.test.ts diff --git a/primitives/inference/src/adapter/credential-sources.ts b/capabilities/reasoning/src/adapter/credential-sources.ts similarity index 100% rename from primitives/inference/src/adapter/credential-sources.ts rename to capabilities/reasoning/src/adapter/credential-sources.ts diff --git a/primitives/inference/src/adapter/direct-providers.test.ts b/capabilities/reasoning/src/adapter/direct-providers.test.ts similarity index 100% rename from primitives/inference/src/adapter/direct-providers.test.ts rename to capabilities/reasoning/src/adapter/direct-providers.test.ts diff --git a/primitives/inference/src/adapter/direct-providers.ts b/capabilities/reasoning/src/adapter/direct-providers.ts similarity index 100% rename from primitives/inference/src/adapter/direct-providers.ts rename to capabilities/reasoning/src/adapter/direct-providers.ts diff --git a/primitives/inference/src/adapter/entra-id.test.ts b/capabilities/reasoning/src/adapter/entra-id.test.ts similarity index 100% rename from primitives/inference/src/adapter/entra-id.test.ts rename to capabilities/reasoning/src/adapter/entra-id.test.ts diff --git a/primitives/inference/src/adapter/entra-id.ts b/capabilities/reasoning/src/adapter/entra-id.ts similarity index 100% rename from primitives/inference/src/adapter/entra-id.ts rename to capabilities/reasoning/src/adapter/entra-id.ts diff --git a/primitives/inference/src/adapter/failure-classification.ts b/capabilities/reasoning/src/adapter/failure-classification.ts similarity index 94% rename from primitives/inference/src/adapter/failure-classification.ts rename to capabilities/reasoning/src/adapter/failure-classification.ts index 886a97411..327bf3fee 100644 --- a/primitives/inference/src/adapter/failure-classification.ts +++ b/capabilities/reasoning/src/adapter/failure-classification.ts @@ -14,10 +14,10 @@ import { } from 'ai'; import { CredentialsRejected } from '../failure/credentials-rejected.ts'; +import { DefinitionInvalid } from '../failure/definition-invalid.ts'; import type { ModelFailure } from '../failure/model-failure.ts'; import { ProviderNotConfigured } from '../failure/provider-not-configured.ts'; import { ProviderUnavailable } from '../failure/provider-unavailable.ts'; -import { SpecInvalid } from '../failure/spec-invalid.ts'; import { apiCallFailure } from './api-call-failure.ts'; import { credentialLookupMarker, OutboundFailure } from './outbound-failure.ts'; import { outputFailure } from './output-failure.ts'; @@ -93,7 +93,7 @@ const unusableOutput: Recognizer = (error, { provider }) => }) : null; -function invalidSpecDetail(error: unknown): string | null { +function invalidDefinitionDetail(error: unknown): string | null { if (InvalidArgumentError.isInstance(error)) { return error.message; } @@ -106,11 +106,11 @@ function invalidSpecDetail(error: unknown): string | null { return InvalidPromptError.isInstance(error) ? 'The prompt is not valid for this model' : null; } -const invalidSpec: Recognizer = (error, { provider }) => { - const detail = invalidSpecDetail(error); +const invalidDefinition: Recognizer = (error, { provider }) => { + const detail = invalidDefinitionDetail(error); return detail === null ? null - : new SpecInvalid({ detail, provider, status: null, provider_message: null, issues: [] }); + : new DefinitionInvalid({ detail, provider, status: null, provider_message: null, issues: [] }); }; function isUnreadableResponse(error: unknown): boolean { @@ -145,7 +145,7 @@ const recognizers: readonly Recognizer[] = [ wrappedCredentials, apiCall, unusableOutput, - invalidSpec, + invalidDefinition, unreadableResponse, missingSetting, ]; diff --git a/primitives/inference/src/adapter/gateway-options.test.ts b/capabilities/reasoning/src/adapter/gateway-options.test.ts similarity index 98% rename from primitives/inference/src/adapter/gateway-options.test.ts rename to capabilities/reasoning/src/adapter/gateway-options.test.ts index 99fde05cd..981765560 100644 --- a/primitives/inference/src/adapter/gateway-options.test.ts +++ b/capabilities/reasoning/src/adapter/gateway-options.test.ts @@ -48,7 +48,7 @@ describe('the provider options of a gateway call', () => { expect(failure).toEqual( expect.objectContaining({ - _tag: 'spec_invalid', + _tag: 'definition_invalid', detail: 'The gateway my-gateway does not allow the provider option mock_response', provider: 'my-gateway', issues: [ @@ -105,7 +105,7 @@ describe('provider options under a namespace no configured provider reads', () = expect( await failed(access, textRequest(model, { provider_options: { [namespace]: { user: 'x' } } })), ).toMatchObject({ - _tag: 'spec_invalid', + _tag: 'definition_invalid', detail: `No configured provider reads the provider options under ${namespace}`, issues: [ { diff --git a/primitives/inference/src/adapter/gateway-options.ts b/capabilities/reasoning/src/adapter/gateway-options.ts similarity index 89% rename from primitives/inference/src/adapter/gateway-options.ts rename to capabilities/reasoning/src/adapter/gateway-options.ts index cc6a38b1d..2cdb74bdc 100644 --- a/primitives/inference/src/adapter/gateway-options.ts +++ b/capabilities/reasoning/src/adapter/gateway-options.ts @@ -1,6 +1,6 @@ import { JsonPointer, Result } from 'effect'; -import { SpecInvalid } from '../failure/spec-invalid.ts'; +import { DefinitionInvalid } from '../failure/definition-invalid.ts'; import type { ProviderOptions } from '../model/model-request.ts'; import { providerNamespaces } from '../model/offered-provider-options.ts'; import type { GatewaySettings } from '../settings/gateway-settings.ts'; @@ -8,7 +8,7 @@ import type { GatewaySettings } from '../settings/gateway-settings.ts'; export type ProviderOptionsCheck = ( provider: string, options: ProviderOptions | undefined, -) => Result.Result; +) => Result.Result; interface Disallowed { readonly namespace: string; @@ -33,8 +33,8 @@ function disallowedIn(gateway: GatewaySettings, options: ProviderOptions): reado ); } -function rejection(gateway: string, first: Disallowed, found: readonly Disallowed[]): SpecInvalid { - return new SpecInvalid({ +function rejection(gateway: string, first: Disallowed, found: readonly Disallowed[]): DefinitionInvalid { + return new DefinitionInvalid({ detail: `The gateway ${gateway} does not allow the provider option ${first.field}`, provider: gateway, status: null, @@ -46,8 +46,8 @@ function rejection(gateway: string, first: Disallowed, found: readonly Disallowe }); } -function unreadNamespace(provider: string, namespace: string): SpecInvalid { - return new SpecInvalid({ +function unreadNamespace(provider: string, namespace: string): DefinitionInvalid { + return new DefinitionInvalid({ detail: `No configured provider reads the provider options under ${namespace}`, provider, status: null, diff --git a/primitives/inference/src/adapter/gateway-providers.test.ts b/capabilities/reasoning/src/adapter/gateway-providers.test.ts similarity index 100% rename from primitives/inference/src/adapter/gateway-providers.test.ts rename to capabilities/reasoning/src/adapter/gateway-providers.test.ts diff --git a/primitives/inference/src/adapter/generation.test.ts b/capabilities/reasoning/src/adapter/generation.test.ts similarity index 100% rename from primitives/inference/src/adapter/generation.test.ts rename to capabilities/reasoning/src/adapter/generation.test.ts diff --git a/primitives/inference/src/adapter/generation.ts b/capabilities/reasoning/src/adapter/generation.ts similarity index 95% rename from primitives/inference/src/adapter/generation.ts rename to capabilities/reasoning/src/adapter/generation.ts index ff64354ff..d95cbf0fc 100644 --- a/primitives/inference/src/adapter/generation.ts +++ b/capabilities/reasoning/src/adapter/generation.ts @@ -12,10 +12,10 @@ import { UnclassifiedModelError } from './unclassified-model-error.ts'; function reportsOf( policy: CallPolicy, { provider }: ModelTarget, - { model, execution_id }: ModelRequest, + { model, run_id }: ModelRequest, { providerText, operatorHint }: CallReports, ): Effect.Effect { - const call = { provider, model, execution_id: execution_id ?? null }; + const call = { provider, model, run_id: run_id ?? null }; return Effect.all( [ providerText === null ? Effect.void : policy.report({ ...call, ...providerText }), diff --git a/primitives/inference/src/adapter/json-output.ts b/capabilities/reasoning/src/adapter/json-output.ts similarity index 86% rename from primitives/inference/src/adapter/json-output.ts rename to capabilities/reasoning/src/adapter/json-output.ts index a78c365cf..9b41b8233 100644 --- a/primitives/inference/src/adapter/json-output.ts +++ b/capabilities/reasoning/src/adapter/json-output.ts @@ -1,9 +1,9 @@ -import { shapeIssues } from '@beonauto/specs/document'; +import { shapeIssues } from '@beonauto/definitions/document'; import { jsonSchema, Output } from 'ai'; import { Result, type Schema } from 'effect'; import type { JSONSchema7 } from 'json-schema'; -import { SpecInvalid } from '../failure/spec-invalid.ts'; +import { DefinitionInvalid } from '../failure/definition-invalid.ts'; import type { JsonOutput } from '../model/model-request.ts'; import type { AnswerSchema } from '../schema/answer-schema.ts'; import { AnswerMismatch } from './answer-mismatch.ts'; @@ -19,8 +19,8 @@ function validation(schema: AnswerSchema, value: unknown) { }); } -function unreadableSchema(document: Schema.JsonObject): SpecInvalid { - return new SpecInvalid({ +function unreadableSchema(document: Schema.JsonObject): DefinitionInvalid { + return new DefinitionInvalid({ detail: 'The output schema is not one this package can validate', provider: null, status: null, diff --git a/primitives/inference/src/adapter/language-model-layer.test.ts b/capabilities/reasoning/src/adapter/language-model-layer.test.ts similarity index 100% rename from primitives/inference/src/adapter/language-model-layer.test.ts rename to capabilities/reasoning/src/adapter/language-model-layer.test.ts diff --git a/primitives/inference/src/adapter/language-model-layer.ts b/capabilities/reasoning/src/adapter/language-model-layer.ts similarity index 100% rename from primitives/inference/src/adapter/language-model-layer.ts rename to capabilities/reasoning/src/adapter/language-model-layer.ts diff --git a/primitives/inference/src/adapter/model-access-options.ts b/capabilities/reasoning/src/adapter/model-access-options.ts similarity index 92% rename from primitives/inference/src/adapter/model-access-options.ts rename to capabilities/reasoning/src/adapter/model-access-options.ts index cce6bad60..9a9a906d8 100644 --- a/primitives/inference/src/adapter/model-access-options.ts +++ b/capabilities/reasoning/src/adapter/model-access-options.ts @@ -9,7 +9,7 @@ export interface ProviderMessageReport { readonly model: string | null; readonly status: number | null; readonly message: string; - readonly execution_id: string | null; + readonly run_id: string | null; } export type ReportProviderMessage = (report: ProviderMessageReport) => Effect.Effect; @@ -18,7 +18,7 @@ export interface OperatorHintReport { readonly provider: string; readonly model: string | null; readonly hint: string; - readonly execution_id: string | null; + readonly run_id: string | null; } export type ReportOperatorHint = (report: OperatorHintReport) => Effect.Effect; diff --git a/primitives/inference/src/adapter/model-access.ts b/capabilities/reasoning/src/adapter/model-access.ts similarity index 100% rename from primitives/inference/src/adapter/model-access.ts rename to capabilities/reasoning/src/adapter/model-access.ts diff --git a/primitives/inference/src/adapter/model-call.ts b/capabilities/reasoning/src/adapter/model-call.ts similarity index 100% rename from primitives/inference/src/adapter/model-call.ts rename to capabilities/reasoning/src/adapter/model-call.ts diff --git a/primitives/inference/src/adapter/model-factories.ts b/capabilities/reasoning/src/adapter/model-factories.ts similarity index 100% rename from primitives/inference/src/adapter/model-factories.ts rename to capabilities/reasoning/src/adapter/model-factories.ts diff --git a/primitives/inference/src/adapter/model-resolution.ts b/capabilities/reasoning/src/adapter/model-resolution.ts similarity index 92% rename from primitives/inference/src/adapter/model-resolution.ts rename to capabilities/reasoning/src/adapter/model-resolution.ts index 4a2b34b75..9d47fbd94 100644 --- a/primitives/inference/src/adapter/model-resolution.ts +++ b/capabilities/reasoning/src/adapter/model-resolution.ts @@ -1,8 +1,8 @@ import { Option, Result } from 'effect'; +import { DefinitionInvalid } from '../failure/definition-invalid.ts'; import { ModelNotAllowed } from '../failure/model-not-allowed.ts'; import { ProviderNotConfigured } from '../failure/provider-not-configured.ts'; -import { SpecInvalid } from '../failure/spec-invalid.ts'; import { aliasResolution } from '../model/model-alias.ts'; import { modelOffer } from '../model/model-offer.ts'; import { parseModelReference } from '../model/model-reference.ts'; @@ -18,10 +18,10 @@ export interface ModelTarget { export type ModelResolution = ( reference: string, -) => Result.Result; +) => Result.Result; -function malformedReference(): SpecInvalid { - return new SpecInvalid({ +function malformedReference(): DefinitionInvalid { + return new DefinitionInvalid({ detail: 'A model is written provider/model, for example anthropic/claude-sonnet-4-5', provider: null, status: null, diff --git a/primitives/inference/src/adapter/operator-hints.test.ts b/capabilities/reasoning/src/adapter/operator-hints.test.ts similarity index 96% rename from primitives/inference/src/adapter/operator-hints.test.ts rename to capabilities/reasoning/src/adapter/operator-hints.test.ts index 639af64f0..2aa778bd5 100644 --- a/primitives/inference/src/adapter/operator-hints.test.ts +++ b/capabilities/reasoning/src/adapter/operator-hints.test.ts @@ -11,7 +11,7 @@ describe('a provider certificate this server does not trust', () => { const fetch = recordingFetch(() => connectionFailure('SELF_SIGNED_CERT_IN_CHAIN')).fetch; const access = await accessFor({ OPENAI_API_KEY: 'k' }, { fetch, reportOperatorHint: recording.report }); - const failure = await failed(access, textRequest('openai/gpt-5', { execution_id: 'exec-1' })); + const failure = await failed(access, textRequest('openai/gpt-5', { run_id: 'exec-1' })); expect(failure.detail).toBe( 'The TLS certificate of openai is not trusted by this server; its operator must add the certificate authority', @@ -21,7 +21,7 @@ describe('a provider certificate this server does not trust', () => { provider: 'openai', model: 'openai/gpt-5', hint: 'The TLS certificate of openai is not trusted; add its certificate authority with NODE_EXTRA_CA_CERTS', - execution_id: 'exec-1', + run_id: 'exec-1', }, ]); }); diff --git a/primitives/inference/src/adapter/outbound-failure.ts b/capabilities/reasoning/src/adapter/outbound-failure.ts similarity index 100% rename from primitives/inference/src/adapter/outbound-failure.ts rename to capabilities/reasoning/src/adapter/outbound-failure.ts diff --git a/primitives/inference/src/adapter/outbound-fetch.ts b/capabilities/reasoning/src/adapter/outbound-fetch.ts similarity index 100% rename from primitives/inference/src/adapter/outbound-fetch.ts rename to capabilities/reasoning/src/adapter/outbound-fetch.ts diff --git a/primitives/inference/src/adapter/output-failure.ts b/capabilities/reasoning/src/adapter/output-failure.ts similarity index 96% rename from primitives/inference/src/adapter/output-failure.ts rename to capabilities/reasoning/src/adapter/output-failure.ts index 4b1548f26..e7d93571c 100644 --- a/primitives/inference/src/adapter/output-failure.ts +++ b/capabilities/reasoning/src/adapter/output-failure.ts @@ -1,4 +1,4 @@ -import type { SchemaIssue } from '@beonauto/specs/document'; +import type { SchemaIssue } from '@beonauto/definitions/document'; import { TypeValidationError, type FinishReason as SdkFinishReason } from 'ai'; import { ContentRefused } from '../failure/content-refused.ts'; diff --git a/primitives/inference/src/adapter/output-failures.test.ts b/capabilities/reasoning/src/adapter/output-failures.test.ts similarity index 100% rename from primitives/inference/src/adapter/output-failures.test.ts rename to capabilities/reasoning/src/adapter/output-failures.test.ts diff --git a/primitives/inference/src/adapter/provider-messages.test.ts b/capabilities/reasoning/src/adapter/provider-messages.test.ts similarity index 96% rename from primitives/inference/src/adapter/provider-messages.test.ts rename to capabilities/reasoning/src/adapter/provider-messages.test.ts index 000fce6f1..491a2d8d8 100644 --- a/primitives/inference/src/adapter/provider-messages.test.ts +++ b/capabilities/reasoning/src/adapter/provider-messages.test.ts @@ -36,7 +36,7 @@ async function outcomeOf( credentials: { aws: staticAwsCredentials }, reportProviderMessage: reporter.report, }); - const failure = await failed(access, textRequest(model, { execution_id: 'exec-1' })); + const failure = await failed(access, textRequest(model, { run_id: 'exec-1' })); expect(exposedText(failure)).not.toContain(promptText); return { failure, reports: reporter.reports() }; } @@ -48,7 +48,7 @@ describe('the message of a built-in provider at its default endpoint', () => { ); expect(failure).toMatchObject({ - _tag: 'spec_invalid', + _tag: 'definition_invalid', detail: 'openai answered HTTP 400: the request was rejected as invalid', provider_message: 'Unsupported parameter: temperature', }); @@ -58,7 +58,7 @@ describe('the message of a built-in provider at its default endpoint', () => { model: 'openai/gpt-5', status: 400, message: 'Unsupported parameter: temperature\nSee the documentation', - execution_id: 'exec-1', + run_id: 'exec-1', }, ]); }); @@ -159,7 +159,7 @@ describe('the message of a built-in provider at an endpoint the operator overrod const { failure, reports } = await outcomeOf(environment, model, response); const [provider] = model.split('/'); - expect(failure).toMatchObject({ _tag: 'spec_invalid', provider, status: 404, provider_message: null }); + expect(failure).toMatchObject({ _tag: 'definition_invalid', provider, status: 404, provider_message: null }); expect(failure).toMatchObject({ detail: `${String(provider)} answered HTTP 404: the model was not found` }); expect(exposedText(failure)).not.toContain(internalText); expect(reports).toMatchObject([{ status: 404, message: internalText, model }]); @@ -177,7 +177,7 @@ describe('the message of a gateway', () => { ); expect(failure).toMatchObject({ - _tag: 'spec_invalid', + _tag: 'definition_invalid', provider: 'gateway', status: 404, detail: 'gateway answered HTTP 404: the model was not found', @@ -190,7 +190,7 @@ describe('the message of a gateway', () => { model: 'gateway/no-such-model-xyz', status: 404, message: gatewayErrorText, - execution_id: 'exec-1', + run_id: 'exec-1', }, ]); }); diff --git a/primitives/inference/src/adapter/resolved-language-model.ts b/capabilities/reasoning/src/adapter/resolved-language-model.ts similarity index 100% rename from primitives/inference/src/adapter/resolved-language-model.ts rename to capabilities/reasoning/src/adapter/resolved-language-model.ts diff --git a/primitives/inference/src/adapter/sdk-failures.test.ts b/capabilities/reasoning/src/adapter/sdk-failures.test.ts similarity index 90% rename from primitives/inference/src/adapter/sdk-failures.test.ts rename to capabilities/reasoning/src/adapter/sdk-failures.test.ts index 7a3dc76af..f6597bb76 100644 --- a/primitives/inference/src/adapter/sdk-failures.test.ts +++ b/capabilities/reasoning/src/adapter/sdk-failures.test.ts @@ -55,18 +55,22 @@ const missingSetting = 'mock is missing a setting on this server; its operator m const classifications: readonly (readonly [Readonly, string, string])[] = [ [ new InvalidPromptError({ prompt: promptText, message: 'messages must not be empty' }), - 'spec_invalid', + 'definition_invalid', 'The prompt is not valid for this model', ], - [new UnsupportedFunctionalityError({ functionality: 'seed' }), 'spec_invalid', 'The model does not support seed'], + [ + new UnsupportedFunctionalityError({ functionality: 'seed' }), + 'definition_invalid', + 'The model does not support seed', + ], [ new NoSuchModelError({ modelId: 'model', modelType: 'languageModel' }), - 'spec_invalid', + 'definition_invalid', 'The provider has no such model', ], [ new InvalidArgumentError({ parameter: 'topK', value: 5, message: 'topK must be an integer' }), - 'spec_invalid', + 'definition_invalid', 'Invalid argument for parameter topK: topK must be an integer', ], [new LoadAPIKeyError({ message: 'missing key' }), 'provider_not_configured', missingSetting], @@ -96,12 +100,12 @@ describe('an SDK failure', () => { "OpenAI API key is missing. Pass it using the 'apiKey' parameter or the OPENAI_API_KEY environment variable."; const failure = await mockGeneration(throwing(new LoadAPIKeyError({ message })), recording.report).failed( - textRequest('mock/model', { execution_id: 'exec-1' }), + textRequest('mock/model', { run_id: 'exec-1' }), ); expect(exposedText(failure)).not.toContain('OPENAI_API_KEY'); expect(recording.hints()).toEqual([ - { provider: 'mock', model: 'mock/model', hint: `mock is missing a setting: ${message}`, execution_id: 'exec-1' }, + { provider: 'mock', model: 'mock/model', hint: `mock is missing a setting: ${message}`, run_id: 'exec-1' }, ]); }); @@ -115,7 +119,7 @@ describe('an SDK failure', () => { }); describe('a request that is not valid', () => { - it('fails as spec_invalid with every issue, before any model is called', async () => { + it('fails as definition_invalid with every issue, before any model is called', async () => { const model = new MockLanguageModelV4(); const request = textRequest('mock/model', { messages: [], @@ -126,7 +130,7 @@ describe('a request that is not valid', () => { const failure = await mockGeneration(() => model).failed(request); expect(failure).toMatchObject({ - _tag: 'spec_invalid', + _tag: 'definition_invalid', issues: [ { pointer: '/messages', detail: 'Expected at least one message' }, { pointer: '/settings/max_output_tokens', detail: 'Expected an integer of 1 or more' }, @@ -139,7 +143,7 @@ describe('a request that is not valid', () => { expect(model.doGenerateCalls).toEqual([]); }); - it('fails as spec_invalid when its output schema is not one this package reads', async () => { + it('fails as definition_invalid when its output schema is not one this package reads', async () => { const model = new MockLanguageModelV4(); const schema = { document: { type: 'banana' }, validate: () => Result.succeed(null) }; @@ -148,7 +152,7 @@ describe('a request that is not valid', () => { ); expect(failure).toMatchObject({ - _tag: 'spec_invalid', + _tag: 'definition_invalid', detail: 'The output schema is not one this package can validate', issues: [ { diff --git a/primitives/inference/src/adapter/sdk-globals.test.ts b/capabilities/reasoning/src/adapter/sdk-globals.test.ts similarity index 100% rename from primitives/inference/src/adapter/sdk-globals.test.ts rename to capabilities/reasoning/src/adapter/sdk-globals.test.ts diff --git a/primitives/inference/src/adapter/sdk-globals.ts b/capabilities/reasoning/src/adapter/sdk-globals.ts similarity index 100% rename from primitives/inference/src/adapter/sdk-globals.ts rename to capabilities/reasoning/src/adapter/sdk-globals.ts diff --git a/primitives/inference/src/adapter/sdk-model.ts b/capabilities/reasoning/src/adapter/sdk-model.ts similarity index 100% rename from primitives/inference/src/adapter/sdk-model.ts rename to capabilities/reasoning/src/adapter/sdk-model.ts diff --git a/primitives/inference/src/adapter/status-failures.test.ts b/capabilities/reasoning/src/adapter/status-failures.test.ts similarity index 92% rename from primitives/inference/src/adapter/status-failures.test.ts rename to capabilities/reasoning/src/adapter/status-failures.test.ts index dfd45737e..f96a40f83 100644 --- a/primitives/inference/src/adapter/status-failures.test.ts +++ b/capabilities/reasoning/src/adapter/status-failures.test.ts @@ -38,12 +38,12 @@ describe('a provider that rejects the request', () => { [422, 'the request was rejected as invalid'], [418, 'the request was not accepted'], ])( - 'fails as spec_invalid on HTTP %i, saying what the status means and quoting the provider', + 'fails as definition_invalid on HTTP %i, saying what the status means and quoting the provider', async (status, reading) => { const failure = await failureFor(() => jsonResponse(openAiError('Unsupported parameter: temperature'), status)); expect(failure).toMatchObject({ - _tag: 'spec_invalid', + _tag: 'definition_invalid', provider: 'openai', status, provider_message: 'Unsupported parameter: temperature', @@ -57,7 +57,7 @@ describe('the provider message of a rejected request', () => { it('never quotes a raw response body', async () => { const failure = await failureFor(() => new Response('upstream said no', { status: 400 })); - expect(failure).toMatchObject({ _tag: 'spec_invalid', provider_message: null }); + expect(failure).toMatchObject({ _tag: 'definition_invalid', provider_message: null }); }); it('never quotes a body that the SDK passes on as the message', async () => { @@ -70,7 +70,11 @@ describe('the provider message of a rejected request', () => { const failure = await failed(access, textRequest('bedrock-anthropic/us.anthropic.claude-sonnet-4-5-20250929-v1:0')); - expect(failure).toMatchObject({ _tag: 'spec_invalid', provider: 'bedrock-anthropic', provider_message: null }); + expect(failure).toMatchObject({ + _tag: 'definition_invalid', + provider: 'bedrock-anthropic', + provider_message: null, + }); expect(exposedText(failure)).not.toContain(promptText); }); }); diff --git a/primitives/inference/src/adapter/stopped-call.ts b/capabilities/reasoning/src/adapter/stopped-call.ts similarity index 100% rename from primitives/inference/src/adapter/stopped-call.ts rename to capabilities/reasoning/src/adapter/stopped-call.ts diff --git a/primitives/inference/src/adapter/unclassified-model-error.ts b/capabilities/reasoning/src/adapter/unclassified-model-error.ts similarity index 100% rename from primitives/inference/src/adapter/unclassified-model-error.ts rename to capabilities/reasoning/src/adapter/unclassified-model-error.ts diff --git a/primitives/inference/src/adapter/wildcard-aliases.test.ts b/capabilities/reasoning/src/adapter/wildcard-aliases.test.ts similarity index 95% rename from primitives/inference/src/adapter/wildcard-aliases.test.ts rename to capabilities/reasoning/src/adapter/wildcard-aliases.test.ts index e30b8312a..83f5fecae 100644 --- a/primitives/inference/src/adapter/wildcard-aliases.test.ts +++ b/capabilities/reasoning/src/adapter/wildcard-aliases.test.ts @@ -29,7 +29,7 @@ async function sentModel(aliases: Readonly>, requested: s } describe('a wildcard alias', () => { - it('sends every model of a provider through a gateway, keeping what the spec asked for', async () => { + it('sends every model of a provider through a gateway, keeping what the definition asked for', async () => { const { access, recording } = await gatewayWith({ 'anthropic/*': 'gateway/anthropic/*' }); const result = await succeeded(access, textRequest('anthropic/claude-sonnet-4-5')); @@ -74,7 +74,7 @@ describe('a server with a wildcard alias', () => { expect(access.offered).toEqual({ providers: ['gateway'], aliases: ['anthropic/*'] }); }); - it('names the alias, with the configured providers, when a spec names another provider', async () => { + it('names the alias, with the configured providers, when a definition names another provider', async () => { const { access, recording } = await gatewayWith({ 'anthropic/*': 'gateway/anthropic/*', 'house/fast': 'gateway/x', @@ -93,6 +93,6 @@ describe('a server with a wildcard alias', () => { it('rejects a reference with nothing in place of the *', async () => { const { access } = await gatewayWith({ 'anthropic/*': 'gateway/anthropic/*' }); - expect(await failed(access, textRequest('anthropic/'))).toMatchObject({ _tag: 'spec_invalid' }); + expect(await failed(access, textRequest('anthropic/'))).toMatchObject({ _tag: 'definition_invalid' }); }); }); diff --git a/primitives/inference/src/primitive/spec-request.ts b/capabilities/reasoning/src/capability/definition-request.ts similarity index 68% rename from primitives/inference/src/primitive/spec-request.ts rename to capabilities/reasoning/src/capability/definition-request.ts index 3e7e848cf..e48b01c62 100644 --- a/primitives/inference/src/primitive/spec-request.ts +++ b/capabilities/reasoning/src/capability/definition-request.ts @@ -1,10 +1,10 @@ +import type { RunContext } from '@beonauto/definitions'; import type { RunTools } from '@beonauto/mcp'; import { runBoundMs } from '@beonauto/mcp/policy'; -import type { RunContext } from '@beonauto/specs'; +import { mostOutputTokens } from '../definition/definition-settings.ts'; +import type { ReasoningFunctionDefinitionDocument } from '../definition/reasoning-function-definition.ts'; import type { ModelRequest, ModelTools } from '../model/model-request.ts'; -import type { ReasoningFunctionDefinitionDocument } from '../spec/reasoning-function-definition.ts'; -import { mostOutputTokens } from '../spec/spec-settings.ts'; import type { RenderedPrompt } from '../template/compiled-template.ts'; const firstAnswerMilliseconds = 60_000; @@ -27,21 +27,21 @@ function modelToolsOf({ offered, callsEnded, ended }: RunTools, timeoutMs: numbe } export function requestFor( - spec: ReasoningFunctionDefinitionDocument, + definition: ReasoningFunctionDefinitionDocument, { instructions, message }: RenderedPrompt, - execution: RunContext, + run: RunContext, tools?: RunTools, ): ModelRequest { - const timeoutMs = timeoutFor(spec.settings.max_output_tokens); + const timeoutMs = timeoutFor(definition.settings.max_output_tokens); return { - model: spec.model, + model: definition.model, ...(instructions === undefined ? {} : { instructions }), messages: [{ role: 'user', content: [{ type: 'text', text: message }] }], - output: spec.output, - settings: spec.settings, - ...(spec.provider_options === undefined ? {} : { provider_options: spec.provider_options }), + output: definition.output, + settings: definition.settings, + ...(definition.provider_options === undefined ? {} : { provider_options: definition.provider_options }), timeout_ms: timeoutMs, - execution_id: execution.id, + run_id: run.id, ...(tools === undefined ? {} : { tools: modelToolsOf(tools, timeoutMs) }), }; } diff --git a/primitives/inference/src/primitive/spec-execution.test.ts b/capabilities/reasoning/src/capability/definition-run.test.ts similarity index 73% rename from primitives/inference/src/primitive/spec-execution.test.ts rename to capabilities/reasoning/src/capability/definition-run.test.ts index 9eb1febdc..53dff0ad5 100644 --- a/primitives/inference/src/primitive/spec-execution.test.ts +++ b/capabilities/reasoning/src/capability/definition-run.test.ts @@ -1,9 +1,9 @@ import { Exit } from 'effect'; import { describe, expect, it } from 'vitest'; +import { documentOf, reasoningExample } from '../testing/definition-documents.ts'; import { answers, jsonResult, textResult } from '../testing/index.ts'; import { reasoningWith } from '../testing/reasoning-runs.ts'; -import { documentOf, reasoningExample } from '../testing/spec-documents.ts'; const summarizing = documentOf( [ @@ -59,10 +59,10 @@ const answeredRecord = { }, }; -describe('executing a reasoning function definition', () => { +describe('running a reasoning function definition', () => { it('sends the rendered instructions and message with the settings, the provider options and a timeout', async () => { - const { executing, requests } = reasoningWith(answers(answered)); - await executing(summarizing, { text: 'the quarter' }); + const { running, requests } = reasoningWith(answers(answered)); + await running(summarizing, { text: 'the quarter' }); expect(requests()).toEqual([ { @@ -78,25 +78,25 @@ describe('executing a reasoning function definition', () => { settings: { max_output_tokens: 300, temperature: 0.1, stop_sequences: ['END'] }, provider_options: { anthropic: { thinking: { type: 'enabled', budgetTokens: 1024 } } }, timeout_ms: 67_500, - execution_id: '0199a3c4-7d2e-7c1a-9b3f-2f1e0d9c8b7a', + run_id: '0199a3c4-7d2e-7c1a-9b3f-2f1e0d9c8b7a', }, ]); }); it('answers with the text and records what happened', async () => { - const { executing } = reasoningWith(answers(answered)); + const { running } = reasoningWith(answers(answered)); - expect(await executing(summarizing, { text: 'the quarter', tone: 'warm' })).toEqual( + expect(await running(summarizing, { text: 'the quarter', tone: 'warm' })).toEqual( Exit.succeed({ output: 'A short summary.', record: answeredRecord }), ); }); }); -describe('the output of an execution', () => { - it('is the JSON value for a JSON spec, which sends its schema', async () => { - const { executing, requests } = reasoningWith(answers(jsonResult({ summary: 'Globex grew.' }))); +describe('the output of a run', () => { + it('is the JSON value for a JSON definition, which sends its schema', async () => { + const { running, requests } = reasoningWith(answers(jsonResult({ summary: 'Globex grew.' }))); - expect(await executing(reasoningExample, { account: 'Globex' })).toMatchObject( + expect(await running(reasoningExample, { account: 'Globex' })).toMatchObject( Exit.succeed({ output: { summary: 'Globex grew.' }, record: { output_format: 'json' } }), ); expect(requests()[0]).toMatchObject({ @@ -108,23 +108,23 @@ describe('the output of an execution', () => { it('is a JSON value that is not an object when the schema allows it', async () => { const source = documentOf('model: openai/gpt-5\noutput:\n format: json\n schema: {type: [string, "null"]}'); - const { executing } = reasoningWith(answers(jsonResult(null))); + const { running } = reasoningWith(answers(jsonResult(null))); - expect(await executing(source, { text: 'x' })).toMatchObject(Exit.succeed({ output: null })); + expect(await running(source, { text: 'x' })).toMatchObject(Exit.succeed({ output: null })); }); it('is a text answer cut off at the token limit, with why it stopped in the record', async () => { - const { executing } = reasoningWith(answers(textResult('A summary that stops', { finish_reason: 'length' }))); + const { running } = reasoningWith(answers(textResult('A summary that stops', { finish_reason: 'length' }))); - expect(await executing(documentOf('model: openai/gpt-5'), { text: 'x' })).toMatchObject( + expect(await running(documentOf('model: openai/gpt-5'), { text: 'x' })).toMatchObject( Exit.succeed({ output: 'A summary that stops', record: { finish_reason: 'length' } }), ); }); it('records no instructions when the template has no system block', async () => { - const { executing } = reasoningWith(answers(textResult('Hi'))); + const { running } = reasoningWith(answers(textResult('Hi'))); - expect(await executing(documentOf('model: openai/gpt-5'), { text: 'x' })).toMatchObject( + expect(await running(documentOf('model: openai/gpt-5'), { text: 'x' })).toMatchObject( Exit.succeed({ record: { prompt: { message: 'Summarize x', truncated: false } } }), ); }); diff --git a/capabilities/reasoning/src/capability/definition-run.ts b/capabilities/reasoning/src/capability/definition-run.ts new file mode 100644 index 000000000..e49861b32 --- /dev/null +++ b/capabilities/reasoning/src/capability/definition-run.ts @@ -0,0 +1,49 @@ +import type { RunContext, Finished } from '@beonauto/definitions'; +import type { ToolAccess } from '@beonauto/mcp'; +import { Clock, Effect, type Schema } from 'effect'; + +import type { ReasoningFunctionDefinitionDocument } from '../definition/reasoning-function-definition.ts'; +import type { LanguageModel } from '../model/language-model.ts'; +import type { DefinitionRejection } from './model-rejection.ts'; +import { preparedInput } from './prepared-input.ts'; +import { renderedPrompt } from './rendered-prompt.ts'; +import { answerOf } from './run-answer.ts'; +import { finishedWith } from './spending-record.ts'; + +export interface RunServices { + readonly languageModel: LanguageModel['Service']; + readonly clock?: Clock.Clock; + readonly tools?: ToolAccess; +} + +function outputOf({ text, json }: { readonly text: string; readonly json?: Schema.Json }): Schema.Json { + return json === undefined ? text : json; +} + +export function definitionRun({ + languageModel, + clock, + tools: access, +}: RunServices): ( + definition: ReasoningFunctionDefinitionDocument, + input: Schema.Json, + run: RunContext, +) => Effect.Effect { + const currentTime = clock === undefined ? Clock.currentTimeMillis : clock.currentTimeMillis; + return Effect.fnUntraced(function* ( + definition: ReasoningFunctionDefinitionDocument, + input: Schema.Json, + run: RunContext, + ) { + const fields = yield* preparedInput(input, definition.input); + const now = new Date(yield* currentTime).toISOString(); + const prompt = yield* renderedPrompt(definition.template, { input: fields, today: now.slice(0, 10), now }); + const result = yield* answerOf({ languageModel, access, definition, prompt, run }); + return yield* finishedWith(outputOf(result), { + prompt, + settings: definition.settings, + format: definition.output.type, + result, + }); + }); +} diff --git a/primitives/inference/src/primitive/gateway-options.test.ts b/capabilities/reasoning/src/capability/gateway-options.test.ts similarity index 80% rename from primitives/inference/src/primitive/gateway-options.test.ts rename to capabilities/reasoning/src/capability/gateway-options.test.ts index 4b57cbdae..df1f46ceb 100644 --- a/primitives/inference/src/primitive/gateway-options.test.ts +++ b/capabilities/reasoning/src/capability/gateway-options.test.ts @@ -3,10 +3,10 @@ import { Effect, Exit } from 'effect'; import { describe, expect, it } from 'vitest'; import { accessFor } from '../testing/adapter-harness.ts'; +import { documentOf } from '../testing/definition-documents.ts'; import { chatCompletion } from '../testing/provider-replies.ts'; -import { execution } from '../testing/reasoning-runs.ts'; +import { runContext } from '../testing/reasoning-runs.ts'; import { jsonResponse, recordingFetch } from '../testing/recording-fetch.ts'; -import { documentOf } from '../testing/spec-documents.ts'; import { makeReasoningFunctionAdapter } from './reasoning-function.ts'; const gateway = { name: 'internal', base_url: 'https://llm.internal.example/v1', allowed_provider_options: ['user'] }; @@ -14,14 +14,14 @@ const gateway = { name: 'internal', base_url: 'https://llm.internal.example/v1', async function executedWith(providerOptions: string) { const recording = recordingFetch(() => jsonResponse(chatCompletion('Hello'))); const access = await accessFor({ MODEL_GATEWAYS: JSON.stringify([gateway]) }, { fetch: recording.fetch }); - const primitive = makeReasoningFunctionAdapter({ languageModel: access.languageModel, offered: access.offered }); + const capability = makeReasoningFunctionAdapter({ languageModel: access.languageModel, offered: access.offered }); const document = documentOf(`model: internal/llama-3.3-70b\nprovider_options:\n internal: ${providerOptions}`, 'Hi'); - const prepared = Effect.runSync(primitive.prepare(document)); - const exit = await Effect.runPromiseExit(prepared.execute({}, execution)); + const prepared = Effect.runSync(capability.prepare(document)); + const exit = await Effect.runPromiseExit(prepared.run({}, runContext)); return { exit, requests: recording.requests() }; } -describe('an execution with provider options for a gateway', () => { +describe('a run with provider options for a gateway', () => { it('sends the fields its operator allows', async () => { const { exit, requests } = await executedWith('{user: tenant-7}'); diff --git a/primitives/inference/src/primitive/model-rejection.test.ts b/capabilities/reasoning/src/capability/model-rejection.test.ts similarity index 92% rename from primitives/inference/src/primitive/model-rejection.test.ts rename to capabilities/reasoning/src/capability/model-rejection.test.ts index 922ca85c8..7c7d263cd 100644 --- a/primitives/inference/src/primitive/model-rejection.test.ts +++ b/capabilities/reasoning/src/capability/model-rejection.test.ts @@ -11,24 +11,24 @@ import { ProviderNotConfigured, ProviderUnavailable, RateLimited, - SpecInvalid, + DefinitionInvalid, TimedOut, type ModelFailure, } from '../index.ts'; -import { reasoningWith, type Execution } from '../testing/reasoning-runs.ts'; -import { documentOf } from '../testing/spec-documents.ts'; +import { documentOf } from '../testing/definition-documents.ts'; +import { reasoningWith, type Run } from '../testing/reasoning-runs.ts'; -const jsonSpec = documentOf( +const jsonDefinition = documentOf( 'model: openai/gpt-5\nconfig: {max_output_tokens: 200}\noutput:\n format: json\n schema: {type: object}', ); -function failingWith(failure: () => ModelFailure): Promise { - return reasoningWith(() => Effect.fail(failure())).executing(jsonSpec, { text: 'x' }); +function failingWith(failure: () => ModelFailure): Promise { + return reasoningWith(() => Effect.fail(failure())).running(jsonDefinition, { text: 'x' }); } -describe('a provider that rejects the spec', () => { +describe('a provider that rejects the definition', () => { it('is a conflict that carries the bounded message of the provider', async () => { - const failure = new SpecInvalid({ + const failure = new DefinitionInvalid({ detail: 'openai answered HTTP 404: the model was not found', provider: 'openai', status: 404, @@ -48,7 +48,7 @@ describe('a provider that rejects the spec', () => { }); it('lists what the request checks found, without a message the provider did not give', async () => { - const failure = new SpecInvalid({ + const failure = new DefinitionInvalid({ detail: 'The request is not valid', provider: null, status: null, @@ -194,7 +194,7 @@ describe('a provider that cannot serve now', () => { }); describe('a model of a provider the server does not have, while it has others', () => { - it('is unavailable of the kind model_not_offered, because its provider is not set up, so that the spec can be switched', async () => { + it('is unavailable of the kind model_not_offered, because its provider is not set up, so that the definition can be switched', async () => { const failure = new ProviderNotConfigured({ detail: 'openai is not configured. Configured providers: anthropic, gateway', provider: 'openai', @@ -255,7 +255,7 @@ describe('an input the model refuses', () => { }); describe('a cancelled call', () => { - it('interrupts the execution', async () => { + it('interrupts the run', async () => { const exit = await failingWith( () => new Cancelled({ detail: 'The call to openai was cancelled', provider: 'openai' }), ); diff --git a/primitives/inference/src/primitive/model-rejection.ts b/capabilities/reasoning/src/capability/model-rejection.ts similarity index 92% rename from primitives/inference/src/primitive/model-rejection.ts rename to capabilities/reasoning/src/capability/model-rejection.ts index a288c6c79..0fa4f68ca 100644 --- a/primitives/inference/src/primitive/model-rejection.ts +++ b/capabilities/reasoning/src/capability/model-rejection.ts @@ -5,15 +5,15 @@ import { Clock, Effect } from 'effect'; import type { FailureIssue } from '../failure/failure-issue.ts'; import type { FinishReason, TokenUsage } from '../model/model-result.ts'; import { stoppedEnding, unavailableAfter, type Spent, type Stopped } from '../tools/tool-endings.ts'; -import { spendingRecord } from './execution-record.ts'; +import { spendingRecord } from './spending-record.ts'; -export type SpecRejection = InvalidInput | Unavailable | Conflict; +export type DefinitionRejection = InvalidInput | Unavailable | Conflict; interface Detailed { readonly detail: string; } -interface RejectedSpec extends Detailed { +interface RejectedDefinition extends Detailed { readonly provider_message: string | null; readonly issues: readonly FailureIssue[]; } @@ -65,7 +65,7 @@ function waitFor(retryAfterMs: number | null): string { return seconds === 1 ? 'in 1 second' : `in ${seconds} seconds`; } -function specRefused({ detail, provider_message, issues }: RejectedSpec): Effect.Effect { +function definitionRefused({ detail, provider_message, issues }: RejectedDefinition): Effect.Effect { const said = provider_message === null ? '' : `. The provider said: ${provider_message}`; return Effect.fail( new Conflict({ @@ -80,7 +80,7 @@ export function rejections(maxOutputTokens: number, spending: Spending, tools?: unavailableAfter(tools, detail, advice, spent); return { cancelled: () => Effect.interrupt, - spec_invalid: specRefused, + definition_invalid: definitionRefused, output_invalid: ({ detail, provider, finish_reason, issues, usage }: InvalidAnswer) => Effect.flatMap(spending(usage), (spent): Effect.Effect => finish_reason === 'length' diff --git a/primitives/inference/src/primitive/on-this-server.test.ts b/capabilities/reasoning/src/capability/on-this-server.test.ts similarity index 100% rename from primitives/inference/src/primitive/on-this-server.test.ts rename to capabilities/reasoning/src/capability/on-this-server.test.ts diff --git a/primitives/inference/src/primitive/on-this-server.ts b/capabilities/reasoning/src/capability/on-this-server.ts similarity index 100% rename from primitives/inference/src/primitive/on-this-server.ts rename to capabilities/reasoning/src/capability/on-this-server.ts diff --git a/primitives/inference/src/primitive/prepared-input.test.ts b/capabilities/reasoning/src/capability/prepared-input.test.ts similarity index 71% rename from primitives/inference/src/primitive/prepared-input.test.ts rename to capabilities/reasoning/src/capability/prepared-input.test.ts index a2f2bf7c2..90cf8fb78 100644 --- a/primitives/inference/src/primitive/prepared-input.test.ts +++ b/capabilities/reasoning/src/capability/prepared-input.test.ts @@ -2,9 +2,9 @@ import { InvalidInput } from '@beonauto/operations'; import { Exit } from 'effect'; import { describe, expect, it } from 'vitest'; +import { documentOf } from '../testing/definition-documents.ts'; import { answers, textResult } from '../testing/index.ts'; import { reasoningWith } from '../testing/reasoning-runs.ts'; -import { documentOf } from '../testing/spec-documents.ts'; const withSchema = documentOf( [ @@ -20,11 +20,11 @@ const withSchema = documentOf( 'Summarize {{ input.text }} in {{ input.words }} words.', ); -describe('the input of an execution', () => { +describe('the input of a run', () => { it('is a JSON object', async () => { - const { executing, requests } = reasoningWith(); + const { running, requests } = reasoningWith(); - expect(await executing(withSchema, ['text'])).toEqual( + expect(await running(withSchema, ['text'])).toEqual( Exit.fail( new InvalidInput({ detail: 'The input of a reasoning function definition is a JSON object', @@ -35,9 +35,9 @@ describe('the input of an execution', () => { expect(requests()).toEqual([]); }); - it('takes the defaults of the spec for the fields it leaves out', async () => { - const { executing, requests } = reasoningWith(answers(textResult('Done'))); - await executing(withSchema, { text: 'the report' }); + it('takes the defaults of the definition for the fields it leaves out', async () => { + const { running, requests } = reasoningWith(answers(textResult('Done'))); + await running(withSchema, { text: 'the report' }); expect(requests()[0]?.messages).toEqual([ { role: 'user', content: [{ type: 'text', text: 'Summarize the report in 50 words.' }] }, @@ -45,16 +45,16 @@ describe('the input of an execution', () => { }); it('overrides the defaults with its own fields', async () => { - const { executing, requests } = reasoningWith(answers(textResult('Done'))); - await executing(withSchema, { text: 'the report', words: 10 }); + const { running, requests } = reasoningWith(answers(textResult('Done'))); + await running(withSchema, { text: 'the report', words: 10 }); expect(requests()[0]?.messages).toMatchObject([{ content: [{ text: 'Summarize the report in 10 words.' }] }]); }); it('must match the input schema, with pointers to its fields', async () => { - const { executing, requests } = reasoningWith(); + const { running, requests } = reasoningWith(); - expect(await executing(withSchema, { words: 0, colour: 'red' })).toEqual( + expect(await running(withSchema, { words: 0, colour: 'red' })).toEqual( Exit.fail( new InvalidInput({ detail: 'The input does not match the reasoning function’s input schema', diff --git a/primitives/inference/src/primitive/prepared-input.ts b/capabilities/reasoning/src/capability/prepared-input.ts similarity index 92% rename from primitives/inference/src/primitive/prepared-input.ts rename to capabilities/reasoning/src/capability/prepared-input.ts index b00aec304..972ca482c 100644 --- a/primitives/inference/src/primitive/prepared-input.ts +++ b/capabilities/reasoning/src/capability/prepared-input.ts @@ -1,7 +1,7 @@ import { InvalidInput } from '@beonauto/operations'; import { Effect, Predicate, Result, type Schema } from 'effect'; -import type { InputContract } from '../spec/reasoning-function-definition.ts'; +import type { InputContract } from '../definition/reasoning-function-definition.ts'; function isJsonObject(value: Schema.Json): value is Schema.JsonObject { return Predicate.isObject(value) && !Array.isArray(value); diff --git a/primitives/inference/src/primitive/provider-messages.test.ts b/capabilities/reasoning/src/capability/provider-messages.test.ts similarity index 80% rename from primitives/inference/src/primitive/provider-messages.test.ts rename to capabilities/reasoning/src/capability/provider-messages.test.ts index 1d794bab0..13c4b5eb7 100644 --- a/primitives/inference/src/primitive/provider-messages.test.ts +++ b/capabilities/reasoning/src/capability/provider-messages.test.ts @@ -3,11 +3,11 @@ import { Effect, Exit } from 'effect'; import { describe, expect, it } from 'vitest'; import { accessFor } from '../testing/adapter-harness.ts'; +import { documentOf } from '../testing/definition-documents.ts'; import { exposedText } from '../testing/exposure.ts'; import { gatewayError, gatewayErrorText, gatewayInternals, recordingReporter } from '../testing/provider-errors.ts'; -import { execution } from '../testing/reasoning-runs.ts'; +import { runContext } from '../testing/reasoning-runs.ts'; import { jsonResponse, recordingFetch } from '../testing/recording-fetch.ts'; -import { documentOf } from '../testing/spec-documents.ts'; import { makeReasoningFunctionAdapter } from './reasoning-function.ts'; async function executedThroughGateway(gateway: object) { @@ -16,15 +16,15 @@ async function executedThroughGateway(gateway: object) { { MODEL_GATEWAYS: JSON.stringify([gateway]) }, { fetch: recordingFetch(() => jsonResponse(gatewayError, 404)).fetch, reportProviderMessage: reporter.report }, ); - const primitive = makeReasoningFunctionAdapter({ languageModel: access.languageModel, offered: access.offered }); - const prepared = Effect.runSync(primitive.prepare(documentOf('model: gateway/no-such-model-xyz', 'Say hello.'))); - const exit = await Effect.runPromiseExit(prepared.execute({}, execution)); + const capability = makeReasoningFunctionAdapter({ languageModel: access.languageModel, offered: access.offered }); + const prepared = Effect.runSync(capability.prepare(documentOf('model: gateway/no-such-model-xyz', 'Say hello.'))); + const exit = await Effect.runPromiseExit(prepared.run({}, runContext)); return { exit, reports: reporter.reports() }; } const gateway = { name: 'gateway', base_url: 'https://llm.internal.example/v1' }; -describe('an execution whose gateway rejects the spec', () => { +describe('a run whose gateway rejects the definition', () => { it('is a conflict built only from the provider, the status and what the status means', async () => { const { exit, reports } = await executedThroughGateway(gateway); @@ -43,7 +43,7 @@ describe('an execution whose gateway rejects the spec', () => { model: 'gateway/no-such-model-xyz', status: 404, message: gatewayErrorText, - execution_id: execution.id, + run_id: runContext.id, }, ]); }); diff --git a/primitives/inference/src/primitive/reasoning-function.test.ts b/capabilities/reasoning/src/capability/reasoning-function.test.ts similarity index 77% rename from primitives/inference/src/primitive/reasoning-function.test.ts rename to capabilities/reasoning/src/capability/reasoning-function.test.ts index f758228b7..b147e3716 100644 --- a/primitives/inference/src/primitive/reasoning-function.test.ts +++ b/capabilities/reasoning/src/capability/reasoning-function.test.ts @@ -3,28 +3,28 @@ import { Effect, Exit } from 'effect'; import { TestClock } from 'effect/testing'; import { describe, expect, it } from 'vitest'; +import { documentOf, reasoningExample } from '../testing/definition-documents.ts'; import { answers, scriptedLanguageModel, textResult } from '../testing/index.ts'; -import { execution, reasoningWith } from '../testing/reasoning-runs.ts'; -import { documentOf, reasoningExample } from '../testing/spec-documents.ts'; +import { runContext, reasoningWith } from '../testing/reasoning-runs.ts'; import { onThisServer } from './on-this-server.ts'; import { makeReasoningFunctionAdapter } from './reasoning-function.ts'; -const { primitive, prepared } = reasoningWith(); +const { capability, prepared } = reasoningWith(); describe('the reasoning function implementation', () => { - it('is the inference primitive, titled Reasoning, whose definitions are Markdown', () => { - expect(primitive).toMatchObject({ name: 'inference', title: 'Reasoning', mediaType: 'text/markdown' }); + it('is the reasoning capability, titled Reasoning, whose definitions are Markdown', () => { + expect(capability).toMatchObject({ type: 'reasoning', title: 'Reasoning', mediaType: 'text/markdown' }); }); it('calls a saved definition a reasoning function', () => { - expect(primitive.noun).toEqual({ one: 'reasoning function', other: 'reasoning functions' }); + expect(capability.noun).toEqual({ one: 'reasoning function', other: 'reasoning functions' }); }); it('repeats a short answer, renders a small structured one, and points to the details for a long one', () => { expect([ - primitive.describeOutput('Profits rose.'), - primitive.describeOutput({ approve: true }), - primitive.describeOutput('a'.repeat(400)), + capability.describeOutput('Profits rose.'), + capability.describeOutput({ approve: true }), + capability.describeOutput('a'.repeat(400)), ]).toEqual([ 'Its answer: “Profits rose.”', 'Its answer: approve: yes.', @@ -32,12 +32,12 @@ describe('the reasoning function implementation', () => { ]); }); - it('runs an execution for at most the deadline of a call for the most output tokens: 60 s and 25 ms a token for 64000', () => { - expect(primitive.longestExecutionMs).toBe(1_660_000); + it('runs a run for at most the deadline of a call for the most output tokens: 60 s and 25 ms a token for 64000', () => { + expect(capability.longestAnyRunMs).toBe(1_660_000); }); it('reaches outside the server, since it calls a model provider', () => { - expect(primitive.reachesOutside).toBe(true); + expect(capability.reachesOutside).toBe(true); }); it('says a reasoning function calls tools only when it names them, so a started run of it is never run again', () => { @@ -60,7 +60,7 @@ describe('the longest run of a reasoning function', () => { describe('the guide to the reasoning function document', () => { it('is reasoning-function, ending with what this server offers', () => { - expect(primitive.guide).toEqual({ + expect(capability.guide).toEqual({ name: 'reasoning-function', onThisServer: onThisServer({ providers: ['anthropic'], aliases: [] }, false), }); @@ -82,7 +82,7 @@ describe('preparing a reasoning function definition', () => { }); }); - it('gives no schemas for a text spec without an input schema', () => { + it('gives no schemas for a text definition without an input schema', () => { expect(prepared(documentOf('model: openai/gpt-5')).summary).toEqual({ warnings: [] }); }); @@ -97,7 +97,7 @@ describe('preparing a reasoning function definition', () => { describe('preparing a reasoning function definition that is not valid', () => { it('rejects it with its problems, each with its line', () => { - expect(Effect.runSyncExit(primitive.prepare(documentOf('model: gpt-5')))).toEqual( + expect(Effect.runSyncExit(capability.prepare(documentOf('model: gpt-5')))).toEqual( Exit.fail( new InvalidInput({ detail: 'The reasoning function definition has a problem', @@ -107,7 +107,7 @@ describe('preparing a reasoning function definition that is not valid', () => { }), ), ); - expect(Effect.runSyncExit(primitive.prepare(documentOf('model: gpt-5\nseed: 1')))).toEqual( + expect(Effect.runSyncExit(capability.prepare(documentOf('model: gpt-5\nseed: 1')))).toEqual( Exit.fail( new InvalidInput({ detail: 'The reasoning function definition has 2 problems', @@ -125,8 +125,8 @@ describe('preparing a reasoning function definition that is not valid', () => { }); }); -describe('the moment an execution renders', () => { - it('comes from the clock the primitive is given', async () => { +describe('the moment a run renders', () => { + it('comes from the clock the capability is given', async () => { const clock = await Effect.runPromise( TestClock.setTime(Date.parse('2030-01-02T03:04:05.000Z')).pipe( Effect.andThen(Effect.clockWith((current) => Effect.succeed(current))), @@ -139,10 +139,10 @@ describe('the moment an execution renders', () => { clock, offered: { providers: ['anthropic'], aliases: [] }, }); - const spec = Effect.runSync( + const definition = Effect.runSync( timed.prepare(documentOf('model: openai/gpt-5', 'Today is {{ today }}, now {{ now }}')), ); - await Effect.runPromise(spec.execute({}, execution)); + await Effect.runPromise(definition.run({}, runContext)); expect(scripted.requests()[0]?.messages).toEqual([ { role: 'user', content: [{ type: 'text', text: 'Today is 2030-01-02, now 2030-01-02T03:04:05.000Z' }] }, diff --git a/primitives/inference/src/primitive/reasoning-function.ts b/capabilities/reasoning/src/capability/reasoning-function.ts similarity index 64% rename from primitives/inference/src/primitive/reasoning-function.ts rename to capabilities/reasoning/src/capability/reasoning-function.ts index 27a162c42..284a606f2 100644 --- a/primitives/inference/src/primitive/reasoning-function.ts +++ b/capabilities/reasoning/src/capability/reasoning-function.ts @@ -1,29 +1,29 @@ -import { asSentence, InvalidInput } from '@beonauto/operations'; import { - definePrimitive, + defineCapability, functionCategoryLabels, functionResourceLabels, inWords, type DefinitionSummary, - type Primitive, -} from '@beonauto/specs'; -import { issueText } from '@beonauto/specs/document'; + type Capability, +} from '@beonauto/definitions'; +import { issueText } from '@beonauto/definitions/document'; +import { asSentence, InvalidInput } from '@beonauto/operations'; import { Effect, Result, type Schema } from 'effect'; +import { parseDefinitionDocument } from '../definition/definition-parsing.ts'; +import type { ReasoningFunctionDefinitionDocument } from '../definition/reasoning-function-definition.ts'; import type { OfferedModels } from '../model/offered-models.ts'; -import type { ReasoningFunctionDefinitionDocument } from '../spec/reasoning-function-definition.ts'; -import { parseSpecDocument } from '../spec/spec-parsing.ts'; +import { longestRequestMs, longestRunMsOf } from './definition-request.ts'; +import { definitionRun, type RunServices } from './definition-run.ts'; import { onThisServer } from './on-this-server.ts'; -import { specExecution, type ExecutionServices } from './spec-execution.ts'; -import { longestRequestMs, longestRunMsOf } from './spec-request.ts'; -export interface ReasoningFunctionAdapterOptions extends ExecutionServices { +export interface ReasoningFunctionAdapterOptions extends RunServices { readonly offered: OfferedModels; } function parse(source: string): Effect.Effect { - return Result.match(parseSpecDocument(source), { - onSuccess: (spec) => Effect.succeed(spec), + return Result.match(parseDefinitionDocument(source), { + onSuccess: (definition) => Effect.succeed(definition), onFailure: (issues) => Effect.fail( new InvalidInput({ @@ -50,22 +50,22 @@ function describeAnswer(output: Schema.Json): string { : asSentence(`Its answer: ${words}`); } -export function makeReasoningFunctionAdapter(options: ReasoningFunctionAdapterOptions): Primitive { - const execute = specExecution(options); - return definePrimitive({ - name: 'inference', - title: functionCategoryLabels.reason, +export function makeReasoningFunctionAdapter(options: ReasoningFunctionAdapterOptions): Capability { + const run = definitionRun(options); + return defineCapability({ + type: 'reasoning', + title: functionCategoryLabels.reasoning, guide: { name: 'reasoning-function', onThisServer: onThisServer(options.offered, options.tools?.configured === true), }, - noun: { one: functionResourceLabels.reason.singular, other: functionResourceLabels.reason.plural }, + noun: { one: functionResourceLabels.reasoning.singular, other: functionResourceLabels.reasoning.plural }, describeOutput: describeAnswer, mediaType: 'text/markdown', parse, summarize, - execute: (spec, input, execution) => execute(spec, input, execution), - longestExecutionMs: longestRequestMs, + run: (definition, input, context) => run(definition, input, context), + longestAnyRunMs: longestRequestMs, longestRunOf: longestRunMsOf, reachesOutside: true, mayChangeOutside: options.tools?.configured === true, diff --git a/primitives/inference/src/primitive/reference-page.test.ts b/capabilities/reasoning/src/capability/reference-page.test.ts similarity index 92% rename from primitives/inference/src/primitive/reference-page.test.ts rename to capabilities/reasoning/src/capability/reference-page.test.ts index 03c9a761a..f6f664a74 100644 --- a/primitives/inference/src/primitive/reference-page.test.ts +++ b/capabilities/reasoning/src/capability/reference-page.test.ts @@ -1,10 +1,10 @@ import { readFileSync } from 'node:fs'; +import { templateLimits } from '@beonauto/definitions/template'; import { toolBounds } from '@beonauto/mcp'; -import { templateLimits } from '@beonauto/specs/template'; import { describe, expect, it } from 'vitest'; -import { mostOutputTokens } from '../spec/spec-settings.ts'; +import { mostOutputTokens } from '../definition/definition-settings.ts'; const page = readFileSync(new URL('../../../../docs/reference/reasoning-format.md', import.meta.url), 'utf8'); diff --git a/primitives/inference/src/primitive/rejected-spending.test.ts b/capabilities/reasoning/src/capability/rejected-spending.test.ts similarity index 89% rename from primitives/inference/src/primitive/rejected-spending.test.ts rename to capabilities/reasoning/src/capability/rejected-spending.test.ts index d818b3db9..bcfa9c9d2 100644 --- a/primitives/inference/src/primitive/rejected-spending.test.ts +++ b/capabilities/reasoning/src/capability/rejected-spending.test.ts @@ -4,11 +4,11 @@ import { TestClock } from 'effect/testing'; import { describe, expect, it } from 'vitest'; import { ContentRefused, OutputInvalid, ToolsStopped, type ModelFailure } from '../index.ts'; +import { documentOf } from '../testing/definition-documents.ts'; import { reasoningWith } from '../testing/reasoning-runs.ts'; import type { ScriptedReply } from '../testing/scripted-language-model.ts'; -import { documentOf } from '../testing/spec-documents.ts'; -const jsonSpec = documentOf( +const jsonDefinition = documentOf( 'model: openai/gpt-5\nconfig: {max_output_tokens: 200}\noutput:\n format: json\n schema: {type: object}', ); @@ -50,13 +50,13 @@ describe('a run rejected after its model answered', () => { it('records the tokens the answer used and how long the model took, whatever the rejection', async () => { const record = { usage, duration_ms: 1500 }; - const executions = await Promise.all( + const runs = await Promise.all( [invalidAnswer('stop'), invalidAnswer('length'), refused, stopped].map((failureOf) => - reasoningWith(answeredAfter(1500, failureOf)).executing(jsonSpec, { text: 'x' }), + reasoningWith(answeredAfter(1500, failureOf)).running(jsonDefinition, { text: 'x' }), ), ); - expect(executions).toMatchObject([ + expect(runs).toMatchObject([ Exit.fail(new Unavailable({ detail: 'The answer is not JSON; try again', record })), Exit.fail({ _tag: 'conflict', record }), Exit.fail({ _tag: 'invalid_input', record }), diff --git a/primitives/inference/src/primitive/rendered-prompt.test.ts b/capabilities/reasoning/src/capability/rendered-prompt.test.ts similarity index 70% rename from primitives/inference/src/primitive/rendered-prompt.test.ts rename to capabilities/reasoning/src/capability/rendered-prompt.test.ts index 10a86f23b..ad630fc5f 100644 --- a/primitives/inference/src/primitive/rendered-prompt.test.ts +++ b/capabilities/reasoning/src/capability/rendered-prompt.test.ts @@ -2,8 +2,8 @@ import { InvalidInput } from '@beonauto/operations'; import { Exit } from 'effect'; import { describe, expect, it } from 'vitest'; +import { documentOf } from '../testing/definition-documents.ts'; import { reasoningWith } from '../testing/reasoning-runs.ts'; -import { documentOf } from '../testing/spec-documents.ts'; const model = 'model: openai/gpt-5'; @@ -13,9 +13,9 @@ function rejected(detail: string, issue: string, pointer = '') { describe('a prompt that cannot be rendered from the input', () => { it('names the field the template reads and the input does not have', async () => { - const { executing, requests } = reasoningWith(); + const { running, requests } = reasoningWith(); - expect(await executing(documentOf(model, 'Dear {{ input.customer["first/name"] }},'), { customer: {} })).toEqual( + expect(await running(documentOf(model, 'Dear {{ input.customer["first/name"] }},'), { customer: {} })).toEqual( rejected( 'The template reads a field the input does not have', 'Line 4: the template reads input.customer.first/name, which this input does not have', @@ -26,10 +26,10 @@ describe('a prompt that cannot be rendered from the input', () => { }); it('points at the input as a whole when the missing name is not a field of it', async () => { - const { executing } = reasoningWith(); + const { running } = reasoningWith(); expect( - await executing(documentOf(model, '{% for item in input.items %}{{ item.name }}{% endfor %}'), { items: [{}] }), + await running(documentOf(model, '{% for item in input.items %}{{ item.name }}{% endfor %}'), { items: [{}] }), ).toEqual( rejected( 'The template reads a field the input does not have', @@ -41,9 +41,9 @@ describe('a prompt that cannot be rendered from the input', () => { describe('a prompt the input makes unusable', () => { it('is rejected when a filter rejects a value of the input', async () => { - const { executing } = reasoningWith(); + const { running } = reasoningWith(); - expect(await executing(documentOf(model, 'Revenue: {{ input.revenue | money }}'), { revenue: 'lots' })).toEqual( + expect(await running(documentOf(model, 'Revenue: {{ input.revenue | money }}'), { revenue: 'lots' })).toEqual( rejected( 'The template cannot be rendered with this input', 'Line 4: money takes a number, or text that is a decimal number', @@ -52,10 +52,10 @@ describe('a prompt the input makes unusable', () => { }); it('is rejected when it would be longer than a prompt may be', async () => { - const { executing } = reasoningWith(); + const { running } = reasoningWith(); expect( - await executing(documentOf(model, '{% for i in (1..3) %}{{ input.text }}{% endfor %}'), { + await running(documentOf(model, '{% for i in (1..3) %}{{ input.text }}{% endfor %}'), { text: 'x'.repeat(70_000), }), ).toEqual( @@ -67,10 +67,10 @@ describe('a prompt the input makes unusable', () => { }); it('is rejected when the render takes more than it may', async () => { - const { executing } = reasoningWith(); + const { running } = reasoningWith(); expect( - await executing(documentOf(model, 'Count {% for i in (1..input.count) %}{% endfor %}'), { count: 6_000_000 }), + await running(documentOf(model, 'Count {% for i in (1..input.count) %}{% endfor %}'), { count: 6_000_000 }), ).toEqual( rejected( 'Rendering the template with this input takes more memory than a render may', @@ -82,9 +82,9 @@ describe('a prompt the input makes unusable', () => { describe('a prompt without a message', () => { it('is rejected when the message it renders is empty', async () => { - const { executing } = reasoningWith(); + const { running } = reasoningWith(); - expect(await executing(documentOf(model, '{{ input.text }}'), { text: ' ' })).toEqual( + expect(await running(documentOf(model, '{{ input.text }}'), { text: ' ' })).toEqual( rejected( 'With this input the template renders an empty message', 'The message the template renders from this input is empty', diff --git a/primitives/inference/src/primitive/rendered-prompt.ts b/capabilities/reasoning/src/capability/rendered-prompt.ts similarity index 100% rename from primitives/inference/src/primitive/rendered-prompt.ts rename to capabilities/reasoning/src/capability/rendered-prompt.ts diff --git a/primitives/inference/src/primitive/spec-answer.ts b/capabilities/reasoning/src/capability/run-answer.ts similarity index 57% rename from primitives/inference/src/primitive/spec-answer.ts rename to capabilities/reasoning/src/capability/run-answer.ts index 5fe31f54d..c5d42fc61 100644 --- a/primitives/inference/src/primitive/spec-answer.ts +++ b/capabilities/reasoning/src/capability/run-answer.ts @@ -1,42 +1,42 @@ +import type { RunContext } from '@beonauto/definitions'; import type { ToolAccess } from '@beonauto/mcp'; -import type { RunContext } from '@beonauto/specs'; import { Clock, Effect } from 'effect'; +import type { ReasoningFunctionDefinitionDocument } from '../definition/reasoning-function-definition.ts'; import type { LanguageModel } from '../model/language-model.ts'; import type { ModelResult } from '../model/model-result.ts'; -import type { ReasoningFunctionDefinitionDocument } from '../spec/reasoning-function-definition.ts'; import type { RenderedPrompt } from '../template/compiled-template.ts'; import { withTools } from '../tools/tool-opening.ts'; -import { rejections, spendingSince, type SpecRejection } from './model-rejection.ts'; -import { requestFor } from './spec-request.ts'; +import { requestFor } from './definition-request.ts'; +import { rejections, spendingSince, type DefinitionRejection } from './model-rejection.ts'; export interface Answering { readonly languageModel: LanguageModel['Service']; readonly access: ToolAccess | undefined; - readonly spec: ReasoningFunctionDefinitionDocument; + readonly definition: ReasoningFunctionDefinitionDocument; readonly prompt: RenderedPrompt; - readonly execution: RunContext; + readonly run: RunContext; } export function answerOf({ languageModel, access, - spec, + definition, prompt, - execution, -}: Answering): Effect.Effect { - const { max_output_tokens: maxOutputTokens } = spec.settings; + run, +}: Answering): Effect.Effect { + const { max_output_tokens: maxOutputTokens } = definition.settings; const admitted = Effect.flatMap(Clock.currentTimeMillis, (started) => languageModel - .admit(requestFor(spec, prompt, execution)) + .admit(requestFor(definition, prompt, run)) .pipe(Effect.catchTags(rejections(maxOutputTokens, spendingSince(started)))), ); return Effect.andThen( admitted, - withTools(access, spec.tools, execution, (tools) => + withTools(access, definition.tools, run, (tools) => Effect.flatMap(Clock.currentTimeMillis, (started) => languageModel - .generate(requestFor(spec, prompt, execution, tools)) + .generate(requestFor(definition, prompt, run, tools)) .pipe(Effect.catchTags(rejections(maxOutputTokens, spendingSince(started), tools))), ), ), diff --git a/primitives/inference/src/primitive/execution-record.test.ts b/capabilities/reasoning/src/capability/spending-record.test.ts similarity index 51% rename from primitives/inference/src/primitive/execution-record.test.ts rename to capabilities/reasoning/src/capability/spending-record.test.ts index 87ac73b8f..805ffe2a4 100644 --- a/primitives/inference/src/primitive/execution-record.test.ts +++ b/capabilities/reasoning/src/capability/spending-record.test.ts @@ -1,13 +1,13 @@ import { Buffer } from 'node:buffer'; +import type { CapabilityAnswer } from '@beonauto/definitions'; import { Conflict } from '@beonauto/operations'; -import type { Executed } from '@beonauto/specs'; import { Exit } from 'effect'; import { describe, expect, it } from 'vitest'; +import { documentOf } from '../testing/definition-documents.ts'; import { answers, textResult } from '../testing/index.ts'; import { reasoningWith } from '../testing/reasoning-runs.ts'; -import { documentOf } from '../testing/spec-documents.ts'; const oneMebibyte = 1_048_576; @@ -19,7 +19,7 @@ const withInstructions = documentOf( const withoutInstructions = documentOf('model: openai/gpt-5', '{{ input.text }}'); const measuring = { - onSuccess: (executed: Executed) => Buffer.byteLength(JSON.stringify(executed), 'utf8'), + onSuccess: (ran: CapabilityAnswer) => Buffer.byteLength(JSON.stringify(ran), 'utf8'), onFailure: () => Number.POSITIVE_INFINITY, }; @@ -29,12 +29,12 @@ const usage = { total: 64_020, }; -describe('the record of an execution', () => { +describe('the record of a run', () => { it('keeps the whole prompt when it fits with the answer', async () => { - const { executing } = reasoningWith(answers(textResult('Short'))); + const { running } = reasoningWith(answers(textResult('Short'))); const text = 'é'.repeat(150_000); - expect(await executing(withInstructions, { rules: 'Be brief.', text })).toMatchObject( + expect(await running(withInstructions, { rules: 'Be brief.', text })).toMatchObject( Exit.succeed({ output: 'Short', record: { prompt: { instructions: 'Be brief.', message: text, truncated: false } }, @@ -43,29 +43,27 @@ describe('the record of an execution', () => { }); it('cuts the prompt so that the answer and the record take at most 1 MiB', async () => { - const { executing } = reasoningWith(answers(textResult('a'.repeat(700_000)))); - const execution = await executing(withInstructions, { rules: 'r'.repeat(199_000), text: 't'.repeat(199_000) }); + const { running } = reasoningWith(answers(textResult('a'.repeat(700_000)))); + const run = await running(withInstructions, { rules: 'r'.repeat(199_000), text: 't'.repeat(199_000) }); - expect(execution).toMatchObject( - Exit.succeed({ output: 'a'.repeat(700_000), record: { prompt: { truncated: true } } }), - ); - expect(Exit.match(execution, measuring)).toBeLessThanOrEqual(oneMebibyte); - expect(Exit.match(execution, measuring)).toBeGreaterThan(oneMebibyte - 4096); + expect(run).toMatchObject(Exit.succeed({ output: 'a'.repeat(700_000), record: { prompt: { truncated: true } } })); + expect(Exit.match(run, measuring)).toBeLessThanOrEqual(oneMebibyte); + expect(Exit.match(run, measuring)).toBeGreaterThan(oneMebibyte - 4096); }); it('gives the message all the room when there are no instructions', async () => { - const { executing } = reasoningWith(answers(textResult('a'.repeat(900_000)))); - const execution = await executing(withoutInstructions, { text: 't'.repeat(199_000) }); + const { running } = reasoningWith(answers(textResult('a'.repeat(900_000)))); + const run = await running(withoutInstructions, { text: 't'.repeat(199_000) }); - expect(execution).toMatchObject(Exit.succeed({ record: { prompt: { truncated: true } } })); - expect(Exit.match(execution, measuring)).toBeLessThanOrEqual(oneMebibyte); - expect(Exit.match(execution, measuring)).toBeGreaterThan(oneMebibyte - 4096); + expect(run).toMatchObject(Exit.succeed({ record: { prompt: { truncated: true } } })); + expect(Exit.match(run, measuring)).toBeLessThanOrEqual(oneMebibyte); + expect(Exit.match(run, measuring)).toBeGreaterThan(oneMebibyte - 4096); }); - it('cannot hold an answer that leaves no room for the prompt: the spec must ask for less', async () => { - const { executing } = reasoningWith(answers(textResult('a'.repeat(oneMebibyte), { usage, duration_ms: 1500 }))); + it('cannot hold an answer that leaves no room for the prompt: the definition must ask for less', async () => { + const { running } = reasoningWith(answers(textResult('a'.repeat(oneMebibyte), { usage, duration_ms: 1500 }))); - expect(await executing(withoutInstructions, { text: 'Go' })).toEqual( + expect(await running(withoutInstructions, { text: 'Go' })).toEqual( Exit.fail( new Conflict({ detail: diff --git a/primitives/inference/src/primitive/execution-record.ts b/capabilities/reasoning/src/capability/spending-record.ts similarity index 98% rename from primitives/inference/src/primitive/execution-record.ts rename to capabilities/reasoning/src/capability/spending-record.ts index 7aa4e8c37..759e282b3 100644 --- a/primitives/inference/src/primitive/execution-record.ts +++ b/capabilities/reasoning/src/capability/spending-record.ts @@ -1,7 +1,7 @@ import { Buffer } from 'node:buffer'; +import { mostResultBytes, type Finished } from '@beonauto/definitions'; import { Conflict } from '@beonauto/operations'; -import { mostResultBytes, type Finished } from '@beonauto/specs'; import { Effect, type Schema } from 'effect'; import type { GenerationSettings, OutputRequest } from '../model/model-request.ts'; diff --git a/primitives/inference/src/catalog/catalog-leaks.test.ts b/capabilities/reasoning/src/catalog/catalog-leaks.test.ts similarity index 100% rename from primitives/inference/src/catalog/catalog-leaks.test.ts rename to capabilities/reasoning/src/catalog/catalog-leaks.test.ts diff --git a/primitives/inference/src/catalog/catalog-listing.test.ts b/capabilities/reasoning/src/catalog/catalog-listing.test.ts similarity index 98% rename from primitives/inference/src/catalog/catalog-listing.test.ts rename to capabilities/reasoning/src/catalog/catalog-listing.test.ts index 9bfc61bd9..2e7f18e1c 100644 --- a/primitives/inference/src/catalog/catalog-listing.test.ts +++ b/capabilities/reasoning/src/catalog/catalog-listing.test.ts @@ -149,8 +149,8 @@ describe('the models of providers that do not list their own', () => { }); }); -describe('an alias whose target a spec cannot call', () => { - it('is left out when the provider of its target is not configured, as the description of inference leaves it out', async () => { +describe('an alias whose target a definition cannot call', () => { + it('is left out when the provider of its target is not configured, as the description of reasoning leaves it out', async () => { const aliases = { 'house/fast': 'gateway/llama-3.3-70b', 'team/large': 'mistral/large', diff --git a/primitives/inference/src/catalog/catalog-listing.ts b/capabilities/reasoning/src/catalog/catalog-listing.ts similarity index 100% rename from primitives/inference/src/catalog/catalog-listing.ts rename to capabilities/reasoning/src/catalog/catalog-listing.ts diff --git a/primitives/inference/src/catalog/catalog-words.test.ts b/capabilities/reasoning/src/catalog/catalog-words.test.ts similarity index 100% rename from primitives/inference/src/catalog/catalog-words.test.ts rename to capabilities/reasoning/src/catalog/catalog-words.test.ts diff --git a/primitives/inference/src/catalog/catalog-words.ts b/capabilities/reasoning/src/catalog/catalog-words.ts similarity index 100% rename from primitives/inference/src/catalog/catalog-words.ts rename to capabilities/reasoning/src/catalog/catalog-words.ts diff --git a/primitives/inference/src/catalog/list-models.test.ts b/capabilities/reasoning/src/catalog/list-models.test.ts similarity index 100% rename from primitives/inference/src/catalog/list-models.test.ts rename to capabilities/reasoning/src/catalog/list-models.test.ts diff --git a/primitives/inference/src/catalog/list-models.ts b/capabilities/reasoning/src/catalog/list-models.ts similarity index 100% rename from primitives/inference/src/catalog/list-models.ts rename to capabilities/reasoning/src/catalog/list-models.ts diff --git a/primitives/inference/src/catalog/listing-cache.test.ts b/capabilities/reasoning/src/catalog/listing-cache.test.ts similarity index 100% rename from primitives/inference/src/catalog/listing-cache.test.ts rename to capabilities/reasoning/src/catalog/listing-cache.test.ts diff --git a/primitives/inference/src/catalog/listing-cache.ts b/capabilities/reasoning/src/catalog/listing-cache.ts similarity index 100% rename from primitives/inference/src/catalog/listing-cache.ts rename to capabilities/reasoning/src/catalog/listing-cache.ts diff --git a/primitives/inference/src/catalog/listing-refresh.ts b/capabilities/reasoning/src/catalog/listing-refresh.ts similarity index 95% rename from primitives/inference/src/catalog/listing-refresh.ts rename to capabilities/reasoning/src/catalog/listing-refresh.ts index cf8501059..6c86e66fe 100644 --- a/primitives/inference/src/catalog/listing-refresh.ts +++ b/capabilities/reasoning/src/catalog/listing-refresh.ts @@ -23,13 +23,13 @@ function reported( model: null, status: problem.status, message: scrub(problem.message).slice(0, mostReportedCharacters), - execution_id: null, + run_id: null, }) : reportHint({ provider, model: null, hint: `The list of models of ${provider} could not be read: ${problem.reason}`, - execution_id: null, + run_id: null, }); } diff --git a/primitives/inference/src/catalog/model-catalog.ts b/capabilities/reasoning/src/catalog/model-catalog.ts similarity index 100% rename from primitives/inference/src/catalog/model-catalog.ts rename to capabilities/reasoning/src/catalog/model-catalog.ts diff --git a/primitives/inference/src/catalog/model-entries.ts b/capabilities/reasoning/src/catalog/model-entries.ts similarity index 100% rename from primitives/inference/src/catalog/model-entries.ts rename to capabilities/reasoning/src/catalog/model-entries.ts diff --git a/primitives/inference/src/catalog/model-list.ts b/capabilities/reasoning/src/catalog/model-list.ts similarity index 100% rename from primitives/inference/src/catalog/model-list.ts rename to capabilities/reasoning/src/catalog/model-list.ts diff --git a/primitives/inference/src/spec/spec-parsing.test.ts b/capabilities/reasoning/src/definition/definition-parsing.test.ts similarity index 91% rename from primitives/inference/src/spec/spec-parsing.test.ts rename to capabilities/reasoning/src/definition/definition-parsing.test.ts index 1573afa18..2f72b4e73 100644 --- a/primitives/inference/src/spec/spec-parsing.test.ts +++ b/capabilities/reasoning/src/definition/definition-parsing.test.ts @@ -1,7 +1,7 @@ import { Result } from 'effect'; import { describe, expect, it } from 'vitest'; -import { documentOf, issuesIn, parsed } from '../testing/spec-documents.ts'; +import { documentOf, issuesIn, parsed } from '../testing/definition-documents.ts'; const complete = documentOf( [ @@ -41,9 +41,9 @@ const complete = documentOf( describe('a complete reasoning function definition', () => { it('parses into the model, the settings, the input, the output, the provider options and the template', () => { - const spec = parsed(complete); + const definition = parsed(complete); - expect(spec).toMatchObject({ + expect(definition).toMatchObject({ description: 'Summarizes an account for the sales team', model: 'anthropic/claude-sonnet-4-5', settings: { @@ -60,7 +60,7 @@ describe('a complete reasoning function definition', () => { template: { hasInstructions: true, hasMessage: true }, warnings: [], }); - expect(spec.input.schema?.document).toEqual({ + expect(definition.input.schema?.document).toEqual({ type: 'object', properties: { account: { type: 'string' }, tone: { type: 'string' } }, required: ['account'], @@ -96,11 +96,11 @@ describe('the smallest reasoning function definition', () => { }); it('leaves out what the front matter does not say', () => { - const spec = parsed('---\nmodel: openai/gpt-5\n---\nSay hello.'); + const definition = parsed('---\nmodel: openai/gpt-5\n---\nSay hello.'); - expect(spec).not.toHaveProperty('description'); - expect(spec).not.toHaveProperty('provider_options'); - expect(spec.input).not.toHaveProperty('schema'); + expect(definition).not.toHaveProperty('description'); + expect(definition).not.toHaveProperty('provider_options'); + expect(definition.input).not.toHaveProperty('schema'); }); }); diff --git a/primitives/inference/src/spec/spec-parsing.ts b/capabilities/reasoning/src/definition/definition-parsing.ts similarity index 73% rename from primitives/inference/src/spec/spec-parsing.ts rename to capabilities/reasoning/src/definition/definition-parsing.ts index 9838b887b..6762521df 100644 --- a/primitives/inference/src/spec/spec-parsing.ts +++ b/capabilities/reasoning/src/definition/definition-parsing.ts @@ -4,16 +4,16 @@ import { type DocumentIssue, type DocumentParts, type ReadFrontMatter, -} from '@beonauto/specs/document'; -import { inputVariableIssues } from '@beonauto/specs/template'; +} from '@beonauto/definitions/document'; +import { inputVariableIssues } from '@beonauto/definitions/template'; import { Option, Result, type Schema } from 'effect'; import type { CompiledTemplate } from '../template/compiled-template.ts'; +import { inputContractOf, outputContractOf } from './definition-schemas.ts'; +import { modelOf, providerOptionsOf, settingsOf, toolsOf } from './definition-settings.ts'; +import { templateOf } from './definition-template.ts'; import { decodeSection, frontMatterIn } from './front-matter-schema.ts'; import type { ReasoningFunctionDefinitionDocument } from './reasoning-function-definition.ts'; -import { inputContractOf, outputContractOf } from './spec-schemas.ts'; -import { modelOf, providerOptionsOf, settingsOf, toolsOf } from './spec-settings.ts'; -import { templateOf } from './spec-template.ts'; type Checked = Result.Result; @@ -38,7 +38,7 @@ function variablesOf( : []; } -function specFrom({ root, lines, issues }: ReadFrontMatter, template: () => Checked) { +function definitionFrom({ root, lines, issues }: ReadFrontMatter, template: () => Checked) { const model = checkedSection( () => decodeSection.model(root['model']), (reference) => modelOf(reference, lines), @@ -82,31 +82,31 @@ function specFrom({ root, lines, issues }: ReadFrontMatter, template: () => Chec : Result.all({ model, settings, input, output, template: template(), description, providerOptions, tools }); } -function specOf(parts: DocumentParts): Checked { +function definitionOf(parts: DocumentParts): Checked { const template = templateOf(parts); const reading = frontMatterIn(parts.frontMatter, parts.frontMatterLine); if (Result.isFailure(reading)) { return Result.fail([...reading.failure, ...issuesOf(() => template)]); } return Result.map( - specFrom(reading.success, () => template), - (spec) => ({ - ...(spec.description === undefined ? {} : { description: spec.description }), - model: spec.model, - settings: spec.settings, - input: spec.input, - output: spec.output.output, - ...(spec.providerOptions === undefined ? {} : { provider_options: spec.providerOptions }), - tools: spec.tools, - template: spec.template, - warnings: spec.output.warnings, + definitionFrom(reading.success, () => template), + (definition) => ({ + ...(definition.description === undefined ? {} : { description: definition.description }), + model: definition.model, + settings: definition.settings, + input: definition.input, + output: definition.output.output, + ...(definition.providerOptions === undefined ? {} : { provider_options: definition.providerOptions }), + tools: definition.tools, + template: definition.template, + warnings: definition.output.warnings, }), ); } -export function parseSpecDocument(source: string): Checked { +export function parseDefinitionDocument(source: string): Checked { return Result.mapError( - Result.flatMap(splitDocument(source, 'A reasoning function definition'), specOf), + Result.flatMap(splitDocument(source, 'A reasoning function definition'), definitionOf), reportedIssues, ); } diff --git a/primitives/inference/src/spec/spec-schemas.test.ts b/capabilities/reasoning/src/definition/definition-schemas.test.ts similarity index 95% rename from primitives/inference/src/spec/spec-schemas.test.ts rename to capabilities/reasoning/src/definition/definition-schemas.test.ts index a6986a925..23daad300 100644 --- a/primitives/inference/src/spec/spec-schemas.test.ts +++ b/capabilities/reasoning/src/definition/definition-schemas.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest'; -import { documentOf, issuesIn, parsed } from '../testing/spec-documents.ts'; +import { documentOf, issuesIn, parsed } from '../testing/definition-documents.ts'; const inputSchema = [ 'input:', @@ -117,9 +117,9 @@ describe('the output', () => { }); }); -describe('the warnings of a spec', () => { +describe('the warnings of a definition', () => { it('say where a JSON output schema is not portable across providers, or not checked here', () => { - const spec = parsed( + const definition = parsed( documentOf( [ 'model: openai/gpt-5', @@ -136,7 +136,7 @@ describe('the warnings of a spec', () => { ), ); - expect(spec.warnings).toEqual([ + expect(definition.warnings).toEqual([ 'Line 8, /output/schema/properties/total/minimum: minimum is not enforced while Anthropic models write the answer; an answer outside it fails as output_invalid (anthropic, bedrock, bedrock-anthropic, vertex-anthropic)', 'Line 9, /output/schema/properties/day/format: format is sent to the provider but not checked here', ]); @@ -145,7 +145,7 @@ describe('the warnings of a spec', () => { it('are at most 100', () => { const properties = Array.from({ length: 120 }, (_, index) => ` p${index}: {type: integer, minimum: 0}`); const names = Array.from({ length: 120 }, (_, index) => `p${index}`).join(', '); - const spec = parsed( + const definition = parsed( documentOf( [ 'model: openai/gpt-5', @@ -161,6 +161,6 @@ describe('the warnings of a spec', () => { ), ); - expect(spec.warnings).toHaveLength(100); + expect(definition.warnings).toHaveLength(100); }); }); diff --git a/primitives/inference/src/spec/spec-schemas.ts b/capabilities/reasoning/src/definition/definition-schemas.ts similarity index 96% rename from primitives/inference/src/spec/spec-schemas.ts rename to capabilities/reasoning/src/definition/definition-schemas.ts index 774b368b1..4f571b67c 100644 --- a/primitives/inference/src/spec/spec-schemas.ts +++ b/capabilities/reasoning/src/definition/definition-schemas.ts @@ -1,4 +1,10 @@ -import { issueAt, issueText, type DocumentIssue, type SchemaIssue, type SourceLines } from '@beonauto/specs/document'; +import { + issueAt, + issueText, + type DocumentIssue, + type SchemaIssue, + type SourceLines, +} from '@beonauto/definitions/document'; import { Result, type Schema } from 'effect'; import type { OutputRequest } from '../model/model-request.ts'; diff --git a/primitives/inference/src/spec/spec-settings.test.ts b/capabilities/reasoning/src/definition/definition-settings.test.ts similarity index 95% rename from primitives/inference/src/spec/spec-settings.test.ts rename to capabilities/reasoning/src/definition/definition-settings.test.ts index 815e4cd72..e6e0e4349 100644 --- a/primitives/inference/src/spec/spec-settings.test.ts +++ b/capabilities/reasoning/src/definition/definition-settings.test.ts @@ -1,8 +1,8 @@ import { describe, expect, it } from 'vitest'; -import { documentOf, issuesIn, parsed } from '../testing/spec-documents.ts'; +import { documentOf, issuesIn, parsed } from '../testing/definition-documents.ts'; -describe('the model of a spec', () => { +describe('the model of a definition', () => { it('is written provider/model', () => { expect(parsed(documentOf('model: bedrock/arn:aws:bedrock:eu-central-1:1:inference-profile/x')).model).toBe( 'bedrock/arn:aws:bedrock:eu-central-1:1:inference-profile/x', @@ -14,7 +14,7 @@ describe('the model of a spec', () => { }); }); -describe('the settings of a spec', () => { +describe('the settings of a definition', () => { it('default to 1024 output tokens', () => { expect(parsed(documentOf('model: openai/gpt-5\nconfig: {temperature: 0}')).settings).toEqual({ max_output_tokens: 1024, @@ -70,7 +70,7 @@ const withheldOptions = [ const namespaces = 'provider_options takes anthropic, openai, azure, google, vertex, googleVertex, amazonBedrock, bedrock, or the name of a gateway'; -describe('the provider options of a spec', () => { +describe('the provider options of a definition', () => { it('take the options that only shape how the model reasons or writes its answer, and a gateway name', () => { const options = parsed(documentOf(`model: anthropic/claude-sonnet-4-5\n${everyOffered}`)).provider_options; diff --git a/primitives/inference/src/spec/spec-settings.ts b/capabilities/reasoning/src/definition/definition-settings.ts similarity index 99% rename from primitives/inference/src/spec/spec-settings.ts rename to capabilities/reasoning/src/definition/definition-settings.ts index e273b50c1..16a4c8f00 100644 --- a/primitives/inference/src/spec/spec-settings.ts +++ b/capabilities/reasoning/src/definition/definition-settings.ts @@ -1,5 +1,5 @@ +import { issueAt, type DocumentIssue, type SourceLines } from '@beonauto/definitions/document'; import { toolReferenceOf, toolReferenceShape, type ToolReference } from '@beonauto/mcp/policy'; -import { issueAt, type DocumentIssue, type SourceLines } from '@beonauto/specs/document'; import { JsonPointer, Option, Result, type Schema } from 'effect'; import { parseModelReference } from '../model/model-reference.ts'; diff --git a/primitives/inference/src/spec/spec-template.ts b/capabilities/reasoning/src/definition/definition-template.ts similarity index 90% rename from primitives/inference/src/spec/spec-template.ts rename to capabilities/reasoning/src/definition/definition-template.ts index 644a4c3a3..803ca5dd0 100644 --- a/primitives/inference/src/spec/spec-template.ts +++ b/capabilities/reasoning/src/definition/definition-template.ts @@ -1,4 +1,4 @@ -import type { DocumentIssue, DocumentParts } from '@beonauto/specs/document'; +import type { DocumentIssue, DocumentParts } from '@beonauto/definitions/document'; import { Result } from 'effect'; import type { CompiledTemplate } from '../template/compiled-template.ts'; diff --git a/primitives/inference/src/spec/front-matter-reading.test.ts b/capabilities/reasoning/src/definition/front-matter-reading.test.ts similarity index 98% rename from primitives/inference/src/spec/front-matter-reading.test.ts rename to capabilities/reasoning/src/definition/front-matter-reading.test.ts index 5d716bbd2..96416e109 100644 --- a/primitives/inference/src/spec/front-matter-reading.test.ts +++ b/capabilities/reasoning/src/definition/front-matter-reading.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest'; -import { documentOf, issuesIn, parsed } from '../testing/spec-documents.ts'; +import { documentOf, issuesIn, parsed } from '../testing/definition-documents.ts'; describe('the split of a document', () => { it('needs front matter that opens on the first line', () => { diff --git a/primitives/inference/src/spec/front-matter-schema.test.ts b/capabilities/reasoning/src/definition/front-matter-schema.test.ts similarity index 97% rename from primitives/inference/src/spec/front-matter-schema.test.ts rename to capabilities/reasoning/src/definition/front-matter-schema.test.ts index 4311087de..cec95d2ac 100644 --- a/primitives/inference/src/spec/front-matter-schema.test.ts +++ b/capabilities/reasoning/src/definition/front-matter-schema.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest'; -import { documentOf, issuesIn, parsed } from '../testing/spec-documents.ts'; +import { documentOf, issuesIn, parsed } from '../testing/definition-documents.ts'; describe('the keys of the front matter', () => { it('are the known ones, at every level', () => { diff --git a/primitives/inference/src/spec/front-matter-schema.ts b/capabilities/reasoning/src/definition/front-matter-schema.ts similarity index 98% rename from primitives/inference/src/spec/front-matter-schema.ts rename to capabilities/reasoning/src/definition/front-matter-schema.ts index 591396458..b4e2c9f64 100644 --- a/primitives/inference/src/spec/front-matter-schema.ts +++ b/capabilities/reasoning/src/definition/front-matter-schema.ts @@ -3,7 +3,7 @@ import { type DocumentIssue, type FrontMatterSection, type ReadFrontMatter, -} from '@beonauto/specs/document'; +} from '@beonauto/definitions/document'; import { Schema, type Result } from 'effect'; import type { ReasoningEffort } from '../model/model-request.ts'; diff --git a/primitives/inference/src/spec/reasoning-function-definition.ts b/capabilities/reasoning/src/definition/reasoning-function-definition.ts similarity index 100% rename from primitives/inference/src/spec/reasoning-function-definition.ts rename to capabilities/reasoning/src/definition/reasoning-function-definition.ts diff --git a/primitives/inference/src/spec/template-checks.test.ts b/capabilities/reasoning/src/definition/template-checks.test.ts similarity index 96% rename from primitives/inference/src/spec/template-checks.test.ts rename to capabilities/reasoning/src/definition/template-checks.test.ts index a96612d65..645875eb6 100644 --- a/primitives/inference/src/spec/template-checks.test.ts +++ b/capabilities/reasoning/src/definition/template-checks.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest'; -import { documentOf, issuesIn, parsed } from '../testing/spec-documents.ts'; +import { documentOf, issuesIn, parsed } from '../testing/definition-documents.ts'; const closedInput = [ 'model: openai/gpt-5', @@ -73,7 +73,7 @@ describe('the fields of the input a template reads', () => { }); }); -describe('the template of a spec', () => { +describe('the template of a definition', () => { it('reports its own issues with the lines of the document', () => { expect(issuesIn(documentOf('model: openai/gpt-5', 'One\n{% if input.x %}Two'))).toEqual([ 'Line 5: tag {% if input.x %} not closed', diff --git a/primitives/inference/src/failure/cancelled.ts b/capabilities/reasoning/src/failure/cancelled.ts similarity index 100% rename from primitives/inference/src/failure/cancelled.ts rename to capabilities/reasoning/src/failure/cancelled.ts diff --git a/primitives/inference/src/failure/content-refused.ts b/capabilities/reasoning/src/failure/content-refused.ts similarity index 100% rename from primitives/inference/src/failure/content-refused.ts rename to capabilities/reasoning/src/failure/content-refused.ts diff --git a/primitives/inference/src/failure/credentials-rejected.ts b/capabilities/reasoning/src/failure/credentials-rejected.ts similarity index 100% rename from primitives/inference/src/failure/credentials-rejected.ts rename to capabilities/reasoning/src/failure/credentials-rejected.ts diff --git a/primitives/inference/src/failure/spec-invalid.ts b/capabilities/reasoning/src/failure/definition-invalid.ts similarity index 77% rename from primitives/inference/src/failure/spec-invalid.ts rename to capabilities/reasoning/src/failure/definition-invalid.ts index 9dcd06872..8e7fd4f63 100644 --- a/primitives/inference/src/failure/spec-invalid.ts +++ b/capabilities/reasoning/src/failure/definition-invalid.ts @@ -2,7 +2,7 @@ import { Data } from 'effect'; import type { FailureIssue } from './failure-issue.ts'; -export class SpecInvalid extends Data.TaggedError('spec_invalid')<{ +export class DefinitionInvalid extends Data.TaggedError('definition_invalid')<{ readonly detail: string; readonly provider: string | null; readonly status: number | null; diff --git a/primitives/inference/src/failure/failure-issue.ts b/capabilities/reasoning/src/failure/failure-issue.ts similarity index 100% rename from primitives/inference/src/failure/failure-issue.ts rename to capabilities/reasoning/src/failure/failure-issue.ts diff --git a/primitives/inference/src/failure/model-failure.ts b/capabilities/reasoning/src/failure/model-failure.ts similarity index 100% rename from primitives/inference/src/failure/model-failure.ts rename to capabilities/reasoning/src/failure/model-failure.ts diff --git a/primitives/inference/src/failure/model-not-allowed.ts b/capabilities/reasoning/src/failure/model-not-allowed.ts similarity index 100% rename from primitives/inference/src/failure/model-not-allowed.ts rename to capabilities/reasoning/src/failure/model-not-allowed.ts diff --git a/primitives/inference/src/failure/output-invalid.ts b/capabilities/reasoning/src/failure/output-invalid.ts similarity index 100% rename from primitives/inference/src/failure/output-invalid.ts rename to capabilities/reasoning/src/failure/output-invalid.ts diff --git a/primitives/inference/src/failure/provider-not-configured.ts b/capabilities/reasoning/src/failure/provider-not-configured.ts similarity index 100% rename from primitives/inference/src/failure/provider-not-configured.ts rename to capabilities/reasoning/src/failure/provider-not-configured.ts diff --git a/primitives/inference/src/failure/provider-unavailable.ts b/capabilities/reasoning/src/failure/provider-unavailable.ts similarity index 100% rename from primitives/inference/src/failure/provider-unavailable.ts rename to capabilities/reasoning/src/failure/provider-unavailable.ts diff --git a/primitives/inference/src/failure/rate-limited.ts b/capabilities/reasoning/src/failure/rate-limited.ts similarity index 100% rename from primitives/inference/src/failure/rate-limited.ts rename to capabilities/reasoning/src/failure/rate-limited.ts diff --git a/primitives/inference/src/failure/request-failure.ts b/capabilities/reasoning/src/failure/request-failure.ts similarity index 54% rename from primitives/inference/src/failure/request-failure.ts rename to capabilities/reasoning/src/failure/request-failure.ts index bc4375617..f707bed11 100644 --- a/primitives/inference/src/failure/request-failure.ts +++ b/capabilities/reasoning/src/failure/request-failure.ts @@ -1,6 +1,6 @@ import type { CredentialsRejected } from './credentials-rejected.ts'; +import type { DefinitionInvalid } from './definition-invalid.ts'; import type { ModelNotAllowed } from './model-not-allowed.ts'; import type { ProviderNotConfigured } from './provider-not-configured.ts'; -import type { SpecInvalid } from './spec-invalid.ts'; -export type RequestFailure = SpecInvalid | ProviderNotConfigured | ModelNotAllowed | CredentialsRejected; +export type RequestFailure = DefinitionInvalid | ProviderNotConfigured | ModelNotAllowed | CredentialsRejected; diff --git a/primitives/inference/src/failure/timed-out.ts b/capabilities/reasoning/src/failure/timed-out.ts similarity index 100% rename from primitives/inference/src/failure/timed-out.ts rename to capabilities/reasoning/src/failure/timed-out.ts diff --git a/primitives/inference/src/failure/tools-stopped.ts b/capabilities/reasoning/src/failure/tools-stopped.ts similarity index 100% rename from primitives/inference/src/failure/tools-stopped.ts rename to capabilities/reasoning/src/failure/tools-stopped.ts diff --git a/primitives/inference/src/index.ts b/capabilities/reasoning/src/index.ts similarity index 90% rename from primitives/inference/src/index.ts rename to capabilities/reasoning/src/index.ts index 2e575545d..4c8619f26 100644 --- a/primitives/inference/src/index.ts +++ b/capabilities/reasoning/src/index.ts @@ -21,12 +21,12 @@ export { OutputInvalid } from './failure/output-invalid.ts'; export { ProviderNotConfigured } from './failure/provider-not-configured.ts'; export { ProviderUnavailable } from './failure/provider-unavailable.ts'; export { RateLimited } from './failure/rate-limited.ts'; -export { SpecInvalid } from './failure/spec-invalid.ts'; +export { DefinitionInvalid } from './failure/definition-invalid.ts'; export { TimedOut } from './failure/timed-out.ts'; export { ToolsStopped, type ToolsStoppedBecause } from './failure/tools-stopped.ts'; export { LanguageModel } from './model/language-model.ts'; -export { makeReasoningFunctionAdapter, type ReasoningFunctionAdapterOptions } from './primitive/reasoning-function.ts'; -export type { ReasoningFunctionDefinitionDocument } from './spec/reasoning-function-definition.ts'; +export { makeReasoningFunctionAdapter, type ReasoningFunctionAdapterOptions } from './capability/reasoning-function.ts'; +export type { ReasoningFunctionDefinitionDocument } from './definition/reasoning-function-definition.ts'; export { parseModelReference, type ModelReference } from './model/model-reference.ts'; export type { ContentPart, @@ -63,7 +63,7 @@ export { type AnswerSchema, type SchemaReport, } from './schema/answer-schema.ts'; -export type { SchemaIssue } from '@beonauto/specs/document'; +export type { SchemaIssue } from '@beonauto/definitions/document'; export type { PortabilityIssue } from './schema/schema-portability.ts'; export { ModelAliasesSchema } from './settings/alias-settings.ts'; export { AllowedModelsSchema, DeclaredModelsSchema } from './settings/catalog-settings.ts'; diff --git a/primitives/inference/src/listing/anthropic-listing.test.ts b/capabilities/reasoning/src/listing/anthropic-listing.test.ts similarity index 100% rename from primitives/inference/src/listing/anthropic-listing.test.ts rename to capabilities/reasoning/src/listing/anthropic-listing.test.ts diff --git a/primitives/inference/src/listing/anthropic-listing.ts b/capabilities/reasoning/src/listing/anthropic-listing.ts similarity index 100% rename from primitives/inference/src/listing/anthropic-listing.ts rename to capabilities/reasoning/src/listing/anthropic-listing.ts diff --git a/primitives/inference/src/listing/declared-models.ts b/capabilities/reasoning/src/listing/declared-models.ts similarity index 100% rename from primitives/inference/src/listing/declared-models.ts rename to capabilities/reasoning/src/listing/declared-models.ts diff --git a/primitives/inference/src/listing/gateway-listing.test.ts b/capabilities/reasoning/src/listing/gateway-listing.test.ts similarity index 100% rename from primitives/inference/src/listing/gateway-listing.test.ts rename to capabilities/reasoning/src/listing/gateway-listing.test.ts diff --git a/primitives/inference/src/listing/gateway-listing.ts b/capabilities/reasoning/src/listing/gateway-listing.ts similarity index 100% rename from primitives/inference/src/listing/gateway-listing.ts rename to capabilities/reasoning/src/listing/gateway-listing.ts diff --git a/primitives/inference/src/listing/google-listing.test.ts b/capabilities/reasoning/src/listing/google-listing.test.ts similarity index 100% rename from primitives/inference/src/listing/google-listing.test.ts rename to capabilities/reasoning/src/listing/google-listing.test.ts diff --git a/primitives/inference/src/listing/google-listing.ts b/capabilities/reasoning/src/listing/google-listing.ts similarity index 100% rename from primitives/inference/src/listing/google-listing.ts rename to capabilities/reasoning/src/listing/google-listing.ts diff --git a/primitives/inference/src/listing/listed-model.ts b/capabilities/reasoning/src/listing/listed-model.ts similarity index 100% rename from primitives/inference/src/listing/listed-model.ts rename to capabilities/reasoning/src/listing/listed-model.ts diff --git a/primitives/inference/src/listing/listing-failures.test.ts b/capabilities/reasoning/src/listing/listing-failures.test.ts similarity index 98% rename from primitives/inference/src/listing/listing-failures.test.ts rename to capabilities/reasoning/src/listing/listing-failures.test.ts index f5ecde824..378d64117 100644 --- a/primitives/inference/src/listing/listing-failures.test.ts +++ b/capabilities/reasoning/src/listing/listing-failures.test.ts @@ -23,7 +23,7 @@ function hintOf(reason: string): object { provider: 'gateway', model: null, hint: `The list of models of gateway could not be read: ${reason}`, - execution_id: null, + run_id: null, }; } @@ -39,7 +39,7 @@ describe('a list of models the provider answers with an error', () => { model: null, status: 401, message: '{"error":{"message":"Invalid key [redacted] for tenant"}}', - execution_id: null, + run_id: null, }, ]); expect(catalog.hints()).toEqual([]); @@ -91,7 +91,7 @@ describe('a list of models that cannot be read', () => { provider: 'anthropic', model: null, hint: 'The list of models of anthropic could not be read: it has more than 10 pages of models', - execution_id: null, + run_id: null, }, ]); }); diff --git a/primitives/inference/src/listing/listing-request.ts b/capabilities/reasoning/src/listing/listing-request.ts similarity index 100% rename from primitives/inference/src/listing/listing-request.ts rename to capabilities/reasoning/src/listing/listing-request.ts diff --git a/primitives/inference/src/listing/model-sources.ts b/capabilities/reasoning/src/listing/model-sources.ts similarity index 100% rename from primitives/inference/src/listing/model-sources.ts rename to capabilities/reasoning/src/listing/model-sources.ts diff --git a/primitives/inference/src/listing/openai-listing.test.ts b/capabilities/reasoning/src/listing/openai-listing.test.ts similarity index 100% rename from primitives/inference/src/listing/openai-listing.test.ts rename to capabilities/reasoning/src/listing/openai-listing.test.ts diff --git a/primitives/inference/src/listing/openai-listing.ts b/capabilities/reasoning/src/listing/openai-listing.ts similarity index 100% rename from primitives/inference/src/listing/openai-listing.ts rename to capabilities/reasoning/src/listing/openai-listing.ts diff --git a/primitives/inference/src/model/language-model.ts b/capabilities/reasoning/src/model/language-model.ts similarity index 91% rename from primitives/inference/src/model/language-model.ts rename to capabilities/reasoning/src/model/language-model.ts index 12c6e37b3..41801170d 100644 --- a/primitives/inference/src/model/language-model.ts +++ b/capabilities/reasoning/src/model/language-model.ts @@ -10,4 +10,4 @@ export class LanguageModel extends Context.Service< readonly admit: (request: ModelRequest) => Effect.Effect; readonly generate: (request: ModelRequest) => Effect.Effect; } ->()('@beonauto/inference/LanguageModel') {} +>()('@beonauto/reasoning/LanguageModel') {} diff --git a/primitives/inference/src/model/model-alias.ts b/capabilities/reasoning/src/model/model-alias.ts similarity index 100% rename from primitives/inference/src/model/model-alias.ts rename to capabilities/reasoning/src/model/model-alias.ts diff --git a/primitives/inference/src/model/model-offer.test.ts b/capabilities/reasoning/src/model/model-offer.test.ts similarity index 95% rename from primitives/inference/src/model/model-offer.test.ts rename to capabilities/reasoning/src/model/model-offer.test.ts index 47737c6b0..9176977b1 100644 --- a/primitives/inference/src/model/model-offer.test.ts +++ b/capabilities/reasoning/src/model/model-offer.test.ts @@ -57,7 +57,7 @@ const aliased = { 'house/fast': 'anthropic/claude-haiku-4-5', 'team/smart': 'ant const allowedWithAliases = ['anthropic/claude-sonnet-4-5', 'house/*', 'anthropic/claude-opus-4-1']; -describe('the models a spec may name when an operator allows some', () => { +describe('the models a definition may name when an operator allows some', () => { it('are those allowed, and an alias whose name or target is allowed; the rest fail before anything is sent', async () => { const catalog = await catalogFor(offering(allowedWithAliases, aliased), answering); @@ -89,7 +89,7 @@ describe('the models a spec may name when an operator allows some', () => { ]); }); - it('agree with the list, which shows the aliases a spec may name', async () => { + it('agree with the list, which shows the aliases a definition may name', async () => { const catalog = await catalogFor(offering(allowedWithAliases, aliased), answering); expect(idsIn(await catalog.list('anthropic'))).toEqual(['house/fast', 'team/smart']); @@ -108,7 +108,7 @@ const tellingAliases = { 'team/large': 'mistral/large', }; -describe('the providers and aliases a spec is told about', () => { +describe('the providers and aliases a definition is told about', () => { it('are those it may name under the allow list, and never an alias whose provider is not configured', async () => { const restricted = await catalogFor(offering(['gateway/openai/*', 'house/fast'], tellingAliases), answering); const open = await catalogFor({ ...offering(['anthropic/*'], tellingAliases), ALLOWED_MODELS: '' }, answering); diff --git a/primitives/inference/src/model/model-offer.ts b/capabilities/reasoning/src/model/model-offer.ts similarity index 100% rename from primitives/inference/src/model/model-offer.ts rename to capabilities/reasoning/src/model/model-offer.ts diff --git a/primitives/inference/src/model/model-reference.ts b/capabilities/reasoning/src/model/model-reference.ts similarity index 100% rename from primitives/inference/src/model/model-reference.ts rename to capabilities/reasoning/src/model/model-reference.ts diff --git a/primitives/inference/src/model/model-request.ts b/capabilities/reasoning/src/model/model-request.ts similarity index 98% rename from primitives/inference/src/model/model-request.ts rename to capabilities/reasoning/src/model/model-request.ts index 86bdb4c45..f451bb482 100644 --- a/primitives/inference/src/model/model-request.ts +++ b/capabilities/reasoning/src/model/model-request.ts @@ -83,6 +83,6 @@ export interface ModelRequest { readonly timeout_ms?: number; readonly signal?: Readonly; readonly retries?: RetryOwner; - readonly execution_id?: string; + readonly run_id?: string; readonly tools?: ModelTools; } diff --git a/primitives/inference/src/model/model-result.ts b/capabilities/reasoning/src/model/model-result.ts similarity index 100% rename from primitives/inference/src/model/model-result.ts rename to capabilities/reasoning/src/model/model-result.ts diff --git a/primitives/inference/src/model/offered-models.ts b/capabilities/reasoning/src/model/offered-models.ts similarity index 100% rename from primitives/inference/src/model/offered-models.ts rename to capabilities/reasoning/src/model/offered-models.ts diff --git a/primitives/inference/src/model/offered-provider-options.test.ts b/capabilities/reasoning/src/model/offered-provider-options.test.ts similarity index 100% rename from primitives/inference/src/model/offered-provider-options.test.ts rename to capabilities/reasoning/src/model/offered-provider-options.test.ts diff --git a/primitives/inference/src/model/offered-provider-options.ts b/capabilities/reasoning/src/model/offered-provider-options.ts similarity index 100% rename from primitives/inference/src/model/offered-provider-options.ts rename to capabilities/reasoning/src/model/offered-provider-options.ts diff --git a/primitives/inference/src/model/request-checks.ts b/capabilities/reasoning/src/model/request-checks.ts similarity index 92% rename from primitives/inference/src/model/request-checks.ts rename to capabilities/reasoning/src/model/request-checks.ts index a858409ba..72f032c9c 100644 --- a/primitives/inference/src/model/request-checks.ts +++ b/capabilities/reasoning/src/model/request-checks.ts @@ -1,7 +1,7 @@ import { Effect } from 'effect'; +import { DefinitionInvalid } from '../failure/definition-invalid.ts'; import type { FailureIssue } from '../failure/failure-issue.ts'; -import { SpecInvalid } from '../failure/spec-invalid.ts'; import type { ModelRequest } from './model-request.ts'; function issue(pointer: string, detail: string): readonly FailureIssue[] { @@ -29,12 +29,12 @@ export function requestIssues(request: ModelRequest): readonly FailureIssue[] { ]; } -export function checkedRequest(request: ModelRequest): Effect.Effect { +export function checkedRequest(request: ModelRequest): Effect.Effect { const issues = requestIssues(request); return issues.length === 0 ? Effect.void : Effect.fail( - new SpecInvalid({ + new DefinitionInvalid({ detail: 'The request is not valid', provider: null, status: null, diff --git a/primitives/inference/src/schema/answer-schema.ts b/capabilities/reasoning/src/schema/answer-schema.ts similarity index 96% rename from primitives/inference/src/schema/answer-schema.ts rename to capabilities/reasoning/src/schema/answer-schema.ts index 1c63df960..99805dae5 100644 --- a/primitives/inference/src/schema/answer-schema.ts +++ b/capabilities/reasoning/src/schema/answer-schema.ts @@ -4,7 +4,7 @@ import { jsonSchemaLimits, type CompiledSchema, type SchemaIssue as Issue, -} from '@beonauto/specs/document'; +} from '@beonauto/definitions/document'; import { Result } from 'effect'; import { portabilityOf, toolInputPortabilityOf, type PortabilityIssue } from './schema-portability.ts'; diff --git a/primitives/inference/src/schema/schema-check.test.ts b/capabilities/reasoning/src/schema/schema-check.test.ts similarity index 100% rename from primitives/inference/src/schema/schema-check.test.ts rename to capabilities/reasoning/src/schema/schema-check.test.ts diff --git a/primitives/inference/src/schema/schema-nodes.ts b/capabilities/reasoning/src/schema/schema-nodes.ts similarity index 100% rename from primitives/inference/src/schema/schema-nodes.ts rename to capabilities/reasoning/src/schema/schema-nodes.ts diff --git a/primitives/inference/src/schema/schema-portability.ts b/capabilities/reasoning/src/schema/schema-portability.ts similarity index 99% rename from primitives/inference/src/schema/schema-portability.ts rename to capabilities/reasoning/src/schema/schema-portability.ts index 6643a37ae..1d93293a0 100644 --- a/primitives/inference/src/schema/schema-portability.ts +++ b/capabilities/reasoning/src/schema/schema-portability.ts @@ -1,4 +1,4 @@ -import { isKnownKeyword, pointerOf, type SchemaIssue } from '@beonauto/specs/document'; +import { isKnownKeyword, pointerOf, type SchemaIssue } from '@beonauto/definitions/document'; import type { Schema } from 'effect'; import { schemaNodes, type Path, type SchemaNode } from './schema-nodes.ts'; diff --git a/primitives/inference/src/settings/alias-settings.test.ts b/capabilities/reasoning/src/settings/alias-settings.test.ts similarity index 100% rename from primitives/inference/src/settings/alias-settings.test.ts rename to capabilities/reasoning/src/settings/alias-settings.test.ts diff --git a/primitives/inference/src/settings/alias-settings.ts b/capabilities/reasoning/src/settings/alias-settings.ts similarity index 100% rename from primitives/inference/src/settings/alias-settings.ts rename to capabilities/reasoning/src/settings/alias-settings.ts diff --git a/primitives/inference/src/settings/allowed-provider-options.ts b/capabilities/reasoning/src/settings/allowed-provider-options.ts similarity index 100% rename from primitives/inference/src/settings/allowed-provider-options.ts rename to capabilities/reasoning/src/settings/allowed-provider-options.ts diff --git a/primitives/inference/src/settings/catalog-settings.test.ts b/capabilities/reasoning/src/settings/catalog-settings.test.ts similarity index 98% rename from primitives/inference/src/settings/catalog-settings.test.ts rename to capabilities/reasoning/src/settings/catalog-settings.test.ts index d161508a1..ce87280a8 100644 --- a/primitives/inference/src/settings/catalog-settings.test.ts +++ b/capabilities/reasoning/src/settings/catalog-settings.test.ts @@ -106,7 +106,7 @@ function allowing(entries: readonly string[]): Environment { const shape = 'Expected provider/model, or provider/ followed by a * that stands for any model id'; -describe('the models a spec may name, in ALLOWED_MODELS', () => { +describe('the models a definition may name, in ALLOWED_MODELS', () => { it('are read as model references and wildcards of a provider or of an alias', async () => { const entries = ['anthropic/*', 'gateway/llama-3.3-70b', 'openai/gpt-5*', 'house/fast']; diff --git a/primitives/inference/src/settings/catalog-settings.ts b/capabilities/reasoning/src/settings/catalog-settings.ts similarity index 100% rename from primitives/inference/src/settings/catalog-settings.ts rename to capabilities/reasoning/src/settings/catalog-settings.ts diff --git a/primitives/inference/src/settings/gateway-settings.ts b/capabilities/reasoning/src/settings/gateway-settings.ts similarity index 100% rename from primitives/inference/src/settings/gateway-settings.ts rename to capabilities/reasoning/src/settings/gateway-settings.ts diff --git a/primitives/inference/src/settings/json-settings.test.ts b/capabilities/reasoning/src/settings/json-settings.test.ts similarity index 100% rename from primitives/inference/src/settings/json-settings.test.ts rename to capabilities/reasoning/src/settings/json-settings.test.ts diff --git a/primitives/inference/src/settings/message-exposure.ts b/capabilities/reasoning/src/settings/message-exposure.ts similarity index 100% rename from primitives/inference/src/settings/message-exposure.ts rename to capabilities/reasoning/src/settings/message-exposure.ts diff --git a/primitives/inference/src/settings/model-settings.test.ts b/capabilities/reasoning/src/settings/model-settings.test.ts similarity index 100% rename from primitives/inference/src/settings/model-settings.test.ts rename to capabilities/reasoning/src/settings/model-settings.test.ts diff --git a/primitives/inference/src/settings/model-settings.ts b/capabilities/reasoning/src/settings/model-settings.ts similarity index 100% rename from primitives/inference/src/settings/model-settings.ts rename to capabilities/reasoning/src/settings/model-settings.ts diff --git a/primitives/inference/src/settings/provider-settings.ts b/capabilities/reasoning/src/settings/provider-settings.ts similarity index 100% rename from primitives/inference/src/settings/provider-settings.ts rename to capabilities/reasoning/src/settings/provider-settings.ts diff --git a/primitives/inference/src/settings/provider-status.test.ts b/capabilities/reasoning/src/settings/provider-status.test.ts similarity index 100% rename from primitives/inference/src/settings/provider-status.test.ts rename to capabilities/reasoning/src/settings/provider-status.test.ts diff --git a/primitives/inference/src/settings/provider-status.ts b/capabilities/reasoning/src/settings/provider-status.ts similarity index 100% rename from primitives/inference/src/settings/provider-status.ts rename to capabilities/reasoning/src/settings/provider-status.ts diff --git a/primitives/inference/src/settings/setting-values.ts b/capabilities/reasoning/src/settings/setting-values.ts similarity index 100% rename from primitives/inference/src/settings/setting-values.ts rename to capabilities/reasoning/src/settings/setting-values.ts diff --git a/primitives/inference/src/template/compiled-template.ts b/capabilities/reasoning/src/template/compiled-template.ts similarity index 86% rename from primitives/inference/src/template/compiled-template.ts rename to capabilities/reasoning/src/template/compiled-template.ts index 33a5d94f1..3310958c7 100644 --- a/primitives/inference/src/template/compiled-template.ts +++ b/capabilities/reasoning/src/template/compiled-template.ts @@ -1,7 +1,7 @@ -import type { RenderFailure as EngineRenderFailure, VariableReference } from '@beonauto/specs/template'; +import type { RenderFailure as EngineRenderFailure, VariableReference } from '@beonauto/definitions/template'; import type { Result, Schema } from 'effect'; -export type { TemplateIssue } from '@beonauto/specs/template'; +export type { TemplateIssue } from '@beonauto/definitions/template'; export interface TemplateScope { readonly input: Schema.Json; diff --git a/primitives/inference/src/template/liquid-engine.test.ts b/capabilities/reasoning/src/template/liquid-engine.test.ts similarity index 100% rename from primitives/inference/src/template/liquid-engine.test.ts rename to capabilities/reasoning/src/template/liquid-engine.test.ts diff --git a/primitives/inference/src/template/liquid-engine.ts b/capabilities/reasoning/src/template/liquid-engine.ts similarity index 87% rename from primitives/inference/src/template/liquid-engine.ts rename to capabilities/reasoning/src/template/liquid-engine.ts index b7e49efb9..0da12266a 100644 --- a/primitives/inference/src/template/liquid-engine.ts +++ b/capabilities/reasoning/src/template/liquid-engine.ts @@ -1,4 +1,4 @@ -import { templateEngine, type TagDefinition } from '@beonauto/specs/template'; +import { templateEngine, type TagDefinition } from '@beonauto/definitions/template'; import { clip, money, words } from './prompt-filters.ts'; diff --git a/primitives/inference/src/template/prompt-filters.test.ts b/capabilities/reasoning/src/template/prompt-filters.test.ts similarity index 100% rename from primitives/inference/src/template/prompt-filters.test.ts rename to capabilities/reasoning/src/template/prompt-filters.test.ts diff --git a/primitives/inference/src/template/prompt-filters.ts b/capabilities/reasoning/src/template/prompt-filters.ts similarity index 96% rename from primitives/inference/src/template/prompt-filters.ts rename to capabilities/reasoning/src/template/prompt-filters.ts index 765904fda..7fbacfb91 100644 --- a/primitives/inference/src/template/prompt-filters.ts +++ b/capabilities/reasoning/src/template/prompt-filters.ts @@ -1,4 +1,4 @@ -import { outputText } from '@beonauto/specs/template'; +import { outputText } from '@beonauto/definitions/template'; const defaultClipLength = 400; diff --git a/primitives/inference/src/template/prompt-too-long.ts b/capabilities/reasoning/src/template/prompt-too-long.ts similarity index 100% rename from primitives/inference/src/template/prompt-too-long.ts rename to capabilities/reasoning/src/template/prompt-too-long.ts diff --git a/primitives/inference/src/template/rendered-corpus.ts b/capabilities/reasoning/src/template/rendered-corpus.ts similarity index 100% rename from primitives/inference/src/template/rendered-corpus.ts rename to capabilities/reasoning/src/template/rendered-corpus.ts diff --git a/primitives/inference/src/template/rendering-corpus.json b/capabilities/reasoning/src/template/rendering-corpus.json similarity index 100% rename from primitives/inference/src/template/rendering-corpus.json rename to capabilities/reasoning/src/template/rendering-corpus.json diff --git a/primitives/inference/src/template/rendering-corpus.test.ts b/capabilities/reasoning/src/template/rendering-corpus.test.ts similarity index 100% rename from primitives/inference/src/template/rendering-corpus.test.ts rename to capabilities/reasoning/src/template/rendering-corpus.test.ts diff --git a/primitives/inference/src/template/rendering-corpus.ts b/capabilities/reasoning/src/template/rendering-corpus.ts similarity index 100% rename from primitives/inference/src/template/rendering-corpus.ts rename to capabilities/reasoning/src/template/rendering-corpus.ts diff --git a/primitives/inference/src/template/system-block.ts b/capabilities/reasoning/src/template/system-block.ts similarity index 98% rename from primitives/inference/src/template/system-block.ts rename to capabilities/reasoning/src/template/system-block.ts index 9747dd5fd..f8975682c 100644 --- a/primitives/inference/src/template/system-block.ts +++ b/capabilities/reasoning/src/template/system-block.ts @@ -1,4 +1,4 @@ -import type { LineOfOffset } from '@beonauto/specs/template'; +import type { LineOfOffset } from '@beonauto/definitions/template'; import { toValueSync, TypeGuards, type Template } from 'liquidjs'; import type { TemplateIssue } from './compiled-template.ts'; diff --git a/primitives/inference/src/template/template-compilation.test.ts b/capabilities/reasoning/src/template/template-compilation.test.ts similarity index 100% rename from primitives/inference/src/template/template-compilation.test.ts rename to capabilities/reasoning/src/template/template-compilation.test.ts diff --git a/primitives/inference/src/template/template-compilation.ts b/capabilities/reasoning/src/template/template-compilation.ts similarity index 93% rename from primitives/inference/src/template/template-compilation.ts rename to capabilities/reasoning/src/template/template-compilation.ts index ebb987b78..33ae1a062 100644 --- a/primitives/inference/src/template/template-compilation.ts +++ b/capabilities/reasoning/src/template/template-compilation.ts @@ -1,4 +1,4 @@ -import { linesOf, parsedTemplate } from '@beonauto/specs/template'; +import { linesOf, parsedTemplate } from '@beonauto/definitions/template'; import { Result } from 'effect'; import type { CompiledTemplate, TemplateIssue } from './compiled-template.ts'; diff --git a/primitives/inference/src/template/template-rendering.test.ts b/capabilities/reasoning/src/template/template-rendering.test.ts similarity index 100% rename from primitives/inference/src/template/template-rendering.test.ts rename to capabilities/reasoning/src/template/template-rendering.test.ts diff --git a/primitives/inference/src/template/template-rendering.ts b/capabilities/reasoning/src/template/template-rendering.ts similarity index 97% rename from primitives/inference/src/template/template-rendering.ts rename to capabilities/reasoning/src/template/template-rendering.ts index 56d3968b7..44eea95ce 100644 --- a/primitives/inference/src/template/template-rendering.ts +++ b/capabilities/reasoning/src/template/template-rendering.ts @@ -1,4 +1,4 @@ -import { outputText, renderedTemplate, templateLimits, type ParsedTemplate } from '@beonauto/specs/template'; +import { outputText, renderedTemplate, templateLimits, type ParsedTemplate } from '@beonauto/definitions/template'; import { Result } from 'effect'; import { toValue, type Emitter } from 'liquidjs'; diff --git a/primitives/inference/src/testing/adapter-harness.ts b/capabilities/reasoning/src/testing/adapter-harness.ts similarity index 100% rename from primitives/inference/src/testing/adapter-harness.ts rename to capabilities/reasoning/src/testing/adapter-harness.ts diff --git a/primitives/inference/src/testing/calling-tools.ts b/capabilities/reasoning/src/testing/calling-tools.ts similarity index 100% rename from primitives/inference/src/testing/calling-tools.ts rename to capabilities/reasoning/src/testing/calling-tools.ts diff --git a/primitives/inference/src/testing/catalog-harness.ts b/capabilities/reasoning/src/testing/catalog-harness.ts similarity index 100% rename from primitives/inference/src/testing/catalog-harness.ts rename to capabilities/reasoning/src/testing/catalog-harness.ts diff --git a/primitives/inference/src/testing/spec-documents.ts b/capabilities/reasoning/src/testing/definition-documents.ts similarity index 73% rename from primitives/inference/src/testing/spec-documents.ts rename to capabilities/reasoning/src/testing/definition-documents.ts index 13eed02d7..625ec4f01 100644 --- a/primitives/inference/src/testing/spec-documents.ts +++ b/capabilities/reasoning/src/testing/definition-documents.ts @@ -1,8 +1,8 @@ -import { issueText } from '@beonauto/specs/document'; +import { issueText } from '@beonauto/definitions/document'; import { Result } from 'effect'; -import type { ReasoningFunctionDefinitionDocument } from '../spec/reasoning-function-definition.ts'; -import { parseSpecDocument } from '../spec/spec-parsing.ts'; +import { parseDefinitionDocument } from '../definition/definition-parsing.ts'; +import type { ReasoningFunctionDefinitionDocument } from '../definition/reasoning-function-definition.ts'; export const reasoningExample = [ '---', @@ -24,11 +24,11 @@ export function documentOf(frontMatter: string, body = 'Summarize {{ input.text } export function parsed(source: string): ReasoningFunctionDefinitionDocument { - return Result.getOrThrow(parseSpecDocument(source)); + return Result.getOrThrow(parseDefinitionDocument(source)); } export function issuesIn(source: string): readonly string[] { - return Result.match(parseSpecDocument(source), { + return Result.match(parseDefinitionDocument(source), { onSuccess: () => [], onFailure: (issues) => issues.map((issue) => issueText(issue)), }); diff --git a/primitives/inference/src/testing/exposure.ts b/capabilities/reasoning/src/testing/exposure.ts similarity index 100% rename from primitives/inference/src/testing/exposure.ts rename to capabilities/reasoning/src/testing/exposure.ts diff --git a/primitives/inference/src/testing/index.ts b/capabilities/reasoning/src/testing/index.ts similarity index 100% rename from primitives/inference/src/testing/index.ts rename to capabilities/reasoning/src/testing/index.ts diff --git a/primitives/inference/src/testing/mock-models.ts b/capabilities/reasoning/src/testing/mock-models.ts similarity index 100% rename from primitives/inference/src/testing/mock-models.ts rename to capabilities/reasoning/src/testing/mock-models.ts diff --git a/primitives/inference/src/testing/model-lists.ts b/capabilities/reasoning/src/testing/model-lists.ts similarity index 100% rename from primitives/inference/src/testing/model-lists.ts rename to capabilities/reasoning/src/testing/model-lists.ts diff --git a/primitives/inference/src/testing/model-results.ts b/capabilities/reasoning/src/testing/model-results.ts similarity index 100% rename from primitives/inference/src/testing/model-results.ts rename to capabilities/reasoning/src/testing/model-results.ts diff --git a/primitives/inference/src/testing/provider-errors.ts b/capabilities/reasoning/src/testing/provider-errors.ts similarity index 100% rename from primitives/inference/src/testing/provider-errors.ts rename to capabilities/reasoning/src/testing/provider-errors.ts diff --git a/primitives/inference/src/testing/provider-replies.ts b/capabilities/reasoning/src/testing/provider-replies.ts similarity index 100% rename from primitives/inference/src/testing/provider-replies.ts rename to capabilities/reasoning/src/testing/provider-replies.ts diff --git a/primitives/inference/src/testing/reasoning-runs.ts b/capabilities/reasoning/src/testing/reasoning-runs.ts similarity index 70% rename from primitives/inference/src/testing/reasoning-runs.ts rename to capabilities/reasoning/src/testing/reasoning-runs.ts index cbc6c2508..d94a25e2a 100644 --- a/primitives/inference/src/testing/reasoning-runs.ts +++ b/capabilities/reasoning/src/testing/reasoning-runs.ts @@ -1,24 +1,24 @@ +import type { CapabilityAnswer, RunContext, PreparedDefinition, Capability } from '@beonauto/definitions'; +import { noLongestRuns, recordingJournal, type RecordingJournal } from '@beonauto/definitions/testing'; import type { ToolAccess } from '@beonauto/mcp'; import { allPermissions, type Conflict, type InvalidInput, type Unavailable } from '@beonauto/operations'; -import type { Executed, RunContext, PreparedDefinition, Primitive } from '@beonauto/specs'; -import { noLongestRuns, recordingJournal, type RecordingJournal } from '@beonauto/specs/testing'; import { DateTime, Effect, type Exit, type Schema } from 'effect'; import { TestClock } from 'effect/testing'; +import { makeReasoningFunctionAdapter } from '../capability/reasoning-function.ts'; import type { ModelRequest } from '../model/model-request.ts'; -import { makeReasoningFunctionAdapter } from '../primitive/reasoning-function.ts'; import { scriptedLanguageModel, type ScriptedReply } from './scripted-language-model.ts'; const moment = '2026-10-01T09:30:00.000Z'; const anthropicOnly = { providers: ['anthropic'], aliases: [] }; -export const execution: RunContext = { +export const runContext: RunContext = { id: '0199a3c4-7d2e-7c1a-9b3f-2f1e0d9c8b7a', org: 'acme', brain: 'alpha', caller: { id: 'acme-admin', org: 'acme', permissions: allPermissions, brains: '*' }, - spec: { name: 'summary', version: 1 }, + definition: { name: 'summary', version: 1 }, journal: recordingJournal(), lineage: { startId: '5d0e9f6a-1b2c-5d3e-8f4a-6b7c8d9e0f1a', correlationId: '0199a3c4-7d2e-7c1a-9b3f-2f1e0d9c8b7a' }, depth: 0, @@ -26,13 +26,13 @@ export const execution: RunContext = { longestRunOf: noLongestRuns, }; -export type Execution = Exit.Exit; +export type Run = Exit.Exit; export interface ReasoningRun { - readonly primitive: Primitive; + readonly capability: Capability; readonly requests: () => readonly ModelRequest[]; readonly prepared: (source: string) => PreparedDefinition; - readonly executing: (source: string, input?: Schema.Json) => Promise; + readonly running: (source: string, input?: Schema.Json) => Promise; } export interface ToolRun extends ReasoningRun { @@ -45,20 +45,20 @@ function reasoningOf( context: RunContext, ): ReasoningRun { const scripted = scriptedLanguageModel(...replies); - const primitive = makeReasoningFunctionAdapter({ + const capability = makeReasoningFunctionAdapter({ languageModel: scripted.languageModel, offered: anthropicOnly, ...(tools === undefined ? {} : { tools }), }); - const prepared = (source: string): PreparedDefinition => Effect.runSync(primitive.prepare(source)); + const prepared = (source: string): PreparedDefinition => Effect.runSync(capability.prepare(source)); return { - primitive, + capability, requests: scripted.requests, prepared, - executing: (source, input = {}) => + running: (source, input = {}) => Effect.runPromiseExit( TestClock.setTime(DateTime.toEpochMillis(DateTime.makeUnsafe(moment))).pipe( - Effect.andThen(prepared(source).execute(input, context)), + Effect.andThen(prepared(source).run(input, context)), Effect.provide(TestClock.layer()), ), ), @@ -66,10 +66,10 @@ function reasoningOf( } export function reasoningWith(...replies: readonly ScriptedReply[]): ReasoningRun { - return reasoningOf(undefined, replies, execution); + return reasoningOf(undefined, replies, runContext); } export function reasoningWithTools(tools: ToolAccess, ...replies: readonly ScriptedReply[]): ToolRun { const journal = recordingJournal(); - return { ...reasoningOf(tools, replies, { ...execution, journal }), journal }; + return { ...reasoningOf(tools, replies, { ...runContext, journal }), journal }; } diff --git a/primitives/inference/src/testing/recording-fetch.test.ts b/capabilities/reasoning/src/testing/recording-fetch.test.ts similarity index 100% rename from primitives/inference/src/testing/recording-fetch.test.ts rename to capabilities/reasoning/src/testing/recording-fetch.test.ts diff --git a/primitives/inference/src/testing/recording-fetch.ts b/capabilities/reasoning/src/testing/recording-fetch.ts similarity index 100% rename from primitives/inference/src/testing/recording-fetch.ts rename to capabilities/reasoning/src/testing/recording-fetch.ts diff --git a/primitives/inference/src/testing/scripted-language-model.test.ts b/capabilities/reasoning/src/testing/scripted-language-model.test.ts similarity index 95% rename from primitives/inference/src/testing/scripted-language-model.test.ts rename to capabilities/reasoning/src/testing/scripted-language-model.test.ts index 89a58d210..3ac2986be 100644 --- a/primitives/inference/src/testing/scripted-language-model.test.ts +++ b/capabilities/reasoning/src/testing/scripted-language-model.test.ts @@ -30,7 +30,7 @@ describe('scriptedLanguageModel', () => { const failure = await Effect.runPromise(Effect.flip(script.languageModel.generate(invalid))); - expect(failure).toMatchObject({ _tag: 'spec_invalid', issues: [{ pointer: '/messages' }] }); + expect(failure).toMatchObject({ _tag: 'definition_invalid', issues: [{ pointer: '/messages' }] }); }); it('dies when the script has no reply left', async () => { diff --git a/primitives/inference/src/testing/scripted-language-model.ts b/capabilities/reasoning/src/testing/scripted-language-model.ts similarity index 100% rename from primitives/inference/src/testing/scripted-language-model.ts rename to capabilities/reasoning/src/testing/scripted-language-model.ts diff --git a/primitives/inference/src/testing/scripted-tools.ts b/capabilities/reasoning/src/testing/scripted-tools.ts similarity index 100% rename from primitives/inference/src/testing/scripted-tools.ts rename to capabilities/reasoning/src/testing/scripted-tools.ts diff --git a/primitives/inference/src/testing/tool-call-replies.ts b/capabilities/reasoning/src/testing/tool-call-replies.ts similarity index 100% rename from primitives/inference/src/testing/tool-call-replies.ts rename to capabilities/reasoning/src/testing/tool-call-replies.ts diff --git a/primitives/inference/src/tools/final-step.test.ts b/capabilities/reasoning/src/tools/final-step.test.ts similarity index 100% rename from primitives/inference/src/tools/final-step.test.ts rename to capabilities/reasoning/src/tools/final-step.test.ts diff --git a/primitives/inference/src/tools/final-step.ts b/capabilities/reasoning/src/tools/final-step.ts similarity index 100% rename from primitives/inference/src/tools/final-step.ts rename to capabilities/reasoning/src/tools/final-step.ts diff --git a/primitives/inference/src/tools/input-schema-warnings.test.ts b/capabilities/reasoning/src/tools/input-schema-warnings.test.ts similarity index 100% rename from primitives/inference/src/tools/input-schema-warnings.test.ts rename to capabilities/reasoning/src/tools/input-schema-warnings.test.ts diff --git a/primitives/inference/src/tools/input-schema-warnings.ts b/capabilities/reasoning/src/tools/input-schema-warnings.ts similarity index 100% rename from primitives/inference/src/tools/input-schema-warnings.ts rename to capabilities/reasoning/src/tools/input-schema-warnings.ts diff --git a/primitives/inference/src/tools/offered-tools.ts b/capabilities/reasoning/src/tools/offered-tools.ts similarity index 94% rename from primitives/inference/src/tools/offered-tools.ts rename to capabilities/reasoning/src/tools/offered-tools.ts index 97540d4ee..3aa7b0955 100644 --- a/primitives/inference/src/tools/offered-tools.ts +++ b/capabilities/reasoning/src/tools/offered-tools.ts @@ -12,7 +12,7 @@ const notAnObject: ToolReply = { isError: true, }; -interface Execution { +interface Run { readonly toolCallId: string; readonly abortSignal?: Readonly | undefined; } @@ -26,7 +26,7 @@ function toolOf(offered: ModelTool, stop: Readonly, cancelled: Read return dynamicTool({ description: offered.description, inputSchema: jsonSchema(offered.inputSchema), - execute: (input: unknown, { toolCallId, abortSignal }: Execution) => { + execute: (input: unknown, { toolCallId, abortSignal }: Run) => { const signal = AbortSignal.any([...Arr.fromNullishOr(abortSignal), stop]); return Option.match(decodeArguments(input), { onNone: () => Promise.resolve(notAnObject), diff --git a/primitives/inference/src/tools/tool-endings.test.ts b/capabilities/reasoning/src/tools/tool-endings.test.ts similarity index 97% rename from primitives/inference/src/tools/tool-endings.test.ts rename to capabilities/reasoning/src/tools/tool-endings.test.ts index 921f8092f..97f25aa22 100644 --- a/primitives/inference/src/tools/tool-endings.test.ts +++ b/capabilities/reasoning/src/tools/tool-endings.test.ts @@ -5,9 +5,9 @@ import { afterEach, describe, expect, it } from 'vitest'; import { ContentRefused, TimedOut, ToolsStopped, type ToolsStoppedBecause } from '../index.ts'; import { callingTools, type ScriptedCall } from '../testing/calling-tools.ts'; +import { documentOf } from '../testing/definition-documents.ts'; import { reasoningWithTools } from '../testing/reasoning-runs.ts'; import type { ScriptedReply } from '../testing/scripted-language-model.ts'; -import { documentOf } from '../testing/spec-documents.ts'; const apiKey = 'graph-api-key-4f1d9a7c2b'; @@ -30,7 +30,7 @@ function ending(fake: FakeMcpServer, reply: ScriptedReply) { ); closing.push(access.close); const source = documentOf('model: anthropic/claude-sonnet-4-5\ntools: [graph/*]', 'Summarize acme.'); - return reasoningWithTools(access, reply).executing(source); + return reasoningWithTools(access, reply).running(source); } const stoppedBy = diff --git a/primitives/inference/src/tools/tool-endings.ts b/capabilities/reasoning/src/tools/tool-endings.ts similarity index 100% rename from primitives/inference/src/tools/tool-endings.ts rename to capabilities/reasoning/src/tools/tool-endings.ts diff --git a/primitives/inference/src/tools/tool-loop.test.ts b/capabilities/reasoning/src/tools/tool-loop.test.ts similarity index 100% rename from primitives/inference/src/tools/tool-loop.test.ts rename to capabilities/reasoning/src/tools/tool-loop.test.ts diff --git a/primitives/inference/src/tools/tool-loop.ts b/capabilities/reasoning/src/tools/tool-loop.ts similarity index 100% rename from primitives/inference/src/tools/tool-loop.ts rename to capabilities/reasoning/src/tools/tool-loop.ts diff --git a/primitives/inference/src/tools/tool-opening.test.ts b/capabilities/reasoning/src/tools/tool-opening.test.ts similarity index 81% rename from primitives/inference/src/tools/tool-opening.test.ts rename to capabilities/reasoning/src/tools/tool-opening.test.ts index 7413f4e37..5572c33a1 100644 --- a/primitives/inference/src/tools/tool-opening.test.ts +++ b/capabilities/reasoning/src/tools/tool-opening.test.ts @@ -2,13 +2,13 @@ import { reportingAccess, serveFakeMcp, type FakeMcpServer } from '@beonauto/mcp import { Effect, Exit } from 'effect'; import { afterEach, describe, expect, it } from 'vitest'; -import { makeReasoningFunctionAdapter } from '../primitive/reasoning-function.ts'; +import { makeReasoningFunctionAdapter } from '../capability/reasoning-function.ts'; import { accessFor } from '../testing/adapter-harness.ts'; import { callingTools } from '../testing/calling-tools.ts'; +import { documentOf, issuesIn } from '../testing/definition-documents.ts'; import { textResult } from '../testing/model-results.ts'; -import { execution, reasoningWith, reasoningWithTools } from '../testing/reasoning-runs.ts'; +import { runContext, reasoningWith, reasoningWithTools } from '../testing/reasoning-runs.ts'; import { answers } from '../testing/scripted-language-model.ts'; -import { documentOf, issuesIn } from '../testing/spec-documents.ts'; const apiKey = 'graph-api-key-4f1d9a7c2b'; @@ -54,16 +54,16 @@ describe('a reasoning function that names tools', () => { callingTools([['mcp__graph__search', { query: 'acme' }]], answers(textResult('Acme has 2 rows.'))), ); - const executed = await run.executing(naming('graph/search')); + const ran = await run.running(naming('graph/search')); - expect(executed).toMatchObject({ _tag: 'Success', value: { output: 'Acme has 2 rows.' } }); + expect(ran).toMatchObject({ _tag: 'Success', value: { output: 'Acme has 2 rows.' } }); expect(run.requests()[0]?.tools).toMatchObject({ offered: [{ name: 'mcp__graph__search' }], runBoundMs: 600_000, }); expect(run.journal.recorded().map(({ type }) => type)).toEqual(['tool_call_started', 'tool_call_answered']); expect(fake.received()).toEqual([ - { tool: 'search', arguments: { query: 'acme' }, meta: { 'com.beonauto/execution_id': execution.id } }, + { tool: 'search', arguments: { query: 'acme' }, meta: { 'com.beonauto/run_id': runContext.id } }, ]); expect(fake.endedSessions()).toBe(1); }); @@ -71,8 +71,8 @@ describe('a reasoning function that names tools', () => { it('says it may change something outside when MCP servers are configured', async () => { const run = reasoningWithTools(accessTo(await graphServer())); - expect(run.primitive.mayChangeOutside).toBe(true); - expect(reasoningWith().primitive.mayChangeOutside).toBe(false); + expect(run.capability.mayChangeOutside).toBe(true); + expect(reasoningWith().capability.mayChangeOutside).toBe(false); }); }); @@ -80,7 +80,7 @@ describe('a tool a reasoning function names that is not offered', () => { it('rejects the run as unavailable when no server of that name is configured for its brain', async () => { const run = reasoningWithTools(accessTo(await graphServer(), 'globex')); - expect(await run.executing(naming('graph/search'))).toEqual( + expect(await run.running(naming('graph/search'))).toEqual( Exit.fail( expect.objectContaining({ _tag: 'unavailable', @@ -92,7 +92,7 @@ describe('a tool a reasoning function names that is not offered', () => { }); it('rejects the run as unavailable when no MCP server is configured at all', async () => { - expect(await reasoningWith().executing(naming('graph/search', 'crm/find'))).toEqual( + expect(await reasoningWith().running(naming('graph/search', 'crm/find'))).toEqual( Exit.fail( expect.objectContaining({ kind: 'tool_not_offered', @@ -109,7 +109,7 @@ describe('a tool a reasoning function names that is not offered', () => { await fake.close(); const run = reasoningWithTools(accessTo(fake)); - expect(await run.executing(naming('graph/search'))).toEqual( + expect(await run.running(naming('graph/search'))).toEqual( Exit.fail(expect.objectContaining({ kind: 'mcp_server_failed', because: 'unreachable' })), ); }); @@ -119,14 +119,14 @@ describe('a reasoning function with tools whose model is not offered', () => { it('is rejected before its tools are opened, so it reaches no server and starts no process', async () => { const fake = await graphServer(); const { languageModel } = await accessFor({ OPENAI_API_KEY: 'k' }, {}); - const primitive = makeReasoningFunctionAdapter({ + const capability = makeReasoningFunctionAdapter({ languageModel, offered: { providers: ['openai'], aliases: [] }, tools: accessTo(fake), }); - const prepared = Effect.runSync(primitive.prepare(naming('graph/search'))); + const prepared = Effect.runSync(capability.prepare(naming('graph/search'))); - expect(await Effect.runPromiseExit(prepared.execute({}, execution))).toEqual( + expect(await Effect.runPromiseExit(prepared.run({}, runContext))).toEqual( Exit.fail(expect.objectContaining({ kind: 'model_not_offered', because: 'provider_not_configured' })), ); expect(fake.seen()).toEqual([]); diff --git a/primitives/inference/src/tools/tool-opening.ts b/capabilities/reasoning/src/tools/tool-opening.ts similarity index 84% rename from primitives/inference/src/tools/tool-opening.ts rename to capabilities/reasoning/src/tools/tool-opening.ts index fe1ac009e..0a044e034 100644 --- a/primitives/inference/src/tools/tool-opening.ts +++ b/capabilities/reasoning/src/tools/tool-opening.ts @@ -1,7 +1,7 @@ +import type { RunContext } from '@beonauto/definitions'; import type { NotOfferedBecause, RunTools, ServerFailedBecause, ToolAccess } from '@beonauto/mcp'; -import { executionIdKey, writtenOf, type ToolReference } from '@beonauto/mcp/policy'; +import { runIdKey, writtenOf, type ToolReference } from '@beonauto/mcp/policy'; import { Unavailable } from '@beonauto/operations'; -import type { RunContext } from '@beonauto/specs'; import { Effect } from 'effect'; interface Refused { @@ -25,7 +25,7 @@ function opened( references: readonly ToolReference[], { id, org, brain, journal }: RunContext, ): Effect.Effect { - return access.open({ id, org, brain, journal, meta: { [executionIdKey]: id } }, references).pipe( + return access.open({ id, org, brain, journal, meta: { [runIdKey]: id } }, references).pipe( Effect.catchTags({ tool_not_offered: ({ because, detail }: Refused) => Effect.fail(new Unavailable({ detail, kind: 'tool_not_offered', because })), @@ -38,7 +38,7 @@ function opened( export function withTools( access: ToolAccess | undefined, references: readonly ToolReference[], - execution: RunContext, + run: RunContext, use: (tools?: RunTools) => Effect.Effect, ): Effect.Effect { if (references.length === 0) { @@ -47,7 +47,7 @@ export function withTools( if (access === undefined) { return Effect.fail(noServerFor(references)); } - return opened(access, references, execution).pipe( + return opened(access, references, run).pipe( Effect.flatMap((tools) => use(tools).pipe(Effect.ensuring(Effect.promise(() => tools.close())))), ); } diff --git a/primitives/inference/src/tools/tool-stops.test.ts b/capabilities/reasoning/src/tools/tool-stops.test.ts similarity index 98% rename from primitives/inference/src/tools/tool-stops.test.ts rename to capabilities/reasoning/src/tools/tool-stops.test.ts index 90e376090..55fd653a8 100644 --- a/primitives/inference/src/tools/tool-stops.test.ts +++ b/capabilities/reasoning/src/tools/tool-stops.test.ts @@ -106,7 +106,9 @@ describe('the deadlines of a run that calls tools', () => { const { tools } = scriptedTools(); const { access, aborted } = await run(refuse); - expect(await failed(access, textRequest(model, { tools, timeout_ms: 50 }))).toMatchObject({ _tag: 'spec_invalid' }); + expect(await failed(access, textRequest(model, { tools, timeout_ms: 50 }))).toMatchObject({ + _tag: 'definition_invalid', + }); await setTimeout(150); expect(aborted()).toEqual([false]); diff --git a/primitives/orchestration/tsconfig.json b/capabilities/reasoning/tsconfig.json similarity index 100% rename from primitives/orchestration/tsconfig.json rename to capabilities/reasoning/tsconfig.json diff --git a/primitives/inference/vitest.config.ts b/capabilities/reasoning/vitest.config.ts similarity index 87% rename from primitives/inference/vitest.config.ts rename to capabilities/reasoning/vitest.config.ts index 8b2c2e0d6..5e1343f5d 100644 --- a/primitives/inference/vitest.config.ts +++ b/capabilities/reasoning/vitest.config.ts @@ -2,4 +2,4 @@ import { defineConfig, mergeConfig } from 'vitest/config'; import { sharedConfig } from '../../vitest.shared.ts'; -export default mergeConfig(sharedConfig, defineConfig({ test: { name: 'inference' } })); +export default mergeConfig(sharedConfig, defineConfig({ test: { name: 'reasoning' } })); diff --git a/primitives/recollection/README.md b/capabilities/recall/README.md similarity index 66% rename from primitives/recollection/README.md rename to capabilities/recall/README.md index 7e5d36033..6c485ae83 100644 --- a/primitives/recollection/README.md +++ b/capabilities/recall/README.md @@ -1,6 +1,6 @@ -# @beonauto/recollection +# @beonauto/recall -The implementation of recall functions. A recall function keeps a view of its brain's own history: its fold, a program in jq, folds each event its filters name into the view, the host keeps the view as the brain records events, and a run answers from the view as it stands, applying the function's `answer` to it with the run's input. Its API identifier and package name are `recollection`; in text a user reads it is a recall function, and what it keeps its view. +The implementation of recall functions. A recall function keeps a view of its brain's own history: its fold, a program in jq, folds each event its filters name into the view, the host keeps the view as the brain records events, and a run answers from the view as it stands, applying the function's `answer` to it with the run's input. A recall function answers from what the brain keeps of its own history, as [Brain terminology](../../docs/concepts/terminology.md) says. Documents, files and other connected sources are not implemented, and neither is semantic search over text. @@ -8,7 +8,7 @@ User documentation is [Recall function format](../../docs/reference/recall-forma ## The document -`parseRecallDocument(source)` reads a document with the shared reader of [`@beonauto/specs/document`](../../packages/specs/README.md#reading-function-documents) and gives a `RecallFunctionDefinitionDocument`: an optional `description`, the `language`, `jq`, the compiled `input.schema` and `output.schema` when the document has them, the `answer` with the line it is on, and the `details` the host folds the view by: the fold and the line it starts on, the answer's text, the filters, `initial` and the view's schema. The front matter's keys are `description`, `language`, `source.events`, `view.initial`, `view.schema`, `input.schema`, `output.schema` and `answer`; every other key, `model`, `config`, `tools`, `output.format` and `input.default` among them, is an issue at its line, and the message for empty front matter names `the language and the events it folds`. +`parseRecallDocument(source)` reads a document with the shared reader of [`@beonauto/definitions/document`](../../packages/definitions/README.md#reading-function-documents) and gives a `RecallFunctionDefinitionDocument`: an optional `description`, the `language`, `jq`, the compiled `input.schema` and `output.schema` when the document has them, the `answer` with the line it is on, and the `details` the host folds the view by: the fold and the line it starts on, the answer's text, the filters, `initial` and the view's schema. The front matter's keys are `description`, `language`, `source.events`, `view.initial`, `view.schema`, `input.schema`, `output.schema` and `answer`; every other key, `model`, `config`, `tools`, `output.format` and `input.default` among them, is an issue at its line, and the message for empty front matter names `the language and the events it folds`. The fold and the answer are compiled with the evaluator of [`@beonauto/workflow-engine/dsl`](../../packages/workflow-engine/README.md#programs) under the refusals of computation functions and `$ARGS` (`src/document/recall-dialects.ts`): the fold binds `$event` alone, the answer `$input` alone, and a filter's `data` expression nothing, so every other `$name` is refused with its line. A filter (`src/document/source-filters.ts`) is read with the engine's `literalFilterOf`, so its `type`, `source` and `subject` are written out and it tests no other attribute; a `data` that names a variable, or does not compile in the filter's dialect, is refused with the evaluator's own message, never the expression's text. `initial` must fit the bound on a view, 524,288 bytes as JSON, and match the view's schema; the shared reader already bounds the front matter's nesting at 72 levels. @@ -16,16 +16,16 @@ The fold and the answer are compiled with the evaluator of [`@beonauto/workflow- ## A run -`makeRecallFunctionAdapter({ pool, views, mostFunctions, deadlineMs })` makes the primitive; the server gives it the computation pool and the host's `ViewsPort`. Its `mostActive` is `mostFunctions`, 32 unless the server's `RECOLLECTION_MAX_FUNCTIONS` says otherwise, counted by the registry at each save. `deadlineMs` is 10,000 unless a test gives less, and is the primitive's `longestExecutionMs`. The primitive reaches nothing outside and changes nothing there. An execution (`src/run/recall-run.ts`): +`makeRecallFunctionAdapter({ pool, views, mostFunctions, deadlineMs })` makes the capability; the server gives it the computation pool and the host's `ViewsPort`. Its `mostActive` is `mostFunctions`, 32 unless the server's `RECALL_MAX_FUNCTIONS` says otherwise, counted by the registry at each save. `deadlineMs` is 10,000 unless a test gives less, and is the capability's `longestAnyRunMs`. The capability reaches nothing outside and changes nothing there. A run (`src/run/recall-run.ts`): 1. refuses an input nested deeper than 512 levels, and one its input schema refuses, as `invalid_input` with the schema's pointers; 2. reads the view through the views port and, while the view of the version saved is missing, of an older version, waiting or being built, answers `unavailable` with the kind `rebuilding`, the count folded and the lag in words; while it is stalled, `conflict` with the kind `stalled`, in fixed words with the type and time of the event and the line of the fold, never the fold's own message, the event's values or its id (`src/run/view-words.ts`); -3. without an `answer`, answers the view itself, checked against the output schema on the serving thread, since the view is at most 512 KiB; with one, asks the pool to apply it to the view with the input as `$input`, under 16,000,000 units of work, in `exactly one` mode, with an output of at most `mostOutputBytes`, 1 MiB less 2,048 bytes for the record, and the checked worker of `@beonauto/specs/json-schema` for every answer, with the output schema as its context, or `null` without one, so the worker that applies `answer` also checks its output there; +3. without an `answer`, answers the view itself, checked against the output schema on the serving thread, since the view is at most 512 KiB; with one, asks the pool to apply it to the view with the input as `$input`, under 16,000,000 units of work, in `exactly one` mode, with an output of at most `mostOutputBytes`, 1 MiB less 2,048 bytes for the record, and the checked worker of `@beonauto/definitions/json-schema` for every answer, with the output schema as its context, or `null` without one, so the worker that applies `answer` also checks its output there; 4. turns the outcome into the run's ending as a computation function's run does (`src/run/answer-endings.ts`), and records `{ language, work, duration_ms, input_bytes, output_bytes, view: { version, checkpoint, checkpoint_at, folded, last_event } }`, so a caller who does not see an event can tell how far the view had read. -A run never waits for the projector. `get_spec` of a recall function adds its `standing` (`src/primitive/recall-standing.ts`): the view's state, version, checkpoint, count folded, last event, lag and the time of the brain's newest record, and for a stalled view the event's id, type and time, the kind of stall, the fold's raw error and its line; a retired function has none. +A run never waits for the projector. `get_definition` of a recall function adds its `standing` (`src/capability/recall-standing.ts`): the view's state, version, checkpoint, count folded, last event, lag and the time of the brain's newest record, and for a stalled view the event's id, type and time, the kind of stall, the fold's raw error and its line; a retired function has none. -The host's projector folds pages of events in the pool with `recallFolding`, whose worker is that checked worker too, so each view is checked against its schema where it was folded, and a view or an answer it refuses is worded with `issuesDetail` of `@beonauto/specs/json-schema`, the one wording of the issues a computation output gets. +The host's projector folds pages of events in the pool with `recallFolding`, whose worker is that checked worker too, so each view is checked against its schema where it was folded, and a view or an answer it refuses is worded with `issuesDetail` of `@beonauto/definitions/json-schema`, the one wording of the issues a computation output gets. ## Bounds @@ -35,8 +35,8 @@ The hosted runtime does not offer recall functions until its adapter bounds the ## Testing -`@beonauto/recollection/testing` exports `campaignReviews`, the example document, `recallDocument(fold, frontMatter)`, a document of a fold over every successful run, and `reviewBrief`, a reasoning function whose runs the example folds. The tests of this package run real workers through a fake views port (`src/testing/kept-views.ts`), and the engine's `scriptedPool` for the outcomes a real worker gives only rarely. +`@beonauto/recall/testing` exports `campaignReviews`, the example document, `recallDocument(fold, frontMatter)`, a document of a fold over every successful run, and `reviewBrief`, a reasoning function whose runs the example folds. The tests of this package run real workers through a fake views port (`src/testing/kept-views.ts`), and the engine's `scriptedPool` for the outcomes a real worker gives only rarely. ## Source -`src/index.ts` is the entry point and `src/testing/index.ts` the entry point of the test support. `src/document` holds the document: its type, the front matter's keys, the dialects, the filters and parsing. `src/run` holds a run and the folding settings: the bounds, the input's checks, the answer, its endings and the words of a view not ready. `src/primitive` holds the primitive, its parsing and summary, its standing, and its guide, the public reference page, served to agents as `recall-function`. `src/testing` holds the example and what the tests share. +`src/index.ts` is the entry point and `src/testing/index.ts` the entry point of the test support. `src/document` holds the document: its type, the front matter's keys, the dialects, the filters and parsing. `src/run` holds a run and the folding settings: the bounds, the input's checks, the answer, its endings and the words of a view not ready. `src/capability` holds the capability, its parsing and summary, its standing, and its guide, the public reference page, served to agents as `recall-function`. `src/testing` holds the example and what the tests share. diff --git a/primitives/recollection/package.json b/capabilities/recall/package.json similarity index 88% rename from primitives/recollection/package.json rename to capabilities/recall/package.json index 561acdbf1..8d80111fa 100644 --- a/primitives/recollection/package.json +++ b/capabilities/recall/package.json @@ -1,5 +1,5 @@ { - "name": "@beonauto/recollection", + "name": "@beonauto/recall", "version": "0.0.0", "private": true, "license": "Elastic-2.0", @@ -14,8 +14,8 @@ "test": "vitest run --coverage" }, "dependencies": { + "@beonauto/definitions": "workspace:*", "@beonauto/operations": "workspace:*", - "@beonauto/specs": "workspace:*", "@beonauto/workflow-engine": "workspace:*", "@beonauto/workflow-host": "workspace:*", "effect": "catalog:" diff --git a/primitives/recollection/src/primitive/recall-definitions.ts b/capabilities/recall/src/capability/recall-definitions.ts similarity index 91% rename from primitives/recollection/src/primitive/recall-definitions.ts rename to capabilities/recall/src/capability/recall-definitions.ts index 891e59184..287eb91c8 100644 --- a/primitives/recollection/src/primitive/recall-definitions.ts +++ b/capabilities/recall/src/capability/recall-definitions.ts @@ -1,6 +1,6 @@ +import type { DefinitionSummary } from '@beonauto/definitions'; +import { issueText } from '@beonauto/definitions/document'; import { InvalidInput } from '@beonauto/operations'; -import type { DefinitionSummary } from '@beonauto/specs'; -import { issueText } from '@beonauto/specs/document'; import { ViewDetailsSchema } from '@beonauto/workflow-host'; import { Effect, Result, Schema } from 'effect'; diff --git a/primitives/recollection/src/primitive/recall-function.test.ts b/capabilities/recall/src/capability/recall-function.test.ts similarity index 73% rename from primitives/recollection/src/primitive/recall-function.test.ts rename to capabilities/recall/src/capability/recall-function.test.ts index 12ba9ec56..ece14a0cb 100644 --- a/primitives/recollection/src/primitive/recall-function.test.ts +++ b/capabilities/recall/src/capability/recall-function.test.ts @@ -6,7 +6,7 @@ import { campaignReviews, recallDocument } from '../testing/campaign-reviews.ts' import { liveView, poolOf, recallWith } from '../testing/recall-runs.ts'; const stall: ViewStall = { - event: { id: '5d0e9f6a-1b2c-5d3e-8f4a-6b7c8d9e0f1b', type: 'execution_succeeded', time: '2026-10-06T10:00:01.000Z' }, + event: { id: '5d0e9f6a-1b2c-5d3e-8f4a-6b7c8d9e0f1b', type: 'run_succeeded', time: '2026-10-06T10:00:01.000Z' }, kind: 'raised', message: 'cannot take bad', line: 31, @@ -16,28 +16,28 @@ const alpha = { org: 'acme', brain: 'alpha', name: 'reviews', version: 1 }; describe('a recall function', () => { it('describes its result in words', () => { - const { primitive } = recallWith(poolOf({ workers: 1 })); + const { capability } = recallWith(poolOf({ workers: 1 })); - expect(primitive.describeOutput([{ verdict: 'approve' }])).toBe('Its result: verdict: “approve”.'); - expect(primitive.describeOutput('x'.repeat(5000))).toBe( + expect(capability.describeOutput([{ verdict: 'approve' }])).toBe('Its result: verdict: “approve”.'); + expect(capability.describeOutput('x'.repeat(5000))).toBe( 'Its result is too long to repeat here; the whole of it is in the details below.', ); }); it('is a function of the brain that reaches nothing outside, of which a brain keeps at most thirty-two', () => { - const { primitive, prepared } = recallWith(poolOf({ workers: 1 })); + const { capability, prepared } = recallWith(poolOf({ workers: 1 })); - expect(primitive).toMatchObject({ - name: 'recollection', + expect(capability).toMatchObject({ + type: 'recall', title: 'Recall', noun: { one: 'recall function', other: 'recall functions' }, mediaType: 'text/markdown', reachesOutside: false, mayChangeOutside: false, - longestExecutionMs: 10_000, + longestAnyRunMs: 10_000, mostActive: 32, }); - expect(primitive.guide).toEqual({ name: 'recall-function' }); + expect(capability.guide).toEqual({ name: 'recall-function' }); expect(prepared(campaignReviews).callsTools).toBe(false); }); }); @@ -53,7 +53,7 @@ describe('the summary of a recall function', () => { details: { language: 'jq', foldLine: 27, - filters: [{ type: 'execution_succeeded', subject: 'inference/review-brief' }], + filters: [{ type: 'run_succeeded', subject: 'reasoning/review-brief' }], initial: {}, answer: '.[$input.campaign] // [] | .[-($input.last // 5):]', }, @@ -63,7 +63,7 @@ describe('the summary of a recall function', () => { language: 'jq', fold: '. + 1', foldLine: 7, - filters: [{ type: 'execution_succeeded' }], + filters: [{ type: 'run_succeeded' }], initial: null, }, }); @@ -72,11 +72,11 @@ describe('the summary of a recall function', () => { describe('a recall function definition with problems', () => { it('refuses a definition with its problems, each with its line', async () => { - const { primitive } = recallWith(poolOf({ workers: 1 })); + const { capability } = recallWith(poolOf({ workers: 1 })); expect( await Effect.runPromise( - Effect.flip(primitive.prepare(recallDocument('now', 'language: python\nsource: {events: [{type: x}]}'))), + Effect.flip(capability.prepare(recallDocument('now', 'language: python\nsource: {events: [{type: x}]}'))), ), ).toMatchObject({ detail: 'The recall function definition has 2 problems', @@ -92,7 +92,7 @@ describe('a recall function definition with problems', () => { }, ], }); - expect(await Effect.runPromise(Effect.flip(primitive.prepare(recallDocument('.a +'))))).toMatchObject({ + expect(await Effect.runPromise(Effect.flip(capability.prepare(recallDocument('.a +'))))).toMatchObject({ detail: 'The recall function definition has a problem', }); }); @@ -104,7 +104,7 @@ describe('the standing of a recall function', () => { run.keep(liveView({})); run.newest('2026-10-06T10:00:07.000Z'); - expect(await Effect.runPromise(run.primitive.standing({ ...alpha, status: 'active' }))).toEqual({ + expect(await Effect.runPromise(run.capability.standing({ ...alpha, status: 'active' }))).toEqual({ state: 'live', version: 1, checkpoint: 'YnJhaW4vYWNtZS9hbHBoYS8sNDI', @@ -118,9 +118,9 @@ describe('the standing of a recall function', () => { it('is rebuilding before its view of the version saved exists, and waiting behind others', async () => { const run = recallWith(poolOf({ workers: 1 })); - const missing = await Effect.runPromise(run.primitive.standing({ ...alpha, version: 2, status: 'active' })); + const missing = await Effect.runPromise(run.capability.standing({ ...alpha, version: 2, status: 'active' })); run.keep(liveView({}, { phase: 'waiting', checkpoint: null, checkpointAt: null, lastEvent: null, folded: 0 })); - const waiting = await Effect.runPromise(run.primitive.standing({ ...alpha, status: 'active' })); + const waiting = await Effect.runPromise(run.capability.standing({ ...alpha, status: 'active' })); expect(missing).toEqual({ state: 'rebuilding', @@ -140,10 +140,10 @@ describe('the standing of a stalled or retired recall function', () => { const run = recallWith(poolOf({ workers: 1 })); run.keep(liveView({}, { phase: 'stalled', stall })); - expect(await Effect.runPromise(run.primitive.standing({ ...alpha, status: 'active' }))).toMatchObject({ + expect(await Effect.runPromise(run.capability.standing({ ...alpha, status: 'active' }))).toMatchObject({ state: 'stalled', stalled: { event: stall.event, kind: 'raised', error: 'cannot take bad', line: 31 }, }); - expect(await Effect.runPromise(run.primitive.standing({ ...alpha, status: 'retired' }))).toBeUndefined(); + expect(await Effect.runPromise(run.capability.standing({ ...alpha, status: 'retired' }))).toBeUndefined(); }); }); diff --git a/primitives/recollection/src/primitive/recall-function.ts b/capabilities/recall/src/capability/recall-function.ts similarity index 84% rename from primitives/recollection/src/primitive/recall-function.ts rename to capabilities/recall/src/capability/recall-function.ts index 85f83ad2f..38846424d 100644 --- a/primitives/recollection/src/primitive/recall-function.ts +++ b/capabilities/recall/src/capability/recall-function.ts @@ -1,11 +1,11 @@ -import { asSentence } from '@beonauto/operations'; import { - definePrimitive, + defineCapability, functionCategoryLabels, functionResourceLabels, inWords, - type Primitive, -} from '@beonauto/specs'; + type Capability, +} from '@beonauto/definitions'; +import { asSentence } from '@beonauto/operations'; import type { ProgramPool } from '@beonauto/workflow-engine/dsl'; import type { ViewsPort } from '@beonauto/workflow-host'; import type { Schema } from 'effect'; @@ -34,10 +34,10 @@ export function makeRecallFunctionAdapter({ views, mostFunctions = recallBounds.mostFunctions, deadlineMs = recallBounds.deadlineMs, -}: RecallFunctionAdapterOptions): Primitive { +}: RecallFunctionAdapterOptions): Capability { const run = recallRun({ pool, views, deadlineMs }); - return definePrimitive({ - name: recallDefinitionType, + return defineCapability({ + type: recallDefinitionType, title: functionCategoryLabels.recall, guide: { name: 'recall-function' }, noun: { one: functionResourceLabels.recall.singular, other: functionResourceLabels.recall.plural }, @@ -45,8 +45,8 @@ export function makeRecallFunctionAdapter({ mediaType: 'text/markdown', parse, summarize, - execute: (document, input, execution) => run(document, input, execution), - longestExecutionMs: deadlineMs, + run: (document, input, context) => run(document, input, context), + longestAnyRunMs: deadlineMs, mostActive: mostFunctions, standing: recallStanding(views), }); diff --git a/primitives/recollection/src/primitive/recall-standing.ts b/capabilities/recall/src/capability/recall-standing.ts similarity index 96% rename from primitives/recollection/src/primitive/recall-standing.ts rename to capabilities/recall/src/capability/recall-standing.ts index 88666508e..cd3d08744 100644 --- a/primitives/recollection/src/primitive/recall-standing.ts +++ b/capabilities/recall/src/capability/recall-standing.ts @@ -1,4 +1,4 @@ -import type { Standing } from '@beonauto/specs'; +import type { Standing } from '@beonauto/definitions'; import type { KeptView, ViewsPort, ViewStall } from '@beonauto/workflow-host'; import { Effect, type Schema } from 'effect'; diff --git a/primitives/recollection/src/document/document-parsing.test.ts b/capabilities/recall/src/document/document-parsing.test.ts similarity index 97% rename from primitives/recollection/src/document/document-parsing.test.ts rename to capabilities/recall/src/document/document-parsing.test.ts index cf0770c82..34870c414 100644 --- a/primitives/recollection/src/document/document-parsing.test.ts +++ b/capabilities/recall/src/document/document-parsing.test.ts @@ -1,4 +1,4 @@ -import { issueText } from '@beonauto/specs/document'; +import { issueText } from '@beonauto/definitions/document'; import { Result } from 'effect'; import { describe, expect, it } from 'vitest'; @@ -6,7 +6,7 @@ import { campaignReviews, recallDocument } from '../testing/campaign-reviews.ts' import { parseRecallDocument } from './document-parsing.ts'; import type { RecallFunctionDefinitionDocument } from './recall-document.ts'; -const succeeded = 'language: jq\nsource:\n events:\n - type: execution_succeeded'; +const succeeded = 'language: jq\nsource:\n events:\n - type: run_succeeded'; function parsed(source: string): RecallFunctionDefinitionDocument { return Result.getOrThrow(parseRecallDocument(source)); @@ -35,7 +35,7 @@ describe('a recall function definition', () => { fold: campaignReviews.split('---\n')[2], foldLine: 27, answer: '.[$input.campaign] // [] | .[-($input.last // 5):]', - filters: [{ type: 'execution_succeeded', subject: 'inference/review-brief' }], + filters: [{ type: 'run_succeeded', subject: 'reasoning/review-brief' }], initial: {}, schema: { type: 'object', maxProperties: 50, additionalProperties: { type: 'array', maxItems: 20 } }, }); @@ -50,7 +50,7 @@ describe('a recall function definition', () => { language: 'jq', fold: '. + 1', foldLine: 7, - filters: [{ type: 'execution_succeeded' }], + filters: [{ type: 'run_succeeded' }], initial: null, }, }); diff --git a/primitives/recollection/src/document/document-parsing.ts b/capabilities/recall/src/document/document-parsing.ts similarity index 99% rename from primitives/recollection/src/document/document-parsing.ts rename to capabilities/recall/src/document/document-parsing.ts index 6149cbd34..70e385850 100644 --- a/primitives/recollection/src/document/document-parsing.ts +++ b/capabilities/recall/src/document/document-parsing.ts @@ -9,7 +9,7 @@ import { type DocumentParts, type ReadFrontMatter, type SourceLines, -} from '@beonauto/specs/document'; +} from '@beonauto/definitions/document'; import { compileProgram, jsonBytesOf, lineOf, mostValueDepth } from '@beonauto/workflow-engine/dsl'; import type { ViewFilter } from '@beonauto/workflow-host'; import { Result, type Schema } from 'effect'; diff --git a/primitives/recollection/src/document/front-matter.ts b/capabilities/recall/src/document/front-matter.ts similarity index 98% rename from primitives/recollection/src/document/front-matter.ts rename to capabilities/recall/src/document/front-matter.ts index 7475e6ae3..03c71dc14 100644 --- a/primitives/recollection/src/document/front-matter.ts +++ b/capabilities/recall/src/document/front-matter.ts @@ -1,4 +1,4 @@ -import type { FrontMatterSection, FrontMatterShape } from '@beonauto/specs/document'; +import type { FrontMatterSection, FrontMatterShape } from '@beonauto/definitions/document'; import { Schema } from 'effect'; const descriptionLength = 1000; diff --git a/primitives/recollection/src/document/recall-dialects.test.ts b/capabilities/recall/src/document/recall-dialects.test.ts similarity index 98% rename from primitives/recollection/src/document/recall-dialects.test.ts rename to capabilities/recall/src/document/recall-dialects.test.ts index 895fbdd63..cc388422d 100644 --- a/primitives/recollection/src/document/recall-dialects.test.ts +++ b/capabilities/recall/src/document/recall-dialects.test.ts @@ -1,4 +1,4 @@ -import { issueText } from '@beonauto/specs/document'; +import { issueText } from '@beonauto/definitions/document'; import { Result } from 'effect'; import { describe, expect, it } from 'vitest'; diff --git a/primitives/recollection/src/document/recall-dialects.ts b/capabilities/recall/src/document/recall-dialects.ts similarity index 100% rename from primitives/recollection/src/document/recall-dialects.ts rename to capabilities/recall/src/document/recall-dialects.ts diff --git a/primitives/recollection/src/document/recall-document.ts b/capabilities/recall/src/document/recall-document.ts similarity index 86% rename from primitives/recollection/src/document/recall-document.ts rename to capabilities/recall/src/document/recall-document.ts index 79e7a92f4..a8fbae139 100644 --- a/primitives/recollection/src/document/recall-document.ts +++ b/capabilities/recall/src/document/recall-document.ts @@ -1,4 +1,4 @@ -import type { CompiledSchema } from '@beonauto/specs/document'; +import type { CompiledSchema } from '@beonauto/definitions/document'; import type { ViewDetails } from '@beonauto/workflow-host'; export interface ValueContract { diff --git a/primitives/recollection/src/document/reference-bounds.test.ts b/capabilities/recall/src/document/reference-bounds.test.ts similarity index 100% rename from primitives/recollection/src/document/reference-bounds.test.ts rename to capabilities/recall/src/document/reference-bounds.test.ts diff --git a/primitives/recollection/src/document/reference-example.test.ts b/capabilities/recall/src/document/reference-example.test.ts similarity index 87% rename from primitives/recollection/src/document/reference-example.test.ts rename to capabilities/recall/src/document/reference-example.test.ts index c071ea758..be14e44ab 100644 --- a/primitives/recollection/src/document/reference-example.test.ts +++ b/capabilities/recall/src/document/reference-example.test.ts @@ -25,16 +25,16 @@ const reviews = [ const events = reviews.map(([time, run, output]) => ({ specversion: '1.0', id: `5d0e9f6a-1b2c-5d3e-8f4a-6b7c8d9e0f${run}`, - source: `/executions/0199a3c4-7d2e-7c1a-9b3f-2f1e0d9c8b${run}`, - type: 'execution_succeeded', - subject: 'inference/review-brief', + source: `/runs/0199a3c4-7d2e-7c1a-9b3f-2f1e0d9c8b${run}`, + type: 'run_succeeded', + subject: 'reasoning/review-brief', time, - data: { primitive: 'inference', name: 'review-brief', version: 1, caller: 'acme-admin', output }, + data: { type: 'reasoning', name: 'review-brief', version: 1, caller: 'acme-admin', output }, })); function announcementRun(index: number) { const run = String(index).padStart(2, '0'); - return { time: `2026-10-07T09:${run}:00.000Z`, source: `/executions/0199a3c4-7d2e-7c1a-9b3f-2f1e0d9c8c${run}` }; + return { time: `2026-10-07T09:${run}:00.000Z`, source: `/runs/0199a3c4-7d2e-7c1a-9b3f-2f1e0d9c8c${run}` }; } type Outcome = { readonly output: Schema.Json } | { readonly output_bytes: number }; @@ -44,9 +44,9 @@ function announcement(index: number, outcome: Outcome): Schema.JsonObject { specversion: '1.0', id: `announcement-${index}`, ...announcementRun(index), - type: 'execution_succeeded', - subject: 'inference/post-announcement', - data: { primitive: 'inference', name: 'post-announcement', version: 1, caller: 'acme-admin', ...outcome }, + type: 'run_succeeded', + subject: 'reasoning/post-announcement', + data: { type: 'reasoning', name: 'post-announcement', version: 1, caller: 'acme-admin', ...outcome }, }; } @@ -93,7 +93,7 @@ describe('the example on the reference page of recall functions', { timeout: wor run.keep(liveView(decodeJson(view?.body))); expect([input?.language, output?.language]).toEqual(['json', 'json']); - expect(await run.executing(campaignReviews, decodeJson(input?.body))).toMatchObject( + expect(await run.running(campaignReviews, decodeJson(input?.body))).toMatchObject( Exit.succeed({ output: decodeJson(output?.body) }), ); }); @@ -106,7 +106,7 @@ describe('the example of the common case on the reference page', { timeout: work it('folds the succeeded runs of post-announcement, by the filter written out', () => { expect(document?.language).toBe('markdown'); expect(Result.getOrThrow(parseRecallDocument(source)).details.filters).toEqual([ - { type: 'execution_succeeded', subject: 'inference/post-announcement' }, + { type: 'run_succeeded', subject: 'reasoning/post-announcement' }, ]); }); diff --git a/primitives/recollection/src/document/source-filters.test.ts b/capabilities/recall/src/document/source-filters.test.ts similarity index 91% rename from primitives/recollection/src/document/source-filters.test.ts rename to capabilities/recall/src/document/source-filters.test.ts index b06865d36..3bcfd105e 100644 --- a/primitives/recollection/src/document/source-filters.test.ts +++ b/capabilities/recall/src/document/source-filters.test.ts @@ -1,4 +1,4 @@ -import { issueText } from '@beonauto/specs/document'; +import { issueText } from '@beonauto/definitions/document'; import { Result } from 'effect'; import { describe, expect, it } from 'vitest'; @@ -23,14 +23,14 @@ function filtersOf(source: string): unknown { describe('the events a recall function folds', () => { it('are named by filters of a literal type, and optionally a literal source and subject and data, kept as written', () => { const filters = [ - '{type: execution_succeeded, subject: inference/review-brief}', + '{type: run_succeeded, subject: reasoning/review-brief}', '{type: com.acme.ledger.month-closed, source: /ledger/eu, data: "${ .revenue > 100 }"}', '{type: com.acme.ping, data: {region: eu}}', ]; expect(filtersOf(withEvents(`[${filters.join(', ')}]`))).toEqual( Result.succeed([ - { type: 'execution_succeeded', subject: 'inference/review-brief' }, + { type: 'run_succeeded', subject: 'reasoning/review-brief' }, { type: 'com.acme.ledger.month-closed', source: '/ledger/eu', data: '${ .revenue > 100 }' }, { type: 'com.acme.ping', data: { region: 'eu' } }, ]), @@ -51,9 +51,7 @@ describe('the events a recall function folds', () => { describe('the filters a recall function refuses', () => { it('refuses a filter that is not a mapping, or names no literal type, each with its line', () => { - expect( - issuesIn(withEvents('\n - execution_succeeded\n - {source: /ledger}\n - {type: "${ .x }"}')), - ).toEqual([ + expect(issuesIn(withEvents('\n - run_succeeded\n - {source: /ledger}\n - {type: "${ .x }"}'))).toEqual([ 'Line 5, /source/events/0: A filter is a mapping of the attributes an event must have: type, and optionally source, subject and data', 'Line 6, /source/events/1/type: An event filter matched over the event alone names the type of the events it takes', 'Line 7, /source/events/2/type: type is written out, not computed by an expression, so that it is matched as it is', diff --git a/primitives/recollection/src/document/source-filters.ts b/capabilities/recall/src/document/source-filters.ts similarity index 99% rename from primitives/recollection/src/document/source-filters.ts rename to capabilities/recall/src/document/source-filters.ts index 49afb0a46..0cafaf545 100644 --- a/primitives/recollection/src/document/source-filters.ts +++ b/capabilities/recall/src/document/source-filters.ts @@ -1,4 +1,4 @@ -import { issueAt, type DocumentIssue, type SourceLines } from '@beonauto/specs/document'; +import { issueAt, type DocumentIssue, type SourceLines } from '@beonauto/definitions/document'; import { compileProgram, enclosedBody, diff --git a/primitives/recollection/src/index.ts b/capabilities/recall/src/index.ts similarity index 83% rename from primitives/recollection/src/index.ts rename to capabilities/recall/src/index.ts index 2a70a4a44..3825db5bf 100644 --- a/primitives/recollection/src/index.ts +++ b/capabilities/recall/src/index.ts @@ -1,3 +1,3 @@ export type { RecallFunctionDefinitionDocument } from './document/recall-document.ts'; -export { makeRecallFunctionAdapter, type RecallFunctionAdapterOptions } from './primitive/recall-function.ts'; +export { makeRecallFunctionAdapter, type RecallFunctionAdapterOptions } from './capability/recall-function.ts'; export { recallBounds, recallDefinitionType, recallFolding } from './run/recall-bounds.ts'; diff --git a/primitives/recollection/src/run/answer-endings.test.ts b/capabilities/recall/src/run/answer-endings.test.ts similarity index 94% rename from primitives/recollection/src/run/answer-endings.test.ts rename to capabilities/recall/src/run/answer-endings.test.ts index 9d9285656..895dd1045 100644 --- a/primitives/recollection/src/run/answer-endings.test.ts +++ b/capabilities/recall/src/run/answer-endings.test.ts @@ -7,7 +7,7 @@ import { describe, expect, it } from 'vitest'; import { campaignReviews, recallDocument } from '../testing/campaign-reviews.ts'; import { liveView, poolOf, recallWith, workerTestTimeoutMs } from '../testing/recall-runs.ts'; -const succeeded = 'language: jq\nsource:\n events:\n - type: execution_succeeded'; +const succeeded = 'language: jq\nsource:\n events:\n - type: run_succeeded'; function unworkable(detail: string): Exit.Exit { return Exit.fail(new Conflict({ detail, kind: 'unworkable' })); @@ -24,7 +24,7 @@ const noInput = {}; function ended(source: string, view: Schema.Json = springWithoutReviews, input: Schema.Json = noInput) { const run = recallWith(); run.keep(liveView(view)); - return run.executing(source, input); + return run.running(source, input); } describe('a run whose answer cannot work as written', { timeout: workerTestTimeoutMs }, () => { @@ -98,10 +98,10 @@ describe('a run whose answer reaches a bound', { timeout: workerTestTimeoutMs }, const run = recallWith(scriptedPool([overflowing], poolOf())); run.keep(liveView({})); - expect(await run.executing(answering('.'))).toEqual( + expect(await run.running(answering('.'))).toEqual( unworkable('The answer went deeper than the 64 MiB stack of a run allows'), ); - expect(await run.executing(answering('.'))).toMatchObject(Exit.succeed({ output: {} })); + expect(await run.running(answering('.'))).toMatchObject(Exit.succeed({ output: {} })); }); }); @@ -110,7 +110,7 @@ describe('a run whose answer the server cannot finish', { timeout: workerTestTim const slow = recallWith(poolOf(), 50); slow.keep(liveView({})); - expect(await slow.executing(answering('[range(100000000)] | length'))).toEqual( + expect(await slow.running(answering('[range(100000000)] | length'))).toEqual( Exit.fail( new Unavailable({ detail: 'The answer took longer than the 50 ms a recall function may run, and was stopped' }), ), @@ -148,7 +148,7 @@ describe('a run whose answer the pool stops', { timeout: workerTestTimeoutMs }, const run = recallWith(scriptedPool([outcome], poolOf())); run.keep(liveView({})); - expect(await run.executing(answering('.'))).toEqual(Exit.fail(new Unavailable({ detail }))); + expect(await run.running(answering('.'))).toEqual(Exit.fail(new Unavailable({ detail }))); }); it.each([ @@ -157,7 +157,7 @@ describe('a run whose answer the pool stops', { timeout: workerTestTimeoutMs }, ])('fails, as the server breaks, when the pool answers %j', async (outcome, defect) => { const run = recallWith(scriptedPool([outcome], poolOf())); run.keep(liveView({})); - const exit = await run.executing(answering('.')); + const exit = await run.running(answering('.')); expect(Exit.hasDies(exit)).toBe(true); expect(String(Exit.findDefect(exit))).toContain(defect); diff --git a/primitives/recollection/src/run/answer-endings.ts b/capabilities/recall/src/run/answer-endings.ts similarity index 97% rename from primitives/recollection/src/run/answer-endings.ts rename to capabilities/recall/src/run/answer-endings.ts index 4880b6e04..bde9fc55d 100644 --- a/primitives/recollection/src/run/answer-endings.ts +++ b/capabilities/recall/src/run/answer-endings.ts @@ -1,6 +1,6 @@ +import type { CompiledSchema } from '@beonauto/definitions/document'; +import { issuesDetail } from '@beonauto/definitions/json-schema'; import { Conflict, Unavailable } from '@beonauto/operations'; -import type { CompiledSchema } from '@beonauto/specs/document'; -import { issuesDetail } from '@beonauto/specs/json-schema'; import { jsonBytesOf, lineOf, diff --git a/primitives/recollection/src/run/answer-worker.test.ts b/capabilities/recall/src/run/answer-worker.test.ts similarity index 93% rename from primitives/recollection/src/run/answer-worker.test.ts rename to capabilities/recall/src/run/answer-worker.test.ts index 92058ce6b..5b817db79 100644 --- a/primitives/recollection/src/run/answer-worker.test.ts +++ b/capabilities/recall/src/run/answer-worker.test.ts @@ -5,7 +5,7 @@ import { describe, expect, it } from 'vitest'; import { recallDocument } from '../testing/campaign-reviews.ts'; import { liveView, poolOf, recallWith, workerTestTimeoutMs } from '../testing/recall-runs.ts'; -const succeeded = 'language: jq\nsource:\n events:\n - type: execution_succeeded'; +const succeeded = 'language: jq\nsource:\n events:\n - type: run_succeeded'; function recording(pool: ProgramPool): { readonly pool: ProgramPool; readonly requests: ProgramRequest[] } { const requests: ProgramRequest[] = []; @@ -32,7 +32,7 @@ describe('the worker an answer runs in', { timeout: workerTestTimeoutMs }, () => `${succeeded}\noutput:\n schema: {type: integer}\nanswer: '.spring | length'`, ); - const answered = [await run.executing(withoutASchema), await run.executing(withASchema)]; + const answered = [await run.running(withoutASchema), await run.running(withASchema)]; expect(answered).toMatchObject([Exit.succeed({ output: 2 }), Exit.succeed({ output: 2 })]); expect(requests.map(({ worker, context }) => ({ worker: worker?.pathname.split('/').at(-1), context }))).toEqual([ diff --git a/primitives/recollection/src/run/recall-bounds.ts b/capabilities/recall/src/run/recall-bounds.ts similarity index 86% rename from primitives/recollection/src/run/recall-bounds.ts rename to capabilities/recall/src/run/recall-bounds.ts index 405070798..2d54b3892 100644 --- a/primitives/recollection/src/run/recall-bounds.ts +++ b/capabilities/recall/src/run/recall-bounds.ts @@ -1,5 +1,5 @@ -import { mostResultBytes } from '@beonauto/specs'; -import { checkedWorker } from '@beonauto/specs/json-schema'; +import { mostResultBytes } from '@beonauto/definitions'; +import { checkedWorker } from '@beonauto/definitions/json-schema'; import { liftedLimits, mostEvaluationDepth, mostValueDepth, type ProgramLimits } from '@beonauto/workflow-engine/dsl'; import type { FoldingSettings } from '@beonauto/workflow-host'; @@ -38,4 +38,4 @@ export const recallFolding: FoldingSettings = { worker: checkedWorker, }; -export const recallDefinitionType = 'recollection'; +export const recallDefinitionType = 'recall'; diff --git a/primitives/recollection/src/run/recall-run.test.ts b/capabilities/recall/src/run/recall-run.test.ts similarity index 78% rename from primitives/recollection/src/run/recall-run.test.ts rename to capabilities/recall/src/run/recall-run.test.ts index f2aac6fdf..6884ec616 100644 --- a/primitives/recollection/src/run/recall-run.test.ts +++ b/capabilities/recall/src/run/recall-run.test.ts @@ -8,16 +8,16 @@ import { liveView, recallWith, workerTestTimeoutMs } from '../testing/recall-run const reviews = { spring: [ - { at: '2026-10-06T10:00:00.000Z', verdict: 'approve', run: '/executions/a' }, - { at: '2026-10-06T10:00:03.000Z', verdict: 'reject', run: '/executions/b' }, + { at: '2026-10-06T10:00:00.000Z', verdict: 'approve', run: '/runs/a' }, + { at: '2026-10-06T10:00:03.000Z', verdict: 'reject', run: '/runs/b' }, ], - unknown: [{ at: '2026-10-06T10:00:01.000Z', verdict: 'none', run: '/executions/c' }], + unknown: [{ at: '2026-10-06T10:00:01.000Z', verdict: 'none', run: '/runs/c' }], }; const waitsToBuild: unknown = expect.stringContaining('waits to build'); const stall: ViewStall = { - event: { id: '5d0e9f6a-1b2c-5d3e-8f4a-6b7c8d9e0f1b', type: 'execution_succeeded', time: '2026-10-06T10:00:01.000Z' }, + event: { id: '5d0e9f6a-1b2c-5d3e-8f4a-6b7c8d9e0f1b', type: 'run_succeeded', time: '2026-10-06T10:00:01.000Z' }, kind: 'raised', message: 'cannot take bad', line: 31, @@ -28,15 +28,15 @@ describe('a run of a recall function', { timeout: workerTestTimeoutMs }, () => { const run = recallWith(); run.keep(liveView(reviews)); - const answered = await run.executing(campaignReviews, { campaign: 'spring', last: 1 }); + const answered = await run.running(campaignReviews, { campaign: 'spring', last: 1 }); expect(answered).toMatchObject( Exit.succeed({ - output: [{ at: '2026-10-06T10:00:03.000Z', verdict: 'reject', run: '/executions/b' }], + output: [{ at: '2026-10-06T10:00:03.000Z', verdict: 'reject', run: '/runs/b' }], record: { language: 'jq', input_bytes: 30, - output_bytes: 76, + output_bytes: 70, view: { version: 1, checkpoint: 'YnJhaW4vYWNtZS9hbHBoYS8sNDI', @@ -53,9 +53,9 @@ describe('a run of a recall function', { timeout: workerTestTimeoutMs }, () => { const run = recallWith(); run.keep(liveView({ count: 3 })); - const answered = await run.executing(recallDocument('. + 1')); + const answered = await run.running(recallDocument('. + 1')); run.keep(liveView(null, { folded: 0, lastEvent: null })); - const initial = await run.executing(recallDocument('. + 1')); + const initial = await run.running(recallDocument('. + 1')); expect(answered).toMatchObject(Exit.succeed({ output: { count: 3 }, record: { work: 0, output_bytes: 11 } })); expect(initial).toMatchObject(Exit.succeed({ output: null, record: { view: { folded: 0, last_event: null } } })); @@ -68,7 +68,7 @@ describe('the input of a run of a recall function', { timeout: workerTestTimeout run.keep(liveView(reviews)); const deep = Array.from({ length: 600 }).reduce((inner) => [inner], null); - expect(await run.executing(campaignReviews, { last: 1 })).toEqual( + expect(await run.running(campaignReviews, { last: 1 })).toEqual( Exit.fail( new InvalidInput({ detail: 'The input does not match the recall function’s input schema', @@ -76,7 +76,7 @@ describe('the input of a run of a recall function', { timeout: workerTestTimeout }), ), ); - expect(await run.executing(campaignReviews, deep)).toMatchObject( + expect(await run.running(campaignReviews, deep)).toMatchObject( Exit.fail({ detail: 'The input nests more than the 512 levels a recall function takes' }), ); }); @@ -85,14 +85,14 @@ describe('the input of a run of a recall function', { timeout: workerTestTimeout describe('a run of a recall function whose view is not ready', { timeout: workerTestTimeoutMs }, () => { it('is unavailable, rebuilding, while its view is missing, of an older version, waiting or being built', async () => { const run = recallWith(); - const notBegun = await run.executing(campaignReviews, { campaign: 'spring' }); + const notBegun = await run.running(campaignReviews, { campaign: 'spring' }); run.keep(liveView(reviews, { version: 0 })); - const older = await run.executing(campaignReviews, { campaign: 'spring' }); + const older = await run.running(campaignReviews, { campaign: 'spring' }); run.keep(liveView(reviews, { phase: 'waiting' })); - const waiting = await run.executing(campaignReviews, { campaign: 'spring' }); + const waiting = await run.running(campaignReviews, { campaign: 'spring' }); run.keep(liveView(reviews, { phase: 'rebuilding', folded: 40 })); run.newest('2026-10-06T10:00:35.000Z'); - const building = await run.executing(campaignReviews, { campaign: 'spring' }); + const building = await run.running(campaignReviews, { campaign: 'spring' }); expect(notBegun).toMatchObject(Exit.fail({ kind: 'rebuilding' })); expect(older).toEqual(notBegun); @@ -112,11 +112,11 @@ describe('a run of a recall function whose view is not ready', { timeout: worker const run = recallWith(); run.keep(liveView(reviews, { phase: 'stalled', stall })); - expect(await run.executing(campaignReviews, { campaign: 'spring' })).toEqual( + expect(await run.running(campaignReviews, { campaign: 'spring' })).toEqual( Exit.fail( new Conflict({ detail: - "The view of the recall function “reviews” stopped at the execution_succeeded event of 2026-10-06T10:00:01.000Z: its fold raised an error on line 31. Save a corrected version to build the view again from the brain's history", + "The view of the recall function “reviews” stopped at the run_succeeded event of 2026-10-06T10:00:01.000Z: its fold raised an error on line 31. Save a corrected version to build the view again from the brain's history", kind: 'stalled', }), ), diff --git a/primitives/recollection/src/run/recall-run.ts b/capabilities/recall/src/run/recall-run.ts similarity index 61% rename from primitives/recollection/src/run/recall-run.ts rename to capabilities/recall/src/run/recall-run.ts index 3314e83c2..223fb8530 100644 --- a/primitives/recollection/src/run/recall-run.ts +++ b/capabilities/recall/src/run/recall-run.ts @@ -1,5 +1,5 @@ +import type { CapabilityAnswer, CapabilityRejection, RunContext } from '@beonauto/definitions'; import { Conflict, Unavailable, type InvalidInput } from '@beonauto/operations'; -import type { Executed, PrimitiveRejection, RunContext } from '@beonauto/specs'; import { jsonBytesOf } from '@beonauto/workflow-engine/dsl'; import type { KeptView, ViewsPort } from '@beonauto/workflow-host'; import { Effect, type Schema } from 'effect'; @@ -13,19 +13,20 @@ import { rebuildingDetail, stalledDetail } from './view-words.ts'; export type RecallRun = ( document: RecallFunctionDefinitionDocument, input: Schema.Json, - execution: RunContext, -) => Effect.Effect; + run: RunContext, +) => Effect.Effect; -function rebuilding(views: ViewsPort, { org, brain, spec }: RunContext, kept: KeptView | undefined) { - return views - .newestRecordAt({ org, brain }) - .pipe( - Effect.flatMap((newestAt) => - Effect.fail( - new Unavailable({ detail: rebuildingDetail(spec.name, spec.version, kept, newestAt), kind: 'rebuilding' }), - ), +function rebuilding(views: ViewsPort, { org, brain, definition }: RunContext, kept: KeptView | undefined) { + return views.newestRecordAt({ org, brain }).pipe( + Effect.flatMap((newestAt) => + Effect.fail( + new Unavailable({ + detail: rebuildingDetail(definition.name, definition.version, kept, newestAt), + kind: 'rebuilding', + }), ), - ); + ), + ); } function recordOf(kept: KeptView, answered: Answered, input: Schema.Json): Schema.JsonObject { @@ -50,7 +51,7 @@ function answeredFrom( document: RecallFunctionDefinitionDocument, kept: KeptView, input: Schema.Json, -): Effect.Effect { +): Effect.Effect { return answerOf(options, document, kept.view, input).pipe( Effect.map((answered) => ({ output: answered.output, record: recordOf(kept, answered, input) })), ); @@ -59,27 +60,27 @@ function answeredFrom( function ranOn( options: RecallRunOptions, document: RecallFunctionDefinitionDocument, - execution: RunContext, + run: RunContext, input: Schema.Json, ) { - return (kept: KeptView | undefined): Effect.Effect => { - if (kept?.version !== execution.spec.version || kept.phase === 'rebuilding' || kept.phase === 'waiting') { - return rebuilding(options.views, execution, kept); + return (kept: KeptView | undefined): Effect.Effect => { + if (kept?.version !== run.definition.version || kept.phase === 'rebuilding' || kept.phase === 'waiting') { + return rebuilding(options.views, run, kept); } if (kept.stall !== undefined) { - return Effect.fail(new Conflict({ detail: stalledDetail(execution.spec.name, kept.stall), kind: 'stalled' })); + return Effect.fail(new Conflict({ detail: stalledDetail(run.definition.name, kept.stall), kind: 'stalled' })); } return answeredFrom(options, document, kept, input); }; } export function recallRun(options: RecallRunOptions): RecallRun { - return (document, input, execution) => + return (document, input, run) => preparedInput(input, document.input).pipe( - Effect.flatMap((admitted): Effect.Effect => + Effect.flatMap((admitted): Effect.Effect => options.views - .viewOf({ org: execution.org, brain: execution.brain }, execution.spec.name) - .pipe(Effect.flatMap(ranOn(options, document, execution, admitted))), + .viewOf({ org: run.org, brain: run.brain }, run.definition.name) + .pipe(Effect.flatMap(ranOn(options, document, run, admitted))), ), ); } diff --git a/primitives/recollection/src/run/run-input.ts b/capabilities/recall/src/run/run-input.ts similarity index 100% rename from primitives/recollection/src/run/run-input.ts rename to capabilities/recall/src/run/run-input.ts diff --git a/primitives/recollection/src/run/view-answer.ts b/capabilities/recall/src/run/view-answer.ts similarity index 96% rename from primitives/recollection/src/run/view-answer.ts rename to capabilities/recall/src/run/view-answer.ts index 43407bd67..513acae84 100644 --- a/primitives/recollection/src/run/view-answer.ts +++ b/capabilities/recall/src/run/view-answer.ts @@ -1,5 +1,5 @@ +import { checkedWorker } from '@beonauto/definitions/json-schema'; import type { Conflict, Unavailable } from '@beonauto/operations'; -import { checkedWorker } from '@beonauto/specs/json-schema'; import type { ProgramPool, ProgramRequest } from '@beonauto/workflow-engine/dsl'; import type { ViewsPort } from '@beonauto/workflow-host'; import { Effect, type Schema } from 'effect'; diff --git a/primitives/recollection/src/run/view-folding.test.ts b/capabilities/recall/src/run/view-folding.test.ts similarity index 85% rename from primitives/recollection/src/run/view-folding.test.ts rename to capabilities/recall/src/run/view-folding.test.ts index 6c7e0a23b..e1aec40d7 100644 --- a/primitives/recollection/src/run/view-folding.test.ts +++ b/capabilities/recall/src/run/view-folding.test.ts @@ -12,11 +12,11 @@ function review(at: string, output: Schema.Json): Schema.JsonObject { return { specversion: '1.0', id: `run-${at}`, - source: `/executions/${at}`, - type: 'execution_succeeded', - subject: 'inference/review-brief', + source: `/runs/${at}`, + type: 'run_succeeded', + subject: 'reasoning/review-brief', time: at, - data: { primitive: 'inference', name: 'review-brief', version: 1, output }, + data: { type: 'reasoning', name: 'review-brief', version: 1, output }, }; } @@ -58,10 +58,10 @@ describe('the checked worker, folding the views of recall functions', { timeout: { folded: 3, view: { - unknown: [{ at: '2026-10-06T10:00:01.000Z', verdict: 'none', run: '/executions/2026-10-06T10:00:01.000Z' }], + unknown: [{ at: '2026-10-06T10:00:01.000Z', verdict: 'none', run: '/runs/2026-10-06T10:00:01.000Z' }], spring: [ - { at: '2026-10-06T10:00:00.000Z', verdict: 'approve', run: '/executions/2026-10-06T10:00:00.000Z' }, - { at: '2026-10-06T10:00:02.000Z', verdict: 'reject', run: '/executions/2026-10-06T10:00:02.000Z' }, + { at: '2026-10-06T10:00:00.000Z', verdict: 'approve', run: '/runs/2026-10-06T10:00:00.000Z' }, + { at: '2026-10-06T10:00:02.000Z', verdict: 'reject', run: '/runs/2026-10-06T10:00:02.000Z' }, ], }, }, @@ -71,7 +71,7 @@ describe('the checked worker, folding the views of recall functions', { timeout: it('stops a view at the event after which its schema refuses it, naming where', async () => { const front = - 'language: jq\nsource:\n events:\n - type: execution_succeeded\nview:\n initial: {}\n schema: {additionalProperties: {type: integer}}'; + 'language: jq\nsource:\n events:\n - type: run_succeeded\nview:\n initial: {}\n schema: {additionalProperties: {type: integer}}'; const details = detailsOf(recallDocument('.[$event.time] = $event.data.output', front)); const events = [review('2026-10-06T10:00:00.000Z', 1), review('2026-10-06T10:00:01.000Z', 'two')]; diff --git a/primitives/recollection/src/run/view-words.test.ts b/capabilities/recall/src/run/view-words.test.ts similarity index 95% rename from primitives/recollection/src/run/view-words.test.ts rename to capabilities/recall/src/run/view-words.test.ts index 6102d650e..f0ea9bf04 100644 --- a/primitives/recollection/src/run/view-words.test.ts +++ b/capabilities/recall/src/run/view-words.test.ts @@ -6,7 +6,7 @@ import { lagInWords, lagOf, rebuildingDetail, stalledDetail } from './view-words const stalledEvent = { id: '5d0e9f6a-1b2c-5d3e-8f4a-6b7c8d9e0f1b', - type: 'execution_succeeded', + type: 'run_succeeded', time: '2026-10-06T10:00:01.000Z', }; @@ -69,7 +69,7 @@ describe('the detail of a run whose view stalled', () => { const detail = stalledDetail('reviews', { event: stalledEvent, kind, message: 'cannot take bad', line: null }); expect(detail).toBe( - `The view of the recall function “reviews” stopped at the execution_succeeded event of 2026-10-06T10:00:01.000Z: ${words}. Save a corrected version to build the view again from the brain's history`, + `The view of the recall function “reviews” stopped at the run_succeeded event of 2026-10-06T10:00:01.000Z: ${words}. Save a corrected version to build the view again from the brain's history`, ); }, ); diff --git a/primitives/recollection/src/run/view-words.ts b/capabilities/recall/src/run/view-words.ts similarity index 100% rename from primitives/recollection/src/run/view-words.ts rename to capabilities/recall/src/run/view-words.ts diff --git a/primitives/recollection/src/testing/campaign-reviews.ts b/capabilities/recall/src/testing/campaign-reviews.ts similarity index 91% rename from primitives/recollection/src/testing/campaign-reviews.ts rename to capabilities/recall/src/testing/campaign-reviews.ts index e237eaa63..d54512891 100644 --- a/primitives/recollection/src/testing/campaign-reviews.ts +++ b/capabilities/recall/src/testing/campaign-reviews.ts @@ -4,8 +4,8 @@ export const campaignReviews = [ 'language: jq', 'source:', ' events:', - ' - type: execution_succeeded', - ' subject: inference/review-brief', + ' - type: run_succeeded', + ' subject: reasoning/review-brief', 'view:', ' initial: {}', ' schema:', @@ -46,7 +46,7 @@ export const reviewBrief = [ export function recallDocument( fold: string, - frontMatter = 'language: jq\nsource:\n events:\n - type: execution_succeeded', + frontMatter = 'language: jq\nsource:\n events:\n - type: run_succeeded', ): string { return `---\n${frontMatter}\n---\n${fold}`; } diff --git a/primitives/recollection/src/testing/index.ts b/capabilities/recall/src/testing/index.ts similarity index 100% rename from primitives/recollection/src/testing/index.ts rename to capabilities/recall/src/testing/index.ts diff --git a/primitives/recollection/src/testing/kept-views.ts b/capabilities/recall/src/testing/kept-views.ts similarity index 100% rename from primitives/recollection/src/testing/kept-views.ts rename to capabilities/recall/src/testing/kept-views.ts diff --git a/primitives/recollection/src/testing/recall-runs.ts b/capabilities/recall/src/testing/recall-runs.ts similarity index 77% rename from primitives/recollection/src/testing/recall-runs.ts rename to capabilities/recall/src/testing/recall-runs.ts index 920ee4113..3f15a3bb3 100644 --- a/primitives/recollection/src/testing/recall-runs.ts +++ b/capabilities/recall/src/testing/recall-runs.ts @@ -1,12 +1,12 @@ +import type { CapabilityAnswer, PreparedDefinition, Capability, RunContext } from '@beonauto/definitions'; +import { noLongestRuns, recordingJournal } from '@beonauto/definitions/testing'; import { allPermissions, type Conflict, type InvalidInput, type Unavailable } from '@beonauto/operations'; -import type { Executed, PreparedDefinition, Primitive, RunContext } from '@beonauto/specs'; -import { noLongestRuns, recordingJournal } from '@beonauto/specs/testing'; import { programPool, type PoolSettings, type ProgramPool } from '@beonauto/workflow-engine/dsl'; import type { KeptView } from '@beonauto/workflow-host'; import { Effect, type Exit, type Schema } from 'effect'; import { afterEach } from 'vitest'; -import { makeRecallFunctionAdapter } from '../primitive/recall-function.ts'; +import { makeRecallFunctionAdapter } from '../capability/recall-function.ts'; import { recallBounds } from '../run/recall-bounds.ts'; import { keptViews, type KeptViews } from './kept-views.ts'; @@ -17,7 +17,7 @@ const reviewsRun: RunContext = { org: 'acme', brain: 'alpha', caller: { id: 'acme-admin', org: 'acme', permissions: allPermissions, brains: '*' }, - spec: { name: 'reviews', version: 1 }, + definition: { name: 'reviews', version: 1 }, journal: recordingJournal(), lineage: { startId: '5d0e9f6a-1b2c-5d3e-8f4a-6b7c8d9e0f1a', correlationId: '0199a3c4-7d2e-7c1a-9b3f-2f1e0d9c8b7a' }, depth: 0, @@ -25,12 +25,12 @@ const reviewsRun: RunContext = { longestRunOf: noLongestRuns, }; -type Execution = Exit.Exit; +type Run = Exit.Exit; export interface RecallRuns extends KeptViews { - readonly primitive: Primitive; + readonly capability: Capability; readonly prepared: (source: string) => PreparedDefinition; - readonly executing: (source: string, input?: Schema.Json) => Promise; + readonly running: (source: string, input?: Schema.Json) => Promise; } const pools: ProgramPool[] = []; @@ -72,16 +72,16 @@ export function liveView(view: Schema.Json, more: Partial = {}): KeptV export function recallWith(pool: ProgramPool = poolOf(), deadlineMs?: number): RecallRuns { const views = keptViews(); - const primitive = makeRecallFunctionAdapter({ + const capability = makeRecallFunctionAdapter({ pool, views: views.views, ...(deadlineMs === undefined ? {} : { deadlineMs }), }); - const prepared = (source: string): PreparedDefinition => Effect.runSync(primitive.prepare(source)); + const prepared = (source: string): PreparedDefinition => Effect.runSync(capability.prepare(source)); return { ...views, - primitive, + capability, prepared, - executing: (source, input = {}) => Effect.runPromiseExit(prepared(source).execute(input, reviewsRun)), + running: (source, input = {}) => Effect.runPromiseExit(prepared(source).run(input, reviewsRun)), }; } diff --git a/primitives/recollection/tsconfig.json b/capabilities/recall/tsconfig.json similarity index 100% rename from primitives/recollection/tsconfig.json rename to capabilities/recall/tsconfig.json diff --git a/packages/specs/vitest.config.ts b/capabilities/recall/vitest.config.ts similarity index 89% rename from packages/specs/vitest.config.ts rename to capabilities/recall/vitest.config.ts index b10007530..ba51a70fb 100644 --- a/packages/specs/vitest.config.ts +++ b/capabilities/recall/vitest.config.ts @@ -2,4 +2,4 @@ import { defineConfig, mergeConfig } from 'vitest/config'; import { sharedConfig } from '../../vitest.shared.ts'; -export default mergeConfig(sharedConfig, defineConfig({ test: { name: 'specs' } })); +export default mergeConfig(sharedConfig, defineConfig({ test: { name: 'recall' } })); diff --git a/commitlint.config.ts b/commitlint.config.ts index 56b938d9d..d243e4e95 100644 --- a/commitlint.config.ts +++ b/commitlint.config.ts @@ -5,7 +5,7 @@ import type { UserConfig } from '@commitlint/types'; const workspaceScopes = [ ...new Set( - globSync(['{packages,primitives}/*/package.json', '{packages,primitives}/*/README.md'], { + globSync(['{packages,capabilities}/*/package.json', '{packages,capabilities}/*/README.md'], { cwd: import.meta.dirname, }).map((file) => basename(dirname(file))), ), diff --git a/docs/assets/brain-functions.json b/docs/assets/brain-functions.json index e175ce142..0190a9212 100644 --- a/docs/assets/brain-functions.json +++ b/docs/assets/brain-functions.json @@ -1,37 +1,37 @@ { - "schema": 1, - "source": "@beonauto/specs", + "schema": 2, + "source": "@beonauto/definitions", "functions": [ { - "kind": "reason", + "type": "reasoning", "label": "Reasoning", "singular": "reasoning function", "plural": "reasoning functions", "description": "Use a prompt, skills, and tools to interpret information or produce a response." }, { - "kind": "interact", + "type": "interaction", "label": "Interaction", "singular": "interaction function", "plural": "interaction functions", "description": "Exchange information with people or systems." }, { - "kind": "predict", + "type": "prediction", "label": "Prediction", "singular": "prediction function", "plural": "prediction functions", "description": "Create and use an ML model to make predictions." }, { - "kind": "recall", + "type": "recall", "label": "Recall", "singular": "recall function", "plural": "recall functions", "description": "Answer from what the brain keeps of its own history." }, { - "kind": "compute", + "type": "computation", "label": "Computation", "singular": "computation function", "plural": "computation functions", diff --git a/docs/concepts/brains.md b/docs/concepts/brains.md index 9e66aa503..a0a9be0ba 100644 --- a/docs/concepts/brains.md +++ b/docs/concepts/brains.md @@ -19,7 +19,7 @@ For a campaign review, the method might require a specific audience, a clear off | Function | A reusable operation with defined inputs, outputs and behavior | Review a brief against those criteria | | Workflow | A definition coordinating steps and their dependencies | Review the brief, then request approval | | Step | A use of a function, another workflow or control operation | Run the brief review | -| Run | One execution against particular inputs | Review of the autumn campaign brief | +| Run | A definition carried out once against particular inputs | Review of the autumn campaign brief | | Result | The output produced by that run | A recommendation and missing information | The [function types](functions.md) describe different kinds of work. [Workflows](workflows.md) coordinate those functions. A workflow that another workflow calls is a subworkflow, and the workflow that calls it waits for its run. diff --git a/docs/concepts/functions.md b/docs/concepts/functions.md index 14a3ed9c7..128b5257c 100644 --- a/docs/concepts/functions.md +++ b/docs/concepts/functions.md @@ -18,7 +18,7 @@ The source-available runtime is in early development and is not ready for produc Workflows are available and coordinate the functions above. They are not another function type. -Every runtime runs workflows itself, with nothing more to set up: a connection's tools include `send_execution_event`, and `create_spec` accepts the primitive `orchestration`. A self-hosted runtime runs interaction, computation and recall functions too: its `create_spec` accepts the primitives `interaction`, `computation` and `recollection`, and its tools include `list_interactions` and `answer_interaction`; Auto Cloud does not offer them yet. +Every runtime runs workflows itself, with nothing more to set up: a connection's tools include `send_run_event`, and `create_definition` accepts the type `workflow`. A self-hosted runtime runs interaction, computation and recall functions too: its `create_definition` accepts the types `interaction`, `computation` and `recall`, and its tools include `list_interactions` and `answer_interaction`; Auto Cloud does not offer them yet. Dream is coming soon. It is an optional process using history and functions, not a sixth function type. API details should match the runtime version in use. @@ -78,7 +78,7 @@ A skill is reusable task guidance and associated resources. A tool is a calling A reasoning function can call the tools of MCP servers that the runtime's operator configures. The operator binds each server to an org, and optionally to some of its brains, and can narrow which of its tools functions may name. A function lists the tools it may use, such as `graph/search`, or `graph/*` for every tool of a server that the operator allows. During a run, the model can request one of those tools, receive its result and continue before it answers, within bounds on the number of calls, the size of their results and the time the run takes. Each call appears in the run's history as it happens. -A tool may change something outside the brain. A run that called tools and did not succeed is therefore not run again under the same execution id; start a new run once you have checked what its history shows it called. When any MCP server is configured, an agent connected to Auto sees `execute_spec` marked as possibly destructive, so it can ask before running a function. +A tool may change something outside the brain. A run that called tools and did not succeed is therefore not run again under the same run id; start a new run once you have checked what its history shows it called. When any MCP server is configured, an agent connected to Auto sees `run_definition` marked as possibly destructive, so it can ask before running a function. To learn what a tool answers before a function names it, an agent tests it with `test_tool_call` instead of making a function to look: the brain calls the tool once, as a run would, and answers what the run's model would see. Only a tool its server marks read-only, or that the operator marks testable on its entry, can be tested, and each test is recorded in the brain's history, never as a run. diff --git a/docs/concepts/history-and-dream.md b/docs/concepts/history-and-dream.md index 2f6459c33..6c06b274a 100644 --- a/docs/concepts/history-and-dream.md +++ b/docs/concepts/history-and-dream.md @@ -14,11 +14,11 @@ A self-hosted runtime runs recall functions over a brain's own history. A recall You can also read a brain's recorded history in three ways, over HTTP and MCP: -- `list_executions` lists the runs of a brain, newest first, and can keep only the runs of one function or in one status. -- `get_execution_history` reads the history of one run: each start, what the run did, such as the tool calls of a reasoning function or the inputs and steps of a workflow, and how it ended, each with when it happened and a plain-language summary. +- `list_runs` lists the runs of a brain, newest first, and can keep only the runs of one function or in one status. +- `get_run_history` reads the history of one run: each start, what the run did, such as the tool calls of a reasoning function or the inputs and steps of a workflow, and how it ended, each with when it happened and a plain-language summary. - `list_brain_events` follows everything recorded in a brain, such as definitions created, updated and retired and runs started and ended, and can keep one type of event, what was recorded since a time, or everything one run and the runs it started recorded. -These reads page through long histories and keep working after a brain is retired. Events show the sizes of inputs and outputs rather than the values; `get_execution` returns a run's result in full. A brain's own creation and retirement belong to its organization and are not among its events. A workflow run's history also shows, for each input the run took, the steps that moved and how they ended, and every event names the event that led to it. See [Run history and brain events](../reference/http.md#run-history-and-brain-events). +These reads page through long histories and keep working after a brain is retired. Events show the sizes of inputs and outputs rather than the values; `get_run` returns a run's result in full. A brain's own creation and retirement belong to its organization and are not among its events. A workflow run's history also shows, for each input the run took, the steps that moved and how they ended, and every event names the event that led to it. See [Run history and brain events](../reference/http.md#run-history-and-brain-events). `get_brain_analytics` sums up the runs of a brain over the last 7, 14 or 30 days, or between two days: how many ended and how, the tokens their models used, a rejected run's included, and how long they took, for each day and each function or workflow. It reads a projection the runtime keeps as each run is recorded. See [Analytics](../reference/http.md#analytics). diff --git a/docs/concepts/terminology.md b/docs/concepts/terminology.md index b9985cbf9..def91ee1b 100644 --- a/docs/concepts/terminology.md +++ b/docs/concepts/terminology.md @@ -2,7 +2,7 @@ This is the shared vocabulary for Auto's brain offering, its documentation and the code. It names the supported concepts and the capabilities being developed; [Functions and availability](functions.md#availability) records what the runtime currently implements. -The brain is the system. Workflows coordinate the work. Functions perform it. Assets support it. Runs are its executions. +The brain is the system. Workflows coordinate the work. Functions perform it. Assets support it. Runs are each time it is done. ## Capabilities and resources @@ -46,8 +46,8 @@ Classify a function by its responsibility. Calling an API does not turn a recall | Step | One use of a function, another workflow or a control operation within a workflow. | | Trigger | A configured condition that starts a workflow. | | Version | An identified revision of a definition or predictive model. | -| Run | One execution of a workflow or function against particular inputs. | -| Step run | Execution of one workflow step. | +| Run | A workflow or function carried out once against particular inputs. | +| Step run | One workflow step carried out once. | | Attempt | One try at a step's work, or at a run started again under its id; a retry is another attempt. | | Result | The output produced by a run. | @@ -55,7 +55,7 @@ A method does not require a `Method` resource. A shared function remains one def Definitions, versions, runs and results are separate concepts. The runtime versions definitions and records the version each run uses. It does not currently let a caller select an arbitrary historical definition version to run. A workflow can call the functions of its brain and other workflows; a workflow called this way is a subworkflow. A run of a workflow finishes later, so the call waits for it, and a run can be cancelled, which ends it as cancelled. -The configured trigger types are **Schedule trigger** and **Event trigger**; a workflow's schedule may name an event trigger and schedule triggers of both kinds, a cron schedule and an every schedule, each kept and matched on its own. Manual execution is a **Run** action. Sending an approval or other input to a waiting run answers that run; it does not start a new one. Existing workflow timers and event waits are control steps, not configured triggers. +The configured trigger types are **Schedule trigger** and **Event trigger**; a workflow's schedule may name an event trigger and schedule triggers of both kinds, a cron schedule and an every schedule, each kept and matched on its own. Starting a workflow by hand is a **Run** action. Sending an approval or other input to a waiting run answers that run; it does not start a new one. Existing workflow timers and event waits are control steps, not configured triggers. See [Workflows and runs](workflows.md) for the implemented version, retry, waiting and event behavior. diff --git a/docs/concepts/workflows.md b/docs/concepts/workflows.md index 710648856..3db290b61 100644 --- a/docs/concepts/workflows.md +++ b/docs/concepts/workflows.md @@ -16,23 +16,23 @@ A budget-review workflow could assess the options with a reasoning function, the Keep the saved work separate from what happens when it executes: -| Term | Meaning | -| ---------- | ---------------------------------------------------- | -| Definition | The reusable function or workflow someone creates | -| Version | A particular revision of that definition | -| Run | One execution against particular inputs | -| Result | The output of that run | -| Step | One task of a workflow, such as a call to a function | -| Step run | Execution of a particular step within a workflow run | -| Attempt | One try at a step's work; a retry is another attempt | +| Term | Meaning | +| ---------- | ------------------------------------------------------- | +| Definition | The reusable function or workflow someone creates | +| Version | A particular revision of that definition | +| Run | A definition carried out once against particular inputs | +| Result | The output of that run | +| Step | One task of a workflow, such as a call to a function | +| Step run | One step carried out once within a workflow run | +| Attempt | One try at a step's work; a retry is another attempt | -Saving a workflow creates its definition at version 1, and each change to its document adds a version. A run uses the active latest version when it starts and keeps that version until it ends; the run records it as `spec_version`. A step that calls a function runs the function's active latest version at the time of the call. +Saving a workflow creates its definition at version 1, and each change to its document adds a version. A run uses the active latest version when it starts and keeps that version until it ends; the run records it as `definition_version`. A step that calls a function runs the function's active latest version at the time of the call. -Each call to a function starts a run of that function, recorded under its own execution id. A retry is a new attempt at the step: it starts another run of the function, not a new function. When the runtime resumes a step after an interruption, the step keeps its execution id, so a function run that already has a final result is not run again. A reasoning function's run is not run again under its id either when it had recorded a tool call and did not succeed, or when its function names tools and the run had started without ending, since its tools may have changed something. The step then fails with an error of the type `https://on.auto/problems/tools_called`, which a retry of runtime or communication errors does not catch; a retry that catches every error, or names that type, calls the function again under a new execution id, and so calls its tools again. The run's history shows what the function called. +Each call to a function starts a run of that function, recorded under its own run id. A retry is a new attempt at the step: it starts another run of the function, not a new function. When the runtime resumes a step after an interruption, the step keeps its run id, so a function run that already has a final result is not run again. A reasoning function's run is not run again under its id either when it had recorded a tool call and did not succeed, or when its function names tools and the run had started without ending, since its tools may have changed something. The step then fails with an error of the type `https://on.auto/problems/tools_called`, which a retry of runtime or communication errors does not catch; a retry that catches every error, or names that type, calls the function again under a new run id, and so calls its tools again. The run's history shows what the function called. ## Starting a run -A run starts when a caller executes the workflow with `execute_spec`, giving its input. The call answers at once with the run's execution id and the status `started`; the run then carries on by itself. Executing again with the same execution id and input returns that run as it stands rather than starting another. A workflow runs once for each execution id: executing again with the id of a run that ended without a result, `rejected` as `unavailable` or `failed`, is refused with `conflict`, so start a new run under a new execution id. +A run starts when a caller runs the workflow with `run_definition`, giving its input. The call answers at once with the `run_id` of the run and the status `started`; the run then carries on by itself. Executing again with the same run id and input returns that run as it stands rather than starting another. A workflow runs once for each run id: executing again with the id of a run that ended without a result, `rejected` as `unavailable` or `failed`, is refused with `conflict`, so start a new run under a new run id. A run also starts when a trigger of the workflow fires, as the next section describes. @@ -57,7 +57,7 @@ A workflow can also announce something with an `emit` step, which records an eve A run waits while a step's function runs, while a timer or a retry delay passes, and while a step listens for an event. It shows the status `started` throughout. -An event answers a waiting run. A caller sends it with `send_execution_event`, naming the run's execution id and giving the event a `type` and, usually, `data`. The event belongs to that run: it does not start another one. An event that arrives before the run listens for it is kept until a step takes it, and an event repeated with the same id is taken once, so a sender can retry safely. A run that has ended refuses events. +An event answers a waiting run. A caller sends it with `send_run_event`, naming the `run_id` of the run and giving the event a `type` and, usually, `data`. The event belongs to that run: it does not start another one. An event that arrives before the run listens for it is kept until a step takes it, and an event repeated with the same id is taken once, so a sender can retry safely. A run that has ended refuses events. A step that listens for an event whose type it names also hears the events of the whole brain while it listens: one published with `publish_event`, emitted by another workflow, or one of the brain's facts. The run checks the event against its own filter, so it can take only the event about the case it handles. An event published before the step listened, or after it stopped, does not reach it; send the event to the run itself when it must not be missed. @@ -79,19 +79,19 @@ A rejection's reason is `invalid_input` when the error says the input or the doc ## Calling another workflow -A workflow calls another workflow with `execute_spec`, as it calls any function of its brain; the workflow it calls is a subworkflow. A run of a reasoning, computation or recall function finishes within its call. A run of a workflow, like a run of an interaction function, finishes later, so the calling workflow waits for that run, holding nothing of the server while it waits, and continues with its output, or catches its rejection like any error. A server that restarts meanwhile keeps the wait, and the subworkflow's ending answers it on whichever server that ending is recorded. +A workflow calls another workflow with `run_definition`, as it calls any function of its brain; the workflow it calls is a subworkflow. A run of a reasoning, computation or recall function finishes within its call. A run of a workflow, like a run of an interaction function, finishes later, so the calling workflow waits for that run, holding nothing of the server while it waits, and continues with its output, or catches its rejection like any error. A server that restarts meanwhile keeps the wait, and the subworkflow's ending answers it on whichever server that ending is recorded. A call waits as long as the function it names may take, plus a minute; past that, it raises a `timeout` error and the run it waited for is cancelled. Workflows that call workflows reach 8 calls deep, and the runs under one workflow wait for at most 1,000 calls at once. ## Cancelling a run -`cancel_execution` cancels a workflow run that has not ended, with a reason the run keeps. The request is recorded at once, so it can reach any server and outlasts a restart; the run then stops, cancels each run it waits for, and ends `rejected` as `cancelled` with the kind `requested`. `cancel_execution` also cancels the run of an interaction function whose request waits, which ends `rejected` as `cancelled`. A run of a reasoning, computation or recall function ends within its call, and cannot be cancelled from outside it. +`cancel_run` cancels a workflow run that has not ended, with a reason the run keeps. The request is recorded at once, so it can reach any server and outlasts a restart; the run then stops, cancels each run it waits for, and ends `rejected` as `cancelled` with the kind `requested`. `cancel_run` also cancels the run of an interaction function whose request waits, which ends `rejected` as `cancelled`. A run of a reasoning, computation or recall function ends within its call, and cannot be cancelled from outside it. ## Inspecting a run -`get_execution` reads a run by its execution id. For a workflow it returns the workflow's name, the version that ran, who started it and when, its status and, once it ends, when it finished and its output or rejection. +`get_run` reads a run by its `run_id`. For a workflow it returns the workflow's name, the version that ran, who started it and when, its status and, once it ends, when it finished and its output or rejection. -`get_execution_history` reads what happened in the run, oldest first: its start, its end and, for each input the run took (its start, a function's answer, a timer or an event), a `workflow_input_applied` event. That event names the input and lists the steps it moved, each with how it ended, such as `waiting` or `completed`, so the latest one shows what the run is waiting for; an event for each step follows it, named for how the step ended that input, such as `step_waiting` or `step_finished`. Every event names the event that led to it, so the history draws as a graph: the branch a `switch` took, the branches of a `fork`, a retry and a wait. A step that calls a function shows the execution id of the function's run on its `step_waiting` event, and `list_brain_events` with the workflow's `execution_id` reads the workflow and every function run it started together. It shows neither the data the run holds nor the events' data. Each function run a step started is recorded in the brain under its own execution id, which `list_executions` lists. +`get_run_history` reads what happened in the run, oldest first: its start, its end and, for each input the run took (its start, a function's answer, a timer or an event), a `workflow_input_applied` event. That event names the input and lists the steps it moved, each with how it ended, such as `waiting` or `completed`, so the latest one shows what the run is waiting for; an event for each step follows it, named for how the step ended that input, such as `step_waiting` or `step_finished`. Every event names the event that led to it, so the history draws as a graph: the branch a `switch` took, the branches of a `fork`, a retry and a wait. A step that calls a function shows the run id of the function's run on its `step_waiting` event, and `list_brain_events` with the workflow's `run_id` reads the workflow and every function run it started together. It shows neither the data the run holds nor the events' data. Each function run a step started is recorded in the brain under its own run id, which `list_runs` lists. ## Planned diff --git a/docs/decisions/0007-one-vocabulary.md b/docs/decisions/0007-one-vocabulary.md new file mode 100644 index 000000000..a2cec071f --- /dev/null +++ b/docs/decisions/0007-one-vocabulary.md @@ -0,0 +1,576 @@ +# 0007 — One vocabulary: the product's words are the only words, in text, code, the API and the ledger + +**Status:** accepted (2026-10-09); proposed 2026-10-06, revised 2026-10-09 against `origin/main` at b1401264, main after #136, where interaction functions with `deliver` and `replies` (0010, its amendment, and 0020), the test of a tool call (0018), the answer shape of an open request (0019), computation functions (0005), several triggers (0015), the per-server tool lists and the org-level `list_tool_servers` (0003's amendments) have landed, channels and the request-token credential have gone, and the tools advertise no output schema (#136). Every `path:line` and every count below is read on the tree that b1401264 merged, the head of #136 at c4073497, which merged main at af87111c. + +## Context + +The product's vocabulary is settled in `docs/concepts/terminology.md`: six capabilities, coordination, reasoning, interaction, prediction, recall and computation (`:11-20`); coordination is expressed through workflows and the other five through functions; a workflow or a function is a definition, which a run executes against particular inputs (`:44-49`). The runtime speaks another language on the wire, in the ledger, in the package names and in the adapter contract: `primitive` for a definition's type, `spec` for a definition, `execution` for a run, and `inference`, `recollection` and `orchestration` for the types of a reasoning function, a recall function and a workflow. CLAUDE.md states the arrangement and its end: "The API's wire names today, `primitive` with the values `inference`, `computation`, `recollection` and `orchestration`, `spec` and `execution`, are what the code says until they are renamed in one pass" (`CLAUDE.md:16`). That list is already one value short: `interaction` has been a type since 0010 (`primitives/interaction/src/primitive/primitive-name.ts:1`). + +What changed between the first version of this record and af87111c: + +- **The bridge is gone.** The aliases, the mapping from `inference` to `reason`, the retained anchors, the round-trip tests, the migration report and the "legacy" sentences that the first version set out to delete are no longer in the repository: of the 2,089 tracked files, none is named `terminology-compatibility` or `migration-report`, and no file outside `docs/decisions` says "called specs" or "legacy primitive". What remains is the old words themselves and the sentences that teach a reader or an agent to translate them (§1.3). +- **Main grew.** `/mcp` serves 25 tools, not 17, which is the bound `mostToolsOnAConnection` (`packages/api/src/bounds/served-bounds.ts:13`), asserted by `packages/server/src/mcp/served-tools.test.ts:81` and by the smoke (`.github/workflows/ci.yml:214`). The ledger has 29 event types a brain reserves (`packages/specs/src/events/reserved-attributes.ts:35-65`), keyed projections that name stream kinds and event types (`run_outcomes_2`, `open_requests_4`, `conversations_1`), and a host that keeps triggers, listeners and a reaction backlog in tables of its own. The engine's state format is 6 (`packages/workflow-engine/src/run-log/state-format.ts:3`), not 3. +- **The first version had two facts wrong.** The definition stream is `specs/`, one stream for each type in each brain holding every definition of that type (`packages/specs/src/registry/specs-decider.ts:20-21`), not `specs//`; `/specs//` is the source of a definition's fact (`packages/specs/src/events/brain-facts.ts:97`). And the JSON Schema identifiers it would rename, `Run`, `RunDetail`, `ListedRun`, `Definition` and `ListedDefinition`, already carry the product's words (`packages/specs/src/execution/execution.ts:80,109`, `packages/specs/src/reading/listed-execution.ts:32`, `packages/specs/src/registry/spec.ts:52`); since #136 they name output schemas, which no tool advertises. +- **CLAUDE.md now has a naming rule** (`CLAUDE.md:18`): a definition is named for its resource, `ReasoningFunctionDefinition` and the others, a parsed document is a `...DefinitionDocument`, and a factory of a runtime adapter is `make...Adapter`. The first version would have removed those names; this revision keeps them. + +The scale, measured: 952 of the 2,089 tracked files hold 13,923 occurrences of the six words outside `docs/decisions`, the lockfile and the jq patch under `patches/`. Of those, 13,684 are renamed and 239 stay (§1.2). + +Nothing is live. There are no users, no stored data and no clients outside this repository but the management plane, so there is no upgrade path to protect. That is still the moment to rename everything once. + +## Decision + +One vocabulary everywhere. Every identifier a person can read, in the API, the MCP tools, the routes, the configuration, the ledger's stream names, event types and tables, the package names, the folders and the code, uses the product's words. Nothing keeps an old name beside a new one: no alias, no mapping, no retained anchor, no test that an old name or an old record still works, no sentence that explains what something "used to be called". The workflow engine's run-log formats are the one exception, because they are the engine's replay discipline and not an upgrade path: the rename makes the next format, format 7, with format 6 frozen as the engine's rule says (`packages/workflow-engine/README.md:159-165`). + +### 1. The words + +| Concept | Word | Replaces | +| ------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------- | +| What the brain can do | capability: `coordination`, `reasoning`, `interaction`, `prediction`, `recall`, `computation` | primitive | +| The contract a capability package implements | `Capability` | `Primitive` | +| What someone defines | definition | spec | +| The type of a definition, on a resource: a definition, a run, a listing, an analytics group, a call's `with` | `type` | `primitive` | +| The type of a definition, on a record whose own `type` names the record: a ledger event, a brain event's `data`, a fact's `data`, a ledger table | `definition_type` | `primitive` | +| The values of a type | `reasoning`, `interaction`, `computation`, `recall`, `workflow`; `prediction` once built | `inference`, `interaction`, `computation`, `recollection`, `orchestration` | +| The version a run used | `definition_version` | `spec_version` | +| One execution of a definition, and its id | run, `run_id` | execution, `execution_id` | +| What a run records in the ledger | the run's stream, `runs/`, distinct from the `record` a run answers with (`RunDetail.record`) | `executions/` | +| The host's key of a run, `org/brain/` | `run_key`, made by `runKeyOf`, beside `brain_key` | `run_id`, `runIdOf` | +| What the workflow engine logs | the run log, on the stream `run-logs/` | `runs/` | +| A brain's definitions of one type | the stream `definitions/` | `specs/` | +| The source of a run's fact and of a definition's fact | `/runs/`, `/definitions//` | `/executions/`, `/specs//` | +| The subject of a run's fact | `/`, such as `reasoning/triage` | `/`, such as `inference/triage` | +| The folder of the capability packages | `capabilities/` | `primitives/` | + +The values of `type` are the types of definition, the five function types and `workflow`; the capability behind a workflow is coordination, which names its package and folder and is never a value of `type`. + +A record whose own `type` names it cannot carry a second `type`: a ledger event is stored with its `type` inside its data (`packages/ledger/src/event-codec.ts:23`), a CloudEvent's `type` is `run_succeeded`, and a step event's `data` already holds `type` as its error's type. Those records say `definition_type`, which is already the code's word for the same thing (`DefinitionType`, `packages/api/src/mcp/instructions.ts:5`; `definitionTypeOfStream`, `packages/ledger/src/postgresql-reads/brain-indexes.ts:12`; `definitionStreamOf(brainKey, definitionType)`, `packages/workflow-host/src/projector/brain-definitions.ts:19`). A resource says `type`. + +`inference` keeps one meaning only, the call to a language model and the terms of its providers. Measured, that leaves 13 occurrences in 7 files, all provider terms (§1.2); nothing in the code today uses `inference` for the model call itself, which the code calls a model call (`primitives/inference/src/adapter/model-call.ts`). `orchestration` and `recollection` disappear; the engine and the host already say "workflow", and the recall capability already says "recall" (`recallDefinitionType`, `primitives/recollection/src/run/recall-bounds.ts:41`). + +#### 1.1 Where the words are + +The counts use these patterns, over every tracked file except `docs/decisions/`, `pnpm-lock.yaml` and the jq patch under `patches/`: + +- `primitive`: `[pP]rimitive|PRIMITIVE` +- `spec`: `(?/`" (`remember.md:8`), "save it with create_definition, `type` recall" (`remember.md:9`), "Find the workflow with list_definitions, `type` workflow" (`schedule.md:5`), "save it with update_definition, or with create_definition for a new workflow" (`schedule.md:7`), "call list_runs with `type` workflow and the workflow's `name`, and get_run for one run" (`schedule.md:9`). Measured in UTF-8 bytes, against `mostRecipeBytes`, 4,096 (`packages/api/src/bounds/served-bounds.ts:17`), reproducing today's numbers of `packages/server/src/guides/served-guides.test.ts:187,206-207` first: + +| Recipe | Today | After | With interaction functions, today | After | +| ------------- | ----: | ----: | --------------------------------: | ----: | +| `first-brain` | 1,761 | 1,764 | 1,851 | 1,854 | +| `remember` | 1,758 | 1,762 | 1,758 | 1,762 | +| `give-tools` | 2,052 | 2,049 | 2,312 | 2,309 | +| `schedule` | 1,299 | 1,285 | 1,299 | 1,285 | + +#### 2.6 The tool descriptions, measured + +Every tool description is at most 800 characters and 3 to 8 sentences (`served-bounds.ts:5-9`). The longest that names an old word today are `execute_spec`'s (`packages/specs/src/operations/execute-spec.ts:12-17`) and `create_spec`'s (`create-spec.ts:19-25`), at 798 each. Measured with the renames of §2.1 and §2.2 applied, the 23 descriptions written as literal text evaluated as written, and the two built from expressions evaluated with the type list `create_spec` writes from each capability (`packages/specs/src/primitive/known-primitives.ts:59-61`) and the 1,000 runs a filtered page looks at (`packages/operations/src/reading/page-bounds.ts:5`): + +| Tool after the rename | Characters, today → after | Sentences | +| ------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------: | --------: | +| `create_definition` | 798 → 788, the longest after the rename | 5 | +| `test_tool_call` | 780, unchanged | | +| `run_definition` | 798 → 775 | | +| `answer_interaction` | 780 → 768 | | +| `list_interactions` | 724 → 724, pinned with its 5 sentences (`packages/server/src/mcp/answering-what-runs-wait-on.test.ts:206-212`), since `run_id` saves the six characters `get_definition` adds | 5 | +| `list_tool_servers` at the org, at a brain | 643 and 559, unchanged | 6 and 5 | +| `cancel_run` | 621 → 609 | | +| `update_definition`, `get_definition` | 608 → 609, 600 → 601 | | +| `list_runs` | 521 → 510 | 5 | +| `list_definitions`, `retire_definition` | 406 → 407, 398 → 399 | | + +None passes 800 or 8 sentences. + +#### 2.7 The metadata a tool server receives + +A run's tool call carries its id in the request's metadata under `com.beonauto/execution_id` (`packages/mcp/src/calls/call-meta.ts:1`), which an operator's server may read; it becomes `com.beonauto/run_id`. `com.beonauto/delivery_id`, `com.beonauto/tool_test_id` and `com.beonauto/conversation_call_id` (`:3-7`) are unchanged. + +#### 2.8 The settings and the logs + +| Was | Is | Read at | +| --------------------------------- | ------------------------- | ------------------------------------------------------------- | +| `ORCHESTRATION_MAX_DURATION` | `WORKFLOW_MAX_DURATION` | `packages/server/src/settings/workflow-settings.ts:27` | +| `ORCHESTRATION_SWEEP_INTERVAL` | `WORKFLOW_SWEEP_INTERVAL` | `:34` | +| `ORCHESTRATION_NESTED_EXECUTIONS` | `WORKFLOW_NESTED_RUNS` | `:41` | +| `ORCHESTRATION_MAX_OPEN_CALLS` | `WORKFLOW_MAX_OPEN_CALLS` | `:48` | +| `RECOLLECTION_MAX_FUNCTIONS` | `RECALL_MAX_FUNCTIONS` | `packages/server/src/function-settings/recall-settings.ts:20` | +| `RECOLLECTION_MAX_REBUILDS` | `RECALL_MAX_REBUILDS` | `:22` | +| `RECOLLECTION_BRAINS_AT_ONCE` | `RECALL_BRAINS_AT_ONCE` | `:24` | + +`COMPUTATION_WORKERS` and `INTERACTION_OPEN_REQUESTS` are unchanged, and no key of `auto-brain.yaml` or `auto-brain.schema.json` holds a word; `allowed: [..., execute, ...]` in `auto-brain.example.yaml:59` and `docs/engineering/self-host/configuration.md:60` names a tool of an operator's server and stays. The settings are documented in `docs/engineering/self-host/container.md:57-60,74-76`, `workflows.md:17-33` and `docs/engineering/reference/workflow-format.md:9,53,59,82,105,111-120`. The log annotation `execution_id` becomes `run_id` (`packages/server/src/logging/logging.ts:213,237,243`, `host-notes.ts:21,26,45,57`), and so does the caller a provider's message names, `callerOf({ execution_id, tool_test_id })` (`logging.ts:247-252`). + +### 3. The ledger + +#### 3.1 Event types + +A brain reserves 29 event types (`packages/specs/src/events/reserved-attributes.ts:35-65`); nine change. + +| Was | Is | +| ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `execution_started`, `execution_deferred`, `execution_succeeded`, `execution_rejected`, `execution_failed`, `execution_cancel_requested` | `run_started`, `run_deferred`, `run_succeeded`, `run_rejected`, `run_failed`, `run_cancel_requested` (`packages/specs/src/execution/execution-events.ts:25-80`) | +| `spec_created`, `spec_updated`, `spec_retired` | `definition_created`, `definition_updated`, `definition_retired` (`packages/specs/src/registry/spec-events.ts:20,27,33`) | +| `tool_call_started`, `tool_call_answered`, `delivery_started`, `delivery_ended`, `reply_taken`, `reply_refused`, `tool_test_started`, `tool_test_answered`, `replies_read`, `telling_started`, `telling_ended`, `interaction_requested`, `event_published`, `workflow_input_applied`, `step_started`, `step_waiting`, `step_finished`, `step_failed`, `step_skipped`, `reaction_refused` | unchanged | + +The engine's stored `input_applied` and its inputs, `started`, `timer_fired`, `call_answered`, `event_received`, `event_offered` and `cancel_requested` (`packages/workflow-engine/README.md:116-123`), are unchanged. A trigger or a recall filter that names `spec_created` or `execution_succeeded` names the new type (`docs/reference/workflow-format.md:149`, `docs/reference/recall-format.md:22,113,146-147`). + +#### 3.2 Event fields + +| Was | Is | In | +| ------------------------- | -------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `primitive` | `definition_type` | every event of a run's stream (`execution-events.ts:10,27,76`), a definition's events, the run filters of the ledger (`packages/ledger/src/recorded/sqlite-recorded.ts:185`, `packages/operations/src/reading/recorded-read.ts:10`) | +| `spec_version` | `definition_version` | the same events (`execution-events.ts:10,29,78`) | +| `called_by.execution_id` | `called_by.run_id` | the chain of a called run (`execution-events.ts:14`; `packages/operations/src/caller/call-lineage.ts:6`) | +| `emitted_by.execution_id` | `emitted_by.run_id` | `event_published` of an event a workflow emitted (`packages/specs/src/events/published-events.ts:15`) | +| `execution_id` | `run_id` | `telling_started` (`packages/mcp/src/own-calls/conversation-call-events.ts:35`) | + +`trigger`, `depth`, `call_depth`, `calls_tools` and `finishes_later` (`execution-events.ts:18-32`) keep their names. + +The brain events `list_brain_events` and `get_run_history` show follow the records: in every event of a run, `execution_id` in `data` becomes `run_id` (`packages/specs/src/presenting/execution-presenter.ts:135,189`, `primitives/orchestration/src/presenting/run-presenter.ts:64`, `packages/mcp/src/own-calls/conversation-call-presenter.ts:124`), the child's `execution_id` on `step_waiting` becomes `run_id` (`primitives/orchestration/src/presenting/step-events.ts:47`), `called_by` and `emitted_by` follow (`execution-presenter.ts:70`, `published-event-presenter.ts:25`), and a start's and a definition's `primitive` and `spec_version` become `definition_type` and `definition_version` (`execution-presenter.ts:140-147`, `spec-presenter.ts:24`). The `summary` of each event already speaks of runs, definitions and functions, and the check of internal terms already holds it to that (`packages/api/src/testing/internal-terms.ts:1-6`); it changes only where it names a tool. + +#### 3.3 Stream kinds + +| Was | Is | Made at | +| ------------------------------------------------------------------------------------------------------- | -------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `specs/` | `definitions/` | `specsStreamOf` (`packages/specs/src/registry/specs-decider.ts:20-21`), named `definitionTypeStreamOf`; read at `packages/workflow-host/src/projector/brain-definitions.ts:19-24`, `packages/workflow-host/src/follower/record-steps.ts:118`, and the kind `specs` at `packages/workflow-host/src/follower/followed-events.ts:91` | +| `executions/` | `runs/` | `executionStreamOf` (`packages/specs/src/execution/execution-decider.ts:49-50`), named `runStreamNameOf`, since `runStreamOf` is taken (`packages/operations/src/run-outcomes/run-outcomes.ts:54`); `packages/workflow-host/src/reactions/run-workflows.ts:21`, `waiting/pending-cancels.ts:23`, `waiting/child-endings.ts:50`; `executionsKind` (`packages/specs/src/presenting/execution-presenter.ts:170`), named `runStreamsKind`; the kind `executions` at `packages/specs/src/events/brain-facts.ts:110` and `packages/workflow-host/src/follower/followed-events.ts:91`; the ledger port's bound streams (`packages/operations/src/ledger/bound-ports.ts:61,64`); the ledger's selection of runs (`packages/ledger/src/recorded/recorded-statements.ts:51,77,100,116`), its two streams of a run (`:105`) and its list of runs (`sqlite-recorded.ts:201`, `postgresql-reads/postgresql-runs.ts:73`); the pattern of `runStreamOf` (`run-outcomes.ts:52`); the measuring dataset's `executions(run)` (`packages/ledger/measure/dataset.ts:41`), named `datasetRunStream` | +| `runs/` | `run-logs/` | the eight sites below | +| `events/`, `reactions/`, `tool-tests/`, `conversation-calls/`, the org's `brains` | unchanged | `packages/specs/src/events/published-events.ts:73-82`, `reaction-refusals.ts:3`, `packages/mcp/src/tool-tests/tool-test-events.ts:5`, `packages/mcp/src/own-calls/conversation-call-events.ts:5`, `packages/brains/src/registry/brains-stream.ts:3` | + +Decision 0002's rule holds under the new name: a run is a stream whose kind key, its name to the fourth `/`, is `runs/`, so no other stream may be nested under `runs/` (`packages/ledger/README.md:51`). The run log's kind key is `run-logs/`, another key, so the two streams of a run stay apart. + +The kind `runs` changes meaning, from the run log to the run's stream, so a site the rename misses still says a word of the product and no search finds it. Every site that names the run log's kind today is listed, and every run-log name takes `runLog…`: + +- In the code, eight sites in seven files: `streamOfRun` (`packages/workflow-host/src/runs/run-address.ts:25`), named `runLogStreamOf`; the follower's test of a run log (`packages/workflow-host/src/follower/record-steps.ts:112`); the sweep's (`packages/workflow-host/src/sweeps/wakes.ts:19`); coordination's `runsKind` (`primitives/orchestration/src/presenting/run-presenter.ts:21,23`), named `runLogsKind`; the ledger's second stream of a run (`packages/ledger/src/recorded/recorded-statements.ts:105`) and the in-memory ledger's (`packages/operations/src/testing/memory-recorded.ts:100`); the measuring dataset's long run (`packages/ledger/measure/dataset.ts:15,75`). With them, the engine's port `RunStore` and the host's `ledgerRunStore` (`packages/workflow-engine/src/run-log/run-store.ts:30`, `packages/workflow-host/src/runs/ledger-run-store.ts:37`) become `RunLogStore` and `ledgerRunLogStore`. +- In the tests, thirty sites: `packages/brains/src/testing/brain-feed.ts:51`; `packages/ledger/src/projections/projections-behaviour.ts:79`, `testing/store-behaviour.ts:87,96`, `testing/runs-behaviour.ts:74`, `outcomes/run-outcomes-behaviour.ts:143`, `lineage/lineage-behaviour.ts:33,40`, `signal/append-signal.test.ts:38,40`; `packages/specs/src/reading/get-execution-history.test.ts:36,67`, `presenting/presenter-catalog.test.ts:191`, `events/brain-facts.test.ts:231`; `packages/operations/src/projections/memory-projections.test.ts:35`, `testing/memory-ledger.test.ts:54,111,247,256`, `run-outcomes/memory-run-outcomes.test.ts:59,113`; `packages/workflow-host/src/follower/follower-loop.test.ts:120`, `reactions/listener-offers.test.ts:31`, `sweeps/brain-sweeps.test.ts:78`, `waiting/cancel-requests.test.ts:72`, `runs/ledger-run-store.test.ts:61`, `listeners/sql-listeners.test.ts:34`; `primitives/orchestration/src/presenting/step-events.test.ts:46`, `run-presenter.test.ts:61`, `cancelled-runs.test.ts:26`. + +A test of the server, after workflows of every kind have run, reads every stream of the brain whose kind key is `runs/` and finds none whose first message is `input_applied`, so a run log written under the run's kind fails the gate. + +The selection of the ledger's port follows: `{ kind: 'executions', primitive }` and `{ kind: 'run', execution }` (`packages/operations/src/reading/recorded-read.ts:8-13`) become `{ kind: 'runs', definitionType }` and `{ kind: 'run', run }`. + +#### 3.4 Facts and reserved attributes + +The facts a trigger matches and a recall function folds are CloudEvents the brain makes from its records (`packages/specs/src/events/brain-facts.ts:67-101`): + +| Was | Is | +| ----------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| a run's fact: `source` `/executions/`, `subject` `/`, `data.primitive` | `source` `/runs/`, `subject` `/`, `data.definition_type` (`:83-85,71`) | +| a definition's fact: `source` `/specs//`, `data.primitive` | `source` `/definitions//`, `data.definition_type` (`:97,100`) | +| the reserved source prefixes `/executions/`, `/specs/`, `/callers/` | `/runs/`, `/definitions/`, `/callers/` (`packages/specs/src/events/reserved-attributes.ts:9-13`; the host's copy, `packages/workflow-host/src/pages/page-folding.ts:43`) | + +So a subject written in a document, such as `inference/review-brief` (`docs/reference/recall-format.md:23`) or `inference/summarize` (`docs/reference/workflow-format.md:145`), is written `reasoning/review-brief`; and the entries a recall view keeps of a run name `/runs/` (`docs/reference/recall-format.md:68-99`). + +#### 3.5 Projections and tables + +A change to what a table keeps is a new version, which the next server fills from history and whose fill drops the version before (`packages/ledger/README.md:259-261`). The projections of runs name the kind and the types they fold, so each takes its next version. + +| Table today | Changes | After | +| ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------- | +| `run_outcomes_2` (`packages/ledger/src/outcomes/run-outcome-projection.ts:10,62-77`) | kind `runs`, the run types, column `primitive` → `definition_type`, read by `get_brain_analytics` (`postgresql/postgresql-run-outcomes.ts:14`, `outcomes/sqlite-run-outcomes.ts:18`) | `run_outcomes_3` | +| `open_requests_4` (`primitives/interaction/src/requests/open-requests.ts:160-174`) | kind `runs`, the run types; columns unchanged | `open_requests_5` | +| `conversations_1` (`primitives/interaction/src/conversations/conversation-rows.ts:105-109`) | kinds `runs` and `conversation-calls` | `conversations_2` | +| the test projections `run_tallies` and `topics` (`packages/operations/src/projections/tally-rows.ts:48-49`, `topic-rows.ts:45-47`) | kind `runs` | their next versions | +| `workflow_reaction_backlog` (`packages/workflow-host/src/database/host-tables.ts:111-118`) | column `execution_id`, the bare id of the run a trigger defers, → `run_id`, the key with `brain_key` | same table | +| the host's tables keyed by a run: `workflow_passed_runs`, `workflow_listeners`, `workflow_listener_types`, `workflow_runs` (`host-tables.ts:58,62,77,134`), and `workflow_snapshot_chunks`, `workflow_timers`, `workflow_calls`, `workflow_pending_cancels`, `workflow_due` and `workflow_settlements` (`:143,152,162,174,181,188`) | column `run_id`, which holds the host's key `org/brain/` that the engine is given as its run's id (`packages/workflow-host/src/runs/run-address.ts:9-11`, `calls/call-rows.ts:91`, `dispatch/sql-watermark.ts:25-26`), → `run_key`, beside `brain_key` | same tables | +| every other host table and `recall_views` | none | unchanged | + +`runIdOf` becomes `runKeyOf` (`run-address.ts:9-11`), and the engine's run id the host hands it is a run key where the host builds it (`packages/workflow-host/src/waiting/pending-cancels.ts:51`, `waiting/child-endings.ts:34`); the bare id, as in a `called_by` or an emitter, is `run_id`. So the test of a listener for its own run's emission (`packages/workflow-host/src/reactions/listener-offers.ts:65`), `event.emitter?.executionId === addressOfRun(row.run_id).executionId`, reads `event.emitter?.runId === addressOfRun(row.run_key).runId`. + +The index `ledger_definition_streams` keeps its name and takes the new kind in its expression: `substring(stream_id FROM '^(?:[^/]*/){3}specs/([^/]+)$')` becomes `…definitions/…` on PostgreSQL (`packages/ledger/src/postgresql-reads/brain-indexes.ts:12,45`, its plan check `index-checks.ts:6,12`), and `CASE WHEN substr(…, 6) = 'specs/' THEN substr(…+7)` becomes the 12 characters of `definitions/` on SQLite (`packages/ledger/src/recorded/sqlite-indexes.ts:21,55`). Since the ledger looks its indexes up by name and creates only those missing (`packages/ledger/README.md:74`), a database that kept the old index would keep the old expression; that is one more reason a database made before the rename is deleted (§3.7). + +#### 3.6 Message ids + +The id of every message is `messageIdOf(stream, position)`, a version 5 UUID of the stream's name (`packages/ledger/README.md:13`), and a step event's id is derived from its run's id. The stream names change, so every id the brain records changes, and every test that pins one is pinned again. Nothing reads an id recorded before, since the database that holds it is deleted. The ids derived from a run id and a reference, `nestedExecutionId` (`primitives/orchestration/src/calls/nested-execution-id.ts:3-6`) and the reaction ids (`packages/workflow-host/src/reactions/reaction-ids.ts:6`), use namespaces that are UUIDs and names that hold no word, so they do not change. + +#### 3.7 A local database made before the rename + +Nothing reads the old names, since nothing is live, so no migration is written: the database is deleted before this build runs on it, as 0015 and 0010's amendment had it. Kept, it fails: definitions recorded under `specs/` are no longer found, so the brain lists none; streams named `runs/` hold run logs where the reads of runs now look for runs' streams; and the host's tables are made with `CREATE TABLE IF NOT EXISTS`, so a kept table keeps its old columns. The count of the reaction backlog names no id and still works (`packages/workflow-host/src/reactions/start-rates.ts:77-78`), but every write to the reaction backlog fails, and the host's sweep of deferred starts, which reads it `ORDER BY due, execution_id` under `Effect.orDie` (`:177-183`, swept from `reactions/reacting.ts:26`), dies on every pass; the tables keyed by a run keep `run_id` where the host now names `run_key`. + +`TODO.md` gains one line and loses the two that this one makes moot, "Delete every local ledger made before several triggers" and "Delete every local ledger made before an interaction function named the tool it sends through" (`TODO.md:39-40`), since a ledger made before either was made before the rename: + +> - [ ] **Delete every local ledger made before the one vocabulary.** The streams, event types and fields of definitions, runs and run logs took the product's words, the projections of runs took their next versions, and the host's tables key a run by `run_key` and its reaction backlog by `run_id`, and nothing reads the old names, since nothing is live. Delete `packages/server/.data/ledger.db` for `pnpm dev`, or the file `LEDGER_FILE` names, or the PostgreSQL database `DATABASE_URL` names. Kept, such a database shows no definition and reads its run logs as runs; every write to the reaction backlog fails, and the host's sweep of deferred starts dies on every pass. + +### 4. The workflow format + +- **The call.** `call: execute_spec` with `with: { primitive, name, input }` becomes `call: run_definition` with `with: { type, name, input }` (`primitives/orchestration/src/document/workflow-functions.ts:18-38`), and the policy's words "run the workflow with execute_spec" (`:77`) say `run_definition`. A task whose `with.type` and `with.name` are written out still gives its call the longest its definition may run (`primitives/orchestration/src/runs/call-limits.ts`). `$error`, its `kind` and `because`, and the error types are unchanged. +- **`$runtime`.** `metadata: { primitive: 'orchestration' }` (`primitives/orchestration/src/runs/orchestration-machine.ts:9`; `docs/reference/workflow-format.md:359`) becomes `metadata: { type: 'workflow' }`. It is an option of the machine, not part of its state, so it changes what an expression of a new run reads and nothing a replay reads. +- **Format 7: the state, the events and the snapshots.** The state names its run `executionId` (`packages/workflow-engine/src/machine/run-state.ts:119,230`), and so does every input (`src/machine/run-input.ts:27-71`). So does every event, not in its patch alone but in its `outputs` (`src/run-log/run-event.ts:17`): `arm_timer`, `cancel_timer`, `settle` and the rest carry `executionId` (`src/dispatch/run-output.ts:12,21,60`), and a call's key does too (`src/executor/call-key.ts:4`). And so does a snapshot's envelope (`src/run-log/snapshot.ts:13`). The corpus test decodes every committed stream and snapshot with the current schemas (`src/run-log/corpus.test.ts:16,38`), and `corpus/format-6.json` carries `executionId` in 14 of its 14 outputs and in its snapshot. All of them become `runId`, which is format 7: `stateFormat` becomes 7; format 6's state is frozen in `src/run-log/format-six.ts`, read strictly with frozen copies of the schemas it uses and upcast by renaming `executionId` to `runId`; the same file freezes the event and snapshot schemas of formats 1 to 6, the outputs and call keys among them, and the run log decodes each event and snapshot with the schemas of its own `format` (`run-event.ts:12`, `snapshot.ts:12`); and `src/run-log/known-formats.ts:8-11` lists format 6 with the five before. The README's rule (`packages/workflow-engine/README.md:159-165`) gains that sentence: an event and a snapshot are read with the schemas of the format they name. The corpus gains `packages/workflow-engine/corpus/format-7.json`, recorded by the new code, and keeps `format-1.json` to `format-6.json` loading. The call frame's `function` and its held `arguments` are data from the document, so a format-6 log of a call keeps `execute_spec` and `primitive` in its values and loads as it was written. +- **The engine's own names.** `drivenExecutionId`, `ExecutionIdSchema`, the settle receipt `unknown_execution` (`src/settlement/record-store.ts:6`) and the ports' `executionId` parameters take `run`; the run log's event, `RunEvent` and `RunEventSchema` (`run-event.ts:10,20`), becomes `RunLogEvent` and `RunLogEventSchema`, so that `RunEvent` names the events of a run's stream in the definitions package, and the test-local `RunLogEventSchema` and `RunLogEvent` that already stand in for a run log (`packages/specs/src/reading/get-execution-history.test.ts:18,24`) become `StandInLogEventSchema` and `StandInLogEvent`. `Executor`, the port that performs calls, keeps its name (§5.6). +- **The recorded input logs.** The fifteen input logs in `primitives/orchestration/input-logs/` are format 6 today, with 120 `executionId`, 15 of them patch paths `/executionId`, 20 `execute_spec`, 20 `primitive`, 20 `inference`, and "rejected the execution with" four times (`retry-with-backoff.json:471,547,797,873`). They are recorded again under format 7 (`RECORD_INPUT_LOGS=1`, `primitives/orchestration/README.md`, Testing), and `execute-spec.json` is named `run-definition.json`. + +### 5. The packages and the code + +#### 5.1 Packages + +| Was | Is | +| --------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------- | +| `primitives/` (`pnpm-workspace.yaml:3`) | `capabilities/` | +| `primitives/inference`, `@beonauto/inference`, 198 files | `capabilities/reasoning`, `@beonauto/reasoning` | +| `primitives/orchestration`, `@beonauto/orchestration`, 96 files | `capabilities/coordination`, `@beonauto/coordination` | +| `primitives/recollection`, `@beonauto/recollection`, 34 files | `capabilities/recall`, `@beonauto/recall` | +| `primitives/interaction`, `primitives/computation` | `capabilities/interaction`, `capabilities/computation`, names unchanged | +| `primitives/prediction`, a note | `capabilities/prediction`, the same note | +| `primitives/dream`, a note | removed; Dream is a process, not a capability, and `docs/concepts/history-and-dream.md:35-43` already says what its note says | +| `packages/specs`, `@beonauto/specs`, with `./document`, `./json-schema`, `./template` and `./testing`, 209 files | `packages/definitions`, `@beonauto/definitions`, the same subpaths: definitions, runs, their operations and their words | +| `packages/operations`, `ledger`, `mcp`, `brains`, `api`, `server`, `workflow-engine`, `workflow-host`, `config`, `identity`, `outbound` | names unchanged; the words inside them change | + +The workspace follows: `tsconfig.json:7,9,10`, `vitest.config.ts:5`, `commitlint.config.ts:8`, `.oxlintrc.json:109`, the root `package.json:33`, `packages/server/Dockerfile:19,22-26,56,59-63` and its install filter `--filter @beonauto/inference` (`:34`), `Dockerfile.dockerignore:14`, the lockfile, the scripts `scripts/try-inference.sh` (named `try-reasoning.sh`) and `try-workflows.sh`, and the taxonomy asset's `source` (`docs/assets/brain-functions.json:3`, written by `scripts/docs-function-taxonomy.ts:14`). + +#### 5.2 Folders + +| Was | Is | +| --------------------------------------------------------------------------------------------------------------- | --------------------------------------- | +| `packages/specs/src/execution`, 22 files | `packages/definitions/src/runs` | +| `packages/specs/src/primitive`, 6 files | `packages/definitions/src/capability` | +| `src/primitive` of computation, inference, interaction, orchestration and recollection, 2, 20, 7, 7 and 4 files | `src/capability` of each | +| `primitives/inference/src/spec`, 12 files | `capabilities/reasoning/src/definition` | +| `packages/server/src/inference`, 17 tests of reasoning functions over HTTP and MCP | `packages/server/src/reasoning` | +| `packages/server/src/workflow-executions`, 13 tests | `packages/server/src/workflow-runs` | + +A file is named after what it exports: 113 file names hold a word, such as `create-spec.ts`, `execute-spec.ts`, `get-execution.ts`, `execution-events.ts`, `known-primitives.ts`, `spec-execution.ts`, `nested-execution-id.ts` and `orchestrated-brain.ts` (`primitives/orchestration/src/testing/`), and each takes the name of its export after the rename (`create-definition.ts`, `run-definition.ts`, `get-run.ts`, `run-events.ts`, `known-capabilities.ts`, `definition-run.ts`, `nested-run-id.ts`, `workflow-brain.ts`). With the 68 files of the folders above whose own names hold no word, 181 files are renamed beyond their package's move. The layout rules of CLAUDE.md (`CLAUDE.md:49`) apply to the renamed folders as to any other. + +#### 5.3 The naming rule of CLAUDE.md + +CLAUDE.md names a definition for its resource (`CLAUDE.md:18`), and the rename keeps those names: `WorkflowDefinition`, `ReasoningFunctionDefinition`, `InteractionFunctionDefinition`, `RecallFunctionDefinition`, `ComputationFunctionDefinition` and `BrainFunctionDefinition` (`packages/specs/src/registry/spec.ts:70-90`); the documents `ReasoningFunctionDefinitionDocument`, `InteractionFunctionDefinitionDocument`, `ComputationFunctionDefinitionDocument`, `RecallFunctionDefinitionDocument` and `WorkflowDefinitionDocument`; the factories `makeReasoningFunctionAdapter`, `makeInteractionFunctionAdapter`, `makeComputationFunctionAdapter`, `makeRecallFunctionAdapter` and `makeWorkflowAdapter`; and `FunctionRun`, `WorkflowRun` and `isFunctionRun` (`packages/specs/src/execution/execution.ts:84-94`). + +A definition's `type` is its one discriminator, and the verb kinds go. Today a type and a kind are two words for one thing: `BrainFunctionKind`, `'reason' | 'interact' | 'predict' | 'recall' | 'compute'`, with `functionKindOrder` and the labels, nouns and descriptions keyed by kind (`packages/specs/src/primitive/function-terminology.ts:1-27`), beside a map from the wire type to its label (`:29-35`). After the rename `BrainFunctionKind` is `FunctionType`, `'reasoning' | 'interaction' | 'prediction' | 'recall' | 'computation'`, `functionKindOrder` is `functionTypeOrder`, the labels, nouns and descriptions are keyed by type, so the capabilities read `functionCategoryLabels.reasoning` where they read `.reason` (`primitives/inference/src/primitive/reasoning-function.ts:57,62` and their siblings), and the map from type to label is that table. The taxonomy asset the documentation import reads takes `type` with those values in place of `kind` with the verbs, and its `schema` becomes 2 (`docs/assets/brain-functions.json:2-6`, written by `scripts/docs-function-taxonomy.ts:13-21`). The sentence of CLAUDE.md that names the kinds changes with it (§8). + +#### 5.4 Exported identifiers + +The 44 exported identifiers that hold a word, in seven packages counted with their subpaths, with the name each takes: + +| Package | Was | Is | +| --------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `@beonauto/specs` | `Primitive`, `PrimitiveDefinition`, `PrimitiveGuide`, `PrimitiveRejection`, `definePrimitive` | `Capability`, `CapabilityDeclaration`, `CapabilityGuide`, `CapabilityRejection`, `defineCapability` (`packages/specs/src/primitive/primitive.ts`); what a capability package declares to `defineCapability` is a declaration, not a definition, which CLAUDE.md keeps for what someone defines and names for its resource | +| `@beonauto/specs` | `SpecEvent`, `SpecEventSchema`, `SpecChange`, `specChangeOf`, `makeSpecOperations`, `makeSpecPresenters`, `specsStreamOf` | `DefinitionEvent`, `DefinitionEventSchema`, `DefinitionChange`, `definitionChangeOf`, `makeDefinitionOperations`, `makeDefinitionPresenters`, `definitionTypeStreamOf`, since the host's `definitionStreamOf` calls it (`packages/workflow-host/src/projector/brain-definitions.ts:19-20`; exported at `packages/specs/src/index.ts:113`) | +| `@beonauto/specs` | `defineCreateSpec`, `defineListSpecs`, `defineGetSpec`, `defineUpdateSpec`, `defineRetireSpec`, `defineExecuteSpec` | `defineCreateDefinition`, `defineListDefinitions`, `defineGetDefinition`, `defineUpdateDefinition`, `defineRetireDefinition`, `defineRunDefinition` (`defineListSpecs` at `packages/specs/src/index.ts:12`) | +| `@beonauto/specs` | `defineGetExecution`, `defineListExecutions`, `defineGetExecutionHistory`, `defineCancelExecution`, `getExecution` | `defineGetRun`, `defineListRuns`, `defineGetRunHistory`, `defineCancelRun`, `getRun` | +| `@beonauto/specs` | `ExecutionEvent`, `executionEventOf`, `ExecutionDeferred`, `Executed`, `CancelExecution`, `SettleExecution`, `executionSettler`, `executionCanceller` | `RunEvent`, `runEventOf`, `RunDeferred`, `CapabilityAnswer`, `CancelRun`, `SettleRun`, `runSettler`, `runCanceller` | +| `@beonauto/specs` | `ExecutionAddress`, `{ org, brain, id }` (`packages/specs/src/execution/execution-settler.ts:26-30`) | `RunStreamAddress`, since `RunAddress` is the host's address of a run (`packages/workflow-host/src/runs/run-address.ts:3`) | +| `@beonauto/specs` | `ExecutionIdField` (`packages/specs/src/operations/spec-fields.ts:34`) | `RunIdInputField`, since the feed already has a `RunIdField` (`packages/brains/src/feed/list-brain-events.ts:27`) | +| `@beonauto/ledger/dataset` | `executions` (`packages/ledger/measure/dataset.ts:41`) | `datasetRunStream` | +| `@beonauto/mcp/policy` | `executionIdKey` | `runIdKey` | +| `@beonauto/inference` | `SpecInvalid`, tagged `spec_invalid` | `DefinitionInvalid`, tagged `definition_invalid` | +| `@beonauto/interaction` | `interactionPrimitive` | `interactionType` | +| `@beonauto/orchestration` | `defineSendExecutionEvent`, `orchestrationMachine` | `defineSendRunEvent`, `workflowMachineOptions`, since `workflowMachine` already names the engine's function (`packages/workflow-engine/README.md:9`) | +| `@beonauto/workflow-engine`, `/testing` | `Executor`; `ExecutorSubject`, `MemoryExecutor`, `memoryExecutor`, `executorProbes` | unchanged (§5.6) | + +Where the plain rename meets a name already taken, the name already taken keeps it and the new one says more, or, where the taken one is the less exact, it moves: the summary `RunStarted` of `packages/specs/src/execution/run-starts.ts:5` becomes `StartedRun`, so that `RunStarted` is the event `run_started`; `executionStreamOf` becomes `runStreamNameOf` and `executionsKind` `runStreamsKind`, since `runStreamOf` (`packages/operations/src/run-outcomes/run-outcomes.ts:54`) and coordination's run-log `runsKind` (`primitives/orchestration/src/presenting/run-presenter.ts:21,23`) are taken; the capability-wide `longestExecutionMs` (`packages/specs/src/primitive/primitive.ts:112,166`) becomes `longestAnyRunMs`, with `defaultLongestAnyRunMs`, since `longestRunMs` is a prepared definition's own (`:130`); and the engine's run-log event becomes `RunLogEvent` (§4). A `Capability` states its `type` where a `Primitive` stated its `name` (`'inference'`, `primitives/inference/src/primitive/reasoning-function.ts:56`), runs a definition with `run` where it had `execute`, and its `RunContext` names the definition it runs `definition` where it had `spec` (`primitive.ts:39-50`); `knownPrimitives`, `KnownPrimitives`, `PrimitiveField`, `primitiveNamed` and `isPrimitiveName` become `knownCapabilities`, `KnownCapabilities`, `DefinitionTypeField`, `capabilityOfType` and `isDefinitionTypeName`. Each name of this section was checked free in the tree. Every other identifier, file and test name with a word, internal to its package, takes the name that says the same thing in the product's words; the search of the verification holds the build to it. + +#### 5.5 Package READMEs + +`packages/specs/README.md` holds 132 lines with a word, the ledger's 35, the workflow engine's 29, the workflow host's 20, the API's 19, orchestration's 16, inference's 11, mcp's 11, recollection's 9, operations' 9, interaction's 8, computation's 4 and brains' 3; each is rewritten with its package, and its anchors with old words, such as `#executions-that-finish-later`, `#execution-ids-and-retries` and `#reading-executions`, are renamed with their headings and every link to them, with no retained anchor. + +#### 5.6 `execute`, `executor` and `executes` + +The decision retires `execution`, not the English verb. Counted as words rather than as matches of the pattern of §1.1, 1,892 words built on `execute` sit outside `docs/decisions`, the lockfile and the jq patch: 273 `execute_spec` in 91 files, 125 identifiers built on `executeSpec` in 40, 100 of the `/execute` route in 66, 362 of the engine's `Executor` port in 73, 19 of the ledger's and the databases' `execute` method in 17, and 1,013 the verb, its tenses and the names of test variables. The first three name the running of a definition and go with §2 and §5.4. `Executor`, the engine's port that performs calls (`packages/workflow-engine/README.md:45`), `Ledger.execute`, which runs a command through the ledger's loop (`packages/operations/src/ledger/stream-ports.ts:21`), a database's `execute`, and the verb in a sentence such as "a run executes it against particular inputs" (`CLAUDE.md:14`) stay. A variable of a test named `executed` for the answer of a run takes the word of the run. + +### 6. What is removed + +- Already gone, measured: the alias re-exports, the compatibility test, the migration report, the retained anchors and their test, and the compatibility sections of the concepts, references, READMEs and contribution guides. Nothing of the first version's list is left to delete. +- Removed now: the sentences of §1.3 that translate, the wire sentence of the served instructions with its tests (`packages/api/src/mcp/instructions.test.ts`, "map the wire names only where a tool listed carries one", and the sentence it strips before its search of internal terms), and `primitives/dream`. + +### 7. What stays + +- The records in `docs/decisions` are history and keep the words of their day. A record written in the old words and built after the rename, as 0021, asking a system, will be, is built in the new words, its names read through this record's tables; the rename does not edit it. +- The names of §2.3 and the unchanged rows of §2.1, §3.1 and §3.3, which are already in the vocabulary: `test_tool_call`, `tool_test_started`, `tool_test_answered`, the `tool-tests/` streams and `test_id` of 0018; `answer_schema` of 0019; `deliver`, `replies`, `delivery_started`, `delivery_ended`, `reply_taken`, `reply_refused`, `replies_read`, `telling_started`, `telling_ended` and `interaction_requested` of 0010 and 0020; `schedule`, `triggers`, `trigger` and `reaction_refused` of 0015; the per-server `allowed` and `testable` of 0003; `list_tool_servers`, `answer_interaction`, `list_interactions`, `publish_event`, `list_brain_events` and `get_brain_analytics`. +- The names of §1.2, which belong to standards, providers and the frozen formats. + +### 8. CLAUDE.md + +Four passages hold a word and change, and one more changes with the discriminator of §5.3; nothing else in CLAUDE.md holds a word. + +`CLAUDE.md:11` becomes: + +> - `capabilities/*`: the runtime adapters of the brain's capabilities. `reasoning` implements reasoning functions, `interaction` interaction functions, `computation` computation functions, `recall` recall functions and `coordination` workflows; `prediction` has a design note only. + +`CLAUDE.md:16`, the paragraph that says the wire names are what the code says until they are renamed in one pass, becomes: + +> The product is not live and has no stored data, so nothing keeps an old name beside a new one: no alias, no mapping, no anchor for an old heading and no test that an old name or an old record still works, and a local database made before a rename is deleted. Brain terminology is the one vocabulary, on the wire, in the ledger, in the package names and in the code: a definition has a `type`, `reasoning`, `interaction`, `computation`, `recall` or `workflow`, and a run has a `run_id`; a record whose own `type` names it, such as a ledger event, carries `definition_type`. The workflow engine's run-log formats are internal replay formats and follow their own rule, which its README sets out under [State formats](packages/workflow-engine/README.md#state-formats). `Capability` is the contract a capability package implements for the runtime, and is named for what it is. + +The last sentence of `CLAUDE.md:18`, "Apart from the wire names and the packages named after them, **inference** names model execution and provider terms, **orchestration** the coordination machinery, **agent** an actual actor, …", becomes: + +> **inference** names a model call and its provider's terms, **agent** an actual actor, such as an external coding agent, and **memory** the retention of information. + +The sentence of `CLAUDE.md:18` that names the function kinds, "The function kinds are `reason`, `interact`, `predict`, `recall` and `compute`; a workflow is not one of them.", becomes the one point of this record whose owner may refuse it: + +> A definition's `type` is its one discriminator: `reasoning`, `interaction`, `prediction`, `recall` and `computation` for the five function types, and `workflow`, which is not a function type. + +`CLAUDE.md:50` becomes: + +> - Commits are conventional with a scope named after a package or capability folder (`feat(server): ...`, `docs(reasoning): ...`); `global`, `deps`, `ci` and `release` are the other scopes. + +`CONTRIBUTING.md:48-49,56` and `.github/pull_request_template.md:7` change the same way. + +### 9. What a client of the HTTP API changes + +This section is handed to the owner of the management plane (Auto Studio), and to the hosting side for the settings, before the merge. The two change in the same step as the merge; nothing serves the old names after it. The management plane's own translation of the wire names into the product's words goes, since the wire now says them; and the hosted image built from this merge is promoted only together with the management plane's change. + +| Was | Is | +| ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `POST`, `GET /specs/{primitive}` | `POST`, `GET /definitions/{type}` | +| `GET`, `PUT /specs/{primitive}/{name}` | `GET`, `PUT /definitions/{type}/{name}` | +| `POST /specs/{primitive}/{name}/retire` | `POST /definitions/{type}/{name}/retire` | +| `POST /specs/{primitive}/{name}/execute` | `POST /definitions/{type}/{name}/run` | +| `GET /executions` | `GET /runs` | +| `GET /executions/{execution_id}` | `GET /runs/{run_id}` | +| `GET /executions/{execution_id}/history` | `GET /runs/{run_id}/history` | +| `POST /executions/{execution_id}/cancel` | `POST /runs/{run_id}/cancel` | +| `POST /executions/{execution_id}/events` | `POST /runs/{run_id}/events` | +| `POST /executions/{execution_id}/answer` | `POST /runs/{run_id}/answer` | +| path values `inference`, `recollection`, `orchestration` | `reasoning`, `recall`, `workflow`; `interaction`, `computation` unchanged | +| body `execution_id` of a run | `run_id` | +| query `primitive` of `GET /executions` and `GET /analytics` | `type`, with the new values, as `GET /analytics?days=7&type=reasoning` | +| query `execution_id` of `GET /events` | `run_id` | +| answer `specs` of a listing of definitions | `definitions` | +| answer `executions` of a listing of runs | `runs` | +| `primitive` of a definition, a listed definition, a run, a listed run and `by_function[]` | `type` | +| `spec_version` of a run and a listed run | `definition_version` | +| `execution_id` of a run, a listed run, `interactions[]` and the answer of sending an event | `run_id` | +| event `type` values `execution_started`, `execution_deferred`, `execution_succeeded`, `execution_rejected`, `execution_failed`, `execution_cancel_requested`, `spec_created`, `spec_updated`, `spec_retired`, in `list_brain_events`' answers and its `type` filter | `run_started`, `run_deferred`, `run_succeeded`, `run_rejected`, `run_failed`, `run_cancel_requested`, `definition_created`, `definition_updated`, `definition_retired` | +| an event's `data.execution_id`, on every event of a run, `workflow_input_applied`, `telling_started`, and a call's `step_waiting` | `data.run_id` | +| an event's `data.primitive` and `data.spec_version` | `data.definition_type` and `data.definition_version` | +| `data.called_by.execution_id`, `data.emitted_by.execution_id` | `data.called_by.run_id`, `data.emitted_by.run_id` | +| sources a published event may not use: `/executions/`, `/specs/`, `/callers/` | `/runs/`, `/definitions/`, `/callers/` | +| a fact's `source` `/executions/`, `/specs//`, and `subject` `/`, in a trigger or a recall filter | `/runs/`, `/definitions//`, `/` | +| the metadata key a tool server receives, `com.beonauto/execution_id` | `com.beonauto/run_id` | +| the settings `ORCHESTRATION_MAX_DURATION`, `ORCHESTRATION_SWEEP_INTERVAL`, `ORCHESTRATION_NESTED_EXECUTIONS`, `ORCHESTRATION_MAX_OPEN_CALLS`, `RECOLLECTION_MAX_FUNCTIONS`, `RECOLLECTION_MAX_REBUILDS`, `RECOLLECTION_BRAINS_AT_ONCE` | `WORKFLOW_MAX_DURATION`, `WORKFLOW_SWEEP_INTERVAL`, `WORKFLOW_NESTED_RUNS`, `WORKFLOW_MAX_OPEN_CALLS`, `RECALL_MAX_FUNCTIONS`, `RECALL_MAX_REBUILDS`, `RECALL_BRAINS_AT_ONCE` | +| the log annotation `execution_id` | `run_id` | +| a client over MCP: the eleven tool names of §2.1 and the arguments above | the names of §2.1 | + +Unchanged for a client: every other route, the problem types, reasons, kinds and `because` values, the JSON Schema identifiers, the scopes, `/health`, and the MCP endpoints. The `detail` of a problem and the `summary` of an event are words for people and change where they name a tool. + +## Consequences + +- One set of words from the terminology page to the stream name, so a reader of any layer can read every other, and an agent connecting to a brain is no longer told how to translate: the instructions lose 100 characters on `/mcp` and on a brain's endpoint, which leaves 110 and 150 under the bound of 2,000. +- The cost is one pull request that moves 687 files with their packages, renames 181 more, changes 13,684 occurrences in 952 files, freezes the engine's event and snapshot schemas with its state, records the corpus at format 7 and the fifteen input logs again, takes three projections to their next version, and renames the host's run columns. It is paid once, while nothing is live, and is cheaper before the next feature than after it. +- Every local database is deleted once more, and every id the ledger records changes with the stream names. +- The management plane and the hosting side change their routes, fields, values and settings in the same step as the merge, from §9. An operator's tool server that reads the run's id from a call's metadata reads `com.beonauto/run_id`. +- The taxonomy asset's `source` changes from the specs package to the definitions package and its entries carry `type` in place of `kind`, at `schema` 2; whatever imports the documentation reads the regenerated asset. A definition's type is the one word for what the verb kinds said beside it. +- A definition's type is `type` on a resource and `definition_type` on a record that has a `type` of its own: two names for one thing, chosen so that no record has two fields of one name and so that an event's `type` is never confused with its definition's. +- The tool count stays at 25, which is `mostToolsOnAConnection`, so the next tool still needs that bound raised; the rename neither helps nor hurts. +- Designs written before this record and built after it, such as 0021, start from the new words. + +## Verification the build must include + +- **A search of the repository, kept by a test.** `scripts/docs-vocabulary.test.ts`, run by `pnpm docs:test` within `pnpm check`, lists the tracked files and finds none of the patterns of §1.1 for `primitive`, `spec`, `execution`, `inference`, `orchestration` and `recollection`, nor `execute_spec`, `/execute\b` or `[eE]xecuteSpec`, outside: `docs/decisions/`, `pnpm-lock.yaml`, `patches/`, the search test itself, and the frozen formats, `packages/workflow-engine/corpus/format-1.json` to `format-6.json`, `src/run-log/format-one.ts` to `format-six.ts`, which hold the frozen states and the frozen event and snapshot schemas, and the tests that write records of those formats. Its only allowed text is the address of the Open Workflow Specification's error types, the CloudEvents specification's address, `inferenceConfig`, `inferenceGeo`, `inference-profile`, `toolSpec`, `code_execution`, `graph/execute` at `docs/reference/reasoning-format.md:98`, the patterns of `packages/api/src/testing/internal-terms.ts:2-6` with the one for `recollection`, the leaks of `internal-terms.test.ts:6-11`, and CLAUDE.md's sentence on what **inference** names; the test also fails when an allowed text no longer occurs, so an exception that stops applying is removed. +- **The run log under its own kind.** The test of §3.3: after workflows of every kind have run, no stream of the brain whose kind key is `runs/` begins with `input_applied`. +- **The internal terms.** `packages/api/src/testing/internal-terms.ts:1-18` gains `/\brecollection\b/iu` and keeps the others, so a summary or a served sentence that says an old word fails as it does today. +- **The served texts at their measured sizes.** The instructions at 1,890 on `/mcp`, 1,495 on the org endpoint and 1,850 on a brain's, and 1,573, 1,610, 1,468 and 1,573 for a key that may only read (`tool-testing-instructions.test.ts:14-17,60`); the recipes at 1,764, 1,762, 2,049 and 1,285 bytes, and `give-tools` and `first-brain` at 2,309 and 1,854 with interaction functions (`served-guides.test.ts:187,206-207`); every tool description within 800 characters and 3 to 8 sentences, `create_definition`'s at 788 in 5 and `list_interactions`' still at 724 in 5 (`answering-what-runs-wait-on.test.ts:206-212`), and every argument's within 300; and the served listings within 40,000, 9,000 and 32,000 bytes (`served-schemas.test.ts:82-86`). +- **The tools.** 25 on `/mcp` (`packages/server/src/mcp/served-tools.test.ts:81`, `mcp-endpoint-workflows.test.ts:82`), 8 on the org endpoint and 19 on a brain's, as the instructions' fixture lists them (`packages/api/src/testing/served-instructions.ts:42-49`); the smoke's count (`.github/workflows/ci.yml:214`), its list of tools (`:215`), its calls (`:218-221,270-275,303-309,335-348,381,396-416,437-444,465-477,502-503,513-525`) through the new names, routes, values and fields, and its container named `inference` (`:259,279-280`), named `reasoning`. +- **The gate with PostgreSQL**, since the ledger's suite, its run filters, its projections and the index's plan check run on both stores (`packages/ledger/README.md:386`). +- **The projections.** A ledger opened with the mappings makes and fills `run_outcomes_3`, `open_requests_5` and `conversations_2`, and drops the versions before, as `projection-table-behaviour.ts` and `run-outcome-table-behaviour.ts` test today; `get_brain_analytics` filtered by `type=reasoning` reads `definition_type`. +- **The engine.** The corpus decodes every committed stream and snapshot, formats 1 to 7, each with the schemas of its own format, and loads it to the state it recorded (`src/run-log/corpus.test.ts:16,38`); `format-six.ts` reads a format-6 state, event and snapshot strictly, outputs and call keys with `executionId` included, and upcasts the state to `runId`; a format-6 log of a call whose arguments say `execute_spec` and `primitive` loads unchanged. The fifteen input logs recorded again differ from the committed ones only in the renamed names and values, the format number, the file name of `execute-spec.json`, and the byte counts that measure them, `historyBytes` among them. +- **The documentation gate** with the regenerated taxonomy asset (`pnpm docs:taxonomy`, `docs:check`), the served format guides within 65,536 bytes, and no link to a renamed heading left. + +## Build + +One pull request, whose gate passes on its final commit. The steps below are the order of the work, not commits each gated: the server's tests run the documentation's examples through the real server, as the interaction format's example (`packages/server/src/interaction/documented-examples.test.ts:11-25`) and the first-workflow tutorial's calls (`packages/server/src/testing/servers/tutorial-calls.ts:75-87`) do, so the pages and the code they run agree only once both are renamed. Within each step the packages change in the order of their dependencies: `operations`; `ledger`, `mcp` and `brains`; `workflow-engine`; `definitions`; `workflow-host` and `api`; `computation`, `reasoning`, `interaction` and `recall`; `coordination`, which depends on `api` and `workflow-host`; `server`. + +1. **The moves.** `primitives/` to `capabilities/`, the three renamed capability packages and `packages/specs` to `packages/definitions`, with their package names, every import, the workspace files of §5.1 and a regenerated lockfile; `primitives/dream` removed; the taxonomy asset regenerated. No word in a file changes but the package and folder names. +2. **The contract and the type values.** `Capability` and the identifiers of §5.4, the folders and files of §5.2, the one discriminator of §5.3 with the taxonomy asset, and the values `reasoning`, `recall` and `workflow` in place of `inference`, `recollection` and `orchestration` wherever a type is written, the streams `specs/` and the wire `primitive` included. +3. **The ledger.** The event types and fields of §3.1 and §3.2, the stream kinds of §3.3 with every run-log site and the test that no `runs/` stream begins with `input_applied`, the facts and reserved attributes of §3.4, the projections at their next versions and the host's columns of §3.5, the index's expression, the ledger port's selection, `TODO.md` of §3.7, and the event types the smoke reads (`.github/workflows/ci.yml:413-416`). +4. **The API and MCP.** The tools, routes, fields and outputs of §2.1 and §2.2, the descriptions of §2.6, the instructions of §2.4 without the wire sentence, the recipes of §2.5, the pages served as guides (the five format references and the terminology page, whose words the guides' tests read), the metadata key of §2.7, the settings and logs of §2.8, and the smoke's tools and routes. +5. **The workflow format.** `call: run_definition` with `with.type`, `$runtime.metadata.type`, the engine's `runId` in its state, outputs, call keys and snapshots as format 7, with format 6's state and the event and snapshot schemas of formats 1 to 6 frozen and the README's rule saying so, the corpus `format-7.json`, and the input logs recorded again. +6. **The words for people.** The rest of §1.3, the HTTP and MCP references, the engineering pages, the tutorials and concepts, the package READMEs of §5.5, CLAUDE.md of §8, `CONTRIBUTING.md`, the pull request template and the scripts; this record in `docs/decisions` with its row in the index. +7. **The search test** of the verification, which finds nothing. + +The build of 0021, asking a system, follows this pull request and starts from its main; this pull request does not touch 0021's record or its build. 0021's builder measures its served texts again after the rename. From 0021's own figures (`0021-asking-a-system.md`, its Verification and Self-check) less what this rename takes from the same texts as measured here, they come to 1,514 characters of instructions on the org endpoint, 1,842 on a brain's and 1,882 on `/mcp` (1,514, 1,942 and 1,982 in 0021, less 100 on the two that list a definition tool), `give-tools` at 2,598 bytes with interaction functions (2,601, less 3), `run_definition`'s description at 766 characters (789, less 23) and `cancel_run`'s at 671 (683, less 12), each within its bound. + +## Self-check + +- Every count above was measured with the patterns of §1.1 over the 2,089 tracked files of the tree b1401264 merged: the per-folder table, the per-file table, the union of 952 files, the 483 tests, the 239 occurrences that stay and the 13,684 that change come from the same run, and the 687 moved and 181 renamed files, the 113 file names and the 44 exported identifiers from the tracked list and the entries each package exports. +- The instruction lengths were measured with a copy of `instructionsFor` that first gave today's 1,495, 1,950 and 1,990, and 1,673, 1,710, 1,468 and 1,673; the recipe sizes with `Buffer.byteLength` on texts that first gave today's 2,052, 1,761, 2,312 and 1,851; the descriptions by evaluating the 23 arrays of literal text as written and the two built from expressions with the values they read; the two `list_tool_servers` descriptions from the sentences they share. +- Every route was read from its registration, every event type from `reserved-attributes.ts`, every stream kind from the function that makes it and every site that names it, every projection and host table from its definition and the writes into it, and every setting from where the server reads it; the 25 tools from the fixture the instructions' tests use, which matches `served-tools.test.ts:81`, `mcp-endpoint-workflows.test.ts:82` and the smoke's count. +- The engine's run log was read from its event, output, call-key and snapshot schemas and from the committed `format-6.json`, whose 14 outputs and snapshot all carry `executionId`. +- The first version's claims that no longer hold were checked and corrected: the tool count, the state format, the definition stream's name, the JSON Schema identifiers, the deletions, and the adapter factories that CLAUDE.md now names. +- Not settled here, and left to whoever accepts this record: the sentence of CLAUDE.md that makes a definition's `type` its one discriminator in place of the verb kinds (§8), the one point its owner may refuse; that a record with a `type` of its own says `definition_type` while a resource says `type` (§1); the rewording of the terminology page's four sentences on runs (§1.3), which could instead be an allowed text of the search; the names chosen where the plain rename collides, `CapabilityDeclaration`, `CapabilityAnswer`, `RunStreamAddress`, `RunIdInputField`, `longestAnyRunMs`, `datasetRunStream`, `workflowMachineOptions` and `StartedRun`; and whether the engine's `Executor` and the verb `execute` should also go, which this record keeps. diff --git a/docs/decisions/README.md b/docs/decisions/README.md index 8e931a0a9..04ea50ac5 100644 --- a/docs/decisions/README.md +++ b/docs/decisions/README.md @@ -14,6 +14,7 @@ Use a numbered Markdown filename for each decision and link to it from the affec | [2. Reading the runs of a brain and what happened in it](0002-reading-runs-and-brain-events.md) | accepted 2026-10-05, amended 2026-10-05 and 2026-10-07 | | [3. MCP servers: a brain reaches the outside world through configured MCP servers](0003-mcp-servers.md) | accepted 2026-10-05, amended 2026-10-06, 2026-10-07 and 2026-10-08 | | [5. Computation functions: a brain computes with a program, deterministically and without the outside world](0005-computation-functions.md) | proposed 2026-10-05, built 2026-10-06 | +| [7. One vocabulary: the product's words are the only words, in text, code, the API and the ledger](0007-one-vocabulary.md) | accepted 2026-10-09 | | [10. Interaction functions: a brain asks a person or a system and waits for the answer](0010-interaction-functions.md) | accepted 2026-10-07, amended 2026-10-08 | | [11. Waiting calls: a workflow waits for a run that finishes later, and a run can be cancelled](0011-waiting-calls.md) | accepted 2026-10-07 | | [14. A warm worker pool: a run costs its own work, in a worker kept between jobs and let go of after any bad one](0014-warm-worker-pool.md) | proposed 2026-10-06, built 2026-10-07 | diff --git a/docs/engineering/get-started/self-hosted.md b/docs/engineering/get-started/self-hosted.md index 932a3687d..7ea2f22ca 100644 --- a/docs/engineering/get-started/self-hosted.md +++ b/docs/engineering/get-started/self-hosted.md @@ -63,4 +63,4 @@ Each tool's description explains the supported definition format. This example d To reuse your first function, ask another question in a new conversation connected to the same runtime. For a shared deployment, use a persistent container and scoped API keys. Local mode is only for your own machine; do not expose it to colleagues through a proxy. See [Run in a container](../self-host/container.md) and [Authentication and security](../self-host/security.md). -To try it without an assistant, `scripts/try-inference.sh http://localhost:8080 ` and `scripts/try-workflows.sh http://localhost:8080 ` run a reasoning function and a workflow over HTTP and print what happened. [How it works](../reference/http-tutorial.md) walks through the same steps. +To try it without an assistant, `scripts/try-reasoning.sh http://localhost:8080 ` and `scripts/try-workflows.sh http://localhost:8080 ` run a reasoning function and a workflow over HTTP and print what happened. [How it works](../reference/http-tutorial.md) walks through the same steps. diff --git a/docs/engineering/index.md b/docs/engineering/index.md index c29228e77..3a7846e99 100644 --- a/docs/engineering/index.md +++ b/docs/engineering/index.md @@ -20,6 +20,6 @@ Workflows run in the server itself, on the workflow engine of `@beonauto/workflo - [HTTP API](reference/http.md) and [HTTP walkthrough](reference/http-tutorial.md) - [MCP transport and tool behavior](reference/mcp.md) - [Complete reasoning function format](reference/reasoning-format.md) -- [Workflow format and execution](reference/workflow-format.md) +- [Workflow format and runs](reference/workflow-format.md) Keep these notes accurate when changing the runtime. Publish the relevant user-facing behavior separately in the public guides. Package READMEs retain code entry points and test instructions; architecture decisions live in [decisions](../decisions/README.md). diff --git a/docs/engineering/reference/http-tutorial.md b/docs/engineering/reference/http-tutorial.md index 9c202d8d0..c9163eba8 100644 --- a/docs/engineering/reference/http-tutorial.md +++ b/docs/engineering/reference/http-tutorial.md @@ -14,7 +14,7 @@ curl http://localhost:8080/v1/orgs/local/brains/sales `PUT /v1/orgs/local/brains/sales` replaces the name and the description, and `POST /v1/orgs/local/brains/sales/retire` retires the brain for good. -A brain has reusable function and workflow definitions, stored as named, versioned `specs` in the API. A reasoning function calls a language model, with the key from your `.env`. Write its definition in `greeting.md`: YAML front matter that names the model, then a Liquid template that renders the prompt from the input. +A brain has reusable function and workflow definitions, stored as named, versioned definitions. A reasoning function calls a language model, with the key from your `.env`. Write its definition in `greeting.md`: YAML front matter that names the model, then a Liquid template that renders the prompt from the input. ```markdown --- @@ -32,12 +32,12 @@ Create the reasoning function in the brain, run it, and read the run back with t ```bash jq --null-input --rawfile source greeting.md '{name: "greeting", source: $source}' | - curl --request POST http://localhost:8080/v1/orgs/local/brains/sales/specs/inference \ + curl --request POST http://localhost:8080/v1/orgs/local/brains/sales/definitions/reasoning \ --header 'content-type: application/json' --data @- -curl --request POST http://localhost:8080/v1/orgs/local/brains/sales/specs/inference/greeting/execute \ +curl --request POST http://localhost:8080/v1/orgs/local/brains/sales/definitions/reasoning/greeting/run \ --header 'content-type: application/json' \ - --data '{"input":{"name":"Ada"},"execution_id":"0199a3c4-7d2e-7c1a-9b3f-2f1e0d9c8b7a"}' -curl http://localhost:8080/v1/orgs/local/brains/sales/executions/0199a3c4-7d2e-7c1a-9b3f-2f1e0d9c8b7a + --data '{"input":{"name":"Ada"},"run_id":"0199a3c4-7d2e-7c1a-9b3f-2f1e0d9c8b7a"}' +curl http://localhost:8080/v1/orgs/local/brains/sales/runs/0199a3c4-7d2e-7c1a-9b3f-2f1e0d9c8b7a ``` A document with a problem is rejected with every problem and its line. An input that does not match the schema is rejected before any model is called. A provider that is not configured answers `503`; its missing settings are reported to the operator, not exposed to the caller. [Reasoning function format](reasoning-format.md) describes the document and recorded result. [HTTP API](http.md) describes the operations. An assistant does the same over [MCP](mcp.md), where tool descriptions explain the supported definition formats. @@ -53,9 +53,9 @@ document: summary: Greets a customer, then waits for their reply. do: - greet: - call: execute_spec + call: run_definition with: - primitive: inference + type: reasoning name: greeting input: name: ${ .name } @@ -74,15 +74,15 @@ Create it, execute it, and send it the event it waits for: ```bash jq --null-input --rawfile source welcome.yaml '{name: "welcome", source: $source}' | - curl --request POST http://localhost:8080/v1/orgs/local/brains/sales/specs/orchestration \ + curl --request POST http://localhost:8080/v1/orgs/local/brains/sales/definitions/workflow \ --header 'content-type: application/json' --data @- -curl --request POST http://localhost:8080/v1/orgs/local/brains/sales/specs/orchestration/welcome/execute \ +curl --request POST http://localhost:8080/v1/orgs/local/brains/sales/definitions/workflow/welcome/run \ --header 'content-type: application/json' \ - --data '{"input":{"name":"Ada"},"execution_id":"0199a3c4-7d2e-7c1a-9b3f-2f1e0d9c8b7b"}' -curl --request POST http://localhost:8080/v1/orgs/local/brains/sales/executions/0199a3c4-7d2e-7c1a-9b3f-2f1e0d9c8b7b/events \ + --data '{"input":{"name":"Ada"},"run_id":"0199a3c4-7d2e-7c1a-9b3f-2f1e0d9c8b7b"}' +curl --request POST http://localhost:8080/v1/orgs/local/brains/sales/runs/0199a3c4-7d2e-7c1a-9b3f-2f1e0d9c8b7b/events \ --header 'content-type: application/json' \ --data '{"event":{"type":"com.acme.customer.replied","data":"Thank you!"}}' -curl http://localhost:8080/v1/orgs/local/brains/sales/executions/0199a3c4-7d2e-7c1a-9b3f-2f1e0d9c8b7b +curl http://localhost:8080/v1/orgs/local/brains/sales/runs/0199a3c4-7d2e-7c1a-9b3f-2f1e0d9c8b7b ``` -Starting the workflow answers `started` at once; the run reads `started` until the workflow ends, and then `succeeded` with `{"greeting": ..., "reply": "Thank you!"}`. The greeting has its own recorded run under an id derived from the workflow's run, made by the caller who started the workflow. The public [workflow format](../../reference/workflow-format.md) describes the supported steps, and the repository-only [workflow execution notes](workflow-format.md) how they run. +Starting the workflow answers `started` at once; the run reads `started` until the workflow ends, and then `succeeded` with `{"greeting": ..., "reply": "Thank you!"}`. The greeting has its own recorded run under an id derived from the workflow's run, made by the caller who started the workflow. The public [workflow format](../../reference/workflow-format.md) describes the supported steps, and the repository-only [workflow run notes](workflow-format.md) how they run. diff --git a/docs/engineering/reference/http.md b/docs/engineering/reference/http.md index 9f1d9c504..26a94b376 100644 --- a/docs/engineering/reference/http.md +++ b/docs/engineering/reference/http.md @@ -1,6 +1,6 @@ # HTTP API -The runtime exposes the same operations through HTTP and [MCP](mcp.md). The API calls a definition a `spec` and its run an `execution`. +The runtime exposes the same operations through HTTP and [MCP](mcp.md). The `type` field names the type of a definition: `reasoning`, `interaction`, `computation`, `recall` or `workflow`. Use an API key as `Authorization: Bearer `, except in loopback-only local mode. See [Authentication and security](../self-host/security.md). For executable examples, follow the [HTTP walkthrough](http-tutorial.md). @@ -32,63 +32,63 @@ Read operations need `org:read`, and writes need `org:write`; `list_brains` is p These routes are relative to `/v1/orgs/{org}/brains/{brain}`: -| Operation | Method and route | Input | -| ---------------------- | ---------------------------------------- | ---------------------------------------------- | -| `create_spec` | `POST /specs/{primitive}` | `name`, `source` | -| `list_specs` | `GET /specs/{primitive}` | Optional `include_retired` | -| `get_spec` | `GET /specs/{primitive}/{name}` | Path parameters | -| `update_spec` | `PUT /specs/{primitive}/{name}` | `source` | -| `retire_spec` | `POST /specs/{primitive}/{name}/retire` | Path parameters | -| `execute_spec` | `POST /specs/{primitive}/{name}/execute` | Optional `input`, optional UUID `execution_id` | -| `get_execution` | `GET /executions/{execution_id}` | Execution id in path | -| `cancel_execution` | `POST /executions/{execution_id}/cancel` | Optional `reason`, for a workflow's run | -| `send_execution_event` | `POST /executions/{execution_id}/events` | `event`, for a workflow's run | +| Operation | Method and route | Input | +| ------------------- | ---------------------------------------- | ---------------------------------------- | +| `create_definition` | `POST /definitions/{type}` | `name`, `source` | +| `list_definitions` | `GET /definitions/{type}` | Optional `include_retired` | +| `get_definition` | `GET /definitions/{type}/{name}` | Path parameters | +| `update_definition` | `PUT /definitions/{type}/{name}` | `source` | +| `retire_definition` | `POST /definitions/{type}/{name}/retire` | Path parameters | +| `run_definition` | `POST /definitions/{type}/{name}/run` | Optional `input`, optional UUID `run_id` | +| `get_run` | `GET /runs/{run_id}` | Run id in path | +| `cancel_run` | `POST /runs/{run_id}/cancel` | Optional `reason`, for a workflow's run | +| `send_run_event` | `POST /runs/{run_id}/events` | `event`, for a workflow's run | -Supported primitive identifiers are `inference` and `orchestration`. A reasoning function uses the [Markdown prompt format](reasoning-format.md). A workflow uses the [YAML workflow format](../../reference/workflow-format.md), which [workflow execution](workflow-format.md) runs. +A reasoning function uses the [Markdown prompt format](reasoning-format.md). A workflow uses the [YAML workflow format](../../reference/workflow-format.md); [Workflow format and runs](workflow-format.md) says how it runs. -Names contain 3 to 48 lowercase letters, digits and hyphens, beginning with a letter. Source documents may be at most 65,536 UTF-8 bytes. A name is unique within its primitive and brain and cannot be reused after retirement. +Names contain 3 to 48 lowercase letters, digits and hyphens, beginning with a letter. Source documents may be at most 65,536 UTF-8 bytes. A name is unique within its type and brain and cannot be reused after retirement. -Changing a document creates a new version. Updating it with identical source succeeds without recording a change. Execution uses the active latest version. Retirement is permanent: retired definitions can be read but cannot be edited or run. +Changing a document creates a new version. Updating it with identical source succeeds without recording a change. A run uses the active latest version. Retirement is permanent: retired definitions can be read but cannot be edited or run. -Queries need `brain:read`; commands, including execution and events, need `brain:write`. An API key with read access alone cannot run a function. +Queries need `brain:read`; commands, including running a definition and sending an event, need `brain:write`. An API key with read access alone cannot run a function. ## Runs and results -A run includes `execution_id`, `primitive`, `name`, `spec_version`, `status`, timestamps and caller identity. It includes an `output` when successful or a rejection when rejected. Reading a run with `get_execution` also returns its detailed `record`. +A run includes `run_id`, `type`, `name`, `definition_version`, `status`, timestamps and caller identity. It includes an `output` when successful or a rejection when rejected. Reading a run with `get_run` also returns its detailed `record`. -Reasoning functions normally complete within the execute request. Workflows return `started` while work continues, unless the run ended before its first wait; `get_execution` shows whether a run ended, as `succeeded`, `rejected` or `failed`, or is still `started`. An answer to what a run waits on goes to the run that waits, and creates no other run: `answer_interaction` answers the request of an interaction function, in the shape of the `answer_schema` that `list_interactions` shows with the request, the schema it recorded when asked, and `send_execution_event` gives a waiting workflow an event it listens for. `cancel_execution` records a cancel on a workflow run that is still `started`, on any server, and the run ends `rejected` with the reason `cancelled` within a moment ([Cancelling a run](../../reference/http.md#cancelling-a-run)); it answers `conflict` for a run that has ended or one that runs within its request. +Reasoning functions normally complete within the request that runs them. Workflows return `started` while work continues, unless the run ended before its first wait; `get_run` shows whether a run ended, as `succeeded`, `rejected` or `failed`, or is still `started`. An answer to what a run waits on goes to the run that waits, and creates no other run: `answer_interaction` answers the request of an interaction function, in the shape of the `answer_schema` that `list_interactions` shows with the request, the schema it recorded when asked, and `send_run_event` gives a waiting workflow an event it listens for. `cancel_run` records a cancel on a workflow run that is still `started`, on any server, and the run ends `rejected` with the reason `cancelled` within a moment ([Cancelling a run](../../reference/http.md#cancelling-a-run)); it answers `conflict` for a run that has ended or one that runs within its request. Inputs may be at most 256 KiB as encoded JSON and nest at most 512 levels deep, which `invalid_input` at `/input` refuses before anything is recorded; the data of an event sent to a run or published to a brain may nest at most 510, so that a run can hold the event in a list. Output and record together may be at most 1 MiB. The runtime applies these limits independently of the request-body limit. ## Published events -`publish_event` is `POST /events` relative to `/v1/orgs/{org}/brains/{brain}`, under `brain:write`. Its body is `{ event }`, a CloudEvents 1.0 event with `source`, a URI reference, and `type`, and optionally `specversion` (`1.0`), `id`, `subject`, `time` in RFC 3339, `datacontenttype`, `dataschema` and `data`; any other attribute is an extension, at most 32, named in 1 to 20 lowercase letters and digits, whose value is text, a boolean or an integer, kept as given. Text holds no control character, lone surrogate or noncharacter; `id`, `type` and `subject` hold a character that is not a space; `datacontenttype` is a media type; and a second of 60 is taken only at the end of a day in UTC. The runtime fills in a missing `id`, a version 7 UUID, and a missing `time`, the moment it records the event, and answers `{ id, time, recorded_at }`. The event is appended as `event_published` to a stream of its own, `events/`, the uuid name-based on its source and id, only while that stream is empty: the same event again records nothing and answers the first record, a retry without `time` included and one that gives a `time` to an event first published without one, which answers the time filled in first; times compare as instants, and a different event under the same source and id is `conflict`. With its id and time, the event takes at most 240 KiB as JSON, or it is `invalid_input` at `/event`; the types and sources of the brain's own facts are `invalid_input` at `/event/type` and `/event/source`. [`packages/specs`](https://github.com/BeOnAuto/auto-brain/blob/main/packages/specs/README.md#events-of-a-brain) describes the stream and the facts. +`publish_event` is `POST /events` relative to `/v1/orgs/{org}/brains/{brain}`, under `brain:write`. Its body is `{ event }`, a CloudEvents 1.0 event with `source`, a URI reference, and `type`, and optionally `specversion` (`1.0`), `id`, `subject`, `time` in RFC 3339, `datacontenttype`, `dataschema` and `data`; any other attribute is an extension, at most 32, named in 1 to 20 lowercase letters and digits, whose value is text, a boolean or an integer, kept as given. Text holds no control character, lone surrogate or noncharacter; `id`, `type` and `subject` hold a character that is not a space; `datacontenttype` is a media type; and a second of 60 is taken only at the end of a day in UTC. The runtime fills in a missing `id`, a version 7 UUID, and a missing `time`, the moment it records the event, and answers `{ id, time, recorded_at }`. The event is appended as `event_published` to a stream of its own, `events/`, the uuid name-based on its source and id, only while that stream is empty: the same event again records nothing and answers the first record, a retry without `time` included and one that gives a `time` to an event first published without one, which answers the time filled in first; times compare as instants, and a different event under the same source and id is `conflict`. With its id and time, the event takes at most 240 KiB as JSON, or it is `invalid_input` at `/event`; the types and sources of the brain's own facts are `invalid_input` at `/event/type` and `/event/source`. [`packages/definitions`](https://github.com/BeOnAuto/auto-brain/blob/main/packages/definitions/README.md#events-of-a-brain) describes the stream and the facts. ## Run history and brain events These queries are relative to `/v1/orgs/{org}/brains/{brain}` and need `brain:read`; they work on a retired brain, whose commands are refused with `conflict`: -| Operation | Method and route | Input | -| ----------------------- | ---------------------------------------- | ----------------------------------------------------------------------- | -| `list_executions` | `GET /executions` | Optional `primitive`, `name`, `status`, `limit` and `cursor` | -| `get_execution_history` | `GET /executions/{execution_id}/history` | Execution id in path; optional `order`, `limit` and `cursor` | -| `list_brain_events` | `GET /events` | Optional `type`, `since`, `execution_id`, `order`, `limit` and `cursor` | +| Operation | Method and route | Input | +| ------------------- | ---------------------------- | ----------------------------------------------------------------- | +| `list_runs` | `GET /runs` | Optional `type`, `name`, `status`, `limit` and `cursor` | +| `get_run_history` | `GET /runs/{run_id}/history` | Run id in path; optional `order`, `limit` and `cursor` | +| `list_brain_events` | `GET /events` | Optional `type`, `since`, `run_id`, `order`, `limit` and `cursor` | -`list_executions` answers `{ executions, has_more, next_cursor }`, newest first by the position of each run's first start in the ledger, so a run started again with the same id keeps its place. A listed run is the run as `get_execution` shows it, without `output`, `record` and the detail and issues of a rejection; a run started again and since finished shows its first start, where `get_execution` shows the latest. The ledger answers every filter as it examines the runs, before it counts the page: `status` from the type of each run's latest fact, and `primitive` and `name` from the run's first start. A page with any filter looks at up to 1,000 runs and holds as many matching runs as `limit` asks while it finds them, so it is short, or empty, with `has_more` true only when it has looked at 1,000 or loaded 4 MiB. +`list_runs` answers `{ runs, has_more, next_cursor }`, newest first by the position of each run's first start in the ledger, so a run started again with the same id keeps its place. A listed run is the run as `get_run` shows it, without `output`, `record` and the detail and issues of a rejection; a run started again and since finished shows its first start, where `get_run` shows the latest. The ledger answers every filter as it examines the runs, before it counts the page: `status` from the type of each run's latest fact, and `type` and `name` from the run's first start. A page with any filter looks at up to 1,000 runs and holds as many matching runs as `limit` asks while it finds them, so it is short, or empty, with `has_more` true only when it has looked at 1,000 or loaded 4 MiB. -`get_execution_history` answers `{ events, has_more, next_cursor }` for one run in the ledger's order, oldest first unless `order` is `desc`, which is also the order of causes, since a message is appended only after the one that caused it, and `not_found` for a run the brain does not have, decided as `get_execution` decides it. On PostgreSQL an oldest-first page of a run whose records are still behind a write open in the ledger's database is empty, with `next_cursor` null; read it again once the write ends. It reads the run's execution facts and, for a workflow, its run's log: one `workflow_input_applied` event for each input the run took, followed by one event for each step entry of that input. `list_brain_events` answers the same shape for the brain's whole partition of the ledger, newest first unless `order` is `asc`. `type` takes one public event type; `since` keeps what the store recorded from that time on, in either order, so an event's own `at` may be a little earlier; `execution_id` keeps every message whose correlation is that run, the whole tree of a run that no other run started, through the ledger's index on the brain and the correlation. The id of a run another run started answers nothing, since its messages carry the correlation of the run at the top of its tree. A hosted runtime that keeps run logs outside the ledger must answer this filter from both places, or keep the logs in the ledger. The brain's own creation, update and retirement are recorded in the org's registry, not the brain, and are not among its events. +`get_run_history` answers `{ events, has_more, next_cursor }` for one run in the ledger's order, oldest first unless `order` is `desc`, which is also the order of causes, since a message is appended only after the one that caused it, and `not_found` for a run the brain does not have, decided as `get_run` decides it. On PostgreSQL an oldest-first page of a run whose records are still behind a write open in the ledger's database is empty, with `next_cursor` null; read it again once the write ends. It reads the facts of the run's stream and, for a workflow, its run log: one `workflow_input_applied` event for each input the run took, followed by one event for each step entry of that input. `list_brain_events` answers the same shape for the brain's whole partition of the ledger, newest first unless `order` is `asc`. `type` takes one public event type; `since` keeps what the store recorded from that time on, in either order, so an event's own `at` may be a little earlier; `run_id` keeps every message whose correlation is that run, the whole tree of a run that no other run started, through the ledger's index on the brain and the correlation. The id of a run another run started answers nothing, since its messages carry the correlation of the run at the top of its tree. A hosted runtime that keeps run logs outside the ledger must answer this filter from both places, or keep the logs in the ledger. The brain's own creation, update and retirement are recorded in the org's registry, not the brain, and are not among its events. -An event is `{ id, cursor, causation_id, at, type, summary, data }`: `id` is the id of the message it presents, or for a step event the id derived below, `cursor` the opaque place to read on from, which for a `workflow_input_applied` event and a step event points inside its record (see below), `causation_id` the id of the message or step event that directly caused it, or null, `at` the event's own time, `type` one of `execution_started`, `execution_succeeded`, `execution_rejected`, `execution_failed`, `tool_call_started`, `tool_call_answered`, `workflow_input_applied`, `step_started`, `step_waiting`, `step_finished`, `step_failed`, `step_skipped`, `spec_created`, `spec_updated`, `spec_retired`, `event_published` and `reaction_refused`, `summary` plain words, and `data` at most 4 KiB of JSON. Inputs, outputs, records, documents and schemas appear as byte sizes; a rejection's detail is cut at a code point and its issues shown as a count and the first five; a description is cut at 300 characters and warnings counted. `execution_deferred`, which a run that finishes later records, stays in the ledger, where `status` and a retry read it, and is shown as no event; nothing names it as its cause, so a workflow run's start is followed by its inputs and steps directly. Tool events include sizes and digests, plus at most 2 KiB each of arguments or results when the operator records content. A `workflow_input_applied` event holds the kind and key of the input, how many steps it moved and the first five, each with its task, run and outcome, the `rejection` of an answer that names its kind and because, which its summary also says in words, and the kinds of what the run did next, never the run's data. [`packages/specs`](https://github.com/BeOnAuto/auto-brain/blob/main/packages/specs/README.md#reading-runs) lists every field. +An event is `{ id, cursor, causation_id, at, type, summary, data }`: `id` is the id of the message it presents, or for a step event the id derived below, `cursor` the opaque place to read on from, which for a `workflow_input_applied` event and a step event points inside its record (see below), `causation_id` the id of the message or step event that directly caused it, or null, `at` the event's own time, `type` one of `run_started`, `run_succeeded`, `run_rejected`, `run_failed`, `tool_call_started`, `tool_call_answered`, `workflow_input_applied`, `step_started`, `step_waiting`, `step_finished`, `step_failed`, `step_skipped`, `definition_created`, `definition_updated`, `definition_retired`, `event_published` and `reaction_refused`, `summary` plain words, and `data` at most 4 KiB of JSON. Inputs, outputs, records, documents and schemas appear as byte sizes; a rejection's detail is cut at a code point and its issues shown as a count and the first five; a description is cut at 300 characters and warnings counted. `run_deferred`, which a run that finishes later records, stays in the ledger, where `status` and a retry read it, and is shown as no event; nothing names it as its cause, so a workflow run's start is followed by its inputs and steps directly. Tool events include sizes and digests, plus at most 2 KiB each of arguments or results when the operator records content. A `workflow_input_applied` event holds the kind and key of the input, how many steps it moved and the first five, each with its task, run and outcome, the `rejection` of an answer that names its kind and because, which its summary also says in words, and the kinds of what the run did next, never the run's data. [`packages/definitions`](https://github.com/BeOnAuto/auto-brain/blob/main/packages/definitions/README.md#reading-runs) lists every field. ### Ids, causes and correlations -Every message the ledger writes carries, in its metadata, its id, its cause and its correlation. The id of a message is the version 5 UUID of the UTF-8 JSON array `[stream, position]`, the message's full stream name such as `brain/acme/sales/runs/` and its position in that stream from 1, in the ledger's namespace `5f8d7a5a-8d50-4340-9de3-ba16e8fe732e` (`messageIdOf` of `@beonauto/operations`). A step event is no message: its id is the version 5 UUID of the JSON array `[execution id, reference, run, outcome, times]` of its entry in the record, in the namespace `1e08cd36-b0d0-4ce3-bd92-cd36f7e6c276` (`stepEventIdOf` of `@beonauto/workflow-engine`), so a reader of the raw ledger derives every id the API answers from the stream, the position and the records, and so does the hosted runtime. A step entry's `caused_by` names the entry whose outcome moved it, or `input`, which stands for the record itself; a record's own cause is the `step_waiting` of the entry it resumed, the `execution_started` of the run for its first input, the record that armed the timer of a yield or a timeout, which the host keeps beside the timer, or nothing. The correlation of a run no other run started is its own execution id, and a run another run started, as a function a workflow calls, carries the correlation it is given, so the whole tree shares one. +Every message the ledger writes carries, in its metadata, its id, its cause and its correlation. The id of a message is the version 5 UUID of the UTF-8 JSON array `[stream, position]`, the message's full stream name such as `brain/acme/sales/runs/` and its position in that stream from 1, in the ledger's namespace `5f8d7a5a-8d50-4340-9de3-ba16e8fe732e` (`messageIdOf` of `@beonauto/operations`). A step event is no message: its id is the version 5 UUID of the JSON array `[run id, reference, run, outcome, times]` of its entry in the record, in the namespace `1e08cd36-b0d0-4ce3-bd92-cd36f7e6c276` (`stepEventIdOf` of `@beonauto/workflow-engine`), so a reader of the raw ledger derives every id the API answers from the stream, the position and the records, and so does the hosted runtime. A step entry's `caused_by` names the entry whose outcome moved it, or `input`, which stands for the record itself; a record's own cause is the `step_waiting` of the entry it resumed, the `run_started` of the run for its first input, the record that armed the timer of a yield or a timeout, which the host keeps beside the timer, or nothing. The correlation of a run no other run started is its own run id, and a run another run started, as a function a workflow calls, carries the correlation it is given, so the whole tree shares one. -`limit` is 1 to 100, 20 by default, and counts the events a page answers with, step events included. A page that ends between the step events of one record answers a `next_cursor` that points inside the record, the record's cursor with one more part, the index of the last event answered; a read from it begins with that record and leaves out what was answered, in either order. The `cursor` of a `workflow_input_applied` event and of each step event points inside its record the same way, at the event's own index, 0 for the input, so a read from it goes on with the next event in either order; the `cursor` of any other event is its record's own, the only event of its record, which reads on after it. A page also stops after loading 4 MiB of stored data and, with `status`, `primitive`, `name` or `type`, after looking at 1,000 runs or records, so it may hold fewer items than `limit`, or none, while `has_more` is `true`; read on with `next_cursor` as `cursor` until it is `null`. A cursor that does not decode or that another brain gave is `invalid_input` at `/cursor`. Cursors are not encrypted: one names a position in the ledger. On PostgreSQL, an oldest-first read stays behind the oldest transaction still writing to the ledger's database, so the newest records can appear a moment later. +`limit` is 1 to 100, 20 by default, and counts the events a page answers with, step events included. A page that ends between the step events of one record answers a `next_cursor` that points inside the record, the record's cursor with one more part, the index of the last event answered; a read from it begins with that record and leaves out what was answered, in either order. The `cursor` of a `workflow_input_applied` event and of each step event points inside its record the same way, at the event's own index, 0 for the input, so a read from it goes on with the next event in either order; the `cursor` of any other event is its record's own, the only event of its record, which reads on after it. A page also stops after loading 4 MiB of stored data and, with `status`, `type` or `name`, after looking at 1,000 runs or records, so it may hold fewer items than `limit`, or none, while `has_more` is `true`; read on with `next_cursor` as `cursor` until it is `null`. A cursor that does not decode or that another brain gave is `invalid_input` at `/cursor`. Cursors are not encrypted: one names a position in the ledger. On PostgreSQL, an oldest-first read stays behind the oldest transaction still writing to the ledger's database, so the newest records can appear a moment later. ## Analytics -`get_brain_analytics`, `GET /analytics` relative to `/v1/orgs/{org}/brains/{brain}` under `brain:read`, answers what the brain's runs did over the last 7, 14 or 30 days (`days`, 7 when left out) or between two days of the calendar (`from` and `to`, both included, at most 366 and to no later than today, in UTC), optionally of one definition (`primitive`, `name`): `{ days, runs, tokens, duration_ms, by_day, by_function }`. A run counts on the day it first started, once it has succeeded, failed or been rejected; durations are nearest-rank percentiles of the runs that succeeded or failed, from their latest start to their end, `null` when there are none; tokens sum what reasoning runs recorded, a rejected run's included. The [HTTP reference](../../reference/http.md#analytics) gives every field. The answer reads `run_outcomes_2`, a table the ledger keeps inside each append of a run's events; the [ledger README](https://github.com/BeOnAuto/auto-brain/blob/main/packages/ledger/README.md#keyed-projections) describes it and how it is filled when a server first starts on an existing ledger. +`get_brain_analytics`, `GET /analytics` relative to `/v1/orgs/{org}/brains/{brain}` under `brain:read`, answers what the brain's runs did over the last 7, 14 or 30 days (`days`, 7 when left out) or between two days of the calendar (`from` and `to`, both included, at most 366 and to no later than today, in UTC), optionally of one definition (`type`, `name`): `{ days, runs, tokens, duration_ms, by_day, by_function }`. A run counts on the day it first started, once it has succeeded, failed or been rejected; durations are nearest-rank percentiles of the runs that succeeded or failed, from their latest start to their end, `null` when there are none; tokens sum what reasoning runs recorded, a rejected run's included. The [HTTP reference](../../reference/http.md#analytics) gives every field. The answer reads `run_outcomes_3`, a table the ledger keeps inside each append of a run's events; the [ledger README](https://github.com/BeOnAuto/auto-brain/blob/main/packages/ledger/README.md#keyed-projections) describes it and how it is filled when a server first starts on an existing ledger. ## Tool servers @@ -100,15 +100,15 @@ The same operation answers at the org, `GET /tool-servers` relative to `/v1/orgs ## Idempotency and retries -Supply `execution_id` when you need to inspect failures or retry a request. Reusing an id with a different function or input returns `conflict`. +Supply `run_id` when you need to inspect failures or retry a request. Reusing an id with a different function or input returns `conflict`. -Once a run succeeds, rejects invalid input or is cancelled, it has a final result. Calling again with the same id and input returns that recorded result. A waiting workflow also returns its existing run without restarting it. A workflow runs once for an execution id: calling again with the id of a workflow that ended without a final result, `unavailable` or `failed`, returns `conflict`; run it again under a new id. +Once a run succeeds, rejects invalid input or is cancelled, it has a final result. Calling again with the same id and input returns that recorded result. A waiting workflow also returns its existing run without restarting it. A workflow runs once for a run id: calling again with the id of a workflow that ended without a final result, `unavailable` or `failed`, returns `conflict`; run it again under a new id. -A run without a final result may be attempted again after an interruption, an unavailable dependency or another recoverable failure. A retry can use the latest definition version, which the new attempt records. Side effects must tolerate at-least-once execution; one recorded final result does not guarantee that an external action ran only once. A reasoning function that calls tools is the exception, since its tools are the side effects: a run whose stream holds a tool call and that did not succeed, or a started run whose function names tools, is not attempted again under its id: the call answers `conflict` with the kind `tools_called`, and a new run needs a new id. +A run without a final result may be attempted again after an interruption, an unavailable dependency or another recoverable failure. A retry can use the latest definition version, which the new attempt records. Side effects must tolerate being carried out more than once; one recorded final result does not guarantee that an external action ran only once. A reasoning function that calls tools is the exception, since its tools are the side effects: a run whose stream holds a tool call and that did not succeed, or a started run whose function names tools, is not attempted again under its id: the call answers `conflict` with the kind `tools_called`, and a new run needs a new id. ## Errors -API errors use RFC 9457 problem documents with `Content-Type: application/problem+json`, whose `type` is `https://on.auto/problems/`, or `https://on.auto/problems/tools_unfinished` for a run that called tools and could not finish `https://on.auto/problems/tools_called` for a run not run again under its id because its tools may have been called, and `https://on.auto/problems/cancelled` for a run that was cancelled ([the list](../../reference/http.md#responses-and-errors)). Inspect `reason` and `detail`; `invalid_input` includes an `errors` list of JSON pointers. A rejection that has a `kind` carries it, and an `unavailable` one its `because`, as extension members: `tools_unfinished` means a run called tools and could not finish, so its tools may have changed something, and a request with the same `execution_id` answers `conflict` with the kind `tools_called`. `Retry-After: 5` comes only with an `unavailable` answer that a retry of the same request may resolve, never with `tools_unfinished`, `tool_not_offered` or `model_not_offered`. +API errors use RFC 9457 problem documents with `Content-Type: application/problem+json`, whose `type` is `https://on.auto/problems/`, or `https://on.auto/problems/tools_unfinished` for a run that called tools and could not finish, `https://on.auto/problems/tools_called` for a run not run again under its id because its tools may have been called, and `https://on.auto/problems/cancelled` for a run that was cancelled ([the list](../../reference/http.md#responses-and-errors)). Inspect `reason` and `detail`; `invalid_input` includes an `errors` list of JSON pointers. A rejection that has a `kind` carries it, and an `unavailable` one its `because`, as extension members: `tools_unfinished` means a run called tools and could not finish, so its tools may have changed something, and a request with the same `run_id` answers `conflict` with the kind `tools_called`. `Retry-After: 5` comes only with an `unavailable` answer that a retry of the same request may resolve, never with `tools_unfinished`, `tool_not_offered` or `model_not_offered`. | Status | Common reason | | ------ | ----------------------------------- | diff --git a/docs/engineering/reference/mcp.md b/docs/engineering/reference/mcp.md index 640ea5840..8fb2a967c 100644 --- a/docs/engineering/reference/mcp.md +++ b/docs/engineering/reference/mcp.md @@ -1,6 +1,6 @@ # Connect an agent over MCP -These instructions cover this development checkout. The current API keeps its existing tool names while human-readable results use reasoning functions, workflows and runs. +These instructions cover this development checkout. The server is also an [MCP](https://modelcontextprotocol.io) server, so an agent can call the same operations as tools. Connect it to `/mcp`, with an [API key](../self-host/security.md#api-keys): @@ -18,7 +18,7 @@ The server is also an [MCP](https://modelcontextprotocol.io) server, so an agent Most MCP clients take an entry of this shape. In Claude Code, `claude mcp add --transport http auto-brain http://localhost:8080/mcp --header "Authorization: Bearer "` adds the same. In [local mode](../self-host/security.md#local-mode), leave out the header. -`/mcp` serves every tool on one connection, so an agent can create a brain and work in it at once. The org is the key's own, since a key belongs to one org, and never an argument; in local mode, where no key names one, it is `local`, so the brains an agent creates there are the ones `GET /v1/orgs/local/brains` lists. The org id `local` is reserved for local mode: the key command refuses it, and an `API_KEYS` entry with it stops the server at start-up, so no key can reach the brains made in local mode. The brain tools (`create_brain`, `list_brains`, `get_brain`, `update_brain` and `retire_brain`) and `list_models` are as on HTTP. Every tool that works inside a brain (`create_spec`, `list_specs`, `get_spec`, `update_spec`, `retire_spec`, `execute_spec`, `get_execution`, `cancel_execution`, `list_executions`, `get_execution_history`, `get_brain_analytics`, `list_brain_events`, `publish_event`, `test_tool_call`, `list_interactions`, `answer_interaction` and `send_execution_event`) takes the brain's id as a required `brain` argument, `list_tool_servers` takes it as an optional one, and `get_guide` reads the guides on every endpoint: twenty-five tools for a key that may call them all. `list_tool_servers` lists the MCP servers the brain may use and the tools each offers, which an agent names in a reasoning function's `tools`, each tool saying whether it can be tested, and without `brain` every server set up for the org, each with the brains it serves, so an agent sees what is set up before it makes a brain; it is the org's operation, `GET /v1/orgs/{org}/tool-servers`, which the org endpoint serves too; `test_tool_call` calls one of them as a run would and answers what the run's model would see, a tool its server marks read-only or the operator marks testable on its entry, recorded in the brain's history and never a run; the [HTTP reference](http.md#tool-servers) describes both. `list_executions`, `get_execution_history` and `list_brain_events` page with `limit` and `cursor`, answer `has_more` and `next_cursor`, and read a retired brain; the [HTTP reference](http.md#run-history-and-brain-events) describes their fields. A connection lists the tools its key may call, so a read-only key is offered the queries alone, and a key that may only read inside some brains is offered `list_brains` too, which lists those brains. Every endpoint gives a connecting agent instructions, `instructionsFor` of `@beonauto/api`, built per request for the tools listed, the definition types the server runs and the recipes whose tools are listed, with the texts of [decision 0016](../../decisions/0016-speaking-to-agents.md#3-the-instructions-per-endpoint): what a brain is and a sentence for each type it runs, what the connection acts on, the one sentence that maps the wire names `spec`, `execution` and `primitive`, where the formats and recipes are, how a reasoning function names its model and tools and, where `test_tool_call` is listed, that it shows what a tool answers, that `get_execution` shows whether a run that finishes later ended or still waits, that an answer to what a run waits on goes to its request through `answer_interaction` and no new run, the paging rule, how to answer the person and what a refusal says. On the server with every tool they take 1,990 characters on `/mcp`, 1,495 on `/orgs/{org}/mcp`, where `list_tool_servers` is listed, and 1,950 on `/orgs/{org}/brains/{brain}/mcp`, under a bound of 2,000. `get_guide`, the resources `guide://` and the prompts `first-brain`, `remember`, `give-tools` and `schedule`, each offered where the connection lists the tools its steps call, serve the terminology, the reference page of each format and the recipes; the [API README](../../../packages/api/README.md#over-mcp) describes them, the annotations of each tool and the bounds. +`/mcp` serves every tool on one connection, so an agent can create a brain and work in it at once. The org is the key's own, since a key belongs to one org, and never an argument; in local mode, where no key names one, it is `local`, so the brains an agent creates there are the ones `GET /v1/orgs/local/brains` lists. The org id `local` is reserved for local mode: the key command refuses it, and an `API_KEYS` entry with it stops the server at start-up, so no key can reach the brains made in local mode. The brain tools (`create_brain`, `list_brains`, `get_brain`, `update_brain` and `retire_brain`) and `list_models` are as on HTTP. Every tool that works inside a brain (`create_definition`, `list_definitions`, `get_definition`, `update_definition`, `retire_definition`, `run_definition`, `get_run`, `cancel_run`, `list_runs`, `get_run_history`, `get_brain_analytics`, `list_brain_events`, `publish_event`, `test_tool_call`, `list_interactions`, `answer_interaction` and `send_run_event`) takes the brain's id as a required `brain` argument, `list_tool_servers` takes it as an optional one, and `get_guide` reads the guides on every endpoint: twenty-five tools for a key that may call them all. `list_tool_servers` lists the MCP servers the brain may use and the tools each offers, which an agent names in a reasoning function's `tools`, each tool saying whether it can be tested, and without `brain` every server set up for the org, each with the brains it serves, so an agent sees what is set up before it makes a brain; it is the org's operation, `GET /v1/orgs/{org}/tool-servers`, which the org endpoint serves too; `test_tool_call` calls one of them as a run would and answers what the run's model would see, a tool its server marks read-only or the operator marks testable on its entry, recorded in the brain's history and never a run; the [HTTP reference](http.md#tool-servers) describes both. `list_runs`, `get_run_history` and `list_brain_events` page with `limit` and `cursor`, answer `has_more` and `next_cursor`, and read a retired brain; the [HTTP reference](http.md#run-history-and-brain-events) describes their fields. A connection lists the tools its key may call, so a read-only key is offered the queries alone, and a key that may only read inside some brains is offered `list_brains` too, which lists those brains. Every endpoint gives a connecting agent instructions, `instructionsFor` of `@beonauto/api`, built per request for the tools listed, the definition types the server runs and the recipes whose tools are listed, with the texts of [decision 0016](../../decisions/0016-speaking-to-agents.md#3-the-instructions-per-endpoint): what a brain is and a sentence for each type it runs, what the connection acts on, where the formats and recipes are, how a reasoning function names its model and tools and, where `test_tool_call` is listed, that it shows what a tool answers, that `get_run` shows whether a run that finishes later ended or still waits, that an answer to what a run waits on goes to its request through `answer_interaction` and no new run, the paging rule, how to answer the person and what a refusal says. On the server with every tool they take 1,890 characters on `/mcp`, 1,495 on `/orgs/{org}/mcp`, where `list_tool_servers` is listed, and 1,850 on `/orgs/{org}/brains/{brain}/mcp`, under a bound of 2,000. `get_guide`, the resources `guide://` and the prompts `first-brain`, `remember`, `give-tools` and `schedule`, each offered where the connection lists the tools its steps call, serve the terminology, the reference page of each format and the recipes; the [API README](../../../packages/api/README.md#over-mcp) describes them, the annotations of each tool and the bounds. | Endpoint | Tools | Use it | | ------------------------------------- | ----------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------- | diff --git a/docs/engineering/reference/reasoning-format.md b/docs/engineering/reference/reasoning-format.md index f9329a30a..52d3184a1 100644 --- a/docs/engineering/reference/reasoning-format.md +++ b/docs/engineering/reference/reasoning-format.md @@ -2,7 +2,7 @@ # Reasoning function format -A reasoning function is a spec whose `primitive` field is `inference`, in API calls, MCP arguments and workflow definitions alike. A run performs one model invocation, or, when the function names [`tools`](#tools), a loop of them with the tools of the brain's MCP servers. Skill references are still planned; the `tools` field does not load skills or inherit an external agent's context. +A reasoning function is a definition whose `type` field is `reasoning`, in API calls, MCP arguments and workflow definitions alike. A run performs one model invocation, or, when the function names [`tools`](#tools), a loop of them with the tools of the brain's MCP servers. Skill references are still planned; the `tools` field does not load skills or inherit an external agent's context. ## Reasoning function document format @@ -32,7 +32,7 @@ Summarize the account {{ input.account }} as of {{ today }}. | Key | Required | Value | Meaning | | -------------------------- | ----------- | ----------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `description` | No | Text of 1 to 1000 characters | What the spec does. `list_specs` and `get_spec` show it | +| `description` | No | Text of 1 to 1000 characters | What the definition does. `list_definitions` and `get_definition` show it | | `model` | Yes | `provider/model` | The model to call; see [How a model reference is resolved](../self-host/models.md#how-a-model-reference-is-resolved) | | `config.max_output_tokens` | No | A whole number from 1 to 64000; 1024 when left out | The most tokens the answer may take. The time the call may take grows with it | | `config.temperature` | No | A number | Sampling temperature. Providers accept different ranges and reject a value outside theirs | @@ -40,16 +40,16 @@ Summarize the account {{ input.account }} as of {{ today }}. | `config.seed` | No | A whole number of 0 or more | A seed, for providers that take one | | `config.stop_sequences` | No | A list of texts | Texts that end the answer | | `config.reasoning` | No | `none`, `minimal`, `low`, `medium`, `high` or `xhigh` | Reasoning effort, for models that reason | -| `input.schema` | No | A JSON Schema whose root is `"type": "object"` | The input of an execution must match it. Without it, any JSON object is accepted | +| `input.schema` | No | A JSON Schema whose root is `"type": "object"` | The input of a run must match it. Without it, any JSON object is accepted | | `input.default` | No | A JSON object | Values merged under the input before it is validated. They must match the schema for the fields they name; required fields may be left to the input | | `output.format` | No | `text`, the default, or `json` | Whether the answer is the model's text or a JSON value | | `output.schema` | With `json` | A JSON Schema | The answer must match it; see [Answers that are JSON](../self-host/models.md#answers-that-are-json). Not allowed with `text` | | `provider_options` | No | An object of objects, keyed by the provider namespace | Options for `anthropic`, `openai`, `azure`, `google`, `vertex`, `googleVertex`, `amazonBedrock`, `bedrock`, or a gateway's name: only those [Provider options](#provider-options) offers | -| `tools` | No | A list of `server/tool` or `server/*` | The tools of the MCP servers configured for the brain that an execution may call; see [Tools](#tools) | +| `tools` | No | A list of `server/tool` or `server/*` | The tools of the MCP servers configured for the brain that a run may call; see [Tools](#tools) | Any other key, at any level, is rejected. -Parsing is validation. Every create, update and execution parses the document, and a document with a problem is rejected with every problem found at once, each with its line in the document, and a JSON pointer into the front matter where there is one, for example `Line 4, /config/temperature: Expected number`. The operations answer them under `/source`. An issue found more than once is reported once, and a rejection reports at most 20, in the order of their lines, followed by one that says how many more there were, such as `Line 23: 5881 more issues are not shown`. Parsing finds: +Parsing is validation. Every create, update and run parses the document, and a document with a problem is rejected with every problem found at once, each with its line in the document, and a JSON pointer into the front matter where there is one, for example `Line 4, /config/temperature: Expected number`. The operations answer them under `/source`. An issue found more than once is reported once, and a rejection reports at most 20, in the order of their lines, followed by one that says how many more there were, such as `Line 23: 5881 more issues are not shown`. Parsing finds: - front matter that does not open on the first line or is never closed; a document without it is never read as a template; - YAML that cannot be read. The front matter is YAML 1.2 with the core schema, so `yes` and `2026-10-01` stay text. Anchors, aliases and tags are rejected, a key may appear once in a mapping, numbers are finite, and nesting stops at 72 levels: deeper front matter, however deep, is rejected with `The front matter may nest at most 72 levels`; @@ -61,11 +61,11 @@ Parsing is validation. Every create, update and execution parses the document, a - a tool not written `server/tool` or `server/*`, and a tool listed twice; - Liquid that does not parse, a tag or filter that is not available, the rules of the system block, a template that writes no message outside it, and every variable it reads (see below). -A JSON output schema that some providers reject or do not enforce is accepted. The spec then carries `warnings`, which `create_spec`, `get_spec`, `list_specs` and `update_spec` show, each with its line and the providers concerned, for example `Line 8, /output/schema/properties/total/minimum: minimum is not enforced while Anthropic models write the answer; an answer outside it fails as output_invalid (anthropic, bedrock, bedrock-anthropic, vertex-anthropic)`. At most 100 are shown. +A JSON output schema that some providers reject or do not enforce is accepted. The definition then carries `warnings`, which `create_definition`, `get_definition`, `list_definitions` and `update_definition` show, each with its line and the providers concerned, for example `Line 8, /output/schema/properties/total/minimum: minimum is not enforced while Anthropic models write the answer; an answer outside it fails as output_invalid (anthropic, bedrock, bedrock-anthropic, vertex-anthropic)`. At most 100 are shown. ### Provider options -`provider_options` passes options to the AI SDK for a provider, keyed by the namespace the SDK reads for it. A spec may set only the options that shape how the model reasons or writes its answer. None of these is offered, whatever the provider: attribution (`user`, `metadata`, `labels`, request metadata), anything stored or reused on the operator's account (`store`, `previousResponseId`, `conversation`, `container`, `cachedContent`, prompt cache keys and retention), routing, fallbacks, capacity and service tiers, request headers and betas, tools and servers, guardrails, raw request fields, options for provider-managed conversation state (the runtime manages the run's conversation), output the runtime does not read, and anything the front matter already sets (`config` and `output`, such as an effort level or the strictness of a JSON schema). An option is offered only when it is listed here; an option a provider adds is not offered until it is reviewed and listed. +`provider_options` passes options to the AI SDK for a provider, keyed by the namespace the SDK reads for it. A definition may set only the options that shape how the model reasons or writes its answer. None of these is offered, whatever the provider: attribution (`user`, `metadata`, `labels`, request metadata), anything stored or reused on the operator's account (`store`, `previousResponseId`, `conversation`, `container`, `cachedContent`, prompt cache keys and retention), routing, fallbacks, capacity and service tiers, request headers and betas, tools and servers, guardrails, raw request fields, options for provider-managed conversation state (the runtime manages the run's conversation), output the runtime does not read, and anything the front matter already sets (`config` and `output`, such as an effort level or the strictness of a JSON schema). An option is offered only when it is listed here; an option a provider adds is not offered until it is reviewed and listed. | Namespace | Read by | Offered | | --------------- | ---------------------------------------------------------------------------- | ------------------------------------------------------------------------------ | @@ -85,39 +85,39 @@ Some of what is withheld, and why: - `google`, `vertex`, `googleVertex`: `labels`; `cachedContent`; `serviceTier`, `sharedRequestType` and `requestType` (Vertex AI capacity headers); `retrievalConfig` and `streamFunctionCallArguments`; `responseModalities`, `imageConfig`, `audioTimestamp` and `mediaResolution`; `structuredOutputs`. - `amazonBedrock`, `bedrock`: `additionalModelRequestFields`, and every key Bedrock does not read, because Bedrock adds those to the Converse request as they are (`inferenceConfig`, `guardrailConfig`, `requestMetadata` and the like); `anthropicBeta`; `serviceTier`; `structuredOutputMode`. The options of Anthropic models on Bedrock go under `anthropic`, since Bedrock would send them as they are under `bedrock`. -Any other option is rejected when the document is parsed, with the option named and the words `is not offered`, so such a spec is never stored. A namespace that is neither a provider's nor shaped like a gateway's name is rejected the same way. The offered and withheld options are one table of data in `src/model/offered-provider-options.ts`, typed against the option types the AI SDK exports, so an upgrade that adds or removes an option fails the type check until the option is decided. +Any other option is rejected when the document is parsed, with the option named and the words `is not offered`, so such a definition is never stored. A namespace that is neither a provider's nor shaped like a gateway's name is rejected the same way. The offered and withheld options are one table of data in `src/model/offered-provider-options.ts`, typed against the option types the AI SDK exports, so an upgrade that adds or removes an option fails the type check until the option is decided. -A gateway is a different case: the AI SDK adds every key under the gateway's name, or its camel case (`my-gateway` and `myGateway`), to the request body as it is, and reads `user` from `openaiCompatible`. A gateway gives meaning to body fields its operator may not want a tenant to set: attribution and budgets, routing and fallbacks, mock answers, endpoints and credentials. So a spec sets for a gateway only the top-level fields its entry lists in `allowed_provider_options`, under any of those namespaces, and by default none. The list is checked when the server starts: at most 64 distinct names of 1 to 64 characters, none of them a field the runtime sets or that changes what the call is (`model`, `messages`, `stream`, `stream_options`, `n`, `max_tokens`, `max_completion_tokens`, `temperature`, `top_p`, `frequency_penalty`, `presence_penalty`, `seed`, `stop`, `response_format`, `tools`, `tool_choice`, `functions`, `function_call`, `reasoning_effort`, `verbosity`, and the SDK's `reasoningEffort`, `textVerbosity` and `strictJsonSchema`). A problem names the setting and the field, never a value. +A gateway is a different case: the AI SDK adds every key under the gateway's name, or its camel case (`my-gateway` and `myGateway`), to the request body as it is, and reads `user` from `openaiCompatible`. A gateway gives meaning to body fields its operator may not want a tenant to set: attribution and budgets, routing and fallbacks, mock answers, endpoints and credentials. So a definition sets for a gateway only the top-level fields its entry lists in `allowed_provider_options`, under any of those namespaces, and by default none. The list is checked when the server starts: at most 64 distinct names of 1 to 64 characters, none of them a field the runtime sets or that changes what the call is (`model`, `messages`, `stream`, `stream_options`, `n`, `max_tokens`, `max_completion_tokens`, `temperature`, `top_p`, `frequency_penalty`, `presence_penalty`, `seed`, `stop`, `response_format`, `tools`, `tool_choice`, `functions`, `function_call`, `reasoning_effort`, `verbosity`, and the SDK's `reasoningEffort`, `textVerbosity` and `strictJsonSchema`). A problem names the setting and the field, never a value. -The parser does not know the gateways, so this check runs when the spec executes: a field outside the list rejects the execution as `conflict`, naming the field and saying the gateway does not allow it, and the gateway is not called. The same goes for a namespace that is shaped like a gateway's name but is no configured gateway's: the execution is rejected as `conflict` before any provider is called. +The parser does not know the gateways, so this check runs when the function runs: a field outside the list rejects the run as `conflict`, naming the field and saying the gateway does not allow it, and the gateway is not called. The same goes for a namespace that is shaped like a gateway's name but is no configured gateway's: the run is rejected as `conflict` before any provider is called. A document is checked without calling a model, and the same document always gives the same answer. Whether its provider is configured, and whether the provider accepts the model and the settings, shows only when it runs; see [When a run is rejected](#when-a-run-is-rejected). ### Tools -`tools` names the tools an execution may call, each `server/tool` or `server/*`, from the servers in [`mcp_servers`](../self-host/configuration.md#mcp-servers) that serve the brain's org and brain. An execution checks them once its model is known to be offered, and before the model is called, so a run whose model is not offered reaches no server and starts no process: a server that is not configured for the brain is `unavailable` of the kind `tool_not_offered` because `mcp_server_not_configured`, a tool outside the server's `allowed` because `tool_not_allowed`, and a tool the server does not list because `tool_not_listed`; a server that cannot be used is `unavailable` of the kind `mcp_server_failed`, because `unreachable`, `failing`, `rate_limited` or `key_refused`, the last when the server did not accept the key the runtime gives it, whose words say to check that key rather than to try again. `server/*` gives every tool the server lists that the server's `allowed` names, or every tool it lists when `allowed` is left out. `list_tool_servers` lists the servers that serve the brain and the tools each would give `server/*`, asking each server as an execution does, so the names can be found rather than told ([Tool servers](http.md#tool-servers)), and `test_tool_call` calls one of them as an execution would and answers what its model would see, so what a tool answers can be read before a function names it ([Testing a tool](../../reference/http.md#testing-a-tool)). +`tools` names the tools a run may call, each `server/tool` or `server/*`, from the servers in [`mcp_servers`](../self-host/configuration.md#mcp-servers) that serve the brain's org and brain. A run checks them once its model is known to be offered, and before the model is called, so a run whose model is not offered reaches no server and starts no process: a server that is not configured for the brain is `unavailable` of the kind `tool_not_offered` because `mcp_server_not_configured`, a tool outside the server's `allowed` because `tool_not_allowed`, and a tool the server does not list because `tool_not_listed`; a server that cannot be used is `unavailable` of the kind `mcp_server_failed`, because `unreachable`, `failing`, `rate_limited` or `key_refused`, the last when the server did not accept the key the runtime gives it, whose words say to check that key rather than to try again. `server/*` gives every tool the server lists that the server's `allowed` names, or every tool it lists when `allowed` is left out. `list_tool_servers` lists the servers that serve the brain and the tools each would give `server/*`, asking each server as a run does, so the names can be found rather than told ([Tool servers](http.md#tool-servers)), and `test_tool_call` calls one of them as a run would and answers what its model would see, so what a tool answers can be read before a function names it ([Testing a tool](../../reference/http.md#testing-a-tool)). -The model receives each tool under a unique name derived from `mcp__server__tool`. Characters other than letters, digits and underscores become underscores; names longer than 64 characters or sharing a normalized name receive an 8-character hash. A numeric suffix resolves any remaining collision within the run's tool list. The description is cut to 4 KiB and the server's input schema is unchanged; what in it the provider of the run's model may refuse or not hold the tool's arguments to is added to the run's `warnings`: a root that is not an object, and for Anthropic models the bounds, `oneOf` and the formats they do not enforce or accept, such as `mcp__graph__search inputSchema#/properties/query/maxLength`. Tools are not sent as strict structured outputs, so the rules a JSON output schema has for open objects and optional properties do not apply to them. The model keeps the function's output format and may call tools, several in one step, before it answers, each call forwarded with the execution id in the request's metadata under `com.beonauto/execution_id`: +The model receives each tool under a unique name derived from `mcp__server__tool`. Characters other than letters, digits and underscores become underscores; names longer than 64 characters or sharing a normalized name receive an 8-character hash. A numeric suffix resolves any remaining collision within the run's tool list. The description is cut to 4 KiB and the server's input schema is unchanged; what in it the provider of the run's model may refuse or not hold the tool's arguments to is added to the run's `warnings`: a root that is not an object, and for Anthropic models the bounds, `oneOf` and the formats they do not enforce or accept, such as `mcp__graph__search inputSchema#/properties/query/maxLength`. Tools are not sent as strict structured outputs, so the rules a JSON output schema has for open objects and optional properties do not apply to them. The model keeps the function's output format and may call tools, several in one step, before it answers, each call forwarded with the run id in the request's metadata under `com.beonauto/run_id`: | Bound | Value | When it is reached | | ----------------------------------------------- | ------------------------------------------------------ | ------------------------------------------------------------------------- | -| Calls in one execution | 25 | further calls are refused, then one more step without tools | -| Results sent to the model in one execution | 256 KiB | further calls are refused, then one more step without tools | +| Calls in one run | 25 | further calls are refused, then one more step without tools | +| Results sent to the model in one run | 256 KiB | further calls are refused, then one more step without tools | | One result | 64 KiB | cut at a character, with a note asking for fewer rows, fields or depth | | One call's arguments | 16 KiB | the call is refused | | The same tool with the same arguments | twice | the third call is refused, naming the earlier answer | | One call | 30 s | a tool error that counts as a server failure | | A 429 | waited out when `Retry-After` asks for at most 10 s | otherwise a server failure | -| Server failures in one execution | 5 | `unavailable` of the kind `tools_unfinished`, because `server_failed` | +| Server failures in one run | 5 | `unavailable` of the kind `tools_unfinished`, because `server_failed` | | One model call | 60 s plus 25 ms for every token of `max_output_tokens` | `unavailable` of the kind `tools_unfinished`, because `model_unavailable` | -| The whole execution | 10 minutes, or one model call's limit when longer | `unavailable` of the kind `tools_unfinished`, because `run_bound` | +| The whole run | 10 minutes, or one model call's limit when longer | `unavailable` of the kind `tools_unfinished`, because `run_bound` | | The last step, without tools, still calls tools | | `unavailable` of the kind `tools_unfinished`, because `no_answer` | A refused call is not sent and not recorded. The step that follows the end of the calls withholds the tools and tells the model the calls so far as text, so every provider takes it, with their answers in a block fenced by a random marker and labelled as the results of the tools it called: data to answer from, not instructions, and not the words of the user. A result's text content reaches the model as text, structured content only when there is no text, other content as a one-line placeholder, and a result the server marks `isError`, such as a policy's denial, as a tool error the model may recover from; a server's own instructions never reach it. Error text is scrubbed of the entry's secrets and minted tokens. -Each call is two events on the execution's stream, appended as it happens: `tool_call_started`, recorded before the call is sent, with its number, the id the model gave it, the server, the tool, and the size and SHA-256 digest of its arguments; and `tool_call_answered`, with the outcome (`result`, `tool_error`, `server_failure`, `timed_out` or `cancelled`), the size and digest of the result's content, its `content` and `structuredContent`, the duration, the JSON-RPC id, and the server's own request id where the entry's `request_id` names it. The arguments and the result's content, scrubbed and cut to 4 KiB as they are stored, are added only for an entry with `record_content: true`. `get_execution_history` shows them. Once an execution has recorded a call, every `unavailable` ending names the tools it called in words and has the kind `tools_unfinished`, and `execute_spec` answers the same `execution_id` with `conflict` of the kind `tools_called` unless the execution succeeded: a tool may have changed something, so start a new execution instead. It answers the same while an execution of a function with `tools` is started, before any call too, since that execution may still be in progress. +Each call is two events on the run's stream, appended as it happens: `tool_call_started`, recorded before the call is sent, with its number, the id the model gave it, the server, the tool, and the size and SHA-256 digest of its arguments; and `tool_call_answered`, with the outcome (`result`, `tool_error`, `server_failure`, `timed_out` or `cancelled`), the size and digest of the result's content, its `content` and `structuredContent`, the duration, the JSON-RPC id, and the server's own request id where the entry's `request_id` names it. The arguments and the result's content, scrubbed and cut to 4 KiB as they are stored, are added only for an entry with `record_content: true`. `get_run_history` shows them. Once a run has recorded a call, every `unavailable` ending names the tools it called in words and has the kind `tools_unfinished`, and `run_definition` answers the same `run_id` with `conflict` of the kind `tools_called` unless the run succeeded: a tool may have changed something, so start a new run instead. It answers the same while a run of a function with `tools` is started, before any call too, since that run may still be in progress. -A run that calls tools costs more: every step resends the conversation so far, so an execution that uses its whole budget of results sends on the order of a million tokens. +A run that calls tools costs more: every step resends the conversation so far, so a run that uses its whole budget of results sends on the order of a million tokens. ## The template language @@ -127,13 +127,13 @@ The template is [Liquid](https://shopify.github.io/liquid/), rendered by liquidj A template reads three variables and nothing else: -- `input`: the input of the execution, with the defaults merged under it and validated; +- `input`: the input of the run, with the defaults merged under it and validated; - `today`: the date, `YYYY-MM-DD`, in UTC; - `now`: the time, in ISO 8601, in UTC. -`today` and `now` come from the server's clock when the execution starts, and have no properties. Names a template makes itself, with `assign`, `for`, `increment` or `cycle`, are fine. Any other name is rejected when the document is parsed, by the engine's static analysis of the template. When the input schema declares `properties` and sets `"additionalProperties": false`, `input.` must be one of them. +`today` and `now` come from the server's clock when the run starts, and have no properties. Names a template makes itself, with `assign`, `for`, `increment` or `cycle`, are fine. Any other name is rejected when the document is parsed, by the engine's static analysis of the template. When the input schema declares `properties` and sets `"additionalProperties": false`, `input.` must be one of them. -Reading is strict. A field the input does not have stops the execution with `invalid_input`, naming the field, except in a condition of `if`, `elsif` or `unless` and in the `default` filter, so a template can test a field that may be absent: `{% if input.vip %}…{% endif %}`, `{{ input.nickname | default: "friend" }}`. Only the input's own fields can be read: `input.constructor` is a missing field. Values are written as Liquid writes them: a list as its items without separators, an object as `[object Object]`; write `{{ input.account | json }}` for JSON. +Reading is strict. A field the input does not have stops the run with `invalid_input`, naming the field, except in a condition of `if`, `elsif` or `unless` and in the `default` filter, so a template can test a field that may be absent: `{% if input.vip %}…{% endif %}`, `{{ input.nickname | default: "friend" }}`. Only the input's own fields can be read: `input.constructor` is a missing field. Values are written as Liquid writes them: a list as its items without separators, an object as `[object Object]`; write `{{ input.account | json }}` for JSON. ### The system block @@ -151,7 +151,7 @@ Review this expense: {{ input.expense | json }} Available: `assign`, `if` with `elsif` and `else`, `unless`, `case` with `when`, `for` with `break` and `continue`, `cycle`, `increment`, `decrement`, `echo`, `liquid`, `raw`, `comment`, `#` and `tablerow`. -Not available: `include`, `render`, `layout` and `block` load other templates, and a spec is one document; `capture` renders into a buffer of its own, outside the limit on the rendered size, so use `assign` with `append` instead. +Not available: `include`, `render`, `layout` and `block` load other templates, and a definition is one document; `capture` renders into a buffer of its own, outside the limit on the rendered size, so use `assign` with `append` instead. ### Filters @@ -168,14 +168,14 @@ Three more: Left out, and why: - `where_exp`, `reject_exp`, `group_by_exp`, `has_exp`, `find_exp` and `find_index_exp` evaluate an expression given as text, which the check of the variables a template reads cannot see; -- `date`, `date_to_xmlschema`, `date_to_rfc822`, `date_to_string` and `date_to_long_string` read the server's clock, time zone and locale, so the same spec would render differently from one server to the next; `today` and `now` give the date and the time; +- `date`, `date_to_xmlschema`, `date_to_rfc822`, `date_to_string` and `date_to_long_string` read the server's clock, time zone and locale, so the same definition would render differently from one server to the next; `today` and `now` give the date and the time; - `sample` is random; -- `sha256` and `hmac_sha256` compute digests, which a prompt has no use for, and `hmac_sha256` would put a key in a spec; +- `sha256` and `hmac_sha256` compute digests, which a prompt has no use for, and `hmac_sha256` would put a key in a definition; - `strip_html` is a hand-written HTML parser that had three advisories in 2026 (a regular expression that backtracks, an infinite loop, and a bypass); they are fixed in this version, and a prompt has no HTML to strip; - `url_encode`, `cgi_escape`, `uri_escape` and `url_decode`: the encoders grow their output without charging the memory limit (nine `url_encode` in a row turn 65,536 characters into 1,245,184 without charging anything), and a prompt has no URL to encode; - `inspect` and `jsonify` do what `json` does. -As of October 2026, no published advisory affects liquidjs 10.27.2 or later; earlier versions have several, including code execution from a crafted template. 10.29.0 is the newest release the workspace's minimum release age allows. +As of October 2026, no published advisory affects liquidjs 10.27.2 or later; earlier versions have several, including running code from a crafted template. 10.29.0 is the newest release the workspace's minimum release age allows. ### Limits @@ -184,14 +184,14 @@ As of October 2026, no published advisory affects liquidjs 10.27.2 or later; ear | Length of the template | 65,536 characters | The document itself takes at most 65,536 bytes | | Names in tags and outputs | 1000 | The engine's static analysis finds the position of every variable from the start of the text: 1000 variables at the end of a 64 KiB template take 90 ms to analyse, 13,107 take 574 ms | | Time to render | 200 ms | Rendering is synchronous. The heaviest renders measured take 3 to 10 ms: a loop over 2000 items writing 114,000 characters takes 9 ms | -| Memory that filters and ranges may charge | 5,000,000 characters or items | About nineteen passes of a filter over the largest input an execution takes (256 KiB) | +| Memory that filters and ranges may charge | 5,000,000 characters or items | About nineteen passes of a filter over the largest input a run takes (256 KiB) | | Rendered instructions, and rendered message | 200,000 characters each | Checked as the output is written, so a render stops as soon as it grows past it | -Names are counted in the text inside `{{ }}` and `{% %}`: every variable, property, filter and keyword. A template over the first two limits is rejected when it is parsed. The engine reads tags and parentheses recursively, so a template that nests them more deeply than it can read (about 2000 levels of tags) is rejected with `The tags or parentheses of the template nest too deeply to be read`, never with the engine's own message. A render that hits one of the last three stops the execution with `invalid_input`, because it is the input that makes the render grow. +Names are counted in the text inside `{{ }}` and `{% %}`: every variable, property, filter and keyword. A template over the first two limits is rejected when it is parsed. The engine reads tags and parentheses recursively, so a template that nests them more deeply than it can read (about 2000 levels of tags) is rejected with `The tags or parentheses of the template nest too deeply to be read`, never with the engine's own message. A render that hits one of the last three stops the run with `invalid_input`, because it is the input that makes the render grow. ## Creating and running a reasoning function -The definition and run operations of [`@beonauto/specs`](../../../packages/specs) store reasoning function definitions and record their runs: `create_spec`, `list_specs`, `get_spec`, `update_spec`, `retire_spec`, `execute_spec` and `get_execution`, under `/v1/orgs/{org}/brains/{brain}`, with `primitive: "inference"` as the API type identifier. Give the server a key for the provider first; it reads the model settings when it starts: +The definition and run operations of [`@beonauto/definitions`](../../../packages/definitions) store reasoning function definitions and record their runs: `create_definition`, `list_definitions`, `get_definition`, `update_definition`, `retire_definition`, `run_definition` and `get_run`, under `/v1/orgs/{org}/brains/{brain}`, with `type: "reasoning"`. Give the server a key for the provider first; it reads the model settings when it starts: ```bash export ANTHROPIC_API_KEY=sk-ant-... @@ -205,31 +205,31 @@ curl --request POST http://localhost:8080/v1/orgs/acme/brains \ --header 'content-type: application/json' \ --data '{"brain":"sales","name":"Sales"}' jq --null-input --rawfile source account-summary.md '{name: "account-summary", source: $source}' | - curl --request POST http://localhost:8080/v1/orgs/acme/brains/sales/specs/inference \ + curl --request POST http://localhost:8080/v1/orgs/acme/brains/sales/definitions/reasoning \ --header 'content-type: application/json' --data @- ``` -`create_spec` answers `201` with the definition: its `version` 1, the `description`, the `input_schema`, any `warnings`, and the document as `source`. A document with problems gets `422` with every problem under `/source`. Read it back, and list the reasoning functions in the brain: +`create_definition` answers `201` with the definition: its `version` 1, the `description`, the `input_schema`, any `warnings`, and the document as `source`. A document with problems gets `422` with every problem under `/source`. Read it back, and list the reasoning functions in the brain: ```bash -curl http://localhost:8080/v1/orgs/acme/brains/sales/specs/inference/account-summary -curl http://localhost:8080/v1/orgs/acme/brains/sales/specs/inference +curl http://localhost:8080/v1/orgs/acme/brains/sales/definitions/reasoning/account-summary +curl http://localhost:8080/v1/orgs/acme/brains/sales/definitions/reasoning ``` -Run the function. The optional `execution_id` identifies the run so you can inspect it and retry according to the [retry rules](#when-a-run-is-rejected): +Run the function. The optional `run_id` identifies the run so you can inspect it and retry according to the [retry rules](#when-a-run-is-rejected): ```bash -curl --request POST http://localhost:8080/v1/orgs/acme/brains/sales/specs/inference/account-summary/execute \ +curl --request POST http://localhost:8080/v1/orgs/acme/brains/sales/definitions/reasoning/account-summary/run \ --header 'content-type: application/json' \ - --data '{"input":{"account":"Globex"},"execution_id":"0199a3c4-7d2e-7c1a-9b3f-2f1e0d9c8b7a"}' + --data '{"input":{"account":"Globex"},"run_id":"0199a3c4-7d2e-7c1a-9b3f-2f1e0d9c8b7a"}' ``` ```json { - "execution_id": "0199a3c4-7d2e-7c1a-9b3f-2f1e0d9c8b7a", - "primitive": "inference", + "run_id": "0199a3c4-7d2e-7c1a-9b3f-2f1e0d9c8b7a", + "type": "reasoning", "name": "account-summary", - "spec_version": 1, + "definition_version": 1, "status": "succeeded", "output": "Globex renewed for two years and expanded to three regions this quarter.", "started_at": "2026-10-01T09:30:00.000Z", @@ -238,18 +238,18 @@ curl --request POST http://localhost:8080/v1/orgs/acme/brains/sales/specs/infere } ``` -`get_execution` reads it back with the [record](#what-a-run-records): +`get_run` reads it back with the [record](#what-a-run-records): ```bash -curl http://localhost:8080/v1/orgs/acme/brains/sales/executions/0199a3c4-7d2e-7c1a-9b3f-2f1e0d9c8b7a +curl http://localhost:8080/v1/orgs/acme/brains/sales/runs/0199a3c4-7d2e-7c1a-9b3f-2f1e0d9c8b7a ``` -An agent calls the same operations as MCP tools on `POST /mcp`, where the tools inside a brain take its id as `brain`: `get_guide` serves the format of the document as the guide `reasoning-function`, the public reference page with what this server offers at its end, and a rejection comes back as `isError` with the same problem document. A retry with the same `execution_id`, definition and input returns a recorded success or invalid-input rejection without calling the model. Other failures may permit another attempt, except where tool calls may already have changed something; [rejection and retry rules](#when-a-run-is-rejected) cover those cases. `update_spec` (`PUT …/specs/inference/account-summary` with a new `source`) makes version 2, and `retire_spec` (`POST …/retire`) retires the function for good. The [specs README](../../../packages/specs/README.md) has the rules of each operation. +An agent calls the same operations as MCP tools on `POST /mcp`, where the tools inside a brain take its id as `brain`: `get_guide` serves the format of the document as the guide `reasoning-function`, the public reference page with what this server offers at its end, and a rejection comes back as `isError` with the same problem document. A retry with the same `run_id`, definition and input returns a recorded success or invalid-input rejection without calling the model. Other failures may permit another attempt, except where tool calls may already have changed something; [rejection and retry rules](#when-a-run-is-rejected) cover those cases. `update_definition` (`PUT …/definitions/reasoning/account-summary` with a new `source`) makes version 2, and `retire_definition` (`POST …/retire`) retires the function for good. The [definitions README](../../../packages/definitions/README.md) has the rules of each operation. -`scripts/try-inference.sh` at the root of the repository does all of this against a server that is already running, with a small reasoning function of its own, and prints the run and its record. It starts nothing, and needs `curl` and `jq`: +`scripts/try-reasoning.sh` at the root of the repository does all of this against a server that is already running, with a small reasoning function of its own, and prints the run and its record. It starts nothing, and needs `curl` and `jq`: ```bash -scripts/try-inference.sh http://localhost:8080 anthropic/claude-sonnet-4-5 +scripts/try-reasoning.sh http://localhost:8080 anthropic/claude-sonnet-4-5 ``` It works in org `local`, or `AUTO_BRAIN_ORG`, creates a brain named `try-`, and sends `AUTO_BRAIN_KEY` as the API key when it is set. CI never runs it. @@ -285,7 +285,7 @@ output: Expense: {{ input.expense }}, {{ input.amount | money }}. ``` -Executed with `{"input":{"expense":"Dinner for two with a client","amount":142.5}}`, it answers with the JSON value, validated against the schema: +Run with `{"input":{"expense":"Dinner for two with a client","amount":142.5}}`, it answers with the JSON value, validated against the schema: ```json { @@ -296,35 +296,35 @@ Executed with `{"input":{"expense":"Dinner for two with a client","amount":142.5 ## When a run is rejected -The spec operations answer every rejection as a problem document, and record it on the execution. A call with the same `execution_id` runs the spec again after `unavailable` or `conflict`, unless the execution called tools, and answers the same `invalid_input` again. - -| What happened | Rejection | HTTP | -| ---------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---- | -| The input is not a JSON object, or does not match the input schema | `invalid_input`, with the issues under `/input` | 422 | -| The template reads a field the input does not have, outside a condition | `invalid_input` at `/input/`, naming the field and the line | 422 | -| A filter rejects a value of the input, a render limit stops the render, or the message renders empty | `invalid_input`, with the line | 422 | -| The model refuses the content under its policy | `invalid_input` at `/input` | 422 | -| The provider rejects the spec: a model it does not have, a schema or a setting it cannot accept | `conflict` of the kind `unworkable`, naming the provider, the HTTP status and what it means; the provider's own words only where [Provider messages](../self-host/models.md#provider-messages) allow them | 409 | -| The spec sets a provider option its gateway does not allow | `conflict` of the kind `unworkable`, naming the option; the gateway is not called | 409 | -| A JSON answer is cut off at `max_output_tokens` | `conflict` of the kind `unworkable`, saying to raise `config.max_output_tokens` | 409 | -| The answer leaves no room in the 1 MiB an execution records | `conflict` of the kind `unworkable`, saying to lower `config.max_output_tokens` | 409 | -| The spec names a model outside `allowed_models` | `unavailable` of the kind `model_not_offered` because `model_not_allowed`; nothing is sent | 503 | -| The provider is not configured, or its certificate is not trusted | `unavailable`, naming the provider, the configured providers and the aliases, or saying that the operator must act; never a setting | 503 | -| The provider rejects the credentials | `unavailable`, with the HTTP status | 503 | -| The provider limits the rate of requests | `unavailable`, saying how many seconds to wait when it said | 503 | -| The provider cannot be reached or cannot serve now, or does not answer in time | `unavailable`, saying to try again later | 503 | -| A JSON answer does not match the schema | `unavailable`, with the first issues, saying to try again | 503 | -| The spec names a tool the brain's MCP servers do not offer, or a server cannot be used | `unavailable` of the kind `tool_not_offered` or `mcp_server_failed`; the model is not called | 503 | -| The execution called tools and then could not finish | `unavailable` of the kind `tools_unfinished`, naming the tools it called in words | 503 | -| The same `execution_id` again, after an execution that called tools and did not succeed | `conflict` of the kind `tools_called`; inspect the history and external effects before deliberately starting a new run | 409 | -| The same `execution_id` again, while a run of a reasoning function with `tools` is started | `conflict` of the kind `tools_called`; the run may still be in progress, so inspect its history before starting another | 409 | -| The caller goes away before the answer | the run is interrupted and recorded as `failed`; in-flight tool calls are cancelled and may have external effects | 499 | - -A text answer cut off at `max_output_tokens` succeeds, with `finish_reason: "length"` in the record. A call may take 60 seconds plus 25 ms for every token of `max_output_tokens`: 85.6 seconds for the default 1024, and that covers the up to two retries of a failure that may pass (see [Retries](../self-host/models.md#retries)). The spec operations add their own rejections: `not_found` for a spec the brain does not have, and `conflict` for a retired spec or one whose document no longer parses. +The definition operations answer every rejection as a problem document, and record it on the run. A call with the same `run_id` runs the definition again after `unavailable` or `conflict`, unless the run called tools, and answers the same `invalid_input` again. + +| What happened | Rejection | HTTP | +| ----------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---- | +| The input is not a JSON object, or does not match the input schema | `invalid_input`, with the issues under `/input` | 422 | +| The template reads a field the input does not have, outside a condition | `invalid_input` at `/input/`, naming the field and the line | 422 | +| A filter rejects a value of the input, a render limit stops the render, or the message renders empty | `invalid_input`, with the line | 422 | +| The model refuses the content under its policy | `invalid_input` at `/input` | 422 | +| The provider rejects the definition: a model it does not have, a schema or a setting it cannot accept | `conflict` of the kind `unworkable`, naming the provider, the HTTP status and what it means; the provider's own words only where [Provider messages](../self-host/models.md#provider-messages) allow them | 409 | +| The definition sets a provider option its gateway does not allow | `conflict` of the kind `unworkable`, naming the option; the gateway is not called | 409 | +| A JSON answer is cut off at `max_output_tokens` | `conflict` of the kind `unworkable`, saying to raise `config.max_output_tokens` | 409 | +| The answer leaves no room in the 1 MiB a run records | `conflict` of the kind `unworkable`, saying to lower `config.max_output_tokens` | 409 | +| The definition names a model outside `allowed_models` | `unavailable` of the kind `model_not_offered` because `model_not_allowed`; nothing is sent | 503 | +| The provider is not configured, or its certificate is not trusted | `unavailable`, naming the provider, the configured providers and the aliases, or saying that the operator must act; never a setting | 503 | +| The provider rejects the credentials | `unavailable`, with the HTTP status | 503 | +| The provider limits the rate of requests | `unavailable`, saying how many seconds to wait when it said | 503 | +| The provider cannot be reached or cannot serve now, or does not answer in time | `unavailable`, saying to try again later | 503 | +| A JSON answer does not match the schema | `unavailable`, with the first issues, saying to try again | 503 | +| The definition names a tool the brain's MCP servers do not offer, or a server cannot be used | `unavailable` of the kind `tool_not_offered` or `mcp_server_failed`; the model is not called | 503 | +| The run called tools and then could not finish | `unavailable` of the kind `tools_unfinished`, naming the tools it called in words | 503 | +| The same `run_id` again, after a run that called tools and did not succeed | `conflict` of the kind `tools_called`; inspect the history and external effects before deliberately starting a new run | 409 | +| The same `run_id` again, while a run of a reasoning function with `tools` is started | `conflict` of the kind `tools_called`; the run may still be in progress, so inspect its history before starting another | 409 | +| The caller goes away before the answer | the run is interrupted and recorded as `failed`; in-flight tool calls are cancelled and may have external effects | 499 | + +A text answer cut off at `max_output_tokens` succeeds, with `finish_reason: "length"` in the record. A call may take 60 seconds plus 25 ms for every token of `max_output_tokens`: 85.6 seconds for the default 1024, and that covers the up to two retries of a failure that may pass (see [Retries](../self-host/models.md#retries)). The definition operations add their own rejections: `not_found` for a definition the brain does not have, and `conflict` for a retired definition or one whose document no longer parses. ## What a run records -`get_execution` shows the record of a succeeded execution: +`get_run` shows the record of a succeeded run: | Field | What it holds | | ------------------------------------ | --------------------------------------------------------------------------------------------------------------------- | @@ -338,6 +338,6 @@ A text answer cut off at `max_output_tokens` succeeds, with `finish_reason: "len | `duration_ms` | How long the call took | | `prompt` | The rendered `instructions` and `message`, and `truncated` | -A run rejected after its model answered keeps a record too, of `usage` and `duration_ms` alone: when the answer does not match the schema or is cut off at `max_output_tokens`, when the model refused the content, when it kept calling tools in the step that withheld them, and when the answer leaves no room in the 1 MiB a run records. `get_execution` shows it, and the brain's analytics count its tokens. A rejection that comes before the model answers, or a call stopped by its deadline, by the bound of a run that calls tools or by failing tool servers, records none, since no usage is known then. +A run rejected after its model answered keeps a record too, of `usage` and `duration_ms` alone: when the answer does not match the schema or is cut off at `max_output_tokens`, when the model refused the content, when it kept calling tools in the step that withheld them, and when the answer leaves no room in the 1 MiB a run records. `get_run` shows it, and the brain's analytics count its tokens. A rejection that comes before the model answers, or a call stopped by its deadline, by the bound of a run that calls tools or by failing tool servers, records none, since no usage is known then. -The output and the record take at most 1 MiB together, the limit of the spec operations. When the prompt does not fit beside the answer, the record keeps the start of the instructions and of the message and sets `truncated: true`. The record holds no credential, and not the provider options, which the spec already holds. +The output and the record take at most 1 MiB together, the limit of the definition operations. When the prompt does not fit beside the answer, the record keeps the start of the instructions and of the message and sets `truncated: true`. The record holds no credential, and not the provider options, which the definition already holds. diff --git a/docs/engineering/reference/workflow-format.md b/docs/engineering/reference/workflow-format.md index 157b50d55..e7983ecec 100644 --- a/docs/engineering/reference/workflow-format.md +++ b/docs/engineering/reference/workflow-format.md @@ -1,14 +1,14 @@ -# Workflow format and execution +# Workflow format and runs -The runtime stores workflows as `orchestration` specs and runs them on the workflow machine of `@beonauto/workflow-engine`, in the server, through `@beonauto/workflow-host`. Keep `orchestration` in API calls and MCP arguments. +The runtime stores workflows as definitions of the type `workflow` and runs them on the workflow machine of `@beonauto/workflow-engine`, in the server, through `@beonauto/workflow-host`. The public [workflow format reference](../../reference/workflow-format.md) owns the document: its fields, tasks, flow, expressions and their variables, errors, retries, timeouts, events, limits and how a run ends, with examples recorded against this runtime. This page covers what the implementation adds: where each check runs, how expressions are metered, how calls, events and settling work on the host, and how the server runs and bounds its workflows. ## Checking a document -The spec operations read the YAML with `readYaml` of `@beonauto/config`, validate it against the DSL schema and its rules with `@openworkflowspec/sdk`, build its graph of tasks, and apply the policy of `@beonauto/workflow-engine` (`src/dsl/policy.ts`), given the functions of `src/document/workflow-functions.ts`. A document that nests task lists more than 64 levels deep, or any value more than 512, is rejected at the list or value that is too deep, before anything else is checked, and a workflow refuses to start with one however it was stored. A fork's 32 branches are checked when the document is stored and again when a workflow starts. A `wait` or a `timeout` written as a duration longer than `ORCHESTRATION_MAX_DURATION` is rejected when the document is stored, and one an expression computes longer fails the task that computes it with a `configuration` error. The policy calls `scheduleRejections` of `src/document/workflow-schedule.ts` for a `schedule`, which takes up to three triggers, each checked on its own: `on` with at most 64 literal filters (`literalFilterOf` of the engine), none written twice, a five-field `cron` read by `cronRejectionOf` of `@beonauto/workflow-host`, and an `every` of at least a minute, and `emitRejections` and `emitRefusal` of `src/document/workflow-functions.ts` for an `emit`, which refuse the brain's own types and sources when the document is stored and when the event is computed. `triggersOfDocument` reads the triggers once, when the document is stored, each with its kind and its reference in the document, and the summary carries them as `triggers` on the definition's record: the registry counts a workflow with triggers among those that start on their own, at most 1,024 in a brain, and the workflow host takes the triggers from the record and reads no document. +The definition operations read the YAML with `readYaml` of `@beonauto/config`, validate it against the DSL schema and its rules with `@openworkflowspec/sdk`, build its graph of tasks, and apply the policy of `@beonauto/workflow-engine` (`src/dsl/policy.ts`), given the functions of `src/document/workflow-functions.ts`. A document that nests task lists more than 64 levels deep, or any value more than 512, is rejected at the list or value that is too deep, before anything else is checked, and a workflow refuses to start with one however it was stored. A fork's 32 branches are checked when the document is stored and again when a workflow starts. A `wait` or a `timeout` written as a duration longer than `WORKFLOW_MAX_DURATION` is rejected when the document is stored, and one an expression computes longer fails the task that computes it with a `configuration` error. The policy calls `scheduleRejections` of `src/document/workflow-schedule.ts` for a `schedule`, which takes up to three triggers, each checked on its own: `on` with at most 64 literal filters (`literalFilterOf` of the engine), none written twice, a five-field `cron` read by `cronRejectionOf` of `@beonauto/workflow-host`, and an `every` of at least a minute, and `emitRejections` and `emitRefusal` of `src/document/workflow-functions.ts` for an `emit`, which refuse the brain's own types and sources when the document is stored and when the event is computed. `triggersOfDocument` reads the triggers once, when the document is stored, each with its kind and its reference in the document, and the summary carries them as `triggers` on the definition's record: the registry counts a workflow with triggers among those that start on their own, at most 1,024 in a brain, and the workflow host takes the triggers from the record and reads no document. -When a workflow starts, the machine applies again only the policy's prohibitions, what this runtime does not allow at all: the tasks, calls, components, `schedule` and `listen` options the public reference lists as refused, executing another workflow, schemas that are not inline JSON Schema, and a DSL version other than 1.0.x. So a document that never went through the spec operations cannot use what the policy forbids. The other problems the spec operations reject, such as an expression that does not parse or uses `localtime`, a duration in years, or a `then` that names no task, are not checked again: they fail the task that has them when it runs. +When a workflow starts, the machine applies again only the policy's prohibitions, what this runtime does not allow at all: the tasks, calls, components, `schedule` and `listen` options the public reference lists as refused, running another workflow, schemas that are not inline JSON Schema, and a DSL version other than 1.0.x. So a document that never went through the definition operations cannot use what the policy forbids. The other problems the definition operations reject, such as an expression that does not parse or uses `localtime`, a duration in years, or a `then` that names no task, are not checked again: they fail the task that has them when it runs. ### Expressions @@ -21,7 +21,7 @@ Workflows of every org share the server's one thread, so no document may make it - An expression may do 8000000 units. One that tries to do more stops at once, and the task fails with a `runtime` error of status 500; jq's `try` cannot catch it, and the workflow's `try` can. - Pure tasks run one after another without waiting. Before a task, a run that has done 8000000 units of expression work, or run 100 tasks, in the input it is deciding lets other runs go: it arms a timer due at once and goes on when it fires. A task cannot pause in the middle, so a run fails with a `runtime` error once it has done 16000000 units in one input. - A regular expression may compile to at most 4096 instructions. A counted repeat compiles to one copy of what it repeats for each count, so `(a{100}){100}` needs more than 10000. A pattern beyond the limit fails with a `runtime` error that jq's `try` can catch, `regex too large: more than 4096 instructions`, whether it is written in the document or comes from data. Ordinary patterns fit one expression over 100 KB of generated text: URLs take 7.1M units, ISO dates 6.0M, the levels of log lines 6.2M, lines that are emails 6.7M, lines that are IPv4 addresses 7.1M, UUIDs 7.5M, amounts of money 5.9M, repeated spaces 5.7M, words in any case 6.5M and a separator pattern 6.0M. A list of 60 keywords takes 26.2M units over 100 KB and fits over 25 KB (6.5M), and a 454-character line parser with 47 groups takes 15.8M over 100 KB and fits over 40 KB (6.3M) (`packages/workflow-engine/src/dsl/expression-regex.test.ts`). -- A value the workflow holds, its input, the output of a task, its context and its output, may take at most 8000000 units to visit and nest at most 512 levels deep; a value counts a part it shares as often as it holds it, so doubling a value from task to task fails too. An execution with an input deeper than that is rejected with `invalid_input` before the workflow starts. +- A value the workflow holds, its input, the output of a task, its context and its output, may take at most 8000000 units to visit and nest at most 512 levels deep; a value counts a part it shares as often as it holds it, so doubling a value from task to task fails too. A run with an input deeper than that is rejected with `invalid_input` before the workflow starts. - A value an expression builds may nest at most 512 levels deep too, the evaluator's `mostValueDepth`, whether it builds it, sets a path or reads it with `fromjson`. One deeper stops the expression with `Value depth limit exceeded`, and the task fails with an `expression` error of status 400; jq's `try` cannot catch it, and the workflow's `try` can. Without the bound, the answer depended on the stack: `reduce range(2000) as $i (null; [.]) | tojson | length` answered 4004 when evaluated directly and overflowed the stack when evaluated 2,000 frames deeper (`packages/workflow-engine/src/dsl/expressions.test.ts`). The bound decides the same way when a history is replayed, and the corpus of state formats and the 15 recorded input logs replay unchanged, so it needs no new state format. Measured on an Apple M-series machine, the expressions that do the most work per unit, run up to the budget, take at most about 30 ms and allocate at most about 25 MB (`@base64` of a 400000-character string); before the budget, `"x" * 40000000 | length` took 0.5 s and 0.8 GB, and `"a" * 200000 | indices("a" * 100000)` 2.5 s; before regular expressions were bounded and charged, `"a" | test("(((a{100}){100}){100}){40}")` took 1.1 s and 2.4 GB, one more level of nesting exhausted the heap after 15 s, and `"a" * 200000 | test("(?:|){2000}b")` took 8.1 s; before object keys were charged, a task whose expressions used a 4000000-character string as an object key in a loop took 6.9 s for one activation's 16000000 units. Each of these now stops within 35 ms. @@ -30,33 +30,33 @@ The count comes from a patch of `@gabrielbryk/jq-ts` 1.7.0 (`patches/@gabrielbry ### Calling a definition -`call: execute_spec` executes the active spec of that primitive and name in the same brain through the server's dispatcher, for the caller who started the run, and outputs its output; the public reference lists the errors a call raises. Retrying is the document's choice, with `try` and `catch.retry`: the run waits on durable timers between attempts. The server does not retry a call on its own; a call cut off when the server stopped is performed again when it starts. +`call: run_definition` executes the active definition of that type and name in the same brain through the server's dispatcher, for the caller who started the run, and outputs its output; the public reference lists the errors a call raises. Retrying is the document's choice, with `try` and `catch.retry`: the run waits on durable timers between attempts. The server does not retry a call on its own; a call cut off when the server stopped is performed again when it starts. -Each run of a call is its own execution, with an id derived from the workflow's execution id, the reference of the task and how many times that task ran: a UUID version 5. A call performed again asks for the same execution id, so an execution that already has a final result is answered from the ledger and its model is not called again. +Each time a call runs, it starts a run of its own, with an id derived from the workflow's run id, the reference of the task and how many times that task ran: a UUID version 5. A call performed again asks for the same run id, so a run that already has a final result is answered from the ledger and its model is not called again. -A call may run for the longest a nested execution may legitimately take and a minute more: the most `longestExecutionMs` the server's primitives state (see `@beonauto/specs`), which the server gives the run as its `longestCallMs`. For inference that is the deadline of a model call for the most output tokens, 60 seconds and 25 ms a token, 1660 seconds for 64000, so 1720 seconds; a primitive that states none is given 10 minutes. Each call arms a `call_deadline` timer at that limit: when it fires, the task fails with a `communication` error of status 503, and the call is cut off. When the server stops while a nested execution runs, the call stays recorded and is performed again when the server starts, under the same id, since an execution that started and never finished runs again for its id. +A call may run for the longest a nested run may legitimately take and a minute more: the most `longestAnyRunMs` the server's capabilities state (see `@beonauto/definitions`), which the server gives the run as its `longestCallMs`. For reasoning that is the deadline of a model call for the most output tokens, 60 seconds and 25 ms a token, 1660 seconds for 64000, so 1720 seconds; a capability that states none is given 10 minutes. Each call arms a `call_deadline` timer at that limit: when it fires, the task fails with a `communication` error of status 503, and the call is cut off. When the server stops while a nested run runs, the call stays recorded and is performed again when the server starts, under the same id, since a run that started and never finished runs again for its id. -A call cancelled, by the timeout of its task, by its `call_deadline` or because its run ends, stops the nested execution: the host interrupts the fiber that performs the call, `inRuntime` (`packages/server/src/workflows/in-runtime.ts`) passes the abort of that fiber to the application runtime's `run` as its signal, and the runtime interrupts the execution. A reasoning function then stops forwarding tool calls, cancels the ones in flight, which its MCP client tells each server with `notifications/cancelled`, ends its sessions and records its ending, `execution_failed`, with each call in flight started and never answered (`src/workflow-executions/workflow-tool-cancellation.test.ts` of the server). +A call cancelled, by the timeout of its task, by its `call_deadline` or because its run ends, stops the nested run: the host interrupts the fiber that performs the call, `inRuntime` (`packages/server/src/workflows/in-runtime.ts`) passes the abort of that fiber to the application runtime's `run` as its signal, and the runtime interrupts the run. A reasoning function then stops forwarding tool calls, cancels the ones in flight, which its MCP client tells each server with `notifications/cancelled`, ends its sessions and records its ending, `run_failed`, with each call in flight started and never answered (`src/workflow-runs/workflow-tool-cancellation.test.ts` of the server). ### Events -`send_execution_event` (`src/events/send-execution-event.ts`) gives the run of that execution an `event_received` input, with the event's `id`, made when left out, its `source`, `/callers/` when left out, a prefix the brain keeps for itself, and its `time`. It answers `not_found` when the brain has no run of that execution that is going, including one that ended, and `unavailable` when the run cannot take the event at that moment, because its log kept changing or the server is stopping. The public HTTP and format references describe the operation, its limits and how `listen` takes events. A `listen` whose filters name a literal type also arms a listener, through the engine's `arm_listener` output, and the host offers the run each matching event of the brain as an `event_offered` input while the listen is open (see [Reactions](#reactions)). +`send_run_event` (`src/events/send-run-event.ts`) gives that run an `event_received` input, with the event's `id`, made when left out, its `source`, `/callers/` when left out, a prefix the brain keeps for itself, and its `time`. It answers `not_found` when the brain has no workflow run of that id that is going, including one that ended, and `unavailable` when the run cannot take the event at that moment, because its log kept changing or the server is stopping. The public HTTP and format references describe the operation, its limits and how `listen` takes events. A `listen` whose filters name a literal type also arms a listener, through the engine's `arm_listener` output, and the host offers the run each matching event of the brain as an `event_offered` input while the listen is open (see [Reactions](#reactions)). -## Execution and settling +## Running and settling -`execute_spec` of a workflow spec starts the run of that execution and answers the execution `started`, with an empty record, or how it ended when the run ended in its first input: the run's log is `runs/` under the brain, and its first input carries the document, the input, the run's limits, a random seed and, as attributes, the org, the brain, the execution id, the spec, the caller, the reaction depth and the call depth. The capability declares that its runs finish later, so the start records `finishes_later` and a settle that lands before the deferral is accepted; the limits name, for each task that calls a definition it names as written, the longest a run of that definition may take plus a minute (`longestCallMsByTask`, resolved through `longestRunOf` of the run's context), and the run-wide `longestCallMs` serves every other call. A start with the id of a run that is going answers the execution as it stands, so an execution started twice runs one workflow. A run never starts twice in one log, so a start with the id of a run that ended and settled its execution without a final result, `unavailable` or `failed`, is rejected with `conflict`; the workflow runs again under a new execution id. When the server is stopping, or the run's log keeps changing while the start is decided, the execution is rejected with `unavailable` and the fixed detail `The workflow cannot start now; try again shortly`; an event the run cannot take then answers `unavailable` with `The workflow cannot take the event now; try again shortly`. +`run_definition` of a workflow starts its run and answers it `started`, with an empty record, or how it ended when the run ended in its first input: the run log is `run-logs/` under the brain, and its first input carries the document, the input, the run's limits, a random seed and, as attributes, the org, the brain, the run id, the definition, the caller, the reaction depth and the call depth. The capability declares that its runs finish later, so the start records `finishes_later` and a settle that lands before the deferral is accepted; the limits name, for each task that calls a definition it names as written, the longest a run of that definition may take plus a minute (`longestCallMsByTask`, resolved through `longestRunOf` of the run's context), and the run-wide `longestCallMs` serves every other call. A start with the id of a run that is going answers the run as it stands, so a run started twice runs one workflow. A run never starts twice in one log, so a start with the id of a run that ended without a final result, `unavailable` or `failed`, is rejected with `conflict`; the workflow runs again under a new run id. When the server is stopping, or the run's log keeps changing while the start is decided, the run is rejected with `unavailable` and the fixed detail `The workflow cannot start now; try again shortly`; an event the run cannot take then answers `unavailable` with `The workflow cannot take the event now; try again shortly`. -When the run ends, its last event settles the execution through `executionSettler` of `@beonauto/specs`: +When the workflow ends, its last event settles the run through `runSettler` of `@beonauto/definitions`: - **succeeded** with the output of the workflow, when it completes; -- **rejected** when an error is not caught: `invalid_input` for an error of a 4xx status other than 408 and 429 (the input led to it, and retrying the execution answers the same), and `unavailable` for every other status (a timeout, a failure to reach a spec, a server error: retrying the execution may succeed), except that an error of a kind with a problem type of its own takes that kind's reason, `unavailable` for `tools_unfinished` and `conflict` for `tools_called`. The detail is the title or type, the detail and the instance of the error, and the error's `kind` and `because` are kept on the rejection only for `tools_unfinished` and `tools_called`, whose words hold for the whole workflow, so a workflow whose step ended `tools_unfinished` reads as `tools_unfinished`, with its because, not as a plain `unavailable`; any other kind of a step, such as `mcp_server_failed`, whose words speak for that step alone (they say nothing was called through it, though an earlier step may have called tools), settles as a plain `unavailable` with the step's detail; -- **rejected** with the reason `cancelled` and the kind of the cancel, when it was cancelled (`requested`, `deadline` or `parent_ended`, with the actor and the reason the cancel carried) or when it has run `ORCHESTRATION_MAX_DURATION` (`overrun`); -- **rejected** with the reason `conflict` and the kind `oversized`, when its output is larger than an execution records (1048574 bytes as JSON); +- **rejected** when an error is not caught: `invalid_input` for an error of a 4xx status other than 408 and 429 (the input led to it, and retrying the run answers the same), and `unavailable` for every other status (a timeout, a failure to reach a definition, a server error: retrying the run may succeed), except that an error of a kind with a problem type of its own takes that kind's reason, `unavailable` for `tools_unfinished` and `conflict` for `tools_called`. The detail is the title or type, the detail and the instance of the error, and the error's `kind` and `because` are kept on the rejection only for `tools_unfinished` and `tools_called`, whose words hold for the whole workflow, so a workflow whose step ended `tools_unfinished` reads as `tools_unfinished`, with its because, not as a plain `unavailable`; any other kind of a step, such as `mcp_server_failed`, whose words speak for that step alone (they say nothing was called through it, though an earlier step may have called tools), settles as a plain `unavailable` with the step's detail; +- **rejected** with the reason `cancelled` and the kind of the cancel, when it was cancelled (`requested`, `deadline` or `parent_ended`, with the actor and the reason the cancel carried) or when it has run `WORKFLOW_MAX_DURATION` (`overrun`); +- **rejected** with the reason `conflict` and the kind `oversized`, when its output is larger than a run records (1048574 bytes as JSON); - **failed** when the run breaks down. -The settlement is handed out after the event that holds it is appended, and settling again with the same settlement records nothing, so a server that stops in between settles it when it starts again. A settlement the ledger refuses, such as one for a run that ended before its start recorded that the execution finishes later, or one the ledger cannot take while it cannot be reached, is handed out again at every sweep, and after 20 failed attempts once a minute, for ever, so a run is never left unsettled for want of a retry: the server warns once when the back-off begins, `An execution could not be settled in 20 attempts; it is tried again once a minute until it is`, and once when the execution is settled at last. Starting the execution again tries its settlement at once. An execution the ledger does not have, or one already settled otherwise, stays as it is: the server logs `An execution stays started because settling it failed` as an error with the org, the brain, the execution id and the reason, never the input or output. +The settlement is handed out after the event that holds it is appended, and settling again with the same settlement records nothing, so a server that stops in between settles it when it starts again. A settlement the ledger refuses, such as one for a run that ended before its start recorded that the run finishes later, or one the ledger cannot take while it cannot be reached, is handed out again at every sweep, and after 20 failed attempts once a minute, for ever, so a run is never left unsettled for want of a retry: the server warns once when the back-off begins, `A run could not be settled in 20 attempts; it is tried again once a minute until it is`, and once when the run is settled at last. Starting the run again tries its settlement at once. A run the ledger does not have, or one already settled otherwise, stays as it is: the server logs `A run stays started because settling it failed` as an error with the org, the brain, the run id and the reason, never the input or output. -Each run arms its deadline when it starts, at `ORCHESTRATION_MAX_DURATION` exactly: when it fires, the run cancels what is running and ends, and its execution settles `rejected` as `cancelled` of the kind `overrun`. The machine's limits keep a run within what one event and one snapshot hold; the [engine's README](../../../packages/workflow-engine/README.md#limits) lists them, and [The work of expressions](#the-work-of-expressions) has its own. +Each run arms its deadline when it starts, at `WORKFLOW_MAX_DURATION` exactly: when it fires, the workflow cancels what is running and ends, and the run settles `rejected` as `cancelled` of the kind `overrun`. The machine's limits keep a run within what one event and one snapshot hold; the [engine's README](../../../packages/workflow-engine/README.md#limits) lists them, and [The work of expressions](#the-work-of-expressions) has its own. ### Determinism and replay @@ -64,72 +64,72 @@ A run decides each input as a pure function of the input and its state: it reads ### The steps of a record -Each event of a run's log, `input_applied`, lists one entry for each run of a task the input moved, in the order the runs first moved, with how it ended the input: `started` for a task that runs others and was still running, or `skipped`, `waiting`, `completed`, `raised`, `timed_out` or `cancelled`. A later outcome in the same input replaces the earlier one, so a task that starts and finishes in one input has only `completed`, and a call only `waiting` in the input that starts it. An entry has `reference`, `run`, `outcome`, `name`, cut at 256 bytes, `times`, the count of entries of the run so far with the same reference, run and outcome, from 1, which goes past 1 only for a `listen` for all of several events, and `caused_by`, the reference, run, outcome and times of the entry whose outcome made it move, or `input`. A task that starts in the input is caused by the step before it in its list, the `switch` that chose it, the `fork` it is a branch of, the failed attempt it retries or the step before a yield, and the first step of a run by `input`; a task that started in an earlier input by its own latest entry, the `waiting` it resumes or the `started` of a task that runs others. An entry a later outcome replaced keeps its cause, and what it caused names it as it ended the input, so every cause names an entry before it in the same event or in an earlier one. For `raised` and `timed_out` the error's `type` and `title`, the title cut at 1 KiB; on `waiting`, `waits_for`, and on the wait of a call, `child`, the execution id of the run the call starts, or nothing for arguments the call would refuse. The event also holds `resumed`, the reference, run and times of the `waiting` entry its input resumed, or null for a start, for a timer of a yield, a timeout, a retry's delay, an attempt's limit or the run's deadline, for an event no listen took, and for a cancel. +Each event of a run's log, `input_applied`, lists one entry for each run of a task the input moved, in the order the runs first moved, with how it ended the input: `started` for a task that runs others and was still running, or `skipped`, `waiting`, `completed`, `raised`, `timed_out` or `cancelled`. A later outcome in the same input replaces the earlier one, so a task that starts and finishes in one input has only `completed`, and a call only `waiting` in the input that starts it. An entry has `reference`, `run`, `outcome`, `name`, cut at 256 bytes, `times`, the count of entries of the run so far with the same reference, run and outcome, from 1, which goes past 1 only for a `listen` for all of several events, and `caused_by`, the reference, run, outcome and times of the entry whose outcome made it move, or `input`. A task that starts in the input is caused by the step before it in its list, the `switch` that chose it, the `fork` it is a branch of, the failed attempt it retries or the step before a yield, and the first step of a run by `input`; a task that started in an earlier input by its own latest entry, the `waiting` it resumes or the `started` of a task that runs others. An entry a later outcome replaced keeps its cause, and what it caused names it as it ended the input, so every cause names an entry before it in the same event or in an earlier one. For `raised` and `timed_out` the error's `type` and `title`, the title cut at 1 KiB; on `waiting`, `waits_for`, and on the wait of a call, `child`, the run id of the run the call starts, or nothing for arguments the call would refuse. The event also holds `resumed`, the reference, run and times of the `waiting` entry its input resumed, or null for a start, for a timer of a yield, a timeout, a retry's delay, an attempt's limit or the run's deadline, for an event no listen took, and for a cancel. These are state format 6: across inputs the state keeps the entry before a yield, the entry that failed the attempt a `try` waits to repeat, how often a `listen` waited, the listeners its open listens armed, the keys of the offers it took, the count and bytes of the events it emitted, the limits of each call, and the cancel that ended a cancelled run. Formats 3, 4 and 5 are read with their own frozen schemas and upcast, format 4's upcast arming the listeners its open listens' literal filters name and format 5's giving a cancelled run a cancel that says it was recorded before a cancel named who asked, the engine's corpus holds a run of every format, and events of formats 1 to 3 keep the steps they recorded, one for each run of a task with how it ended the input, and no `resumed`. -`input-logs/` of `@beonauto/orchestration` holds 15 paths as the workflow machine of `@beonauto/workflow-engine` runs them, each the inputs a run took and the events the machine decided: a call of `execute_spec` and a `set` (`execute-spec`), a `try` retried with backoff on timers (`retry-with-backoff`), a caught error that recovers through `catch.do` (`catch-do-recovery`), a `raise` nobody catches (`uncaught-error`), a `wait` (`timer`), a `for` loop that waits (`for-with-wait`), a fork (`parallel-fork`) and a fork that competes, cancelling the slower branch (`fork-compete`), a `listen` that takes events sent while it waits (`listen-signals`), a `timeout` that fires and is caught (`timeout-fires`), a `switch` (`switch`), a `then` that jumps back to an earlier task (`then-jump-back`), a cancelled run (`cancelled`), a run a new machine rebuilds from its log (`worker-restart`), and a run that reads the time, which comes only from its inputs (`temporal-global`). The names are those of the paths they were first recorded from. `src/input-logs/input-logs.test.ts` replays every log through the machine and expects exactly the events it recorded, and runs every path again and expects the log as committed. To record them again, deliberately, run `RECORD_INPUT_LOGS=1` with the tests. +`input-logs/` of `@beonauto/coordination` holds 15 paths as the workflow machine of `@beonauto/workflow-engine` runs them, each the inputs a run took and the events the machine decided: a call of `run_definition` and a `set` (`run-definition`), a `try` retried with backoff on timers (`retry-with-backoff`), a caught error that recovers through `catch.do` (`catch-do-recovery`), a `raise` nobody catches (`uncaught-error`), a `wait` (`timer`), a `for` loop that waits (`for-with-wait`), a fork (`parallel-fork`) and a fork that competes, cancelling the slower branch (`fork-compete`), a `listen` that takes events sent while it waits (`listen-signals`), a `timeout` that fires and is caught (`timeout-fires`), a `switch` (`switch`), a `then` that jumps back to an earlier task (`then-jump-back`), a cancelled run (`cancelled`), a run a new machine rebuilds from its log (`worker-restart`), and a run that reads the time, which comes only from its inputs (`temporal-global`). The names are those of the paths they were first recorded from. `src/input-logs/input-logs.test.ts` replays every log through the machine and expects exactly the events it recorded, and runs every path again and expects the log as committed. To record them again, deliberately, run `RECORD_INPUT_LOGS=1` with the tests. ## Running it Every server runs workflows (`packages/server/src/workflows/workflows.ts`). The pieces it puts together: -- `openWorkflowHost(options)` of `@beonauto/workflow-host`, opened on the ledger's own database, with `orchestrationMachine`, the calls of `definitionCalls`, which run a definition through the server's dispatcher as the caller who started the run (`definitionRunResultOf` turns its outcome into a result), `executionSettler` over the server's ledger, and reports to the server's log. This callback also supports extension adapters outside the five brain function types. -- the waiting options of the host: `callResultOfEnding`, which maps the ending of a run a call waits for to the call's answer, `executionCanceller` and `deferredCanceller` of `@beonauto/specs` over the server's ledger, so the host cancels the runs a cancelled call waited for and settles a cancelled run of another capability that finishes later; -- `makeWorkflowAdapter({ runs, mostDurationMs, longestCallMs })`, the workflow runtime adapter for `makeSpecOperations`, whose `runs` reach the host's start through a port bound once the host is open, since the nested `execute_spec` of the host runs over every capability, the workflow included, and `defineSendExecutionEvent(runs)`, the brain operation `send_execution_event`. -- `runPresenter`, given with the presenters of the primitives, so a workflow's history and the brain's events show each input its run took and each step it moved. +- `openWorkflowHost(options)` of `@beonauto/workflow-host`, opened on the ledger's own database, with `workflowMachineOptions`, the calls of `definitionCalls`, which run a definition through the server's dispatcher as the caller who started the run (`definitionRunResultOf` turns its outcome into a result), `runSettler` over the server's ledger, and reports to the server's log. This callback also supports extension adapters outside the five brain function types. +- the waiting options of the host: `callResultOfEnding`, which maps the ending of a run a call waits for to the call's answer, `runCanceller` and `deferredCanceller` of `@beonauto/definitions` over the server's ledger, so the host cancels the runs a cancelled call waited for and settles a cancelled run of another capability that finishes later; +- `makeWorkflowAdapter({ runs, mostDurationMs, longestCallMs })`, the workflow runtime adapter for `makeDefinitionOperations`, whose `runs` reach the host's start through a port bound once the host is open, since the nested `run_definition` of the host runs over every capability, the workflow included, and `defineSendRunEvent(runs)`, the brain operation `send_run_event`. +- `runPresenter`, given with the presenters of the capabilities, so a workflow's history and the brain's events show each input its run took and each step it moved. -The host runs in the server's process: it fires timers when they are due, sweeps every `ORCHESTRATION_SWEEP_INTERVAL`, and runs at most `ORCHESTRATION_NESTED_EXECUTIONS` calls at once. A call whose run finishes later, such as one of another workflow, waits for that run without holding one of them, and the run's ending answers it through the host's follower; at most `ORCHESTRATION_MAX_OPEN_CALLS` calls wait under one run at the top of a tree. When the server stops, the host lets the starts and events it took and the decision in progress finish, then cuts off the calls in flight, which start again when the server next starts, while a waiting call stays waiting, lets go of its claim on the workflows, and closes its database. [Workflow operations](../self-host/workflows.md) describes it for an operator. +The host runs in the server's process: it fires timers when they are due, sweeps every `WORKFLOW_SWEEP_INTERVAL`, and runs at most `WORKFLOW_NESTED_RUNS` calls at once. A call whose run finishes later, such as one of another workflow, waits for that run without holding one of them, and the run's ending answers it through the host's follower; at most `WORKFLOW_MAX_OPEN_CALLS` calls wait under one run at the top of a tree. When the server stops, the host lets the starts and events it took and the decision in progress finish, then cuts off the calls in flight, which start again when the server next starts, while a waiting call stays waiting, lets go of its claim on the workflows, and closes its database. [Workflow operations](../self-host/workflows.md) describes it for an operator. ### What the server logs of its workflows - at start-up, one line saying how long a run lasts at most, how many calls run at once and how often the runs are swept; - a warning for each failure the host retries: a sweep that failed, a timer that could not fire, an answer of a call that could not be recorded or given to its run, and a claim on the workflows that could not be renewed, each with its cause cut at 2,000 characters; - a warning when the server stands by because another server holds the claim on the workflows of its database, naming that server, and one when it takes the workflows over; -- a warning when the settlement of a run backs off to one attempt a minute, and one when that execution is settled at last; +- a warning when the settlement of a run backs off to one attempt a minute, and one when that run is settled at last; - on PostgreSQL, a warning for a lost connection of the host's; -- an error for an execution a run could not settle because the ledger has no such execution or settled it otherwise before, with its org, brain, execution id and reason; +- an error for a run its workflow could not settle because the ledger has no such run or settled it otherwise before, with its org, brain, run id and reason; - a warning for an event a waiting run did not take because its filter failed on the event, and one for a record of a brain the host could not read to match triggers and listeners against, which it passed over. -A workflow that fails for a reason of its tenant, an uncaught error, a rejected nested execution or a limit, is not logged at all; its execution's rejection says why, and its history shows its steps. +A workflow that fails for a reason of its tenant, an uncaught error, a rejected nested run or a limit, is not logged at all; its run's rejection says why, and its history shows its steps. ### Memory What a server spends on workflows is bounded by limits a tenant cannot raise: - **Data a run holds at once**, at most 4 MiB, counted by the machine (`heldBytes`); a value held across a wait or a yield is bounded by the 1,572,864 bytes one event holds. A run that would hold more ends with a `runtime` error. -- **Events** (`send_execution_event`): each at most 256 KiB as JSON, its `type` and `id` at most 256 characters and its `source` and `subject` at most 1024. A run holds at most 64 events it has not consumed, and 1 MiB of them; it takes at most 1024 events, or 4 MiB of them as JSON, over its life. One more ends the run with a `runtime` error at once, whatever it is doing; its execution settles `rejected`, and later events are rejected as `not_found`. +- **Events** (`send_run_event`): each at most 256 KiB as JSON, its `type` and `id` at most 256 characters and its `source` and `subject` at most 1024. A run holds at most 64 events it has not consumed, and 1 MiB of them; it takes at most 1024 events, or 4 MiB of them as JSON, over its life. One more ends the run with a `runtime` error at once, whatever it is doing; the run settles `rejected`, and later events are rejected as `not_found`. - **Loaded runs**: the engine keeps at most 1,024 runs and 64 MiB of the data they hold between their inputs, letting go of the run used longest ago; a run it let go of is loaded again from its latest snapshot, at most about 5.3 MiB, and the events after it. - **Compiled expressions** are kept in one cache for the process, holding at most 262,144 characters of expression source and letting go of the expression used longest ago. A compiled expression measured 22 to 34 bytes of heap for each character of its source, so the cache holds at most about 9 MiB; compiling one again took 10 to 150 µs. -Measured once with the arm64 image and no memory limit, the server took about 180 MiB idle. Requests to the API add what they carry (each body at most 1 MiB), and calls add what their primitives use, at most `ORCHESTRATION_NESTED_EXECUTIONS` (32) of them at once. To cap the resident size, give the container a memory limit, from which Node sizes its heap to about half of it, or set `--max-old-space-size` in `NODE_OPTIONS`. +Measured once with the arm64 image and no memory limit, the server took about 180 MiB idle. Requests to the API add what they carry (each body at most 1 MiB), and calls add what their capabilities use, at most `WORKFLOW_NESTED_RUNS` (32) of them at once. To cap the resident size, give the container a memory limit, from which Node sizes its heap to about half of it, or set `--max-old-space-size` in `NODE_OPTIONS`. ### Settings -| Variable | Default | Purpose | -| --------------------------------- | ------- | ---------------------------------------------------------------------------------------------------- | -| `ORCHESTRATION_MAX_DURATION` | `P30D` | The most a run may last, an ISO 8601 duration from `PT2H` to `P365D`; checked when the server starts | -| `ORCHESTRATION_NESTED_EXECUTIONS` | `32` | How many calls of runs the server runs at once, from 1 to 1000; more wait until one ends | -| `ORCHESTRATION_MAX_OPEN_CALLS` | `1000` | How many calls may wait under one run at the top of a tree, from 1 to 9999; the next is a `conflict` | -| `ORCHESTRATION_SWEEP_INTERVAL` | `PT1S` | How often the host sweeps the runs, an ISO 8601 duration from `PT0.01S` to `PT1M` | +| Variable | Default | Purpose | +| ------------------------- | ------- | ---------------------------------------------------------------------------------------------------- | +| `WORKFLOW_MAX_DURATION` | `P30D` | The most a run may last, an ISO 8601 duration from `PT2H` to `P365D`; checked when the server starts | +| `WORKFLOW_NESTED_RUNS` | `32` | How many calls the server runs at once, from 1 to 1000; more wait until one ends | +| `WORKFLOW_MAX_OPEN_CALLS` | `1000` | How many calls may wait under one run at the top of a tree, from 1 to 9999; the next is a `conflict` | +| `WORKFLOW_SWEEP_INTERVAL` | `PT1S` | How often the host sweeps the runs, an ISO 8601 duration from `PT0.01S` to `PT1M` | ### Tenancy -- A run is addressed by its org, its brain and its execution id, and its log is a stream under the brain's prefix of the ledger, where the brain's other records are. -- A call executes a spec in the org and brain of its run, for the caller who started the run, with the permissions that caller had then. -- There is no fairness between orgs and no limit for one: the calls of every org share the server's `ORCHESTRATION_NESTED_EXECUTIONS` slots, so one org's runs can take them all, and the others' calls wait. -- A run's log holds its document, its input, the outputs of the specs it executes and of the workflow, its events, and the identity of the caller who started it, in the ledger's database: tenant data is stored once, beside the brain's records, and is readable by whoever can read that database. +- A run is addressed by its org, its brain and its run id, and its log is a stream under the brain's prefix of the ledger, where the brain's other records are. +- A call executes a definition in the org and brain of its run, for the caller who started the run, with the permissions that caller had then. +- There is no fairness between orgs and no limit for one: the calls of every org share the server's `WORKFLOW_NESTED_RUNS` slots, so one org's runs can take them all, and the others' calls wait. +- A run's log holds its document, its input, the outputs of the definitions it executes and of the workflow, its events, and the identity of the caller who started it, in the ledger's database: tenant data is stored once, beside the brain's records, and is readable by whoever can read that database. ## Reactions The host follows every brain of its database (`src/follower` of `@beonauto/workflow-host`): it finds them from the orgs' registries of brains when it starts and from a brain's creation afterwards, and reads each one's records oldest first through the ledger's recorded read, from a cursor it keeps in its own tables, woken by the ledger's append signal and at every sweep, which passes the brains the ledger says were appended to since the last sweep and those whose deliveries wait. A record is read with its data only when a trigger or a listener of its brain names its type, or, for a published event, any type that is not a fact's; the others are passed over without it. A record of a run's log is passed only once the run's dispatch watermark covers it, so the listeners it armed are kept by then, and a listener takes only events recorded after the record that armed it; a record the watermark has not covered by its twentieth sweep is passed all the same, and noted. -Each event and each fact of a run or a definition goes to two consumers, at most 100 deliveries of a record a pass between them. The offers to listeners give each listening run an `event_offered` input keyed by the record's id; the run checks its own filters, with its variables, and takes or declines the offer. The starts of triggered workflows call `start_definition_version` of `@beonauto/specs` in the server's process, never over a transport, as the brain's own caller, `brain:` (`brainCallerOf` of `@beonauto/operations`), with the run's id the version 5 UUID of `[workflow, version, trigger reference, record id]`, or of the due time for a schedule, in the namespace `3c9e1f04-7b2a-5d68-8e41-0a6f5c2d9b73`, the record's id as the cause, or for a schedule the record that activated it, the run's own id as its correlation, and the reaction depth and the trigger in the brain request, which `execution_started` records. The start is create-only, so a delivery repeated after a restart starts nothing again, while a delivery whose run ended without a result, as `unavailable`, starts it again. A delivery that fails holds the brain's cursor and is tried at each sweep; after 20 it is given up and recorded. A start the brain rejects outright is recorded at once. +Each event and each fact of a run or a definition goes to two consumers, at most 100 deliveries of a record a pass between them. The offers to listeners give each listening run an `event_offered` input keyed by the record's id; the run checks its own filters, with its variables, and takes or declines the offer. The starts of triggered workflows call `start_definition_version` of `@beonauto/definitions` in the server's process, never over a transport, as the brain's own caller, `brain:` (`brainCallerOf` of `@beonauto/operations`), with the run's id the version 5 UUID of `[workflow, version, trigger reference, record id]`, or of the due time for a schedule, in the namespace `3c9e1f04-7b2a-5d68-8e41-0a6f5c2d9b73`, the record's id as the cause, or for a schedule the record that activated it, the run's own id as its correlation, and the reaction depth and the trigger in the brain request, which `run_started` records. The start is create-only, so a delivery repeated after a restart starts nothing again, while a delivery whose run ended without a result, as `unavailable`, starts it again. A delivery that fails holds the brain's cursor and is tried at each sweep; after 20 it is given up and recorded. A start the brain rejects outright is recorded at once. -Each trigger is a row of the host's subscriptions table, keyed by the brain, the workflow and the trigger's reference, kept with its activation across versions while it is unchanged; a schedule's row holds its next due time and its running run: `cron` through `croner` in UTC, `every` from the trigger's activation. A time is skipped while the run of the time before still runs, and after downtime only the latest time runs. An `emit` output is recorded through `eventEmitter` of `@beonauto/specs` over the server's ledger, with the run and workflow that emitted it, one reaction depth deeper than the run. Every refusal, a depth past 8, a start past 60 a minute with 1,000 waiting, a listener past 4,096 in a brain, a filter that failed, a skipped time, is counted in the host's tables and recorded once its minute ends as `reaction_refused` on `reactions/` under the brain, which `list_brain_events` presents. The [host's README](../../../packages/workflow-host/README.md#reactions) has the tables and the bounds. +Each trigger is a row of the host's subscriptions table, keyed by the brain, the workflow and the trigger's reference, kept with its activation across versions while it is unchanged; a schedule's row holds its next due time and its running run: `cron` through `croner` in UTC, `every` from the trigger's activation. A time is skipped while the run of the time before still runs, and after downtime only the latest time runs. An `emit` output is recorded through `eventEmitter` of `@beonauto/definitions` over the server's ledger, with the run and workflow that emitted it, one reaction depth deeper than the run. Every refusal, a depth past 8, a start past 60 a minute with 1,000 waiting, a listener past 4,096 in a brain, a filter that failed, a skipped time, is counted in the host's tables and recorded once its minute ends as `reaction_refused` on `reactions/` under the brain, which `list_brain_events` presents. The [host's README](../../../packages/workflow-host/README.md#reactions) has the tables and the bounds. An event sent to a run without a source now carries `/callers/`, so a `listen` filter of `source: null` no longer matches it; the public format reference says so in its upgrade note. ## Not in this version -The public reference lists what a document may not use. The implementation also has no operation that cancels a run, and runs the workflows of a database in one server at a time. The only supported DSL call is `execute_spec`. Adding another DSL call would require its name and argument checks in `src/document/workflow-functions.ts`, and an implementation in `src/calls/function-calls.ts`. This is the workflow engine's call interface, separate from defining a reusable brain function. +The public reference lists what a document may not use. The implementation also has no operation that cancels a run, and runs the workflows of a database in one server at a time. The only supported DSL call is `run_definition`. Adding another DSL call would require its name and argument checks in `src/document/workflow-functions.ts`, and an implementation in `src/calls/function-calls.ts`. This is the workflow engine's call interface, separate from defining a reusable brain function. diff --git a/docs/engineering/self-host/configuration.md b/docs/engineering/self-host/configuration.md index 0c770ccaf..164b73af9 100644 --- a/docs/engineering/self-host/configuration.md +++ b/docs/engineering/self-host/configuration.md @@ -34,7 +34,7 @@ model_aliases: | Tools that functions may call | `mcp_servers` in the file, each entry's `allowed` to allow only some of its tools | [MCP servers](#mcp-servers) | | The requests interaction functions may keep open | `INTERACTION_OPEN_REQUESTS` | [Interaction functions](#interaction-functions) | -`list_models` (`GET /v1/orgs/{org}/models`, and the MCP tool of the same name) lists the models the server can call: it asks Anthropic, OpenAI, Google and each gateway for their models with the server's own credentials, keeps each list for five minutes, adds the models `declared_models` names and the aliases whose target's provider is configured, and leaves out what `allowed_models` does not allow. A spec that names a model outside `allowed_models`, by its own name or the alias it is sent through, cannot run, and its run says the model is not offered and that `list_models` shows those that are. +`list_models` (`GET /v1/orgs/{org}/models`, and the MCP tool of the same name) lists the models the server can call: it asks Anthropic, OpenAI, Google and each gateway for their models with the server's own credentials, keeps each list for five minutes, adds the models `declared_models` names and the aliases whose target's provider is configured, and leaves out what `allowed_models` does not allow. A definition that names a model outside `allowed_models`, by its own name or the alias it is sent through, cannot run, and its run says the model is not offered and that `list_models` shows those that are. ```yaml declared_models: diff --git a/docs/engineering/self-host/container.md b/docs/engineering/self-host/container.md index 92c965c24..5a3b94c08 100644 --- a/docs/engineering/self-host/container.md +++ b/docs/engineering/self-host/container.md @@ -36,7 +36,7 @@ The secrets the file refers to, such as `GATEWAY_API_KEY`, go in `auto-brain.env | `LOCAL_MODE` | `false` | `true` trusts every request as the local developer; see [Local mode](security.md#local-mode) | | `LOG_FORMAT` | `json` | `json`, one JSON object per line on stderr, or `pretty`, lines of text for a person at a terminal | -The [reasoning function adapter](../reference/reasoning-format.md) calls language models with these settings, all optional; [Configuring a model](configuration.md) says which to set for what. A provider whose settings are absent is not configured, and a spec that names it is rejected as `unavailable` when it runs, naming the providers that are configured; the reasoning function description that the spec tools carry names them too, so an assistant writes the model with one of them. When it starts, the server logs one line naming the providers that are configured, or a warning when none is, and a warning for each provider that has some of its settings but not all it needs. Settings it cannot read stop it at start-up, naming the setting and never its value. +The [reasoning function adapter](../reference/reasoning-format.md) calls language models with these settings, all optional; [Configuring a model](configuration.md) says which to set for what. A provider whose settings are absent is not configured, and a definition that names it is rejected as `unavailable` when it runs, naming the providers that are configured; the reasoning function description that the definition tools carry names them too, so an assistant writes the model with one of them. When it starts, the server logs one line naming the providers that are configured, or a warning when none is, and a warning for each provider that has some of its settings but not all it needs. Settings it cannot read stop it at start-up, naming the setting and never its value. | Variable | Purpose | | ------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------- | @@ -50,14 +50,14 @@ The [reasoning function adapter](../reference/reasoning-format.md) calls languag | `MODEL_ALIASES` | JSON map from one model reference to another; a trailing `*` on both sides covers every model of a provider; `model_aliases` in the file | | `NODE_USE_ENV_PROXY`, `HTTPS_PROXY`, `NO_PROXY`, `NODE_EXTRA_CA_CERTS` | Node's own switches for an outbound proxy and a private certificate authority | -The [workflow adapter](../reference/workflow-format.md) runs workflow specs in the server, keeping them in the ledger's database, with these settings; [Workflow operations](workflows.md) says what an operator must know about them. The server logs at start-up how long a run lasts at most, how many calls run at once and how often the runs are swept. +The [workflow adapter](../reference/workflow-format.md) runs workflow definitions in the server, keeping them in the ledger's database, with these settings; [Workflow operations](workflows.md) says what an operator must know about them. The server logs at start-up how long a run lasts at most, how many calls run at once and how often the runs are swept. -| Variable | Default | Purpose | -| --------------------------------- | ------- | --------------------------------------------------------------------------------------- | -| `ORCHESTRATION_MAX_DURATION` | `P30D` | The most a workflow may run, an ISO 8601 duration from `PT2H` to `P365D` | -| `ORCHESTRATION_NESTED_EXECUTIONS` | `32` | How many nested executions the server runs at once, from 1 to 1000; shared by every org | -| `ORCHESTRATION_MAX_OPEN_CALLS` | `1000` | How many calls may wait under one run at the top of a tree, from 1 to 9999 | -| `ORCHESTRATION_SWEEP_INTERVAL` | `PT1S` | How often the server sweeps the runs, an ISO 8601 duration from `PT0.01S` to `PT1M` | +| Variable | Default | Purpose | +| ------------------------- | ------- | ----------------------------------------------------------------------------------- | +| `WORKFLOW_MAX_DURATION` | `P30D` | The most a workflow may run, an ISO 8601 duration from `PT2H` to `P365D` | +| `WORKFLOW_NESTED_RUNS` | `32` | How many nested runs the server runs at once, from 1 to 1000; shared by every org | +| `WORKFLOW_MAX_OPEN_CALLS` | `1000` | How many calls may wait under one run at the top of a tree, from 1 to 9999 | +| `WORKFLOW_SWEEP_INTERVAL` | `PT1S` | How often the server sweeps the runs, an ISO 8601 duration from `PT0.01S` to `PT1M` | The [computation function adapter](../../reference/computation-format.md) runs each run of a computation function in a worker thread the server keeps between runs, so a run pays for starting a worker only when none is ready, with this setting: @@ -69,11 +69,11 @@ The server never keeps more worker threads alive, idle or busy, than this settin The [recall function adapter](../../reference/recall-format.md) shares those workers: a run of a recall function answers in one, and the server that runs the workflows keeps the views of recall functions, folding each page of a brain's history in one, using at most half of them at once and at least one. It keeps each view in a table beside the workflows' tables, in the ledger's database, so a view needs no other storage. These settings bound it: -| Variable | Default | Purpose | -| ----------------------------- | ------- | ------------------------------------------------------------------------------------------------------------------------------------------ | -| `RECOLLECTION_MAX_FUNCTIONS` | `32` | How many active recall functions a brain may keep, from 1 to 1000; lowering it below what a brain keeps refuses its saves and nothing else | -| `RECOLLECTION_MAX_REBUILDS` | `4` | How many views of one brain are built at once, from 1 to 64; the others wait in the order they were saved | -| `RECOLLECTION_BRAINS_AT_ONCE` | `4` | How many brains the server folds at once, from 1 to 64 | +| Variable | Default | Purpose | +| ----------------------- | ------- | ------------------------------------------------------------------------------------------------------------------------------------------ | +| `RECALL_MAX_FUNCTIONS` | `32` | How many active recall functions a brain may keep, from 1 to 1000; lowering it below what a brain keeps refuses its saves and nothing else | +| `RECALL_MAX_REBUILDS` | `4` | How many views of one brain are built at once, from 1 to 64; the others wait in the order they were saved | +| `RECALL_BRAINS_AT_ONCE` | `4` | How many brains the server folds at once, from 1 to 64 | A value outside its range stops the server at start, naming the setting. diff --git a/docs/engineering/self-host/installation.md b/docs/engineering/self-host/installation.md index f2054ca23..4573f4df8 100644 --- a/docs/engineering/self-host/installation.md +++ b/docs/engineering/self-host/installation.md @@ -13,7 +13,7 @@ The repository pins pnpm and Node in `package.json`; pnpm downloads the required ## Development server -`pnpm dev` starts the server, which runs workflows itself, and nothing else. The ledger, and with it the workflows, lives in `packages/server/.data/ledger.db`, so brains, specs and waiting workflows are still there after a restart; delete that directory to start over. +`pnpm dev` starts the server, which runs workflows itself, and nothing else. The ledger, and with it the workflows, lives in `packages/server/.data/ledger.db`, so brains, definitions and waiting workflows are still there after a restart; delete that directory to start over. Saving a `.ts` file other than a test under the `src` of any package of the repository, or `packages/server/dev.env`, `.env` or the configuration file, restarts the server through its clean shutdown; a workflow waiting for an event or a timer goes on after the restart, and a call cut off by it runs again. A server that does not start says why and starts again on the next save. Ctrl-C stops it. diff --git a/docs/engineering/self-host/models.md b/docs/engineering/self-host/models.md index 042de5c1c..f90c1962e 100644 --- a/docs/engineering/self-host/models.md +++ b/docs/engineering/self-host/models.md @@ -1,21 +1,21 @@ # Model providers and gateways -These are settings for the runtime's implemented reasoning functions, identified as `inference` in the API. Model availability depends on your provider account. +These are settings for the runtime's reasoning functions. Model availability depends on your provider account. ## How a model reference is resolved -A spec names its model as `provider/model`, for example `anthropic/claude-sonnet-4-5` or `bedrock/eu.anthropic.claude-sonnet-4-5-20250929-v1:0`. +A definition names its model as `provider/model`, for example `anthropic/claude-sonnet-4-5` or `bedrock/eu.anthropic.claude-sonnet-4-5-20250929-v1:0`. 1. If the aliases (`model_aliases` in the configuration file, or `MODEL_ALIASES`) map the reference to another one, the other one is used: an exact alias first, then the wildcard alias with the longest prefix, such as `anthropic/*` (see [Model aliases](#model-aliases)). An alias resolves in one hop. -2. The reference is split at its first `/`. The part before it is the provider, everything after it is the model id the provider receives, unchanged (Bedrock ARNs keep their own `/`). A reference without a `/`, or with nothing on either side of it, is `spec_invalid` and nothing is sent. -3. When `allowed_models` is set, the reference as the spec writes it, or the reference an alias sends it to, must be one of its entries, or start with the part before the `*` of one of them; otherwise it fails as `model_not_allowed` and nothing is sent (see [Listing the models](#listing-the-models)). +2. The reference is split at its first `/`. The part before it is the provider, everything after it is the model id the provider receives, unchanged (Bedrock ARNs keep their own `/`). A reference without a `/`, or with nothing on either side of it, is `definition_invalid` and nothing is sent. +3. When `allowed_models` is set, the reference as the definition writes it, or the reference an alias sends it to, must be one of its entries, or start with the part before the `*` of one of them; otherwise it fails as `model_not_allowed` and nothing is sent (see [Listing the models](#listing-the-models)). 4. The provider must be configured (see the table below). There is no default provider. A model id without a provider never reaches a default gateway: the package replaces the AI SDK's global default provider with one that has no models. ## Providers -A provider is configured when its required settings are present. One that is not configured is simply absent; a spec that names it fails with `provider_not_configured`, which names the providers that are configured. The settings a provider lacks are an operator's business: the server's start-up log names them, and the caller of a spec never sees them. +A provider is configured when its required settings are present. One that is not configured is simply absent; a definition that names it fails with `provider_not_configured`, which names the providers that are configured. The settings a provider lacks are an operator's business: the server's start-up log names them, and the caller of a definition never sees them. | Prefix | Calls | Required settings | Optional settings | In the default image | | ------------------- | ---------------------------------------------- | ---------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------- | ------------------------------------------------ | @@ -50,7 +50,7 @@ OPENAI_API_KEY=sk-... GOOGLE_GENERATIVE_AI_API_KEY=... ``` -Specs then name `anthropic/claude-sonnet-4-5`, `openai/gpt-5` or `google/gemini-2.5-flash`. +Definitions then name `anthropic/claude-sonnet-4-5`, `openai/gpt-5` or `google/gemini-2.5-flash`. ### Amazon Bedrock with an IAM role @@ -66,7 +66,7 @@ To reach Bedrock through a VPC endpoint, add: AWS_ENDPOINT_URL_BEDROCK_RUNTIME=https://vpce-0123456789abcdef0-abcdefgh.bedrock-runtime.eu-central-1.vpce.amazonaws.com ``` -Specs name `bedrock/eu.anthropic.claude-sonnet-4-5-20250929-v1:0`, or `bedrock-anthropic/` to use Anthropic's own request format through InvokeModel, which also accepts application inference profile ARNs. +Definitions name `bedrock/eu.anthropic.claude-sonnet-4-5-20250929-v1:0`, or `bedrock-anthropic/` to use Anthropic's own request format through InvokeModel, which also accepts application inference profile ARNs. ### Azure OpenAI with an API key @@ -75,7 +75,7 @@ AZURE_RESOURCE_NAME=acme-openai AZURE_API_KEY=... ``` -Specs name the deployment: `azure/gpt-5-production`. To address the resource by URL, set `AZURE_BASE_URL=https://acme-openai.openai.azure.com/openai` in place of `AZURE_RESOURCE_NAME`, and optionally `AZURE_API_VERSION`. When `AZURE_BASE_URL` is a host outside Azure, such as an API Management gateway, requests go to `/responses` without an `api-version`; the gateway owns the path and version. +Definitions name the deployment: `azure/gpt-5-production`. To address the resource by URL, set `AZURE_BASE_URL=https://acme-openai.openai.azure.com/openai` in place of `AZURE_RESOURCE_NAME`, and optionally `AZURE_API_VERSION`. When `AZURE_BASE_URL` is a host outside Azure, such as an API Management gateway, requests go to `/responses` without an `api-version`; the gateway owns the path and version. ### Azure OpenAI with Microsoft Entra ID (opt-in) @@ -85,7 +85,7 @@ Microsoft Entra ID needs `@azure/identity`, an optional dependency of this packa docker build --build-arg AZURE_IDENTITY=true --file packages/server/Dockerfile --tag auto-brain:entra . ``` -`AZURE_IDENTITY=true` adds `@azure/identity`, at the exact version `primitives/inference/package.json` pins, and nothing else: the other optional packages of the server's dependencies, such as the telemetry exporters of the ledger's libraries, stay out. Measured on arm64, the default image is 402 MB and this one 426 MB. Any value but `true` or `false` stops the build. Then give the workload an identity with the role _Cognitive Services OpenAI User_ on the resource (Azure workload identity on AKS, or a managed identity), and leave `AZURE_API_KEY` unset: +`AZURE_IDENTITY=true` adds `@azure/identity`, at the exact version `capabilities/reasoning/package.json` pins, and nothing else: the other optional packages of the server's dependencies, such as the telemetry exporters of the ledger's libraries, stay out. Measured on arm64, the default image is 402 MB and this one 426 MB. Any value but `true` or `false` stops the build. Then give the workload an identity with the role _Cognitive Services OpenAI User_ on the resource (Azure workload identity on AKS, or a managed identity), and leave `AZURE_API_KEY` unset: ```sh AZURE_RESOURCE_NAME=acme-openai @@ -102,7 +102,7 @@ GOOGLE_VERTEX_PROJECT=acme-ai GOOGLE_VERTEX_LOCATION=europe-west4 ``` -Outside Kubernetes, `GOOGLE_APPLICATION_CREDENTIALS=/var/run/secrets/google/credentials.json` names a credentials file instead. Specs name `vertex/gemini-2.5-flash` or `vertex-anthropic/claude-sonnet-4-5`. A `vertex-anthropic` call reads the access token twice, because the AI SDK's Anthropic model resolves its headers twice per request; the token client caches it, so this costs no extra exchange. +Outside Kubernetes, `GOOGLE_APPLICATION_CREDENTIALS=/var/run/secrets/google/credentials.json` names a credentials file instead. Definitions name `vertex/gemini-2.5-flash` or `vertex-anthropic/claude-sonnet-4-5`. A `vertex-anthropic` call reads the access token twice, because the AI SDK's Anthropic model resolves its headers twice per request; the token client caches it, so this costs no extra exchange. ### An internal OpenAI-compatible gateway with a custom header @@ -125,20 +125,20 @@ MODEL_GATEWAYS='[{"name":"internal","base_url":"https://llm.internal.example/v1" INTERNAL_LLM_KEY=... ``` -Specs name `internal/llama-3.3-70b`. Each gateway has: - -| Field | Required | Meaning | -| -------------------------- | -------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `name` | Yes | The provider prefix: 1 to 32 lowercase letters, digits and hyphens, starting with a letter, unique, and not one of the built-in prefixes | -| `base_url` | Yes | The http or https URL that `/chat/completions` is appended to | -| `api_key` | No | The key, sent as `Authorization: Bearer `. In the file, a reference to the variable that holds it, such as `${INTERNAL_LLM_KEY}` | -| `api_key_env` | No | In `MODEL_GATEWAYS` only, instead of `api_key`: the name of the variable that holds the key; it must be set | -| `headers` | No | Headers sent with every request; their values are treated as secrets. In the file, a header that carries a credential, such as `authorization`, is a reference | -| `query_params` | No | Query parameters added to every request; their values are treated as secrets | -| `structured_outputs` | No | `true` when the endpoint accepts `response_format: json_schema`; otherwise JSON is asked for as `json_object` and the schema is only checked here. Default `false` | -| `include_usage` | No | Asks for usage in streamed responses. Default `false` | -| `expose_provider_messages` | No | `true` when the gateway's error messages are safe to show to the callers of a spec. Default `false`: callers get only the provider prefix, the HTTP status and what it means, and the message goes to the operator; see [Provider messages](#provider-messages) | -| `allowed_provider_options` | No | The top-level request body fields a spec may set for this gateway through `provider_options`, for example `["user", "metadata"]`. Default none; see [Provider options](../reference/reasoning-format.md#provider-options) | +Definitions name `internal/llama-3.3-70b`. Each gateway has: + +| Field | Required | Meaning | +| -------------------------- | -------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `name` | Yes | The provider prefix: 1 to 32 lowercase letters, digits and hyphens, starting with a letter, unique, and not one of the built-in prefixes | +| `base_url` | Yes | The http or https URL that `/chat/completions` is appended to | +| `api_key` | No | The key, sent as `Authorization: Bearer `. In the file, a reference to the variable that holds it, such as `${INTERNAL_LLM_KEY}` | +| `api_key_env` | No | In `MODEL_GATEWAYS` only, instead of `api_key`: the name of the variable that holds the key; it must be set | +| `headers` | No | Headers sent with every request; their values are treated as secrets. In the file, a header that carries a credential, such as `authorization`, is a reference | +| `query_params` | No | Query parameters added to every request; their values are treated as secrets | +| `structured_outputs` | No | `true` when the endpoint accepts `response_format: json_schema`; otherwise JSON is asked for as `json_object` and the schema is only checked here. Default `false` | +| `include_usage` | No | Asks for usage in streamed responses. Default `false` | +| `expose_provider_messages` | No | `true` when the gateway's error messages are safe to show to the callers of a definition. Default `false`: callers get only the provider prefix, the HTTP status and what it means, and the message goes to the operator; see [Provider messages](#provider-messages) | +| `allowed_provider_options` | No | The top-level request body fields a definition may set for this gateway through `provider_options`, for example `["user", "metadata"]`. Default none; see [Provider options](../reference/reasoning-format.md#provider-options) | ### Behind an outbound proxy with a private certificate authority @@ -161,7 +161,7 @@ Mutual TLS to the model endpoints is not supported. ## Model aliases -`model_aliases` in the server's [configuration file](configuration.md) maps one reference to another. It lets specs keep a name while the deployment decides where it runs: +`model_aliases` in the server's [configuration file](configuration.md) maps one reference to another. It lets definitions keep a name while the deployment decides where it runs: ```yaml model_aliases: @@ -177,7 +177,7 @@ MODEL_ALIASES='{"anthropic/claude-haiku-4-5":"bedrock/eu.anthropic.claude-haiku- Both sides are written `provider/model`. A target may not itself be an alias, so cycles and chains are rejected when the server starts. The result of a call records the model as requested, as resolved, and as the provider answered. -A trailing `*` on both sides makes a wildcard alias, which sends every model of a provider through a gateway. With only a gateway configured, a spec that names `anthropic/claude-sonnet-4-5` reaches it as `gateway/anthropic/claude-sonnet-4-5` with: +A trailing `*` on both sides makes a wildcard alias, which sends every model of a provider through a gateway. With only a gateway configured, a definition that names `anthropic/claude-sonnet-4-5` reaches it as `gateway/anthropic/claude-sonnet-4-5` with: ```yaml model_aliases: @@ -188,7 +188,7 @@ or `MODEL_ALIASES='{"anthropic/*":"gateway/anthropic/*"}'`. For a gateway that names models without the provider's prefix, the target is `gateway/*`. The `*` stands once, at the end of both sides, for the rest of the reference, which may not be empty. An exact alias wins over a wildcard, and among wildcards the longest prefix wins. A wildcard target may not reach another alias either, so `{"anthropic/*":"gateway/*","gateway/fast":"gateway/llama-3.3-70b"}` is rejected when the server starts. A provider reached only through a wildcard alias is not a configured provider: with the alias above, `anthropic` stays unconfigured in `status` and in the start-up log. -The reasoning function description that the spec tools carry lists the alias names as they are written, `anthropic/*` included, and says that a spec may give any `anthropic/`, so an agent writing a spec sees them. +The reasoning function description that the definition tools carry lists the alias names as they are written, `anthropic/*` included, and says that a definition may give any `anthropic/`, so an agent writing a definition sees them. ## Listing the models @@ -242,7 +242,7 @@ It answers in the shape of the OpenAI API's list of models, the shape OpenAI, Az | Field | Meaning | | ---------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- | -| `id` | The model as a spec names it: `/`, or an alias as its operator wrote it | +| `id` | The model as a definition names it: `/`, or an alias as its operator wrote it | | `created` | When the provider released or added the model, in seconds since 1970, as the provider says; 0 when it does not | | `owned_by` | The provider prefix that serves the model; for an alias, the prefix of its target. Never the owner a provider reports, which can name an organisation | | `name` | The provider's name for the model, only when it reports one | @@ -253,7 +253,7 @@ It answers in the shape of the OpenAI API's list of models, the shape OpenAI, Az | `catalog_status` | `partial` when a provider could not be asked, so its models are missing or are its last list; `complete` otherwise | | `listed_at` | When the oldest list in the answer was read from its provider; the time of the answer when none was read | -The entries are sorted by `id`, each `id` once. A model an alias sends elsewhere is left out, since a spec that names it reaches the alias: with `anthropic/*` sent to a gateway, the models Anthropic lists are not listed, and `anthropic/*` is. +The entries are sorted by `id`, each `id` once. A model an alias sends elsewhere is left out, since a definition that names it reaches the alias: with `anthropic/*` sent to a gateway, the models Anthropic lists are not listed, and `anthropic/*` is. Its plain words name the models by the name their provider gives, or by the last part of the id, twenty at most, adding the provider to a name two providers share and naming by the id a name one provider repeats, and say which providers take any model id and when the list may be incomplete: `This server can call 6 models through anthropic and gateway: Claude Haiku 4.5 (anthropic), Claude Opus 4.1, Claude Sonnet 4.5, Qwen3-14B, Claude Haiku 4.5 (gateway), and fast.` @@ -269,7 +269,7 @@ Each list is read with the credentials and endpoint the server calls the provide | a gateway | `GET /models` with its key, headers and query parameters; an entry with a `type` other than `language`, as Vercel's gateway marks embedding and image models, is left out. When the list cannot be read, the models declared for the gateway are listed instead, and the answer is `partial` | | `bedrock`, `bedrock-anthropic`, `azure`, `vertex`, `vertex-anthropic` | The models declared for it, or `/*` when none is. Their own list APIs show a catalogue rather than what the credential may call, and need permissions of their own | -Every alias is listed by its own name, unless the provider of its target is not configured: a spec cannot call it then, and the reasoning function description leaves it out too. Each entry of a list is read on its own, so an entry with a field of an unexpected type, or null, does not cost the others: an entry without a text id is left out, and a detail that is not of its type is taken as not reported. An entry whose id is empty, longer than 256 characters, holds a space, a control character or a `*`, or is a Bedrock ARN is left out, the same rule declared models meet; a lookalike of another id, such as one with a Cyrillic letter, is kept, since it is what a spec would have to write. A name longer than 100 characters or on more than one line is left out, and the entry kept. +Every alias is listed by its own name, unless the provider of its target is not configured: a definition cannot call it then, and the reasoning function description leaves it out too. Each entry of a list is read on its own, so an entry with a field of an unexpected type, or null, does not cost the others: an entry without a text id is left out, and a detail that is not of its type is taken as not reported. An entry whose id is empty, longer than 256 characters, holds a space, a control character or a `*`, or is a Bedrock ARN is left out, the same rule declared models meet; a lookalike of another id, such as one with a Cyrillic letter, is kept, since it is what a definition would have to write. A name longer than 100 characters or on more than one line is left out, and the entry kept. ### Declared models @@ -289,7 +289,7 @@ declared_models: ### Allowed models -`allowed_models` in the configuration file, or `ALLOWED_MODELS` as a JSON list, which wins over it, names the only model references a spec may give. It is applied in one place for both of its uses: `list_models` shows only what it allows, and a spec that names anything else fails as `model_not_allowed` before any provider is called, which the spec operations answer as `unavailable` of the kind `model_not_offered` with `because: "model_not_allowed"`; its detail names the model, never the allow list, and its plain words point to `list_models`. A provider that is not configured, while others are, is answered with the same kind and `because: "provider_not_configured"`; the plain words say which. +`allowed_models` in the configuration file, or `ALLOWED_MODELS` as a JSON list, which wins over it, names the only model references a definition may give. It is applied in one place for both of its uses: `list_models` shows only what it allows, and a definition that names anything else fails as `model_not_allowed` before any provider is called, which the definition operations answer as `unavailable` of the kind `model_not_offered` with `because: "model_not_allowed"`; its detail names the model, never the allow list, and its plain words point to `list_models`. A provider that is not configured, while others are, is answered with the same kind and `because: "provider_not_configured"`; the plain words say which. ```yaml allowed_models: @@ -298,11 +298,11 @@ allowed_models: - house/fast ``` -An entry is `provider/model`, or ends in a `*` that stands for any model id, as in an alias. It applies to the reference a spec names and to the reference that reference resolves to: an alias is allowed when its own name or its target is, and a target named directly only when it is allowed itself. A wildcard alias, or `/*`, is listed only when a wildcard allows every model it stands for. The reasoning function description names only the providers and aliases a spec may use under it. Without the setting every model is allowed. An empty list stops the start, and so does an entry that is not a reference, has an uppercase provider, a space or a control character, is a Bedrock ARN, is listed twice, or can match no configured provider and no alias, such as `There is no provider named mistral, nor an alias that mistral/large matches`. +An entry is `provider/model`, or ends in a `*` that stands for any model id, as in an alias. It applies to the reference a definition names and to the reference that reference resolves to: an alias is allowed when its own name or its target is, and a target named directly only when it is allowed itself. A wildcard alias, or `/*`, is listed only when a wildcard allows every model it stands for. The reasoning function description names only the providers and aliases a definition may use under it. Without the setting every model is allowed. An empty list stops the start, and so does an entry that is not a reference, has an uppercase provider, a space or a control character, is a Bedrock ARN, is listed twice, or can match no configured provider and no alias, such as `There is no provider named mistral, nor an alias that mistral/large matches`. ### How long a list is kept -Each list is read when `list_models` first needs it and kept in memory for five minutes, the interval the AI SDK's gateway provider keeps its own for. Calls that arrive while it is read wait for the same read. When a list cannot be read again, its last list is served and the answer is `partial`, and the provider is not asked again for a minute, so that callers cannot make the server press a provider that is failing or limiting its rate; the first call after that minute asks again. Lists are kept per provider, and a catalog belongs to one model access whose settings never change, so a list read with one credential is never served for another. Nothing is read when a spec runs, and the settings are read when the server starts, so a changed setting takes effect at the next start. +Each list is read when `list_models` first needs it and kept in memory for five minutes, the interval the AI SDK's gateway provider keeps its own for. Calls that arrive while it is read wait for the same read. When a list cannot be read again, its last list is served and the answer is `partial`, and the provider is not asked again for a minute, so that callers cannot make the server press a provider that is failing or limiting its rate; the first call after that minute asks again. Lists are kept per provider, and a catalog belongs to one model access whose settings never change, so a list read with one credential is never served for another. Nothing is read when a definition runs, and the settings are read when the server starts, so a changed setting takes effect at the next start. ### What is never shown @@ -310,7 +310,7 @@ The answer and its plain words carry no key, token, base URL, header, query para ## When a provider is not configured -Nothing fails at start. A spec that names the provider fails with `provider_not_configured`, whose `detail`, the text its caller sees, names the providers that are configured: +Nothing fails at start. A definition that names the provider fails with `provider_not_configured`, whose `detail`, the text its caller sees, names the providers that are configured: ```json { @@ -324,7 +324,7 @@ Nothing fails at start. A spec that names the provider fails with `provider_not_ A prefix nobody configures, such as `mistral`, says `There is no provider named mistral` and the same; with no provider configured, the detail says `No model provider is configured`. When any alias is set, the detail names the aliases after the providers, so the caller sees every reference that works: `openai is not configured. Configured providers: gateway. Aliases: anthropic/*`. `missing` stays on the failure for the code that handles it and never reaches the caller. -An agent learns this before it writes a spec: the reasoning function description, which every spec tool carries, names the providers this server calls models through and how a model is written with them (`This server calls models through gateway: write model as /, with a model id that provider serves, for example gateway/.`), the alias names when any is set, with the references a wildcard alias accepts, or that no provider is configured. It does not name the models of a provider; it says that `list_models` lists the models this server can call. `makeModelAccess` also returns a `status`: the configured prefixes and, for each unconfigured built-in provider, the names of the settings it lacks, marked `partial` when some of its settings are present. When it starts, the server logs one line naming the configured providers, with every provider in its annotations, or a warning when none is configured. It adds a warning of its own only for a provider that is partly configured, the case that is usually a mistake: +An agent learns this before it writes a definition: the reasoning function description, which every definition tool carries, names the providers this server calls models through and how a model is written with them (`This server calls models through gateway: write model as /, with a model id that provider serves, for example gateway/.`), the alias names when any is set, with the references a wildcard alias accepts, or that no provider is configured. It does not name the models of a provider; it says that `list_models` lists the models this server can call. `makeModelAccess` also returns a `status`: the configured prefixes and, for each unconfigured built-in provider, the names of the settings it lacks, marked `partial` when some of its settings are present. When it starts, the server logs one line naming the configured providers, with every provider in its annotations, or a warning when none is configured. It adds a warning of its own only for a provider that is partly configured, the case that is usually a mistake: ```json {"message":"Model providers configured: anthropic","level":"INFO","annotations":{"providers":[{"provider":"anthropic","configured":true},{"provider":"openai","configured":false,"missing":["OPENAI_API_KEY"]},…]}} @@ -337,8 +337,8 @@ Every failure has a `_tag`, a `detail` safe to show the caller, and the `provide | Failure | When | Also carries | What the caller can do | | ------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------- | -| `spec_invalid` | The model is not written `provider/model`; the request is invalid (no messages, `max_output_tokens` not an integer of 1 or more, `temperature` or `top_p` not finite, `seed` not an integer of 0 or more, `timeout_ms` not an integer of 1 or more); the output schema cannot be read; the provider answered HTTP 400, 404, 413, 422 or another 4xx not listed below; the SDK refused a setting, the prompt, a feature or the model | `status`, `issues` with JSON pointers into the request, and `provider_message`: the first line of the provider's own error message, at most 300 characters, only for a built-in provider at its default endpoint or a gateway with `expose_provider_messages`, and left out when it is empty, holds the raw response body, or is itself a JSON, HTML or XML document; see [Provider messages](#provider-messages). The `detail` names the provider, the HTTP status and what it means: the model was not found (404), the request was rejected as invalid (400 and 422), the request was too large (413), or the request was not accepted | Fix the spec | -| `model_not_allowed` | `allowed_models` is set and neither the model as the spec names it nor the model an alias sends it to is among them | | Name an offered model | +| `definition_invalid` | The model is not written `provider/model`; the request is invalid (no messages, `max_output_tokens` not an integer of 1 or more, `temperature` or `top_p` not finite, `seed` not an integer of 0 or more, `timeout_ms` not an integer of 1 or more); the output schema cannot be read; the provider answered HTTP 400, 404, 413, 422 or another 4xx not listed below; the SDK refused a setting, the prompt, a feature or the model | `status`, `issues` with JSON pointers into the request, and `provider_message`: the first line of the provider's own error message, at most 300 characters, only for a built-in provider at its default endpoint or a gateway with `expose_provider_messages`, and left out when it is empty, holds the raw response body, or is itself a JSON, HTML or XML document; see [Provider messages](#provider-messages). The `detail` names the provider, the HTTP status and what it means: the model was not found (404), the request was rejected as invalid (400 and 422), the request was too large (413), or the request was not accepted | Fix the definition | +| `model_not_allowed` | `allowed_models` is set and neither the model as the definition names it nor the model an alias sends it to is among them | | Name an offered model | | `provider_not_configured` | The prefix is unknown; the provider's settings are missing (including Azure without a key and without `@azure/identity`); the provider's TLS certificate is not trusted | `configured` prefixes, `missing` setting names | Fix the settings | | `credentials_rejected` | HTTP 401 or 403; no credential could be obtained from the AWS chain, Google application default credentials or Microsoft Entra ID | `status` (`null` when the credential source failed) | Fix the credentials or the role | | `rate_limited` | HTTP 429 | `retry_after_ms` from `retry-after-ms`, or `retry-after` in seconds or as a date; `null` without a hint | Retry after the delay | @@ -356,7 +356,7 @@ With `retries: 'adapter'`, the default, a call that fails with HTTP 408, 409, 42 ## What is recorded, and what is never logged -A result carries `text`; `json` when JSON was asked for; `finish_reason` (`stop`, `length`, `content_filter`, `tool_calls`, `error` or `other`) and the provider's `raw_finish_reason`; `usage` (input tokens, of which uncached, cache read and cache write; output tokens, of which text and reasoning; and the total, each `null` when the provider did not say); the `model` as requested, as resolved and as answered; the provider's `response_id` (Bedrock's request id); `warnings` from the provider, such as a setting a model ignores; and `duration_ms`. The execution that runs a spec records the result. +A result carries `text`; `json` when JSON was asked for; `finish_reason` (`stop`, `length`, `content_filter`, `tool_calls`, `error` or `other`) and the provider's `raw_finish_reason`; `usage` (input tokens, of which uncached, cache read and cache write; output tokens, of which text and reasoning; and the total, each `null` when the provider did not say); the `model` as requested, as resolved and as answered; the provider's `response_id` (Bedrock's request id); `warnings` from the provider, such as a setting a model ignores; and `duration_ms`. The run records the result. This package logs nothing itself. It never returns, and no failure or defect carries: @@ -372,7 +372,7 @@ A provider's error message is free text, and through a gateway, or through a bas For a gateway, and for a built-in provider at an overridden endpoint, the caller gets only what is structured: the provider prefix, the HTTP status, and what the status means. A gateway whose messages are safe to show opts in with `expose_provider_messages: true` in its entry. -The operator always gets the message: `makeModelAccess` takes an optional `reportProviderMessage`, which receives, for every call the provider answered with an error, the `provider`, the `model` as the spec names it (`null` for a list of models), the HTTP `status` (`null` when there was no response), the `message` (at most 2000 characters, never the request body) and the `execution_id` when the request carries one. The server logs it as a warning: +The operator always gets the message: `makeModelAccess` takes an optional `reportProviderMessage`, which receives, for every call the provider answered with an error, the `provider`, the `model` as the definition names it (`null` for a list of models), the HTTP `status` (`null` when there was no response), the `message` (at most 2000 characters, never the request body) and the `run_id` when the request carries one. The server logs it as a warning: ```json { @@ -382,7 +382,7 @@ The operator always gets the message: `makeModelAccess` takes an optional `repor "provider": "gateway", "model": "gateway/no-such-model-xyz", "status": 404, - "execution_id": "0199a3c4-7d2e-7c1a-9b3f-2f1e0d9c8b7a", + "run_id": "0199a3c4-7d2e-7c1a-9b3f-2f1e0d9c8b7a", "provider_message": "The model no-such-model-xyz does not exist on any upstream" } } @@ -390,11 +390,11 @@ The operator always gets the message: `makeModelAccess` takes an optional `repor In case a provider echoes them, what the caller and the operator get has every secret of the model settings (API keys, tokens, and the keys, headers and query parameters of gateways) replaced with `[redacted]`, and the instructions and each message of the prompt replaced with `[prompt]`, each when it is at least 8 characters long. A quote of only part of the prompt stays in the message. -Only `spec_invalid` can carry provider text. The other failures are built from the provider prefix, the HTTP status, a `retry-after` header and the answer's finish reason and usage. +Only `definition_invalid` can carry provider text. The other failures are built from the provider prefix, the HTTP status, a `retry-after` header and the answer's finish reason and usage. ### Operator hints -A failure that only the operator can fix tells the caller what happened and that the operator must act, and never names a setting. What to set goes to the operator: `makeModelAccess` takes an optional `reportOperatorHint`, which receives the `provider`, the `model` as the spec names it, the `hint` and the `execution_id`, for a provider certificate this server does not trust and for a setting the AI SDK found missing when it called the provider, in the SDK's words. The server logs it as a warning: +A failure that only the operator can fix tells the caller what happened and that the operator must act, and never names a setting. What to set goes to the operator: `makeModelAccess` takes an optional `reportOperatorHint`, which receives the `provider`, the `model` as the definition names it, the `hint` and the `run_id`, for a provider certificate this server does not trust and for a setting the AI SDK found missing when it called the provider, in the SDK's words. The server logs it as a warning: ```json { @@ -403,7 +403,7 @@ A failure that only the operator can fix tells the caller what happened and that "annotations": { "provider": "gateway", "model": "gateway/llama-3.3-70b", - "execution_id": "0199a3c4-7d2e-7c1a-9b3f-2f1e0d9c8b7a" + "run_id": "0199a3c4-7d2e-7c1a-9b3f-2f1e0d9c8b7a" } } ``` @@ -412,7 +412,7 @@ A failure that only the operator can fix tells the caller what happened and that An answer schema is a JSON Schema document, draft 2020-12, or draft-07 when `$schema` says so or the document uses `definitions` without `$defs`. `compileAnswerSchema(document)` checks it and gives an `AnswerSchema`, or issues with JSON pointers into the document. The answer is validated here, against the schema as written, whatever the provider enforced. The validator is Effect's JSON Schema importer: it compiles a schema into data, not code, and it rejects regular expressions. -Limits on schemas, which the spec author controls: +Limits on schemas, which the definition author controls: - at most 65,536 bytes as JSON, at most 64 nested levels of objects and lists, at most 1000 values in one `enum`; - no `pattern` or `patternProperties`, because a hostile regular expression can stall validation; @@ -421,7 +421,7 @@ Limits on schemas, which the spec author controls: Limits on answers: at most 128 nested levels. A schema, an input or an answer that does not match reports each distinct issue once and at most 20 of them, followed by one that says how many more there were, such as `130 more issues are not shown`. -`checkAnswerSchema(document)` is the check a spec author needs when a document is stored. It reports: +`checkAnswerSchema(document)` is the check a definition author needs when a document is stored. It reports: - `unsupported`: what makes the schema unusable here, as above; - `not_portable`: what some providers reject or do not enforce, with the prefixes concerned; diff --git a/docs/engineering/self-host/troubleshooting.md b/docs/engineering/self-host/troubleshooting.md index ea4961b97..745a605f1 100644 --- a/docs/engineering/self-host/troubleshooting.md +++ b/docs/engineering/self-host/troubleshooting.md @@ -10,7 +10,7 @@ Check which providers the startup log names. Confirm the provider prefix in the ## Workflows are missing or unavailable -Every server offers workflows and `send_execution_event`, with nothing to configure. A workflow that does not go on is usually waiting: for an event, a timer or a function it called. When the server cannot do the work of its runs, it logs a warning saying what failed, such as `A sweep of the runs failed; the next sweep tries again`; [Workflow operations](workflows.md) lists them. When several servers share a database, one of them runs the workflows and the others answer workflow operations `unavailable`, saying another server runs them; the log of each server says which it is. +Every server offers workflows and `send_run_event`, with nothing to configure. A workflow that does not go on is usually waiting: for an event, a timer or a function it called. When the server cannot do the work of its runs, it logs a warning saying what failed, such as `A sweep of the runs failed; the next sweep tries again`; [Workflow operations](workflows.md) lists them. When several servers share a database, one of them runs the workflows and the others answer workflow operations `unavailable`, saying another server runs them; the log of each server says which it is. ## The browser receives 403 @@ -22,4 +22,4 @@ The ledger lives on `/data`. Reuse the named persistent volume across container ## A run stays started -A waiting workflow can legitimately remain `started`. Its history, `get_execution_history`, shows the steps each input moved, and the last one shows what the run waits for. A run whose execution the ledger would not settle yet also leaves it `started` until a later attempt settles it; the server warns once when such a run backs off to an attempt a minute, and once when it is settled. [Workflow operations](workflows.md) explains how a run ends. +A waiting workflow can legitimately remain `started`. Its history, `get_run_history`, shows the steps each input moved, and the last one shows what the run waits for. A run the ledger would not settle yet also stays `started` until a later attempt settles it; the server warns once when such a run backs off to an attempt a minute, and once when it is settled. [Workflow operations](workflows.md) explains how a run ends. diff --git a/docs/engineering/self-host/workflows.md b/docs/engineering/self-host/workflows.md index 0dc1ec09c..f5d83ba5b 100644 --- a/docs/engineering/self-host/workflows.md +++ b/docs/engineering/self-host/workflows.md @@ -4,7 +4,7 @@ The server runs its workflows itself. The [workflow host](../../../packages/work ## Where a run is kept -A run's log is a stream of the ledger, `runs/` under its brain, which the history of the run and the events of the brain read. The host keeps seven tables of its own in the same database: the latest snapshot of each run, its timers, its calls and their answers, the dispatch watermark, the due times, the settlements and the claim on the workflows (`workflow_snapshot_chunks`, `workflow_timers`, `workflow_calls`, `workflow_runs`, `workflow_due`, `workflow_settlements` and `workflow_leases`). It creates them when it starts, in the SQLite file of `LEDGER_FILE` or in the PostgreSQL database of `DATABASE_URL`, so a backup of the ledger's database holds the workflows too. +A workflow run's log is a stream of the ledger, `run-logs/` under its brain, which the history of the run and the events of the brain read. The host keeps seven tables of its own in the same database: the latest snapshot of each run, its timers, its calls and their answers, the dispatch watermark, the due times, the settlements and the claim on the workflows (`workflow_snapshot_chunks`, `workflow_timers`, `workflow_calls`, `workflow_runs`, `workflow_due`, `workflow_settlements` and `workflow_leases`). It creates them when it starts, in the SQLite file of `LEDGER_FILE` or in the PostgreSQL database of `DATABASE_URL`, so a backup of the ledger's database holds the workflows too. Several servers may share a database, and one of them runs its workflows at a time. That server holds a claim on them, a row of `workflow_leases`, which it renews at every sweep and which lapses the longer of three sweeps and three seconds after it was last renewed, so a pause of that server shorter than that, as for garbage collection, does not hand its workflows to another. A server that finds the claim held by another stands by: it serves everything else, answers workflow operations `unavailable` with `The workflows of this database run in another server; ...`, and warns once at start-up, naming the holder. It tries the claim at every sweep, and takes the workflows over, with a warning, once the claim lapsed, as when the server that held it died, or was let go of, as when that server stopped. A server that cannot renew its claim for as long as a claim lasts stops running workflows and stands by, since another may then hold it. On PostgreSQL the claim is taken, renewed and judged by the database's own clock, so the servers' clocks need not agree; on SQLite one server holds the file, and its own clock serves. After a server that held the claim dies, another takes the workflows over within about three seconds and a sweep. @@ -12,24 +12,24 @@ Several servers may share a database, and one of them runs its workflows at a ti The server logs one line at start-up, `Workflows run in this server: a run lasts at most 30 days, at most 32 of their calls run at once, and the runs are swept every 1000 ms`, with the settings below. -When it stops, the server lets the starts and events it already took finish, and the work the host is deciding, so what it decided is appended and handed out, cuts off the calls in flight, lets go of its claim on the workflows, so a server standing by takes them over at its next sweep, and closes the database; it does not wait for a call to end. A call cut off stays recorded and is performed again when the server next starts, under the same execution id, so a nested execution that already has a final result is answered from the ledger. A call that waits for a run that finishes later, such as another workflow's, is not cut off: it stays waiting without holding one of the calls that run at once, and the ending of that run answers it whenever it is recorded, on whichever server. The first sweep after a start fires the timers that came due while the server was stopped, hands out what a run decided and did not hand out, and resumes the calls. +When it stops, the server lets the starts and events it already took finish, and the work the host is deciding, so what it decided is appended and handed out, cuts off the calls in flight, lets go of its claim on the workflows, so a server standing by takes them over at its next sweep, and closes the database; it does not wait for a call to end. A call cut off stays recorded and is performed again when the server next starts, under the same run id, so a nested run that already has a final result is answered from the ledger. A call that waits for a run that finishes later, such as another workflow's, is not cut off: it stays waiting without holding one of the calls that run at once, and the ending of that run answers it whenever it is recorded, on whichever server. The first sweep after a start fires the timers that came due while the server was stopped, hands out what a run decided and did not hand out, and resumes the calls. -The host sweeps every `ORCHESTRATION_SWEEP_INTERVAL`, one second unless set otherwise: it resumes the runs that have been due for more than a minute, up to 1,024 runs whose decisions were not all handed out, and the calls recorded as running that nothing runs. A timer fires when it is due, without waiting for a sweep. +The host sweeps every `WORKFLOW_SWEEP_INTERVAL`, one second unless set otherwise: it resumes the runs that have been due for more than a minute, up to 1,024 runs whose decisions were not all handed out, and the calls recorded as running that nothing runs. A timer fires when it is due, without waiting for a sweep. -| Variable | Default | Purpose | -| --------------------------------- | ------- | ------------------------------------------------------------------------------------ | -| `ORCHESTRATION_MAX_DURATION` | `P30D` | The most a run may last, an ISO 8601 duration from `PT2H` to `P365D` | -| `ORCHESTRATION_NESTED_EXECUTIONS` | `32` | How many calls of the server's runs run at once, from 1 to 1000; shared by every org | -| `ORCHESTRATION_MAX_OPEN_CALLS` | `1000` | How many calls may wait under one run at the top of a tree, from 1 to 9999 | -| `ORCHESTRATION_SWEEP_INTERVAL` | `PT1S` | How often the host sweeps the runs, an ISO 8601 duration from `PT0.01S` to `PT1M` | +| Variable | Default | Purpose | +| ------------------------- | ------- | --------------------------------------------------------------------------------- | +| `WORKFLOW_MAX_DURATION` | `P30D` | The most a run may last, an ISO 8601 duration from `PT2H` to `P365D` | +| `WORKFLOW_NESTED_RUNS` | `32` | How many calls the server runs at once, from 1 to 1000; shared by every org | +| `WORKFLOW_MAX_OPEN_CALLS` | `1000` | How many calls may wait under one run at the top of a tree, from 1 to 9999 | +| `WORKFLOW_SWEEP_INTERVAL` | `PT1S` | How often the host sweeps the runs, an ISO 8601 duration from `PT0.01S` to `PT1M` | A setting the server cannot read stops it at start-up, naming the setting and what it expects, never its value. ## What an operator must know - A run's log holds its document, its input, the outputs of the functions it calls, the events sent to it and the identity of the caller who started it, in the ledger's database like the brain's other records. Whoever can read the database can read them. -- A run acts for the caller who started it, with the permissions that caller had then, for as long as it runs, at most `ORCHESTRATION_MAX_DURATION`. Revoking the caller's key does not stop it. `cancel_execution` cancels a run that has not ended: the request is recorded on whichever server receives it, and the server that holds the claim on the workflows stops the run, which cancels the runs it waits for. Otherwise a run ends by itself, by a `timeout` its document sets, or when it has run `ORCHESTRATION_MAX_DURATION`, which settles its execution `rejected` as `cancelled` of the kind `overrun`. +- A run acts for the caller who started it, with the permissions that caller had then, for as long as it runs, at most `WORKFLOW_MAX_DURATION`. Revoking the caller's key does not stop it. `cancel_run` cancels a run that has not ended: the request is recorded on whichever server receives it, and the server that holds the claim on the workflows stops the run, which cancels the runs it waits for. Otherwise a run ends by itself, by a `timeout` its document sets, or when it has run `WORKFLOW_MAX_DURATION`, which ends it `rejected` as `cancelled` of the kind `overrun`. - `/health` answers whether the server is alive. The host's trouble is logged as warnings, each saying what failed and what happens next: `A sweep of the runs failed; the next sweep tries again`, `A timer of a run could not fire; it fires again at the next sweep`, `An answer of a call could not be recorded; it is written again until it is`, `A call could not record its answer`, `An answer of a call could not be given to its run` and `The claim of this server on the workflows of its database could not be renewed`, with the cause cut at 2,000 characters; on PostgreSQL, a lost connection is a warning too. -- There are no limits for one org and no fairness between orgs: every org's runs share the server's thread and its `ORCHESTRATION_NESTED_EXECUTIONS` calls at a time. -- A run whose settlement the ledger refuses, as while the ledger cannot be reached, is settled again at every sweep, and after 20 attempts once a minute until it is; the server warns once when it starts backing off and once when the execution is settled. An execution the ledger does not have, or one already settled otherwise, is logged as an error with its org, brain, execution id and reason. +- There are no limits for one org and no fairness between orgs: every org's runs share the server's thread and its `WORKFLOW_NESTED_RUNS` calls at a time. +- A run whose settlement the ledger refuses, as while the ledger cannot be reached, is settled again at every sweep, and after 20 attempts once a minute until it is; the server warns once when it starts backing off and once when the run is settled. A run the ledger does not have, or one already settled otherwise, is logged as an error with its org, brain, run id and reason. - The engine keeps at most 1,024 loaded runs and 64 MiB of the data they hold, and a run holds at most 4 MiB. Measured once with the arm64 image and no memory limit, the server took about 180 MiB idle. The [engine's README](../../../packages/workflow-engine/README.md#limits) lists the limits of a run, and the [host's README](../../../packages/workflow-host/README.md#measurements) how late timers fire and how fast runs go on each store. diff --git a/docs/get-started/local.md b/docs/get-started/local.md index ba64a80ba..9a9737ef4 100644 --- a/docs/get-started/local.md +++ b/docs/get-started/local.md @@ -132,7 +132,7 @@ After you approve the definition, ask it to run the saved function on: Promote our reporting tool to finance teams with a USD 5,000 budget. ``` -Ask for the result and the recorded run, including its execution id. The review should identify the missing measurable goal. A concrete model or configured alias is required; a wildcard such as `provider/*` is not a model to run. +Ask for the result and the recorded run, including its run id. The review should identify the missing measurable goal. A concrete model or configured alias is required; a wildcard such as `provider/*` is not a model to run. A client that shows MCP prompts also offers the brain's own recipes: `first-brain` makes a first brain this way, and `remember`, `give-tools` and `schedule` make a brain remember what its functions answered, give a function tools and run a workflow on a schedule. The agent reads the same recipes, and the format of each kind of definition, with `get_guide`. diff --git a/docs/integrations/apollo.md b/docs/integrations/apollo.md index 10e425186..ea03b5e18 100644 --- a/docs/integrations/apollo.md +++ b/docs/integrations/apollo.md @@ -38,7 +38,7 @@ The function receives the supplied values. It does not automatically inherit the ## 5. Verify the recorded run -Have the agent read the resulting run from Auto and show its execution id, definition version, recorded prompt and output. Confirm that the prompt contains the evidence you approved and that the result addresses the intended review. The current run API returns the rendered prompt, subject to record-size limits, rather than the original input object. +Have the agent read the resulting run from Auto and show its run id, definition version, recorded prompt and output. Confirm that the prompt contains the evidence you approved and that the result addresses the intended review. The current run API returns the rendered prompt, subject to record-size limits, rather than the original input object. If a required input is missing, compare the function's input contract with the mapping. If the graph operation is unavailable, check the selected Apollo server and the connection's permissions before changing the function. diff --git a/docs/reference/computation-format.md b/docs/reference/computation-format.md index 40595d75a..f8957cab2 100644 --- a/docs/reference/computation-format.md +++ b/docs/reference/computation-format.md @@ -2,7 +2,7 @@ # Computation function format -The API stores a computation function as a `computation` spec. Its source document declares the input and output contracts and holds a program, written in jq, that computes the output from the input. A run applies the program to its input and answers with exactly one output, the same output for the same input every time. Use one for the arithmetic and data shaping a language model should not do: totals, paces, projections and transformations of rows of figures. +The API stores a computation function as a definition of the type `computation`. Its source document declares the input and output contracts and holds a program, written in jq, that computes the output from the input. A run applies the program to its input and answers with exactly one output, the same output for the same input every time. Use one for the arithmetic and data shaping a language model should not do: totals, paces, projections and transformations of rows of figures. Numbers are double-precision floating point. Integers are exact up to 2^53, there is no decimal type and nothing rounds to decimal places, so compute money in whole minor units, such as cents, as the example below does. See [Numbers](#numbers). @@ -41,7 +41,7 @@ output: | { campaigns: ., total_spend_cents: (map(.spend_cents) | add) } ``` -For this document, `create_spec` takes `primitive: "computation"`, a function `name` such as `campaign-pace`, and the document as `source`. `execute_spec` takes the same primitive and name, with `rows` and `period` in the `input` object. Both operations also require the brain id unless the MCP connection is scoped to that brain. +For this document, `create_definition` takes `type: "computation"`, a function `name` such as `campaign-pace`, and the document as `source`. `run_definition` takes the same type and name, with `rows` and `period` in the `input` object. Both operations also require the brain id unless the MCP connection is scoped to that brain. Given this input: @@ -147,9 +147,9 @@ A run first checks the input against `input.schema`, then applies the program, r | `unavailable` | The run took longer or used more memory than a run may, or found no turn to run within its time | | `failed` | The runtime itself broke down | -A `conflict` of the kind `unworkable` names the program's own error and the line of the document it came from, such as `The program raised an error on line 4: no rows`. The text of the program's error is cut at 1,024 bytes of UTF-8 and marked with `…`, and so are the pointer and the detail of each issue of an output the schema refuses, each on its own, so an issue at a very long key still says what is wrong. A long error is answered, recorded and passed to a workflow at that size. The same input gives the same result every time, so running it again does not help: update the definition, or change the input. `get_execution` shows the kind on the run's rejection, and `list_executions` shows it in the listing. +A `conflict` of the kind `unworkable` names the program's own error and the line of the document it came from, such as `The program raised an error on line 4: no rows`. The text of the program's error is cut at 1,024 bytes of UTF-8 and marked with `…`, and so are the pointer and the detail of each issue of an output the schema refuses, each on its own, so an issue at a very long key still says what is wrong. A long error is answered, recorded and passed to a workflow at that size. The same input gives the same result every time, so running it again does not help: update the definition, or change the input. `get_run` shows the kind on the run's rejection, and `list_runs` shows it in the listing. -A run that succeeds records `language`, `work`, the units of work it spent, `duration_ms`, and `input_bytes` and `output_bytes`, the sizes of its input and output as JSON. `get_execution` returns that record with the output. +A run that succeeds records `language`, `work`, the units of work it spent, `duration_ms`, and `input_bytes` and `output_bytes`, the sizes of its input and output as JSON. `get_run` returns that record with the output. ## Bounds @@ -174,7 +174,7 @@ The duration is a safeguard for what work does not stop. Measured on Node 26.10. ## In a workflow -A workflow calls a computation function as it calls a reasoning function, with `call: execute_spec` and `primitive: computation`, and the task's output is the function's output; see [Calling a function](workflow-format.md#calling-a-function). The function reaches nothing outside the brain, so a workflow may run it again freely: its result does not depend on how often it ran. +A workflow calls a computation function as it calls a reasoning function, with `call: run_definition` and `type: computation`, and the task's output is the function's output; see [Calling a function](workflow-format.md#calling-a-function). The function reaches nothing outside the brain, so a workflow may run it again freely: its result does not depend on how often it ran. A run rejected with `conflict` raises a `runtime` error with status 409, and the error's `kind` is `unworkable`. A retry gives the same result for the same input, so a retry policy should not match it: retry on status 503, which a run that was `unavailable` raises, as the example below does. @@ -252,9 +252,9 @@ input: required: [month, period] do: - read: - call: execute_spec + call: run_definition with: - primitive: inference + type: reasoning name: read-campaign-costs input: { month: '${ .month }' } output: @@ -262,9 +262,9 @@ do: - compute: try: - pace: - call: execute_spec + call: run_definition with: - primitive: computation + type: computation name: campaign-pace input: '${ . }' catch: @@ -275,16 +275,16 @@ do: limit: attempt: { count: 2 } - write: - call: execute_spec + call: run_definition with: - primitive: inference + type: reasoning name: write-pace-summary input: campaigns: '${ .campaigns }' total_spend_cents: '${ .total_spend_cents }' ``` -`execute_spec` of `campaign-pace-report` takes an input such as `{"month": "2026-09", "period": {"days_elapsed": 12, "days_total": 30}}`. The run reads the rows, computes them, and ends with the summary as its output. When the computation function's run is `unavailable`, the `compute` task tries it up to twice more; when the program raises an error, the run ends `rejected` at once, and the history shows the computation function's run with its line and error. +`run_definition` of `campaign-pace-report` takes an input such as `{"month": "2026-09", "period": {"days_elapsed": 12, "days_total": 30}}`. The run reads the rows, computes them, and ends with the summary as its output. When the computation function's run is `unavailable`, the `compute` task tries it up to twice more; when the program raises an error, the run ends `rejected` at once, and the history shows the computation function's run with its line and error. ## Availability diff --git a/docs/reference/http.md b/docs/reference/http.md index 150bac2db..05c91888f 100644 --- a/docs/reference/http.md +++ b/docs/reference/http.md @@ -2,7 +2,7 @@ The HTTP API provides brain management, reasoning-function, interaction-function, computation-function, recall-function and workflow definitions, the requests of interaction functions and their answers, recorded runs, events for waiting workflows, events published to a brain, the history of a run and of a brain, the brain's analytics, the tool servers its functions may use, and tests of their tools. Requests use the API base URL and credentials supplied for the workspace. -The runtime exposes the same operations through HTTP and [MCP](mcp.md). The API calls definitions `specs` and runs `executions`. The `primitive` field names the type of a definition: `inference` for a reasoning function, `interaction` for an interaction function, `computation` for a computation function, `recollection` for a recall function and `orchestration` for a workflow. +The runtime exposes the same operations through HTTP and [MCP](mcp.md). The `type` field names the type of a definition: `reasoning`, `interaction`, `computation`, `recall` or `workflow`. ## Requests and access @@ -40,93 +40,93 @@ The JSON result contains `object: "list"`, `data`, `catalog_status` and `listed_ These routes are relative to `/v1/orgs/{org}/brains/{brain}`: -| Operation | Method and route | Input | -| --------------- | -------------------------------------- | ---------------------------------------------- | -| `create_spec` | `POST /specs/inference` | `name`, `source` | -| `list_specs` | `GET /specs/inference` | Optional `include_retired` | -| `get_spec` | `GET /specs/inference/{name}` | Name in path | -| `update_spec` | `PUT /specs/inference/{name}` | `source` | -| `retire_spec` | `POST /specs/inference/{name}/retire` | Name in path | -| `execute_spec` | `POST /specs/inference/{name}/execute` | Optional `input`, optional UUID `execution_id` | -| `get_execution` | `GET /executions/{execution_id}` | Execution id in path | +| Operation | Method and route | Input | +| ------------------- | ------------------------------------------- | ---------------------------------------- | +| `create_definition` | `POST /definitions/reasoning` | `name`, `source` | +| `list_definitions` | `GET /definitions/reasoning` | Optional `include_retired` | +| `get_definition` | `GET /definitions/reasoning/{name}` | Name in path | +| `update_definition` | `PUT /definitions/reasoning/{name}` | `source` | +| `retire_definition` | `POST /definitions/reasoning/{name}/retire` | Name in path | +| `run_definition` | `POST /definitions/reasoning/{name}/run` | Optional `input`, optional UUID `run_id` | +| `get_run` | `GET /runs/{run_id}` | Run id in path | -The `source` is a [reasoning function document](reasoning-format.md). Names follow the same 3 to 48 character rule as brain ids. Source documents may be at most 65,536 UTF-8 bytes. A name is unique within its primitive and brain and cannot be reused after retirement. +The `source` is a [reasoning function document](reasoning-format.md). Names follow the same 3 to 48 character rule as brain ids. Source documents may be at most 65,536 UTF-8 bytes. A name is unique within its type and brain and cannot be reused after retirement. Changing a document creates a version. Updating it with identical source records no change. A run uses the active latest version. Retired definitions can be read but cannot be edited or run. ## Interaction functions -A self-hosted runtime offers interaction functions under the same operations, with `interaction` in place of `inference` in each route, such as `POST /specs/interaction` and `POST /specs/interaction/{name}/execute`. The `source` is an [interaction function document](interaction-format.md), and names, document size, versions and retirement follow the rules for reasoning functions above. A run answers 200 with `status: started` and its `execution_id`, and waits until its request is answered, expires or is cancelled; a notification without `deliver` succeeds within the request. A `to` or an argument the function cannot render answers `conflict` with the kind `unworkable`, a server or a tool the brain may not use `unavailable` with the kind `tool_not_offered`, and a brain with as many open requests as the runtime allows `unavailable` with the kind `requests_full`. +A self-hosted runtime offers interaction functions under the same operations, with `interaction` in place of `reasoning` in each route, such as `POST /definitions/interaction` and `POST /definitions/interaction/{name}/run`. The `source` is an [interaction function document](interaction-format.md), and names, document size, versions and retirement follow the rules for reasoning functions above. A run answers 200 with `status: started` and its `run_id`, and waits until its request is answered, expires or is cancelled; a notification without `deliver` succeeds within the request. A `to` or an argument the function cannot render answers `conflict` with the kind `unworkable`, a server or a tool the brain may not use `unavailable` with the kind `tool_not_offered`, and a brain with as many open requests as the runtime allows `unavailable` with the kind `requests_full`. These routes are relative to `/v1/orgs/{org}/brains/{brain}`: -| Operation | Method and route | Needs | Input | -| -------------------- | ---------------------------------------- | ------------- | ------------------------------------------------------------ | -| `list_interactions` | `GET /interactions` | `brain:read` | Optional `to`, `function`, `limit` and `cursor` | -| `answer_interaction` | `POST /executions/{execution_id}/answer` | `brain:write` | Execution id in path; `answer` and an optional `claimed_for` | +| Operation | Method and route | Needs | Input | +| -------------------- | ---------------------------- | ------------- | ------------------------------------------------------ | +| `list_interactions` | `GET /interactions` | `brain:read` | Optional `to`, `function`, `limit` and `cursor` | +| `answer_interaction` | `POST /runs/{run_id}/answer` | `brain:write` | Run id in path; `answer` and an optional `claimed_for` | -`list_interactions` returns `interactions`, the brain's open requests newest first, with `has_more` and `next_cursor`. Each has the `execution_id` of the interaction function's run, `function` and `version`, `to`, `delivery`, the `server` and `tool` the request is sent through or `null` for the inbox, `message`, `takes_answer`, `requested_at`, `expires_at`, `attempts`, `conversation`, the conversation the brain reads replies in or `null`, `answerer`, the party whose reply counts or `null`, `reply_refusals`, and `standing`, which is `in_inbox`, `to_deliver`, `delivering`, `delivered`, `retrying`, `undelivered`, `answered`, while a reply that answered it settles its run, or `cancelling`, once a cancel of its run was asked. +`list_interactions` returns `interactions`, the brain's open requests newest first, with `has_more` and `next_cursor`. Each has the `run_id` of the interaction function's run, `function` and `version`, `to`, `delivery`, the `server` and `tool` the request is sent through or `null` for the inbox, `message`, `takes_answer`, `requested_at`, `expires_at`, `attempts`, `conversation`, the conversation the brain reads replies in or `null`, `answerer`, the party whose reply counts or `null`, `reply_refusals`, and `standing`, which is `in_inbox`, `to_deliver`, `delivering`, `delivered`, `retrying`, `undelivered`, `answered`, while a reply that answered it settles its run, or `cancelling`, once a cancel of its run was asked. -Each request also has `answer_schema`, the JSON Schema the request recorded when it was asked, which an answer must match: `answer_interaction` checks this one, even once the function has a newer version whose `output_schema` `get_spec` shows. It is `null` for a notification. A schema takes at most 64 KiB as JSON, so a page of 100 requests can hold up to 6.25 MiB of schemas, and a smaller `limit` keeps a page smaller. +Each request also has `answer_schema`, the JSON Schema the request recorded when it was asked, which an answer must match: `answer_interaction` checks this one, even once the function has a newer version whose `output_schema` `get_definition` shows. It is `null` for a notification. A schema takes at most 64 KiB as JSON, so a page of 100 requests can hold up to 6.25 MiB of schemas, and a smaller `limit` keeps a page smaller. -`answer_interaction` takes the run's `execution_id` and an `answer` that matches the answer schema of the request, at most 64 KiB as JSON, and returns the run, `succeeded` with the answer as its `output`. Its `record` shows `answered_by`, `answered_at` and the optional `claimed_for`, whom the caller says it answers for, at most 256 bytes and never checked; a run a reply answered shows `answered_by` the brain and `reply`, the identity of that reply. An answer that does not match returns `invalid_input` with pointers under `/answer` and leaves the request open. The same answer again returns the run as it stands; a different answer, an answer to a request that has ended, and an answer to a notification return `conflict`; a run the brain does not have returns `not_found`. +`answer_interaction` takes the run's `run_id` and an `answer` that matches the answer schema of the request, at most 64 KiB as JSON, and returns the run, `succeeded` with the answer as its `output`. Its `record` shows `answered_by`, `answered_at` and the optional `claimed_for`, whom the caller says it answers for, at most 256 bytes and never checked; a run a reply answered shows `answered_by` the brain and `reply`, the identity of that reply. An answer that does not match returns `invalid_input` with pointers under `/answer` and leaves the request open. The same answer again returns the run as it stands; a different answer, an answer to a request that has ended, and an answer to a notification return `conflict`; a run the brain does not have returns `not_found`. -A request nobody answered before it expired ends its run `rejected` with the reason `unanswered` and the kind `expired`, and a notification that could not be delivered with the kind `undelivered`. A request with the same execution id and input then returns that ending as a `410` problem of the type `https://on.auto/problems/unanswered`. [How a run ends](interaction-format.md#how-a-run-ends) lists every ending. +A request nobody answered before it expired ends its run `rejected` with the reason `unanswered` and the kind `expired`, and a notification that could not be delivered with the kind `undelivered`. A request with the same run id and input then returns that ending as a `410` problem of the type `https://on.auto/problems/unanswered`. [How a run ends](interaction-format.md#how-a-run-ends) lists every ending. ## Computation functions -A self-hosted runtime offers computation functions under the same operations, with `computation` in place of `inference` in each route, such as `POST /specs/computation` and `POST /specs/computation/{name}/execute`. The `source` is a [computation function document](computation-format.md), and names, document size, versions and retirement follow the rules for reasoning functions above. A run completes within the execute request. A program that cannot give its output for the input, because it raised an error, gave no output or more than one, or did more work or nested deeper than a run may, answers `conflict` with the kind `unworkable`; the same input gives the same answer again, so change the definition or the input rather than retrying. +A self-hosted runtime offers computation functions under the same operations, with `computation` in place of `reasoning` in each route, such as `POST /definitions/computation` and `POST /definitions/computation/{name}/run`. The `source` is a [computation function document](computation-format.md), and names, document size, versions and retirement follow the rules for reasoning functions above. A run completes within the request that runs it. A program that cannot give its output for the input, because it raised an error, gave no output or more than one, or did more work or nested deeper than a run may, answers `conflict` with the kind `unworkable`; the same input gives the same answer again, so change the definition or the input rather than retrying. ## Recall functions -A self-hosted runtime offers recall functions under the same operations, with `recollection` in place of `inference` in each route, such as `POST /specs/recollection` and `POST /specs/recollection/{name}/execute`. The `source` is a [recall function document](recall-format.md), and names, document size, versions and retirement follow the rules for reasoning functions above; a brain keeps at most 32 active recall functions, and a save past that answers `conflict`. A run completes within the execute request, answering from the function's view as it stands. While the view of the latest version is still being built, a run answers `unavailable` with the kind `rebuilding` and a `Retry-After`; while the view has stalled, `conflict` with the kind `stalled`; and an answer that cannot give its output, `conflict` with the kind `unworkable`. `get_spec` adds the view's `standing`, and `get_execution` the checkpoint the run answered at in its `record`; see [How the view is kept](recall-format.md#how-the-view-is-kept). +A self-hosted runtime offers recall functions under the same operations, with `recall` in place of `reasoning` in each route, such as `POST /definitions/recall` and `POST /definitions/recall/{name}/run`. The `source` is a [recall function document](recall-format.md), and names, document size, versions and retirement follow the rules for reasoning functions above; a brain keeps at most 32 active recall functions, and a save past that answers `conflict`. A run completes within the request that runs it, answering from the function's view as it stands. While the view of the latest version is still being built, a run answers `unavailable` with the kind `rebuilding` and a `Retry-After`; while the view has stalled, `conflict` with the kind `stalled`; and an answer that cannot give its output, `conflict` with the kind `unworkable`. `get_definition` adds the view's `standing`, and `get_run` the checkpoint the run answered at in its `record`; see [How the view is kept](recall-format.md#how-the-view-is-kept). ## Workflows These routes are relative to `/v1/orgs/{org}/brains/{brain}`: -| Operation | Method and route | Input | -| ---------------------- | ------------------------------------------ | ---------------------------------------------------------------------- | -| `create_spec` | `POST /specs/orchestration` | `name`, `source` | -| `list_specs` | `GET /specs/orchestration` | Optional `include_retired` | -| `get_spec` | `GET /specs/orchestration/{name}` | Name in path | -| `update_spec` | `PUT /specs/orchestration/{name}` | `source` | -| `retire_spec` | `POST /specs/orchestration/{name}/retire` | Name in path | -| `execute_spec` | `POST /specs/orchestration/{name}/execute` | Optional `input`, optional UUID `execution_id` | -| `get_execution` | `GET /executions/{execution_id}` | Execution id in path | -| `cancel_execution` | `POST /executions/{execution_id}/cancel` | Optional `reason` | -| `send_execution_event` | `POST /executions/{execution_id}/events` | `event` with `type`, and optional `id`, `source`, `subject` and `data` | +| Operation | Method and route | Input | +| ------------------- | ------------------------------------------ | ---------------------------------------------------------------------- | +| `create_definition` | `POST /definitions/workflow` | `name`, `source` | +| `list_definitions` | `GET /definitions/workflow` | Optional `include_retired` | +| `get_definition` | `GET /definitions/workflow/{name}` | Name in path | +| `update_definition` | `PUT /definitions/workflow/{name}` | `source` | +| `retire_definition` | `POST /definitions/workflow/{name}/retire` | Name in path | +| `run_definition` | `POST /definitions/workflow/{name}/run` | Optional `input`, optional UUID `run_id` | +| `get_run` | `GET /runs/{run_id}` | Run id in path | +| `cancel_run` | `POST /runs/{run_id}/cancel` | Optional `reason` | +| `send_run_event` | `POST /runs/{run_id}/events` | `event` with `type`, and optional `id`, `source`, `subject` and `data` | -The `source` is a [workflow document](workflow-format.md). Names, document size, versions and retirement follow the rules for reasoning functions above. A saved workflow has the `media_type` `application/yaml`, and its `description`, `input_schema` and `output_schema` come from the document. A workflow whose document has a `schedule` also has `triggers`, in `get_spec` and `list_specs`: the [triggers](workflow-format.md#triggers) its schedule names, in the order it names them, each with its `kind`, `event`, `cron` or `every`, its `reference`, `/schedule/on`, `/schedule/cron` or `/schedule/every`, and its rule: an event trigger's `filters`, each with its `reference`, `type` and `attributes`, a cron's `expression` or an every's period in `milliseconds`. `get_spec` of an active workflow with triggers adds `triggers_since`, the id of the record that saved its current version: from that record on, what its triggers start is a run of this version. It is one id for the workflow; a trigger that a later version left unchanged goes on from the record that saved it as it is, which `triggers_since` does not show. +The `source` is a [workflow document](workflow-format.md). Names, document size, versions and retirement follow the rules for reasoning functions above. A saved workflow has the `media_type` `application/yaml`, and its `description`, `input_schema` and `output_schema` come from the document. A workflow whose document has a `schedule` also has `triggers`, in `get_definition` and `list_definitions`: the [triggers](workflow-format.md#triggers) its schedule names, in the order it names them, each with its `kind`, `event`, `cron` or `every`, its `reference`, `/schedule/on`, `/schedule/cron` or `/schedule/every`, and its rule: an event trigger's `filters`, each with its `reference`, `type` and `attributes`, a cron's `expression` or an every's period in `milliseconds`. `get_definition` of an active workflow with triggers adds `triggers_since`, the id of the record that saved its current version: from that record on, what its triggers start is a run of this version. It is one id for the workflow; a trigger that a later version left unchanged goes on from the record that saved it as it is, which `triggers_since` does not show. -`execute_spec` returns 200 as soon as the run begins, with its `execution_id` and `status: started`, or, for a run that ended before its first wait, how it ended. Read the run with `get_execution` until its status is `succeeded`, `rejected` or `failed`, and its steps with `get_execution_history`, which holds one `workflow_input_applied` event for each input the run took (see [Run history and brain events](#run-history-and-brain-events)). While the runtime is stopping, `execute_spec` returns `unavailable`; try again shortly. +`run_definition` returns 200 as soon as the run begins, with its `run_id` and `status: started`, or, for a run that ended before its first wait, how it ended. Read the run with `get_run` until its status is `succeeded`, `rejected` or `failed`, and its steps with `get_run_history`, which holds one `workflow_input_applied` event for each input the run took (see [Run history and brain events](#run-history-and-brain-events)). While the runtime is stopping, `run_definition` returns `unavailable`; try again shortly. -A workflow runs once for each `execution_id`. Executing it again with the `execution_id` of a run that is going returns the run as it stands; with the `execution_id` of a run that ended without a final result, it returns `conflict`, so run the workflow again under a new `execution_id`. +A workflow runs once for each `run_id`. Executing it again with the `run_id` of a run that is going returns the run as it stands; with the `run_id` of a run that ended without a final result, it returns `conflict`, so run the workflow again under a new `run_id`. -`send_execution_event` delivers an event to a run that is still `started`. It returns the `execution_id` and the delivered `event`, with an `id`, made by the runtime when you leave it out, a `source`, `/callers/` and your caller id when you leave it out, and the `time` it was sent. An event may take at most 256 KiB as JSON; its `type` and `id` at most 256 characters, its `source`, a URI reference such as `/ledger/eu` as for a published event, and its `subject` at most 1,024, and its `data` may nest at most 510 levels deep, so that the run can hold the whole event in a list. A run takes an event with a given `id` once, so a request can be retried with the same id. The types and sources the runtime keeps for its own facts, listed under [Publishing events](#publishing-events), return `invalid_input` here too, and so does text that a published event may not hold: a control character, a lone surrogate or a noncharacter, or a `type`, `id` or `subject` without a character that is not a space. The operation returns `not_found` when the brain has no running workflow with that execution id, including one that has ended, and `unavailable` when the run cannot take the event at that moment, as while the runtime is stopping; try again shortly. +`send_run_event` delivers an event to a run that is still `started`. It returns the `run_id` and the delivered `event`, with an `id`, made by the runtime when you leave it out, a `source`, `/callers/` and your caller id when you leave it out, and the `time` it was sent. An event may take at most 256 KiB as JSON; its `type` and `id` at most 256 characters, its `source`, a URI reference such as `/ledger/eu` as for a published event, and its `subject` at most 1,024, and its `data` may nest at most 510 levels deep, so that the run can hold the whole event in a list. A run takes an event with a given `id` once, so a request can be retried with the same id. The types and sources the runtime keeps for its own facts, listed under [Publishing events](#publishing-events), return `invalid_input` here too, and so does text that a published event may not hold: a control character, a lone surrogate or a noncharacter, or a `type`, `id` or `subject` without a character that is not a space. The operation returns `not_found` when the brain has no running workflow with that run id, including one that has ended, and `unavailable` when the run cannot take the event at that moment, as while the runtime is stopping; try again shortly. ### Cancelling a run -`cancel_execution` cancels a workflow run that is still `started`, or the run of an interaction function whose request waits, and needs `brain:write`. It takes an optional `reason`, 1 to 1,024 characters with no control character, which the run keeps as the detail of its ending; without one, the detail says who asked. The request is recorded on the run at once, on any server, and it returns 200 with the run as it stands, still `started`. Within a moment the run stops its tasks, cancels each run it waits for, and ends `rejected` with the reason `cancelled` and the kind `requested`; read it with `get_execution`. Asking again before it ended records nothing more. It returns `not_found` when the brain has no run with that id, and `conflict` when the run has ended, or when it is a run of a reasoning, computation or recall function, which ends within the request that started it and cannot be interrupted from outside. +`cancel_run` cancels a workflow run that is still `started`, or the run of an interaction function whose request waits, and needs `brain:write`. It takes an optional `reason`, 1 to 1,024 characters with no control character, which the run keeps as the detail of its ending; without one, the detail says who asked. The request is recorded on the run at once, on any server, and it returns 200 with the run as it stands, still `started`. Within a moment the run stops its tasks, cancels each run it waits for, and ends `rejected` with the reason `cancelled` and the kind `requested`; read it with `get_run`. Asking again before it ended records nothing more. It returns `not_found` when the brain has no run with that id, and `conflict` when the run has ended, or when it is a run of a reasoning, computation or recall function, which ends within the request that started it and cannot be interrupted from outside. A cancelled run's rejection names the kind of its cancellation: | Kind | The run was cancelled because | | -------------- | ----------------------------------------------------------------------------------------- | -| `requested` | someone allowed to change the brain asked, with `cancel_execution` | +| `requested` | someone allowed to change the brain asked, with `cancel_run` | | `deadline` | the workflow that waited for it ran out of time | | `overrun` | it ran as long as a workflow may run | | `parent_ended` | the workflow that waited for it ended first, or the branch that waited for it lost a race | ## Runs and results -A run records its `execution_id`, `primitive`, `name`, `spec_version`, `status`, timestamps and caller identity. Successful runs include `output`; rejected runs include a rejection. `get_execution` also returns the detailed `record`. +A run records its `run_id`, `type`, `name`, `definition_version`, `status`, timestamps and caller identity. Successful runs include `output`; rejected runs include a rejection. `get_run` also returns the detailed `record`. -Reasoning, computation and recall functions normally complete within the execute request. A workflow run answers `started` and continues after the request; while it is in progress, its `record` is empty. [Workflows and runs](../concepts/workflows.md) explains how a run waits and ends. Inputs may be at most 256 KiB as encoded JSON and nest at most 512 levels deep, as deep as a workflow holds a value; a deeper one returns `invalid_input` at `/input`. Output and record together may be at most 1 MiB. These limits apply independently of the request-body limit. +Reasoning, computation and recall functions normally complete within the request that runs them. A workflow run answers `started` and continues after the request; while it is in progress, its `record` is empty. [Workflows and runs](../concepts/workflows.md) explains how a run waits and ends. Inputs may be at most 256 KiB as encoded JSON and nest at most 512 levels deep, as deep as a workflow holds a value; a deeper one returns `invalid_input` at `/input`. Output and record together may be at most 1 MiB. These limits apply independently of the request-body limit. -Supply `execution_id` when you need to inspect failures or retry a request. Reusing an id with a different function or input returns `conflict`. Once a run succeeds, rejects invalid input or is cancelled, another request with the same id and input returns the recorded final result. A request with the id of a workflow run still in progress returns that run as it stands, without starting another. +Supply `run_id` when you need to inspect failures or retry a request. Reusing an id with a different function or input returns `conflict`. Once a run succeeds, rejects invalid input or is cancelled, another request with the same id and input returns the recorded final result. A request with the id of a workflow run still in progress returns that run as it stands, without starting another. -A run without a final result may be attempted again after an interruption or recoverable failure, with two exceptions. A workflow run runs once for its execution id. A reasoning function that calls tools is never run again under its id once one of its tools may have been called: when an earlier attempt called a tool and did not succeed, or when the function names tools and an earlier attempt has started and not ended, since it may still be running. The answer is `conflict` with the kind `tools_called`; check what the run's history shows it called, then start a new run under a new id. A retry can use the latest definition version, which the new attempt records. Do not assume that an external effect happened only once because the runtime records one final result. +A run without a final result may be attempted again after an interruption or recoverable failure, with two exceptions. A workflow run runs once for its run id. A reasoning function that calls tools is never run again under its id once one of its tools may have been called: when an earlier attempt called a tool and did not succeed, or when the function names tools and an earlier attempt has started and not ended, since it may still be running. The answer is `conflict` with the kind `tools_called`; check what the run's history shows it called, then start a new run under a new id. A retry can use the latest definition version, which the new attempt records. Do not assume that an external effect happened only once because the runtime records one final result. ## Publishing events @@ -168,41 +168,41 @@ It returns 200 with the event's `id` and `time`, and `recorded_at`, when the bra A brain holds one event for each `source` and `id`. Publishing the same event again records nothing and returns the first `id`, `time` and `recorded_at`, so a request can be retried with the same id; a retry that leaves out `time` is the same event. Times are compared as instants, so `2026-10-01T10:59:00+02:00` is the same time as `2026-10-01T08:59:00Z`. A leap second counts as the first second of the next day, so `2016-12-31T23:59:60Z` is the same time as `2017-01-01T00:00:00Z`. A retry that gives a `time` to an event first published without one is the same event too, and returns the time the runtime filled in the first time, not the one the retry gave. A different event with the same `source` and `id` returns `conflict`. Without an `id`, every request records a new event. -The event, with its id and time filled in, may take at most 240 KiB as JSON. The types `execution_started`, `execution_deferred`, `execution_succeeded`, `execution_rejected`, `execution_failed`, `tool_call_started`, `tool_call_answered`, `delivery_started`, `delivery_ended`, `tool_test_started`, `tool_test_answered`, `interaction_requested`, `spec_created`, `spec_updated`, `spec_retired`, `event_published`, `workflow_input_applied`, `step_started`, `step_waiting`, `step_finished`, `step_failed`, `step_skipped` and `reaction_refused`, which include every type the brain's events show, and sources beginning `/executions/`, `/specs/` or `/callers/`, name what the runtime records itself; an event that uses them returns `invalid_input` at `/event/type` or `/event/source`. `list_brain_events` shows each published event as an `event_published` event. A published event starts the workflows whose [event trigger](workflow-format.md#triggers) matches it, and reaches the runs listening for its type. +The event, with its id and time filled in, may take at most 240 KiB as JSON. The types `run_started`, `run_deferred`, `run_succeeded`, `run_rejected`, `run_failed`, `tool_call_started`, `tool_call_answered`, `delivery_started`, `delivery_ended`, `tool_test_started`, `tool_test_answered`, `interaction_requested`, `definition_created`, `definition_updated`, `definition_retired`, `event_published`, `workflow_input_applied`, `step_started`, `step_waiting`, `step_finished`, `step_failed`, `step_skipped` and `reaction_refused`, which include every type the brain's events show, and sources beginning `/runs/`, `/definitions/` or `/callers/`, name what the runtime records itself; an event that uses them returns `invalid_input` at `/event/type` or `/event/source`. `list_brain_events` shows each published event as an `event_published` event. A published event starts the workflows whose [event trigger](workflow-format.md#triggers) matches it, and reaches the runs listening for its type. ## Run history and brain events These routes are relative to `/v1/orgs/{org}/brains/{brain}` and need `brain:read`: -| Operation | Method and route | Input | -| ----------------------- | ---------------------------------------- | ----------------------------------------------------------------------- | -| `list_executions` | `GET /executions` | Optional `primitive`, `name`, `status`, `limit` and `cursor` | -| `get_execution_history` | `GET /executions/{execution_id}/history` | Execution id in path; optional `order`, `limit` and `cursor` | -| `list_brain_events` | `GET /events` | Optional `type`, `since`, `execution_id`, `order`, `limit` and `cursor` | +| Operation | Method and route | Input | +| ------------------- | ---------------------------- | ----------------------------------------------------------------- | +| `list_runs` | `GET /runs` | Optional `type`, `name`, `status`, `limit` and `cursor` | +| `get_run_history` | `GET /runs/{run_id}/history` | Run id in path; optional `order`, `limit` and `cursor` | +| `list_brain_events` | `GET /events` | Optional `type`, `since`, `run_id`, `order`, `limit` and `cursor` | -`list_executions` returns `executions`, newest first by when each run first started. A listed run has the fields `get_execution` returns, without `output`, `record` and the detail and issues of a rejection; a rejection shows its `reason`, with `kind` and `because` when the function gave them. `status` keeps the runs whose status is `started`, `succeeded`, `rejected` or `failed`, and `primitive` and `name` keep the runs of one definition. +`list_runs` returns `runs`, newest first by when each run first started. A listed run has the fields `get_run` returns, without `output`, `record` and the detail and issues of a rejection; a rejection shows its `reason`, with `kind` and `because` when the function gave them. `status` keeps the runs whose status is `started`, `succeeded`, `rejected` or `failed`, and `type` and `name` keep the runs of one definition. -`get_execution_history` returns the `events` of one run in the order the brain recorded them, oldest first unless `order` is `desc`, so each event comes after the event that caused it: the facts the runtime recorded about the run, each start and how it ended. Tool-using runs also record `tool_call_started` and `tool_call_answered`. The run of an interaction function shows its request as `interaction_requested`, with the delivery, the party, the size of the message and the expiry, each delivery attempt as `delivery_started`, with the tool and the size and digest of its arguments, and `delivery_ended`, with how it ended and the size and digest of what the tool answered, the arguments and the answer shown at 2 KiB only where the operator records the server's content, and each reply taken or refused as `reply_taken` or `reply_refused`, with the reply's identity and never its words; its answer is the run's output, which the history does not show. A run that does not exist in the brain returns `not_found`. For a workflow, the history also holds one `workflow_input_applied` event for each input its run took, followed by one event for each step that input moved, named for how the step ended that input: `step_waiting`, `step_finished`, `step_failed`, `step_skipped`, or `step_started` for a step that runs others, such as a `do` or a `fork`, and was still running. A step that starts and finishes in one input shows only `step_finished`. `list_brain_events` returns the `events` of the whole brain, newest first unless `order` is `asc`: definitions created, updated and retired, runs started and ended, tool calls, tests of a tool, the brain's reads of a conversation that found a reply or failed and its tellings, the inputs workflow runs took and the events published to the brain. `type` keeps one event type. `since`, an ISO 8601 time with its offset such as `2026-10-05T09:00:00Z`, keeps what the brain recorded from that time on, in either order; its date must exist in the calendar and its hour run from 00 to 23, so `T24:00:00Z` is refused. `execution_id` keeps what one run and every run it started recorded, such as the functions a workflow called: its whole tree. A run that another run started belongs to the tree of the run at its top, so its own id answers no events; read the tree from the id of the run you started. +`get_run_history` returns the `events` of one run in the order the brain recorded them, oldest first unless `order` is `desc`, so each event comes after the event that caused it: the facts the runtime recorded about the run, each start and how it ended. Tool-using runs also record `tool_call_started` and `tool_call_answered`. The run of an interaction function shows its request as `interaction_requested`, with the delivery, the party, the size of the message and the expiry, each delivery attempt as `delivery_started`, with the tool and the size and digest of its arguments, and `delivery_ended`, with how it ended and the size and digest of what the tool answered, the arguments and the answer shown at 2 KiB only where the operator records the server's content, and each reply taken or refused as `reply_taken` or `reply_refused`, with the reply's identity and never its words; its answer is the run's output, which the history does not show. A run that does not exist in the brain returns `not_found`. For a workflow, the history also holds one `workflow_input_applied` event for each input its run took, followed by one event for each step that input moved, named for how the step ended that input: `step_waiting`, `step_finished`, `step_failed`, `step_skipped`, or `step_started` for a step that runs others, such as a `do` or a `fork`, and was still running. A step that starts and finishes in one input shows only `step_finished`. `list_brain_events` returns the `events` of the whole brain, newest first unless `order` is `asc`: definitions created, updated and retired, runs started and ended, tool calls, tests of a tool, the brain's reads of a conversation that found a reply or failed and its tellings, the inputs workflow runs took and the events published to the brain. `type` keeps one event type. `since`, an ISO 8601 time with its offset such as `2026-10-05T09:00:00Z`, keeps what the brain recorded from that time on, in either order; its date must exist in the calendar and its hour run from 00 to 23, so `T24:00:00Z` is refused. `run_id` keeps what one run and every run it started recorded, such as the functions a workflow called: its whole tree. A run that another run started belongs to the tree of the run at its top, so its own id answers no events; read the tree from the id of the run you started. Tool-call events identify the server, tool, argument and result sizes and digests, and how each call ended. Content is omitted unless the operator enables `record_content`. Recorded content is scrubbed and bounded; the history API shows at most 2 KiB of each recorded argument or result. Anyone with read access to the brain can read that history. A started call without an answer may have had an external effect; absence of an answer does not prove it was cancelled before acting. Each event has these fields: -| Field | Contents | -| -------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `id` | The event's id, a UUID that stays the same on every read | -| `cursor` | The place to read on from: pass it as `cursor` to read what follows the event | -| `causation_id` | The `id` of the event that directly led to this one, or `null` when nothing recorded did, as for a run you started | -| `at` | When it happened, by its own clock, in ISO 8601 UTC | -| `type` | `execution_started`, `execution_succeeded`, `execution_rejected`, `execution_failed`, `tool_call_started`, `tool_call_answered`, `tool_test_started`, `tool_test_answered`, `replies_read`, `telling_started`, `telling_ended`, `workflow_input_applied`, `step_started`, `step_waiting`, `step_finished`, `step_failed`, `step_skipped`, `spec_created`, `spec_updated`, `spec_retired`, `event_published` or `reaction_refused` | -| `summary` | A sentence in plain language | -| `data` | The facts of the event, at most 4 KiB as JSON | +| Field | Contents | +| -------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `id` | The event's id, a UUID that stays the same on every read | +| `cursor` | The place to read on from: pass it as `cursor` to read what follows the event | +| `causation_id` | The `id` of the event that directly led to this one, or `null` when nothing recorded did, as for a run you started | +| `at` | When it happened, by its own clock, in ISO 8601 UTC | +| `type` | `run_started`, `run_succeeded`, `run_rejected`, `run_failed`, `tool_call_started`, `tool_call_answered`, `tool_test_started`, `tool_test_answered`, `replies_read`, `telling_started`, `telling_ended`, `workflow_input_applied`, `step_started`, `step_waiting`, `step_finished`, `step_failed`, `step_skipped`, `definition_created`, `definition_updated`, `definition_retired`, `event_published` or `reaction_refused` | +| `summary` | A sentence in plain language | +| `data` | The facts of the event, at most 4 KiB as JSON | -In `data`, inputs, outputs, records, documents and schemas appear as their sizes in bytes. `get_execution` returns a run's output and record, and `get_spec` a definition's document and schemas; the API does not return a run's original input. A rejection shows its reason, its detail shortened to fit, and for invalid input the number of issues and the first five. A success shows the sizes of its output and record as `output_bytes` and `record_bytes`, and a rejection that kept a record, as a run of a reasoning function does when it is rejected after its model answered, shows its size as `record_bytes` too. A definition's description shows its first 300 characters, and its warnings as a count. A `workflow_input_applied` event shows the kind and key of the input, how many steps it moved and the first five, each with its task, run and outcome, the kind and because of a function's rejection that has them, in its words too, and the kinds of what the run did next; it never shows the run's data. A step event shows the step's `name`, its `reference` in the document, its `run`, which counts each time the step starts, and `times`, which counts how often it moved that way in that run; `step_waiting` adds what it waits for, `call`, `timer` or `event`, and for a call the `execution_id` of the run it started, whose `execution_started` names that `step_waiting` as its cause; `step_failed` adds how it failed and its error's type and title. An `event_published` event shows the published event's `event_id`, `event_type`, `source`, `subject` and `time`, the size of its data as `data_bytes`, and which attributes the runtime `filled` in; for an event a workflow emitted, `emitted_by` names its run's `execution_id`, the `workflow` and its `version`, and `depth` its depth in a chain of triggers. A `reaction_refused` event shows the `workflow` a trigger did not start, how many times in the `minute` it shows as `count`, and the last `reason`. A run a trigger started shows `brain:` and the brain's name as its caller, and its `execution_started` shows the `trigger` that started it, its `kind` and `reference`, and says in words which kind it was, such as "was started by its every schedule". A run an event trigger started names the event's record as its cause. A run a schedule started names as its cause the record that saved its schedule as it is, which comes before the record of the run's `spec_version` when a later version left the schedule unchanged. +In `data`, inputs, outputs, records, documents and schemas appear as their sizes in bytes. `get_run` returns a run's output and record, and `get_definition` a definition's document and schemas; the API does not return a run's original input. A rejection shows its reason, its detail shortened to fit, and for invalid input the number of issues and the first five. A success shows the sizes of its output and record as `output_bytes` and `record_bytes`, and a rejection that kept a record, as a run of a reasoning function does when it is rejected after its model answered, shows its size as `record_bytes` too. A definition's description shows its first 300 characters, and its warnings as a count. A `workflow_input_applied` event shows the kind and key of the input, how many steps it moved and the first five, each with its task, run and outcome, the kind and because of a function's rejection that has them, in its words too, and the kinds of what the run did next; it never shows the run's data. A step event shows the step's `name`, its `reference` in the document, its `run`, which counts each time the step starts, and `times`, which counts how often it moved that way in that run; `step_waiting` adds what it waits for, `call`, `timer` or `event`, and for a call the `run_id` of the run it started, whose `run_started` names that `step_waiting` as its cause; `step_failed` adds how it failed and its error's type and title. An `event_published` event shows the published event's `event_id`, `event_type`, `source`, `subject` and `time`, the size of its data as `data_bytes`, and which attributes the runtime `filled` in; for an event a workflow emitted, `emitted_by` names its run's `run_id`, the `workflow` and its `version`, and `depth` its depth in a chain of triggers. A `reaction_refused` event shows the `workflow` a trigger did not start, how many times in the `minute` it shows as `count`, and the last `reason`. A run a trigger started shows `brain:` and the brain's name as its caller, and its `run_started` shows the `trigger` that started it, its `kind` and `reference`, and says in words which kind it was, such as "was started by its every schedule". A run an event trigger started names the event's record as its cause. A run a schedule started names as its cause the record that saved its schedule as it is, which comes before the record of the run's `definition_version` when a later version left the schedule unchanged. The causes link the events of a run into a graph. A run you start has no cause. A workflow run's start is followed directly by its inputs and their steps, and its first input is caused by its start. A step that starts in an input is caused by what came before it: the input for the first step, the step before it in its list, the `switch` that chose it, the `fork` it is a branch of, or the failed attempt a retry repeats. A step that goes on from an earlier input is caused by its own event before: the input that answers a call or delivers an event, and the step's `step_finished`, are both caused by its `step_waiting`. The run's end is caused by its last step event. -Every page carries `has_more` and `next_cursor`. Pass `next_cursor` as `cursor` to read the next page, until `next_cursor` is `null`. `limit` is 1 to 100, and 20 when left out; it counts the events a page answers with, step events included, so a page can end between the step events of one input, and its `next_cursor` reads on from the next one. A page can hold fewer items than `limit`, or none, while `has_more` is `true`: records with no event type are left out, a page stops after loading 4 MiB of stored data, and with `status`, `primitive`, `name` or `type` after looking at 1,000 runs or records. Cursors are opaque; a cursor this brain did not give returns `invalid_input` at `/cursor`. +Every page carries `has_more` and `next_cursor`. Pass `next_cursor` as `cursor` to read the next page, until `next_cursor` is `null`. `limit` is 1 to 100, and 20 when left out; it counts the events a page answers with, step events included, so a page can end between the step events of one input, and its `next_cursor` reads on from the next one. A page can hold fewer items than `limit`, or none, while `has_more` is `true`: records with no event type are left out, a page stops after loading 4 MiB of stored data, and with `status`, `type` or `name` after looking at 1,000 runs or records. Cursors are opaque; a cursor this brain did not give returns `invalid_input` at `/cursor`. The brain's own creation, changes and retirement are not brain events; `get_brain` shows them. A retired brain stays readable: these reads work on it, while every change to it is refused with `conflict`. @@ -210,14 +210,14 @@ The brain's own creation, changes and retirement are not brain events; `get_brai This route is relative to `/v1/orgs/{org}/brains/{brain}` and needs `brain:read`: -| Operation | Method and route | Input | -| --------------------- | ---------------- | -------------------------------------------------------------------- | -| `get_brain_analytics` | `GET /analytics` | Optional `days`, or `from` and `to`; optional `primitive` and `name` | +| Operation | Method and route | Input | +| --------------------- | ---------------- | --------------------------------------------------------------- | +| `get_brain_analytics` | `GET /analytics` | Optional `days`, or `from` and `to`; optional `type` and `name` | -`get_brain_analytics` answers what the brain's runs did over a window of days in UTC. `days` is `7`, `14` or `30`, the last days ending today, and `7` when nothing is given. `from` and `to` name the first and the last day as `YYYY-MM-DD`, both included: at most 366 days, with `to` not before `from` and not after today. A day must exist in the calendar, so `2026-02-30` is refused rather than read as 2 March. `primitive` and `name` keep the runs of one definition, as they do for `list_executions`. `days` with `from` or `to`, `from` without `to`, any other `days` and any other parameter are refused with `invalid_input`, pointing at the parameter. +`get_brain_analytics` answers what the brain's runs did over a window of days in UTC. `days` is `7`, `14` or `30`, the last days ending today, and `7` when nothing is given. `from` and `to` name the first and the last day as `YYYY-MM-DD`, both included: at most 366 days, with `to` not before `from` and not after today. A day must exist in the calendar, so `2026-02-30` is refused rather than read as 2 March. `type` and `name` keep the runs of one definition, as they do for `list_runs`. `days` with `from` or `to`, `from` without `to`, any other `days` and any other parameter are refused with `invalid_input`, pointing at the parameter. ```http -GET /v1/orgs/acme/brains/sales/analytics?days=7&primitive=inference +GET /v1/orgs/acme/brains/sales/analytics?days=7&type=reasoning Authorization: Bearer ``` @@ -237,7 +237,7 @@ Authorization: Bearer ], "by_function": [ { - "primitive": "inference", + "type": "reasoning", "name": "triage", "runs": 12 } @@ -254,9 +254,9 @@ The example shortens `by_day`, which holds every day of the window, oldest first | `tokens` | The `input` and `output` tokens the models of reasoning functions used, a rejected run's included when its model answered before the rejection; `cached` is the part of `input` read from the provider's cache; `0` where nothing was recorded | | `duration_ms` | The median, `p50`, and the 95th percentile, `p95`, of how long the runs that succeeded or failed took, from their latest start to their end, in milliseconds; `null` when no run of the period has a duration | | `by_day` | The same `runs`, `tokens` and `duration_ms` for each `day` | -| `by_function` | Each definition by `primitive` and `name`, with `runs`, how many of its runs ended; the most runs first, then by `primitive` and `name` | +| `by_function` | Each definition by `type` and `name`, with `runs`, how many of its runs ended; the most runs first, then by `type` and `name` | -A run started again under its `execution_id` counts once: its tokens add up over every attempt that ended, while its duration is that of its last attempt. A percentile is the nearest rank: the duration at place ⌈p × n⌉ of the n durations in order. Rejected runs count in `runs` and in `tokens`, never in `duration_ms`; workflow runs count in `runs` and in `duration_ms`, from when the run started to when the workflow ended. The answer reads a table the runtime keeps as each run is recorded, so it is as current as the runs themselves. A brain that does not exist returns `not_found`; a retired brain answers like any other. +A run started again under its `run_id` counts once: its tokens add up over every attempt that ended, while its duration is that of its last attempt. A percentile is the nearest rank: the duration at place ⌈p × n⌉ of the n durations in order. Rejected runs count in `runs` and in `tokens`, never in `duration_ms`; workflow runs count in `runs` and in `duration_ms`, from when the run started to when the workflow ended. The answer reads a table the runtime keeps as each run is recorded, so it is as current as the runs themselves. A brain that does not exist returns `not_found`; a retired brain answers like any other. ## Tool servers @@ -383,13 +383,13 @@ Content-Type: application/json The answer is `200` whenever the call was made, however the tool answered. Only a tool its server marks read-only, or that the operator marks testable on its entry, can be tested; any other returns `unavailable` with kind `tool_not_offered` and `because: "not_testable"`, and nothing is called. A tool the operator does not allow, a server not set up for the brain and a tool the server does not list return the same kind with `tool_not_allowed`, `mcp_server_not_configured` or `tool_not_listed`, and a server that cannot be used returns `unavailable` with kind `mcp_server_failed`. A test is live: a tool the operator marks testable may still change something, which is why `test_tool_call` is marked destructive over MCP while any entry marks a tool testable. -Each test is recorded in the brain's history as `tool_test_started` and `tool_test_answered`, which `list_brain_events` shows, with content only where the operator enables `record_content`; a test starts no run, so `list_executions` and `get_execution_history` never show it. A caller that goes away during the call leaves a start without an answer. Each test makes one call; a test of a server no run holds opens a connection and lets it go once the call has answered. +Each test is recorded in the brain's history as `tool_test_started` and `tool_test_answered`, which `list_brain_events` shows, with content only where the operator enables `record_content`; a test starts no run, so `list_runs` and `get_run_history` never show it. A caller that goes away during the call leaves a start without an answer. Each test makes one call; a test of a server no run holds opens a connection and lets it go once the call has answered. ## Responses and errors Successful responses contain JSON with `Cache-Control: no-store`. Create operations return 201; other successful operations generally return 200. -API errors use RFC 9457 problem documents with `Content-Type: application/problem+json`. Inspect `reason` and `detail`; `invalid_input` includes an `errors` list of JSON pointers. A rejection that has a `kind` carries it, and an `unavailable` one its `because`: `tools_unfinished` means a run called tools and could not finish, so its tools may have changed something, and a request with the same execution id answers `conflict` with the kind `tools_called`. `Retry-After` comes only with an `unavailable` answer that a retry of the same request may resolve, never with `tools_unfinished`, `tool_not_offered` or `model_not_offered`, and never with `cancelled`. A `conflict` may have the kind `oversized`, for a workflow whose output is larger than a run records. +API errors use RFC 9457 problem documents with `Content-Type: application/problem+json`. Inspect `reason` and `detail`; `invalid_input` includes an `errors` list of JSON pointers. A rejection that has a `kind` carries it, and an `unavailable` one its `because`: `tools_unfinished` means a run called tools and could not finish, so its tools may have changed something, and a request with the same run id answers `conflict` with the kind `tools_called`. `Retry-After` comes only with an `unavailable` answer that a retry of the same request may resolve, never with `tools_unfinished`, `tool_not_offered` or `model_not_offered`, and never with `cancelled`. A `conflict` may have the kind `oversized`, for a workflow whose output is larger than a run records. | Status | Common reason | | ------ | ------------------------------------------ | diff --git a/docs/reference/interaction-format.md b/docs/reference/interaction-format.md index 81b4c2140..69acc6164 100644 --- a/docs/reference/interaction-format.md +++ b/docs/reference/interaction-format.md @@ -2,7 +2,7 @@ # Interaction function format -The API stores an interaction function as an `interaction` spec. Its source document names a party and an expiry, holds the message, and may name the tool of a tool server its request is sent through. A run renders the request from its input, sends it through that tool or, for a function that names none, leaves it in the brain's inbox, and waits, holding nothing of the server, until the request is answered, expires or is cancelled. The answer, checked against the answer schema the document gives, is the run's output. Use one wherever a brain asks a person or a system and takes the answer later: an approval, a choice, a figure only someone else has, or a notification that needs no answer. +The API stores an interaction function as a definition of the type `interaction`. Its source document names a party and an expiry, holds the message, and may name the tool of a tool server its request is sent through. A run renders the request from its input, sends it through that tool or, for a function that names none, leaves it in the brain's inbox, and waits, holding nothing of the server, until the request is answered, expires or is cancelled. The answer, checked against the answer schema the document gives, is the run's output. Use one wherever a brain asks a person or a system and takes the answer later: an approval, a choice, a figure only someone else has, or a notification that needs no answer. ## A function document @@ -35,7 +35,7 @@ Please review the brief for {{ input.campaign }}. {{ input.summary }} ``` -For this document, `create_spec` takes `primitive: "interaction"`, a function `name` such as `approve-brief`, and the document as `source`. `execute_spec` takes the same primitive and name, with `campaign`, `owner` and `summary` in the `input` object. Both operations also require the brain id unless the MCP connection is scoped to that brain. The run answers `status: started` with its `execution_id`, and the request waits in the brain's inbox until it is answered, since the function names no `deliver`. +For this document, `create_definition` takes `type: "interaction"`, a function `name` such as `approve-brief`, and the document as `source`. `run_definition` takes the same type and name, with `campaign`, `owner` and `summary` in the `input` object. Both operations also require the brain id unless the MCP connection is scoped to that brain. The run answers `status: started` with its `run_id`, and the request waits in the brain's inbox until it is answered, since the function names no `deliver`. ## Sending through a tool @@ -44,7 +44,7 @@ A function sends its request through a tool of a tool server the brain may use b 1. Call `list_tool_servers` to see the servers this brain may use and the tools each offers. 2. Test the tool that sends with `test_tool_call`, with the arguments its `input_schema` takes, and read its answer: that is the document `deliver.sent` points into, with JSON Pointers, to where the conversation the message landed in and the identity of the message are named. A chat's tool that posts a message answers the conversation it posted in and the message's timestamp or id. 3. When the person answers where the message reached them, test the tool that reads what came since a point, and write `replies`: the `conversation` the brain reads by, the tool and its arguments over `sent` and the cursor `since`, the pointers of `read` to the list of replies and, within each, its id, its sender, its words and the message it answers, the floor `wait` of the cadence, and `tell`, how the party is told when a reply was not an answer. The `reply` rule maps the reply's words to the answer. -4. Write `deliver` with `server`, `tool`, `with` and `sent`, show the person the whole document, and save it with `create_spec`, `primitive` interaction, once they agree. A run refuses a server this brain may not use or a tool its operator does not allow, in the words of `list_tool_servers`. +4. Write `deliver` with `server`, `tool`, `with` and `sent`, show the person the whole document, and save it with `create_definition`, `type` interaction, once they agree. A run refuses a server this brain may not use or a tool its operator does not allow, in the words of `list_tool_servers`. The arguments of `deliver.with`, `replies.with` and `tell.with` are typed as written: a number, boolean, null, list or object is sent as written, with the templates inside its strings rendered; a string that is exactly one `{{ expression }}` is sent as the value the expression reads, so a number stays a number and an object an object, and `'{{ answer_schema }}'` sends the schema itself; any other string is rendered as text, with `| json` to embed a structured value in it. The templates of `deliver.with` read `input`, `today` and `now` as the message does, and the request's `to`, `message`, `run_id`, `function`, `expires_at` and `answer_schema`; `today` and `now` are the request's own moment, so every attempt sends the same arguments. `to` is the party the request goes to, which the templates may use as the tool's address or not. A template holds no `${`, since a document is never filled from the environment. @@ -131,17 +131,17 @@ A delivery's templates are checked the same way when the document is saved, each ## Answering a request -`answer_interaction` answers a request, over MCP or as `POST /v1/orgs/{org}/brains/{brain}/executions/{execution_id}/answer` over HTTP, with the `execution_id` of the interaction function's run, which `list_interactions` shows, and the `answer`: +`answer_interaction` answers a request, over MCP or as `POST /v1/orgs/{org}/brains/{brain}/runs/{run_id}/answer` over HTTP, with the `run_id` of the interaction function's run, which `list_interactions` shows, and the `answer`: ```json { "answer": { "choice": "approve", "note": "Ready to launch." }, "claimed_for": "the campaign team" } ``` -When the person answers a request in a conversation, by approving, rejecting, asking for changes or in other words, the agent they talk to answers it with `answer_interaction`, on the request's `execution_id`, with the answer in the shape of the request's `answer_schema`, which `list_interactions` shows beside that `execution_id`: the function's `output.schema` as it stood when the request was asked, which `get_spec` no longer shows once the function has changed. For the document above, the person's "approve" is `{ "choice": "approve" }`; for a function whose answer has a `decision` of `approve`, `revise` or `skip` and an optional `note`, it is `{ "decision": "approve" }`. This holds wherever the request reached the person, in the inbox or through a tool such as a chat's. Running the function or its workflow again answers nothing: it makes a new request and leaves the first open until it expires, while the run that waits for it goes on waiting. +When the person answers a request in a conversation, by approving, rejecting, asking for changes or in other words, the agent they talk to answers it with `answer_interaction`, on the request's `run_id`, with the answer in the shape of the request's `answer_schema`, which `list_interactions` shows beside that `run_id`: the function's `output.schema` as it stood when the request was asked, which `get_definition` no longer shows once the function has changed. For the document above, the person's "approve" is `{ "choice": "approve" }`; for a function whose answer has a `decision` of `approve`, `revise` or `skip` and an optional `note`, it is `{ "decision": "approve" }`. This holds wherever the request reached the person, in the inbox or through a tool such as a chat's. Running the function or its workflow again answers nothing: it makes a new request and leaves the first open until it expires, while the run that waits for it goes on waiting. A caller that may write to the brain can answer. The answer is checked against the answer schema the request recorded, at most 64 KiB as JSON, and settles the run `succeeded` with the answer as its output. The run's record shows `answered_by`, the caller's id, `answered_at`, and `claimed_for`, whom the caller says it answers for, kept as a claim and never checked. An answer that does not match is `invalid_input`, with a pointer under `/answer` for each problem, and leaves the request open. The same answer again answers the run as it stands; a different answer, or an answer to a request that has ended, is `conflict`. -`list_interactions`, `GET /v1/orgs/{org}/brains/{brain}/interactions` over HTTP, lists the brain's open requests, newest first: the `execution_id`, the function and its version, `to`, the `delivery`, the tool the request is sent through or `null` for the inbox, the message, whether it takes an answer, when it was asked and expires, the attempts made and how its delivery stands, `in_inbox`, `to_deliver`, `delivering`, `delivered`, `retrying`, `undelivered`, `answered`, while a reply that answered it settles its run, or `cancelling`, once a cancel of its run was asked. `to` keeps the requests to one party and `function` those of one interaction function; it pages with `limit` and `cursor`. Every reader of the brain sees each party and message, as a run's input is seen. +`list_interactions`, `GET /v1/orgs/{org}/brains/{brain}/interactions` over HTTP, lists the brain's open requests, newest first: the `run_id`, the function and its version, `to`, the `delivery`, the tool the request is sent through or `null` for the inbox, the message, whether it takes an answer, when it was asked and expires, the attempts made and how its delivery stands, `in_inbox`, `to_deliver`, `delivering`, `delivered`, `retrying`, `undelivered`, `answered`, while a reply that answered it settles its run, or `cancelling`, once a cancel of its run was asked. `to` keeps the requests to one party and `function` those of one interaction function; it pages with `limit` and `cursor`. Every reader of the brain sees each party and message, as a run's input is seen. Each listed request also shows its `answer_schema`, the JSON Schema an answer is checked against, as the request recorded it, or `null` for a notification. @@ -160,12 +160,12 @@ Where a function reads replies, the person answers by replying to the message th | `unavailable`, kind `requests_full` | The brain already has as many open requests as the runtime allows, 10,000 unless its operator sets another number | | `rejected` as `unanswered`, kind `expired` | Nobody answered the request before it expired | | `rejected` as `unanswered`, kind `undelivered` | Every attempt to deliver a notification failed | -| `rejected` as `cancelled` | `cancel_execution` cancelled the run, or the workflow that waited for it ran out of time or ended first | +| `rejected` as `cancelled` | `cancel_run` cancelled the run, or the workflow that waited for it ran out of time or ended first | | `failed` | The runtime itself broke down | -A request that has ended is final for its execution id: answering it is `conflict`, and running the function again starts a new request under a new id. +A request that has ended is final for its run id: answering it is `conflict`, and running the function again starts a new request under a new id. -The run's history shows the request as `interaction_requested`, with the delivery, the party, the size of the message, whether it takes an answer and the expiry; then each delivery attempt as `delivery_started`, with the tool and the size and digest of its arguments, and `delivery_ended`, with how it ended, the size and digest of what the tool answered and, where the delivery writes `sent`, what the message was delivered as; each reply taken or refused as `reply_taken` or `reply_refused`, with the reply's identity and never its words; and the run's end. The arguments, the message among them, and what the tool answered are shown at 2 KiB only where the operator records the server's content. An answer is the run's output, which `get_execution` returns and the history does not show. `list_brain_events` shows the brain's reads of a conversation that found a reply or failed, and its tellings. +The run's history shows the request as `interaction_requested`, with the delivery, the party, the size of the message, whether it takes an answer and the expiry; then each delivery attempt as `delivery_started`, with the tool and the size and digest of its arguments, and `delivery_ended`, with how it ended, the size and digest of what the tool answered and, where the delivery writes `sent`, what the message was delivered as; each reply taken or refused as `reply_taken` or `reply_refused`, with the reply's identity and never its words; and the run's end. The arguments, the message among them, and what the tool answered are shown at 2 KiB only where the operator records the server's content. An answer is the run's output, which `get_run` returns and the history does not show. `list_brain_events` shows the brain's reads of a conversation that found a reply or failed, and its tellings. ## Bounds @@ -189,7 +189,7 @@ The run's history shows the request as `interaction_requested`, with the deliver ## In a workflow -A workflow calls an interaction function as it calls any function, with `call: execute_spec` and `primitive: interaction`. The step waits for the request, as long as the function's `expires` and a minute more, holding nothing of the server, across a restart, and its output is the answer. A request nobody answers raises an error of the [problem type](http.md#responses-and-errors) `https://on.auto/problems/unanswered`, status 410, with the kind `expired` or, for a notification, `undelivered`. No retry policy matches it unless it names that type, so a workflow handles an unanswered request only on purpose, and a workflow that does not catch it ends `rejected` as `unanswered` with the same kind. When the step runs out of time, or the workflow ends first, the request is cancelled. +A workflow calls an interaction function as it calls any function, with `call: run_definition` and `type: interaction`. The step waits for the request, as long as the function's `expires` and a minute more, holding nothing of the server, across a restart, and its output is the answer. A request nobody answers raises an error of the [problem type](http.md#responses-and-errors) `https://on.auto/problems/unanswered`, status 410, with the kind `expired` or, for a notification, `undelivered`. No retry policy matches it unless it names that type, so a workflow handles an unanswered request only on purpose, and a workflow that does not catch it ends `rejected` as `unanswered` with the same kind. When the step runs out of time, or the workflow ends first, the request is cancelled. This workflow asks for the approval and, when nobody answers in time, records that the brief went unapproved: @@ -209,9 +209,9 @@ do: - approval: try: - ask: - call: execute_spec + call: run_definition with: - primitive: interaction + type: interaction name: approve-brief input: '${ . }' catch: @@ -221,9 +221,9 @@ do: - unapproved: { set: { choice: unanswered } } ``` -The run ends `succeeded` with the answer, such as `{"choice": "approve"}`, as its output, or with `{"choice": "unanswered"}` when the request expired. `list_interactions` shows the request while it waits, under the `execution_id` of the interaction function's run. +The run ends `succeeded` with the answer, such as `{"choice": "approve"}`, as its output, or with `{"choice": "unanswered"}` when the request expired. `list_interactions` shows the request while it waits, under the `run_id` of the interaction function's run. -`send_execution_event` remains for events a waiting workflow listens for that are not the answer to a question. +`send_run_event` remains for events a waiting workflow listens for that are not the answer to a question. ## Availability diff --git a/docs/reference/mcp.md b/docs/reference/mcp.md index ad0008ac6..48a8d77ac 100644 --- a/docs/reference/mcp.md +++ b/docs/reference/mcp.md @@ -6,7 +6,7 @@ The Auto runtime exposes brain, reasoning-function and workflow operations throu This is an inbound interface: an external assistant connects to a local or self-hosted Auto runtime and calls its operations. It does not configure tools inside a reasoning function: the operator configures their servers in `mcp_servers`, each entry with the tools it allows, which the repository's [configuration guide](https://github.com/BeOnAuto/auto-brain/blob/main/docs/engineering/self-host/configuration.md#mcp-servers) describes, and a function names their tools in its [`tools`](reasoning-format.md#tools). Auto Cloud is currently invite-only. [Request an invitation](https://on.auto/request-invite) if you would prefer a hosted brain. -A reasoning function can also call tools itself, through MCP servers the operator of a self-hosted runtime configures. That outbound connection is set up on the server, never by adding an endpoint to an external assistant. While any MCP server is configured, `execute_spec` carries the destructive annotation, since a function's tools may change something. See [Tool access inside a reasoning function](../concepts/functions.md#tool-access-inside-a-reasoning-function). +A reasoning function can also call tools itself, through MCP servers the operator of a self-hosted runtime configures. That outbound connection is set up on the server, never by adding an endpoint to an external assistant. While any MCP server is configured, `run_definition` carries the destructive annotation, since a function's tools may change something. See [Tool access inside a reasoning function](../concepts/functions.md#tool-access-inside-a-reasoning-function). ## Transport and authentication @@ -26,45 +26,45 @@ Authorization uses the same organization, brain and operation permissions as the | `/orgs/{org}/mcp` | Brain management, model and tool server discovery for the named org | | `/orgs/{org}/brains/{brain}/mcp` | Function and workflow operations for one brain, without a `brain` argument | -An agent that connects receives instructions built for the tools its key may call. They say what a brain is, in the words of the [terminology](../concepts/terminology.md), with a sentence for each type of function the runtime runs and for workflows; what the connection acts on: `/mcp` acts in the key's own org, `/orgs/{org}/mcp` manages the brains of one org, and `/orgs/{org}/brains/{brain}/mcp` acts inside one brain; the one sentence that maps the tools' names to the product's words, a definition a `spec`, a run an `execution` and a definition's type its `primitive`, whose values the `primitive` argument of each tool names; that `get_guide` holds the format of each definition and the recipes; how a reasoning function names its model and its tools; that `get_execution` shows whether a run that finishes later ended or still waits; that when the person approves, rejects or otherwise answers what a run waits on, the agent answers its request with `answer_interaction`, in the shape its function's answer takes, and starts no new run for it; that runs, history, events and requests come a page at a time; and how to answer the person: what was done and what they can do next, in the words of brains, functions, workflows and runs, with an id or a status only when the person needs it to act. A recall function's sentence says what it answers from: what it keeps of the brain's own history, every run's start and end, with its result when it succeeded, the definitions saved and the events published to the brain, never a run's input or its tool calls, so nothing has to write into it. +An agent that connects receives instructions built for the tools its key may call. They say what a brain is, in the words of the [terminology](../concepts/terminology.md), with a sentence for each type of function the runtime runs and for workflows; what the connection acts on: `/mcp` acts in the key's own org, `/orgs/{org}/mcp` manages the brains of one org, and `/orgs/{org}/brains/{brain}/mcp` acts inside one brain; that `get_guide` holds the format of each definition and the recipes; how a reasoning function names its model and its tools; that `get_run` shows whether a run that finishes later ended or still waits; that when the person approves, rejects or otherwise answers what a run waits on, the agent answers its request with `answer_interaction`, in the shape its function's answer takes, and starts no new run for it; that runs, history, events and requests come a page at a time; and how to answer the person: what was done and what they can do next, in the words of brains, functions, workflows and runs, with an id or a status only when the person needs it to act. A recall function's sentence says what it answers from: what it keeps of the brain's own history, every run's start and end, with its result when it succeeded, the definitions saved and the events published to the brain, never a run's input or its tool calls, so nothing has to write into it. -Functions and workflows share the definition tools: those tools accept `inference` for reasoning functions, `interaction` for interaction functions, `computation` for computation functions and `recollection` for recall functions in a self-hosted runtime, and `orchestration` for workflows. Workflow operations also include `send_execution_event`, and interaction functions add `list_interactions` and `answer_interaction`. On `/mcp`, the API key determines the org; local mode uses its local org. Tools do not take a separate org argument. The named org and brain in scoped endpoints remain subject to the key's access restrictions on an authenticated deployment. +Functions and workflows share the definition tools, whose `type` is `reasoning`, `interaction`, `computation`, `recall` or `workflow`; a self-hosted runtime is the one that offers `interaction`, `computation` and `recall`. Workflow operations also include `send_run_event`, and interaction functions add `list_interactions` and `answer_interaction`. On `/mcp`, the API key determines the org; local mode uses its local org. Tools do not take a separate org argument. The named org and brain in scoped endpoints remain subject to the key's access restrictions on an authenticated deployment. ## Tools -| Work | Tools | -| ---------------------------- | -------------------------------------------------------------------------------------------------------- | -| Manage brains | `create_brain`, `list_brains`, `get_brain`, `update_brain`, `retire_brain` | -| List available models | `list_models`, with an optional `provider` filter | -| Manage reasoning functions | `create_spec`, `list_specs`, `get_spec`, `update_spec`, `retire_spec`, with `primitive: "inference"` | -| Manage interaction functions | `create_spec`, `list_specs`, `get_spec`, `update_spec`, `retire_spec`, with `primitive: "interaction"` | -| Manage computation functions | `create_spec`, `list_specs`, `get_spec`, `update_spec`, `retire_spec`, with `primitive: "computation"` | -| Manage recall functions | `create_spec`, `list_specs`, `get_spec`, `update_spec`, `retire_spec`, with `primitive: "recollection"` | -| Manage workflows | `create_spec`, `list_specs`, `get_spec`, `update_spec`, `retire_spec`, with `primitive: "orchestration"` | -| Run and inspect | `execute_spec`, `get_execution`, `list_executions`, `get_execution_history` | -| Answer a request | `list_interactions`, `answer_interaction` | -| Answer a waiting workflow | `send_execution_event` | -| Cancel a run | `cancel_execution` | -| Publish an event | `publish_event` | -| Follow a brain | `list_brain_events` | -| Read a brain's analytics | `get_brain_analytics` | -| Find the tools to name | `list_tool_servers`, with optional `brain` and `server` filters | -| Learn what a tool answers | `test_tool_call`, with the `server`, the `tool` and its `arguments` | -| Read a guide | `get_guide`, with the name of the guide | +| Work | Tools | +| ---------------------------- | ------------------------------------------------------------------------------------------------------------------------------- | +| Manage brains | `create_brain`, `list_brains`, `get_brain`, `update_brain`, `retire_brain` | +| List available models | `list_models`, with an optional `provider` filter | +| Manage reasoning functions | `create_definition`, `list_definitions`, `get_definition`, `update_definition`, `retire_definition`, with `type: "reasoning"` | +| Manage interaction functions | `create_definition`, `list_definitions`, `get_definition`, `update_definition`, `retire_definition`, with `type: "interaction"` | +| Manage computation functions | `create_definition`, `list_definitions`, `get_definition`, `update_definition`, `retire_definition`, with `type: "computation"` | +| Manage recall functions | `create_definition`, `list_definitions`, `get_definition`, `update_definition`, `retire_definition`, with `type: "recall"` | +| Manage workflows | `create_definition`, `list_definitions`, `get_definition`, `update_definition`, `retire_definition`, with `type: "workflow"` | +| Run and inspect | `run_definition`, `get_run`, `list_runs`, `get_run_history` | +| Answer a request | `list_interactions`, `answer_interaction` | +| Answer a waiting workflow | `send_run_event` | +| Cancel a run | `cancel_run` | +| Publish an event | `publish_event` | +| Follow a brain | `list_brain_events` | +| Read a brain's analytics | `get_brain_analytics` | +| Find the tools to name | `list_tool_servers`, with optional `brain` and `server` filters | +| Learn what a tool answers | `test_tool_call`, with the `server`, the `tool` and its `arguments` | +| Read a guide | `get_guide`, with the name of the guide | Every tool supplies its description and the JSON Schema of its input, and no output schema; [Successful results](#successful-results) says what a result carries. A description says what the tool does and when to use it; the rules of each argument are in its schema, and the format of a definition is in its guide. Each tool's annotations say whether it only reads, whether what it does cannot be undone, as retiring, cancelling and answering a request cannot, whether calling it again with the same input changes nothing, and whether it reaches outside the runtime. Brain-management and model-discovery tools are available at `/mcp` and the org endpoint; function, workflow, brain event and tool test tools are available at `/mcp` and the brain endpoint; `list_tool_servers` and `get_guide` are available on every endpoint. -Definition operations identify a function or workflow by `primitive` and `name`. Creating or updating a definition takes its document as `source`. Running it accepts `input` and an optional UUID `execution_id`; inspecting a run requires `execution_id`. Cancelling a run requires its `execution_id` and takes an optional `reason`, 1 to 1,024 characters, which the run keeps as the detail of its ending; [Cancelling a run](http.md#cancelling-a-run) says which runs can be cancelled. Sending an event requires the run's `execution_id` and an `event` with a `type`; [HTTP workflows](http.md#workflows) lists its other fields and limits. Publishing an event to a brain requires an `event` with a `source` and a `type`; [Publishing events](http.md#publishing-events) lists its attributes and limits and what publishing it again returns. See [HTTP operations](http.md) for field limits and retry behavior. +Definition operations identify a function or workflow by `type` and `name`. Creating or updating a definition takes its document as `source`. Running it accepts `input` and an optional UUID `run_id`; inspecting a run requires `run_id`. Cancelling a run requires its `run_id` and takes an optional `reason`, 1 to 1,024 characters, which the run keeps as the detail of its ending; [Cancelling a run](http.md#cancelling-a-run) says which runs can be cancelled. Sending an event requires the run's `run_id` and an `event` with a `type`; [HTTP workflows](http.md#workflows) lists its other fields and limits. Publishing an event to a brain requires an `event` with a `source` and a `type`; [Publishing events](http.md#publishing-events) lists its attributes and limits and what publishing it again returns. See [HTTP operations](http.md) for field limits and retry behavior. -`list_executions` lists a brain's runs newest first, with optional `primitive`, `name` and `status` filters. `get_execution_history` reads what was recorded about one run, and `list_brain_events` follows everything that happened in a brain, with optional `type`, `since` and `execution_id` filters, the last keeping the whole tree of one run; both take `order`. Their events carry `id`, `cursor`, `causation_id`, the `id` of the event that led to it, `at`, `type`, a plain-language `summary` and `data` of at most 4 KiB; a workflow run's start is followed directly by one event for each input it took and each step that moved. All three page with `limit` and `cursor` and answer `has_more` and `next_cursor`; a page may be short or empty while `has_more` is `true`. They work on a retired brain. See [Run history and brain events](http.md#run-history-and-brain-events) for the fields and limits. +`list_runs` lists a brain's runs newest first, with optional `type`, `name` and `status` filters. `get_run_history` reads what was recorded about one run, and `list_brain_events` follows everything that happened in a brain, with optional `type`, `since` and `run_id` filters, the last keeping the whole tree of one run; both take `order`. Their events carry `id`, `cursor`, `causation_id`, the `id` of the event that led to it, `at`, `type`, a plain-language `summary` and `data` of at most 4 KiB; a workflow run's start is followed directly by one event for each input it took and each step that moved. All three page with `limit` and `cursor` and answer `has_more` and `next_cursor`; a page may be short or empty while `has_more` is `true`. They work on a retired brain. See [Run history and brain events](http.md#run-history-and-brain-events) for the fields and limits. -`get_brain_analytics` counts the runs of a brain that ended over the last 7, 14 or 30 days, with `days`, or between two days, with `from` and `to`: by how they ended, the tokens their models used, and the median and 95th percentile of how long they took, for the whole window and for each `day` of `by_day`, and in `by_function` the number of runs of each definition. It takes the optional `primitive` and `name` filters of `list_executions`. See [Analytics](http.md#analytics) for the window and how each number is counted. +`get_brain_analytics` counts the runs of a brain that ended over the last 7, 14 or 30 days, with `days`, or between two days, with `from` and `to`: by how they ended, the tokens their models used, and the median and 95th percentile of how long they took, for the whole window and for each `day` of `by_day`, and in `by_function` the number of runs of each definition. It takes the optional `type` and `name` filters of `list_runs`. See [Analytics](http.md#analytics) for the window and how each number is counted. `list_tool_servers` lists the MCP servers set up for the brain, each with the tools a reasoning function may name in `tools` as `server/tool` or `server/*`, so an agent finds the names without being told them; a server that cannot be asked just now shows `unavailable`, in words, in place of its tools. Each tool carries the hints its server gives it, as `annotations`, and `testable`, whether `test_tool_call` may test it. Its optional `server` keeps one server; a name the brain has no server of is refused with `invalid_input`. It asks the servers when it is called, so it is marked as reaching outside the runtime, and it works on a retired brain. On `/mcp` and the org endpoint its `brain` is optional: without it, the tool answers for the whole org, each server with `brains`, the brains whose functions may use it, `["*"]` for every brain of the org, so an agent can see what is set up before a brain exists; with it, the tool keeps the servers that serve that brain. A key limited to some brains gives `brain`. On a brain endpoint it takes no `brain` and lists that brain's servers. See [Tool servers](http.md#tool-servers) for the fields. `test_tool_call` calls one tool of a tool server with the `arguments` given, as a run of a reasoning function would call it, and answers what that run's model would see, so an agent learns what a tool answers, or finds an id a prompt needs, such as a channel's, without making a function to look. Only a tool its server marks read-only, or that the operator marks testable on its entry, can be tested; any other is refused with words that say so. The call is live and recorded in the brain's history, which `list_brain_events` shows. It needs `brain:write`, so a key that may only read is not offered it, and it is marked as reaching outside the runtime, and as destructive while the operator marks any tool testable. See [Testing a tool](http.md#testing-a-tool) for the fields. -`list_interactions` lists the brain's open requests, newest first, each with the `execution_id` of the interaction function's run that asked, the party, the `delivery`, the tool it is sent through or `null` for the inbox, the message, the expiry, how its delivery stands, `answer_schema`, the shape an answer must have as the request recorded it, `null` for a notification, and, for a request whose function reads replies, the `conversation` read, the `answerer` and how many replies were refused, with optional `to` and `function` filters; it pages with `limit` and `cursor`. `answer_interaction` answers one, with that `execution_id`, an `answer` its `answer_schema` takes and an optional `claimed_for`; the run succeeds with the answer as its output, which reaches the workflow that waits for it. An answer that does not match is `invalid_input` with a pointer under `/answer`, and a request that has ended or was answered otherwise is `conflict`. See [Interaction function format](interaction-format.md#answering-a-request). +`list_interactions` lists the brain's open requests, newest first, each with the `run_id` of the interaction function's run that asked, the party, the `delivery`, the tool it is sent through or `null` for the inbox, the message, the expiry, how its delivery stands, `answer_schema`, the shape an answer must have as the request recorded it, `null` for a notification, and, for a request whose function reads replies, the `conversation` read, the `answerer` and how many replies were refused, with optional `to` and `function` filters; it pages with `limit` and `cursor`. `answer_interaction` answers one, with that `run_id`, an `answer` its `answer_schema` takes and an optional `claimed_for`; the run succeeds with the answer as its output, which reaches the workflow that waits for it. An answer that does not match is `invalid_input` with a pointer under `/answer`, and a request that has ended or was answered otherwise is `conflict`. See [Interaction function format](interaction-format.md#answering-a-request). Retirement is permanent. A retired name cannot be reused, and retired definitions cannot be edited or run. @@ -102,19 +102,19 @@ A successful tool result carries a plain-language summary, the operation's outpu Consumers should read `structuredContent`. The first text block is not JSON. -A run result includes its execution id, definition version, status and output when successful. Reading it through `get_execution` also returns the detailed record. A returned review recommending changes can still belong to a succeeded run: the operation completed and produced that recommendation. +A run result includes its run id, definition version, status and output when successful. Reading it through `get_run` also returns the detailed record. A returned review recommending changes can still belong to a succeeded run: the operation completed and produced that recommendation. For a workflow, the summaries read like these, recorded from the [first-workflow tutorial](../tutorials/first-workflow.md): ```text -execute_spec: The workflow “review-and-approve” has started and is still running. It carries on by itself, and how it ends can be looked up later. -get_execution: The workflow “review-and-approve” is still running; how it ends can be looked up again later. +run_definition: The workflow “review-and-approve” has started and is still running. It carries on by itself, and how it ends can be looked up later. +get_run: The workflow “review-and-approve” is still running; how it ends can be looked up again later. list_interactions: Found 1 request waiting on this page. answer_interaction: The request is answered: the run that asked it succeeded, with the answer as its output. -get_execution: The run of the workflow “review-and-approve” finished. Its result: review: “Revise: the audience lacks a job role.” and approval: (verdict: “approve” and note: “Approved for the autumn launch.”) +get_run: The run of the workflow “review-and-approve” finished. Its result: review: “Revise: the audience lacks a job role.” and approval: (verdict: “approve” and note: “Approved for the autumn launch.”) ``` -A workflow run is `started` when `execute_spec` returns, unless it ended before its first wait. Read it again with `get_execution` until its status is `succeeded`, `rejected` or `failed`; a cancelled run is `rejected` with the reason `cancelled`; the summary repeats a short result in words and points to `structuredContent` for a long one. +A workflow run is `started` when `run_definition` returns, unless it ended before its first wait. Read it again with `get_run` until its status is `succeeded`, `rejected` or `failed`; a cancelled run is `rejected` with the reason `cancelled`; the summary repeats a short result in words and points to `structuredContent` for a long one. ## Errors diff --git a/docs/reference/reasoning-format.md b/docs/reference/reasoning-format.md index 3353cbacf..4eea42fb6 100644 --- a/docs/reference/reasoning-format.md +++ b/docs/reference/reasoning-format.md @@ -2,7 +2,7 @@ # Reasoning function format -The API stores a reasoning function as an `inference` spec. Its source document defines the model, input and output contracts, settings and prompt template. This reference describes that format; [Build your first brain](../tutorials/first-brain.md) provides a guided example using an agent. +The API stores a reasoning function as a definition of the type `reasoning`. Its source document defines the model, input and output contracts, settings and prompt template. This reference describes that format; [Build your first brain](../tutorials/first-brain.md) provides a guided example using an agent. ## A function document @@ -28,7 +28,7 @@ Brief: {{ input.brief }} Criteria: {{ input.criteria }} ``` -For this document, `create_spec` takes `primitive: "inference"`, a function `name` and the document as `source`. `execute_spec` takes the same primitive and name, with `brief` and `criteria` in the `input` object. Both operations also require the brain id unless the MCP connection is scoped to that brain. +For this document, `create_definition` takes `type: "reasoning"`, a function `name` and the document as `source`. `run_definition` takes the same type and name, with `brief` and `criteria` in the `input` object. Both operations also require the brain id unless the MCP connection is scoped to that brain. ## Fields @@ -54,7 +54,7 @@ The Liquid template reads supplied values through `input`. An optional `{% syste A run invokes the model and records its output and usage. When the function names tools, the model can use them in a bounded loop before answering. An external agent can also pass evidence it collected through its own connections as input. -Changing the document creates a version. A run uses the active latest version and records `spec_version`; the current API does not select an arbitrary historical version to execute. See the [HTTP reference](http.md) for input limits and retry behavior. +Changing the document creates a version. A run uses the active latest version and records `definition_version`; the current API does not select an arbitrary historical version to execute. See the [HTTP reference](http.md) for input limits and retry behavior. ## The template @@ -95,14 +95,14 @@ The date filters are left out, since they read the server's clock and time zone, `tools` lists the tools of the MCP servers configured for the brain that a run may call, each written `server/tool`, or `server/*` for every tool of a server that the operator allows: ```yaml -tools: [graph/search, graph/execute, notes/*] +tools: [graph/search, graph/query, notes/*] ``` To find the names, call `list_tool_servers` (`GET /v1/orgs/{org}/brains/{brain}/tool-servers`, or the MCP tool of the same name). It lists the servers set up for the brain, each with the tools the operator allows, asking each server for them as a run does, so an agent can write `tools` without being told the names; see [Tool servers](http.md#tool-servers). To learn what a tool answers before naming it, test it with `test_tool_call`, which calls it once as a run would and answers what the run's model would see, for a tool its server marks read-only or the operator marks testable on its entry; see [Testing a tool](http.md#testing-a-tool). The model receives those tools and can call them before it answers. A run makes at most 25 calls and receives at most 256 KiB of results; a call that would exceed a bound, sends more than 16 KiB of arguments or repeats an earlier call a third time is refused, and the model is told why. Once the calls end, the model answers from what it has, without the tools. Each call appears in the run's history, with the server and tool, the size of its arguments and result, and how it ended. -A run whose function names a tool the brain's servers do not offer, or whose server cannot be reached, is `unavailable` before the model is called. After a tool call, an `unavailable` ending has the kind `tools_unfinished`: a tool may already have changed an external system. The same execution id cannot run that work again and returns `tools_called`. A tool-using run still marked `started` also cannot restart under its id, even before its first recorded call. Inspect its history and any external effects before deliberately starting a new run with a new id. Retrying a successful run returns its recorded result without calling tools again. +A run whose function names a tool the brain's servers do not offer, or whose server cannot be reached, is `unavailable` before the model is called. After a tool call, an `unavailable` ending has the kind `tools_unfinished`: a tool may already have changed an external system. The same run id cannot run that work again and returns `tools_called`. A tool-using run still marked `started` also cannot restart under its id, even before its first recorded call. Inspect its history and any external effects before deliberately starting a new run with a new id. Retrying a successful run returns its recorded result without calling tools again. Tool access is available in a self-hosted runtime whose operator configures MCP servers; Auto Cloud does not offer it yet. The operator configures the servers with `mcp_servers` and narrows the tools of each with its `allowed`, as the repository's [configuration guide](https://github.com/BeOnAuto/auto-brain/blob/main/docs/engineering/self-host/configuration.md#mcp-servers) describes. See [Tool access inside a reasoning function](../concepts/functions.md#tool-access-inside-a-reasoning-function). diff --git a/docs/reference/recall-format.md b/docs/reference/recall-format.md index 8f38a4cfe..197266795 100644 --- a/docs/reference/recall-format.md +++ b/docs/reference/recall-format.md @@ -2,7 +2,7 @@ # Recall function format -The API stores a recall function as a `recollection` spec. Its source document names the events of the brain it folds, the view they fold into and how that view starts, and holds a fold, written in jq, that takes the view and one event and answers the next view. The runtime keeps the view as the brain records events, from the first event of its history on, and a run answers from the view as it stands, applying the function's `answer` to it. Use one so that a brain remembers what it decided before: the reviews of each campaign, the latest verdict per region, the refusals of a quarter. +The API stores a recall function as a definition of the type `recall`. Its source document names the events of the brain it folds, the view they fold into and how that view starts, and holds a fold, written in jq, that takes the view and one event and answers the next view. The runtime keeps the view as the brain records events, from the first event of its history on, and a run answers from the view as it stands, applying the function's `answer` to it. Use one so that a brain remembers what it decided before: the reviews of each campaign, the latest verdict per region, the refusals of a quarter. The view is a function of the brain's events alone. Nothing a run passes in is kept, no run changes it, and the same history folds to the same view on every server and either store. @@ -19,8 +19,8 @@ description: The reviews of each campaign, latest last, as the review-brief func language: jq source: events: - - type: execution_succeeded - subject: inference/review-brief + - type: run_succeeded + subject: reasoning/review-brief view: initial: {} schema: @@ -55,7 +55,7 @@ The fold runs once for every run of `review-brief` that succeeds, whatever the m A fold that raises an error on an ordinary output stops its view at that event, and so does a view that outgrows its bound or its schema; see [When a view stalls](#when-a-view-stalls). Guard the fold, and bound the view, before you save it. -For this document, `create_spec` takes `primitive: "recollection"`, a function `name` such as `campaign-reviews`, and the document as `source`. `execute_spec` takes the same primitive and name, with the `campaign` and optionally how many reviews to answer, `last`, in the `input` object. Both operations also require the brain id unless the MCP connection is scoped to that brain. +For this document, `create_definition` takes `type: "recall"`, a function `name` such as `campaign-reviews`, and the document as `source`. `run_definition` takes the same type and name, with the `campaign` and optionally how many reviews to answer, `last`, in the `input` object. Both operations also require the brain id unless the MCP connection is scoped to that brain. After three runs of `review-brief`, the view is: @@ -65,19 +65,19 @@ After three runs of `review-brief`, the view is: { "at": "2026-09-30T14:02:11.000Z", "verdict": "approve", - "run": "/executions/0199a3c4-7d2e-7c1a-9b3f-2f1e0d9c8b71" + "run": "/runs/0199a3c4-7d2e-7c1a-9b3f-2f1e0d9c8b71" } ], "spring-sale": [ { "at": "2026-10-01T09:15:42.000Z", "verdict": "reject: the budget is not stated", - "run": "/executions/0199a3c4-7d2e-7c1a-9b3f-2f1e0d9c8b72" + "run": "/runs/0199a3c4-7d2e-7c1a-9b3f-2f1e0d9c8b72" }, { "at": "2026-10-03T16:40:05.000Z", "verdict": "approve", - "run": "/executions/0199a3c4-7d2e-7c1a-9b3f-2f1e0d9c8b73" + "run": "/runs/0199a3c4-7d2e-7c1a-9b3f-2f1e0d9c8b73" } ] } @@ -96,7 +96,7 @@ the run succeeds with this output: { "at": "2026-10-03T16:40:05.000Z", "verdict": "approve", - "run": "/executions/0199a3c4-7d2e-7c1a-9b3f-2f1e0d9c8b73" + "run": "/runs/0199a3c4-7d2e-7c1a-9b3f-2f1e0d9c8b73" } ] ``` @@ -110,8 +110,8 @@ description: What the post-announcement function answered, oldest first, the las language: jq source: events: - - type: execution_succeeded - subject: inference/post-announcement + - type: run_succeeded + subject: reasoning/post-announcement view: initial: [] schema: { type: array, maxItems: 50 } @@ -120,7 +120,7 @@ view: | .[-50:] ``` -The filter writes out the type and the subject, `inference/post-announcement` for the runs of that reasoning function. An output larger than 8 KiB as JSON, or too large for its event, is kept as `null`, so 50 entries stay under the 512 KiB a view may take, and `get_execution` of the run an entry names reads the whole output. Every run of `post-announcement` is in the brain's history whoever started it, so this view misses none, where a log that workflows write into misses each run that does not write to it. +The filter writes out the type and the subject, `reasoning/post-announcement` for the runs of that reasoning function. An output larger than 8 KiB as JSON, or too large for its event, is kept as `null`, so 50 entries stay under the 512 KiB a view may take, and `get_run` of the run an entry names reads the whole output. Every run of `post-announcement` is in the brain's history whoever started it, so this view misses none, where a log that workflows write into misses each run that does not write to it. ## Fields @@ -141,11 +141,11 @@ Unknown fields are rejected, among them the fields of a reasoning function that A recall function folds the events of its own brain, in the order the brain recorded them, from the first: -| Events | `source` | `subject` | `data` | -| ------------------------------------------------------------------------------------ | --------------------------- | -------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `execution_started`, `execution_succeeded`, `execution_rejected`, `execution_failed` | `/executions/` | `/` | `primitive`, `name`, `version` and `caller`; on a success also `output`, or `output_bytes`, its size, when the output would make the event larger than 240 KiB | -| `spec_created`, `spec_updated`, `spec_retired` | `/specs//` | none | `primitive`, `name`, `caller`, and `version` unless retired | -| An event published to the brain, of any other type | as its publisher gave it | as given | as given; see [Publishing events](http.md#publishing-events) | +| Events | `source` | `subject` | `data` | +| ---------------------------------------------------------------- | ---------------------------- | --------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `run_started`, `run_succeeded`, `run_rejected`, `run_failed` | `/runs/` | `/` | `definition_type`, `name`, `version` and `caller`; on a success also `output`, or `output_bytes`, its size, when the output would make the event larger than 240 KiB | +| `definition_created`, `definition_updated`, `definition_retired` | `/definitions//` | none | `definition_type`, `name`, `caller`, and `version` unless retired | +| An event published to the brain, of any other type | as its publisher gave it | as given | as given; see [Publishing events](http.md#publishing-events) | Each event is a CloudEvent with its `id`, `type`, `source`, `time`, the time it happened as the brain recorded it, and its `data`; an event the brain recorded as the effect of another names that one in `causationid`, and the run at the top of its chain in `correlationid`. A recall function never folds the events of its own runs. An event the runtime cannot read, such as one whose data nests deeper than 512 levels, is passed over and reported to the operator once. @@ -155,7 +155,7 @@ A filter names the events it takes: | --------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `type` | Required; exactly this type, written out | | `source` | Optional; exactly this source, written out | -| `subject` | Optional; exactly this subject, written out, such as `inference/review-brief` for the runs of one reasoning function | +| `subject` | Optional; exactly this subject, written out, such as `reasoning/review-brief` for the runs of one reasoning function | | `data` | Optional; data equal to this value, or, as an expression such as `'${ .revenue > 100 }'`, data for which it is true; the expression reads the event's data as `.` and nothing else | An event is folded when it matches any of the filters, and once only. Any other key is rejected, and so are a computed `type`, `source` or `subject` and a `data` expression that names a variable. @@ -194,9 +194,9 @@ A run never waits for the view: it answers from what the brain has folded so far | `view.last_event` | The `id` and `time` of the last event the view folded, `null` before the first | | `view.folded` | How many events the view has folded | -The record also holds `language`, `work`, the units of work the answer spent, `duration_ms`, and `input_bytes` and `output_bytes`. `get_execution` returns it with the output. +The record also holds `language`, `work`, the units of work the answer spent, `duration_ms`, and `input_bytes` and `output_bytes`. `get_run` returns it with the output. -`get_spec` of a recall function adds its view's `standing`: +`get_definition` of a recall function adds its view's `standing`: | Field | What it says | | ----------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------- | @@ -255,7 +255,7 @@ Work is counted as for a computation function, so the same fold spends the same ## In a workflow -A workflow calls a recall function as it calls any function, with `call: execute_spec` and `primitive: recollection`, and the task's output is the run's output, typically passed to a reasoning function whose prompt reads it as any input; see [Calling a function](workflow-format.md#calling-a-function). A recall function reaches nothing outside the brain, so a workflow may run it again freely. +A workflow calls a recall function as it calls any function, with `call: run_definition` and `type: recall`, and the task's output is the run's output, typically passed to a reasoning function whose prompt reads it as any input; see [Calling a function](workflow-format.md#calling-a-function). A recall function reaches nothing outside the brain, so a workflow may run it again freely. A view follows its brain by up to a pass and a page's folds, so a workflow that recalls what it recorded a moment before may not see it yet, and a step sees only the output, not the checkpoint. A run rejected with `conflict`, of the kind `stalled` or `unworkable`, raises a `runtime` error with status 409 and that `kind`; running it again gives the same answer, so a retry policy should not match it. A view still being built raises status 503, which a retry may resolve. @@ -312,9 +312,9 @@ do: - recall: try: - remember: - call: execute_spec + call: run_definition with: - primitive: recollection + type: recall name: campaign-reviews input: { campaign: '${ .campaign }', last: 20 } catch: @@ -327,20 +327,20 @@ do: export: as: '${ { reviews: . } }' - advise: - call: execute_spec + call: run_definition with: - primitive: inference + type: reasoning name: advise-on-campaign input: { reviews: '${ $context.reviews }' } - tally: - call: execute_spec + call: run_definition with: - primitive: computation + type: computation name: tally-verdicts input: { reviews: '${ $context.reviews }', advice: '${ . }' } ``` -`execute_spec` of `decide-on-campaign` takes an input such as `{"campaign": "spring-sale"}`. When the view is still being built, the `recall` task tries again up to three more times; when the view has stalled, the run ends `rejected` at once, and its history shows the recall function's run with the stall. +`run_definition` of `decide-on-campaign` takes an input such as `{"campaign": "spring-sale"}`. When the view is still being built, the `recall` task tries again up to three more times; when the view has stalled, the run ends `rejected` at once, and its history shows the recall function's run with the stall. ## Availability diff --git a/docs/reference/workflow-format.md b/docs/reference/workflow-format.md index 956d07166..b54f18ec0 100644 --- a/docs/reference/workflow-format.md +++ b/docs/reference/workflow-format.md @@ -2,7 +2,7 @@ # Workflow format -The API stores a workflow as an `orchestration` spec. Its source document is YAML written in the [Open Workflow Specification](https://github.com/open-workflow-specification/specification) DSL 1.0, within the rules and limits on this page. [Build your first workflow](../tutorials/first-workflow.md) provides a guided example, and [Workflows and runs](../concepts/workflows.md) explains how a run starts, waits and ends. +The API stores a workflow as a definition of the type `workflow`. Its source document is YAML written in the [Open Workflow Specification](https://github.com/open-workflow-specification/specification) DSL 1.0, within the rules and limits on this page. [Build your first workflow](../tutorials/first-workflow.md) provides a guided example, and [Workflows and runs](../concepts/workflows.md) explains how a run starts, waits and ends. ## A workflow document @@ -34,9 +34,9 @@ do: - review: try: - review-brief: - call: execute_spec + call: run_definition with: - primitive: inference + type: reasoning name: review-campaign-brief input: brief: ${ .brief } @@ -91,7 +91,7 @@ do: review: ${ .review } ``` -For this document, `create_spec` takes `primitive: "orchestration"`, a workflow `name` and the document as `source`. `execute_spec` takes the same primitive and name, with `brief` in the `input` object. The run reviews the brief, then waits. Sending it the event `com.example.brief.decided` with `data` of `{"approved": true}` ends it with `approved: true` and the review. A decision of `{"approved": false, "reason": "..."}` ends it with that reason, and no decision within seven days ends it with the reason `No decision within a week`. +For this document, `create_definition` takes `type: "workflow"`, a workflow `name` and the document as `source`. `run_definition` takes the same type and name, with `brief` in the `input` object. The run reviews the brief, then waits. Sending it the event `com.example.brief.decided` with `data` of `{"approved": true}` ends it with `approved: true` and the review. A decision of `{"approved": false, "reason": "..."}` ends it with that reason, and no decision within seven days ends it with the reason `No decision within a week`. ## Document fields @@ -112,7 +112,7 @@ For this document, `create_spec` takes `primitive: "orchestration"`, a workflow The runtime does not check a run's input or output against these schemas; they tell callers what the workflow takes and gives. A run whose input lacks a value still starts, and a step that depends on the value fails: a reasoning function, for example, rejects input that does not match its own schema. Schemas must be written inline under `document`, as JSON Schema. -`document.version` is part of the document you write. The definition's `version` counts saved changes: it is 1 when the workflow is created and increases each time `update_spec` changes the document. +`document.version` is part of the document you write. The definition's `version` counts saved changes: it is 1 when the workflow is created and increases each time `update_definition` changes the document. ## Triggers @@ -135,18 +135,18 @@ schedule: every: PT15M ``` -A trigger is identified by its kind, `event`, `cron` or `every`, and its place in the document, `/schedule/on`, `/schedule/cron` or `/schedule/every`. `get_spec` and `list_specs` show a saved workflow's triggers in that form as `triggers`, and a run a trigger started names its trigger on its `execution_started` event, whose words say which kind started it, such as "was started by its cron schedule". No trigger has an input of its own: a run's input is the list holding the event or the due time, so a workflow with both kinds tells them apart with `input.from` or a `switch`: +A trigger is identified by its kind, `event`, `cron` or `every`, and its place in the document, `/schedule/on`, `/schedule/cron` or `/schedule/every`. `get_definition` and `list_definitions` show a saved workflow's triggers in that form as `triggers`, and a run a trigger started names its trigger on its `run_started` event, whose words say which kind started it, such as "was started by its cron schedule". No trigger has an input of its own: a run's input is the list holding the event or the due time, so a workflow with both kinds tells them apart with `input.from` or a `switch`: ```yaml input: from: '${ if type == "array" then { month: .[0].data.month } else { due: .schedule.due } end }' ``` -`on.one` takes one filter and `on.any` a list of at least one and at most 64; a run starts when any of them matches. A filter names the `type` of its events as written text, and may name `source` and `subject` as written text and `data` as a value or as an expression over the event alone, such as `data: '${ .region == "eu" }'`; an expression cannot use the variables of a run, such as `$workflow`, since no run exists yet. A filter is written once: a filter of `any` whose `type` and attributes an earlier one has, in any order, is refused. An expression that fails on an event does not match it, and the brain records that once for each version. A filter matches every event the brain records: events published with `publish_event`, events workflows emit, and the brain's own facts, such as `execution_succeeded`, whose `source` is `/executions/` and whose `subject` names the definition, as `inference/summarize`. A run's facts carry the trigger that started it in their data, so a filter can test it, as `data: '${ .trigger.kind == "event" }'`. +`on.one` takes one filter and `on.any` a list of at least one and at most 64; a run starts when any of them matches. A filter names the `type` of its events as written text, and may name `source` and `subject` as written text and `data` as a value or as an expression over the event alone, such as `data: '${ .region == "eu" }'`; an expression cannot use the variables of a run, such as `$workflow`, since no run exists yet. A filter is written once: a filter of `any` whose `type` and attributes an earlier one has, in any order, is refused. An expression that fails on an event does not match it, and the brain records that once for each version. A filter matches every event the brain records: events published with `publish_event`, events workflows emit, and the brain's own facts, such as `run_succeeded`, whose `source` is `/runs/` and whose `subject` names the definition, as `reasoning/summarize`. A run's facts carry the trigger that started it in their data, so a filter can test it, as `data: '${ .trigger.kind == "event" }'`. `cron` has the five fields minute, hour, day of month, month and day of week, read in UTC; when both day of month and day of week are restricted, a day that matches either is due. `every` is a [duration](#durations) of at least a minute, counted from when the trigger was saved as it is. -A trigger applies from the moment it is saved as it is: nothing recorded before is matched. The saving itself is the first thing it can match, so a trigger that names `spec_created` also starts on the fact of its own workflow's definition being saved, which a `data` filter on the definition's name, such as `data: '${ .name != "close-month" }'`, leaves out. A new version's triggers are compared with those of the version before, by kind and place: a trigger it leaves unchanged goes on as it was, with its times and its running run, and starts the new version from then on; a trigger it changes or adds applies from the new version's saving, and a changed schedule counts its times from then; a trigger it removes stops. Saving a version without a schedule, or retiring the workflow, stops every trigger. A run a trigger starts uses the version current when the event was recorded or the time came, and its `execution_id` is derived from the workflow, that version, the trigger and the event or the due time, so an event or a time starts it once. +A trigger applies from the moment it is saved as it is: nothing recorded before is matched. The saving itself is the first thing it can match, so a trigger that names `definition_created` also starts on the fact of its own workflow's definition being saved, which a `data` filter on the definition's name, such as `data: '${ .name != "close-month" }'`, leaves out. A new version's triggers are compared with those of the version before, by kind and place: a trigger it leaves unchanged goes on as it was, with its times and its running run, and starts the new version from then on; a trigger it changes or adds applies from the new version's saving, and a changed schedule counts its times from then; a trigger it removes stops. Saving a version without a schedule, or retiring the workflow, stops every trigger. A run a trigger starts uses the version current when the event was recorded or the time came, and its `run_id` is derived from the workflow, that version, the trigger and the event or the due time, so an event or a time starts it once. A run a trigger starts acts as the brain itself: its `started_by` is `brain:` and the brain's name, and each step acts with read and write access to that brain and nothing else. It never acts for a person, so no key's permissions or revocation affect it. @@ -165,19 +165,19 @@ What a trigger did not start is recorded in the brain as a `reaction_refused` ev Each item of a task list is a mapping with one key, the task's name. Its value defines the task, and the kind of task is the key the definition contains. A task is one step of the workflow. -| Task | Fields | Output | -| -------------------- | ------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------- | -| `call: execute_spec` | `with.primitive`, `with.name`, optional `with.input` | The output of the function's run | -| `set` | A template | The template, with its expressions evaluated | -| `do` | A task list | The output of the list | -| `switch` | A list of named cases, each with an optional `when` and a `then` | Its input, unchanged | -| `for` | `for.in`, optional `for.each` and `for.at`, optional `while`, and `do` | The output of the last iteration | -| `fork` | `fork.branches`, a task list, and optional `fork.compete` | A list of the branches' outputs, or the first output with `compete: true` | -| `try` | `try`, a task list, and `catch` with optional `errors.with`, `as`, `when`, `exceptWhen`, `retry`, `do` | The output of `try`, or of the recovery | -| `raise` | `raise.error`: an error, or the name of one in `use.errors` | None; it raises the error | -| `wait` | A duration | Its input, unchanged | -| `listen` | `listen.to` with `one`, `any` or `all`, and optional `listen.read` | A list with the data of each event it took | -| `emit` | `emit.event.with`, the event's attributes, with `type` and `source` | Its input, unchanged | +| Task | Fields | Output | +| ---------------------- | ------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------- | +| `call: run_definition` | `with.type`, `with.name`, optional `with.input` | The output of the function's run | +| `set` | A template | The template, with its expressions evaluated | +| `do` | A task list | The output of the list | +| `switch` | A list of named cases, each with an optional `when` and a `then` | Its input, unchanged | +| `for` | `for.in`, optional `for.each` and `for.at`, optional `while`, and `do` | The output of the last iteration | +| `fork` | `fork.branches`, a task list, and optional `fork.compete` | A list of the branches' outputs, or the first output with `compete: true` | +| `try` | `try`, a task list, and `catch` with optional `errors.with`, `as`, `when`, `exceptWhen`, `retry`, `do` | The output of `try`, or of the recovery | +| `raise` | `raise.error`: an error, or the name of one in `use.errors` | None; it raises the error | +| `wait` | A duration | Its input, unchanged | +| `listen` | `listen.to` with `one`, `any` or `all`, and optional `listen.read` | A list with the data of each event it took | +| `emit` | `emit.event.with`, the event's attributes, with `type` and `source` | Its input, unchanged | Every task also accepts these fields: @@ -206,11 +206,11 @@ A `switch` tests its cases in order and follows the `then` of the first case who ### Calling a function -`call: execute_spec` runs the active latest version of another definition in the same brain: `with.primitive` names its type, `inference` for a reasoning function, `interaction` for an [interaction function](interaction-format.md), `computation` for a [computation function](computation-format.md), `recollection` for a [recall function](recall-format.md) or `orchestration` for another workflow, and `with.name` the definition. `with.input` is a template for its input, `{}` when left out. The task's output is that run's output: the text or JSON value a reasoning function answered, the answer an interaction function's request took, the value a computation function's program gave, what a recall function answered from its view, or the output of the workflow it ran. +`call: run_definition` runs the active latest version of another definition in the same brain: `with.type` names its type, `reasoning` for a reasoning function, `interaction` for an [interaction function](interaction-format.md), `computation` for a [computation function](computation-format.md), `recall` for a [recall function](recall-format.md) or `workflow` for another workflow, and `with.name` the definition. `with.input` is a template for its input, `{}` when left out. The task's output is that run's output: the text or JSON value a reasoning function answered, the answer an interaction function's request took, the value a computation function's program gave, what a recall function answered from its view, or the output of the workflow it ran. -Each time the task runs, including on a retry, it starts a separate run of the function, recorded under its own `execution_id`. That run acts for the caller who started the workflow, with the permissions that caller had when the workflow started. `execute_spec` takes no arguments other than `primitive`, `name` and `input`. +Each time the task runs, including on a retry, it starts a separate run of the function, recorded under its own `run_id`. That run acts for the caller who started the workflow, with the permissions that caller had when the workflow started. `run_definition` takes no arguments other than `type`, `name` and `input`. -A run of a reasoning, computation or recall function finishes within the call that starts it. A run of a workflow or of an interaction function finishes later, and the task waits for it: the run records the task it answers, and its ending, whenever it comes and on whichever server it is recorded, answers the task. While the task waits, it holds none of the calls the server runs at once, and a restart of the server does not start the run again. A task waits for a run as long as a run of that definition may take, plus a minute, and never past the longest the workflow itself may still run: a workflow may take the longest a run may last, an interaction function the `expires` of its document, a reasoning function its model's deadline or, when it names tools, the bound of its tool loop, and a computation or recall function ten seconds. That is settled when the workflow starts, for each task that names its definition as written; a task whose `with.primitive` or `with.name` is an expression, or a definition saved after the workflow started, waits as long as the longest run of any function other than a workflow, plus a minute. When the wait passes, the task raises a `timeout` error, status 408, and the run it waited for is cancelled with the kind `deadline`; an ending that comes later answers nothing. +A run of a reasoning, computation or recall function finishes within the call that starts it. A run of a workflow or of an interaction function finishes later, and the task waits for it: the run records the task it answers, and its ending, whenever it comes and on whichever server it is recorded, answers the task. While the task waits, it holds none of the calls the server runs at once, and a restart of the server does not start the run again. A task waits for a run as long as a run of that definition may take, plus a minute, and never past the longest the workflow itself may still run: a workflow may take the longest a run may last, an interaction function the `expires` of its document, a reasoning function its model's deadline or, when it names tools, the bound of its tool loop, and a computation or recall function ten seconds. That is settled when the workflow starts, for each task that names its definition as written; a task whose `with.type` or `with.name` is an expression, or a definition saved after the workflow started, waits as long as the longest run of any function other than a workflow, plus a minute. When the wait passes, the task raises a `timeout` error, status 408, and the run it waited for is cancelled with the kind `deadline`; an ending that comes later answers nothing. Workflows that call workflows reach at most 8 calls deep: the run that would sit a ninth call below the workflow at the top is refused as a `conflict`, which the calling task raises as a `runtime` error. The runs under one workflow at the top of a tree wait for at most 1,000 calls at once, a limit the deployment can change; the next call is refused in the same way. @@ -237,7 +237,7 @@ A computation function's run that is rejected with `conflict` has the kind `unwo A recall function's run that is rejected with `conflict` has the kind `stalled`, when its view stopped at an event its fold could not take, or `unworkable`, when its answer could not give an output; neither changes on a retry, so leave 409 out of a retry policy as well. While its view is still being built, its run is `unavailable` with the kind `rebuilding`, status 503, which a retry after a few seconds may resolve. A recall function answers from what its view has folded so far, so a step may not see an event recorded a moment before. [Recall function format](recall-format.md#in-a-workflow) has a workflow that recalls, reasons and computes. -A run that was cancelled raises the [problem type](http.md#responses-and-errors) `https://on.auto/problems/cancelled` with the kind of its cancellation: `requested` when someone cancelled it with `cancel_execution`, `deadline` when what waited for it ran out of time, `overrun` when it ran as long as a workflow may, and `parent_ended` when the run that waited for it ended first. No retry policy matches it unless it names that type, so a workflow catches a cancellation only on purpose, as with `errors: { with: { type: https://on.auto/problems/cancelled } }` and `when: '${ $error.kind == "requested" }'`. A workflow that does not catch it is rejected by the error's status, 409, as `invalid_input`, and not as `cancelled`, since the workflow itself was not cancelled. +A run that was cancelled raises the [problem type](http.md#responses-and-errors) `https://on.auto/problems/cancelled` with the kind of its cancellation: `requested` when someone cancelled it with `cancel_run`, `deadline` when what waited for it ran out of time, `overrun` when it ran as long as a workflow may, and `parent_ended` when the run that waited for it ended first. No retry policy matches it unless it names that type, so a workflow catches a cancellation only on purpose, as with `errors: { with: { type: https://on.auto/problems/cancelled } }` and `when: '${ $error.kind == "requested" }'`. A workflow that does not catch it is rejected by the error's status, 409, as `invalid_input`, and not as `cancelled`, since the workflow itself was not cancelled. A request of an [interaction function](interaction-format.md) that nobody answered raises the problem type `https://on.auto/problems/unanswered`, status 410, with the kind `expired` when it expired unanswered or `undelivered` when a notification could not be delivered. No retry policy matches it unless it names that type, as with `errors: { with: { type: https://on.auto/problems/unanswered, kind: expired } }`, since asking again is a decision. A workflow that does not catch it is rejected as `unanswered` with the same kind. @@ -245,7 +245,7 @@ The error's `title` names the definition and, for a rejection, its reason; its ` ### Waiting for events -A `listen` task waits for events sent to the run with `send_execution_event`, through [HTTP](http.md) or [MCP](mcp.md), and, through a filter that names its `type` as written text, for the events of the whole brain: +A `listen` task waits for events sent to the run with `send_run_event`, through [HTTP](http.md) or [MCP](mcp.md), and, through a filter that names its `type` as written text, for the events of the whole brain: - `listen.to.one` takes one event that matches its filter. - `listen.to.any` takes the first event that matches any filter in its list. @@ -257,7 +257,7 @@ The task's output is a list with the `data` of each event it took. With `listen. An event sent before a `listen` task waits for it is kept, and the task takes the earliest event that matches. An event whose `id` the run has already received is ignored, so a sender can retry with the same id. Waiting `until` a condition, `foreach` and `correlate` are not supported. -A filter whose `type` is written out, such as `type: com.example.brief.decided`, also hears the events the brain records: events published with `publish_event`, events workflows emit, and the brain's own facts. Such an event reaches the run only while the task listens; one recorded before the task began to listen, or after it ended, is not offered to it. The run checks the rest of its filter itself, with its own variables, so `data: '${ .ticket == $workflow.input.ticket }'` takes only the event about the run's own ticket. With `all`, the events may come in any order. A filter that computes its `type` hears only events sent to the run. A run never takes an event it emitted itself. Send an event to the run by its execution id when the run must not miss it. A brain has at most 4,096 tasks listening for its events at once; one more hears only events sent to its run. +A filter whose `type` is written out, such as `type: com.example.brief.decided`, also hears the events the brain records: events published with `publish_event`, events workflows emit, and the brain's own facts. Such an event reaches the run only while the task listens; one recorded before the task began to listen, or after it ended, is not offered to it. The run checks the rest of its filter itself, with its own variables, so `data: '${ .ticket == $workflow.input.ticket }'` takes only the event about the run's own ticket. With `all`, the events may come in any order. A filter that computes its `type` hears only events sent to the run. A run never takes an event it emitted itself. Send an event to the run by its run id when the run must not miss it. A brain has at most 4,096 tasks listening for its events at once; one more hears only events sent to its run. ### Emitting an event @@ -331,15 +331,15 @@ This task reviews two briefs at once and outputs both reviews: fork: branches: - first: - call: execute_spec + call: run_definition with: - primitive: inference + type: reasoning name: review-campaign-brief input: { brief: '${ .brief }' } - second: - call: execute_spec + call: run_definition with: - primitive: inference + type: reasoning name: review-campaign-brief input: { brief: '${ .revised }' } ``` @@ -348,17 +348,17 @@ This task reviews two briefs at once and outputs both reviews: Expressions are [jq](https://jqlang.org). A string enclosed in `${ }` is an expression wherever a value is written, including in templates: the values of `set`, `with`, `raise.error`, durations, and object forms of `input.from`, `output.as` and `export.as`. `if`, `when`, `exceptWhen`, `for.in`, `while` and the string forms of `input.from`, `output.as` and `export.as` are expressions even without `${ }`. A condition holds unless it gives `false` or `null`. -| Variable | Value | -| ----------------- | ----------------------------------------------------------------------------------------- | -| `.` | The data of the task: its input, or what it produced in `output.as` | -| `$input` | The task's input | -| `$output` | The task's output, in `export.as` | -| `$context` | What earlier tasks exported; `{}` until a task exports | -| `$task` | `name`, `reference`, `definition`, `input`, `startedAt`, and `output` after it ran | -| `$workflow` | `id` (the run's `execution_id`), `definition`, `input` (before `input.from`), `startedAt` | -| `$runtime` | `name: auto-brain`, `version` and `metadata.primitive: orchestration` | -| `$item`, `$index` | The current item and position in a `for` loop, unless renamed | -| `$error` | The caught error in `catch`, unless renamed with `catch.as` | +| Variable | Value | +| ----------------- | ----------------------------------------------------------------------------------- | +| `.` | The data of the task: its input, or what it produced in `output.as` | +| `$input` | The task's input | +| `$output` | The task's output, in `export.as` | +| `$context` | What earlier tasks exported; `{}` until a task exports | +| `$task` | `name`, `reference`, `definition`, `input`, `startedAt`, and `output` after it ran | +| `$workflow` | `id` (the run's `run_id`), `definition`, `input` (before `input.from`), `startedAt` | +| `$runtime` | `name: auto-brain`, `version` and `metadata.type: workflow` | +| `$item`, `$index` | The current item and position in a `for` loop, unless renamed | +| `$error` | The caught error in `catch`, unless renamed with `catch.as` | `startedAt` values hold `iso8601` and `epoch` with `seconds` and `milliseconds`. `now` gives the time the run recorded for the task, never the clock of the machine. `localtime` and `strflocaltime`, which read the machine's time zone, are refused; use the UTC builtins. @@ -376,7 +376,7 @@ A duration is an ISO 8601 string such as `PT30M` or `P7D`, in weeks, days, hours ## Saving a document -`create_spec` and `update_spec` check the whole document before saving it: its YAML, the DSL schema, the connections between its tasks, every expression and duration, and the rules on this page. Anchors, aliases and tags are refused. Each problem is an issue under `/source` with its line, column and JSON Pointer: +`create_definition` and `update_definition` check the whole document before saving it: its YAML, the DSL schema, the connections between its tasks, every expression and duration, and the rules on this page. Anchors, aliases and tags are refused. Each problem is an issue under `/source` with its line, column and JSON Pointer: ```text Line 7, column 12: at /do/1/loop/for: It needs in @@ -388,7 +388,7 @@ These are refused when a document is saved: | ---------------------------------------------------------------------------------------------- | --------------------------------------------------------------- | | `run` tasks | The runtime does not run them | | `call` of `http`, `grpc`, `openapi`, `asyncapi`, `a2a` or `mcp` | A workflow reaches the world only through its brain's functions | -| A `call` of anything other than `execute_spec` | `execute_spec` is the one function | +| A `call` of anything other than `run_definition` | `run_definition` is the one function | | `schedule.after`, `schedule.on.all`, `schedule.on.until`, or a schedule that names no trigger | A trigger starts one run for each event or time | | A trigger filter without a written `type`, or with a `data` expression that uses `$` variables | A trigger is matched before any run exists | | A trigger filter written twice in `any`, or more than 64 filters in one trigger | See [Triggers](#triggers) | @@ -405,18 +405,18 @@ These are refused when a document is saved: ## How a run ends -`execute_spec` answers `status: started` as soon as the run begins, or how the run ended when it ended before its first wait. `get_execution` shows the run as `started` until it ends: +`run_definition` answers `status: started` as soon as the run begins, or how the run ended when it ended before its first wait. `get_run` shows the run as `started` until it ends: - `succeeded`, with the run's `output`, when its last task completes or a task ends it. - `rejected`, when an error is not caught. The rejection's `reason` is `invalid_input` for an error with a 4xx status other than 408 and 429, and `unavailable` otherwise. Its `detail` gives the error's title, or else its type, then its detail and the task that raised it, such as `The brief is not usable: Missing: audience (at /do/0/stop)`. -- `rejected` with the reason `cancelled`, when it was cancelled: with the kind `requested` by `cancel_execution`, `deadline` when the workflow that waited for it ran out of time, `overrun` when it was still running at the longest a run may last, and `parent_ended` when the workflow that waited for it ended first, or the branch that waited for it lost a race. The `detail` says why, in the words of whoever cancelled it. +- `rejected` with the reason `cancelled`, when it was cancelled: with the kind `requested` by `cancel_run`, `deadline` when the workflow that waited for it ran out of time, `overrun` when it was still running at the longest a run may last, and `parent_ended` when the workflow that waited for it ended first, or the branch that waited for it lost a race. The `detail` says why, in the words of whoever cancelled it. - `rejected` with the reason `unanswered`, with the kind `expired` or `undelivered`, when it did not catch the error of a request of an interaction function that nobody answered. - `rejected` with the reason `conflict` of the kind `oversized`, when its output is larger than 1 MiB. - `failed`, when the run broke down inside the runtime. A timeout that is not caught therefore rejects the run as `unavailable`, and a call rejected with `invalid_input` that is not caught rejects it as `invalid_input`. -`cancel_execution` cancels a workflow run that has not ended, from any server: the run stops its tasks, cancels each run it waits for, with the kind `parent_ended`, and ends as `cancelled` within a moment, unless it ends first. It cancels the run of an interaction function whose request waits in the same way. A run that has ended, or a run of a function that finishes within its call, cannot be cancelled. +`cancel_run` cancels a workflow run that has not ended, from any server: the run stops its tasks, cancels each run it waits for, with the kind `parent_ended`, and ends as `cancelled` within a moment, unless it ends first. It cancels the run of an interaction function whose request waits in the same way. A run that has ended, or a run of a function that finishes within its call, cannot be cancelled. ## Limits diff --git a/docs/tutorials/first-brain.md b/docs/tutorials/first-brain.md index be0e0b645..a259c8071 100644 --- a/docs/tutorials/first-brain.md +++ b/docs/tutorials/first-brain.md @@ -39,7 +39,7 @@ Give the agent your confirmed model reference, then send this instruction: Review the proposal. It should use the four criteria above, require `brief`, and produce a text answer. Approve saving it once it matches. If a previous exercise left a different definition under the same name, ask the agent to show the proposed changes and approve them before updating it. -Ask the agent to read back the saved definition. You should see the name `review-campaign-brief` and a version. The MCP tool may call it an `inference` spec; that is the API name for a reasoning function. +Ask the agent to read back the saved definition. You should see the name `review-campaign-brief`, the type `reasoning` and a version. ## 3. Run an incomplete brief @@ -72,11 +72,11 @@ The wording can vary between models. The review should identify both gaps withou Ask: -> Read back the Auto run that produced this review. Show its execution id, function name, definition version, status, recorded prompt and output. +> Read back the Auto run that produced this review. Show its run id, function name, definition version, status, recorded prompt and output. -The run should name `review-campaign-brief`, have an `execution_id` and `spec_version`, and show `status: succeeded`. A successful run can recommend Revise: the review completed, even though the brief needs work. +The run should name `review-campaign-brief`, have a `run_id` and a `definition_version`, and show `status: succeeded`. A successful run can recommend Revise: the review completed, even though the brief needs work. -The recorded prompt should contain the sample brief. Keep the execution id so you can return to this result. If the agent cannot produce a recorded run, have it call the saved function and inspect that run before continuing. +The recorded prompt should contain the sample brief. Keep the run id so you can return to this result. If the agent cannot produce a recorded run, have it call the saved function and inspect that run before continuing. ## 5. Run the revised brief @@ -91,7 +91,7 @@ Total budget: USD 8,000 Success measure: 100 trial registrations ``` -This review should mark all four criteria as met and recommend Ready. Ask your agent to compare the two saved runs. They should have different execution ids, the same function name and the same definition version, with different briefs in the recorded prompts and different results. +This review should mark all four criteria as met and recommend Ready. Ask your agent to compare the two saved runs. They should have different run ids, the same function name and the same definition version, with different briefs in the recorded prompts and different results. Ready means the brief meets this exercise's four criteria. It is not approval to launch a campaign or a forecast of its performance. diff --git a/docs/tutorials/first-workflow.md b/docs/tutorials/first-workflow.md index a007dede0..a70958100 100644 --- a/docs/tutorials/first-workflow.md +++ b/docs/tutorials/first-workflow.md @@ -16,11 +16,11 @@ Your agent needs a connection with permission to create and run definitions in t Ask your connected agent: -> List the Auto tools you can call. Tell me whether `list_interactions` and `answer_interaction` are among them, and which values the `primitive` field of `create_spec` accepts. +> List the Auto tools you can call. Tell me whether `list_interactions` and `answer_interaction` are among them, and which values the `type` field of `create_definition` accepts. -The agent should report `list_interactions` and `answer_interaction` among the tools, and `inference`, `interaction` and `orchestration` among the accepted values of `primitive`. These are the API identifiers for reasoning functions, interaction functions and workflows. The tools also include `send_execution_event`, which sends a waiting run an event that is not the answer to a question. +The agent should report `list_interactions` and `answer_interaction` among the tools, and `reasoning`, `interaction` and `workflow` among the accepted values of `type`, the types of reasoning functions, interaction functions and workflows. The tools also include `send_run_event`, which sends a waiting run an event that is not the answer to a question. -If neither `create_spec` nor `list_interactions` is listed, the connection uses an organization endpoint, which offers brain management and model discovery only. Connect your agent to the runtime's `/mcp` endpoint or to the brain's own endpoint before continuing; [MCP endpoint scope](../reference/mcp.md#endpoint-scope) lists them. +If neither `create_definition` nor `list_interactions` is listed, the connection uses an organization endpoint, which offers brain management and model discovery only. Connect your agent to the runtime's `/mcp` endpoint or to the brain's own endpoint before continuing; [MCP endpoint scope](../reference/mcp.md#endpoint-scope) lists them. ## 2. Confirm the reasoning function @@ -34,7 +34,7 @@ You should see `review-campaign-brief`, its version, and `brief` as its required The interaction function asks one person for a decision. Its run renders the message from its input, leaves the request in the brain's inbox for the party named in `to`, and waits until someone answers, the request expires after two days, or the run is cancelled. The answer must match `output.schema`, and the answer is the run's output. -> In `campaign-review-tutorial`, create an interaction function named `approve-campaign-brief` from the document below. Use the document unchanged as the source, with the primitive `interaction`. If a function with that name already exists, show it to me instead of changing it. Then show me the saved function's version and description. +> In `campaign-review-tutorial`, create an interaction function named `approve-campaign-brief` from the document below. Use the document unchanged as the source, with the type `interaction`. If a function with that name already exists, show it to me instead of changing it. Then show me the saved function's version and description. ```markdown @@ -70,7 +70,7 @@ The agent should confirm the interaction function `approve-campaign-brief` at ve The workflow has two steps. `review-brief` runs the reasoning function on the brief the run starts with and keeps the owner beside the review. `ask-for-approval` runs the interaction function with the owner and the review, and waits for its answer. The run's output keeps the review and the approval. -> In `campaign-review-tutorial`, create a workflow named `review-and-approve` from the document below. Use the document unchanged as the source, with the primitive `orchestration`. If a workflow with that name already exists, show it to me instead of changing it. Then show me the saved workflow's version, description and required input. +> In `campaign-review-tutorial`, create a workflow named `review-and-approve` from the document below. Use the document unchanged as the source, with the type `workflow`. If a workflow with that name already exists, show it to me instead of changing it. Then show me the saved workflow's version, description and required input. ```yaml document: @@ -89,18 +89,18 @@ input: required: [brief, owner] do: - review-brief: - call: execute_spec + call: run_definition with: - primitive: inference + type: reasoning name: review-campaign-brief input: brief: ${ .brief } output: as: '${ { owner: $input.owner, review: . } }' - ask-for-approval: - call: execute_spec + call: run_definition with: - primitive: interaction + type: interaction name: approve-campaign-brief input: owner: ${ .owner } @@ -126,23 +126,23 @@ Total budget: USD 8,000 Success measure: Generate interest in the product ``` -> Run the workflow `review-and-approve` in `campaign-review-tutorial` with the brief above as its `brief` input and `ada@example.com` as its `owner`. Show me the run's execution id and status, and keep the execution id for later. +> Run the workflow `review-and-approve` in `campaign-review-tutorial` with the brief above as its `brief` input and `ada@example.com` as its `owner`. Show me the run's id and status, and keep the id for later. -The run should answer with an `execution_id` and `status: started`. The tool's summary reads: The workflow “review-and-approve” has started and is still running. It carries on by itself, and how it ends can be looked up later. +The run should answer with a `run_id` and `status: started`. The tool's summary reads: The workflow “review-and-approve” has started and is still running. It carries on by itself, and how it ends can be looked up later. The run reviews the brief, then asks for the approval and waits. Check it: -> Read the Auto run with that execution id and show its status. +> Read the Auto run with that run id and show its status. It should still show `status: started`, and the tool's summary reads: The workflow “review-and-approve” is still running; how it ends can be looked up again later. A run that waits for an answer stays started until the answer arrives. ## 6. Read the inbox -> List the open requests of `campaign-review-tutorial`. Show the execution id, the party, the message, the expiry and the standing of each. +> List the open requests of `campaign-review-tutorial`. Show the run id, the party, the message, the expiry and the standing of each. The agent should show one request, to `ada@example.com`, waiting in the inbox with no `delivery`, with the standing `in_inbox`, an `expires_at` two days ahead, and the message rendered from the review. The tool's summary reads: Found 1 request waiting on this page. -The request has an `execution_id` of its own: it is the run of the interaction function that the workflow started, and it is the id to answer. +The request has a `run_id` of its own: it is the run of the interaction function that the workflow started, and it is the id to answer. ## 7. Answer the request @@ -156,7 +156,7 @@ An answer that does not match the function's `output.schema`, such as the verdic > Read the workflow run again until its status is no longer `started`. Show its status, definition version, finish time and output. -The run should show `status: succeeded`, `spec_version: 1`, a `finished_at` time and an output with two fields: `review`, the reasoning function's review, and `approval`, the answer you gave, `{ "verdict": "approve", "note": "Approved for the autumn launch." }`. +The run should show `status: succeeded`, `definition_version: 1`, a `finished_at` time and an output with two fields: `review`, the reasoning function's review, and `approval`, the answer you gave, `{ "verdict": "approve", "note": "Approved for the autumn launch." }`. The review comes from your model, so its wording can vary. For this brief it should recommend Revise: the audience lacks a job role and type of company, and the success measure lacks a numeric target. The approval is the decision of whoever answered; the workflow records both. @@ -168,7 +168,7 @@ The tool's summary reads: Found 9 events in the history of the run, oldest first | Type | Summary | Steps that moved | | ------------------------ | ------------------------------------------------------------------------------ | ---------------------------------------------------------------- | -| `execution_started` | A run of the workflow “review-and-approve” started. | | +| `run_started` | A run of the workflow “review-and-approve” started. | | | `workflow_input_applied` | The workflow started, and 1 step moved. | `/do/0/review-brief` waiting | | `step_waiting` | The step “review brief” waits for a function it called. | | | `workflow_input_applied` | A function the workflow called answered, and 2 steps moved. | `/do/0/review-brief` completed; `/do/1/ask-for-approval` waiting | @@ -176,7 +176,7 @@ The tool's summary reads: Found 9 events in the history of the run, oldest first | `step_waiting` | The step “ask for approval” waits for a function it called. | | | `workflow_input_applied` | A function the workflow called answered, and 1 step moved; the workflow ended. | `/do/1/ask-for-approval` completed | | `step_finished` | The step “ask for approval” finished. | | -| `execution_succeeded` | A run finished. | | +| `run_succeeded` | A run finished. | | Each event also carries `causation_id`, the `id` of the event that led to it, so the run can be drawn as a graph; each `step_waiting` names the run of the function it started, among them the run of `approve-campaign-brief` that held the request. @@ -190,7 +190,7 @@ Its record shows `answered_by`, the caller that answered, and `answered_at`. The > Answer the same request again with the verdict `revise`, and show me the answer. Then list the open requests again. -The agent should report the answer refused with `conflict`, since the request was already answered with another answer, and the inbox empty: No request is waiting. The same answer again would answer the run as it stands. To ask again, start a new run of the workflow: it receives a new execution id and uses the same definition versions. +The agent should report the answer refused with `conflict`, since the request was already answered with another answer, and the inbox empty: No request is waiting. The same answer again would answer the run as it stands. To ask again, start a new run of the workflow: it receives a new run id and uses the same definition versions. ## What was tested diff --git a/package.json b/package.json index c684a6889..24bdbb5eb 100644 --- a/package.json +++ b/package.json @@ -30,7 +30,7 @@ "prepare": "lefthook install" }, "devDependencies": { - "@beonauto/specs": "workspace:*", + "@beonauto/definitions": "workspace:*", "@commitlint/cli": "21.2.3", "@commitlint/config-conventional": "21.2.3", "@commitlint/types": "21.2.3", diff --git a/packages/api/README.md b/packages/api/README.md index 2626c9eaa..2d96a48c6 100644 --- a/packages/api/README.md +++ b/packages/api/README.md @@ -64,35 +64,35 @@ All three sit behind the same chain as every other path. Before the SDK runs, a The HTTP method decides nothing. The server serves these, which `packages/server/src/mcp/served-tools.test.ts` checks against this table: -| Tool | `readOnlyHint` | `destructiveHint` | `idempotentHint` | `openWorldHint` | -| ----------------------- | -------------- | ----------------- | ---------------- | --------------- | -| `create_brain` | false | false | false | false | -| `list_brains` | true | false | true | false | -| `get_brain` | true | false | true | false | -| `update_brain` | false | false | true | false | -| `retire_brain` | false | true | true | false | -| `list_models` | true | false | true | true | -| `create_spec` | false | false | false | false | -| `list_specs` | true | false | true | false | -| `get_spec` | true | false | true | false | -| `update_spec` | false | false | true | false | -| `retire_spec` | false | true | true | false | -| `execute_spec` | false | false | false | true | -| `get_execution` | true | false | true | false | -| `cancel_execution` | false | true | true | false | -| `list_executions` | true | false | true | false | -| `get_execution_history` | true | false | true | false | -| `get_brain_analytics` | true | false | true | false | -| `list_brain_events` | true | false | true | false | -| `publish_event` | false | false | false | false | -| `list_tool_servers` | true | false | true | true | -| `test_tool_call` | false | false | false | true | -| `answer_interaction` | false | true | true | false | -| `list_interactions` | true | false | true | false | -| `send_execution_event` | false | false | false | false | -| `get_guide` | true | false | true | false | - -`execute_spec` is destructive while a tool server is configured, since a reasoning function's tools or the tool an interaction function sends through may then change something outside, and `test_tool_call` while an entry of `mcp_servers` marks a tool testable, since a tool its server does not mark read-only may then be tested; the table is the server without either, as the test serves it. `list_models` and `execute_spec` reach model providers, and `list_tool_servers` and `test_tool_call` reach the tool servers. +| Tool | `readOnlyHint` | `destructiveHint` | `idempotentHint` | `openWorldHint` | +| --------------------- | -------------- | ----------------- | ---------------- | --------------- | +| `create_brain` | false | false | false | false | +| `list_brains` | true | false | true | false | +| `get_brain` | true | false | true | false | +| `update_brain` | false | false | true | false | +| `retire_brain` | false | true | true | false | +| `list_models` | true | false | true | true | +| `create_definition` | false | false | false | false | +| `list_definitions` | true | false | true | false | +| `get_definition` | true | false | true | false | +| `update_definition` | false | false | true | false | +| `retire_definition` | false | true | true | false | +| `run_definition` | false | false | false | true | +| `get_run` | true | false | true | false | +| `cancel_run` | false | true | true | false | +| `list_runs` | true | false | true | false | +| `get_run_history` | true | false | true | false | +| `get_brain_analytics` | true | false | true | false | +| `list_brain_events` | true | false | true | false | +| `publish_event` | false | false | false | false | +| `list_tool_servers` | true | false | true | true | +| `test_tool_call` | false | false | false | true | +| `answer_interaction` | false | true | true | false | +| `list_interactions` | true | false | true | false | +| `send_run_event` | false | false | false | false | +| `get_guide` | true | false | true | false | + +`run_definition` is destructive while a tool server is configured, since a reasoning function's tools or the tool an interaction function sends through may then change something outside, and `test_tool_call` while an entry of `mcp_servers` marks a tool testable, since a tool its server does not mark read-only may then be tested; the table is the server without either, as the test serves it. `list_models` and `run_definition` reach model providers, and `list_tool_servers` and `test_tool_call` reach the tool servers. **Calls.** On a scoped endpoint the org, and the brain of a brain endpoint, come from the URL, and the arguments are the whole input; on `/mcp` the org is the caller's own and the brain an argument. The input is decoded with the `json` encoding. A call goes through the same dispatcher and the same `settle` as an HTTP request, with the caller in the URL's org. @@ -101,7 +101,7 @@ The HTTP method decides nothing. The server serves these, which `packages/server | succeeded | the first text content says in plain words what happened, the second is the output as JSON text, and `structuredContent` is the same output, with no output schema to check it against | | rejected, failed, cancelled | `isError: true`; the first text content says in plain words what could not be done, and the second is the problem document HTTP would answer with, as JSON; no `structuredContent` | -Human-readable results name reasoning functions, workflows and runs. They omit ids, versions, formats and status codes, which remain in the structured result. The tools take `primitive: inference` for reasoning functions and `primitive: orchestration` for workflows. `get_guide` serves the format of each definition type; no tool description carries one. The words of an outcome take at most 400 characters and those of a refusal at most 600: `withinCharacters` of `@beonauto/operations` keeps the whole sentences that fit and says the rest is in the details below, the JSON beside them. +Human-readable results name reasoning functions, workflows and runs. They omit ids, versions, formats and status codes, which remain in the structured result. `get_guide` serves the format of each definition type; no tool description carries one. The words of an outcome take at most 400 characters and those of a refusal at most 600: `withinCharacters` of `@beonauto/operations` keeps the whole sentences that fit and says the rest is in the details below, the JSON beside them. Each operation supplies `plainLanguage`: a `task` and an `attempt` describing the action, and an `outcome` based on its output. It may also give `remedies` of its own by `because`, which take the place of the shared remedy in the words of its refusals, as `test_tool_call` names what `list_tool_servers` shows where a function's refusal speaks of the function. Each adapter gives the noun for its definitions and a sentence for a run's result. An endpoint refuses to mount an operation without them. @@ -111,11 +111,11 @@ So invalid arguments are a tool result with `isError` and an `invalid_input` pro **Server identity.** `serverInfo` carries the name and version the server passes in, which are the product name and the release version. -**Instructions.** Every endpoint answers `initialize` with instructions that `instructionsFor` in `src/mcp/instructions.ts` builds per request from the tools the key is offered, the definition types of `McpServing`, which the server gives from the primitives it registers, each with the noun of its definitions and the name of its guide, and the recipes, each with the tools its steps call. So they never name a tool the connection does not list. They are the texts of [decision 0016](../../docs/decisions/0016-speaking-to-agents.md#3-the-instructions-per-endpoint): what a brain is, as [Brain terminology](../../docs/concepts/terminology.md) defines it; a sentence for each type the server runs; what the connection acts on; the one sentence that maps the wire names, where a listed tool carries one, leaving the value of each type to the `primitive` argument of the tools that take one; where the formats and the recipes are; how a reasoning function names its model and its tools, and, where `test_tool_call` is listed, that it shows what a tool answers; that `get_execution` shows whether a run that finishes later ended or still waits; that what the person answers to a run that waits goes to its request through `answer_interaction`, and no new run; that runs, history, events and requests come a page at a time; how to answer the person; and what a refusal says. For the server with every type and every tool, `/mcp` reads: +**Instructions.** Every endpoint answers `initialize` with instructions that `instructionsFor` in `src/mcp/instructions.ts` builds per request from the tools the key is offered, the definition types of `McpServing`, which the server gives from the capabilities it registers, each with the noun of its definitions and the name of its guide, and the recipes, each with the tools its steps call. So they never name a tool the connection does not list. They are the texts of [decision 0016](../../docs/decisions/0016-speaking-to-agents.md#3-the-instructions-per-endpoint): what a brain is, as [Brain terminology](../../docs/concepts/terminology.md) defines it; a sentence for each type the server runs; what the connection acts on; where the formats and the recipes are; how a reasoning function names its model and its tools, and, where `test_tool_call` is listed, that it shows what a tool answers; that `get_run` shows whether a run that finishes later ended or still waits; that what the person answers to a run that waits goes to its request through `answer_interaction`, and no new run; that runs, history, events and requests come a page at a time; how to answer the person; and what a refusal says. For the server with every type and every tool, `/mcp` reads: -> A brain is the complete system for a business responsibility. It holds the functions that do its work and the workflows that coordinate them, and it keeps every run with its result as its history. A reasoning function has a prompt and calls a language model. An interaction function asks a person or a system and takes the answer later. A computation function runs a program on its input and gives the same output every time. A recall function answers from what it keeps of the brain's own history: every run's start and end, with its result when it succeeded, the definitions saved and the events published to the brain, never a run's input or its tool calls, so nothing has to write into it. A workflow runs functions in steps, waits for input and can start on a schedule or on an event. This connection acts in the caller's own org: list_brains shows its brains and create_brain makes one. The tools call a definition a spec, a run an execution and a definition's type its primitive. Before writing a definition, read its format with get_guide, which also holds the recipes: first-brain, remember, give-tools and schedule. A reasoning function names a model that list_models lists and may name tools that list_tool_servers lists; test_tool_call shows what a tool answers. A run of an interaction function or a workflow answers started; get_execution shows whether it ended or still waits. When the person approves, rejects or otherwise answers what a run waits on, answer its request with answer_interaction, in the shape its function's answer takes, and start no new run for it. Runs, history, events and requests come a page at a time; read on only when the person needs more. When you tell the person what happened, say what was done and what they can do next, in the words of brains, functions, workflows and runs, not in the tools' names, fields or rules; give an id or a status only when the person needs it to act. A tool that cannot do what was asked says why and what to change. +> A brain is the complete system for a business responsibility. It holds the functions that do its work and the workflows that coordinate them, and it keeps every run with its result as its history. A reasoning function has a prompt and calls a language model. An interaction function asks a person or a system and takes the answer later. A computation function runs a program on its input and gives the same output every time. A recall function answers from what it keeps of the brain's own history: every run's start and end, with its result when it succeeded, the definitions saved and the events published to the brain, never a run's input or its tool calls, so nothing has to write into it. A workflow runs functions in steps, waits for input and can start on a schedule or on an event. This connection acts in the caller's own org: list_brains shows its brains and create_brain makes one. Before writing a definition, read its format with get_guide, which also holds the recipes: first-brain, remember, give-tools and schedule. A reasoning function names a model that list_models lists and may name tools that list_tool_servers lists; test_tool_call shows what a tool answers. A run of an interaction function or a workflow answers started; get_run shows whether it ended or still waits. When the person approves, rejects or otherwise answers what a run waits on, answer its request with answer_interaction, in the shape its function's answer takes, and start no new run for it. Runs, history, events and requests come a page at a time; read on only when the person needs more. When you tell the person what happened, say what was done and what they can do next, in the words of brains, functions, workflows and runs, not in the tools' names, fields or rules; give an id or a status only when the person needs it to act. A tool that cannot do what was asked says why and what to change. -On `/orgs/{org}/mcp` the connection "manages the brains of one org", whose functions and workflows are made on the brain's own connection, `/orgs/{org}/brains/{brain}/mcp`, and `get_guide` "holds what these words mean"; on `/orgs/{org}/brains/{brain}/mcp` it "acts inside one brain", and the recipes it names leave out `first-brain`, which needs `create_brain`. A recipe is named only where the tools its steps call are listed, and the sentence on `get_guide` names the recipes only where `create_spec` is. Each endpoint's instructions stay under 2,000 characters, open with the terminology page's definition of a brain, name as types only those on that page, and use no term of the internal vocabulary once the sentence that maps the wire names and the address of a brain's connection are taken out, which `src/mcp/instructions.test.ts` checks. The server refuses to start when the instructions of a key that may call every tool would be longer. With a fifth type served, the record's texts would pass that bound, so, as its bound says, the sentence on the interaction tools is in their descriptions and the interaction-function guide, the clause that gives a waiting run its event is in `send_execution_event`'s description and the workflow guide, the run sentence covers every type whose run finishes later, the `brain` argument is left to each tool's schema, and the sentence that maps the wire names leaves each type's value to the `primitive` argument. With every tool they take 1,990 characters on `/mcp`, 1,495 on `/orgs/{org}/mcp` and 1,950 on `/orgs/{org}/brains/{brain}/mcp`; a key that may only read is offered no `test_tool_call`, and its instructions keep 1,673 characters on `/mcp`, 1,710 with `brain:read` alone, 1,468 on the org endpoint and 1,673 on the brain endpoint. The org endpoint lists `list_tool_servers`, the org operation that answers for the whole org, so its sentence on models is the one that also names the tools: 22 characters more than when it listed `list_models` alone. The clause on `test_tool_call` is the short one, 42 characters, so that the closing sentence on refusals keeps its place within the bound. +On `/orgs/{org}/mcp` the connection "manages the brains of one org", whose functions and workflows are made on the brain's own connection, `/orgs/{org}/brains/{brain}/mcp`, and `get_guide` "holds what these words mean"; on `/orgs/{org}/brains/{brain}/mcp` it "acts inside one brain", and the recipes it names leave out `first-brain`, which needs `create_brain`. A recipe is named only where the tools its steps call are listed, and the sentence on `get_guide` names the recipes only where `create_definition` is. Each endpoint's instructions stay under 2,000 characters, open with the terminology page's definition of a brain, name as types only those on that page, and use no term of the internal vocabulary once the address of a brain's connection is taken out, which `src/mcp/instructions.test.ts` checks. The server refuses to start when the instructions of a key that may call every tool would be longer. With a fifth type served, the record's texts would pass that bound, so, as its bound says, the sentence on the interaction tools is in their descriptions and the interaction-function guide, the clause that gives a waiting run its event is in `send_run_event`'s description and the workflow guide, the run sentence covers every type whose run finishes later, and the `brain` argument is left to each tool's schema. With every tool they take 1,890 characters on `/mcp`, 1,495 on `/orgs/{org}/mcp` and 1,850 on `/orgs/{org}/brains/{brain}/mcp`; a key that may only read is offered no `test_tool_call`, and its instructions keep 1,573 characters on `/mcp`, 1,610 with `brain:read` alone, 1,468 on the org endpoint and 1,573 on the brain endpoint. The org endpoint lists `list_tool_servers`, the org operation that answers for the whole org, so its sentence on models is the one that also names the tools: 22 characters more than when it listed `list_models` alone. The clause on `test_tool_call` is the short one, 42 characters, so that the closing sentence on refusals keeps its place within the bound. **Guides.** Every endpoint serves `get_guide`, a read-only tool whose `guide` argument is the enum of the guides' names and whose answer is one guide's whole Markdown as text, and lists the same guides as resources, `guide://`, `text/markdown`, annotated for the assistant, which answer the same text. The server passes the guides as `guides`, a `Guide` each with its `name`, `title`, `description` and `text`, and the recipes as `recipes`. A `Recipe` is a guide with the `arguments` its prompt takes, the `formatGuide` it embeds, the tools its steps `call` and the `request` it makes of the person's words. Each recipe whose steps call only tools the connection lists, the rule that names it in the instructions, is also a prompt, under its name and title, so a brain endpoint offers no `first-brain` and a key that may only read no prompt: `prompts/get` answers one user message, the request with the person's words filled in and then the recipe, followed by its format guide as an embedded resource. An endpoint refuses to mount without the guide of each definition type and the format guide of each recipe. @@ -145,7 +145,7 @@ The SDK reports the errors it answers with JSON-RPC to `reportError`, which the ## The list of models -The server serves `list_models`, an org query of [`@beonauto/inference`](../../primitives/inference/README.md#listing-the-models), like any other operation: `GET /v1/orgs/{org}/models`, with an optional `provider` in the query string, and the read-only tool `list_models` on `/mcp` and `/orgs/{org}/mcp`. It answers in the shape of the OpenAI API's list of models, which gateways such as LiteLLM, Portkey and Vercel's serve too: +The server serves `list_models`, an org query of [`@beonauto/reasoning`](../../capabilities/reasoning/README.md#using-it-from-code), like any other operation: `GET /v1/orgs/{org}/models`, with an optional `provider` in the query string, and the read-only tool `list_models` on `/mcp` and `/orgs/{org}/mcp`. It answers in the shape of the OpenAI API's list of models, which gateways such as LiteLLM, Portkey and Vercel's serve too: ```json { @@ -181,7 +181,7 @@ The server serves `list_models`, an org query of [`@beonauto/inference`](../../p } ``` -`id` is the model as a spec names it, `owned_by` the provider prefix that serves it, and `created` the provider's release time in seconds, or 0. `name`, `context_window` and `max_tokens` appear only when the provider reports them, `resolved_to` only for an alias, and `pattern: true` only for an id ending in `*`, which stands for any model id. `catalog_status` is `partial` when a provider could not be asked, and `listed_at` is when the oldest list in the answer was read. Its plain words name the models, such as `This server can call 2 models through anthropic and gateway: Claude Sonnet 4.5 and fast. It can also call any openai model.` +`id` is the model as a definition names it, `owned_by` the provider prefix that serves it, and `created` the provider's release time in seconds, or 0. `name`, `context_window` and `max_tokens` appear only when the provider reports them, `resolved_to` only for an alias, and `pattern: true` only for an id ending in `*`, which stands for any model id. `catalog_status` is `partial` when a provider could not be asked, and `listed_at` is when the oldest list in the answer was read. Its plain words name the models, such as `This server can call 2 models through anthropic and gateway: Claude Sonnet 4.5 and fast. It can also call any openai model.` ## Lifecycle @@ -189,7 +189,7 @@ A `RegisterRoutes` function receives `routes.add`, to add a route, and `routes.o ## Exports -`makeAppRuntime(layer)` builds the runtime every call runs in. Its `run(effect, signal?)` answers the effect's value, or `cancelled` when the effect was interrupted, by the runtime's disposal or by the abort of the signal given, which interrupts it so its finalizers run; the server gives the signal of the call a workflow performs, so a call the workflow cancels stops the execution it started. +`makeAppRuntime(layer)` builds the runtime every call runs in. Its `run(effect, signal?)` answers the effect's value, or `cancelled` when the effect was interrupted, by the runtime's disposal or by the abort of the signal given, which interrupts it so its finalizers run; the server gives the signal of the call a workflow performs, so a call the workflow cancels stops the run it started. `src/index.ts` is the entry point: `createApiHandler`, `makeAppRuntime`, `operationRoutes`, `mcpRoutes`, the instructions, the types of a guide and a recipe, the guide's address and media type, the bounds, and their types. `@beonauto/api/testing` exports what the server's tests share: real MCP clients of the current SDK, on either revision, and of the SDK's 1.x line, each of which records in `compiledOutputSchemas` the output schemas it compiles a validator from and refuses every result it checks against one; helpers that read a tool listing, among them `takingBrain`, which gives a brain endpoint's tool the `brain` argument it has on `/mcp`, and `operationToolsIn`, which answers the listed tools but `get_guide`; and the `wait_forever` operation, which never finishes on its own. diff --git a/packages/api/package.json b/packages/api/package.json index 9040ee4c7..c9cb5b3ed 100644 --- a/packages/api/package.json +++ b/packages/api/package.json @@ -22,7 +22,7 @@ "hono": "catalog:" }, "devDependencies": { - "@beonauto/specs": "workspace:*", + "@beonauto/definitions": "workspace:*", "@modelcontextprotocol/client": "2.2.0", "@modelcontextprotocol/sdk": "1.31.0", "@vitest/coverage-v8": "catalog:", diff --git a/packages/api/src/bounds/served-bounds.test.ts b/packages/api/src/bounds/served-bounds.test.ts index a5247045e..0d81caa2e 100644 --- a/packages/api/src/bounds/served-bounds.test.ts +++ b/packages/api/src/bounds/served-bounds.test.ts @@ -10,7 +10,7 @@ import { testServerInfo } from '../testing/operation-server.ts'; const reportedErrors: string[] = []; -const notebookTypes: readonly DefinitionType[] = [{ primitive: 'notes', noun: 'note', guide: 'notebook' }]; +const notebookTypes: readonly DefinitionType[] = [{ type: 'notes', noun: 'note', guide: 'notebook' }]; interface Serving { readonly operations?: readonly { readonly registration: Registration }[]; @@ -45,7 +45,7 @@ function guideOf(name: string, text = '# A guide\n'): Guide { } function typeOf(noun: string, guide = 'asking'): DefinitionType { - return { primitive: 'asking', noun, guide }; + return { type: 'asking', noun, guide }; } const twoSentences = 'Asks. It answers. '; @@ -63,21 +63,21 @@ describe('a tool description', () => { expect(describedIn(800)).toHaveLength(800); expect(starting({ operations: [asking({ description: describedIn(800) })] })).not.toThrow(); expect(starting({ operations: [asking({ description: describedIn(801) })] })).toThrow( - 'The description of ask_spec: 801 characters, more than the 800 allowed', + 'The description of ask_definition: 801 characters, more than the 800 allowed', ); }); it('starts the server at three sentences, and refuses to start it at two', () => { expect(starting({ operations: [asking({ description: sentencesNumbering(3) })] })).not.toThrow(); expect(starting({ operations: [asking({ description: sentencesNumbering(2) })] })).toThrow( - 'The description of ask_spec: 2 sentences, fewer than the 3 required', + 'The description of ask_definition: 2 sentences, fewer than the 3 required', ); }); it('starts the server at eight sentences, and refuses to start it at nine', () => { expect(starting({ operations: [asking({ description: sentencesNumbering(8) })] })).not.toThrow(); expect(starting({ operations: [asking({ description: sentencesNumbering(9) })] })).toThrow( - 'The description of ask_spec: 9 sentences, more than the 8 allowed', + 'The description of ask_definition: 9 sentences, more than the 8 allowed', ); }); }); @@ -86,7 +86,7 @@ describe('the description of an argument', () => { it('starts the server at 300 characters, and refuses to start it at 301', () => { expect(starting({ operations: [asking({ argument: 'q'.repeat(300) })] })).not.toThrow(); expect(starting({ operations: [asking({ argument: 'q'.repeat(301) })] })).toThrow( - 'The description of question of ask_spec: 301 characters, more than the 300 allowed', + 'The description of question of ask_definition: 301 characters, more than the 300 allowed', ); }); }); @@ -98,7 +98,7 @@ function askingTools(count: number) { describe('the description of an argument of one shape of a union input', () => { it('refuses to start the server over 300 characters', () => { const shaped = defineQuery('brain', { - name: 'shape_spec', + name: 'shape_definition', title: 'Shape', description: 'Shapes. Use it to shape. It answers.', route: { method: 'GET', path: '/shape' }, @@ -113,7 +113,7 @@ describe('the description of an argument of one shape of a union input', () => { }); expect(starting({ operations: [shaped] })).toThrow( - 'The description of square of shape_spec: 301 characters, more than the 300 allowed', + 'The description of square of shape_definition: 301 characters, more than the 300 allowed', ); }); }); @@ -128,16 +128,16 @@ describe('the tools on a connection', () => { }); function workflowCalled(noun: string): DefinitionType { - return { primitive: 'orchestration', noun, guide: 'asking' }; + return { type: 'workflow', noun, guide: 'asking' }; } function instructionLengthWith(noun: string): number { - return instructionsFor('own org', { orgTools: [], brainTools: ['get_execution'] }, [workflowCalled(noun)], []).length; + return instructionsFor('own org', { orgTools: [], brainTools: ['get_run'] }, [workflowCalled(noun)], []).length; } function servingTypeOf(noun: string): Serving { return { - operations: [asking({ name: 'get_execution' })], + operations: [asking({ name: 'get_run' })], guides: [guideOf('asking')], definitionTypes: [workflowCalled(noun)], }; @@ -218,9 +218,9 @@ describe('a server without the guides it names', () => { expect( starting({ guides: [wordsGuide], - definitionTypes: [{ primitive: 'inference', noun: 'reasoning function', guide: 'reasoning-function' }], + definitionTypes: [{ type: 'reasoning', noun: 'reasoning function', guide: 'reasoning-function' }], }), - ).toThrow('The definition type inference names the guide reasoning-function, which the server does not carry'); + ).toThrow('The definition type reasoning names the guide reasoning-function, which the server does not carry'); }); it('refuses to start without the format guide a recipe embeds', () => { diff --git a/packages/api/src/guides/guide-shelf.ts b/packages/api/src/guides/guide-shelf.ts index e2ba14784..39a817c61 100644 --- a/packages/api/src/guides/guide-shelf.ts +++ b/packages/api/src/guides/guide-shelf.ts @@ -64,8 +64,8 @@ export function guideShelfOf( const everyGuide = [...guides, ...recipes]; const names = everyGuide.map(({ name }) => name); requireDistinctNames(names); - for (const { primitive, guide } of definitionTypes) { - guideNamed(guides, `The definition type ${primitive}`, guide); + for (const { type, guide } of definitionTypes) { + guideNamed(guides, `The definition type ${type}`, guide); } const shelved = recipes.map((recipe) => ({ ...recipe, diff --git a/packages/api/src/mcp/instructions.test.ts b/packages/api/src/mcp/instructions.test.ts index 1c0e489f1..7305a1099 100644 --- a/packages/api/src/mcp/instructions.test.ts +++ b/packages/api/src/mcp/instructions.test.ts @@ -15,22 +15,22 @@ import { import { instructionsFor, type ServedTools } from './instructions.ts'; const recordOnMcp = - "A brain is the complete system for a business responsibility. It holds the functions that do its work and the workflows that coordinate them, and it keeps every run with its result as its history. A reasoning function has a prompt and calls a language model. A computation function runs a program on its input and gives the same output every time. A recall function keeps a view folded from the brain's own history, every run's start and ending with its result when it succeeded and fit, the definitions saved and every event published to the brain, never a run's input or the tool calls it made, so nothing has to write into it. A workflow runs functions in steps, waits for input and can start on a schedule or on an event. This connection acts in the caller's own org: list_brains shows its brains and create_brain makes one, and every tool inside a brain takes the brain's id as brain. The tools call a definition a spec, a run an execution and a definition's type its primitive: inference for a reasoning function, computation for a computation function, recollection for a recall function and orchestration for a workflow. Before writing a definition, read its format with get_guide, which also holds the recipes: first-brain, remember, give-tools and schedule. A reasoning function names a model that list_models lists and may name tools that list_tool_servers lists. A workflow run answers started and ends later: read it with get_execution until its status changes, and give a run that waits for input its event with send_execution_event. Runs, history and events come a page at a time; read on only when the person needs more. When you tell the person what happened, say what was done and what they can do next, in the words of brains, functions, workflows and runs, not in the tools' names, fields or rules; give an id or a status only when the person needs it to act, as a run's id they will return to. A tool that cannot do what was asked says why and what to change."; + "A brain is the complete system for a business responsibility. It holds the functions that do its work and the workflows that coordinate them, and it keeps every run with its result as its history. A reasoning function has a prompt and calls a language model. A computation function runs a program on its input and gives the same output every time. A recall function keeps a view folded from the brain's own history, every run's start and ending with its result when it succeeded and fit, the definitions saved and every event published to the brain, never a run's input or the tool calls it made, so nothing has to write into it. A workflow runs functions in steps, waits for input and can start on a schedule or on an event. This connection acts in the caller's own org: list_brains shows its brains and create_brain makes one, and every tool inside a brain takes the brain's id as brain. Before writing a definition, read its format with get_guide, which also holds the recipes: first-brain, remember, give-tools and schedule. A reasoning function names a model that list_models lists and may name tools that list_tool_servers lists. A workflow run answers started and ends later: read it with get_run until its status changes, and give a run that waits for input its event with send_run_event. Runs, history and events come a page at a time; read on only when the person needs more. When you tell the person what happened, say what was done and what they can do next, in the words of brains, functions, workflows and runs, not in the tools' names, fields or rules; give an id or a status only when the person needs it to act, as a run's id they will return to. A tool that cannot do what was asked says why and what to change."; const recordOnAnOrg = "A brain is the complete system for a business responsibility. It holds the functions that do its work and the workflows that coordinate them, and it keeps every run with its result as its history. A reasoning function has a prompt and calls a language model. A computation function runs a program on its input and gives the same output every time. A recall function keeps a view folded from the brain's own history, every run's start and ending with its result when it succeeded and fit, the definitions saved and every event published to the brain, never a run's input or the tool calls it made, so nothing has to write into it. A workflow runs functions in steps, waits for input and can start on a schedule or on an event. This connection manages the brains of one org: list_brains shows them and create_brain makes one, and a brain's functions and workflows are made on the brain's own connection. list_models lists the models this server can call, which a reasoning function names. get_guide holds what these words mean and how each kind of definition is written. When you tell the person what happened, say what was done and what they can do next, in the words of brains, functions, workflows and runs, not in the tools' names, fields or rules; give an id or a status only when the person needs it to act, as a run's id they will return to. A tool that cannot do what was asked says why and what to change."; const recordOnABrain = - "A brain is the complete system for a business responsibility. It holds the functions that do its work and the workflows that coordinate them, and it keeps every run with its result as its history. A reasoning function has a prompt and calls a language model. A computation function runs a program on its input and gives the same output every time. A recall function keeps a view folded from the brain's own history, every run's start and ending with its result when it succeeded and fit, the definitions saved and every event published to the brain, never a run's input or the tool calls it made, so nothing has to write into it. A workflow runs functions in steps, waits for input and can start on a schedule or on an event. This connection acts inside one brain. The tools call a definition a spec, a run an execution and a definition's type its primitive: inference for a reasoning function, computation for a computation function, recollection for a recall function and orchestration for a workflow. Before writing a definition, read its format with get_guide, which also holds the recipes: remember, give-tools and schedule. A reasoning function names a model this server can call; the reasoning-function guide says how it is written, and it may name tools that list_tool_servers lists. A workflow run answers started and ends later: read it with get_execution until its status changes, and give a run that waits for input its event with send_execution_event. Runs, history and events come a page at a time; read on only when the person needs more. When you tell the person what happened, say what was done and what they can do next, in the words of brains, functions, workflows and runs, not in the tools' names, fields or rules; give an id or a status only when the person needs it to act, as a run's id they will return to. A tool that cannot do what was asked says why and what to change."; + "A brain is the complete system for a business responsibility. It holds the functions that do its work and the workflows that coordinate them, and it keeps every run with its result as its history. A reasoning function has a prompt and calls a language model. A computation function runs a program on its input and gives the same output every time. A recall function keeps a view folded from the brain's own history, every run's start and ending with its result when it succeeded and fit, the definitions saved and every event published to the brain, never a run's input or the tool calls it made, so nothing has to write into it. A workflow runs functions in steps, waits for input and can start on a schedule or on an event. This connection acts inside one brain. Before writing a definition, read its format with get_guide, which also holds the recipes: remember, give-tools and schedule. A reasoning function names a model this server can call; the reasoning-function guide says how it is written, and it may name tools that list_tool_servers lists. A workflow run answers started and ends later: read it with get_run until its status changes, and give a run that waits for input its event with send_run_event. Runs, history and events come a page at a time; read on only when the person needs more. When you tell the person what happened, say what was done and what they can do next, in the words of brains, functions, workflows and runs, not in the tools' names, fields or rules; give an id or a status only when the person needs it to act, as a run's id they will return to. A tool that cannot do what was asked says why and what to change."; const recordWorkflowSentence = - 'A workflow run answers started and ends later: read it with get_execution until its status changes, and give a run that waits for input its event with send_execution_event.'; + 'A workflow run answers started and ends later: read it with get_run until its status changes, and give a run that waits for input its event with send_run_event.'; const recordRecallSentence = "A recall function keeps a view folded from the brain's own history, every run's start and ending with its result when it succeeded and fit, the definitions saved and every event published to the brain, never a run's input or the tool calls it made, so nothing has to write into it."; const amendments: readonly (readonly [string, string])[] = [ - [recordWorkflowSentence, 'A run of a workflow answers started; get_execution shows whether it ended or still waits.'], + [recordWorkflowSentence, 'A run of a workflow answers started; get_run shows whether it ended or still waits.'], [", and every tool inside a brain takes the brain's id as brain.", '.'], [ recordRecallSentence, @@ -41,10 +41,6 @@ const amendments: readonly (readonly [string, string])[] = [ 'names a model this server can call; the reasoning-function guide says how it is written, and it may name tools', 'names a model this server can call, as the reasoning-function guide says, and may name tools', ], - [ - "a definition's type its primitive: inference for a reasoning function, computation for a computation function, recollection for a recall function and orchestration for a workflow.", - "a definition's type its primitive.", - ], [", as a run's id they will return to.", '.'], [ 'may name tools that list_tool_servers lists.', @@ -72,8 +68,8 @@ function asServedWithFiveTypes(recordText: string): string { `A reasoning function has a prompt and calls a language model. ${interactionSentence}`, ) .replace( - 'A run of a workflow answers started; get_execution shows whether it ended or still waits.', - `A run of an interaction function or a workflow answers started; get_execution shows whether it ended or still waits. ${answeringSentence}`, + 'A run of a workflow answers started; get_run shows whether it ended or still waits.', + `A run of an interaction function or a workflow answers started; get_run shows whether it ended or still waits. ${answeringSentence}`, ) .replace('Runs, history and events come', 'Runs, history, events and requests come'); } @@ -85,9 +81,6 @@ function withoutInteraction({ orgTools, brainTools }: ServedTools): ServedTools }; } -const wireNamesSentence = - "The tools call a definition a spec, a run an execution and a definition's type its primitive."; - const terminology = readFileSync(new URL('../../../../docs/concepts/terminology.md', import.meta.url), 'utf8'); const resourcesOnTheTerminologyPage: ReadonlySet = new Set( @@ -121,7 +114,7 @@ describe('the instructions of each endpoint, for a key that may call every tool' ] as const)( 'read on the %s endpoint of a server with the four types of the record as the record gives them, but for those two', (endpoint, served, recordText) => { - const fourTypes = definitionTypes.filter(({ primitive }) => primitive !== 'interaction'); + const fourTypes = definitionTypes.filter(({ type }) => type !== 'interaction'); expect(instructionsFor(endpoint, withoutInteraction(served), fourTypes, recipes)).toBe( asServedWithFourTypes(recordText), @@ -146,9 +139,9 @@ describe('the instructions of a key that may only read', () => { expect( [ 'create_brain', - 'create_spec', - 'execute_spec', - 'send_execution_event', + 'create_definition', + 'run_definition', + 'send_run_event', 'answer_interaction', 'first-brain', ].filter((name) => reading.includes(name)), @@ -186,7 +179,7 @@ describe('the instructions of a key that may only read', () => { }); describe('what the instructions say of the definition types', () => { - it('give a sentence only to a type the server runs, and leave the values of a type to the tools that take one', () => { + it('give a sentence only to a type the server runs', () => { const reasoningAlone = definitionTypes.slice(0, 1); const instructions = instructionsFor('brain', brainEndpoint, reasoningAlone, recipes); @@ -194,14 +187,14 @@ describe('what the instructions say of the definition types', () => { expect( ['computation function', 'recall function', 'A workflow runs'].filter((words) => instructions.includes(words)), ).toEqual([]); - expect(instructions).toContain(`${wireNamesSentence} Before writing a definition`); + expect(instructions).toContain('This connection acts inside one brain. Before writing a definition'); }); it('give no sentence to a type they have no words for', () => { - const drafting = [{ primitive: 'drafting', noun: 'draft', guide: 'drafting' }]; + const drafting = [{ type: 'drafting', noun: 'draft', guide: 'drafting' }]; expect(instructionsFor('brain', brainEndpoint, drafting, recipes)).toContain( - `A brain is the complete system for a business responsibility. It holds the functions that do its work and the workflows that coordinate them, and it keeps every run with its result as its history. This connection acts inside one brain. ${wireNamesSentence}`, + 'A brain is the complete system for a business responsibility. It holds the functions that do its work and the workflows that coordinate them, and it keeps every run with its result as its history. This connection acts inside one brain. Before writing a definition', ); }); @@ -265,18 +258,12 @@ describe('the instructions of every endpoint', () => { expect(definitionTypes.filter(({ noun }) => !resourcesOnTheTerminologyPage.has(noun))).toEqual([]); }); - it('use no term of the internal vocabulary once the sentence that maps the wire names and the address of a brain are taken out, and no product name', () => { + it('use no term of the internal vocabulary once the address of a brain is taken out, and no product name', () => { const instructions = everyEndpoint.map(([endpoint, served]) => - instructionsFor(endpoint, served, definitionTypes, recipes) - .replace(wireNamesSentence, '') - .replace(', /orgs/{org}/brains/{brain}/mcp.', '.'), + instructionsFor(endpoint, served, definitionTypes, recipes).replace(', /orgs/{org}/brains/{brain}/mcp.', '.'), ); expect(instructions.flatMap((text) => internalTermsIn(text))).toEqual([]); expect(instructions.filter((text) => /\bauto\b|renamed/iu.test(text))).toEqual([]); }); - - it('map the wire names only where a tool listed carries one', () => { - expect(instructionsFor('org', orgEndpoint, definitionTypes, recipes)).not.toContain('a run an execution'); - }); }); diff --git a/packages/api/src/mcp/instructions.ts b/packages/api/src/mcp/instructions.ts index 72c88be57..6177826fa 100644 --- a/packages/api/src/mcp/instructions.ts +++ b/packages/api/src/mcp/instructions.ts @@ -3,7 +3,7 @@ import { articled } from '@beonauto/operations'; export type McpEndpoint = 'org' | 'brain' | 'own org'; export interface DefinitionType { - readonly primitive: string; + readonly type: string; readonly noun: string; readonly guide: string; } @@ -20,7 +20,6 @@ export interface RecipeCalls { interface Serving { readonly endpoint: McpEndpoint; - readonly names: readonly string[]; readonly listed: (name: string) => boolean; readonly definitionTypes: readonly DefinitionType[]; readonly reasoning: DefinitionType | undefined; @@ -29,15 +28,15 @@ interface Serving { type Sentences = (serving: Serving) => readonly string[]; -const reasoningFunctionType = 'inference'; +const reasoningFunctionType = 'reasoning'; const purposeByType: Readonly> = { - inference: 'A reasoning function has a prompt and calls a language model.', + reasoning: 'A reasoning function has a prompt and calls a language model.', interaction: 'An interaction function asks a person or a system and takes the answer later.', computation: 'A computation function runs a program on its input and gives the same output every time.', - recollection: + recall: "A recall function answers from what it keeps of the brain's own history: every run's start and end, with its result when it succeeded, the definitions saved and the events published to the brain, never a run's input or its tool calls, so nothing has to write into it.", - orchestration: 'A workflow runs functions in steps, waits for input and can start on a schedule or on an event.', + workflow: 'A workflow runs functions in steps, waits for input and can start on a schedule or on an event.', }; const whatABrainIs = [ @@ -45,9 +44,7 @@ const whatABrainIs = [ 'It holds the functions that do its work and the workflows that coordinate them, and it keeps every run with its result as its history.', ]; -const wireWords = /(?:^|_)(?:specs?|executions?)(?:_|$)/u; - -const pagedReads = ['list_executions', 'get_execution_history', 'list_brain_events', 'list_interactions']; +const pagedReads = ['list_runs', 'get_run_history', 'list_brain_events', 'list_interactions']; const howToAnswer = [ 'When you tell the person what happened, say what was done and what they can do next,', @@ -84,25 +81,20 @@ const whatTheConnectionDoes: Readonly }; const purposes: Sentences = ({ definitionTypes }) => - definitionTypes.flatMap(({ primitive }) => { - const purpose = purposeByType[primitive]; + definitionTypes.flatMap(({ type }) => { + const purpose = purposeByType[type]; return purpose === undefined ? [] : [purpose]; }); const connection: Sentences = (serving) => [whatTheConnectionDoes[serving.endpoint](serving)]; -const wireNames: Sentences = ({ names }) => - names.some((name) => wireWords.test(name)) - ? ["The tools call a definition a spec, a run an execution and a definition's type its primitive."] - : []; - const modelsBeforeWriting: Sentences = ({ reasoning, listed }) => reasoning !== undefined && listed('list_models') && !listed('list_tool_servers') ? ['list_models lists the models this server can call, which a reasoning function names.'] : []; const guides: Sentences = ({ listed, recipes }) => { - if (!listed('create_spec')) { + if (!listed('create_definition')) { return ['get_guide holds what these words mean and how each kind of definition is written.']; } const served = recipes.map(({ name }) => name); @@ -123,14 +115,14 @@ const modelsAndTools: Sentences = ({ reasoning, listed }) => { ]; }; -const typesThatFinishLater: ReadonlySet = new Set(['interaction', 'orchestration']); +const typesThatFinishLater: ReadonlySet = new Set(['interaction', 'workflow']); const runsThatFinishLater: Sentences = ({ listed, definitionTypes }) => { const finishingLater = definitionTypes - .filter(({ primitive }) => typesThatFinishLater.has(primitive)) + .filter(({ type }) => typesThatFinishLater.has(type)) .map(({ noun }) => articled(noun)); - return listed('get_execution') && finishingLater.length > 0 - ? [`A run of ${finishingLater.join(' or ')} answers started; get_execution shows whether it ended or still waits.`] + return listed('get_run') && finishingLater.length > 0 + ? [`A run of ${finishingLater.join(' or ')} answers started; get_run shows whether it ended or still waits.`] : []; }; @@ -153,7 +145,6 @@ const closing: Sentences = () => [howToAnswer, whenAToolCannot]; const orientation: readonly Sentences[] = [ purposes, connection, - wireNames, modelsBeforeWriting, guides, modelsAndTools, @@ -184,10 +175,9 @@ export function instructionsFor( const names = namesOf(tools); const serving: Serving = { endpoint, - names, listed: (name) => names.includes(name), definitionTypes, - reasoning: definitionTypes.find(({ primitive }) => primitive === reasoningFunctionType), + reasoning: definitionTypes.find(({ type }) => type === reasoningFunctionType), recipes: recipesFollowedWith(tools, recipes), }; return [...whatABrainIs, ...orientation.flatMap((sentences) => sentences(serving))].join(' '); diff --git a/packages/api/src/mcp/mcp-own-org.test.ts b/packages/api/src/mcp/mcp-own-org.test.ts index 832b17cf2..6cb749615 100644 --- a/packages/api/src/mcp/mcp-own-org.test.ts +++ b/packages/api/src/mcp/mcp-own-org.test.ts @@ -94,7 +94,7 @@ describe('the tools of /mcp', () => { instructionsFor( 'own org', { orgTools, brainTools }, - [{ primitive: 'notes', noun: 'note', guide: 'notebook' }], + [{ type: 'notes', noun: 'note', guide: 'notebook' }], [{ name: 'take-a-note', calls: ['add_note'] }], ), ); diff --git a/packages/api/src/mcp/tool-testing-instructions.test.ts b/packages/api/src/mcp/tool-testing-instructions.test.ts index 0f825eec4..e0cad2bcc 100644 --- a/packages/api/src/mcp/tool-testing-instructions.test.ts +++ b/packages/api/src/mcp/tool-testing-instructions.test.ts @@ -11,10 +11,10 @@ import { import { instructionsFor } from './instructions.ts'; describe('the length of the instructions with every tool', () => { - it('take 1,990 characters on /mcp, 1,495 on the org endpoint and 1,950 on the brain endpoint', () => { + it('take 1,890 characters on /mcp, 1,495 on the org endpoint and 1,850 on the brain endpoint', () => { expect( everyEndpoint.map(([endpoint, served]) => instructionsFor(endpoint, served, definitionTypes, recipes).length), - ).toEqual([1495, 1950, 1990]); + ).toEqual([1495, 1850, 1890]); }); }); @@ -57,6 +57,6 @@ describe('what the instructions say of testing a tool', () => { ]; expect(lengths.filter((instructions) => instructions.includes('test_tool_call'))).toEqual([]); - expect(lengths.map(({ length }) => length)).toEqual([1673, 1710, 1468, 1673]); + expect(lengths.map(({ length }) => length)).toEqual([1573, 1610, 1468, 1573]); }); }); diff --git a/packages/api/src/testing/asking-operation.ts b/packages/api/src/testing/asking-operation.ts index f856f7b2c..8724da83b 100644 --- a/packages/api/src/testing/asking-operation.ts +++ b/packages/api/src/testing/asking-operation.ts @@ -10,7 +10,7 @@ export interface Asking { } export function asking({ - name = 'ask_spec', + name = 'ask_definition', description = 'Asks. Use it to ask. It answers.', argument = 'What to ask', outcome = 'Asked.', diff --git a/packages/api/src/testing/guides.ts b/packages/api/src/testing/guides.ts index 75d2033ea..3821f6d44 100644 --- a/packages/api/src/testing/guides.ts +++ b/packages/api/src/testing/guides.ts @@ -33,4 +33,4 @@ export const testGuides: readonly Guide[] = [wordsGuide, notebookGuide]; export const testRecipes: readonly Recipe[] = [noteRecipe]; -export const testDefinitionTypes: readonly DefinitionType[] = [{ primitive: 'notes', noun: 'note', guide: 'notebook' }]; +export const testDefinitionTypes: readonly DefinitionType[] = [{ type: 'notes', noun: 'note', guide: 'notebook' }]; diff --git a/packages/api/src/testing/internal-terms.test.ts b/packages/api/src/testing/internal-terms.test.ts index 5a9e8fe07..c228dea61 100644 --- a/packages/api/src/testing/internal-terms.test.ts +++ b/packages/api/src/testing/internal-terms.test.ts @@ -9,6 +9,7 @@ const leaks: ReadonlyArray = [ ['The execution started.', 'execution'], ['It ran an inference.', 'inference'], ['An orchestration started.', 'orchestration'], + ['It folds a recollection.', 'recollection'], ['Its YAML is wrong.', 'YAML'], ['Its yml is wrong.', 'yml'], ['It answered in JSON.', 'JSON'], diff --git a/packages/api/src/testing/internal-terms.ts b/packages/api/src/testing/internal-terms.ts index 0b8ac5e4b..f32ed3ad7 100644 --- a/packages/api/src/testing/internal-terms.ts +++ b/packages/api/src/testing/internal-terms.ts @@ -4,6 +4,7 @@ export const internalTerms: readonly RegExp[] = [ /\bexecutions?\b/iu, /\binference\b/iu, /\borchestration\b/iu, + /\brecollection\b/iu, /\bya?ml\b/iu, /\bjson\b/iu, /\bliquid\b/iu, diff --git a/packages/api/src/testing/served-instructions.ts b/packages/api/src/testing/served-instructions.ts index eddde390d..c4df0283e 100644 --- a/packages/api/src/testing/served-instructions.ts +++ b/packages/api/src/testing/served-instructions.ts @@ -3,16 +3,16 @@ import type { DefinitionType, McpEndpoint, RecipeCalls, ServedTools } from '../m const managingBrains = ['create_brain', 'list_brains', 'get_brain', 'update_brain', 'retire_brain']; const insideABrain = [ - 'create_spec', - 'list_specs', - 'get_spec', - 'update_spec', - 'retire_spec', - 'execute_spec', - 'get_execution', - 'cancel_execution', - 'list_executions', - 'get_execution_history', + 'create_definition', + 'list_definitions', + 'get_definition', + 'update_definition', + 'retire_definition', + 'run_definition', + 'get_run', + 'cancel_run', + 'list_runs', + 'get_run_history', 'get_brain_analytics', 'list_brain_events', 'publish_event', @@ -20,16 +20,16 @@ const insideABrain = [ 'test_tool_call', 'list_interactions', 'answer_interaction', - 'send_execution_event', + 'send_run_event', 'get_guide', ]; export const queriesInsideABrain = [ - 'list_specs', - 'get_spec', - 'get_execution', - 'list_executions', - 'get_execution_history', + 'list_definitions', + 'get_definition', + 'get_run', + 'list_runs', + 'get_run_history', 'get_brain_analytics', 'list_brain_events', 'list_tool_servers', @@ -49,21 +49,24 @@ export const ownOrg: ServedTools = { }; export const definitionTypes: readonly DefinitionType[] = [ - { primitive: 'inference', noun: 'reasoning function', guide: 'reasoning-function' }, - { primitive: 'interaction', noun: 'interaction function', guide: 'interaction-function' }, - { primitive: 'computation', noun: 'computation function', guide: 'computation-function' }, - { primitive: 'recollection', noun: 'recall function', guide: 'recall-function' }, - { primitive: 'orchestration', noun: 'workflow', guide: 'workflow' }, + { type: 'reasoning', noun: 'reasoning function', guide: 'reasoning-function' }, + { type: 'interaction', noun: 'interaction function', guide: 'interaction-function' }, + { type: 'computation', noun: 'computation function', guide: 'computation-function' }, + { type: 'recall', noun: 'recall function', guide: 'recall-function' }, + { type: 'workflow', noun: 'workflow', guide: 'workflow' }, ]; export const recipes: readonly RecipeCalls[] = [ - { name: 'first-brain', calls: ['list_brains', 'create_brain', 'create_spec', 'test_tool_call', 'execute_spec'] }, - { name: 'remember', calls: ['list_specs', 'create_spec', 'update_spec', 'execute_spec'] }, + { + name: 'first-brain', + calls: ['list_brains', 'create_brain', 'create_definition', 'test_tool_call', 'run_definition'], + }, + { name: 'remember', calls: ['list_definitions', 'create_definition', 'update_definition', 'run_definition'] }, { name: 'give-tools', - calls: ['list_tool_servers', 'test_tool_call', 'create_spec', 'execute_spec', 'get_execution_history'], + calls: ['list_tool_servers', 'test_tool_call', 'create_definition', 'run_definition', 'get_run_history'], }, - { name: 'schedule', calls: ['list_specs', 'create_spec', 'update_spec', 'list_executions', 'get_execution'] }, + { name: 'schedule', calls: ['list_definitions', 'create_definition', 'update_definition', 'list_runs', 'get_run'] }, ]; export const everyEndpoint: readonly (readonly [McpEndpoint, ServedTools])[] = [ diff --git a/packages/api/src/tools/mcp-tools.test.ts b/packages/api/src/tools/mcp-tools.test.ts index 1d7ad86b3..a487e20e1 100644 --- a/packages/api/src/tools/mcp-tools.test.ts +++ b/packages/api/src/tools/mcp-tools.test.ts @@ -1,5 +1,5 @@ -import { makeSpecOperations } from '@beonauto/specs'; -import { echo } from '@beonauto/specs/testing'; +import { makeDefinitionOperations } from '@beonauto/definitions'; +import { echo } from '@beonauto/definitions/testing'; import { afterAll, beforeAll, describe, expect, it } from 'vitest'; import { listenOnLoopback, type Listening } from '../testing/listening.ts'; @@ -15,7 +15,7 @@ let server: OperationServer; let listening: Listening; beforeAll(async () => { - server = await operationServer({ operations: [...orgOperations, ...makeSpecOperations([echo])] }); + server = await operationServer({ operations: [...orgOperations, ...makeDefinitionOperations([echo])] }); listening = await listenOnLoopback(server.handler); }); @@ -32,17 +32,17 @@ function onAlpha(use: (session: McpSession) => Promise): Promise { return withMcpSession('current revision', endpoint('/orgs/acme/brains/alpha/mcp'), use); } -const specTools = [ - 'create_spec', - 'list_specs', - 'get_spec', - 'update_spec', - 'retire_spec', - 'execute_spec', - 'get_execution', - 'cancel_execution', - 'list_executions', - 'get_execution_history', +const definitionTools = [ + 'create_definition', + 'list_definitions', + 'get_definition', + 'update_definition', + 'retire_definition', + 'run_definition', + 'get_run', + 'cancel_run', + 'list_runs', + 'get_run_history', 'get_brain_analytics', ]; @@ -55,70 +55,70 @@ describe('the tools of each endpoint', () => { expect(listedTools(listing).map(({ name }) => name)).toEqual(['label_brain', 'list_labels', guideToolName]); }); - it('lists the eleven spec operations on a brain endpoint and no org operation', async () => { + it('lists the eleven definition operations on a brain endpoint and no org operation', async () => { expect(listedTools(await onAlpha((session) => session.listTools())).map(({ name }) => name)).toEqual([ - ...specTools, + ...definitionTools, guideToolName, ]); }); - it('publishes self-contained input schemas with an object root, the primitive as a plain enum, and no output schema', async () => { + it('publishes self-contained input schemas with an object root, the type as a plain enum, and no output schema', async () => { const tools = listedTools(await onAlpha((session) => session.listTools())); const schemas = tools.map(({ inputSchema }) => inputSchema); expect(schemas.map((schema) => schema['type'])).toEqual(schemas.map(() => 'object')); expect(schemas.flatMap((schema) => danglingReferencesIn(schema))).toEqual([]); expect(tools.filter(({ outputSchema }) => outputSchema !== undefined)).toEqual([]); - expect(tools.find(({ name }) => name === 'create_spec')?.inputSchema).toMatchObject({ - properties: { primitive: { type: 'string', enum: ['echo'] } }, + expect(tools.find(({ name }) => name === 'create_definition')?.inputSchema).toMatchObject({ + properties: { type: { type: 'string', enum: ['echo'] } }, }); }); }); const echoRecord = { record: { greeting: 'Hello' } }; -describe('the spec tools on a brain endpoint', () => { - it('create a spec, execute it and read the execution back', async () => { +describe('the definition tools on a brain endpoint', () => { + it('create a definition, execute it and read the run back', async () => { const outcome = await onAlpha(async (session) => { - const created = await session.callTool('create_spec', { - primitive: 'echo', + const created = await session.callTool('create_definition', { + type: 'echo', name: 'greeter', source: '{"greeting":"Hello"}', }); - const executed = await session.callTool('execute_spec', { - primitive: 'echo', + const ran = await session.callTool('run_definition', { + type: 'echo', name: 'greeter', input: { who: 'Ada' }, }); - const execution = await session.callTool('get_execution', { - execution_id: String(executed.structuredContent?.['execution_id']), + const run = await session.callTool('get_run', { + run_id: String(ran.structuredContent?.['run_id']), }); - return { created, executed, execution }; + return { created, ran, run }; }); - expect(outcome.created.structuredContent).toMatchObject({ primitive: 'echo', name: 'greeter', version: 1 }); - expect(outcome.executed.structuredContent).toMatchObject({ + expect(outcome.created.structuredContent).toMatchObject({ type: 'echo', name: 'greeter', version: 1 }); + expect(outcome.ran.structuredContent).toMatchObject({ status: 'succeeded', output: { greeting: 'Hello', input: { who: 'Ada' } }, }); - expect(outcome.execution.structuredContent).toEqual({ ...outcome.executed.structuredContent, ...echoRecord }); + expect(outcome.run.structuredContent).toEqual({ ...outcome.ran.structuredContent, ...echoRecord }); }); - it('list, read, update and retire a spec', async () => { + it('list, read, update and retire a definition', async () => { const outcome = await onAlpha(async (session) => { - await session.callTool('create_spec', { primitive: 'echo', name: 'welcomer', source: '{"greeting":"Hi"}' }); - const listed = await session.callTool('list_specs', { primitive: 'echo' }); - const read = await session.callTool('get_spec', { primitive: 'echo', name: 'welcomer' }); - const updated = await session.callTool('update_spec', { - primitive: 'echo', + await session.callTool('create_definition', { type: 'echo', name: 'welcomer', source: '{"greeting":"Hi"}' }); + const listed = await session.callTool('list_definitions', { type: 'echo' }); + const read = await session.callTool('get_definition', { type: 'echo', name: 'welcomer' }); + const updated = await session.callTool('update_definition', { + type: 'echo', name: 'welcomer', source: '{"greeting":"Welcome"}', }); - const retired = await session.callTool('retire_spec', { primitive: 'echo', name: 'welcomer' }); + const retired = await session.callTool('retire_definition', { type: 'echo', name: 'welcomer' }); return { listed, read, updated, retired }; }); - expect(outcome.listed.structuredContent?.['specs']).toContainEqual( + expect(outcome.listed.structuredContent?.['definitions']).toContainEqual( expect.objectContaining({ name: 'welcomer', version: 1 }), ); expect(outcome.read.structuredContent).toMatchObject({ name: 'welcomer', version: 1, status: 'active' }); @@ -135,12 +135,12 @@ describe('the annotations of a tool', () => { ); expect(annotationsByName).toMatchObject({ - create_spec: { readOnlyHint: false, idempotentHint: false, destructiveHint: false, openWorldHint: false }, - list_specs: { readOnlyHint: true, idempotentHint: true, destructiveHint: false, openWorldHint: false }, - update_spec: { readOnlyHint: false, idempotentHint: true, destructiveHint: false, openWorldHint: false }, - retire_spec: { readOnlyHint: false, idempotentHint: true, destructiveHint: true, openWorldHint: false }, - execute_spec: { readOnlyHint: false, idempotentHint: false, destructiveHint: false, openWorldHint: false }, - cancel_execution: { readOnlyHint: false, idempotentHint: true, destructiveHint: true, openWorldHint: false }, + create_definition: { readOnlyHint: false, idempotentHint: false, destructiveHint: false, openWorldHint: false }, + list_definitions: { readOnlyHint: true, idempotentHint: true, destructiveHint: false, openWorldHint: false }, + update_definition: { readOnlyHint: false, idempotentHint: true, destructiveHint: false, openWorldHint: false }, + retire_definition: { readOnlyHint: false, idempotentHint: true, destructiveHint: true, openWorldHint: false }, + run_definition: { readOnlyHint: false, idempotentHint: false, destructiveHint: false, openWorldHint: false }, + cancel_run: { readOnlyHint: false, idempotentHint: true, destructiveHint: true, openWorldHint: false }, }); }); }); diff --git a/packages/api/src/tools/tool-definition.test.ts b/packages/api/src/tools/tool-definition.test.ts index b7640541d..bfc0a5692 100644 --- a/packages/api/src/tools/tool-definition.test.ts +++ b/packages/api/src/tools/tool-definition.test.ts @@ -43,7 +43,7 @@ function eventTaking( inputSchema = Schema.Struct({ id: Schema.String.annotate({ description: idDescription }) }), ) { return defineQuery('brain', { - name: 'nest_spec', + name: 'nest_definition', title: 'Nest', description: 'Nests. Use it to nest. It answers.', route: { method: 'GET', path: '/nest' }, @@ -63,7 +63,7 @@ describe('the description of an argument at any depth of the input', () => { it('is served at 300 characters, and refused at 301, naming the path to the argument', () => { expect(() => toolDefinitionOf(eventTaking('i'.repeat(300)).registration)).not.toThrow(); expect(() => toolDefinitionOf(eventTaking('i'.repeat(301)).registration)).toThrow( - 'The description of event.id of nest_spec: 301 characters, more than the 300 allowed', + 'The description of event.id of nest_definition: 301 characters, more than the 300 allowed', ); }); @@ -73,7 +73,7 @@ describe('the description of an argument at any depth of the input', () => { }); expect(() => toolDefinitionOf(eventTaking('', Event).registration)).toThrow( - 'The description of Event.id of nest_spec: 301 characters, more than the 300 allowed', + 'The description of Event.id of nest_definition: 301 characters, more than the 300 allowed', ); }); }); diff --git a/packages/brains/README.md b/packages/brains/README.md index a57fd24cf..57ad8117b 100644 --- a/packages/brains/README.md +++ b/packages/brains/README.md @@ -43,18 +43,18 @@ Before a handler runs, the dispatcher rejects with `forbidden` a caller of anoth ## What happened in a brain -`defineListBrainEvents(presenters)` makes `list_brain_events`, a brain query at `GET /events` relative to the brain, under `brain:read`. It takes the presenters as a parameter, the way `makeSpecOperations` takes primitives, so this package depends on `@beonauto/operations` alone; the server passes the presenters of every package that owns a stream kind, today `makeSpecPresenters` of `@beonauto/specs`, which presents the specs, the executions and the events published to the brain with `publish_event`. At least one presenter must show at least one type of event. - -| Input | What it does | -| -------------- | ------------------------------------------------------------------------------------------------------------------------------------- | -| `order` | `desc`, newest first, when left out; `asc`, oldest first | -| `limit` | 1 to 100, 20 when left out: the most events a page answers with | -| `cursor` | The `next_cursor` of the page before, or the `cursor` of an event, to read on after it | -| `since` | A time in ISO 8601: what the brain recorded from then on, by the time it was recorded, in either order | -| `type` | One public type of event, which the published JSON Schema lists; it is translated to the stored types its presenters present under it | -| `execution_id` | The id of a run no other run started: only what that run and the runs it started recorded, the messages whose correlation is that run | - -It reads the brain's whole partition of the ledger (`BrainReader.readRecorded({ kind: 'everything' }, page)`), or with `execution_id` the messages correlated to that run (`{ kind: 'correlated', correlation }`), and answers `{ events, has_more, next_cursor }`, each event a `PublicEvent`, `{ id, cursor, causation_id, at, type, summary, data }`. A run that another run started belongs to the tree of the run at its top, so its own id answers nothing. `limit` counts the events a page answers with, the step events of a workflow's records included, through `eventsPageOf` of `@beonauto/operations`, so a page may end inside a record. A record of a stream kind no presenter presents, or of a type its presenter hides, is left out, and with `type` only the events presented under that name are kept, though another kind stores a type of the same name. A page looks at `limit` records, or with `type` at up to 1,000, and ends at 4 MiB of stored data. So a page may hold fewer events than `limit`, or none, while `has_more` is true; `next_cursor` is null only when nothing remains. A cursor that does not decode, or that another brain gave, is `invalid_input` at `/cursor`, and a `type` no presenter shows `invalid_input` at `/type`. +`defineListBrainEvents(presenters)` makes `list_brain_events`, a brain query at `GET /events` relative to the brain, under `brain:read`. It takes the presenters as a parameter, the way `makeDefinitionOperations` takes capabilities, so this package depends on `@beonauto/operations` alone; the server passes the presenters of every package that owns a stream kind, today `makeDefinitionPresenters` of `@beonauto/definitions`, which presents the definitions, the runs and the events published to the brain with `publish_event`. At least one presenter must show at least one type of event. + +| Input | What it does | +| -------- | ------------------------------------------------------------------------------------------------------------------------------------- | +| `order` | `desc`, newest first, when left out; `asc`, oldest first | +| `limit` | 1 to 100, 20 when left out: the most events a page answers with | +| `cursor` | The `next_cursor` of the page before, or the `cursor` of an event, to read on after it | +| `since` | A time in ISO 8601: what the brain recorded from then on, by the time it was recorded, in either order | +| `type` | One public type of event, which the published JSON Schema lists; it is translated to the stored types its presenters present under it | +| `run_id` | The id of a run no other run started: only what that run and the runs it started recorded, the messages whose correlation is that run | + +It reads the brain's whole partition of the ledger (`BrainReader.readRecorded({ kind: 'everything' }, page)`), or with `run_id` the messages correlated to that run (`{ kind: 'correlated', correlation }`), and answers `{ events, has_more, next_cursor }`, each event a `PublicEvent`, `{ id, cursor, causation_id, at, type, summary, data }`. A run that another run started belongs to the tree of the run at its top, so its own id answers nothing. `limit` counts the events a page answers with, the step events of a workflow's records included, through `eventsPageOf` of `@beonauto/operations`, so a page may end inside a record. A record of a stream kind no presenter presents, or of a type its presenter hides, is left out, and with `type` only the events presented under that name are kept, though another kind stores a type of the same name. A page looks at `limit` records, or with `type` at up to 1,000, and ends at 4 MiB of stored data. So a page may hold fewer events than `limit`, or none, while `has_more` is true; `next_cursor` is null only when nothing remains. A cursor that does not decode, or that another brain gave, is `invalid_input` at `/cursor`, and a `type` no presenter shows `invalid_input` at `/type`. The feed cannot show the brain's own creation, update and retirement: they are recorded in the org's `brains` stream, outside the brain's partition, and `get_brain` reads them. A retired brain stays readable, since the dispatcher runs every query on it. diff --git a/packages/brains/src/feed/list-brain-events.ts b/packages/brains/src/feed/list-brain-events.ts index b15d85a33..30c8cea90 100644 --- a/packages/brains/src/feed/list-brain-events.ts +++ b/packages/brains/src/feed/list-brain-events.ts @@ -18,8 +18,8 @@ import { eventsFound } from '../plain-language/feed-words.ts'; const description = [ 'Lists what happened in the brain a page at a time, newest first: definitions saved and retired, runs started and how they ended,', 'the steps of workflow runs, tool calls, and the events published to it, each with a summary in plain words.', - "Use it to follow the brain's activity or to find the events a recall function or a workflow's schedule can take; get_execution_history reads one run alone.", - '`type` keeps one type of event, `execution_id` one run and every run it started, `since` what was recorded from that time on,', + "Use it to follow the brain's activity or to find the events a recall function or a workflow's schedule can take; get_run_history reads one run alone.", + '`type` keeps one type of event, `run_id` one run and every run it started, `since` what was recorded from that time on,', 'and `cursor` is the next_cursor of the page before.', "The brain's own creation, update and retirement are not among the events; get_brain shows them.", ].join(' '); @@ -37,7 +37,7 @@ function feedInput(publicTypes: readonly string[]) { type: Schema.optionalKey( Schema.Literals(publicTypes).annotate({ description: 'Only the events of this type, by its public name' }), ), - execution_id: Schema.optionalKey(RunIdField), + run_id: Schema.optionalKey(RunIdField), order: PagingInputFields.order, limit: PagingInputFields.limit, }); @@ -53,15 +53,15 @@ function requireSomePublicType(publicTypes: readonly string[]): void { } } -function selectionOf(execution: string | undefined): RecordedSelection { - return execution === undefined ? { kind: 'everything' } : { kind: 'correlated', correlation: execution }; +function selectionOf(run: string | undefined): RecordedSelection { + return run === undefined ? { kind: 'everything' } : { kind: 'correlated', correlation: run }; } function feedReader(presentation: Presentation) { return Effect.fnUntraced(function* (input: FeedInput) { - const { cursor, since, type, execution_id: execution, order = 'desc', limit = defaultPageLimit } = input; + const { cursor, since, type, run_id: run, order = 'desc', limit = defaultPageLimit } = input; const paging = { order, limit, ...(cursor === undefined ? {} : { cursor }) }; - const page = yield* (yield* BrainReader).readRecorded(selectionOf(execution), { + const page = yield* (yield* BrainReader).readRecorded(selectionOf(run), { ...paging, ...(since === undefined ? {} : { since }), ...(type === undefined ? {} : { types: presentation.storedTypesOf(type) }), diff --git a/packages/brains/src/feed/run-feed.test.ts b/packages/brains/src/feed/run-feed.test.ts index a33035908..4e489cb1f 100644 --- a/packages/brains/src/feed/run-feed.test.ts +++ b/packages/brains/src/feed/run-feed.test.ts @@ -15,19 +15,19 @@ describe('the events of one run and the runs it started', () => { await feed.recordingWith('shelves/red', { type: 'added', text: 'c' }, ofRoot); expect([ - textsIn(await feed.reading({ execution_id: root })), - textsIn(await feed.reading({ execution_id: root, order: 'asc', type: 'note_added' })), - textsIn(await feed.reading({ execution_id: '0199a3c4-7d2e-7c1a-9b3f-2f1e0d9c8b7b' })), + textsIn(await feed.reading({ run_id: root })), + textsIn(await feed.reading({ run_id: root, order: 'asc', type: 'note_added' })), + textsIn(await feed.reading({ run_id: '0199a3c4-7d2e-7c1a-9b3f-2f1e0d9c8b7b' })), ]).toEqual([['c', 'a'], ['a'], []]); }); it('are refused for an id that is not a UUID', async () => { const { reading } = brainFeed(); - expect(await reading({ execution_id: 'not-a-run' })).toMatchObject({ + expect(await reading({ run_id: 'not-a-run' })).toMatchObject({ status: 'rejected', reason: 'invalid_input', - issues: [{ pointer: '/execution_id' }], + issues: [{ pointer: '/run_id' }], }); }); }); @@ -35,8 +35,8 @@ describe('the events of one run and the runs it started', () => { describe('a page of the events of a brain', () => { it('counts the events it answers, ends inside a record, and reads on from there in either order', async () => { const feed = brainFeed(); - await feed.recording('runs/r1', 'moved', 'x'); - await feed.recording('runs/r1', 'moved', 'y'); + await feed.recording('run-logs/r1', 'moved', 'x'); + await feed.recording('run-logs/r1', 'moved', 'y'); const read = (order: 'asc' | 'desc') => (cursor: string | undefined) => feed.reading({ order, limit: 2, ...withCursor(cursor) }); @@ -51,7 +51,7 @@ describe('a page of the events of a brain', () => { it('of one type keeps only the events of that type a record shows', async () => { const feed = brainFeed(); - await feed.recording('runs/r1', 'moved', 'x'); + await feed.recording('run-logs/r1', 'moved', 'x'); expect(textsIn(await feed.reading({ type: 'run_stepped', order: 'asc' }))).toEqual(['x1', 'x2']); }); diff --git a/packages/brains/src/plain-language/feed-words.test.ts b/packages/brains/src/plain-language/feed-words.test.ts index 62914e16a..83d56f34d 100644 --- a/packages/brains/src/plain-language/feed-words.test.ts +++ b/packages/brains/src/plain-language/feed-words.test.ts @@ -31,7 +31,7 @@ describe('the plain language of list_brain_events', () => { found([event, event]), found([event], { type: 'note_added', since: '2026-10-01T09:00:00Z', order: 'asc' }, true), found(Array.from({ length: 100 }, () => event)), - found([event], { execution_id: '0199a3c4-7d2e-7c1a-9b3f-2f1e0d9c8b7a' }), + found([event], { run_id: '0199a3c4-7d2e-7c1a-9b3f-2f1e0d9c8b7a' }), ]).toEqual([ 'Found 2 events in this brain, newest first.', 'Found 1 event of the kind asked for in this brain since the time given, oldest first. More remain after these.', @@ -46,7 +46,7 @@ describe('the plain language of list_brain_events', () => { found([], { since: '2026-10-01T09:00:00Z' }), found([], { type: 'note_added', cursor: 'WyJicmFpbiJd' }), found([], { type: 'note_added' }, true), - found([], { execution_id: '0199a3c4-7d2e-7c1a-9b3f-2f1e0d9c8b7a' }), + found([], { run_id: '0199a3c4-7d2e-7c1a-9b3f-2f1e0d9c8b7a' }), ]).toEqual([ 'Nothing has happened in this brain yet.', 'Nothing has happened in this brain since the time given.', diff --git a/packages/brains/src/plain-language/feed-words.ts b/packages/brains/src/plain-language/feed-words.ts index a7e2eb92b..23dfc1d14 100644 --- a/packages/brains/src/plain-language/feed-words.ts +++ b/packages/brains/src/plain-language/feed-words.ts @@ -10,14 +10,14 @@ interface FeedRequest { readonly type?: string; readonly since?: string; readonly cursor?: string; - readonly execution_id?: string; + readonly run_id?: string; } const eventNoun: Noun = { one: 'event', other: 'events' }; const orderInWords: Readonly> = { asc: 'oldest first', desc: 'newest first' }; -function ofTheKind({ type, execution_id: run }: FeedRequest): string { +function ofTheKind({ type, run_id: run }: FeedRequest): string { const kind = type === undefined ? '' : ' of the kind asked for'; return run === undefined ? kind : `${kind} of the run asked for`; } @@ -30,7 +30,7 @@ function nothingFound(request: FeedRequest): string { if (request.cursor !== undefined) { return `There is nothing more${ofTheKind(request)} to read in this brain${sinceTheTime(request)}.`; } - const filtered = request.type !== undefined || request.since !== undefined || request.execution_id !== undefined; + const filtered = request.type !== undefined || request.since !== undefined || request.run_id !== undefined; return `Nothing${ofTheKind(request)} has happened in this brain${sinceTheTime(request)}${filtered ? '' : ' yet'}.`; } diff --git a/packages/brains/src/testing/brain-feed.ts b/packages/brains/src/testing/brain-feed.ts index 25fb95a86..ce84c7ec4 100644 --- a/packages/brains/src/testing/brain-feed.ts +++ b/packages/brains/src/testing/brain-feed.ts @@ -48,7 +48,7 @@ const notes = presenterOf('notes', { added: 'note_added', hidden: null, kept: 'n const shelves = presenterOf('shelves', { added: 'shelf_filled' }); const steps: Presenter = { - streamKind: 'runs', + streamKind: 'run-logs', publicNames: { moved: ['run_moved', 'run_stepped'] }, present: (recorded) => { const { text, at } = decodeFact(recorded.data); diff --git a/packages/definitions/README.md b/packages/definitions/README.md new file mode 100644 index 000000000..9b5b7433c --- /dev/null +++ b/packages/definitions/README.md @@ -0,0 +1,363 @@ +# @beonauto/definitions + +The definition and run operations of auto-brain: create, list, read, update, retire and run a brain's function and workflow definitions, then inspect their runs and history, and publish events to a brain. They are defined on the application layer, [`@beonauto/operations`](../operations). + +## Definitions, runs and runtime adapters + +`Definition` is a named, versioned definition stored in one brain. A reasoning function and a computation function use Markdown with YAML front matter; a workflow uses a YAML document. `ListedDefinition` is the same view without the source document. A `Run` executes a definition against particular inputs and records how it ended; `RunDetail` includes the detailed record. + +`BrainFunctionDefinition` covers the currently implemented `ReasoningFunctionDefinition`, `InteractionFunctionDefinition`, `ComputationFunctionDefinition` and `RecallFunctionDefinition`; `WorkflowDefinition` identifies a stored workflow. `FunctionRun` and `WorkflowRun` identify their runs. The guards `isBrainFunctionDefinition`, `isWorkflowDefinition`, `isFunctionRun` and `isWorkflowRun` narrow decoded records by their `type` without copying or modifying them. Planned function types and custom adapters are not classified as implemented brain functions. The generic `Definition` and `Run` types still support extension adapters. + +These are stored records with names, versions and audit fields. The adapters' parsed source configurations use the separate names `ReasoningFunctionDefinitionDocument`, `ComputationFunctionDefinitionDocument` and `WorkflowDefinitionDocument`. + +`Capability` is the low-level adapter contract shared by functions, workflows and custom extension adapters. The server supplies these adapters explicitly. It is deliberately broader than a brain function: workflows coordinate functions rather than belonging to the five function types. The product taxonomy and supporting assets are defined in [Brain terminology](../../docs/concepts/terminology.md). + +## Defining a runtime adapter + +`defineCapability` turns what a capability package declares, a `CapabilityDeclaration`, into a `Capability`. The server passes its capabilities, in an explicit list, to `makeDefinitionOperations`. + +This extension interface accepts custom adapters. Product categories use the shared `FunctionType` metadata: `functionTypeOrder`, and `functionCategoryLabels`, `functionResourceLabels` and `functionDescriptions`, each keyed by type. + +```ts +import { InvalidInput } from '@beonauto/operations'; +import { defineCapability } from '@beonauto/definitions'; +import { Effect, Predicate } from 'effect'; + +const parseGreeting = (source: string) => + source.includes('{name}') + ? Effect.succeed({ template: source.trim() }) + : Effect.fail( + new InvalidInput({ + detail: 'The greeting names no one', + issues: [{ detail: 'Line 1: expected {name} where the name goes', pointer: '' }], + }), + ); + +export const greeting = defineCapability({ + type: 'greeting', + title: 'Greeting', + guide: { name: 'greeting' }, + noun: { one: 'greeting', other: 'greetings' }, + describeOutput: () => 'It greeted.', + mediaType: 'text/plain', + parse: parseGreeting, + summarize: ({ template }) => ({ + description: `Answers with "${template}"`, + inputSchema: { type: 'object', properties: { name: { type: 'string' } }, required: ['name'] }, + outputSchema: { type: 'string' }, + }), + run: ({ template }, input) => + Predicate.hasProperty(input, 'name') && Predicate.isString(input.name) + ? Effect.succeed({ output: template.replace('{name}', input.name), record: { template } }) + : Effect.fail( + new InvalidInput({ + detail: 'The input names no one', + issues: [{ detail: 'Expected a string', pointer: '/name' }], + }), + ), +}); +``` + +A capability has: + +- `type`: 3 to 32 lowercase letters, digits and hyphens, starting with a letter. It is the type of the capability's definitions in routes, in the `type` field and in stream names, so it never changes. +- `title`, and `guide`: the `name` of the guide that holds its format, which the MCP layer serves through `get_guide` and as the resource `guide://`, so that no operation description carries a format. The server makes the guide from the public reference page of the type, `docs/reference/-format.md`, and refuses to start without it. A guide may carry `onThisServer`, one sentence of what this server's configuration offers, such as the providers a reasoning function names its model through, which the guide ends with. `create_definition` names each type with the kind of definition it is and its guide, from the registered capabilities. +- `mediaType`: the media type of its definition documents, such as `text/markdown`. +- `parse(source)`: turns the document into the capability's own value. Parsing is validation: everything that can be checked without running is checked here. It fails with `InvalidInput`, whose issues each say in `detail` where in the document and what is wrong (line and problem). An issue's `pointer` addresses the document as a whole, so it is `''`; the operations answer it under `/source`. +- `summarize(parsed)`: what the operations show about a definition without knowing the type: an optional `description`, optional JSON Schemas of the input a run takes (`inputSchema`) and the output it gives (`outputSchema`), `triggers`, the parsed triggers of a definition that starts runs on its own, as a workflow with a schedule does, in the order its document names them, each with its `kind`, `event`, `cron` or `every`, its `reference` in the document, and its rule: an event trigger's `filters`, each a `reference`, a `type` and the `attributes` it matches, a cron's `expression` or an every's `milliseconds` (`TriggerSchema`), and optional `warnings`: what `parse` found that does not stop the definition from being accepted but may not work everywhere, each a line of text that says where in the document (for reasoning: a schema some providers reject or do not enforce); and optional `details`, a JSON object the registry stores with the definition's record and keeps in its state, but never in a definition an operation answers, so that code which reads the record without the capability's parser can act on it (for recall: the fold, `answer`, the filters, `initial` and the view's schema, which the workflow host folds a view by). +- `run(parsed, input, context)`: runs the definition. `input` is the caller's JSON value; `context` carries the run's `id`, the `org`, the `brain`, the `caller` who started it (the identity the call was authorized for), the `definition` that runs, by `name` and `version`, the `journal` through which it records the tool calls it makes (see [Tool calls](#tool-calls)), its `lineage`: `startId`, the id of the `run_started` that began this attempt, and `correlationId`, the id of the run at the top of the tree the run belongs to (see [The lineage of a run](#the-lineage-of-a-run)), its `depth`, the reaction depth of the run (see [The reaction depth of a run](#the-reaction-depth-of-a-run)), its `callDepth`, the number of calls above it (see [The call depth of a run](#the-call-depth-of-a-run)), and `longestRunOf(type, name)`, the longest a run of the active latest version of that definition may take, or nothing for a definition the brain does not have, which a workflow reads at its start. It answers with the `output`, a JSON value returned to the caller, and a `record`, a JSON object of what happened, stored with the run (for reasoning: the rendered prompt, the model, token usage). It fails with `InvalidInput`, with pointers into the input (`/name` above; the operations answer them under `/input`); with `Unavailable` when something the capability depends on cannot serve now and retrying may work; or with `Conflict` when the definition cannot run as written, which only running it can tell (for reasoning: a model the provider does not have; for computation: a program that raised an error on the input), so the definition must be updated before it can run, with the kind `unworkable` when it says so. A rejection may carry a `record`, a JSON object of what the capability did before it (for reasoning: the tokens and the duration of a model call whose answer it could not use), which the run keeps on its `run_rejected`, held to the same size limit as any record. A capability that starts work which finishes after the call returns, such as a workflow, answers `{ finishesLater: true, record }` instead, the record saying what it started (for a workflow: its run reference); see [Runs that finish later](#runs-that-finish-later). + +The compiler holds a capability to its contract. The value `parse` gives is the value `summarize` and `run` take. `parse` may fail only with `InvalidInput`, and `run` only with `InvalidInput`, `Unavailable` or `Conflict` (the union `CapabilityRejection`). Neither may ask for a service: whatever a capability needs, such as a model client, it closes over when it is made. The output must be JSON and the record a JSON object. + +TypeScript infers the parsed value from `parse` when `parse` is a function declared elsewhere, as above, or an arrow function. Written inline as `Effect.fnUntraced(function* (source: string) {...})`, `parse` does not give its type to `summarize` and `run`; declare it as a constant first. + +`parse` runs on every create, update and run, and its parsed value is not kept, so it must give the same answer for the same document and be quick. A defect in `run`, or an output that is not JSON, fails the run. + +A call cancelled while `run` runs, because its client went away or the server is stopping, stops `run` and records the run `failed`. A retry with its id can run it again only if no recorded tool call blocks another attempt; see [Run ids and retries](#run-ids-and-retries). A capability that defines `whenCancelled: 'finish'` is not stopped: the call waits for `run` to end and records its answer. Recording the start and the end of a run is never cut short. An abrupt process crash can still leave a run `started` when it prevents the ending from being recorded. + +## The operations + +`makeDefinitionOperations(capabilities, presenters?)` returns the eleven operations for a catalog. All are brain operations, so their routes are relative to the brain. `defineCreateDefinition`, `defineListDefinitions`, `defineGetDefinition`, `defineUpdateDefinition`, `defineRetireDefinition`, `defineRunDefinition` and `defineListRuns` make one of them each for a list of capabilities, `getRun` is another, `defineCancelRun(capabilities)` another, `defineGetRunHistory(presenters)` another, and `defineGetBrainAnalytics` the last, for a list of capabilities too; `presenters` defaults to `makeDefinitionPresenters(capabilities)` (see [Reading runs](#reading-runs)). The list must hold at least one capability, and no two of the same type. + +| Operation | Kind | Route | Input | Answer | Rejections of the handler | +| --------------------- | ------- | ---------------------------------------- | --------------------------------------------------------------------------------- | ------------------------------------------------ | ------------------------------------------------------- | +| `create_definition` | command | `POST /definitions/{type}` | `type`, `name`, `source` | the definition, `201` | `not_found`, `invalid_input`, `conflict` | +| `list_definitions` | query | `GET /definitions/{type}` | `type`, `include_retired` (default `false`) | `{ definitions }`, sorted by name, no documents | `not_found` | +| `get_definition` | query | `GET /definitions/{type}/{name}` | `type`, `name` | the definition, active or retired, with document | `not_found` | +| `update_definition` | command | `PUT /definitions/{type}/{name}` | `type`, `name`, `source` | the definition at its new version | `not_found`, `invalid_input`, `conflict` | +| `retire_definition` | command | `POST /definitions/{type}/{name}/retire` | `type`, `name` | the definition | `not_found`, `conflict` | +| `run_definition` | command | `POST /definitions/{type}/{name}/run` | `type`, `name`, `input` (any JSON, default `{}`), `run_id` (optional UUID) | the run | `not_found`, `conflict`, `invalid_input`, `unavailable` | +| `get_run` | query | `GET /runs/{run_id}` | `run_id` | the run, with its record | `not_found` | +| `cancel_run` | command | `POST /runs/{run_id}/cancel` | `run_id`, `reason` (optional, 1 to 1,024 characters) | the run as it stands | `not_found`, `conflict` | +| `list_runs` | query | `GET /runs` | `type`, `name`, `status`, `limit`, `cursor`, all optional | `{ runs, has_more, next_cursor }`, newest first | `invalid_input` | +| `get_run_history` | query | `GET /runs/{run_id}/history` | `run_id`, `order` (default `asc`), `limit`, `cursor` | `{ events, has_more, next_cursor }` | `not_found`, `invalid_input` | +| `get_brain_analytics` | query | `GET /analytics` | `days` (7, 14 or 30, default 7), or `from` and `to`; `type`, `name`, all optional | the analytics of the brain | `invalid_input` | + +A definition carries `type`, `name`, `version`, `status` (`active` or `retired`), `media_type`, the `description`, `input_schema`, `output_schema` and `warnings` its capability gives when it gives them, `created_at`, `created_by`, `updated_at`, `retired_at` on a retired definition, and its document as `source`. `get_definition` adds `standing` when its capability's `standing` answers one (below). A listed definition is the same without `source` and `standing`. Times are ISO 8601 UTC strings read from Effect's `Clock`. `DefinitionSchema`, `ListedDefinitionSchema`, `RunSchema` and `RunDetailSchema` are the schemas. + +- `type` is the type a capability states. The published JSON Schema of the field is a plain `{ "type": "string", "enum": [...], "description": ... }` of the known types. Decoding checks the shape and then that a capability of the server serves the type, so a malformed type and one the server does not run are both `invalid_input` at `/type` on every operation that takes one, `list_runs` and `get_brain_analytics` among them; the second names the types the server runs. Effect would publish the types under `allOf`, because it inlines no `enum` from a check, so each operation replaces that one property of its input's JSON Schema. +- `name` is 3 to 48 lowercase letters, digits and hyphens, starting with a letter: unique among the definitions of its type in the brain, and never reused. +- `source` is at most 65536 bytes in UTF-8, checked at decoding. The JSON Schema says `maxLength: 65536`, which every such document meets. +- A document the capability's `parse` rejects is `invalid_input` with the capability's issues under `/source`, and nothing is stored. +- `create_definition` meets `conflict` when an active or a retired definition of the type holds the name. +- A definition with triggers is recorded with its `triggers` in the content of `definition_created` and `definition_updated`, which `get_definition` and `list_definitions` show, and a brain holds at most 1,024 active definitions of one type with triggers, however many each has, `mostReactingDefinitions`: a `create_definition` or `update_definition` that would make one more is `conflict`, counted after the version it replaces; `get_definition` of an active definition with triggers shows `triggers_since`, the id of the record that made its current version, from which what its triggers start is a run of that version. It is one id for the definition, while the workflow host keeps, for each trigger, the record that activated it as it is. +- `update_definition` replaces the document. Every update that changes the document makes a new version, one more than the last; an update with the same document succeeds and records nothing. It meets `not_found` for a missing definition and `conflict` for a retired one. +- `retire_definition` is permanent: a retired definition can be read and listed, but not updated, executed or recreated. Retiring a retired definition succeeds and records nothing. +- Every command also meets `conflict` when the definitions of the type, or the run, changed while it decided. +- `run_definition` runs the active latest version of the definition. It meets `not_found` for a missing definition, and `conflict` for a retired definition, a definition whose stored document its capability no longer parses, or a definition its capability finds cannot run as written. + +The queries need `brain:read` and the commands `brain:write`. `run_definition` is a command, because it records a run, so a caller that may only read cannot execute a definition. + +## Runs + +A run carries `run_id`, `type`, `name`, `definition_version`, `status`, `output` when it succeeded, `rejection` (`reason`, `detail`, `issues` for `invalid_input`, for `unavailable` the `kind` and `because` the capability gave, for `conflict` its `kind`, and for `cancelled` the kind of the cancel) when the capability rejected it or the run was cancelled, `started_at`, `started_by`, and `finished_at` once it ended. `get_run` also shows the `record` the capability gave of what it did: of the run that succeeded, of what it did before it rejected the run when its rejection carries a `record`, such as the tokens a model call spent, or of the work it started that finishes later, kept when that work ends rejected or failed and dropped when a retry starts the run again. `run_definition` answers without the record, which can be large, since the output is what its caller asked for. Its status is `started` while it runs, while work it started finishes after the call returned, or when the process ended before it finished; then `succeeded`, `rejected` or `failed`. + +`run_definition` answers with the run when it succeeded, and in status `started` when the capability started work that finishes later. When the capability rejects it with `invalid_input`, `unavailable` or `conflict`, the operation is rejected with that reason, detail and issues, and the rejection is recorded on the run. When the capability breaks down, the call fails with an incident, as any defect does, and the run is recorded as `failed`; the defect itself goes only to the incident reporter. A rejected operation carries no run id, so a caller that wants to read a rejected run later gives it an id. + +### Size limits + +The ledger's cloud store holds at most 2 MB in a row, so a run records bounded values. Sizes are counted on the value encoded as JSON, in UTF-8 bytes. + +- The `input` may take at most 262144 bytes (256 KiB). A larger input is rejected with `invalid_input` at `/input` when the call is decoded, before anything is recorded. +- The `input` may nest at most 512 levels deep, `mostInputDepth`, as deep as a workflow holds a value; `nestsWithin` measures it, counting each array and object a level. A deeper input is rejected the same way, for every runtime adapter, so that none is recorded deeper than a store takes: on SQLite an input 3,000 levels deep failed to append, while PostgreSQL took it. +- The `output` and the `record` of a capability may take at most 1048576 bytes (1 MiB) together. A capability that answers with more breaks down: the call fails with an incident and the run is recorded as `failed`. The same holds for the record of work that finishes later, and for the output and record it is settled with. + +JSON Schema has no keyword for the encoded size of any JSON value, so the published schemas state both limits in the descriptions of `input` and `output`. `mostInputBytes` and `mostResultBytes` export them, so that a capability can keep what it answers within them. + +### Run ids and retries + +A caller may name a run with `run_id`, a UUID; otherwise the operation makes one, a version 7 UUID. Ids are kept in lowercase. An id belongs to one run: one type, one definition and one input. A call with an id of another definition or another input meets `conflict`. + +A run has a **final result** once it succeeded, once the capability rejected its input as invalid, once it was cancelled, or once it ended `unanswered`, the request it made expired or never delivered. A call with the id of a run that has a final result runs nothing: it answers the same run, or the same `invalid_input` rejection, even when the definition has changed or been retired since. A call with the id of a run that waits for work it started to end runs nothing either: it answers the run as it stands, `started`. A call with the id of a run that has no final result and waits for nothing runs the active latest version of the definition again and records another attempt: when the run started within its call and never finished because the process ended, when its call was cancelled, when the capability was unavailable or found a conflict (a retry after the definition was updated runs the new version), and when it failed. + +A run that called tools is the exception, because a tool may have changed something: once its stream holds a tool call, a call with its id that would run it again is rejected with `conflict`, kind `tools_called`, and runs nothing, whether the run failed, was rejected as `unavailable` or with a `conflict`, or stays `started` because the process ended; it is never recorded as finished for it, since that could mark a duplicate still running elsewhere as failed. So is a call with the id of a started run whose definition calls tools before any call is recorded: the first attempt may still be in progress, about to call a tool, or may have stopped without recording how it ended, and running a second would let two runs call tools. The capability tells from the parsed definition whether it calls tools (`callsTools`), and the start of a run records it on its `run_started` as `calls_tools: true`; a start is refused while the attempt started last is running and either recorded that it calls tools or the definition calls them now, so a definition that loses its tools while a run of it is going cannot let a second run start under its id. A new run needs another id. A run that called tools and succeeded, or whose input was rejected, is answered again as any other. + +So running is **at least once**: the capability may run more than once for one id, when a call is retried after the server stopped during a run, after `unavailable`, a `conflict` the capability found, or a failure, or when two calls with the same id run at the same moment and the ledger lets both start. Each id has **exactly one recorded result**: the first final result recorded for it is never replaced, and every later call with the id answers it. A capability that acts on the world, such as one that sends a message, must tolerate running twice for the same `run.id`. + +### Runs that finish later + +A capability may state `mostActive`, the most definitions of its type a brain may keep active, counted by the registry at each save: a creation that would pass it, and an update while the brain keeps more than it, as when the bound was lowered, is `conflict`, and nothing else changes; a retirement is always taken. It is unbounded unless given; recall takes it from a setting of the server. A capability may state `standing({ org, brain, name, version, status })`, which `get_definition` asks for the definition it reads and answers as `standing` when it gives one, so a capability that keeps something for a definition beside the ledger, as recall keeps a view, says how it stands; without it a definition has none. + +A capability may state `longestAnyRunMs`, the longest one run may legitimately take (for reasoning: the deadline of a model call for the most output tokens, 60 seconds and 25 ms a token, 1660000 ms for 64000), and `longestRunOf(parsed)`, the longest a run of one definition may take (for reasoning: the larger of the bound of its tool loop and its model's deadline; for a workflow: the longest a workflow may run), which the prepared definition carries as `longestRunMs`; a capability that states neither is given 10 minutes. A workflow gives a call of a definition it names as written that long, and a minute more, before the call fails, and any other call the longest `longestAnyRunMs` of the capabilities, and a minute more. A capability states `reachesOutside: true` when its runs call systems outside the server, as reasoning calls model providers; `run_definition` then says it reaches outside, which its MCP tool shows as `openWorldHint`. A capability states `mayChangeOutside: true` when those calls may change something there, as reasoning does once an MCP server is configured; `run_definition` then says so, which its MCP tool shows as `destructiveHint`, so that an assistant asks before running a definition. + +A capability such as a workflow starts work that completes long after the call returns. It states `finishesLater: true`, or a function of the parsed definition when only some of its definitions finish later, as an interaction function's notification to the inbox ends within its call while every other request waits for an answer; the prepared definition carries the answer as `finishesLater`, which the start of each run records as `finishes_later`, and its `run` answers `{ finishesLater: true, record }`, the record saying what it started. Such a capability defines `whenCancelled: 'finish'`, so that a call cancelled while `run` runs, because its client went away or the server is stopping, waits for `run` to end and records what it started; `run` must then end within a bounded time (a workflow's start gives up after 10 seconds). The run is recorded as deferred and stays `started`: `run_definition` answers with it in status `started` (still `200`), `get_run` shows it `started` until it is settled, and a retry with its id answers it as it stands without starting the work again. + +Whoever started the work settles the run when the work ends, with `runSettler`: + +```ts +import { runSettler, type SettleRun } from '@beonauto/definitions'; + +const settle: SettleRun = runSettler(ledger); + +settle(run, { status: 'succeeded', output, record }); +settle(run, { status: 'rejected', reason: 'unavailable', detail: 'The worker pool is gone' }); +settle(run, { status: 'failed' }); +``` + +`runSettler(ledger)` takes the unbound `Ledger` and gives a `SettleRun`, which takes, after the run and the settlement, the lineage to record the settlement with. It is not an operation and no transport reaches it: the server's composition root, the only code that holds the `Ledger`, makes it and hands it to the capabilities that finish later when it makes them. Each call to `settle` names the run by `org`, `brain` and `id` (the `RunContext` a capability got carries all three), and binds the ledger to that org and brain alone, through `streamPrefixOfBrain`, after checking the ids are well formed, so it reaches nothing but that brain's `runs/{id}` stream. It records through the same stream and decider as `run_definition`: + +- a run whose start says it finishes later, or that was deferred, and that has not been settled is settled: `succeeded` with its output and record, `rejected` with its reason, detail, kind, because, issues and record as given, `unanswered` with the kind `expired` or `undelivered` among the reasons, or `failed` with its incident; so a run that ends in its first input settles before its deferral is recorded, and the finish of the call that started it then records no deferral; +- a settlement has a key, its status, reason and kind, and for a success the SHA-256 digest of its output as canonical JSON: settling it again with the same key, from any actor, records nothing and answers the run; +- settling a run that already ended with another key, or one that runs within its call, is `conflict`; +- once a reply brought back an answer, the run's `broughtAnswer`, settling it with anything but a success whose output is that answer is `conflict` (`answeredByAReply`), decided with the run's state at the append, so an answer given meanwhile, or a cancel, cannot settle it otherwise however the reads and the reply interleave; +- settling one the brain does not have, or an ill-formed address, is `not_found`; +- an output and record over the size limit, or not JSON, settle it as `failed`, and the call dies with the defect. + +`brainBoundSettler(writer)` settles the same way through a writer already bound to one brain, as an operation's `StreamWriter` is, so `answer_interaction` settles the run it answers with the caller's ledger and names it by its id alone. + +The settlement is recorded as done by the actor it names, `by`, or else by the brain's own caller, `brain:`. A deferred run settled as `unavailable` or `failed` has no final result, so a call with its id runs it again, as any other. A call with the id of a run whose start says it finishes later but that recorded no deferral, because the process ended in between, starts it again rather than answering it, so such a run is never left waiting for nothing. + +A deferred run takes the facts of its own work until it is settled, tool calls and deliveries among them (see [Outbound calls](#outbound-calls)), and refuses them after, with words that say the run has ended. Its state keeps the number of its last call, which the decider gives each call as it appends its start, and whether any call may have changed something outside, which only a tool call of a reasoning run sets. The deferral names the definition that ran, `definition_type`, `name` and `definition_version`, as the endings do, so the projection of open requests reads what asked from the deferral alone. + +### The kind of a conflict + +A run rejected with `conflict` records the kind the capability gave, so `get_run`, `list_runs`, the history and the run's words all show it. `unworkable` says the definition cannot run as written for this input, which only changing the definition or the input puts right: a computation function's program that raised an error, gave no output or more than one, did more work or nested deeper than a run may, or answered what the output schema refuses; `run_definition` also answers it, without recording a run, for a stored definition its capability no longer parses. `tools_called` says a run of a function that may have called tools is not run again under its id (see [Run ids and retries](#run-ids-and-retries)). `@beonauto/definitions/testing` has a probe whose mishap `unworkable` rejects that way. + +## Reading function documents + +`@beonauto/definitions/document` is the reader that every capability whose document is YAML front matter and a body shares, so each capability supplies only its own keys and what its body means: + +- `splitDocument(source, definition)` splits the front matter between two lines of three dashes from the body, with the line each starts on; `definition` names the document in the message for a source that does not start with the dashes. +- `frontMatterIn(text, firstLine, shape)` reads the front matter with the YAML rules every capability keeps (YAML 1.2's core schema, strictly, with no anchor, alias or tag and at most 72 levels of nesting) and decodes it with the capability's `FrontMatterShape`: its `sections`, the keys allowed at the top and under each section, of which any other is an issue at its line; its `decode`; and `required`, which the message for empty front matter names, such as `the model` for reasoning and `the language` for computation. +- `compileJsonSchema(document, { what, nesting })` checks a JSON Schema's shape, size, references and loops, and compiles a validator that answers issues with JSON pointers; `what` names the schema in its issues and `nesting` bounds the values it validates. `jsonSchemaLimits` are its bounds, `boundedJsonSchema` its first check alone, of the size and nesting of a schema, which reasoning also runs on the input schema of a tool, and `shapeIssues`, `isKnownKeyword` and the helpers of `json-bounds.ts` the checks it is built from. +- `issueAt` places an issue at the line of the deepest part of its JSON pointer that the front matter has, `issueText` writes it as `Line 7, /input/schema: ...`, or `Line 7: ...` for the document as a whole, and `reportedIssues` sorts a document's issues by line and bounds how many it reports. + +Reasoning reads its documents with it and keeps its own keys, messages and tests; computation reads its own with the same reader. The subpath is separate from the main entry because it imports the YAML parser, which nothing else in this package needs. + +## Templates of a capability + +`@beonauto/definitions/template` is the Liquid engine every capability whose documents hold templates shares, as a factory that gives each capability an instance of its own, so what one registers no other sees. `templateEngine(registrations?)` makes one, with the filters and tags every instance keeps, strict filters and variables, own properties only, and the bounds of `templateLimits`, 64 KiB of template, 1,000 names, 200 ms and 5 MB of a render, and with the filters and tags the capability registers, given as a function that answers them: the reasoning function adapter registers its system block and its prompt filters, `money`, `clip` and `words`, and the interaction capability nothing. `parsedTemplate(engine, body, firstLine)` parses a template, refusing one that does not parse with the line of its issue, and answers it with the variables it reads, each with its line in the document, and an issue beside it when it uses more names than a template may. `renderedTemplate(engine, parsed, { variables, emitter, refusalOf })` renders it with the variables it is given into an emitter of the capability's own, which decides what a written value becomes and may refuse one, and answers a missing variable, a bound of the render or a failed filter, with its line, or what `refusalOf` makes of what the emitter threw. `inputVariableIssues(variables, inputSchema, what)` refuses a template that reads anything but `input`, `today` and `now`, or a property a closed input schema does not have, in the words of `what` the template is. `outputText` writes a value as Liquid writes it, an object as `[object Object]`; a rendered value is text, never parsed again as a template. + +The subpath is separate from the main entry because it imports `liquidjs`, which nothing else in this package needs. + +`@beonauto/definitions/json-schema` exports, without the YAML reader, `compileJsonSchema`, `schemaCheckOf(schema, { what, nesting })`, a check of a value that names at most three of its issues, `issuesDetail(issues, what)`, the one wording of them, each at its pointer or as the output or the view itself at the root, and `checkedWorker`, the URL of the one worker module that checks values against their schemas where they were computed, for computation and recall functions alike (`src/checking`). The checked worker serves its jobs with `serveJobs` of `@beonauto/workflow-engine/job-loop`: a program with `answerOf` under the output check of the schema its request carries as its context, and a page of folds with `foldAnswerOf` under the view check of each view's schema. Both checks are `schemaCheckOf` at the value depth of 512, the view check words its issues with `issuesDetail`, and the job loop compiles each once for each schema and keeps it while the worker is warm. The computation and recall capabilities name it for every run, the folds of views among them, with a `null` context when there is no output schema, so production runs one worker module. The entry leaves out the YAML reader because the reader's modules and the YAML parser had taken a worker from 138 ms to 254 ms to load, measured on a busy machine, a cost now paid once for each worker the pool starts rather than for each run. + +## Reading runs + +`list_runs` lists the runs of a brain, newest first by the position of the first message of each run stream, so a run started again with the same id keeps the place of its first start, and two started in the same millisecond keep a fixed order. It reads the ledger's selection of the first and the latest message of every run stream (`BrainReader.readRecorded({ kind: 'runs', notBeginningWith: ['run_cancel_requested'], definitionType?, name? }, page)`), which leaves out a stream that begins with its caller's cancel, since such a stream holds no run, before the page counts, so a page is full while runs remain and `has_more` is false after the last. `status` is the page's `types`, the stored types of a run's latest message in that status (`storedTypesByStatus`), and `type` and `name` go to the ledger as `definitionType` and `name`, which it reads from the run's first start, `run_started`, so the store answers every filter as it examines the runs, up to 1,000 of them for a page with any filter, and a page of the runs of one definition is full while it finds them; a run started again under its id is always started for the same definition, so its first start names the definition of every start. The operation folds the two with the run decider's `evolve`, so a `ListedRun` is the run as `get_run` shows it, without its `output`, its `record` and the `detail` and `issues` of a rejection: `run_id`, `type`, `name`, `definition_version`, `status`, `started_at`, `started_by`, `finished_at`, and a `rejection` of `reason`, with the `kind` and `because` of `unavailable` and the `kind` of `conflict`. When the latest message is a start, the run shows that start; when the run was started again and has since finished, it shows its first start, since the selection holds no other, while `get_run` shows the latest. + +- `status` is answered by the ledger from the stored type of the latest message: `storedTypesByStatus` in `src/reading/run-status.ts` is the one place that maps a status to the stored types it stands for, `started` to `run_started`, `run_deferred`, a cancel and the facts of the run's work, its tool calls and deliveries. +- `type` and `name` are applied after decoding the first message of each run the page looked at, so a filter that matches rarely answers short or empty pages with `next_cursor`. A type the server does not offer lists the runs recorded under it, if any. +- `limit` is 1 to 100, 20 when left out. A page also ends at 4 MiB of stored data and, with `status`, after looking at 1,000 runs. Every page carries `has_more` and `next_cursor`, null when nothing remains, and a cursor that does not decode, or that another brain gave, is `invalid_input` at `/cursor`. + +`get_run_history` reads the two streams of one run through the ledger's run selection, `runs/{run_id}` and, for a workflow's run log, `run-logs/{run_id}`, one page at a time, oldest first unless `order` is `desc`. Each record is shown through the presenter of its stream kind, as none, one or more events, and hidden when its kind has none; the server gives the presenter of the run log, `runPresenter` of `@beonauto/coordination`, so a workflow's history shows a `workflow_input_applied` event for each input its run took, followed by an event for each step entry of that input. `limit` counts the events a page answers with, step events included, through `eventsPageOf` of `@beonauto/operations`, so a page may end inside the events of one record, with a `next_cursor` that reads on from the next of them. The events follow the ledger's order, within a page and across pages, so reading on from the cursor of any event answers exactly the events after it; it is also the order of their causes, since a message is appended only after the message that caused it, as a child's start after the record of the step that waits for it. A run the brain does not have is `not_found`. A page that holds a record other than a cancel proves the run exists, and the read is that page alone, however long the run's streams. A page that holds nothing but `run_cancel_requested`, or nothing, reads one more record, the newest of the two streams, newest first and without its data: none, or a cancel alone at the first place of its stream, which is all a stream its caller cancelled before the start ever holds, is `not_found`, and anything else is the run, whose page is answered. That read keeps no horizon, since on PostgreSQL only a read oldest first stays behind the oldest write still open in the ledger's database. So the first page of a run whose records are still behind that horizon is empty, with `has_more` false and `next_cursor` null, and a reader reads it again; and no page loads the run's whole stream, which has no bound on its size. + +Each event is a `PublicEvent`, `{ id, cursor, causation_id, at, type, summary, data }`: `id` is the id of the message the event presents, or for a step event the id `stepEventIdOf` of `@beonauto/workflow-engine` derives from its entry, `cursor` the place to read on from, the record's cursor, or for an event of a workflow's record a cursor inside it, `causation_id` the id of the message or step event that directly caused it, or null, `at` the event's own time, `type` a public name, `summary` plain words with no ids, and `data` at most 4 KiB as JSON. `makeDefinitionPresenters(capabilities)` gives the presenters of the four stream kinds this package owns, which the server also passes to the feed of the brain, `list_brain_events` of `@beonauto/brains`: + +| Stream kind | Stored type and public name | `data` | +| ------------- | ------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `runs` | `run_started` | `run_id`, `by`, `definition_type`, `name`, `definition_version`, `input_bytes` | +| `runs` | `run_succeeded` | `run_id`, `by`, `output_bytes`, `record_bytes` | +| `runs` | `run_rejected` | `run_id`, `by`, `reason`, `detail` cut at 1024 bytes, `kind` and `because` when given, for `invalid_input` `issue_count` and the first five `issues`, and `record_bytes` when the rejection kept a record | +| `runs` | `run_failed` | `run_id`, `by` | +| `runs` | `tool_call_started` | `run_id`, `by`, `number`, `call_id`, `server`, `tool`, `arguments_bytes`, `arguments_sha256`, and `arguments_json` cut at 2048 bytes when recorded | +| `runs` | `tool_call_answered` | `run_id`, `by`, `number`, `outcome`, `result_bytes`, `result_sha256`, `duration_ms`, `jsonrpc_id`, `server_request_id` when recorded, and `result_json` cut at 2048 bytes when recorded | +| `runs` | `run_deferred` | `run_id`, `by`, `record_bytes`, and what the run's capability shows of the record, when it gives words for it | +| `runs` | `delivery_started` | `run_id`, `by`, `number`, `delivery` with its `server` and `tool`, `target` cut at 256 bytes, `arguments_bytes`, `arguments_sha256`, and `arguments_json` cut at 2048 bytes when recorded | +| `runs` | `delivery_ended` | `run_id`, `by`, `number`, `outcome`, `because` and `retry_after_ms` when recorded, `detail` cut at 1024 bytes, the call's fields when a call was answered, `delivered_as`, `replies_in`, `duration_ms` | +| `runs` | `reply_taken`, `reply_refused` | `run_id`, `by`, `reading` with its `server` and `tool`, the `reply`'s `id` and `sender`, and for a refusal `because`, `told` and the issues, never the reply's words | +| `tool-tests` | `tool_test_started` | `test_id`, `by`, `server`, `tool`, `arguments_bytes`, `arguments_sha256`, and `arguments_json` cut at 2048 bytes when recorded; `toolTestPresenter` of `@beonauto/mcp` presents both, and the server registers it beside these | +| `tool-tests` | `tool_test_answered` | `test_id`, `by`, `outcome`, `result_bytes`, `result_sha256`, `duration_ms`, `jsonrpc_id`, `server_request_id` when recorded, and `result_json` cut at 2048 bytes when recorded | +| `definitions` | `definition_created`, `definition_updated` | `definition_type`, `name`, `by`, `version`, `source_bytes`, the first 300 characters of `description`, `input_schema_bytes`, `output_schema_bytes`, `warning_count` | +| `definitions` | `definition_retired` | `definition_type`, `name`, `by` | +| `events` | `event_published` | `event_id` and `event_type` cut at 256 bytes, `source` and `subject` cut at 1024, `time`, `data_bytes` when it has data, the attributes the brain `filled`, for an event a workflow emitted `emitted_by` and `depth`, `by` | +| `reactions` | `reaction_refused` | `workflow` cut at 256 bytes, `count`, the last `reason` cut at 1024, and the `minute` it counts; the workflow host records it (`ReactionRefusedSchema`, `reactionsStreamKind`) | + +The run presenter asks the run's capability, by the run's `definition_type`, for the words of its deferral and its work, `runWords`: `deferral(record)` answers the summary and the fields of the record it shows, or nothing, under the public type `deferralType`, `run_deferred` unless the capability names its own, as an interaction function names `interaction_requested`, and `delivery(fact)` the words of an attempt, which `defaultRunWords` gives every capability and a capability the server no longer has. A capability that gives no words for its deferral, as a workflow gives none, shows it as no event: it stays on the run's stream, where `status` and a retry read it, and nothing names it as its cause, so a history goes on from a start to what the run did next, the inputs and steps of a workflow directly. An interaction function's deferral is its request, shown as “waiting for an answer”. A delivery's words name the tool it calls and say why an attempt failed in words, `deliveryEnded`. Sizes are of the value as JSON in UTF-8, the document's of its own text. A cut is made at a code point, measured as JSON so that escapes count, and `by` is cut at 256 bytes; an issue's `detail` at 256 bytes and its `pointer` at 128; a tool call's `call_id`, `server`, `tool` and `server_request_id` at 256 bytes, its digests and a `jsonrpc_id` that is text at 128. The latest message of a running run may be a tool event, which `list_runs` shows as `started`. A test over the event schemas of both deciders holds every stored type to a decision of its presenter, and the largest record each type can hold to 4 KiB of `data`. + +## Storage + +The definitions of one type in a brain are one stream, named `definitions/{type}` relative to the brain. Its events carry a `type`, the definition `name`, who recorded them (`by`) and when (`at`): + +- `definition_created`, with `version` 1 and the `content`: the `source` and the `description`, `input_schema`, `output_schema`, `warnings` and `details` its capability gave +- `definition_updated`, with the new `version` and the whole new `content` +- `definition_retired` + +`DefinitionEventSchema`, the schema of these events, and `definitionTypeStreamOf(type)`, the name of the stream relative to its brain, are exported, so code that holds the ledger's store, as the workflow host does, reads a brain's definitions of one type, and their `details`, without the operations. + +Each run is a stream of its own, named `runs/{run_id}` relative to the brain. Its events carry a `type`, who and when: + +- `run_started`, with the `definition_type`, the definition `name`, the `definition_version` and the `input`, `calls_tools: true` when its definition calls tools, `depth` when the run's reaction depth is above 0, and `trigger`, the kind and reference of the trigger that started the run, which every ending copies; a retry records it again +- `run_deferred`, with the `record` of the work that finishes later and the definition that ran; a run that started and never finished has no such event, which is how a retry tells the two apart +- `run_succeeded`, with the `output` and the capability's `record` +- `run_rejected`, with the `rejection`, and the `record` its rejection carried, when it carried one +- `run_failed` +- `tool_call_started` and `tool_call_answered`, for each tool call it makes, described under [Tool calls](#tool-calls) +- `delivery_started` and `delivery_ended`, for each delivery a run that finishes later makes, described under [Outbound calls](#outbound-calls) + +Each of the three endings also carries the `definition_type`, the definition `name`, the `definition_version` and the `depth` of the attempt it ends, which the decider takes from the run's latest start, so an ending says what ran without a read of the start. + +### Cancelling a run + +`cancel_run` records `run_cancel_requested` on the run's stream, with the kind `requested`, the caller as `by`, and the `reason`, or words that name the caller: the request is a fact, so it lands on any server and outlasts a crash. It answers the run as it stands. A run that has ended is `conflict`, and so is a run that runs within its call, since no server can interrupt another request's fiber; a run whose start says it finishes later may be cancelled before its deferral is recorded. Asking again before the run ended records nothing more. The workflow host's follower turns the fact into the effect: for a workflow, the engine's input `cancel_requested`; for a run of another capability that finishes later, `deferredCanceller(capabilities, ledger)`, which settles the run with what the capability's `cancel(run)` answers, a pure decision over the run's record, its kind, its reason, the answer a reply brought back, `broughtAnswer`, when a delivery delivered it, `deliveredAt`, and the run's address, `run`, `cancelledAsAsked` unless the capability states one, and settles a run whose `cancel` throws as `failed`; it settles against the version of the run it read (`runSettlerAsRead`), so a fact recorded since, such as the end of a delivery with an answer or delivered, refuses the settlement as `concurrent_change`, and it reads the run again and decides once more, up to three times more. `runCanceller(ledger)` records the cancels the host makes of the runs its calls wait for, with the kind `deadline` or `parent_ended`, answering `requested`, `ended`, or `unknown_run` for an address a brain cannot hold. A cancel from the caller is a fact whatever state the run is in: on a stream with no run yet it is the first record, without a definition, and a start that lands after it, of either kind, is refused with `cancelled` and its kind, so the ledger orders the cancel and the start, and the stream still holds no run, so `list_runs` leaves it out and `get_run` and `get_run_history` answer `not_found`; on a run within its call it is recorded, and the attempt its interrupt stops ends `rejected` as `cancelled` with that kind rather than `failed`. `cancel_run` keeps answering `not_found` for a run the brain does not have, and `conflict` for a run within its call. + +A cancelled run ends `rejected` with the reason `cancelled` and its kind, `requested`, `deadline`, `overrun` or `parent_ended`; `RunCancelled` of `@beonauto/operations` is its error, and its problem type `https://on.auto/problems/cancelled`. + +### The call depth of a run + +A run that a workflow's call starts carries, in `CallLineage`, the number of calls above it, `callDepth`, and the call it answers, `calledBy`: the workflow's run id, the call's reference and its run. `run_started` records them as `call_depth` and `called_by`, and every finish of the attempt copies them from the start, so the ending of the run names the call it answers. A start more than 8 calls below the run at the top of its tree, `mostCallDepth`, is refused with `conflict` before anything is recorded. + +### The reaction depth of a run + +A run started in the process by another run or by a reaction carries a reaction depth, which the brain request gives the start path in `CallLineage` beside the lineage and never in an input. `run_started` records it as `depth` when it is above 0, every ending of the attempt records it again from the start, and the run's context gives it to `run`. The workflow host bounds a chain of reactions by it (`@beonauto/workflow-host`). + +### The trigger of a run + +A run a workflow's trigger started carries that trigger in `CallLineage`, as the brain request gives it: its `kind`, `event`, `cron` or `every`, and its `reference`, the place in the document that names it. `run_started` records it as `trigger`, every ending of the attempt copies it, and `brainFactOf` puts it in the fact's data, so a trigger filter can test `.trigger.kind`. The history says in words which kind started the run, "was started by its event trigger", "by its cron schedule" or "by its every schedule", and gives the trigger in its data; the words never name the reference. `started_by` stays the actor, `brain:`, since who acts is not what fired. + +### Starting one version once + +`defineStartVersion(capabilities)` is `start_definition_version`, a brain command that no catalog serves: the workflow host calls it in the process, through the dispatcher, for the runs reactions start, as the brain's own caller (`brainCallerOf` of `@beonauto/operations`), with the run's depth and lineage in the request. Its input names a `type`, a `name`, a `version`, an `input` and a `run_id`. Its start only creates: the decider records it when the brain has no run under that id, or when the run under it ended without a final result, failed or rejected for any reason but `invalid_input`, as with `unavailable` or `conflict`, without tool calls, and was asked the same, so a start the brain could not take at first goes through on a later delivery; for a run that goes, or that ended with a result, or that another request ended, it refuses with `conflict` of the kind `taken` (`runTaken`), and the command answers the run as it stands and runs nothing, whatever it was asked, also when a second start races it. Otherwise it runs that version of the definition, found in the definition's stream even after a later version or its retirement, and answers as `run_definition` does. A version the definition never had is `not_found`. + +### The lineage of a run + +Every event of a run is written with its cause and its correlation (see the ledger port of `@beonauto/operations`). The correlation of a run that no other run started is its own run id; a run started by another run, as a workflow starts the function it calls, is given a lineage with its request, which the start path takes from `CallLineage` and passes to `run`, and every event of the run takes the correlation of that lineage. The `run_definition` input never carries one: its decoding refuses a field it does not know. The causes: + +| Event | Caused by | +| ---------------------- | ---------------------------------------------------------------------------------------------------------- | +| `run_started` | nothing for a run no other run started; the cause its lineage gives, the waiting step of a call, otherwise | +| `run_deferred` | the `run_started` of the attempt, whose position the start path keeps from what `run` answers | +| `run_cancel_requested` | nothing; its correlation is the one the run's first record has | +| `tool_call_started` | the `tool_call_answered` before it, the first by the start | +| `tool_call_answered` | its `tool_call_started`, or the answer before it for a call the journal has no start of | +| the finish of a run | its last `tool_call_answered`, or its start when it called no tool | +| a settlement | the cause the settler is given: for a workflow, the last step event of the record that ended its run | + +There is no read model of the definitions or of a run: each call folds the streams it needs. The outcomes of runs, which `get_brain_analytics` reads, are a table the ledger keeps from these events (see [Analytics](#analytics)). Pure deciders hold the rules: one for the definitions of each type, and one for runs. The handlers pass them who and when in each command. + +## Analytics + +`defineGetBrainAnalytics(capabilities)` makes `get_brain_analytics`, `GET /analytics` relative to the brain, under `brain:read`. It reads the outcomes of the brain's runs over a window of days in UTC, through `BrainReader.readRunOutcomes` (see [`@beonauto/operations`](../operations/README.md#the-outcomes-of-runs)), one statement of the store: + +- the window: `days`, 7, 14 or 30, the last days ending today, 7 when nothing is given; or `from` and `to`, both included, at most 366 days, with `to` not before `from` and not after today. A day must name the same day when read back, so `2026-02-30` is refused at `/from` by the input schema. `days` with `from` or `to` is `invalid_input` at `/days`, `from` without `to` at `/to` and `to` without `from` at `/from`, a window that ends before it starts or after today at `/to`, and one longer than 366 days at `/from`. An unknown `days` or parameter is refused as every input is. +- `type` and `name`, as `list_runs` takes them, keep the runs of that type and definition name. +- The answer: `days`; `runs`, with the `total`, `succeeded`, `failed` and `rejected`; `tokens`, the `input`, `output` and `cached` tokens; `duration_ms`, with `p50` and `p95`, or `null`; `by_day`, the `day` and the same three for every day of the window, oldest first, a day without runs included; and `by_function`, each definition's `type`, `name` and `runs`, the number of its runs that ended, the most runs first, then by `type` and `name` in the order of their characters. +- A run counts on the day it first started, once it has ended; a run still `started` counts nowhere. Its duration runs from its latest start to its end, for a run that succeeded or failed, workflows included; a rejected run counts in `runs` and in `tokens`, never in `duration_ms`. A percentile is the nearest rank, the duration at place ⌈p × n⌉ of the durations in order; the operation adds the groups the ledger answers and takes the percentiles in code. Tokens sum what was recorded, over every attempt of a run started again under its id, 0 otherwise, and `cached` is the cache-read part of `input`. + +`runOutcomeMapping` is the `RunOutcomeMapping` of the run events, which the server gives the ledger at composition. It reads `run_started`, `run_succeeded`, `run_failed` and `run_rejected`, and leaves the row as it is for anything it does not understand: + +| Field | From | +| --------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `startedDay`, `startedAt`, `definitionType`, `name` | the first `run_started` of the run, its `at` and its UTC date; a finish without a start gives its own `at` and empty names | +| `lastStartedAt` | the `at` of the latest `run_started` | +| `status` | `started` from every start, then `succeeded`, `failed` or `rejected` from the finish | +| `durationMs` | the finish's `at` less `lastStartedAt` for a run that succeeded or failed, never below 0; `null` for a rejected run, a run still started, or a finish without a start | +| the tokens | the sums of `record.usage.input.total`, `record.usage.output.total` and `record.usage.input.cache_read` over every finish of the run, each counted when it is a whole number of at least 0; `null` while no finish gave one, so a run rejected after its model spent tokens and then started again keeps them | + +`measure/measure-outcomes.ts` measures the ledger's table of run outcomes kept with this mapping, on both stores: the read, the append with and without it, and the fill of a new table version (`pnpm --filter @beonauto/definitions measure:outcomes`). It lives here with the mapping rather than in the ledger, so the ledger depends on nothing in this package; [the ledger's README](../ledger/README.md#measurement-of-the-outcomes) holds the figures. + +## Events of a brain + +A brain takes events from outside and records facts of its own, both in the shape of [CloudEvents 1.0](https://github.com/cloudevents/spec/blob/v1.0.2/cloudevents/spec.md). `CloudEventSchema` is an event as the brain keeps it: `specversion` `1.0`, `id`, `source`, a URI reference that is not empty, `type` and `time` in RFC 3339, and optionally `subject`, `datacontenttype`, a media type such as `text/plain; charset=utf-8`, `dataschema`, an absolute URI, and `data`, any JSON value that nests at most 510 levels deep, `mostEventDataDepth`, so that a run can hold the whole event in a list, as an input or as what a `listen` task gives; deeper data is `invalid_input` at `/event/data`. Any other attribute is an extension, at most 32 of them, named in 1 to 20 lowercase letters and digits, as CloudEvents recommends, whose value is text, a boolean or an integer from -2147483648 to 2147483647, kept as given. `id` and `type` take at most 256 characters, `source`, `subject` and `dataschema` at most 1,024, and a time at most nine digits of a fraction of a second. As CloudEvents requires, no text holds a control character (U+0000 to U+001F, U+007F to U+009F), a surrogate that is not one of a pair, or a noncharacter, `refusingForbiddenCharacters`; `id`, `type` and `subject` hold a character that is not a space, `refusingBlankText`, both of which `send_run_event` applies too, as it takes a `source` only as `EventSourceSchema`, the URI reference of a published event; and a time's second is 60 only in the last minute of a day in UTC (`events/event-time.ts`). + +### Publishing an event + +`publishEvent` is `publish_event`, a brain command at `POST /events` under `brain:write`, which the server serves beside the operations of `makeDefinitionOperations`. Its input is `event`, a CloudEvent whose `specversion`, `id` and `time` may be left out, and it answers `{ id, time, recorded_at }`. + +- The brain fills in what is left out: `specversion` 1.0, an `id`, a version 7 UUID, and as `time` the moment it records the event. With them, the event takes at most 245,760 bytes (240 KiB) as JSON in UTF-8, `mostPublishedEventBytes`, so that it fits a run's input of 256 KiB inside an array; a larger one is `invalid_input` at `/event`. +- `causationid` and `correlationid`, the extension attributes that carry the lineage of the brain's own facts (see [The brain's own facts as events](#the-brains-own-facts-as-events)), are refused as `invalid_input` at `/event/causationid` and `/event/correlationid`, so no event published to a brain or sent to a run claims a cause it does not have. +- The types the brain records on the streams of its runs and its definitions, every stored type of their events, and every type its feed shows, `event_published`, `interaction_requested`, `workflow_input_applied`, the step events of a workflow and `reaction_refused` among them, `reservedEventTypes`, and sources under `/runs/`, `/definitions/` and `/callers/`, the last the source the brain gives an event a caller sends a run without one (`callerSourcePrefix`), named in words by `reservedSourcesInWords` and checked by `isReservedSource`, are the brain's own: an event that uses them is `invalid_input` at `/event/type` or `/event/source`. `refusingTheBrainsOwnAttributes` is that check, a filter of an event's schema, which `send_run_event` of `@beonauto/coordination` applies too, so that no event sent to a run poses as a fact a `listen` filter matches. +- Each event is a stream of its own, `events/`, the uuid a name-based UUID, version 5, of its `source` and `id` in a namespace of its own. The publish records `event_published`, with the event, the attributes the brain `filled`, who published it and when, on that stream while it is empty. Publishing it again with the same source and id records nothing and answers the first record's `id`, `time` and `recorded_at`, and a different event under the same source and id is `conflict`. The two are compared on what both callers gave: an attribute the brain filled in for either of them, such as a `time` left out on a retry, is left out of the comparison, so a retry that gives a time to an event first published without one answers the time the brain filled in. Times are compared as instants (`instantOf` in `events/event-time.ts`), so one instant spelled two ways is one time, to the nanosecond; a leap second counts as the first second of the next day. A publish without an `id` gets a new one, so it is always a new event. + +### An event a workflow emits + +`eventEmitter(ledger)` gives the workflow host an `EmitEvent`: it records an event a workflow's `emit` task made as `event_published` on the event's own stream, as `publish_event` would, with `emitted_by`, the run, the workflow and its version, and `depth`, the reaction depth of that run plus one, under the lineage it is given. The same event again records nothing and answers `already_recorded`; an event the brain does not take, by `CloudEventSchema`, its reserved types and sources or its bound of 240 KiB, or one whose source and id another event holds, is `refused` and recorded nowhere; a ledger that kept changing fails, so the output is dispatched again. `emittedEventRefusal(event)` says, in words, why the brain would not take an event a workflow computed, so the run can fail its task instead. `publishedEventOf(data)` reads a stored `event_published`. `list_brain_events` says of an emitted event which workflow emitted it. + +### The brain's own facts as events + +`brainFactOf(record)` turns a record the brain stored into a CloudEvent, or `undefined` for a record that is no fact. It takes the record as `BrainReader.readRecorded` gives it, its stream named relative to the brain, and builds the event from the stored event, never from its presentation: + +| Record | `source` | `subject` | `data` | +| ---------------------------------------------------------------- | ---------------------------- | --------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `run_started`, `run_succeeded`, `run_rejected`, `run_failed` | `/runs/` | `/` | `definition_type`, `name`, `version`, `caller`, `depth`, the run's reaction depth, 0 for a run nothing reacted to, `called_by` and `trigger` when the start names them, for a rejection its `reason` and `kind`, and for a success its `output` when the whole event takes at most 240 KiB and its data nests at most 510 levels, and its `output_bytes` otherwise | +| `definition_created`, `definition_updated`, `definition_retired` | `/definitions//` | none | `definition_type`, `name`, `version` but on a retirement, `caller` | + +The event's `type` is the stored type, its `id` the record's id, the message's own id, and its `time` the time the stored event holds; the record's cause and correlation, when it has them, are its extension attributes `causationid` and `correlationid`. `brainEventOf(record)` is the event a reader of the brain's history sees for a record: a fact of the brain as `brainFactOf` gives it, or an event published to the brain as its publisher gave it, the `event` of its `event_published`, which is how the workflow host's projector folds both. So an interaction function's answer is the `output` of its `run_succeeded`, with the subject `interaction/`, and an expiry the `reason` `unanswered` and the `kind` `expired`, both with the actor as `caller`. Deferrals, tool calls, deliveries, the run logs of workflows and every other stream kind yield no event, and so does a record that does not decode as an event of its stream, so a reader of the ledger never fails on one. A finish names what ran because the decider records the definition on it (see [Storage](#storage)). + +## Tool calls + +A capability whose runs call tools, as reasoning does through MCP servers ([decision 0003](../../docs/decisions/0003-mcp-servers.md)), records each call on the run's own stream as it happens, through the `journal` of its context, which answers whether the fact was recorded: + +- `tool_call_started`, before the call is sent: its `number` within the run, which the decider gives it as it appends the fact and the journal answers, the `call_id` the model gave it, the `server` and `tool`, the size and SHA-256 digest of its arguments as sent (`arguments_bytes`, `arguments_sha256`), and `arguments_json`, cut to 4 KiB, when the server's operator records content; +- `tool_call_answered`: the `number`, the `outcome` (`result`, `tool_error`, `server_failure`, `timed_out` or `cancelled`), the size and digest of the result (`result_bytes`, `result_sha256`, null when there is none), `duration_ms`, the `jsonrpc_id` sent, `server_request_id` when the server's entry names where it carries one, and `result_json`, cut to 4 KiB, when content is recorded. + +The journal is built with the rest of the context in one place, so runs started directly and by a workflow record alike. It keeps the id of the last answer it recorded and of each call it started, for the causes above. It holds a permit per run, so the calls of one step, made at once, append one at a time, as an append is retried only three times on a version conflict. The decider refuses a tool event once the run has ended, however it ended, and a start that names a number other than the next: a call still in flight then keeps a start and no answer, which reads as an outcome unknown. Whether a tool call may have changed something is what keeps a run that called tools from running again under its id (see [Run ids and retries](#run-ids-and-retries)). + +## Outbound calls + +A run that finishes later makes calls after the call that started it returned, with no request behind them: an interaction function's deliveries, which the workflow host makes from the projection of open requests, and the replies its reading takes. `outboundCallRecorder(writer)` records the deliveries on the run's stream, through the run decider, as the brain's own caller, `brain:`, with the lineage it is given, and answers the id of the message it recorded, the cause of what follows: + +- `delivery_started`, before the attempt is sent: its `number`, which must be the next call of the run, so an attempt two hosts make at once is recorded once and the second is `conflict`, the `target`, the `server` and `tool` it calls, and, once its arguments are rendered, the fields a call's start has, `arguments_bytes`, `arguments_sha256` and `arguments_json` where the server records content; +- `delivery_ended`: the `number`, the `outcome` (`delivered`, `failed` or `refused`), `because` (`timed_out`, `too_large`, `tool_not_offered`, `tool_error`, `server_failure` or `lost`), the `retry_after_ms` a server asked for, a `detail`, `duration_ms`, the fields of the call's answer when a call was answered, `delivered_as`, the conversation and the identity of the message the tool sent, and `replies_in`, the server, the tool and the key its replies are read by. + +`replyRecorder(writer)` records the two reply facts on the run's stream as the `reply` command, decided by `decideReply`: `reply_taken`, with the server and tool that read it, the reply's identity and the answer, and `reply_refused`, with its `because` (`not_an_answer`, `invalid`, `too_long` or `ambiguous`), the issues and whether the party was told. Both are refused once the run has ended or a cancel is asked, a reply the run has met already, which the state keeps as `repliesSeen`, a taking once the run holds a `broughtAnswer`, and a refusal past the tenth. + +Each carries the definition that ran, as the endings do. A delivery never says the run may have changed something outside, so a run whose deliveries failed may start again under its id. An address the brain cannot hold is `conflict`, and so is a run that has ended. `recordedRunIn(reader, address)` and `recordedRunInBrain(reader, id)` read a run as it stands, with its input, whether it awaits a settlement and the number of its last call, through a reader of the whole ledger or of one brain, and answer nothing for a run there is not; `runEventOf(data)` reads one stored run event. + +## Testing + +`@beonauto/definitions/testing` exports `echo`, a small real capability for the tests of this and other packages. Its definition document is a JSON object with a string `greeting`, an optional string `description` and optional `warnings`, a list of strings its summary gives back; a run takes a JSON object and answers `{ greeting, input }`. + +## Source + +`src/index.ts` is the main entry point, `src/document.ts` the entry point of the reader of function documents, held in `src/document`, `src/json-schema.ts` the entry point of its JSON Schema compiler alone, of the checks of a value against a schema and of the checked worker, held in `src/checking`, `src/template.ts` the entry point of the template engine of the capabilities, held in `src/template`, and `src/testing/index.ts` the entry point of the test support. `src/capability` holds the declaration of a capability and the list of known capabilities. `src/registry` holds the definitions of a capability in a brain: a definition, the events and commands of its stream, and the decider and its rules. `src/runs` holds a run: its events, commands, state, decider and rules, its size limits, and `runSettler`. `src/operations` holds the seven operations that change and read definitions and runs, and how they load and record them. `src/reading` holds `list_runs` and `get_run_history`, `src/analytics` `get_brain_analytics` and `runOutcomeMapping`, and `src/presenting` the presenters of the three stream kinds. `src/events` holds the events of a brain: the shape of a CloudEvent, `publish_event` and the stream of a published event, and the brain's own facts as events. `src/tool-calls` holds the journal of a run's tool calls, and `src/run-work` the work a run records after its start: the decisions on its calls, the recorder of its outbound calls, the words and accounts of its deliveries and deferral, and a run as it was recorded. `src/testing` holds what the tests share. `operations` depends on the others but `events`, `reading` on `presenting`, on `analytics` and on the fields of `operations`, `analytics` on the fields of `operations` and the words of `plain-language`, `events` on `runs`, `registry` and the command metadata of `operations`, `presenting` on `events`, and `registry` and `runs` on nothing in this package. diff --git a/packages/specs/measure/measure-outcomes.ts b/packages/definitions/measure/measure-outcomes.ts similarity index 96% rename from packages/specs/measure/measure-outcomes.ts rename to packages/definitions/measure/measure-outcomes.ts index 4f9b0692d..aaee9d038 100644 --- a/packages/specs/measure/measure-outcomes.ts +++ b/packages/definitions/measure/measure-outcomes.ts @@ -113,10 +113,10 @@ function runEventsOf(run: number): readonly (readonly RunEvent[])[] { return [ [ { - type: 'execution_started', - primitive: 'inference', + type: 'run_started', + definition_type: 'reasoning', name: `fn-${run % 20}`, - spec_version: 1, + definition_version: 1, input: {}, by: 'u', at, @@ -124,7 +124,7 @@ function runEventsOf(run: number): readonly (readonly RunEvent[])[] { ], [ { - type: 'execution_succeeded', + type: 'run_succeeded', output: 'ok', record: { usage: { input: { total: 100 } }, prompt: 'p'.repeat(2048) }, by: 'u', @@ -145,11 +145,8 @@ async function appendTimes(bench: Bench, kept: boolean): Promise Effect.promise( async () => - ( - await timed(() => - Effect.runPromise(ledger.execute(`brain/o1/big/executions/a-${run}`, runEvents, events)), - ) - ).took, + (await timed(() => Effect.runPromise(ledger.execute(`brain/o1/big/runs/a-${run}`, runEvents, events)))) + .took, ), ), ), diff --git a/packages/specs/measure/outcomes-dataset.ts b/packages/definitions/measure/outcomes-dataset.ts similarity index 83% rename from packages/specs/measure/outcomes-dataset.ts rename to packages/definitions/measure/outcomes-dataset.ts index 5223237b2..2c1d3ca3e 100644 --- a/packages/specs/measure/outcomes-dataset.ts +++ b/packages/definitions/measure/outcomes-dataset.ts @@ -24,9 +24,9 @@ function text(bytes: number): string { function statusOf(run: number): string { if (run % 20 === 0) { - return 'execution_rejected'; + return 'run_rejected'; } - return run % 33 === 1 ? 'execution_failed' : 'execution_succeeded'; + return run % 33 === 1 ? 'run_failed' : 'run_succeeded'; } function usageOf(run: number) { @@ -36,10 +36,10 @@ function usageOf(run: number) { function finishOf(run: number, at: string, recordBytes: number): Readonly> { const status = statusOf(run); const fact = { type: status, by: 'user-1', at }; - if (status === 'execution_failed') { + if (status === 'run_failed') { return fact; } - if (status === 'execution_rejected') { + if (status === 'run_rejected') { return { ...fact, rejection: { reason: 'unavailable', detail: 'The answer is not JSON' }, @@ -57,8 +57,8 @@ export function runOf(brain: string, run: number, recordBytes: number): readonly const startedAt = firstMoment + (run % daysInTheWindow) * dayInMilliseconds + (run % 80_000) * 1000; const at = new Date(startedAt).toISOString(); const finishedAt = new Date(startedAt + 100 + (run % 50) * 97).toISOString(); - const stream = `brain/o1/${brain}/executions/${String(run).padStart(8, '0')}`; - const started = { type: 'execution_started', primitive: 'inference', name: `fn-${run % 20}`, spec_version: 1 }; + const stream = `brain/o1/${brain}/runs/${String(run).padStart(8, '0')}`; + const started = { type: 'run_started', definition_type: 'reasoning', name: `fn-${run % 20}`, definition_version: 1 }; return [ row(stream, 1, { ...started, input: { text: text(320) }, by: 'user-1', at }, at), row(stream, 2, finishOf(run, finishedAt, recordBytes), finishedAt), diff --git a/packages/specs/measure/outcomes-stores.ts b/packages/definitions/measure/outcomes-stores.ts similarity index 95% rename from packages/specs/measure/outcomes-stores.ts rename to packages/definitions/measure/outcomes-stores.ts index cb746169f..33c7b7120 100644 --- a/packages/specs/measure/outcomes-stores.ts +++ b/packages/definitions/measure/outcomes-stores.ts @@ -56,7 +56,7 @@ function kindKeyOfStream(): string { } const sqliteAggregate = `SELECT coalesce(sum(runs), 0) AS runs FROM ( - SELECT substr(json_extract(s.message_data, '$.at'), 1, 10) AS day, json_extract(s.message_data, '$.primitive') AS primitive, + SELECT substr(json_extract(s.message_data, '$.at'), 1, 10) AS day, json_extract(s.message_data, '$.definition_type') AS definition_type, json_extract(s.message_data, '$.name') AS name, f.message_type AS status, count(*) AS runs, sum(json_extract(f.message_data, '$.record.usage.input.total')) AS input_tokens, sum(json_extract(f.message_data, '$.record.usage.output.total')) AS output_tokens, @@ -65,12 +65,12 @@ const sqliteAggregate = `SELECT coalesce(sum(runs), 0) AS runs FROM ( - julianday(json_extract(s.message_data, '$.at'))) * 86400000 AS INTEGER)) AS durations FROM ( SELECT stream_id, message_data FROM emt_messages - WHERE substr(stream_id, 1, ${kindKeyOfStream()}) = 'brain/o1/big/executions/' AND stream_position = 1 + WHERE substr(stream_id, 1, ${kindKeyOfStream()}) = 'brain/o1/big/runs/' AND stream_position = 1 ) AS s JOIN emt_messages AS f ON f.stream_id = s.stream_id AND f.stream_position = ( SELECT max(m.stream_position) FROM emt_messages AS m WHERE m.stream_id = s.stream_id) WHERE substr(json_extract(s.message_data, '$.at'), 1, 10) BETWEEN '${firstDay}' AND '${lastDay}' - GROUP BY day, primitive, name, status)`; + GROUP BY day, definition_type, name, status)`; function sqliteWrite(fileName: string, count: number, rowsOf: RowsOf): void { const database = new DatabaseSync(fileName); @@ -126,7 +126,7 @@ const postgresqlAggregate = `WITH s AS ( SELECT stream_id, (message_data ->> 'json')::jsonb AS e FROM emt_messages WHERE substring(stream_id FROM '^(?:[^/]*/){4}') = ANY($1::text[]) AND stream_position = 1 ), runs AS ( - SELECT left(s.e ->> 'at', 10) AS day, s.e ->> 'primitive' AS primitive, s.e ->> 'name' AS name, + SELECT left(s.e ->> 'at', 10) AS day, s.e ->> 'definition_type' AS definition_type, s.e ->> 'name' AS name, latest.message_type AS status, (latest.message_data ->> 'json')::jsonb AS f, s.e ->> 'at' AS started_at FROM s CROSS JOIN LATERAL ( SELECT message_type, message_data FROM emt_messages AS m WHERE m.stream_id = s.stream_id @@ -135,12 +135,12 @@ const postgresqlAggregate = `WITH s AS ( WHERE left(s.e ->> 'at', 10) BETWEEN $2 AND $3 ) SELECT coalesce(sum(runs), 0)::int AS runs FROM ( - SELECT day, primitive, name, status, count(*) AS runs, + SELECT day, definition_type, name, status, count(*) AS runs, sum((f -> 'record' -> 'usage' -> 'input' ->> 'total')::bigint) AS input_tokens, sum((f -> 'record' -> 'usage' -> 'output' ->> 'total')::bigint) AS output_tokens, sum((f -> 'record' -> 'usage' -> 'input' ->> 'cache_read')::bigint) AS cached_tokens, json_agg((extract(epoch FROM (f ->> 'at')::timestamptz - started_at::timestamptz) * 1000)::bigint) AS durations - FROM runs GROUP BY day, primitive, name, status + FROM runs GROUP BY day, definition_type, name, status ) AS groups`; const postgresqlRows = `INSERT INTO emt_messages (stream_id, stream_position, partition, message_kind, message_data, @@ -231,7 +231,7 @@ export function onPostgreSQL(server: string): Bench { aggregate: ({ location }) => connected(location, async (client) => { const { rows } = await client.query<{ readonly runs: number }>(postgresqlAggregate, [ - ['brain/o1/big/executions/'], + ['brain/o1/big/runs/'], firstDay, lastDay, ]); diff --git a/packages/specs/package.json b/packages/definitions/package.json similarity index 96% rename from packages/specs/package.json rename to packages/definitions/package.json index 6110f1468..7f1a8a624 100644 --- a/packages/specs/package.json +++ b/packages/definitions/package.json @@ -1,5 +1,5 @@ { - "name": "@beonauto/specs", + "name": "@beonauto/definitions", "version": "0.0.0", "private": true, "license": "Elastic-2.0", diff --git a/packages/specs/src/analytics/analytics-answer.test.ts b/packages/definitions/src/analytics/analytics-answer.test.ts similarity index 83% rename from packages/specs/src/analytics/analytics-answer.test.ts rename to packages/definitions/src/analytics/analytics-answer.test.ts index 555e4c12f..5e89d2bdc 100644 --- a/packages/specs/src/analytics/analytics-answer.test.ts +++ b/packages/definitions/src/analytics/analytics-answer.test.ts @@ -6,7 +6,7 @@ import { analyticsOf, durationOf, type BrainAnalytics } from './analytics-answer function group(overrides: Partial): RunOutcomeGroup { return { day: '2026-10-01', - primitive: 'inference', + definitionType: 'reasoning', name: 'triage', status: 'succeeded', runs: 1, @@ -21,7 +21,7 @@ function group(overrides: Partial): RunOutcomeGroup { const window = { from: '2026-09-30', to: '2026-10-02', days: 3 }; function functionsOf({ by_function: functions }: BrainAnalytics): readonly string[] { - return functions.map(({ primitive, name }) => `${primitive} ${name}`); + return functions.map(({ type, name }) => `${type} ${name}`); } const noRuns = { total: 0, succeeded: 0, failed: 0, rejected: 0 }; @@ -55,7 +55,7 @@ describe('the analytics of a brain', () => { const groups = [ group({ runs: 2, inputTokens: 100, outputTokens: 40, cachedTokens: 60, durations: [300, 100] }), group({ status: 'rejected', inputTokens: 10, outputTokens: 2, cachedTokens: 0 }), - group({ day: '2026-10-02', primitive: 'orchestration', name: 'approval', durations: [5000] }), + group({ day: '2026-10-02', definitionType: 'workflow', name: 'approval', durations: [5000] }), group({ day: '2026-10-02', status: 'failed', durations: [200] }), group({ day: '2026-10-02', status: 'started', runs: 4, durations: [] }), group({ day: '2026-10-02', name: 'draft', status: 'started' }), @@ -82,8 +82,8 @@ describe('the analytics of a brain', () => { }, ], by_function: [ - { primitive: 'inference', name: 'triage', runs: 4 }, - { primitive: 'orchestration', name: 'approval', runs: 1 }, + { type: 'reasoning', name: 'triage', runs: 4 }, + { type: 'workflow', name: 'approval', runs: 1 }, ], }); }); @@ -92,14 +92,14 @@ describe('the analytics of a brain', () => { describe('the functions in the analytics of a brain', () => { it('are ordered by their runs, the most first, then by type and name in the order of their characters', () => { const groups = [ - group({ primitive: 'orchestration', name: 'b' }), - group({ primitive: 'inference', name: 'b-2' }), - group({ primitive: 'inference', name: 'b' }), - group({ primitive: 'inference', name: 'a', status: 'failed' }), - group({ primitive: 'orchestration', name: 'a', runs: 3 }), + group({ definitionType: 'workflow', name: 'b' }), + group({ definitionType: 'reasoning', name: 'b-2' }), + group({ definitionType: 'reasoning', name: 'b' }), + group({ definitionType: 'reasoning', name: 'a', status: 'failed' }), + group({ definitionType: 'workflow', name: 'a', runs: 3 }), ]; - const ordered = ['orchestration a', 'inference a', 'inference b', 'inference b-2', 'orchestration b']; + const ordered = ['workflow a', 'reasoning a', 'reasoning b', 'reasoning b-2', 'workflow b']; const givenOrders: readonly (readonly RunOutcomeGroup[])[] = [groups, groups.toReversed()]; diff --git a/packages/specs/src/analytics/analytics-answer.ts b/packages/definitions/src/analytics/analytics-answer.ts similarity index 92% rename from packages/specs/src/analytics/analytics-answer.ts rename to packages/definitions/src/analytics/analytics-answer.ts index 6505843ec..fd26f419c 100644 --- a/packages/specs/src/analytics/analytics-answer.ts +++ b/packages/definitions/src/analytics/analytics-answer.ts @@ -39,7 +39,9 @@ const DaySchema = Schema.Struct({ }); const FunctionSchema = Schema.Struct({ - primitive: Schema.String.annotate({ description: 'The API type identifier of the definition' }), + type: Schema.String.annotate({ + description: 'The type of the definition: reasoning, interaction, computation, recall or workflow', + }), name: Schema.String.annotate({ description: 'The definition name' }), runs: Count.annotate({ description: 'How many of its runs ended' }), }); @@ -53,7 +55,7 @@ export const BrainAnalyticsSchema = Schema.Struct({ description: 'Every day of the window, oldest first, one with no runs included', }), by_function: Schema.Array(FunctionSchema).annotate({ - description: 'The definitions that had runs end, the most runs first, then by API type identifier and name', + description: 'The definitions that had runs end, the most runs first, then by type and name', }), }).annotate({ identifier: 'BrainAnalytics', @@ -88,9 +90,7 @@ function mostRunsFirst(left: FunctionRuns, right: FunctionRuns): number { if (left.runs !== right.runs) { return right.runs - left.runs; } - return left.primitive === right.primitive - ? inCodePointOrder(left.name, right.name) - : inCodePointOrder(left.primitive, right.primitive); + return left.type === right.type ? inCodePointOrder(left.name, right.name) : inCodePointOrder(left.type, right.type); } function tokensOf(groups: readonly RunOutcomeGroup[]): Tokens { @@ -133,9 +133,9 @@ function dayIn(byDay: ReadonlyMap, day: stri function byFunction(groups: readonly RunOutcomeGroup[]): BrainAnalytics['by_function'] { const functions = new Map(); for (const group of groups) { - const key = JSON.stringify([group.primitive, group.name]); + const key = JSON.stringify([group.definitionType, group.name]); const ended = group.status === 'started' ? 0 : group.runs; - functions.set(key, { primitive: group.primitive, name: group.name, runs: (functions.get(key)?.runs ?? 0) + ended }); + functions.set(key, { type: group.definitionType, name: group.name, runs: (functions.get(key)?.runs ?? 0) + ended }); } return [...functions.values()].filter(({ runs }) => runs > 0).toSorted((left, right) => mostRunsFirst(left, right)); } diff --git a/packages/specs/src/analytics/analytics-window.test.ts b/packages/definitions/src/analytics/analytics-window.test.ts similarity index 100% rename from packages/specs/src/analytics/analytics-window.test.ts rename to packages/definitions/src/analytics/analytics-window.test.ts diff --git a/packages/specs/src/analytics/analytics-window.ts b/packages/definitions/src/analytics/analytics-window.ts similarity index 100% rename from packages/specs/src/analytics/analytics-window.ts rename to packages/definitions/src/analytics/analytics-window.ts diff --git a/packages/specs/src/analytics/analytics-words.test.ts b/packages/definitions/src/analytics/analytics-words.test.ts similarity index 95% rename from packages/specs/src/analytics/analytics-words.test.ts rename to packages/definitions/src/analytics/analytics-words.test.ts index 15f3444ac..13e25be0c 100644 --- a/packages/specs/src/analytics/analytics-words.test.ts +++ b/packages/definitions/src/analytics/analytics-words.test.ts @@ -26,8 +26,8 @@ describe('the plain words of get_brain_analytics', () => { it('say what was asked for', () => { expect([ plainLanguage?.attempt({}), - plainLanguage?.attempt({ days: 30, primitive: 'echo', name: 'hello' }), - plainLanguage?.attempt({ from: '2026-09-01', to: '2026-09-30', primitive: 'echo' }), + plainLanguage?.attempt({ days: 30, type: 'echo', name: 'hello' }), + plainLanguage?.attempt({ from: '2026-09-01', to: '2026-09-30', type: 'echo' }), plainLanguage?.attempt({ name: 'hello', from: '2026-09-01' }), plainLanguage?.attempt({ days: 'many' }), ]).toEqual([ diff --git a/packages/specs/src/analytics/analytics-words.ts b/packages/definitions/src/analytics/analytics-words.ts similarity index 87% rename from packages/specs/src/analytics/analytics-words.ts rename to packages/definitions/src/analytics/analytics-words.ts index 2102ea709..1e2cad753 100644 --- a/packages/specs/src/analytics/analytics-words.ts +++ b/packages/definitions/src/analytics/analytics-words.ts @@ -1,12 +1,12 @@ import { capitalized, counted, listed, plainNumber, type Noun, type PlainLanguage } from '@beonauto/operations'; +import type { DefinitionWords } from '../plain-language/definition-words.ts'; import { endingsInWords, runsOfWhat } from '../plain-language/reading-words.ts'; -import type { SpecWords } from '../plain-language/spec-words.ts'; import type { BrainAnalytics } from './analytics-answer.ts'; import { defaultDays, type WindowRequest } from './analytics-window.ts'; export interface AnalyticsRequest extends WindowRequest { - readonly primitive?: string; + readonly type?: string; readonly name?: string; } @@ -39,7 +39,7 @@ function durationInWords(duration: BrainAnalytics['duration_ms']): string { : ` Half of those that ran to an end took at most ${plainNumber(duration.p50)} ms, and nineteen in twenty at most ${plainNumber(duration.p95)} ms.`; } -function activityFound(words: SpecWords, analytics: BrainAnalytics, request: AnalyticsRequest): string { +function activityFound(words: DefinitionWords, analytics: BrainAnalytics, request: AnalyticsRequest): string { const when = capitalized(windowInWords(request)); const which = runsOfWhat(words, request); const { runs } = analytics; @@ -50,7 +50,7 @@ function activityFound(words: SpecWords, analytics: BrainAnalytics, request: Ana return `${ended}${tokensInWords(analytics.tokens)}${durationInWords(analytics.duration_ms)}`; } -export function analyticsWords(words: SpecWords): PlainLanguage { +export function analyticsWords(words: DefinitionWords): PlainLanguage { return { task: 'read how the runs of the brain went', attempt: (request) => `read how the runs${runsOfWhat(words, request)} went ${windowInWords(request)}`, diff --git a/packages/specs/src/analytics/get-brain-analytics.test.ts b/packages/definitions/src/analytics/get-brain-analytics.test.ts similarity index 61% rename from packages/specs/src/analytics/get-brain-analytics.test.ts rename to packages/definitions/src/analytics/get-brain-analytics.test.ts index 36fbd9dea..fa8050334 100644 --- a/packages/specs/src/analytics/get-brain-analytics.test.ts +++ b/packages/definitions/src/analytics/get-brain-analytics.test.ts @@ -2,20 +2,24 @@ import type { Decider } from '@beonauto/operations'; import { Effect, Result, type Schema } from 'effect'; import { describe, expect, it } from 'vitest'; -import { ExecutionEventSchema, type ExecutionEvent } from '../execution/execution-events.ts'; -import type { ExecutionRejection } from '../execution/execution.ts'; import { defineGetBrainAnalytics } from '../index.ts'; +import { RunEventSchema, type RunEvent } from '../runs/run-events.ts'; +import type { RunRejection } from '../runs/run.ts'; import { acmeReader } from '../testing/callers.ts'; import { echo } from '../testing/echo.ts'; import { asQueryString, harness, toBrain, type Harness } from '../testing/harness.ts'; -const getBrainAnalytics = defineGetBrainAnalytics([echo]); +const getBrainAnalytics = defineGetBrainAnalytics([ + echo, + { ...echo, type: 'reasoning' }, + { ...echo, type: 'workflow' }, +]); -const runEvents: Decider = { +const runEvents: Decider = { initialState: null, evolve: () => null, decide: (events) => Result.succeed(events), - eventSchema: ExecutionEventSchema, + eventSchema: RunEventSchema, }; const now = '2026-10-06T12:00:00.000Z'; @@ -24,57 +28,61 @@ const fact = { by: 'acme-admin' }; const usage = { input: { total: 1200, cache_read: 1000 }, output: { total: 300 } }; -function started(name: string, at: string, primitive = 'inference'): ExecutionEvent { - return { type: 'execution_started', primitive, name, spec_version: 1, input: {}, ...fact, at }; +function started(name: string, at: string, type = 'reasoning'): RunEvent { + return { type: 'run_started', definition_type: type, name, definition_version: 1, input: {}, ...fact, at }; } -function succeeded( - at: string, - record: Schema.JsonObject = {}, - name = 'triage', - primitive = 'inference', -): ExecutionEvent { - return { type: 'execution_succeeded', output: 'ok', record, primitive, name, spec_version: 1, ...fact, at }; +function succeeded(at: string, record: Schema.JsonObject = {}, name = 'triage', type = 'reasoning'): RunEvent { + return { + type: 'run_succeeded', + output: 'ok', + record, + definition_type: type, + name, + definition_version: 1, + ...fact, + at, + }; } -function running(specs: Harness, stream: string, ...events: readonly ExecutionEvent[]): Promise { - return Effect.runPromise(specs.ledger.service.execute(stream, runEvents, events)); +function running(definitions: Harness, stream: string, ...events: readonly RunEvent[]): Promise { + return Effect.runPromise(definitions.ledger.service.execute(stream, runEvents, events)); } async function aBrainWithRuns(): Promise { - const specs = harness(); - const rejection: ExecutionRejection = { reason: 'unavailable', detail: 'The answer is not JSON' }; + const definitions = harness(); + const rejection: RunRejection = { reason: 'unavailable', detail: 'The answer is not JSON' }; await running( - specs, - 'brain/acme/alpha/executions/r1', + definitions, + 'brain/acme/alpha/runs/r1', started('triage', '2026-10-05T09:00:00.000Z'), succeeded('2026-10-05T09:00:01.000Z', { usage }), ); - await running(specs, 'brain/acme/alpha/executions/r2', started('triage', '2026-10-05T10:00:00.000Z'), { - type: 'execution_rejected', + await running(definitions, 'brain/acme/alpha/runs/r2', started('triage', '2026-10-05T10:00:00.000Z'), { + type: 'run_rejected', rejection, record: { usage: { input: { total: 10, cache_read: 0 }, output: { total: 2 } } }, - primitive: 'inference', + definition_type: 'reasoning', name: 'triage', - spec_version: 1, + definition_version: 1, ...fact, at: '2026-10-05T10:00:00.500Z', }); await running( - specs, - 'brain/acme/alpha/executions/r3', - started('approval', '2026-10-06T08:00:00.000Z', 'orchestration'), - succeeded('2026-10-06T08:00:02.000Z', {}, 'approval', 'orchestration'), + definitions, + 'brain/acme/alpha/runs/r3', + started('approval', '2026-10-06T08:00:00.000Z', 'workflow'), + succeeded('2026-10-06T08:00:02.000Z', {}, 'approval', 'workflow'), ); - await running(specs, 'brain/acme/alpha/executions/r4', started('draft', '2026-10-06T09:00:00.000Z')); + await running(definitions, 'brain/acme/alpha/runs/r4', started('draft', '2026-10-06T09:00:00.000Z')); await running( - specs, - 'brain/acme/alpha/executions/r5', + definitions, + 'brain/acme/alpha/runs/r5', started('triage', '2026-09-20T09:00:00.000Z'), succeeded('2026-09-20T09:00:00.400Z'), ); - await running(specs, 'brain/acme/beta/executions/r6', started('triage', '2026-10-06T09:00:00.000Z')); - return specs; + await running(definitions, 'brain/acme/beta/runs/r6', started('triage', '2026-10-06T09:00:00.000Z')); + return definitions; } const toAlpha = toBrain('acme', 'alpha'); @@ -91,13 +99,14 @@ const schemaRefusals: readonly (readonly [Readonly>, str [{ from: '2026-10-01', to: '2026-10-6' }, '/to'], [{ from: '2026-10-01T24:00:00Z', to: '2026-10-02' }, '/from'], [{ since: '2026-10-01T00:00:00Z' }, '/since'], + [{ type: 'prediction' }, '/type'], ]; describe('get_brain_analytics', () => { it('answers the last seven days by default, with every day, the runs that ended and what they used', async () => { - const specs = await aBrainWithRuns(); + const definitions = await aBrainWithRuns(); - const read = await specs.call(getBrainAnalytics, toAlpha(acmeReader), now); + const read = await definitions.call(getBrainAnalytics, toAlpha(acmeReader), now); expect(read).toEqual({ status: 'succeeded', @@ -122,8 +131,8 @@ describe('get_brain_analytics', () => { }, ], by_function: [ - { primitive: 'inference', name: 'triage', runs: 2 }, - { primitive: 'orchestration', name: 'approval', runs: 1 }, + { type: 'reasoning', name: 'triage', runs: 2 }, + { type: 'workflow', name: 'approval', runs: 1 }, ], }, }); @@ -132,21 +141,21 @@ describe('get_brain_analytics', () => { describe('get_brain_analytics, asked for some days or some runs', () => { it('reads the last 30 days, or the days between two, and keeps the runs of one type or name', async () => { - const specs = await aBrainWithRuns(); - const reading = (input: object) => specs.call(getBrainAnalytics, toAlpha(acmeReader, input), now); + const definitions = await aBrainWithRuns(); + const reading = (input: object) => definitions.call(getBrainAnalytics, toAlpha(acmeReader, input), now); const answers = await Promise.all([ reading({ days: 30 }), reading({ from: '2026-10-06', to: '2026-10-06' }), - reading({ primitive: 'orchestration' }), - reading({ primitive: 'inference', name: 'triage', days: 30 }), - specs.call(getBrainAnalytics, asQueryString(toAlpha(acmeReader, { days: '14', name: 'approval' })), now), + reading({ type: 'workflow' }), + reading({ type: 'reasoning', name: 'triage', days: 30 }), + definitions.call(getBrainAnalytics, asQueryString(toAlpha(acmeReader, { days: '14', name: 'approval' })), now), ]); expect(answers).toMatchObject([ { output: { days: 30, runs: { total: 4 }, duration_ms: { p50: 1000, p95: 2000 } } }, { output: { days: 1, runs: { total: 1 }, by_day: [{ day: '2026-10-06' }] } }, - { output: { runs: { total: 1 }, by_function: [{ primitive: 'orchestration', name: 'approval' }] } }, + { output: { runs: { total: 1 }, by_function: [{ type: 'workflow', name: 'approval' }] } }, { output: { runs: { total: 3 }, by_function: [{ name: 'triage', runs: 3 }] } }, { output: { days: 14, runs: { total: 1 }, by_function: [{ name: 'approval' }] } }, ]); @@ -155,9 +164,9 @@ describe('get_brain_analytics, asked for some days or some runs', () => { describe('get_brain_analytics, given what it cannot answer', () => { it.each(schemaRefusals)('refuses %j as input that does not match its schema', async (input, pointer) => { - const specs = harness(); + const definitions = harness(); - expect(await specs.call(getBrainAnalytics, toAlpha(acmeReader, input), now)).toMatchObject({ + expect(await definitions.call(getBrainAnalytics, toAlpha(acmeReader, input), now)).toMatchObject({ status: 'rejected', reason: 'invalid_input', issues: [{ pointer }], @@ -165,12 +174,12 @@ describe('get_brain_analytics, given what it cannot answer', () => { }); it('refuses days with from, a day after today, and a brain it does not have', async () => { - const specs = harness(); + const definitions = harness(); const answers = await Promise.all([ - specs.call(getBrainAnalytics, toAlpha(acmeReader, { days: 7, from: '2026-10-01' }), now), - specs.call(getBrainAnalytics, toAlpha(acmeReader, { from: '2026-10-01', to: '2026-10-07' }), now), - specs.call(getBrainAnalytics, toBrain('acme', 'zeta')(acmeReader), now), + definitions.call(getBrainAnalytics, toAlpha(acmeReader, { days: 7, from: '2026-10-01' }), now), + definitions.call(getBrainAnalytics, toAlpha(acmeReader, { from: '2026-10-01', to: '2026-10-07' }), now), + definitions.call(getBrainAnalytics, toBrain('acme', 'zeta')(acmeReader), now), ]); expect(answers).toEqual([ diff --git a/packages/definitions/src/analytics/get-brain-analytics.ts b/packages/definitions/src/analytics/get-brain-analytics.ts new file mode 100644 index 000000000..41e1c6da2 --- /dev/null +++ b/packages/definitions/src/analytics/get-brain-analytics.ts @@ -0,0 +1,60 @@ +import { BrainReader, defineQuery } from '@beonauto/operations'; +import { Clock, Effect, Schema } from 'effect'; + +import type { Capability } from '../capability/capability.ts'; +import { knownCapabilities, type KnownCapabilities } from '../capability/known-capabilities.ts'; +import { RunsOfNameField } from '../operations/definition-fields.ts'; +import { definitionWordsFor } from '../plain-language/definition-words.ts'; +import { analyticsOf, BrainAnalyticsSchema } from './analytics-answer.ts'; +import { DaysField, dayFieldOf, dayOf, longestWindowInDays, windowOf } from './analytics-window.ts'; +import { analyticsWords } from './analytics-words.ts'; + +const description = [ + 'Counts what the runs of the brain did over a window of days in UTC: how many succeeded, failed or were rejected,', + 'the tokens their models used, and how long they took at the median and the 95th percentile,', + 'for the window, for each day of it and for each definition.', + 'Use it when the person asks how the brain is doing; list_runs lists the runs themselves.', + '`days` reads the last 7, 14 or 30 days, or `from` and `to` the days between them, and `type` and `name` keep the runs of one definition.', +].join(' '); + +function analyticsInputOf(typeField: KnownCapabilities['field']) { + return Schema.Struct({ + days: Schema.optionalKey(DaysField), + from: Schema.optionalKey(dayFieldOf('The first day to read, YYYY-MM-DD in UTC, given with to and not with days')), + to: Schema.optionalKey( + dayFieldOf( + `The last day to read, YYYY-MM-DD in UTC, no later than today and at most ${longestWindowInDays} days from the first`, + ), + ), + type: Schema.optionalKey(typeField), + name: Schema.optionalKey(RunsOfNameField), + }); +} + +type AnalyticsInput = ReturnType['Type']; + +function selectionOf({ type, name }: AnalyticsInput) { + return { ...(type === undefined ? {} : { definitionType: type }), ...(name === undefined ? {} : { name }) }; +} + +const readAnalytics = Effect.fnUntraced(function* (input: AnalyticsInput) { + const window = yield* windowOf(input, dayOf(yield* Clock.currentTimeMillis)); + const groups = yield* (yield* BrainReader).readRunOutcomes({ from: window.from, to: window.to }, selectionOf(input)); + return analyticsOf(window, groups); +}); + +export function defineGetBrainAnalytics(capabilities: readonly Capability[]) { + const known = knownCapabilities(capabilities); + const operation = defineQuery('brain', { + name: 'get_brain_analytics', + title: 'Get brain analytics', + description, + route: { method: 'GET', path: '/analytics' }, + inputSchema: analyticsInputOf(known.field), + outputSchema: BrainAnalyticsSchema, + reasons: ['invalid_input'], + handle: readAnalytics, + plainLanguage: analyticsWords(definitionWordsFor(capabilities)), + }); + return known.publish(operation, 'Only the runs of this type'); +} diff --git a/packages/specs/src/analytics/run-outcome-mapping.test.ts b/packages/definitions/src/analytics/run-outcome-mapping.test.ts similarity index 76% rename from packages/specs/src/analytics/run-outcome-mapping.test.ts rename to packages/definitions/src/analytics/run-outcome-mapping.test.ts index 53ad07463..4198c40ef 100644 --- a/packages/specs/src/analytics/run-outcome-mapping.test.ts +++ b/packages/definitions/src/analytics/run-outcome-mapping.test.ts @@ -5,8 +5,8 @@ import { runOutcomeMapping } from './run-outcome-mapping.ts'; const fact = { by: 'acme-admin' }; -function startedAt(at: string, name = 'triage', primitive = 'inference') { - return { type: 'execution_started', primitive, name, spec_version: 1, input: {}, ...fact, at }; +function startedAt(at: string, name = 'triage', type = 'reasoning') { + return { type: 'run_started', definition_type: type, name, definition_version: 1, input: {}, ...fact, at }; } const usage = { @@ -29,7 +29,7 @@ const firstStart = { startedDay: '2026-10-01', startedAt: '2026-10-01T09:00:00.000Z', lastStartedAt: '2026-10-01T09:00:00.000Z', - primitive: 'inference', + definitionType: 'reasoning', name: 'triage', }; @@ -37,12 +37,7 @@ const noTokens = { inputTokens: null, outputTokens: null, cachedTokens: null }; describe('the outcome of a run', () => { it('reads the types of the run events that change it', () => { - expect(runOutcomeMapping.types).toEqual([ - 'execution_started', - 'execution_succeeded', - 'execution_failed', - 'execution_rejected', - ]); + expect(runOutcomeMapping.types).toEqual(['run_started', 'run_succeeded', 'run_failed', 'run_rejected']); }); it('is started from its first start', () => { @@ -52,7 +47,7 @@ describe('the outcome of a run', () => { describe('the outcome of a run that ended', () => { it('takes the tokens a reasoning run recorded and its duration from start to end when it succeeds', () => { - const succeeded = { type: 'execution_succeeded', output: 'ok', record: { usage, duration_ms: 900 }, ...fact }; + const succeeded = { type: 'run_succeeded', output: 'ok', record: { usage, duration_ms: 900 }, ...fact }; expect(keptAfter(started, { ...succeeded, at: '2026-10-01T09:00:01.250Z' })).toEqual({ ...firstStart, @@ -65,13 +60,13 @@ describe('the outcome of a run that ended', () => { }); it('takes the duration of a workflow run, whose record holds no tokens', () => { - const workflow = startedAt('2026-10-01T09:00:00.000Z', 'approval', 'orchestration'); - const deferred = { type: 'execution_deferred', record: { run: 'r1' }, ...fact, at: '2026-10-01T09:00:00.100Z' }; - const succeeded = { type: 'execution_succeeded', output: {}, record: {}, ...fact, at: '2026-10-02T09:00:00.000Z' }; + const workflow = startedAt('2026-10-01T09:00:00.000Z', 'approval', 'workflow'); + const deferred = { type: 'run_deferred', record: { run: 'r1' }, ...fact, at: '2026-10-01T09:00:00.100Z' }; + const succeeded = { type: 'run_succeeded', output: {}, record: {}, ...fact, at: '2026-10-02T09:00:00.000Z' }; expect(keptAfter(workflow, deferred, succeeded)).toEqual({ ...firstStart, - primitive: 'orchestration', + definitionType: 'workflow', name: 'approval', status: 'succeeded', durationMs: 86_400_000, @@ -80,7 +75,7 @@ describe('the outcome of a run that ended', () => { }); it('takes the duration of a failed run, with no tokens', () => { - expect(keptAfter(started, { type: 'execution_failed', ...fact, at: '2026-10-01T09:00:02.000Z' })).toEqual({ + expect(keptAfter(started, { type: 'run_failed', ...fact, at: '2026-10-01T09:00:02.000Z' })).toEqual({ ...firstStart, status: 'failed', durationMs: 2000, @@ -92,7 +87,7 @@ describe('the outcome of a run that ended', () => { describe('the outcome of a rejected run', () => { it('counts the tokens of a rejected run that recorded them, never a duration', () => { const rejection = { reason: 'unavailable', detail: 'The answer is not JSON' }; - const rejected = { type: 'execution_rejected', rejection, ...fact, at: '2026-10-01T09:00:03.000Z' }; + const rejected = { type: 'run_rejected', rejection, ...fact, at: '2026-10-01T09:00:03.000Z' }; expect([keptAfter(started, { ...rejected, record: { usage } }), keptAfter(started, rejected)]).toEqual([ { ...firstStart, status: 'rejected', durationMs: null, inputTokens: 1200, outputTokens: 300, cachedTokens: 1000 }, @@ -107,10 +102,10 @@ function spent(input: number, output: number) { describe('the outcome of a run started again or measured oddly', () => { it('keeps its first start when started again, and measures the attempt started last', () => { - const failed = { type: 'execution_failed', ...fact, at: '2026-10-01T09:01:00.000Z' }; + const failed = { type: 'run_failed', ...fact, at: '2026-10-01T09:01:00.000Z' }; const again = startedAt('2026-10-02T10:00:00.000Z', 'renamed'); const succeeded = { - type: 'execution_succeeded', + type: 'run_succeeded', output: 'ok', record: {}, ...fact, @@ -125,7 +120,7 @@ describe('the outcome of a run started again or measured oddly', () => { it('adds up the tokens of every attempt, while it measures the last one alone', () => { const rejected = { - type: 'execution_rejected', + type: 'run_rejected', rejection: { reason: 'unavailable', detail: 'The answer is not JSON' }, record: spent(500, 40), ...fact, @@ -133,7 +128,7 @@ describe('the outcome of a run started again or measured oddly', () => { }; const again = startedAt('2026-10-01T10:00:00.000Z'); const succeeded = { - type: 'execution_succeeded', + type: 'run_succeeded', output: 'ok', record: spent(100, 10), ...fact, @@ -149,11 +144,11 @@ describe('the outcome of a run started again or measured oddly', () => { describe('the outcome of a run with a finish it cannot measure', () => { it('is kept with the day of its finish when the finish comes without a start', () => { - expect(keptAfter({ type: 'execution_failed', ...fact, at: '2026-10-03T23:59:59.999Z' })).toEqual({ + expect(keptAfter({ type: 'run_failed', ...fact, at: '2026-10-03T23:59:59.999Z' })).toEqual({ startedDay: '2026-10-03', startedAt: '2026-10-03T23:59:59.999Z', lastStartedAt: '2026-10-03T23:59:59.999Z', - primitive: '', + definitionType: '', name: '', status: 'failed', durationMs: null, @@ -171,12 +166,12 @@ describe('the outcome of a run with a finish it cannot measure', () => { }; expect( - runOutcomeMapping.rowAfter(row, { type: 'execution_failed', ...fact, at: '2026-10-01T09:00:01.000Z' }), + runOutcomeMapping.rowAfter(row, { type: 'run_failed', ...fact, at: '2026-10-01T09:00:01.000Z' }), ).toMatchObject({ status: 'failed', durationMs: null }); }); it('takes no duration below zero, when a finish is recorded before its start by another clock', () => { - expect(keptAfter(started, { type: 'execution_failed', ...fact, at: '2026-10-01T08:59:59.000Z' })).toMatchObject({ + expect(keptAfter(started, { type: 'run_failed', ...fact, at: '2026-10-01T08:59:59.000Z' })).toMatchObject({ durationMs: 0, }); }); @@ -193,19 +188,19 @@ const notTokens: readonly unknown[] = [ const notRunEvents: readonly unknown[] = [ null, - 'execution_started', + 'run_started', 7, [], - { type: 'execution_started', primitive: 'inference', at: '2026-10-01T09:00:00.000Z' }, - { type: 'execution_started', primitive: 'inference', name: 'triage', at: 'not a time' }, - { type: 'execution_succeeded' }, - { type: 'execution_deferred', at: '2026-10-01T09:00:00.000Z' }, + { type: 'run_started', definition_type: 'reasoning', at: '2026-10-01T09:00:00.000Z' }, + { type: 'run_started', definition_type: 'reasoning', name: 'triage', at: 'not a time' }, + { type: 'run_succeeded' }, + { type: 'run_deferred', at: '2026-10-01T09:00:00.000Z' }, { type: 'tool_call_started', at: '2026-10-01T09:00:00.000Z' }, ]; describe('what the outcome of a run leaves as it is', () => { it.each(notTokens)('is the tokens of a record that holds no counts, %j', (record) => { - const succeeded = { type: 'execution_succeeded', output: 'ok', record, ...fact, at: '2026-10-01T09:00:01.000Z' }; + const succeeded = { type: 'run_succeeded', output: 'ok', record, ...fact, at: '2026-10-01T09:00:01.000Z' }; expect(keptAfter(started, succeeded)).toMatchObject(noTokens); }); diff --git a/packages/specs/src/analytics/run-outcome-mapping.ts b/packages/definitions/src/analytics/run-outcome-mapping.ts similarity index 81% rename from packages/specs/src/analytics/run-outcome-mapping.ts rename to packages/definitions/src/analytics/run-outcome-mapping.ts index 08f13fe92..8153fb64a 100644 --- a/packages/specs/src/analytics/run-outcome-mapping.ts +++ b/packages/definitions/src/analytics/run-outcome-mapping.ts @@ -6,14 +6,14 @@ const Time = Schema.String.check( ); const StartedSchema = Schema.Struct({ - type: Schema.Literal('execution_started'), - primitive: Schema.String, + type: Schema.Literal('run_started'), + definition_type: Schema.String, name: Schema.String, at: Time, }); const FinishedSchema = Schema.Struct({ - type: Schema.Literals(['execution_succeeded', 'execution_failed', 'execution_rejected']), + type: Schema.Literals(['run_succeeded', 'run_failed', 'run_rejected']), at: Time, record: Schema.optionalKey(Schema.Unknown), }); @@ -25,9 +25,9 @@ type Finished = typeof FinishedSchema.Type; const decodeRunEvent = Schema.decodeUnknownOption(Schema.Union([StartedSchema, FinishedSchema])); const statusOf: Readonly> = { - execution_succeeded: 'succeeded', - execution_failed: 'failed', - execution_rejected: 'rejected', + run_succeeded: 'succeeded', + run_failed: 'failed', + run_rejected: 'rejected', }; const noTokens = { inputTokens: null, outputTokens: null, cachedTokens: null }; @@ -68,17 +68,17 @@ function durationBetween(startedAt: string, finishedAt: string): number | null { type Attempt = Pick; -function started(row: RunOutcome | undefined, { primitive, name, at }: Started): RunOutcome { +function started(row: RunOutcome | undefined, { definition_type: type, name, at }: Started): RunOutcome { const again: Attempt = { lastStartedAt: at, status: 'started', durationMs: null }; return row === undefined - ? { startedDay: dayOf(at), startedAt: at, primitive, name, ...again, ...noTokens } + ? { startedDay: dayOf(at), startedAt: at, definitionType: type, name, ...again, ...noTokens } : { ...row, ...again }; } function finished(row: RunOutcome | undefined, { type, at, record }: Finished): RunOutcome { const status = statusOf[type]; if (row === undefined) { - const notStarted = { startedDay: dayOf(at), startedAt: at, lastStartedAt: at, primitive: '', name: '' }; + const notStarted = { startedDay: dayOf(at), startedAt: at, lastStartedAt: at, definitionType: '', name: '' }; return { ...notStarted, status, durationMs: null, ...tokensAfter(noTokens, record) }; } const durationMs = status === 'rejected' ? null : durationBetween(row.lastStartedAt, at); @@ -86,11 +86,11 @@ function finished(row: RunOutcome | undefined, { type, at, record }: Finished): } export const runOutcomeMapping: RunOutcomeMapping = { - types: ['execution_started', 'execution_succeeded', 'execution_failed', 'execution_rejected'], + types: ['run_started', 'run_succeeded', 'run_failed', 'run_rejected'], rowAfter: (row, event) => Option.getOrUndefined( Option.map(decodeRunEvent(event), (decoded) => - decoded.type === 'execution_started' ? started(row, decoded) : finished(row, decoded), + decoded.type === 'run_started' ? started(row, decoded) : finished(row, decoded), ), ), }; diff --git a/packages/specs/src/cancellation/caller-cancels.test.ts b/packages/definitions/src/cancellation/caller-cancels.test.ts similarity index 60% rename from packages/specs/src/cancellation/caller-cancels.test.ts rename to packages/definitions/src/cancellation/caller-cancels.test.ts index 27be48cb9..a2f631d31 100644 --- a/packages/specs/src/cancellation/caller-cancels.test.ts +++ b/packages/definitions/src/cancellation/caller-cancels.test.ts @@ -1,7 +1,7 @@ import { Effect } from 'effect'; import { describe, expect, it } from 'vitest'; -import { executionCanceller, type CancelRequest } from '../index.ts'; +import { runCanceller, type CancelRequest } from '../index.ts'; import { acmeAdmin } from '../testing/callers.ts'; import { toBrain } from '../testing/harness.ts'; import { withHandOn } from '../testing/relaying.ts'; @@ -18,15 +18,15 @@ const parentEnded: CancelRequest = { kind: 'parent_ended', reason: 'The run that describe('a cancel by the caller of a run that has not started', () => { it('is the first record of its stream, so the start that lands after it is rejected as cancelled and runs nothing', async () => { - const { call, executeSpec, getExecution, cancelExecution, ledger, relayer } = await withHandOn(); - const cancel = executionCanceller(ledger.service); + const { call, runDefinition, getRun, cancelRun, ledger, relayer } = await withHandOn(); + const cancel = runCanceller(ledger.service); const receipt = await Effect.runPromise(cancel(child, parentEnded, lineage)); const started = await call( - executeSpec, - toAlpha(acmeAdmin, { primitive: 'relay', name: 'hand-on', input: {}, execution_id: childId }), + runDefinition, + toAlpha(acmeAdmin, { type: 'relay', name: 'hand-on', input: {}, run_id: childId }), ); - const read = await call(getExecution, toAlpha(acmeAdmin, { execution_id: childId })); + const read = await call(getRun, toAlpha(acmeAdmin, { run_id: childId })); const again = await Effect.runPromise(cancel(child, parentEnded, lineage)); expect(receipt).toBe('requested'); @@ -38,7 +38,7 @@ describe('a cancel by the caller of a run that has not started', () => { }); expect([relayer.runs(), read]).toMatchObject([0, { status: 'rejected', reason: 'not_found' }]); expect(again).toBe('requested'); - expect(await call(cancelExecution, toAlpha(acmeAdmin, { execution_id: childId }))).toMatchObject({ + expect(await call(cancelRun, toAlpha(acmeAdmin, { run_id: childId }))).toMatchObject({ status: 'rejected', reason: 'not_found', }); @@ -47,16 +47,16 @@ describe('a cancel by the caller of a run that has not started', () => { describe('the stream of a run its caller cancelled before it started', () => { it('holds no run: the list leaves it out, filtered or not, and the run and its history are not found', async () => { - const { call, getExecution, getExecutionHistory, listExecutions, ledger } = await withHandOn(); - await Effect.runPromise(executionCanceller(ledger.service)(child, parentEnded, lineage)); + const { call, getRun, getRunHistory, listRuns, ledger } = await withHandOn(); + await Effect.runPromise(runCanceller(ledger.service)(child, parentEnded, lineage)); - const filters: readonly Readonly>[] = [{}, { status: 'started' }, { primitive: 'relay' }]; - const listed = await Promise.all(filters.map((filter) => call(listExecutions, toAlpha(acmeAdmin, filter)))); - const read = await call(getExecution, toAlpha(acmeAdmin, { execution_id: childId })); - const history = await call(getExecutionHistory, toAlpha(acmeAdmin, { execution_id: childId })); + const filters: readonly Readonly>[] = [{}, { status: 'started' }, { type: 'relay' }]; + const listed = await Promise.all(filters.map((filter) => call(listRuns, toAlpha(acmeAdmin, filter)))); + const read = await call(getRun, toAlpha(acmeAdmin, { run_id: childId })); + const history = await call(getRunHistory, toAlpha(acmeAdmin, { run_id: childId })); expect(listed).toEqual( - listed.map(() => ({ status: 'succeeded', output: { executions: [], has_more: false, next_cursor: null } })), + listed.map(() => ({ status: 'succeeded', output: { runs: [], has_more: false, next_cursor: null } })), ); expect([read, history]).toMatchObject([ { status: 'rejected', reason: 'not_found' }, @@ -67,17 +67,17 @@ describe('the stream of a run its caller cancelled before it started', () => { describe('a run within its call that its caller cancels and then interrupts', () => { it('ends rejected as cancelled with the kind of the cancel, not failed', async () => { - const { call, callCancelledWhen, executeSpec, getExecution, ledger, prober } = await withHandOn(); - const cancel = executionCanceller(ledger.service); + const { call, callCancelledWhen, runDefinition, getRun, ledger, prober } = await withHandOn(); + const cancel = runCanceller(ledger.service); prober.sufferOnNextRun('stall'); const cancelled = prober.stalled.then(() => Effect.runPromise(cancel(child, parentEnded, lineage))); const settled = await callCancelledWhen( cancelled, - executeSpec, - toAlpha(acmeAdmin, { primitive: 'probe', name: 'plain', input: {}, execution_id: childId }), + runDefinition, + toAlpha(acmeAdmin, { type: 'probe', name: 'plain', input: {}, run_id: childId }), ); - const read = await call(getExecution, toAlpha(acmeAdmin, { execution_id: childId })); + const read = await call(getRun, toAlpha(acmeAdmin, { run_id: childId })); expect(settled).toEqual({ status: 'cancelled' }); expect(read).toMatchObject({ @@ -89,16 +89,16 @@ describe('a run within its call that its caller cancels and then interrupts', () }); it('ends failed when nothing asked for its cancel before it was interrupted', async () => { - const { call, callCancelledWhen, executeSpec, getExecution, prober } = await withHandOn(); + const { call, callCancelledWhen, runDefinition, getRun, prober } = await withHandOn(); prober.sufferOnNextRun('stall'); await callCancelledWhen( prober.stalled, - executeSpec, - toAlpha(acmeAdmin, { primitive: 'probe', name: 'plain', input: {}, execution_id: childId }), + runDefinition, + toAlpha(acmeAdmin, { type: 'probe', name: 'plain', input: {}, run_id: childId }), ); - expect(await call(getExecution, toAlpha(acmeAdmin, { execution_id: childId }))).toMatchObject({ + expect(await call(getRun, toAlpha(acmeAdmin, { run_id: childId }))).toMatchObject({ output: { status: 'failed' }, }); }); diff --git a/packages/specs/src/cancellation/cancel-execution.test.ts b/packages/definitions/src/cancellation/cancel-run.test.ts similarity index 67% rename from packages/specs/src/cancellation/cancel-execution.test.ts rename to packages/definitions/src/cancellation/cancel-run.test.ts index d1c9a0ce3..6e2566bb4 100644 --- a/packages/specs/src/cancellation/cancel-execution.test.ts +++ b/packages/definitions/src/cancellation/cancel-run.test.ts @@ -1,17 +1,17 @@ import { Effect } from 'effect'; import { describe, expect, it } from 'vitest'; -import { defineCancelExecution } from '../index.ts'; +import { defineCancelRun } from '../index.ts'; import { acmeAdmin } from '../testing/callers.ts'; import { echo } from '../testing/echo.ts'; import { firstMoment, toBrain } from '../testing/harness.ts'; import { cancelledAt, relayedId, withHandOn } from '../testing/relaying.ts'; const asStarted = { - execution_id: relayedId, - primitive: 'relay', + run_id: relayedId, + type: 'relay', name: 'hand-on', - spec_version: 1, + definition_version: 1, status: 'started', started_at: firstMoment, started_by: 'acme-admin', @@ -19,10 +19,10 @@ const asStarted = { const everything = { kind: 'everything' } as const; -describe('cancel_execution', () => { +describe('cancel_run', () => { it('records the request on a run that finishes later, with its caller and reason, and answers the run as it stands', async () => { - const { cancelling, executing, history } = await withHandOn(); - await executing(); + const { cancelling, running, history } = await withHandOn(); + await running(); expect(await cancelling({ reason: 'Not needed any more' })).toStrictEqual({ status: 'succeeded', @@ -31,12 +31,12 @@ describe('cancel_execution', () => { expect(await history()).toMatchObject({ output: { events: [ - { type: 'execution_started' }, + { type: 'run_started' }, { - type: 'execution_cancel_requested', + type: 'run_cancel_requested', at: cancelledAt, summary: 'Someone allowed to change the brain asked for the run to be cancelled.', - data: { execution_id: relayedId, by: 'acme-admin', kind: 'requested', reason: 'Not needed any more' }, + data: { run_id: relayedId, by: 'acme-admin', kind: 'requested', reason: 'Not needed any more' }, }, ], }, @@ -44,8 +44,8 @@ describe('cancel_execution', () => { }); it('says who asked when no reason is given, and records nothing more when asked again before the run ended', async () => { - const { cancelling, executing, ledger, run } = await withHandOn(); - await executing(); + const { cancelling, running, ledger, run } = await withHandOn(); + await running(); await cancelling(); await cancelling({ reason: 'Asked again' }); @@ -55,14 +55,14 @@ describe('cancel_execution', () => { ), ); - expect(records.filter(({ type }) => type === 'execution_cancel_requested').map(({ data }) => data)).toEqual([ + expect(records.filter(({ type }) => type === 'run_cancel_requested').map(({ data }) => data)).toEqual([ { - type: 'execution_cancel_requested', + type: 'run_cancel_requested', kind: 'requested', reason: 'Cancelled at the request of acme-admin', - primitive: 'relay', + definition_type: 'relay', name: 'hand-on', - spec_version: 1, + definition_version: 1, by: 'acme-admin', at: cancelledAt, }, @@ -72,8 +72,8 @@ describe('cancel_execution', () => { describe('a cancel request on a run', () => { it('is put in the tree of the run, caused by nothing, as a request from outside is', async () => { - const { cancelling, executing, ledger, run } = await withHandOn(); - await executing(); + const { cancelling, running, ledger, run } = await withHandOn(); + await running(); await cancelling(); const { records } = await run( @@ -83,22 +83,19 @@ describe('a cancel request on a run', () => { ); expect(records.at(-1)).toMatchObject({ - type: 'execution_cancel_requested', + type: 'run_cancel_requested', causationId: null, correlationId: relayedId, }); }); it('is a conflict for a run that has ended, and for one that runs within its call', async () => { - const { call, cancelling, executeSpec, executing, prober, settling } = await withHandOn(); - await executing(); + const { call, cancelling, runDefinition, running, prober, settling } = await withHandOn(); + await running(); await settling({ status: 'succeeded', output: 'handed on', record: {} }); const plain = '0199a3c4-7d2e-7c1a-9b3f-000000000001'; prober.sufferOnNextRun('stall'); - void call( - executeSpec, - toBrain('acme', 'alpha')(acmeAdmin, { primitive: 'probe', name: 'plain', execution_id: plain }), - ); + void call(runDefinition, toBrain('acme', 'alpha')(acmeAdmin, { type: 'probe', name: 'plain', run_id: plain })); await prober.stalled; expect([await cancelling(), await cancelling({}, plain)]).toEqual([ @@ -111,13 +108,13 @@ describe('a cancel request on a run', () => { status: 'rejected', reason: 'conflict', detail: - 'The run runs within the call that started it, which no server can interrupt from outside, so it cannot be cancelled; it ends when that call does', + 'The run takes place within the call that started it, which no server can interrupt from outside, so it cannot be cancelled; it ends when that call does', }, ]); }); }); -describe('cancel_execution of a run the brain does not have, or with a reason it cannot keep', () => { +describe('cancel_run of a run the brain does not have, or with a reason it cannot keep', () => { it('is not found', async () => { const { cancelling } = await withHandOn(); @@ -129,8 +126,8 @@ describe('cancel_execution of a run the brain does not have, or with a reason it }); it('refuses a reason that is blank, too long or holds a control character', async () => { - const { cancelling, executing } = await withHandOn(); - await executing(); + const { cancelling, running } = await withHandOn(); + await running(); const refusals = [ await cancelling({ reason: ' ' }), @@ -147,26 +144,23 @@ describe('cancel_execution of a run the brain does not have, or with a reason it }); describe('the operation of cancelling a run', () => { - const { registration } = defineCancelExecution([echo]); + const { registration } = defineCancelRun([echo]); - it('is a brain command at POST /executions/{execution_id}/cancel that may be rejected as not found or a conflict', () => { + it('is a brain command at POST /runs/{run_id}/cancel that may be rejected as not found or a conflict', () => { expect(registration).toMatchObject({ scope: 'brain', kind: 'command', - name: 'cancel_execution', + name: 'cancel_run', title: 'Cancel run', - route: { method: 'POST', path: '/executions/{execution_id}/cancel' }, + route: { method: 'POST', path: '/runs/{run_id}/cancel' }, reasons: ['not_found', 'conflict'], }); }); it('says in plain words what it tried and that the run is being cancelled', () => { expect([ - registration.plainLanguage?.attempt({ execution_id: relayedId }), - registration.plainLanguage?.outcome( - { ...asStarted, primitive: 'echo', name: 'greet' }, - { execution_id: relayedId }, - ), + registration.plainLanguage?.attempt({ run_id: relayedId }), + registration.plainLanguage?.outcome({ ...asStarted, type: 'echo', name: 'greet' }, { run_id: relayedId }), ]).toEqual([ 'cancel the run', 'The greeting “greet” is being cancelled: it ends as cancelled within a moment, unless it finishes first, and how it ended can be looked up then.', diff --git a/packages/specs/src/cancellation/cancel-execution.ts b/packages/definitions/src/cancellation/cancel-run.ts similarity index 61% rename from packages/specs/src/cancellation/cancel-execution.ts rename to packages/definitions/src/cancellation/cancel-run.ts index a8f2b08a1..73a8ec361 100644 --- a/packages/specs/src/cancellation/cancel-execution.ts +++ b/packages/definitions/src/cancellation/cancel-run.ts @@ -1,14 +1,14 @@ import { BrainReader, BrainWriter, capitalized, defineCommand } from '@beonauto/operations'; import { Effect, Schema } from 'effect'; +import type { Capability } from '../capability/capability.ts'; import { refusingBlankText, refusingForbiddenCharacters } from '../events/cloud-event.ts'; -import { executionDecider, executionStreamOf } from '../execution/execution-decider.ts'; -import { executionOf } from '../execution/execution-lookup.ts'; -import { RunSchema, type Run } from '../execution/execution.ts'; import { commandMetadata } from '../operations/command-metadata.ts'; -import { ExecutionIdField } from '../operations/spec-fields.ts'; -import { specWordsFor } from '../plain-language/spec-words.ts'; -import type { Primitive } from '../primitive/primitive.ts'; +import { RunIdInputField } from '../operations/definition-fields.ts'; +import { definitionWordsFor } from '../plain-language/definition-words.ts'; +import { runDecider, runStreamNameOf } from '../runs/run-decider.ts'; +import { runOf } from '../runs/run-lookup.ts'; +import { RunSchema, type Run } from '../runs/run.ts'; const mostReasonLength = 1024; @@ -21,12 +21,12 @@ const description = [ "the request is recorded on the run at once, and the run ends as cancelled within a moment, its workflow's steps stopped and the runs they wait for cancelled too.", 'It cannot be undone, and what the run did before it ended stays done.', 'Use it only when the person asks to stop that run; a reasoning, computation or recall function runs within its call and cannot be cancelled.', - "`execution_id` is the run's id and `reason`, kept on the run, says why; asking again records nothing more, and get_execution shows how it ended.", + "`run_id` is the run's id and `reason`, kept on the run, says why; asking again records nothing more, and get_run shows how it ended.", ].join(' '); const correlationOf = Effect.fnUntraced(function* (id: string) { const { records } = yield* (yield* BrainReader).readRecorded( - { kind: 'run', execution: id }, + { kind: 'run', run: id }, { order: 'asc', limit: 1, dataOf: [] }, ); return records[0]?.correlationId ?? id; @@ -37,30 +37,30 @@ const cancelled = Effect.fnUntraced(function* (id: string, reason: string | unde const correlationId = yield* Effect.orDie(correlationOf(id)); const { state } = yield* (yield* BrainWriter) .execute( - executionStreamOf(id), - executionDecider, + runStreamNameOf(id), + runDecider, { type: 'cancel', kind: 'requested', reason: reason ?? `Cancelled at the request of ${by}`, by, at }, { causationId: null, correlationId }, ) .pipe(Effect.catchTag('cancelled', Effect.die)); - return yield* executionOf(id, state); + return yield* runOf(id, state); }); -export function defineCancelExecution(primitives: readonly Primitive[]) { - const words = specWordsFor(primitives); - const cancelling = ({ primitive, name }: Pick) => - `${capitalized(words.named(primitive, name))} is being cancelled: it ends as cancelled within a moment, unless it finishes first, and how it ended can be looked up then.`; +export function defineCancelRun(capabilities: readonly Capability[]) { + const words = definitionWordsFor(capabilities); + const cancelling = ({ type, name }: Pick) => + `${capitalized(words.named(type, name))} is being cancelled: it ends as cancelled within a moment, unless it finishes first, and how it ended can be looked up then.`; return defineCommand('brain', { - name: 'cancel_execution', + name: 'cancel_run', title: 'Cancel run', description, - route: { method: 'POST', path: '/executions/{execution_id}/cancel' }, + route: { method: 'POST', path: '/runs/{run_id}/cancel' }, irreversible: true, repeatable: true, - inputSchema: Schema.Struct({ execution_id: ExecutionIdField, reason: Schema.optionalKey(ReasonField) }), + inputSchema: Schema.Struct({ run_id: RunIdInputField, reason: Schema.optionalKey(ReasonField) }), outputSchema: RunSchema, reasons: ['not_found', 'conflict'], - handle: ({ execution_id: id, reason }) => cancelled(id, reason), + handle: ({ run_id: id, reason }) => cancelled(id, reason), plainLanguage: { task: 'cancel a run', attempt: () => 'cancel the run', diff --git a/packages/specs/src/cancellation/deferred-cancels.test.ts b/packages/definitions/src/cancellation/deferred-cancels.test.ts similarity index 79% rename from packages/specs/src/cancellation/deferred-cancels.test.ts rename to packages/definitions/src/cancellation/deferred-cancels.test.ts index cf026652e..2a003df4d 100644 --- a/packages/specs/src/cancellation/deferred-cancels.test.ts +++ b/packages/definitions/src/cancellation/deferred-cancels.test.ts @@ -2,12 +2,12 @@ import { Conflict, type StreamReader, type StreamWriter } from '@beonauto/operat import { Effect } from 'effect'; import { describe, expect, it } from 'vitest'; -import { deferredCanceller, definePrimitive, outboundCallRecorder, type Primitive } from '../index.ts'; +import { deferredCanceller, defineCapability, outboundCallRecorder, type Capability } from '../index.ts'; import { acmeAdmin } from '../testing/callers.ts'; +import { definitionOperationsFor } from '../testing/definition-operations.ts'; import { harness, toBrain } from '../testing/harness.ts'; import { relay } from '../testing/relay.ts'; import { relayedId, withHandOn } from '../testing/relaying.ts'; -import { specOperationsFor } from '../testing/spec-operations.ts'; const relayed = { org: 'acme', brain: 'alpha', id: relayedId }; @@ -15,9 +15,9 @@ const lineage = { causationId: '5d0e9f6a-1b2c-5d3e-8f4a-6b7c8d9e0f1a', correlati const asked = { kind: 'requested', reason: 'Not needed any more', by: 'acme-admin' } as const; -function relayDeciding(cancel: Primitive['cancel']): Primitive { - return definePrimitive({ - name: 'relay', +function relayDeciding(cancel: Capability['cancel']): Capability { + return defineCapability({ + type: 'relay', title: 'Relay', guide: { name: 'relay' }, noun: { one: 'relay', other: 'relays' }, @@ -25,15 +25,15 @@ function relayDeciding(cancel: Primitive['cancel']): Primitive { mediaType: 'text/plain', parse: (source: string) => Effect.succeed(source), summarize: () => ({}), - execute: () => Effect.succeed({ finishesLater: true, record: {} }), + run: () => Effect.succeed({ finishesLater: true, record: {} }), cancel, }); } describe('a cancel of a run of another capability that finishes later', () => { it('settles the run as its capability decides from what the run recorded, by whoever asked', async () => { - const { executing, ledger, reading } = await withHandOn(); - await executing(); + const { running, ledger, reading } = await withHandOn(); + await running(); const deciding = relayDeciding(({ record, kind }) => ({ status: 'rejected', reason: 'cancelled', @@ -57,11 +57,11 @@ describe('a cancel of a run of another capability that finishes later', () => { it('settles it as cancelled with the kind and reason asked when its capability has no hook of its own, or is not served', async () => { const served = await withHandOn(); - await served.executing(); + await served.running(); const gone = await withHandOn(); - await gone.executing(); + await gone.running(); - await Effect.runPromise(deferredCanceller([relay().primitive], served.ledger.service)(relayed, asked, lineage)); + await Effect.runPromise(deferredCanceller([relay().capability], served.ledger.service)(relayed, asked, lineage)); await Effect.runPromise( deferredCanceller([], gone.ledger.service)(relayed, { ...asked, kind: 'parent_ended' }, lineage), ); @@ -75,8 +75,8 @@ describe('a cancel of a run of another capability that finishes later', () => { describe('a cancel of a run of another capability, as asked', () => { it('settles it by the brain itself when the request names no actor', async () => { - const { executing, ledger, run } = await withHandOn(); - await executing(); + const { running, ledger, run } = await withHandOn(); + await running(); await Effect.runPromise( deferredCanceller([], ledger.service)(relayed, { kind: 'deadline', reason: 'Out of time' }, lineage), @@ -91,14 +91,14 @@ describe('a cancel of a run of another capability, as asked', () => { ), ); - expect(records.at(-1)?.data).toMatchObject({ type: 'execution_rejected', by: 'brain:alpha' }); + expect(records.at(-1)?.data).toMatchObject({ type: 'run_rejected', by: 'brain:alpha' }); }); }); describe('a cancel its capability settles otherwise', () => { it('settles it as its capability decides, by the actor the decision names rather than whoever asked', async () => { - const { executing, ledger, run } = await withHandOn(); - await executing(); + const { running, ledger, run } = await withHandOn(); + await running(); const answering = relayDeciding(({ broughtAnswer, deliveredAt }) => ({ status: 'succeeded', output: { delivered: JSON.stringify({ broughtAnswer, deliveredAt }) }, @@ -118,7 +118,7 @@ describe('a cancel its capability settles otherwise', () => { ); expect(records.at(-1)?.data).toMatchObject({ - type: 'execution_succeeded', + type: 'run_succeeded', output: { delivered: JSON.stringify({ broughtAnswer: null, deliveredAt: null }) }, by: 'ada', }); @@ -127,8 +127,8 @@ describe('a cancel its capability settles otherwise', () => { describe('a cancel of a run of another capability, as asked, when its hook breaks', () => { it('fails the run when its capability’s hook throws', async () => { - const { executing, ledger, reading } = await withHandOn(); - await executing(); + const { running, ledger, reading } = await withHandOn(); + await running(); const throwing = relayDeciding(() => { throw new Error('The hook broke'); }); @@ -141,10 +141,10 @@ describe('a cancel of a run of another capability, as asked, when its hook break describe('a cancel of a run of another capability that is over', () => { it('does nothing for a run that has ended, and for one the brain does not have', async () => { - const { executing, ledger, reading, settling } = await withHandOn(); - await executing(); + const { running, ledger, reading, settling } = await withHandOn(); + await running(); await settling({ status: 'succeeded', output: 'handed on', record: {} }); - const cancel = deferredCanceller([relay().primitive], ledger.service); + const cancel = deferredCanceller([relay().capability], ledger.service); await Effect.runPromise(cancel(relayed, asked, lineage)); await Effect.runPromise(cancel({ ...relayed, id: '0199a3c4-7d2e-7c1a-9b3f-00000000ffff' }, asked, lineage)); @@ -153,8 +153,8 @@ describe('a cancel of a run of another capability that is over', () => { }); it('takes a run that ended between the read and the settlement as done, and fails on any other conflict', async () => { - const { executing, ledger } = await withHandOn(); - await executing(); + const { running, ledger } = await withHandOn(); + await running(); const changed = new Conflict({ detail: 'The state changed while the command was decided', kind: 'concurrent_change', @@ -181,8 +181,8 @@ const decidingFromDelivery = relayDeciding(({ deliveredAt }) => describe('a cancel whose run changes between its read and its settlement', () => { it('reads the run again and lets its capability decide from what the run holds now', async () => { - const { executing, ledger, reading } = await withHandOn(); - await executing(); + const { running, ledger, reading } = await withHandOn(); + await running(); const record = outboundCallRecorder(ledger.service); const delivered = Effect.all([ record( @@ -210,8 +210,8 @@ describe('a cancel whose run changes between its read and its settlement', () => describe('a cancel of a run whose start says it finishes later, before it recorded its deferral', () => { it('lets its capability decide from an empty record', async () => { const starting = Promise.withResolvers(); - const pending = definePrimitive({ - name: 'pending', + const pending = defineCapability({ + type: 'pending', title: 'Pending', guide: { name: 'pending' }, noun: { one: 'pending run', other: 'pending runs' }, @@ -220,22 +220,25 @@ describe('a cancel of a run whose start says it finishes later, before it record parse: (source: string) => Effect.succeed(source), summarize: () => ({}), finishesLater: true, - execute: () => Effect.andThen(Effect.sync(starting.resolve), Effect.never), + run: () => Effect.andThen(Effect.sync(starting.resolve), Effect.never), cancel: ({ record, kind }) => ({ status: 'rejected', reason: 'cancelled', kind, detail: JSON.stringify(record) }), }); - const operations = specOperationsFor([pending]); - const specs = harness(); + const operations = definitionOperationsFor([pending]); + const definitions = harness(); const toAlpha = toBrain('acme', 'alpha'); - await specs.call(operations.createSpec, toAlpha(acmeAdmin, { primitive: 'pending', name: 'slow', source: 'x' })); - void specs.call( - operations.executeSpec, - toAlpha(acmeAdmin, { primitive: 'pending', name: 'slow', execution_id: relayedId }), + await definitions.call( + operations.createDefinition, + toAlpha(acmeAdmin, { type: 'pending', name: 'slow', source: 'x' }), + ); + void definitions.call( + operations.runDefinition, + toAlpha(acmeAdmin, { type: 'pending', name: 'slow', run_id: relayedId }), ); await starting.promise; - await Effect.runPromise(deferredCanceller([pending], specs.ledger.service)(relayed, asked, lineage)); + await Effect.runPromise(deferredCanceller([pending], definitions.ledger.service)(relayed, asked, lineage)); - expect(await specs.call(operations.getExecution, toAlpha(acmeAdmin, { execution_id: relayedId }))).toMatchObject({ + expect(await definitions.call(operations.getRun, toAlpha(acmeAdmin, { run_id: relayedId }))).toMatchObject({ output: { status: 'rejected', rejection: { reason: 'cancelled', detail: '{}' } }, }); }); diff --git a/packages/definitions/src/cancellation/deferred-cancels.ts b/packages/definitions/src/cancellation/deferred-cancels.ts new file mode 100644 index 000000000..13e581450 --- /dev/null +++ b/packages/definitions/src/cancellation/deferred-cancels.ts @@ -0,0 +1,73 @@ +import { + brainCallerOf, + streamPrefixOfBrain, + type Conflict, + type Lineage, + type NotFound, + type Settlement, + type StreamReader, + type StreamWriter, +} from '@beonauto/operations'; +import { Effect, Equal } from 'effect'; + +import { cancelledAsAsked, type CancelledRun, type Capability } from '../capability/capability.ts'; +import { changedSinceRead, runDeciderAsRead, runStreamNameOf } from '../runs/run-decider.ts'; +import { endedWithAnotherResult } from '../runs/run-decisions.ts'; +import { runSettlerAsRead, type RunStreamAddress } from '../runs/run-settler.ts'; +import { startedRunOf, takesSettlement } from '../runs/run-state.ts'; +import type { CancelRequest } from './run-cancels.ts'; + +export type SettleCancelled = ( + run: RunStreamAddress, + request: CancelRequest, + lineage: Lineage, +) => Effect.Effect; + +const brokeDown: Settlement = { status: 'failed' }; + +const readsAgainAtMost = 3; + +function endedOtherwise(error: unknown): boolean { + return Equal.equals(error, endedWithAnotherResult); +} + +function changedMeanwhile(error: unknown): boolean { + return Equal.equals(error, changedSinceRead); +} + +function decided(capability: Capability | undefined, run: CancelledRun): Effect.Effect { + const cancel = capability?.cancel ?? cancelledAsAsked; + return Effect.try({ try: () => cancel(run), catch: () => brokeDown }).pipe(Effect.orElseSucceed(() => brokeDown)); +} + +export function deferredCanceller( + capabilities: readonly Capability[], + ledger: StreamReader & StreamWriter, +): SettleCancelled { + const settle = runSettlerAsRead(ledger); + const settledAsRead: SettleCancelled = (run, { kind, reason, by }, lineage) => + Effect.gen(function* () { + const stream = `${streamPrefixOfBrain(run)}${runStreamNameOf(run.id.toLowerCase())}`; + const read = (yield* ledger.load(stream, runDeciderAsRead)).state; + const state = startedRunOf(read.state); + if (state === undefined || !takesSettlement(state)) { + return; + } + const capability = capabilities.find(({ type }) => type === state.run.type); + const settlement = yield* decided(capability, { + run, + record: state.record ?? {}, + kind, + reason, + broughtAnswer: state.broughtAnswer, + deliveredAt: state.deliveredAt, + }); + const actor = settlement.by ?? by ?? brainCallerOf(run).id; + yield* settle(run, { ...settlement, by: actor }, read.version, lineage).pipe( + Effect.asVoid, + Effect.catchIf(endedOtherwise, () => Effect.void), + ); + }); + return (run, request, lineage) => + settledAsRead(run, request, lineage).pipe(Effect.retry({ times: readsAgainAtMost, while: changedMeanwhile })); +} diff --git a/packages/specs/src/cancellation/run-cancels.test.ts b/packages/definitions/src/cancellation/run-cancels.test.ts similarity index 73% rename from packages/specs/src/cancellation/run-cancels.test.ts rename to packages/definitions/src/cancellation/run-cancels.test.ts index e55a2f6ed..a6982f59a 100644 --- a/packages/specs/src/cancellation/run-cancels.test.ts +++ b/packages/definitions/src/cancellation/run-cancels.test.ts @@ -2,7 +2,7 @@ import { Conflict, type StreamWriter } from '@beonauto/operations'; import { Effect } from 'effect'; import { describe, expect, it } from 'vitest'; -import { executionCanceller, type CancelRequest, type ExecutionAddress } from '../index.ts'; +import { runCanceller, type CancelRequest, type RunStreamAddress } from '../index.ts'; import { acmeAdmin } from '../testing/callers.ts'; import { toBrain } from '../testing/harness.ts'; import { relayedId, withHandOn } from '../testing/relaying.ts'; @@ -17,9 +17,9 @@ const everything = { kind: 'everything' } as const; describe('a cancel request the workflow host records on a run', () => { it('is requested on a run that finishes later, by the brain itself unless an actor is named, with the lineage given', async () => { - const { executing, ledger, run } = await withHandOn(); - await executing(); - const cancel = executionCanceller(ledger.service); + const { running, ledger, run } = await withHandOn(); + await running(); + const cancel = runCanceller(ledger.service); const receipt = await Effect.runPromise(cancel(relayed, deadline, lineage)); const { records } = await run( @@ -30,26 +30,23 @@ describe('a cancel request the workflow host records on a run', () => { expect(receipt).toBe('requested'); expect(records.at(-1)).toMatchObject({ - type: 'execution_cancel_requested', + type: 'run_cancel_requested', ...lineage, data: { kind: 'deadline', reason: deadline.reason, by: 'brain:alpha' }, }); }); it('answers that the run has ended, and records the request on a run within its call, on a run not started yet, but not at an ill-formed address', async () => { - const { call, executeSpec, executing, ledger, prober, settling } = await withHandOn(); - await executing(); + const { call, runDefinition, running, ledger, prober, settling } = await withHandOn(); + await running(); await settling({ status: 'failed' }); const plain = '0199a3c4-7d2e-7c1a-9b3f-000000000001'; prober.sufferOnNextRun('stall'); - void call( - executeSpec, - toBrain('acme', 'alpha')(acmeAdmin, { primitive: 'probe', name: 'plain', execution_id: plain }), - ); + void call(runDefinition, toBrain('acme', 'alpha')(acmeAdmin, { type: 'probe', name: 'plain', run_id: plain })); await prober.stalled; - const cancel = executionCanceller(ledger.service); + const cancel = runCanceller(ledger.service); - const addresses: readonly ExecutionAddress[] = [ + const addresses: readonly RunStreamAddress[] = [ relayed, { ...relayed, id: plain }, { ...relayed, id: '0199a3c4-7d2e-7c1a-9b3f-00000000ffff' }, @@ -57,7 +54,7 @@ describe('a cancel request the workflow host records on a run', () => { { ...relayed, brain: 'al/pha' }, ]; const receipts = await Effect.runPromise( - Effect.forEach(addresses, (execution) => cancel(execution, { ...deadline, by: 'acme-admin' }, lineage)), + Effect.forEach(addresses, (run) => cancel(run, { ...deadline, by: 'acme-admin' }, lineage)), ); expect(receipts).toEqual(['ended', 'requested', 'requested', 'unknown_run', 'unknown_run']); @@ -72,8 +69,6 @@ describe('a cancel request the stream refuses otherwise', () => { }); const changing: StreamWriter = { execute: () => Effect.fail(changed) }; - expect(await Effect.runPromise(Effect.flip(executionCanceller(changing)(relayed, deadline, lineage)))).toBe( - changed, - ); + expect(await Effect.runPromise(Effect.flip(runCanceller(changing)(relayed, deadline, lineage)))).toBe(changed); }); }); diff --git a/packages/specs/src/cancellation/run-cancels.ts b/packages/definitions/src/cancellation/run-cancels.ts similarity index 58% rename from packages/specs/src/cancellation/run-cancels.ts rename to packages/definitions/src/cancellation/run-cancels.ts index 7a2125ebc..ed0b1ad6f 100644 --- a/packages/specs/src/cancellation/run-cancels.ts +++ b/packages/definitions/src/cancellation/run-cancels.ts @@ -9,10 +9,10 @@ import { } from '@beonauto/operations'; import { DateTime, Effect, Equal, Schema } from 'effect'; -import { executionDecider, executionStreamOf } from '../execution/execution-decider.ts'; -import { endedBeforeCancelling } from '../execution/execution-decisions.ts'; -import type { CancelRequestKind } from '../execution/execution-events.ts'; -import type { ExecutionAddress } from '../execution/execution-settler.ts'; +import { runDecider, runStreamNameOf } from '../runs/run-decider.ts'; +import { endedBeforeCancelling } from '../runs/run-decisions.ts'; +import type { CancelRequestKind } from '../runs/run-events.ts'; +import type { RunStreamAddress } from '../runs/run-settler.ts'; export interface CancelRequest { readonly kind: CancelRequestKind; @@ -22,8 +22,8 @@ export interface CancelRequest { export type CancelReceipt = 'requested' | 'ended' | 'unknown_run'; -export type CancelExecution = ( - execution: ExecutionAddress, +export type CancelRun = ( + run: RunStreamAddress, request: CancelRequest, lineage: Lineage, ) => Effect.Effect; @@ -36,17 +36,17 @@ function endedFirst(error: unknown): boolean { return Equal.equals(error, endedBeforeCancelling); } -export function executionCanceller(ledger: StreamWriter): CancelExecution { - return (execution, { kind, reason, by }, lineage) => +export function runCanceller(ledger: StreamWriter): CancelRun { + return (run, { kind, reason, by }, lineage) => Effect.gen(function* () { - if (!isWellFormed(execution)) { + if (!isWellFormed(run)) { return 'unknown_run'; } - const stream = `${streamPrefixOfBrain(execution)}${executionStreamOf(execution.id.toLowerCase())}`; + const stream = `${streamPrefixOfBrain(run)}${runStreamNameOf(run.id.toLowerCase())}`; const at = DateTime.formatIso(yield* DateTime.now); - const actor = by ?? brainCallerOf(execution).id; + const actor = by ?? brainCallerOf(run).id; return yield* ledger - .execute(stream, executionDecider, { type: 'cancel', kind, reason, by: actor, at, byItsCaller: true }, lineage) + .execute(stream, runDecider, { type: 'cancel', kind, reason, by: actor, at, byItsCaller: true }, lineage) .pipe( Effect.as('requested'), Effect.catchTags({ not_found: Effect.die, cancelled: Effect.die }), diff --git a/packages/specs/src/primitive/primitive-rules.test.ts b/packages/definitions/src/capability/capability-rules.test.ts similarity index 72% rename from packages/specs/src/primitive/primitive-rules.test.ts rename to packages/definitions/src/capability/capability-rules.test.ts index 43ffaa70f..cef541a8b 100644 --- a/packages/specs/src/primitive/primitive-rules.test.ts +++ b/packages/definitions/src/capability/capability-rules.test.ts @@ -8,36 +8,36 @@ const header = [ "import * as Operations from '@beonauto/operations';", 'export const { Caller, Conflict, InvalidInput, NotFound, Unavailable } = Operations;', "import { Effect } from 'effect';", - "import { definePrimitive } from '../../../src/index.ts';", - "export const about = { name: 'probe', title: 'Probe', guide: { name: 'probe' }, noun: { one: 'probe', other: 'probes' }, describeOutput: () => 'Probed.', mediaType: 'text/plain' };", + "import { defineCapability } from '../../../src/index.ts';", + "export const about = { type: 'probe', title: 'Probe', guide: { name: 'probe' }, noun: { one: 'probe', other: 'probes' }, describeOutput: () => 'Probed.', mediaType: 'text/plain' };", "export const parse = (source: string) => Effect.succeed({ lines: source.split('\\n') });", "export const summarize = () => ({ description: 'Probe' });", ]; function defined(lines: readonly string[]): readonly string[] { - return [...header, 'export const probe = definePrimitive({', ' ...about,', ...lines, '});']; + return [...header, 'export const probe = defineCapability({', ' ...about,', ...lines, '});']; } -const answering = ' execute: () => Effect.succeed({ output: null, record: {} }),'; +const answering = ' run: () => Effect.succeed({ output: null, record: {} }),'; const accepted: Readonly> = { 'parsed-value-flows.ts': defined([ ' parse,', " summarize: ({ lines }) => ({ description: lines.join(' '), inputSchema: { type: 'object' } }),", - ' execute: ({ lines }, input, { spec }) =>', - ' Effect.succeed({ output: { lines: [...lines], input, version: spec.version }, record: { count: lines.length } }),', + ' run: ({ lines }, input, { definition }) =>', + ' Effect.succeed({ output: { lines: [...lines], input, version: definition.version }, record: { count: lines.length } }),', ]), 'declared-rejections.ts': defined([ " parse: (source: string) => source === '' ? Effect.fail(new InvalidInput({ detail: 'empty', issues: [] })) : parse(source),", ' summarize,', - ' execute: ({ lines }) =>', + ' run: ({ lines }) =>', " lines.length > 2 ? Effect.fail(new Conflict({ detail: 'cannot run as written' })) :", " lines.length > 1 ? Effect.fail(new Unavailable({ detail: 'busy' })) : Effect.fail(new InvalidInput({ detail: 'no', issues: [] })),", ]), 'finishes-later.ts': defined([ ' parse,', ' summarize,', - ' execute: (_parsed, _input, { id }) => Effect.succeed({ finishesLater: true, record: { run: id } }),', + ' run: (_parsed, _input, { id }) => Effect.succeed({ finishesLater: true, record: { run: id } }),', ]), }; @@ -55,12 +55,12 @@ const rejected: Readonly> = { answering, ]), }, - 'execute-expects-another-value.ts': { + 'run-expects-another-value.ts': { because: "Property 'size' is missing", source: defined([ ' parse,', ' summarize,', - ' execute: ({ size }: { size: number }) => Effect.succeed({ output: size, record: {} }),', + ' run: ({ size }: { size: number }) => Effect.succeed({ output: size, record: {} }),', ]), }, 'parse-rejects-undeclared.ts': { @@ -71,37 +71,33 @@ const rejected: Readonly> = { answering, ]), }, - 'execute-rejects-undeclared.ts': { - because: "Type 'NotFound' is not assignable to type 'PrimitiveRejection'", - source: defined([' parse,', ' summarize,', " execute: () => Effect.fail(new NotFound({ detail: 'gone' })),"]), + 'run-rejects-undeclared.ts': { + because: "Type 'NotFound' is not assignable to type 'CapabilityRejection'", + source: defined([' parse,', ' summarize,', " run: () => Effect.fail(new NotFound({ detail: 'gone' })),"]), }, - 'execute-asks-a-service.ts': { + 'run-asks-a-service.ts': { because: "Type 'Caller' is not assignable to type 'never'", source: defined([ ' parse,', ' summarize,', - ' execute: () => Effect.gen(function* () { yield* Caller; return { output: null, record: {} }; }),', + ' run: () => Effect.gen(function* () { yield* Caller; return { output: null, record: {} }; }),', ]), }, 'output-not-json.ts': { because: "is not assignable to type 'Json'", - source: defined([ - ' parse,', - ' summarize,', - ' execute: () => Effect.succeed({ output: new Date(), record: {} }),', - ]), + source: defined([' parse,', ' summarize,', ' run: () => Effect.succeed({ output: new Date(), record: {} }),']), }, 'finishes-later-without-record.ts': { because: "Property 'record' is missing", - source: defined([' parse,', ' summarize,', ' execute: () => Effect.succeed({ finishesLater: true }),']), + source: defined([' parse,', ' summarize,', ' run: () => Effect.succeed({ finishesLater: true }),']), }, 'record-not-an-object.ts': { because: "Type 'string' is not assignable to type 'JsonObject'", - source: defined([' parse,', ' summarize,', " execute: () => Effect.succeed({ output: null, record: 'ran' }),"]), + source: defined([' parse,', ' summarize,', " run: () => Effect.succeed({ output: null, record: 'ran' }),"]), }, }; -const fixtureDirectory = fileURLToPath(new URL('../../node_modules/.cache/primitive-rules/', import.meta.url)); +const fixtureDirectory = fileURLToPath(new URL('../../node_modules/.cache/capability-rules/', import.meta.url)); function compiledErrors(): readonly string[] { rmSync(fixtureDirectory, { recursive: true, force: true }); @@ -131,7 +127,7 @@ describe('the compiler', () => { errors = compiledErrors(); }, 60_000); - it('accepts every primitive that keeps the rules and rejects every one that breaks them', () => { + it('accepts every capability that keeps the rules and rejects every one that breaks them', () => { expect(new Set(errors.map((error) => error.slice(0, error.indexOf('('))))).toEqual(new Set(Object.keys(rejected))); }); diff --git a/packages/specs/src/primitive/primitive.test.ts b/packages/definitions/src/capability/capability.test.ts similarity index 63% rename from packages/specs/src/primitive/primitive.test.ts rename to packages/definitions/src/capability/capability.test.ts index 2a316b244..cf09d9c36 100644 --- a/packages/specs/src/primitive/primitive.test.ts +++ b/packages/definitions/src/capability/capability.test.ts @@ -2,8 +2,8 @@ import { InvalidInput } from '@beonauto/operations'; import { Effect } from 'effect'; import { describe, expect, it } from 'vitest'; -import type { DeliveryEvent } from '../execution/execution-events.ts'; -import { defineExecuteSpec, definePrimitive, type RunContext, type PrimitiveDefinition } from '../index.ts'; +import { defineRunDefinition, defineCapability, type RunContext, type CapabilityDeclaration } from '../index.ts'; +import type { DeliveryEvent } from '../runs/run-events.ts'; import { noLongestRuns } from '../testing/longest-runs.ts'; import { recordingJournal } from '../testing/recording-journal.ts'; import { answerOfCall, startOfCall } from '../testing/tool-user.ts'; @@ -15,8 +15,8 @@ const parseWords = (source: string) => ) : Effect.succeed({ words: source.trim().split(/\s+/u) }); -const words: PrimitiveDefinition<{ readonly words: readonly string[] }> = { - name: 'words', +const words: CapabilityDeclaration<{ readonly words: readonly string[] }> = { + type: 'words', title: 'Words', guide: { name: 'words' }, noun: { one: 'count', other: 'counts' }, @@ -24,8 +24,7 @@ const words: PrimitiveDefinition<{ readonly words: readonly string[] }> = { mediaType: 'text/plain', parse: parseWords, summarize: (parsed) => ({ description: `${parsed.words.length} words` }), - execute: (parsed, input, execution) => - Effect.succeed({ output: { words: parsed.words, input }, record: { execution: execution.id } }), + run: (parsed, input, run) => Effect.succeed({ output: { words: parsed.words, input }, record: { run: run.id } }), }; const deliveryOfWords: DeliveryEvent = { @@ -34,19 +33,19 @@ const deliveryOfWords: DeliveryEvent = { target: 'ada', server: 'chat', tool: 'post_message', - primitive: 'words', + definition_type: 'words', name: 'count', - spec_version: 1, + definition_version: 1, by: 'brain:alpha', at: '2026-10-01T09:00:00.000Z', }; -const execution: RunContext = { +const run: RunContext = { id: '0199a3c4-7d2e-7c1a-9b3f-2f1e0d9c8b7a', org: 'acme', brain: 'alpha', caller: { id: 'acme-admin', org: 'acme', permissions: ['brain:write'], brains: '*' }, - spec: { name: 'count', version: 3 }, + definition: { name: 'count', version: 3 }, journal: recordingJournal(), lineage: { startId: '5d0e9f6a-1b2c-5d3e-8f4a-6b7c8d9e0f1a', correlationId: '0199a3c4-7d2e-7c1a-9b3f-2f1e0d9c8b7a' }, depth: 0, @@ -54,70 +53,70 @@ const execution: RunContext = { longestRunOf: noLongestRuns, }; -describe('a primitive', () => { - it('keeps its name, title, description, noun, words for an output and media type', () => { - const primitive = definePrimitive(words); +describe('a capability', () => { + it('keeps its type, title, description, noun, words for an output and media type', () => { + const capability = defineCapability(words); - expect(primitive).toMatchObject({ - name: 'words', + expect(capability).toMatchObject({ + type: 'words', title: 'Words', guide: { name: 'words' }, noun: { one: 'count', other: 'counts' }, mediaType: 'text/plain', }); - expect(primitive.describeOutput({ words: [] })).toBe('It counted the words.'); + expect(capability.describeOutput({ words: [] })).toBe('It counted the words.'); }); - it('runs an execution for at most the time it states, or 10 minutes when it states none', () => { - expect([definePrimitive(words), definePrimitive({ ...words, longestExecutionMs: 1_660_000 })]).toMatchObject([ - { longestExecutionMs: 600_000 }, - { longestExecutionMs: 1_660_000 }, + it('runs a run for at most the time it states, or 10 minutes when it states none', () => { + expect([defineCapability(words), defineCapability({ ...words, longestAnyRunMs: 1_660_000 })]).toMatchObject([ + { longestAnyRunMs: 600_000 }, + { longestAnyRunMs: 1_660_000 }, ]); }); it.each(['ab', 'Words', '1words', 'word_s', `w${'o'.repeat(32)}`])('may not be named %j', (name) => { - expect(() => definePrimitive({ ...words, name })).toThrow(`The primitive name ${name} is malformed`); + expect(() => defineCapability({ ...words, type: name })).toThrow(`The type ${name} is malformed`); }); it('prepares a document by parsing it once, and summarizes and executes what it parsed', async () => { - const prepared = await Effect.runPromise(definePrimitive(words).prepare(' one two three ')); + const prepared = await Effect.runPromise(defineCapability(words).prepare(' one two three ')); expect(prepared.summary).toEqual({ description: '3 words' }); - expect(await Effect.runPromise(prepared.execute({ shout: true }, execution))).toEqual({ + expect(await Effect.runPromise(prepared.run({ shout: true }, run))).toEqual({ output: { words: ['one', 'two', 'three'], input: { shout: true } }, - record: { execution: execution.id }, + record: { run: run.id }, }); }); it('fails to prepare a document its parser rejects, with the issues the parser found', async () => { - expect(await Effect.runPromise(Effect.flip(definePrimitive(words).prepare(' ')))).toEqual( + expect(await Effect.runPromise(Effect.flip(defineCapability(words).prepare(' ')))).toEqual( new InvalidInput({ detail: 'The document is empty', issues: [{ detail: 'Line 1 is empty', pointer: '' }] }), ); }); }); -describe('the reach of a primitive', () => { - it('reaches systems outside the server only when it says so, and so does execute_spec when one of its primitives does', () => { - const local = definePrimitive(words); - const outside = definePrimitive({ ...words, name: 'lookup', reachesOutside: true }); +describe('the reach of a capability', () => { + it('reaches systems outside the server only when it says so, and so does run_definition when one of its capabilities does', () => { + const local = defineCapability(words); + const outside = defineCapability({ ...words, type: 'lookup', reachesOutside: true }); expect([local.reachesOutside, outside.reachesOutside]).toEqual([false, true]); expect([ - defineExecuteSpec([local]).registration.reachesOutside, - defineExecuteSpec([local, outside]).registration.reachesOutside, + defineRunDefinition([local]).registration.reachesOutside, + defineRunDefinition([local, outside]).registration.reachesOutside, ]).toEqual([false, true]); }); }); -describe('the change a primitive may make outside the server', () => { - it('is none unless it says so, and execute_spec may change the outside when one of its primitives may', () => { - const local = definePrimitive(words); - const acting = definePrimitive({ ...words, name: 'acting', reachesOutside: true, mayChangeOutside: true }); +describe('the change a capability may make outside the server', () => { + it('is none unless it says so, and run_definition may change the outside when one of its capabilities may', () => { + const local = defineCapability(words); + const acting = defineCapability({ ...words, type: 'acting', reachesOutside: true, mayChangeOutside: true }); expect([local.mayChangeOutside, acting.mayChangeOutside]).toEqual([false, true]); expect([ - defineExecuteSpec([local]).registration.mayChangeOutside, - defineExecuteSpec([local, acting]).registration.mayChangeOutside, + defineRunDefinition([local]).registration.mayChangeOutside, + defineRunDefinition([local, acting]).registration.mayChangeOutside, ]).toEqual([false, true]); }); }); @@ -139,15 +138,15 @@ describe('a journal that records in memory, for tests', () => { }); }); -describe('what a primitive declares of its runs', () => { +describe('what a capability declares of its runs', () => { it('is known to a run in a test by none of the definitions it might call', async () => { - expect(await Effect.runPromise(execution.longestRunOf('probe', 'plain'))).toBeUndefined(); + expect(await Effect.runPromise(run.longestRunOf('probe', 'plain'))).toBeUndefined(); }); it('is that they end within their call, within its longest run, unless it says otherwise', async () => { - const plain = await Effect.runPromise(definePrimitive(words).prepare('one two')); + const plain = await Effect.runPromise(defineCapability(words).prepare('one two')); const later = await Effect.runPromise( - definePrimitive({ + defineCapability({ ...words, finishesLater: true, longestRunOf: ({ words: given }) => given.length * 1000, @@ -161,9 +160,9 @@ describe('what a primitive declares of its runs', () => { }); }); -describe('what a primitive decides of each document and says of its runs', () => { +describe('what a capability decides of each document and says of its runs', () => { it('finishes later for the documents it says do, when it decides by what it parsed', async () => { - const deciding = definePrimitive({ ...words, finishesLater: ({ words: given }) => given.includes('later') }); + const deciding = defineCapability({ ...words, finishesLater: ({ words: given }) => given.includes('later') }); expect([ (await Effect.runPromise(deciding.prepare('now'))).finishesLater, @@ -172,12 +171,12 @@ describe('what a primitive decides of each document and says of its runs', () => }); it('gives no words of a deferral and the words of every capability for a delivery, unless it gives its own', () => { - const own = definePrimitive({ + const own = defineCapability({ ...words, runWords: { deferral: (record) => ({ summary: 'Waiting.', data: record }) }, }); - expect([definePrimitive(words).runWords.deferral({}), own.runWords.deferral({ to: 'ada' })]).toEqual([ + expect([defineCapability(words).runWords.deferral({}), own.runWords.deferral({ to: 'ada' })]).toEqual([ undefined, { summary: 'Waiting.', data: { to: 'ada' } }, ]); @@ -190,19 +189,19 @@ describe('what a primitive decides of each document and says of its runs', () => describe('the cancelling of a run', () => { it('cancels a run by settling it as cancelled with the kind and reason asked, unless it decides otherwise', () => { const asked = { - execution: { org: 'acme', brain: 'alpha', id: '0199a3c4-7d2e-7c1a-9b3f-2f1e0d9c8b7a' }, + run: { org: 'acme', brain: 'alpha', id: '0199a3c4-7d2e-7c1a-9b3f-2f1e0d9c8b7a' }, record: { step: 1 }, kind: 'deadline', reason: 'The step ran out of time', broughtAnswer: null, deliveredAt: null, } as const; - const deciding = definePrimitive({ + const deciding = defineCapability({ ...words, cancel: ({ record }) => ({ status: 'rejected', reason: 'conflict', detail: `At step ${JSON.stringify(record)}` }), }); - expect([definePrimitive(words).cancel(asked), deciding.cancel(asked)]).toEqual([ + expect([defineCapability(words).cancel(asked), deciding.cancel(asked)]).toEqual([ { status: 'rejected', reason: 'cancelled', kind: 'deadline', detail: 'The step ran out of time' }, { status: 'rejected', reason: 'conflict', detail: 'At step {"step":1}' }, ]); diff --git a/packages/specs/src/primitive/primitive.ts b/packages/definitions/src/capability/capability.ts similarity index 70% rename from packages/specs/src/primitive/primitive.ts rename to packages/definitions/src/capability/capability.ts index d27e48042..d637dd3e9 100644 --- a/packages/specs/src/primitive/primitive.ts +++ b/packages/definitions/src/capability/capability.ts @@ -1,11 +1,11 @@ import type { CallerIdentity, Conflict, InvalidInput, Noun, Settlement, Unavailable } from '@beonauto/operations'; import { Effect, type Schema } from 'effect'; -import type { CallAnsweredFact, CallStartedFact } from '../execution/execution-commands.ts'; -import type { CancelRequestKind, DeliveryEvent } from '../execution/execution-events.ts'; -import type { BroughtAnswer } from '../execution/execution-state.ts'; -import type { Trigger } from '../registry/spec-triggers.ts'; +import type { Trigger } from '../registry/definition-triggers.ts'; import { deliveryEnded, deliveryStarted } from '../run-work/delivery-words.ts'; +import type { CallAnsweredFact, CallStartedFact } from '../runs/run-commands.ts'; +import type { CancelRequestKind, DeliveryEvent } from '../runs/run-events.ts'; +import type { BroughtAnswer } from '../runs/run-state.ts'; export interface DefinitionSummary { readonly description?: string; @@ -41,12 +41,12 @@ export interface RunContext { readonly org: string; readonly brain: string; readonly caller: CallerIdentity; - readonly spec: { readonly name: string; readonly version: number }; + readonly definition: { readonly name: string; readonly version: number }; readonly journal: ToolCallJournal; readonly lineage: RunLineage; readonly depth: number; readonly callDepth: number; - readonly longestRunOf: (primitive: string, name: string) => Effect.Effect; + readonly longestRunOf: (type: string, name: string) => Effect.Effect; } export interface Finished { @@ -59,12 +59,12 @@ export interface FinishesLater { readonly record: Schema.JsonObject; } -export type Executed = Finished | FinishesLater; +export type CapabilityAnswer = Finished | FinishesLater; -export type PrimitiveRejection = InvalidInput | Unavailable | Conflict; +export type CapabilityRejection = InvalidInput | Unavailable | Conflict; export interface CancelledRun { - readonly execution: { readonly org: string; readonly brain: string; readonly id: string }; + readonly run: { readonly org: string; readonly brain: string; readonly id: string }; readonly record: Schema.JsonObject; readonly kind: CancelRequestKind; readonly reason: string; @@ -74,7 +74,7 @@ export interface CancelledRun { export type CancelDecision = (run: CancelledRun) => Settlement; -export interface PrimitiveGuide { +export interface CapabilityGuide { readonly name: string; readonly onThisServer?: string; } @@ -92,24 +92,24 @@ export interface RunWords { type WhenCancelled = 'stop' | 'finish'; -const defaultLongestExecutionMs = 600_000; +const defaultLongestAnyRunMs = 600_000; -export interface PrimitiveDefinition { - readonly name: string; +export interface CapabilityDeclaration { + readonly type: string; readonly title: string; - readonly guide: PrimitiveGuide; + readonly guide: CapabilityGuide; readonly noun: Noun; readonly describeOutput: (output: Schema.Json) => string; readonly mediaType: string; readonly parse: (source: string) => Effect.Effect; readonly summarize: (parsed: NoInfer) => DefinitionSummary; - readonly execute: ( + readonly run: ( parsed: NoInfer, input: Schema.Json, - execution: RunContext, - ) => Effect.Effect; + context: RunContext, + ) => Effect.Effect; readonly whenCancelled?: WhenCancelled; - readonly longestExecutionMs?: number; + readonly longestAnyRunMs?: number; readonly reachesOutside?: boolean; readonly mayChangeOutside?: boolean; readonly callsTools?: (parsed: NoInfer) => boolean; @@ -123,7 +123,7 @@ export interface PrimitiveDefinition { export interface PreparedDefinition { readonly summary: DefinitionSummary; - readonly execute: (input: Schema.Json, execution: RunContext) => Effect.Effect; + readonly run: (input: Schema.Json, context: RunContext) => Effect.Effect; readonly whenCancelled: WhenCancelled; readonly callsTools: boolean; readonly finishesLater: boolean; @@ -143,7 +143,7 @@ function deliveryInWords(fact: DeliveryEvent): string { } export const defaultRunWords: RunWords = { - deferralType: 'execution_deferred', + deferralType: 'run_deferred', deferral: noDeferralShown, delivery: deliveryInWords, }; @@ -156,14 +156,14 @@ function standsAsSaved(): Effect.Effect { return Effect.undefined; } -export interface Primitive { - readonly name: string; +export interface Capability { + readonly type: string; readonly title: string; - readonly guide: PrimitiveGuide; + readonly guide: CapabilityGuide; readonly noun: Noun; readonly describeOutput: (output: Schema.Json) => string; readonly mediaType: string; - readonly longestExecutionMs: number; + readonly longestAnyRunMs: number; readonly reachesOutside: boolean; readonly mayChangeOutside: boolean; readonly mostActive: number; @@ -173,15 +173,15 @@ export interface Primitive { readonly prepare: (source: string) => Effect.Effect; } -const primitiveName = /^[a-z][a-z0-9-]{2,31}$/u; +const definitionTypePattern = /^[a-z][a-z0-9-]{2,31}$/u; -export function isPrimitiveName(name: string): boolean { - return primitiveName.test(name); +export function isDefinitionTypeName(name: string): boolean { + return definitionTypePattern.test(name); } -function declaredBounds(definition: PrimitiveDefinition) { +function declaredBounds(definition: CapabilityDeclaration) { return { - longestExecutionMs: definition.longestExecutionMs ?? defaultLongestExecutionMs, + longestAnyRunMs: definition.longestAnyRunMs ?? defaultLongestAnyRunMs, reachesOutside: definition.reachesOutside ?? false, mayChangeOutside: definition.mayChangeOutside ?? false, mostActive: definition.mostActive ?? Number.POSITIVE_INFINITY, @@ -191,31 +191,28 @@ function declaredBounds(definition: PrimitiveDefinition) { }; } -function finishingOf(declared: PrimitiveDefinition['finishesLater']): (parsed: Parsed) => boolean { +function finishingOf(declared: CapabilityDeclaration['finishesLater']): (parsed: Parsed) => boolean { return typeof declared === 'function' ? declared : () => declared ?? false; } -function declaredRuns(definition: PrimitiveDefinition, longestExecutionMs: number) { +function declaredRuns(definition: CapabilityDeclaration, longestAnyRunMs: number) { return { whenCancelled: definition.whenCancelled ?? 'stop', callsTools: definition.callsTools ?? callsNoTools, finishesLater: finishingOf(definition.finishesLater), - longestRunOf: definition.longestRunOf ?? (() => longestExecutionMs), + longestRunOf: definition.longestRunOf ?? (() => longestAnyRunMs), }; } -export function definePrimitive(definition: PrimitiveDefinition): Primitive { - const { name, title, guide, noun, describeOutput, mediaType, parse, summarize, execute } = definition; +export function defineCapability(definition: CapabilityDeclaration): Capability { + const { type, title, guide, noun, describeOutput, mediaType, parse, summarize, run } = definition; const bounds = declaredBounds(definition); - const { whenCancelled, callsTools, finishesLater, longestRunOf } = declaredRuns( - definition, - bounds.longestExecutionMs, - ); - if (!isPrimitiveName(name)) { - throw new Error(`The primitive name ${name} is malformed`); + const { whenCancelled, callsTools, finishesLater, longestRunOf } = declaredRuns(definition, bounds.longestAnyRunMs); + if (!isDefinitionTypeName(type)) { + throw new Error(`The type ${type} is malformed`); } return { - name, + type, title, guide, noun, @@ -226,7 +223,7 @@ export function definePrimitive(definition: PrimitiveDefinition) parse(source).pipe( Effect.map((parsed) => ({ summary: summarize(parsed), - execute: (input, execution) => execute(parsed, input, execution), + run: (input, context) => run(parsed, input, context), whenCancelled, callsTools: callsTools(parsed), finishesLater: finishesLater(parsed), diff --git a/packages/specs/src/primitive/function-terminology.test.ts b/packages/definitions/src/capability/function-terminology.test.ts similarity index 70% rename from packages/specs/src/primitive/function-terminology.test.ts rename to packages/definitions/src/capability/function-terminology.test.ts index a6d8b2875..637953246 100644 --- a/packages/specs/src/primitive/function-terminology.test.ts +++ b/packages/definitions/src/capability/function-terminology.test.ts @@ -4,34 +4,34 @@ import { definitionResourceLabel, functionCategoryLabels, functionDescriptions, - functionKindOrder, + functionTypeOrder, functionResourceLabels, } from '../index.ts'; describe('brain function terminology', () => { it('keeps the five function types in product order, without coordination or Dream', () => { - expect(functionKindOrder).toEqual(['reason', 'interact', 'predict', 'recall', 'compute']); - expect(functionKindOrder.map((kind) => functionCategoryLabels[kind])).toEqual([ + expect(functionTypeOrder).toEqual(['reasoning', 'interaction', 'prediction', 'recall', 'computation']); + expect(functionTypeOrder.map((type) => functionCategoryLabels[type])).toEqual([ 'Reasoning', 'Interaction', 'Prediction', 'Recall', 'Computation', ]); - expect(Object.keys(functionCategoryLabels)).toEqual(functionKindOrder); - expect(Object.keys(functionResourceLabels)).toEqual(functionKindOrder); - expect(Object.keys(functionDescriptions)).toEqual(functionKindOrder); + expect(Object.keys(functionCategoryLabels)).toEqual(functionTypeOrder); + expect(Object.keys(functionResourceLabels)).toEqual(functionTypeOrder); + expect(Object.keys(functionDescriptions)).toEqual(functionTypeOrder); }); it('uses countable resource names and describes each declared responsibility', () => { - expect(functionKindOrder.map((kind) => functionResourceLabels[kind])).toEqual([ + expect(functionTypeOrder.map((type) => functionResourceLabels[type])).toEqual([ { singular: 'reasoning function', plural: 'reasoning functions' }, { singular: 'interaction function', plural: 'interaction functions' }, { singular: 'prediction function', plural: 'prediction functions' }, { singular: 'recall function', plural: 'recall functions' }, { singular: 'computation function', plural: 'computation functions' }, ]); - expect(functionKindOrder.map((kind) => functionDescriptions[kind])).toEqual([ + expect(functionTypeOrder.map((type) => functionDescriptions[type])).toEqual([ 'Use a prompt, skills, and tools to interpret information or produce a response.', 'Exchange information with people or systems.', 'Create and use an ML model to make predictions.', @@ -41,11 +41,12 @@ describe('brain function terminology', () => { }); it('labels known resources without reclassifying custom runtime adapters as functions', () => { - expect(definitionResourceLabel('inference')).toBe('reasoning function'); + expect(definitionResourceLabel('reasoning')).toBe('reasoning function'); expect(definitionResourceLabel('interaction')).toBe('interaction function'); expect(definitionResourceLabel('computation')).toBe('computation function'); - expect(definitionResourceLabel('recollection')).toBe('recall function'); - expect(definitionResourceLabel('orchestration')).toBe('workflow'); + expect(definitionResourceLabel('recall')).toBe('recall function'); + expect(definitionResourceLabel('prediction')).toBe('prediction function'); + expect(definitionResourceLabel('workflow')).toBe('workflow'); expect(definitionResourceLabel('echo')).toBe('echo definition'); expect(definitionResourceLabel('agent')).toBe('agent definition'); }); diff --git a/packages/definitions/src/capability/function-terminology.ts b/packages/definitions/src/capability/function-terminology.ts new file mode 100644 index 000000000..6e236e77f --- /dev/null +++ b/packages/definitions/src/capability/function-terminology.ts @@ -0,0 +1,42 @@ +export type FunctionType = 'reasoning' | 'interaction' | 'prediction' | 'recall' | 'computation'; + +export const functionCategoryLabels = { + reasoning: 'Reasoning', + interaction: 'Interaction', + prediction: 'Prediction', + recall: 'Recall', + computation: 'Computation', +} satisfies Record; + +export const functionResourceLabels = { + reasoning: { singular: 'reasoning function', plural: 'reasoning functions' }, + interaction: { singular: 'interaction function', plural: 'interaction functions' }, + prediction: { singular: 'prediction function', plural: 'prediction functions' }, + recall: { singular: 'recall function', plural: 'recall functions' }, + computation: { singular: 'computation function', plural: 'computation functions' }, +} satisfies Record; + +export const functionDescriptions = { + reasoning: 'Use a prompt, skills, and tools to interpret information or produce a response.', + interaction: 'Exchange information with people or systems.', + prediction: 'Create and use an ML model to make predictions.', + recall: 'Answer from what the brain keeps of its own history.', + computation: 'Run defined code or expressions to calculate or transform data.', +} satisfies Record; + +export const functionTypeOrder: readonly FunctionType[] = [ + 'reasoning', + 'interaction', + 'prediction', + 'recall', + 'computation', +]; + +const resourceLabels: ReadonlyMap = new Map([ + ...functionTypeOrder.map((type): [string, string] => [type, functionResourceLabels[type].singular]), + ['workflow', 'workflow'], +]); + +export function definitionResourceLabel(type: string): string { + return resourceLabels.get(type) ?? `${type} definition`; +} diff --git a/packages/definitions/src/capability/known-capabilities.test.ts b/packages/definitions/src/capability/known-capabilities.test.ts new file mode 100644 index 000000000..6735c1b08 --- /dev/null +++ b/packages/definitions/src/capability/known-capabilities.test.ts @@ -0,0 +1,37 @@ +import { NotFound } from '@beonauto/operations'; +import { Effect, Result, Schema } from 'effect'; +import { describe, expect, it } from 'vitest'; + +import { echo } from '../testing/echo.ts'; +import { probe } from '../testing/probe.ts'; +import { knownCapabilities } from './known-capabilities.ts'; + +const known = knownCapabilities([echo, probe().capability]); + +const decodeType = Schema.decodeUnknownResult(known.field); + +function refusalOf(input: unknown): string { + return Result.match(decodeType(input), { onFailure: String, onSuccess: () => 'accepted' }); +} + +describe('the type field of the definition operations', () => { + it('takes a type a capability of the server serves', () => { + expect([refusalOf('echo'), refusalOf('probe')]).toEqual(['accepted', 'accepted']); + }); + + it('refuses a type no capability serves by naming the types the server runs, and a malformed one by its shape alone', () => { + expect([refusalOf('prediction'), refusalOf('Echo!')]).toEqual([ + 'SchemaError(Expected a type this server runs: echo or probe)', + 'SchemaError(Expected a type: 3 to 32 lowercase letters, digits and hyphens, starting with a letter)', + ]); + }); +}); + +describe('the capability of a type', () => { + it('is the capability that serves it, and not found for a type none serves', () => { + expect(Effect.runSync(known.capabilityOfType('echo'))).toBe(echo); + expect(Effect.runSync(Effect.flip(known.capabilityOfType('prediction')))).toEqual( + new NotFound({ detail: 'There is no definition type prediction' }), + ); + }); +}); diff --git a/packages/definitions/src/capability/known-capabilities.ts b/packages/definitions/src/capability/known-capabilities.ts new file mode 100644 index 000000000..7f8cb2bcb --- /dev/null +++ b/packages/definitions/src/capability/known-capabilities.ts @@ -0,0 +1,89 @@ +import { NotFound, alternatives, articled, type JsonSchemaDocument, type Registration } from '@beonauto/operations'; +import { Effect, Schema } from 'effect'; + +import { isDefinitionTypeName, type Capability } from './capability.ts'; + +interface PublishedOperation { + readonly registration: Registration<'brain'>; +} + +const DefinitionTypeField = Schema.String.check( + Schema.makeFilter( + isDefinitionTypeName, + { expected: 'a type: 3 to 32 lowercase letters, digits and hyphens, starting with a letter' }, + true, + ), +); + +function servedTypeField(types: readonly string[]): typeof DefinitionTypeField { + return DefinitionTypeField.check( + Schema.makeFilter((type: string) => types.includes(type), { + expected: `a type this server runs: ${alternatives(types)}`, + }), + ); +} + +export interface KnownCapabilities { + readonly field: typeof DefinitionTypeField; + readonly typesWithGuides: string; + readonly publish: (operation: Operation, meaning?: string) => Operation; + readonly capabilityOfType: (name: string) => Effect.Effect; +} + +function requireSomeCapability(names: readonly string[]): void { + if (names.length === 0) { + throw new Error('The definition operations need at least one capability'); + } +} + +function requireDistinctNames(names: readonly string[]): void { + const repeated = names.find((name, index) => names.indexOf(name) !== index); + if (repeated !== undefined) { + throw new Error(`The type ${repeated} is used more than once`); + } +} + +const typeMeaning = "The definition's type"; + +function withTypeField( + { schema, definitions }: JsonSchemaDocument, + capabilities: readonly Capability[], + meaning: string, +): JsonSchemaDocument { + const types = alternatives(capabilities.map(({ type }) => type)); + const typeProperty = { + type: 'string', + enum: capabilities.map(({ type }) => type), + description: `${meaning}: ${types}`, + }; + return { + schema: { ...schema, properties: Object.assign({}, schema['properties'], { type: typeProperty }) }, + definitions, + }; +} + +export function knownCapabilities(capabilities: readonly Capability[]): KnownCapabilities { + const names = capabilities.map(({ type }) => type); + requireSomeCapability(names); + requireDistinctNames(names); + const byName = new Map(capabilities.map((capability) => [capability.type, capability])); + return { + field: servedTypeField(names), + typesWithGuides: capabilities + .map(({ type, noun, guide }) => `${type}, ${articled(noun.one)}, guide ${guide.name}`) + .join('; '), + publish: (operation, meaning = typeMeaning) => ({ + ...operation, + registration: { + ...operation.registration, + input: withTypeField(operation.registration.input, capabilities, meaning), + }, + }), + capabilityOfType: (name) => { + const capability = byName.get(name); + return capability === undefined + ? Effect.fail(new NotFound({ detail: `There is no definition type ${name}` })) + : Effect.succeed(capability); + }, + }; +} diff --git a/packages/specs/src/checking/checked-worker.ts b/packages/definitions/src/checking/checked-worker.ts similarity index 100% rename from packages/specs/src/checking/checked-worker.ts rename to packages/definitions/src/checking/checked-worker.ts diff --git a/packages/specs/src/checking/schema-checks.test.ts b/packages/definitions/src/checking/schema-checks.test.ts similarity index 100% rename from packages/specs/src/checking/schema-checks.test.ts rename to packages/definitions/src/checking/schema-checks.test.ts diff --git a/packages/specs/src/checking/schema-checks.ts b/packages/definitions/src/checking/schema-checks.ts similarity index 100% rename from packages/specs/src/checking/schema-checks.ts rename to packages/definitions/src/checking/schema-checks.ts diff --git a/packages/specs/src/checking/value-checks.test.ts b/packages/definitions/src/checking/value-checks.test.ts similarity index 100% rename from packages/specs/src/checking/value-checks.test.ts rename to packages/definitions/src/checking/value-checks.test.ts diff --git a/packages/specs/src/checking/value-checks.ts b/packages/definitions/src/checking/value-checks.ts similarity index 100% rename from packages/specs/src/checking/value-checks.ts rename to packages/definitions/src/checking/value-checks.ts diff --git a/packages/specs/src/document.ts b/packages/definitions/src/document.ts similarity index 100% rename from packages/specs/src/document.ts rename to packages/definitions/src/document.ts diff --git a/packages/specs/src/document/document-issue.ts b/packages/definitions/src/document/document-issue.ts similarity index 100% rename from packages/specs/src/document/document-issue.ts rename to packages/definitions/src/document/document-issue.ts diff --git a/packages/specs/src/document/document-split.ts b/packages/definitions/src/document/document-split.ts similarity index 100% rename from packages/specs/src/document/document-split.ts rename to packages/definitions/src/document/document-split.ts diff --git a/packages/specs/src/document/front-matter-keys.ts b/packages/definitions/src/document/front-matter-keys.ts similarity index 100% rename from packages/specs/src/document/front-matter-keys.ts rename to packages/definitions/src/document/front-matter-keys.ts diff --git a/packages/specs/src/document/front-matter-reading.ts b/packages/definitions/src/document/front-matter-reading.ts similarity index 100% rename from packages/specs/src/document/front-matter-reading.ts rename to packages/definitions/src/document/front-matter-reading.ts diff --git a/packages/specs/src/document/front-matter.test.ts b/packages/definitions/src/document/front-matter.test.ts similarity index 100% rename from packages/specs/src/document/front-matter.test.ts rename to packages/definitions/src/document/front-matter.test.ts diff --git a/packages/specs/src/document/hostile-schemas.test.ts b/packages/definitions/src/document/hostile-schemas.test.ts similarity index 100% rename from packages/specs/src/document/hostile-schemas.test.ts rename to packages/definitions/src/document/hostile-schemas.test.ts diff --git a/packages/specs/src/document/json-bounds.ts b/packages/definitions/src/document/json-bounds.ts similarity index 100% rename from packages/specs/src/document/json-bounds.ts rename to packages/definitions/src/document/json-bounds.ts diff --git a/packages/specs/src/document/json-schema.test.ts b/packages/definitions/src/document/json-schema.test.ts similarity index 100% rename from packages/specs/src/document/json-schema.test.ts rename to packages/definitions/src/document/json-schema.test.ts diff --git a/packages/specs/src/document/json-schema.ts b/packages/definitions/src/document/json-schema.ts similarity index 100% rename from packages/specs/src/document/json-schema.ts rename to packages/definitions/src/document/json-schema.ts diff --git a/packages/specs/src/document/reference-loops.ts b/packages/definitions/src/document/reference-loops.ts similarity index 100% rename from packages/specs/src/document/reference-loops.ts rename to packages/definitions/src/document/reference-loops.ts diff --git a/packages/specs/src/document/schema-shape.ts b/packages/definitions/src/document/schema-shape.ts similarity index 95% rename from packages/specs/src/document/schema-shape.ts rename to packages/definitions/src/document/schema-shape.ts index bf0c15b52..d347f6cad 100644 --- a/packages/specs/src/document/schema-shape.ts +++ b/packages/definitions/src/document/schema-shape.ts @@ -43,7 +43,7 @@ function issue(path: Path, detail: string): readonly SchemaIssue[] { return [{ pointer: pointerOf(path), detail }]; } -function isPrimitive(value: Schema.Json): boolean { +function isScalar(value: Schema.Json): boolean { return value === null || typeof value !== 'object'; } @@ -95,12 +95,12 @@ function stringList(value: Schema.Json, path: Path): readonly SchemaIssue[] { : issue(path, 'Expected a list of strings'); } -function primitive(value: Schema.Json, path: Path): readonly SchemaIssue[] { - return isPrimitive(value) ? [] : issue(path, 'Only strings, numbers, booleans and null can be constants'); +function type(value: Schema.Json, path: Path): readonly SchemaIssue[] { + return isScalar(value) ? [] : issue(path, 'Only strings, numbers, booleans and null can be constants'); } -function primitiveList(value: Schema.Json, path: Path): readonly SchemaIssue[] { - if (!isList(value) || value.length === 0 || !value.every((entry) => isPrimitive(entry))) { +function scalarList(value: Schema.Json, path: Path): readonly SchemaIssue[] { + if (!isList(value) || value.length === 0 || !value.every((entry) => isScalar(entry))) { return issue(path, 'Expected a non-empty list of strings, numbers, booleans or null'); } return value.length > mostEnumValues ? issue(path, `An enum may list at most ${mostEnumValues} values`) : []; @@ -150,8 +150,8 @@ const keywordChecks: ReadonlyMap = new Map { it('are its start and its endings, about the run, from the record and its time, naming what ran', () => { const facts = [ - ofRun({ type: 'execution_started', ...ofSummary, input: { text: 'the quarter' }, ...fact }), + ofRun({ type: 'run_started', ...ofSummary, input: { text: 'the quarter' }, ...fact }), ofRun({ - type: 'execution_rejected', + type: 'run_rejected', rejection: { reason: 'unavailable', detail: 'Busy' }, ...ofSummary, ...fact, }), - ofRun({ type: 'execution_failed', ...ofSummary, ...fact }), + ofRun({ type: 'run_failed', ...ofSummary, ...fact }), ].map((recorded) => brainFactOf(recorded)); expect(facts).toEqual([ - { ...aboutTheRun, type: 'execution_started', data: ofTheRun }, - { ...aboutTheRun, type: 'execution_rejected', data: { ...ofTheRun, reason: 'unavailable' } }, - { ...aboutTheRun, type: 'execution_failed', data: ofTheRun }, + { ...aboutTheRun, type: 'run_started', data: ofTheRun }, + { ...aboutTheRun, type: 'run_rejected', data: { ...ofTheRun, reason: 'unavailable' } }, + { ...aboutTheRun, type: 'run_failed', data: ofTheRun }, ]); expect(facts.every((event) => isCloudEvent(event))).toBe(true); }); @@ -87,8 +87,8 @@ describe('the facts of a run as events', () => { const output = { summary: 'Profits rose.' }; expect( - brainFactOf(ofRun({ type: 'execution_succeeded', output, record: { model: 'm' }, ...ofSummary, ...fact })), - ).toEqual({ ...aboutTheRun, type: 'execution_succeeded', data: { ...ofTheRun, output } }); + brainFactOf(ofRun({ type: 'run_succeeded', output, record: { model: 'm' }, ...ofSummary, ...fact })), + ).toEqual({ ...aboutTheRun, type: 'run_succeeded', data: { ...ofTheRun, output } }); }); }); @@ -97,50 +97,46 @@ function deep(levels: number): Schema.Json { } describe('the output a success carries as an event', () => { - it('is its size when it is too large for an event, which get_execution reads whole', () => { + it('is its size when it is too large for an event, which get_run reads whole', () => { const output = { summary: 'x'.repeat(mostPublishedEventBytes) }; - const succeeded = brainFactOf(ofRun({ type: 'execution_succeeded', output, record: {}, ...ofSummary, ...fact })); + const succeeded = brainFactOf(ofRun({ type: 'run_succeeded', output, record: {}, ...ofSummary, ...fact })); expect(succeeded).toEqual({ ...aboutTheRun, - type: 'execution_succeeded', + type: 'run_succeeded', data: { ...ofTheRun, output_bytes: jsonBytesOf(output) }, }); }); it('is its size when it nests too deep for a run to hold the event in a list', () => { expect( - brainFactOf(ofRun({ type: 'execution_succeeded', output: deep(509), record: {}, ...ofSummary, ...fact })), + brainFactOf(ofRun({ type: 'run_succeeded', output: deep(509), record: {}, ...ofSummary, ...fact })), ).toHaveProperty('data.output', deep(509)); expect( - brainFactOf(ofRun({ type: 'execution_succeeded', output: deep(510), record: {}, ...ofSummary, ...fact })), + brainFactOf(ofRun({ type: 'run_succeeded', output: deep(510), record: {}, ...ofSummary, ...fact })), ).toHaveProperty('data.output_bytes', jsonBytesOf(deep(510))); }); it('is the output at the bound of an event, and its size one byte past it', () => { - const empty = { ...aboutTheRun, type: 'execution_succeeded', data: { ...ofTheRun, output: '' } }; + const empty = { ...aboutTheRun, type: 'run_succeeded', data: { ...ofTheRun, output: '' } }; const room = mostPublishedEventBytes - jsonBytesOf(empty); const fitting = 'x'.repeat(room); expect( - brainFactOf(ofRun({ type: 'execution_succeeded', output: fitting, record: {}, ...ofSummary, ...fact })), + brainFactOf(ofRun({ type: 'run_succeeded', output: fitting, record: {}, ...ofSummary, ...fact })), ).toHaveProperty('data.output', fitting); expect( - brainFactOf(ofRun({ type: 'execution_succeeded', output: `${fitting}x`, record: {}, ...ofSummary, ...fact })), + brainFactOf(ofRun({ type: 'run_succeeded', output: `${fitting}x`, record: {}, ...ofSummary, ...fact })), ).toHaveProperty('data.output_bytes', room + 3); }); }); describe('the records of a run that are no facts', () => { it('are its deferrals, its cancel requests and its tool calls', () => { + expect(brainFactOf(ofRun({ type: 'run_deferred', record: { run: 'r-1' }, ...ofSummary, ...fact }))).toBeUndefined(); expect( - brainFactOf(ofRun({ type: 'execution_deferred', record: { run: 'r-1' }, ...ofSummary, ...fact })), - ).toBeUndefined(); - expect( - brainFactOf( - ofRun({ type: 'execution_cancel_requested', kind: 'requested', reason: 'No', ...ofSummary, ...fact }), - ), + brainFactOf(ofRun({ type: 'run_cancel_requested', kind: 'requested', reason: 'No', ...ofSummary, ...fact })), ).toBeUndefined(); expect( brainFactOf( @@ -164,28 +160,33 @@ const content = { source: '---\nmodel: anthropic/claude-sonnet-4-5\n---\nSummari describe('the facts of a definition as events', () => { it('are its creation, its changes and its retirement, about the definition', () => { const facts = [ - ofSpecs({ type: 'spec_created', name: 'summary', version: 1, content, ...fact }), - ofSpecs({ type: 'spec_updated', name: 'summary', version: 2, content, ...fact }), - ofSpecs({ type: 'spec_retired', name: 'summary', ...fact }), + ofDefinitions({ type: 'definition_created', name: 'summary', version: 1, content, ...fact }), + ofDefinitions({ type: 'definition_updated', name: 'summary', version: 2, content, ...fact }), + ofDefinitions({ type: 'definition_retired', name: 'summary', ...fact }), ].map((recorded) => brainFactOf(recorded)); - const aboutTheDefinition = { specversion: '1.0', id: recordId, source: '/specs/inference/summary', time: fact.at }; + const aboutTheDefinition = { + specversion: '1.0', + id: recordId, + source: '/definitions/reasoning/summary', + time: fact.at, + }; expect(facts).toEqual([ { ...aboutTheDefinition, - type: 'spec_created', - data: { primitive: 'inference', name: 'summary', version: 1, caller: 'acme-admin' }, + type: 'definition_created', + data: { definition_type: 'reasoning', name: 'summary', version: 1, caller: 'acme-admin' }, }, { ...aboutTheDefinition, - type: 'spec_updated', - data: { primitive: 'inference', name: 'summary', version: 2, caller: 'acme-admin' }, + type: 'definition_updated', + data: { definition_type: 'reasoning', name: 'summary', version: 2, caller: 'acme-admin' }, }, { ...aboutTheDefinition, - type: 'spec_retired', - data: { primitive: 'inference', name: 'summary', caller: 'acme-admin' }, + type: 'definition_retired', + data: { definition_type: 'reasoning', name: 'summary', caller: 'acme-admin' }, }, ]); expect(facts.every((event) => isCloudEvent(event))).toBe(true); @@ -206,12 +207,12 @@ describe('the records that are no facts of the brain', () => { const unreadable: readonly RecordedEvent[] = [ { ...about, - stream: executionStreamOf(executionId), - type: 'execution_succeeded', - data: { type: 'execution_succeeded' }, + stream: runStreamNameOf(runId), + type: 'run_succeeded', + data: { type: 'run_succeeded' }, }, - { ...about, stream: specsStreamOf('inference'), type: 'spec_created', data: 'not an event' }, - { ...about, stream: executionStreamOf(executionId), type: 'execution_started', data: null }, + { ...about, stream: definitionTypeStreamOf('reasoning'), type: 'definition_created', data: 'not an event' }, + { ...about, stream: runStreamNameOf(runId), type: 'run_started', data: null }, ]; expect(unreadable.map((record) => brainFactOf(record))).toEqual([undefined, undefined, undefined]); @@ -228,7 +229,7 @@ describe('the records that are no facts of the brain', () => { ...fact, }; const others: readonly RecordedEvent[] = [ - { ...about, stream: `runs/${executionId}`, type: 'input_applied', data: {} }, + { ...about, stream: `run-logs/${runId}`, type: 'input_applied', data: {} }, { ...about, stream: 'events/0199a3c4', type: 'event_published', data: {} }, { ...about, stream: 'tool-tests/0199b7e2', type: 'tool_test_started', data: tested }, { ...about, stream: 'conversation-calls/0199b7e3', type: 'replies_read', data: read }, @@ -243,15 +244,15 @@ describe('the records that are no facts of the brain', () => { describe('the cause and the run of a fact', () => { it('are the extension attributes causationid and correlationid, when the record has them', () => { const caused = { - ...ofRun({ type: 'execution_failed', ...ofSummary, ...fact }), + ...ofRun({ type: 'run_failed', ...ofSummary, ...fact }), causationId: 'c-1', correlationId: 'r-1', }; - const correlated = { ...ofRun({ type: 'execution_failed', ...ofSummary, ...fact }), correlationId: 'r-1' }; + const correlated = { ...ofRun({ type: 'run_failed', ...ofSummary, ...fact }), correlationId: 'r-1' }; expect([brainFactOf(caused), brainFactOf(correlated)]).toEqual([ - { ...aboutTheRun, type: 'execution_failed', data: ofTheRun, causationid: 'c-1', correlationid: 'r-1' }, - { ...aboutTheRun, type: 'execution_failed', data: ofTheRun, correlationid: 'r-1' }, + { ...aboutTheRun, type: 'run_failed', data: ofTheRun, causationid: 'c-1', correlationid: 'r-1' }, + { ...aboutTheRun, type: 'run_failed', data: ofTheRun, correlationid: 'r-1' }, ]); }); }); @@ -283,9 +284,9 @@ describe('the events of a brain', () => { ofPublished({ type: 'event_published', event: published, filled: [], by: 'acme-admin', at: fact.at }), ), ).toEqual(published); - expect(brainEventOf(ofRun({ type: 'execution_failed', ...ofSummary, ...fact }))).toEqual({ + expect(brainEventOf(ofRun({ type: 'run_failed', ...ofSummary, ...fact }))).toEqual({ ...aboutTheRun, - type: 'execution_failed', + type: 'run_failed', data: ofTheRun, }); }); diff --git a/packages/specs/src/events/brain-facts.ts b/packages/definitions/src/events/brain-facts.ts similarity index 59% rename from packages/specs/src/events/brain-facts.ts rename to packages/definitions/src/events/brain-facts.ts index 968065126..9370ed949 100644 --- a/packages/specs/src/events/brain-facts.ts +++ b/packages/definitions/src/events/brain-facts.ts @@ -1,24 +1,18 @@ import { streamKindOf, type RecordedEvent } from '@beonauto/operations'; import { Option, Schema } from 'effect'; -import { - ExecutionEventSchema, - type CalledBy, - type ExecutionEvent, - type ExecutionFinished, - type ExecutionStarted, -} from '../execution/execution-events.ts'; -import { jsonBytesOf, nestsWithin } from '../execution/recorded-size.ts'; -import { SpecEventSchema, type SpecEvent } from '../registry/spec-events.ts'; -import type { StartingTrigger } from '../registry/spec-triggers.ts'; +import { DefinitionEventSchema, type DefinitionEvent } from '../registry/definition-events.ts'; +import type { StartingTrigger } from '../registry/definition-triggers.ts'; +import { jsonBytesOf, nestsWithin } from '../runs/recorded-size.ts'; +import { RunEventSchema, type CalledBy, type RunEvent, type RunFinished, type RunStarted } from '../runs/run-events.ts'; import { mostEventDataDepth, mostPublishedEventBytes, type CloudEvent } from './cloud-event.ts'; import { EventPublishedSchema } from './published-events.ts'; -import { runSourcePrefix, specSourcePrefix } from './reserved-attributes.ts'; +import { runSourcePrefix, definitionSourcePrefix } from './reserved-attributes.ts'; -type RunFact = ExecutionStarted | ExecutionFinished; +type RunFact = RunStarted | RunFinished; type RunData = { - readonly primitive: string; + readonly definition_type: string; readonly name: string; readonly version: number; readonly caller: string; @@ -29,22 +23,17 @@ type RunData = { readonly kind?: string; }; -const runFactTypes: readonly RunFact['type'][] = [ - 'execution_started', - 'execution_succeeded', - 'execution_rejected', - 'execution_failed', -]; +const runFactTypes: readonly RunFact['type'][] = ['run_started', 'run_succeeded', 'run_rejected', 'run_failed']; -const decodeExecutionEvent = Schema.decodeUnknownOption(Schema.toCodecJson(ExecutionEventSchema)); +const decodeRunEvent = Schema.decodeUnknownOption(Schema.toCodecJson(RunEventSchema)); -const decodeSpecEvent = Schema.decodeUnknownOption(Schema.toCodecJson(SpecEventSchema)); +const decodeDefinitionEvent = Schema.decodeUnknownOption(Schema.toCodecJson(DefinitionEventSchema)); const decodePublished = Schema.decodeUnknownOption(Schema.toCodecJson(EventPublishedSchema)); const publishedEventsKind = 'events'; -function isRunFact(event: ExecutionEvent): event is RunFact { +function isRunFact(event: RunEvent): event is RunFact { return runFactTypes.some((type) => type === event.type); } @@ -56,7 +45,7 @@ function withOutput(fact: CloudEvent, data: RunData, output: Schema.Json): Cloud } function rejectionOf(event: RunFact): Pick { - if (event.type !== 'execution_rejected') { + if (event.type !== 'run_rejected') { return {}; } const { rejection } = event; @@ -64,11 +53,19 @@ function rejectionOf(event: RunFact): Pick { return kind === undefined ? { reason: rejection.reason } : { reason: rejection.reason, kind }; } -function runFactOf(id: string, execution: string, event: RunFact): CloudEvent { - const { primitive, name, spec_version: version, by: caller, at: time, depth = 0, called_by: calledBy } = event; +function runFactOf(id: string, run: string, event: RunFact): CloudEvent { + const { + definition_type: definitionType, + name, + definition_version: version, + by: caller, + at: time, + depth = 0, + called_by: calledBy, + } = event; const { trigger } = event; const data: RunData = { - primitive, + definition_type: definitionType, name, version, caller, @@ -80,34 +77,41 @@ function runFactOf(id: string, execution: string, event: RunFact): CloudEvent { const fact = { specversion: '1.0', id, - source: `${runSourcePrefix}${execution}`, + source: `${runSourcePrefix}${run}`, type: event.type, - subject: `${primitive}/${name}`, + subject: `${definitionType}/${name}`, time, data, } satisfies CloudEvent; - return event.type === 'execution_succeeded' ? withOutput(fact, data, event.output) : fact; + return event.type === 'run_succeeded' ? withOutput(fact, data, event.output) : fact; } -function specFactOf(id: string, primitive: string, event: SpecEvent): CloudEvent { +function definitionFactOf(id: string, definitionType: string, event: DefinitionEvent): CloudEvent { const { name, by: caller, at: time } = event; return { specversion: '1.0', id, - source: `${specSourcePrefix}${primitive}/${name}`, + source: `${definitionSourcePrefix}${definitionType}/${name}`, type: event.type, time, - data: { primitive, name, ...(event.type === 'spec_retired' ? {} : { version: event.version }), caller }, + data: { + definition_type: definitionType, + name, + ...(event.type === 'definition_retired' ? {} : { version: event.version }), + caller, + }, }; } function factOf({ id, stream, data }: RecordedEvent): CloudEvent | undefined { const kind = streamKindOf(stream); const named = stream.slice(kind.length + 1); - if (kind === 'specs') { - return Option.getOrUndefined(Option.map(decodeSpecEvent(data), (event) => specFactOf(id, named, event))); + if (kind === 'definitions') { + return Option.getOrUndefined( + Option.map(decodeDefinitionEvent(data), (event) => definitionFactOf(id, named, event)), + ); } - const runFact = kind === 'executions' ? Option.filter(decodeExecutionEvent(data), isRunFact) : Option.none(); + const runFact = kind === 'runs' ? Option.filter(decodeRunEvent(data), isRunFact) : Option.none(); return Option.getOrUndefined(Option.map(runFact, (event) => runFactOf(id, named, event))); } diff --git a/packages/specs/src/events/cloud-event.test.ts b/packages/definitions/src/events/cloud-event.test.ts similarity index 100% rename from packages/specs/src/events/cloud-event.test.ts rename to packages/definitions/src/events/cloud-event.test.ts diff --git a/packages/specs/src/events/cloud-event.ts b/packages/definitions/src/events/cloud-event.ts similarity index 98% rename from packages/specs/src/events/cloud-event.ts rename to packages/definitions/src/events/cloud-event.ts index da1c490a9..a43221d5f 100644 --- a/packages/specs/src/events/cloud-event.ts +++ b/packages/definitions/src/events/cloud-event.ts @@ -1,6 +1,6 @@ import { Schema } from 'effect'; -import { mostInputDepth, nestsWithin } from '../execution/recorded-size.ts'; +import { mostInputDepth, nestsWithin } from '../runs/recorded-size.ts'; import { isTime } from './event-time.ts'; export const mostPublishedEventBytes = 245_760; diff --git a/packages/specs/src/events/event-emitter.test.ts b/packages/definitions/src/events/event-emitter.test.ts similarity index 93% rename from packages/specs/src/events/event-emitter.test.ts rename to packages/definitions/src/events/event-emitter.test.ts index 1f5961450..923849dd7 100644 --- a/packages/specs/src/events/event-emitter.test.ts +++ b/packages/definitions/src/events/event-emitter.test.ts @@ -28,7 +28,7 @@ function toldWith(attribute: string, value: string | Readonly { type: 'event_published', event: told, filled: [], - emitted_by: { execution_id: '0199a3c4-7d2e-7c1a-9b3f-2f1e0d9c8b7a', workflow: 'close-the-month', version: 2 }, + emitted_by: { run_id: '0199a3c4-7d2e-7c1a-9b3f-2f1e0d9c8b7a', workflow: 'close-the-month', version: 2 }, depth: 1, by: 'acme-admin', at: '2026-10-01T09:00:01.000Z', @@ -78,7 +78,7 @@ describe('an event a workflow emits that the brain does not record', () => { const outcomes = await Effect.runPromise( Effect.all([ - emit(alpha, emission(toldWith('type', 'execution_succeeded')), lineage), + emit(alpha, emission(toldWith('type', 'run_succeeded')), lineage), emit(alpha, emission(toldWith('source', '')), lineage), emit(alpha, emission(toldWith('data', { region: 'us' })), lineage), ]), diff --git a/packages/specs/src/events/event-emitter.ts b/packages/definitions/src/events/event-emitter.ts similarity index 97% rename from packages/specs/src/events/event-emitter.ts rename to packages/definitions/src/events/event-emitter.ts index 33d0dfa45..9c282be8d 100644 --- a/packages/specs/src/events/event-emitter.ts +++ b/packages/definitions/src/events/event-emitter.ts @@ -1,7 +1,7 @@ import { Conflict, streamPrefixOfBrain, type BrainAddress, type Ledger, type Lineage } from '@beonauto/operations'; import { Effect, Option, Result, Schema, SchemaIssue } from 'effect'; -import { jsonBytesOf } from '../execution/recorded-size.ts'; +import { jsonBytesOf } from '../runs/recorded-size.ts'; import { CloudEventSchema, mostPublishedEventBytes, type CloudEvent } from './cloud-event.ts'; import { publishedEventDecider, publishedEventStreamOf, type Emitter } from './published-events.ts'; import { refusingTheBrainsOwnAttributes } from './reserved-attributes.ts'; diff --git a/packages/specs/src/events/event-time.test.ts b/packages/definitions/src/events/event-time.test.ts similarity index 100% rename from packages/specs/src/events/event-time.test.ts rename to packages/definitions/src/events/event-time.test.ts diff --git a/packages/specs/src/events/event-time.ts b/packages/definitions/src/events/event-time.ts similarity index 100% rename from packages/specs/src/events/event-time.ts rename to packages/definitions/src/events/event-time.ts diff --git a/packages/specs/src/events/publish-event.test.ts b/packages/definitions/src/events/publish-event.test.ts similarity index 68% rename from packages/specs/src/events/publish-event.test.ts rename to packages/definitions/src/events/publish-event.test.ts index 813297faf..189703aea 100644 --- a/packages/specs/src/events/publish-event.test.ts +++ b/packages/definitions/src/events/publish-event.test.ts @@ -2,7 +2,7 @@ import { defineListBrainEvents } from '@beonauto/brains'; import { Effect, Schema } from 'effect'; import { describe, expect, it } from 'vitest'; -import { makeSpecPresenters } from '../index.ts'; +import { makeDefinitionPresenters } from '../index.ts'; import { acmeAdmin, acmeReader } from '../testing/callers.ts'; import { echo } from '../testing/echo.ts'; import { firstMoment, harness, toBrain } from '../testing/harness.ts'; @@ -10,7 +10,7 @@ import { mostPublishedEventBytes } from './cloud-event.ts'; import { publishEvent } from './publish-event.ts'; import { publishedEventDecider, publishedEventStreamOf } from './published-events.ts'; -const listBrainEvents = defineListBrainEvents(makeSpecPresenters([echo])); +const listBrainEvents = defineListBrainEvents(makeDefinitionPresenters([echo])); const toAlpha = toBrain('acme', 'alpha'); @@ -45,9 +45,9 @@ function publishing(event: object) { return toAlpha(acmeAdmin, { event }); } -function storedOn(specs: ReturnType, source: string, id: string) { +function storedOn(definitions: ReturnType, source: string, id: string) { return Effect.runPromise( - specs.ledger.service.load(`brain/acme/alpha/${publishedEventStreamOf(source, id)}`, publishedEventDecider), + definitions.ledger.service.load(`brain/acme/alpha/${publishedEventStreamOf(source, id)}`, publishedEventDecider), ); } @@ -64,10 +64,10 @@ describe('publish_event', () => { }); it('records the event as given, on a stream of its own, and answers its id, its time and when it was recorded', async () => { - const specs = harness(); + const definitions = harness(); - expect(await specs.call(publishEvent, publishing(monthClosed))).toStrictEqual(recordedFirst); - expect(await storedOn(specs, '/ledger/eu', 'm-2026-09')).toStrictEqual({ + expect(await definitions.call(publishEvent, publishing(monthClosed))).toStrictEqual(recordedFirst); + expect(await storedOn(definitions, '/ledger/eu', 'm-2026-09')).toStrictEqual({ state: { type: 'event_published', event: { specversion: '1.0', ...monthClosed }, @@ -80,16 +80,16 @@ describe('publish_event', () => { }); it('fills in an id and the time it records an event that has neither, so each such call is a new event', async () => { - const specs = harness(); + const definitions = harness(); const event = { source: '/ledger/eu', type: 'com.acme.ledger.month-closed' }; - const first = await specs.call(publishEvent, publishing(event)); - const second = await specs.call(publishEvent, publishing(event), later); + const first = await definitions.call(publishEvent, publishing(event)); + const second = await definitions.call(publishEvent, publishing(event), later); expect(first).toMatchObject({ status: 'succeeded', output: { time: firstMoment, recorded_at: firstMoment } }); expect(idOf(first)).toMatch(anUuid); expect(idOf(second)).not.toBe(idOf(first)); - expect(await storedOn(specs, '/ledger/eu', idOf(first))).toMatchObject({ + expect(await storedOn(definitions, '/ledger/eu', idOf(first))).toMatchObject({ state: { event: { specversion: '1.0', id: idOf(first), time: firstMoment }, filled: ['id', 'time'] }, }); }); @@ -97,62 +97,64 @@ describe('publish_event', () => { describe('publishing an event again', () => { it('answers the first record for the same event, even without its time, and records nothing more', async () => { - const specs = harness(); + const definitions = harness(); const { time: _time, ...withoutTime } = monthClosed; - await specs.call(publishEvent, publishing(monthClosed)); + await definitions.call(publishEvent, publishing(monthClosed)); - expect(await specs.call(publishEvent, publishing(monthClosed), later)).toStrictEqual(recordedFirst); - expect(await specs.call(publishEvent, publishing({ specversion: '1.0', ...withoutTime }), later)).toStrictEqual( - recordedFirst, - ); - expect(await storedOn(specs, '/ledger/eu', 'm-2026-09')).toMatchObject({ version: 1 }); + expect(await definitions.call(publishEvent, publishing(monthClosed), later)).toStrictEqual(recordedFirst); + expect( + await definitions.call(publishEvent, publishing({ specversion: '1.0', ...withoutTime }), later), + ).toStrictEqual(recordedFirst); + expect(await storedOn(definitions, '/ledger/eu', 'm-2026-09')).toMatchObject({ version: 1 }); }); it('answers the first record for its time spelled otherwise, and the filled time to a retry that gives one', async () => { - const specs = harness(); + const definitions = harness(); const { time: _time, ...withoutTime } = monthClosed; - await specs.call(publishEvent, publishing(monthClosed)); - await specs.call(publishEvent, publishing({ ...withoutTime, id: 'm-2026-10' })); + await definitions.call(publishEvent, publishing(monthClosed)); + await definitions.call(publishEvent, publishing({ ...withoutTime, id: 'm-2026-10' })); expect( - await specs.call(publishEvent, publishing({ ...monthClosed, time: '2026-10-01t10:59:00+02:00' }), later), + await definitions.call(publishEvent, publishing({ ...monthClosed, time: '2026-10-01t10:59:00+02:00' }), later), ).toStrictEqual(recordedFirst); expect( - await specs.call(publishEvent, publishing({ ...withoutTime, id: 'm-2026-10', time: later }), later), + await definitions.call(publishEvent, publishing({ ...withoutTime, id: 'm-2026-10', time: later }), later), ).toStrictEqual({ status: 'succeeded', output: { id: 'm-2026-10', time: firstMoment, recorded_at: firstMoment } }); }); it('is rejected with conflict for a different event under the same source and id', async () => { - const specs = harness(); - await specs.call(publishEvent, publishing(monthClosed)); - - expect(await specs.call(publishEvent, publishing({ ...monthClosed, data: { region: 'us' } }), later)).toEqual({ - status: 'rejected', - reason: 'conflict', - detail: 'Another event was published with this source and id; give a different event an id of its own', - }); + const definitions = harness(); + await definitions.call(publishEvent, publishing(monthClosed)); + + expect(await definitions.call(publishEvent, publishing({ ...monthClosed, data: { region: 'us' } }), later)).toEqual( + { + status: 'rejected', + reason: 'conflict', + detail: 'Another event was published with this source and id; give a different event an id of its own', + }, + ); }); }); describe('an event the brain does not take', () => { it('is rejected under a type or a source that are the brain’s own', async () => { - const specs = harness(); + const definitions = harness(); expect( - await specs.call(publishEvent, publishing({ source: '/executions/0199a3c4', type: 'execution_succeeded' })), + await definitions.call(publishEvent, publishing({ source: '/runs/0199a3c4', type: 'run_succeeded' })), ).toMatchObject({ status: 'rejected', reason: 'invalid_input', issues: [{ pointer: '/event/type' }, { pointer: '/event/source' }], }); - expect(specs.ledger.streamNames()).toEqual([]); + expect(definitions.ledger.streamNames()).toEqual([]); }); it('is rejected when it claims the cause or the correlation the brain gives its own facts', async () => { - const specs = harness(); + const definitions = harness(); expect( - await specs.call( + await definitions.call( publishEvent, publishing({ source: '/ledger/eu', @@ -166,22 +168,22 @@ describe('an event the brain does not take', () => { reason: 'invalid_input', issues: [{ pointer: '/event/causationid' }, { pointer: '/event/correlationid' }], }); - expect(specs.ledger.streamNames()).toEqual([]); + expect(definitions.ledger.streamNames()).toEqual([]); }); }); describe('an event past its bound', () => { it('is rejected when it takes more than its bound once its id and time are filled in', async () => { - const specs = harness(); + const definitions = harness(); const event = { source: '/ledger/eu', type: 'com.acme.ledger.month-closed' }; const room = mostPublishedEventBytes - JSON.stringify({ specversion: '1.0', ...event, id: 'x'.repeat(36), time: firstMoment, data: '' }).length; - expect(await specs.call(publishEvent, publishing({ ...event, data: 'x'.repeat(room) }))).toMatchObject({ + expect(await definitions.call(publishEvent, publishing({ ...event, data: 'x'.repeat(room) }))).toMatchObject({ status: 'succeeded', }); - expect(await specs.call(publishEvent, publishing({ ...event, data: 'x'.repeat(room + 1) }))).toEqual({ + expect(await definitions.call(publishEvent, publishing({ ...event, data: 'x'.repeat(room + 1) }))).toEqual({ status: 'rejected', reason: 'invalid_input', detail: 'The event cannot be published as it is', @@ -197,9 +199,9 @@ describe('an event past its bound', () => { describe('an event whose data nests too deep', () => { it('is rejected when its data nests deeper than a run can hold, before anything is recorded', async () => { - const specs = harness(); + const definitions = harness(); - expect(await specs.call(publishEvent, publishing({ ...monthClosed, data: deep(511) }))).toEqual({ + expect(await definitions.call(publishEvent, publishing({ ...monthClosed, data: deep(511) }))).toEqual({ status: 'rejected', reason: 'invalid_input', detail: 'The input does not match the input schema', @@ -210,8 +212,8 @@ describe('an event whose data nests too deep', () => { }, ], }); - expect(specs.ledger.streamNames()).toEqual([]); - expect(await specs.call(publishEvent, publishing({ ...monthClosed, data: deep(510) }))).toMatchObject({ + expect(definitions.ledger.streamNames()).toEqual([]); + expect(await definitions.call(publishEvent, publishing({ ...monthClosed, data: deep(510) }))).toMatchObject({ status: 'succeeded', }); }); @@ -226,10 +228,10 @@ describe('an event whose data nests too deep', () => { describe('the events published to a brain', () => { it('appear among its events, by type and in plain words', async () => { - const specs = harness(); - await specs.call(publishEvent, publishing(monthClosed)); + const definitions = harness(); + await definitions.call(publishEvent, publishing(monthClosed)); - expect(await specs.call(listBrainEvents, toAlpha(acmeReader, { type: 'event_published' }))).toMatchObject({ + expect(await definitions.call(listBrainEvents, toAlpha(acmeReader, { type: 'event_published' }))).toMatchObject({ status: 'succeeded', output: { events: [ diff --git a/packages/specs/src/events/publish-event.ts b/packages/definitions/src/events/publish-event.ts similarity index 95% rename from packages/specs/src/events/publish-event.ts rename to packages/definitions/src/events/publish-event.ts index 4396cdccb..b176c0733 100644 --- a/packages/specs/src/events/publish-event.ts +++ b/packages/definitions/src/events/publish-event.ts @@ -1,8 +1,8 @@ import { BrainWriter, InvalidInput, defineCommand, quoted, randomUUIDv7, type Issue } from '@beonauto/operations'; import { Effect, Schema } from 'effect'; -import { jsonBytesOf } from '../execution/recorded-size.ts'; import { commandMetadata } from '../operations/command-metadata.ts'; +import { jsonBytesOf } from '../runs/recorded-size.ts'; import { EventToPublishSchema, mostPublishedEventBytes, type CloudEvent, type EventToPublish } from './cloud-event.ts'; import { publishedEventDecider, @@ -52,7 +52,7 @@ export const publishEvent = defineCommand('brain', { description: [ 'Publishes an event to the brain, where a workflow whose schedule names its type starts a run and a recall function that filters on it folds it,', 'and returns its id and time.', - 'Use it when something outside the brain happened that the brain should react to or remember; send_execution_event gives an event to one waiting run instead.', + 'Use it when something outside the brain happened that the brain should react to or remember; send_run_event gives an event to one waiting run instead.', '`event` is a CloudEvents event with a `source` and a `type`, and publishing the same source and id again records nothing, so a call can be retried with its id.', "The brain's own types and sources, such as those of its runs and definitions, and the lineage attributes it gives its own records, are refused.", ].join(' '), diff --git a/packages/specs/src/events/published-events.test.ts b/packages/definitions/src/events/published-events.test.ts similarity index 100% rename from packages/specs/src/events/published-events.test.ts rename to packages/definitions/src/events/published-events.test.ts diff --git a/packages/specs/src/events/published-events.ts b/packages/definitions/src/events/published-events.ts similarity index 99% rename from packages/specs/src/events/published-events.ts rename to packages/definitions/src/events/published-events.ts index e8cc2844e..67ddc2750 100644 --- a/packages/specs/src/events/published-events.ts +++ b/packages/definitions/src/events/published-events.ts @@ -12,7 +12,7 @@ const FilledAttributeSchema = Schema.Literals(['id', 'time']); export type FilledAttribute = typeof FilledAttributeSchema.Type; const EmitterSchema = Schema.Struct({ - execution_id: Schema.String, + run_id: Schema.String, workflow: Schema.String, version: Schema.Int, }); diff --git a/packages/specs/src/events/reaction-refusals.ts b/packages/definitions/src/events/reaction-refusals.ts similarity index 100% rename from packages/specs/src/events/reaction-refusals.ts rename to packages/definitions/src/events/reaction-refusals.ts diff --git a/packages/specs/src/events/reserved-attributes.test.ts b/packages/definitions/src/events/reserved-attributes.test.ts similarity index 79% rename from packages/specs/src/events/reserved-attributes.test.ts rename to packages/definitions/src/events/reserved-attributes.test.ts index f1eb0fd8b..1d8bef467 100644 --- a/packages/specs/src/events/reserved-attributes.test.ts +++ b/packages/definitions/src/events/reserved-attributes.test.ts @@ -2,7 +2,7 @@ import { toolTestPresenter } from '@beonauto/mcp'; import { Result, Schema, SchemaIssue } from 'effect'; import { describe, expect, it } from 'vitest'; -import { makeSpecPresenters } from '../index.ts'; +import { makeDefinitionPresenters } from '../index.ts'; import { echo } from '../testing/echo.ts'; import { isReservedSource, refusingTheBrainsOwnAttributes, reservedEventTypes } from './reserved-attributes.ts'; @@ -25,12 +25,12 @@ function refusals(event: unknown): readonly string[] { } const typesOfTheBrain = [ - 'execution_started', - 'execution_deferred', - 'execution_succeeded', - 'execution_rejected', - 'execution_failed', - 'execution_cancel_requested', + 'run_started', + 'run_deferred', + 'run_succeeded', + 'run_rejected', + 'run_failed', + 'run_cancel_requested', 'tool_call_started', 'tool_call_answered', 'delivery_started', @@ -43,9 +43,9 @@ const typesOfTheBrain = [ 'telling_started', 'telling_ended', 'interaction_requested', - 'spec_created', - 'spec_updated', - 'spec_retired', + 'definition_created', + 'definition_updated', + 'definition_retired', 'event_published', 'workflow_input_applied', 'step_started', @@ -59,30 +59,25 @@ const typesOfTheBrain = [ describe('the types and sources of what the brain records itself', () => { it('are reserved for the brain: every type its runs and definitions record, and every type its feed shows', () => { expect([...reservedEventTypes]).toEqual(typesOfTheBrain); - const shown = [...makeSpecPresenters([echo]), toolTestPresenter].flatMap(({ publicNames }) => + const shown = [...makeDefinitionPresenters([echo]), toolTestPresenter].flatMap(({ publicNames }) => Object.values(publicNames).flat(), ); expect(shown.filter((name) => !reservedEventTypes.has(name))).toEqual([]); expect( - [ - '/executions/1', - '/specs/inference/summary', - '/callers/acme-admin', - '/executions', - 'executions/1', - '/ledger/eu', - ].map((source) => isReservedSource(source)), + ['/runs/1', '/definitions/reasoning/summary', '/callers/acme-admin', '/runs', 'runs/1', '/ledger/eu'].map( + (source) => isReservedSource(source), + ), ).toEqual([true, true, true, false, false, false]); }); it('are refused in an event from outside, each where it is given', () => { - expect(refusals({ type: 'execution_succeeded', source: '/executions/0199a3c4' })).toEqual([ + expect(refusals({ type: 'run_succeeded', source: '/runs/0199a3c4' })).toEqual([ `/type: Expected a type of your own, not one the brain records itself: ${typesOfTheBrain.join(', ')}`, - '/source: Expected a source of your own, not one under /executions/, /specs/ or /callers/, which the brain records itself', + '/source: Expected a source of your own, not one under /runs/, /definitions/ or /callers/, which the brain records itself', ]); expect( [ - 'execution_deferred', + 'run_deferred', 'tool_call_started', 'tool_test_started', 'tool_test_answered', diff --git a/packages/specs/src/events/reserved-attributes.ts b/packages/definitions/src/events/reserved-attributes.ts similarity index 84% rename from packages/specs/src/events/reserved-attributes.ts rename to packages/definitions/src/events/reserved-attributes.ts index e9405ca7e..b955d556a 100644 --- a/packages/specs/src/events/reserved-attributes.ts +++ b/packages/definitions/src/events/reserved-attributes.ts @@ -2,13 +2,13 @@ import type { ConversationCallEvent, ToolTestEvent } from '@beonauto/mcp'; import { lineageAttributeNames } from '@beonauto/operations'; import { Schema } from 'effect'; -import type { ExecutionEvent } from '../execution/execution-events.ts'; -import type { SpecEvent } from '../registry/spec-events.ts'; +import type { DefinitionEvent } from '../registry/definition-events.ts'; +import type { RunEvent } from '../runs/run-events.ts'; import type { EventPublished } from './published-events.ts'; -export const runSourcePrefix = '/executions/'; +export const runSourcePrefix = '/runs/'; -export const specSourcePrefix = '/specs/'; +export const definitionSourcePrefix = '/definitions/'; export const callerSourcePrefix = '/callers/'; @@ -24,21 +24,21 @@ type WorkflowEventType = | 'reaction_refused'; type FeedType = - | ExecutionEvent['type'] + | RunEvent['type'] | ToolTestEvent['type'] | ConversationCallEvent['type'] - | SpecEvent['type'] + | DefinitionEvent['type'] | EventPublished['type'] | CapabilityEventType | WorkflowEventType; const brainTypes: Readonly> = { - execution_started: true, - execution_deferred: true, - execution_succeeded: true, - execution_rejected: true, - execution_failed: true, - execution_cancel_requested: true, + run_started: true, + run_deferred: true, + run_succeeded: true, + run_rejected: true, + run_failed: true, + run_cancel_requested: true, tool_call_started: true, tool_call_answered: true, delivery_started: true, @@ -51,9 +51,9 @@ const brainTypes: Readonly> = { telling_started: true, telling_ended: true, interaction_requested: true, - spec_created: true, - spec_updated: true, - spec_retired: true, + definition_created: true, + definition_updated: true, + definition_retired: true, event_published: true, workflow_input_applied: true, step_started: true, @@ -66,7 +66,7 @@ const brainTypes: Readonly> = { export const reservedEventTypes: ReadonlySet = new Set(Object.keys(brainTypes)); -const reservedSourcePrefixes: readonly string[] = [runSourcePrefix, specSourcePrefix, callerSourcePrefix]; +const reservedSourcePrefixes: readonly string[] = [runSourcePrefix, definitionSourcePrefix, callerSourcePrefix]; const reservedTypesInWords = [...reservedEventTypes].join(', '); diff --git a/packages/specs/src/events/run-start-facts.test.ts b/packages/definitions/src/events/run-start-facts.test.ts similarity index 56% rename from packages/specs/src/events/run-start-facts.test.ts rename to packages/definitions/src/events/run-start-facts.test.ts index d6639ad0c..dec9d5af2 100644 --- a/packages/specs/src/events/run-start-facts.test.ts +++ b/packages/definitions/src/events/run-start-facts.test.ts @@ -2,27 +2,27 @@ import type { RecordedEvent } from '@beonauto/operations'; import { Schema } from 'effect'; import { describe, expect, it } from 'vitest'; -import { executionDecider, executionStreamOf } from '../execution/execution-decider.ts'; -import type { ExecutionEvent } from '../execution/execution-events.ts'; +import { runDecider, runStreamNameOf } from '../runs/run-decider.ts'; +import type { RunEvent } from '../runs/run-events.ts'; import { brainFactOf } from './brain-facts.ts'; -const executionId = '0199a3c4-7d2e-7c1a-9b3f-2f1e0d9c8b7a'; +const runId = '0199a3c4-7d2e-7c1a-9b3f-2f1e0d9c8b7a'; const fact = { by: 'brain:alpha', at: '2026-10-01T09:00:05.000Z' }; -const ofCheck = { primitive: 'orchestration', name: 'check', spec_version: 1 }; +const ofCheck = { definition_type: 'workflow', name: 'check', definition_version: 1 }; -const calledBy = { execution_id: '0199a3c4-7d2e-7c1a-9b3f-000000000001', reference: '/do/0/check', run: 1 }; +const calledBy = { run_id: '0199a3c4-7d2e-7c1a-9b3f-000000000001', reference: '/do/0/check', run: 1 }; -const encode = Schema.encodeSync(Schema.toCodecJson(executionDecider.eventSchema)); +const encode = Schema.encodeSync(Schema.toCodecJson(runDecider.eventSchema)); -function ofRun(event: ExecutionEvent): RecordedEvent { +function ofRun(event: RunEvent): RecordedEvent { return { id: 'record-1', cursor: 'record-1', causationId: null, correlationId: null, - stream: executionStreamOf(executionId), + stream: runStreamNameOf(runId), version: 2, type: event.type, data: encode(event), @@ -33,10 +33,10 @@ function ofRun(event: ExecutionEvent): RecordedEvent { describe('the facts of a run that answers a call of another run', () => { it('name the call it answers, and its ending the reason and kind it was rejected with', () => { expect([ - brainFactOf(ofRun({ type: 'execution_started', ...ofCheck, input: {}, called_by: calledBy, ...fact }))?.data, + brainFactOf(ofRun({ type: 'run_started', ...ofCheck, input: {}, called_by: calledBy, ...fact }))?.data, brainFactOf( ofRun({ - type: 'execution_rejected', + type: 'run_rejected', rejection: { reason: 'cancelled', detail: 'Out of time', kind: 'deadline' }, ...ofCheck, called_by: calledBy, @@ -45,16 +45,16 @@ describe('the facts of a run that answers a call of another run', () => { )?.data, brainFactOf( ofRun({ - type: 'execution_rejected', + type: 'run_rejected', rejection: { reason: 'conflict', detail: 'Clashed' }, ...ofCheck, ...fact, }), )?.data, ]).toEqual([ - { primitive: 'orchestration', name: 'check', version: 1, caller: 'brain:alpha', depth: 0, called_by: calledBy }, + { definition_type: 'workflow', name: 'check', version: 1, caller: 'brain:alpha', depth: 0, called_by: calledBy }, { - primitive: 'orchestration', + definition_type: 'workflow', name: 'check', version: 1, caller: 'brain:alpha', @@ -63,7 +63,7 @@ describe('the facts of a run that answers a call of another run', () => { reason: 'cancelled', kind: 'deadline', }, - { primitive: 'orchestration', name: 'check', version: 1, caller: 'brain:alpha', depth: 0, reason: 'conflict' }, + { definition_type: 'workflow', name: 'check', version: 1, caller: 'brain:alpha', depth: 0, reason: 'conflict' }, ]); }); }); @@ -72,7 +72,7 @@ describe('the facts of a run a trigger started', () => { it('name the trigger, by its kind and its place in the document, on the start and on the ending', () => { const trigger = { kind: 'every' as const, reference: '/schedule/every' }; const ofTheRun = { - primitive: 'orchestration', + definition_type: 'workflow', name: 'check', version: 1, caller: 'brain:alpha', @@ -81,8 +81,8 @@ describe('the facts of a run a trigger started', () => { }; expect([ - brainFactOf(ofRun({ type: 'execution_started', ...ofCheck, input: {}, trigger, ...fact }))?.data, - brainFactOf(ofRun({ type: 'execution_failed', ...ofCheck, trigger, ...fact }))?.data, + brainFactOf(ofRun({ type: 'run_started', ...ofCheck, input: {}, trigger, ...fact }))?.data, + brainFactOf(ofRun({ type: 'run_failed', ...ofCheck, trigger, ...fact }))?.data, ]).toEqual([ofTheRun, ofTheRun]); }); }); diff --git a/packages/specs/src/index.ts b/packages/definitions/src/index.ts similarity index 61% rename from packages/specs/src/index.ts rename to packages/definitions/src/index.ts index 51cd83f51..bcf35fc0a 100644 --- a/packages/specs/src/index.ts +++ b/packages/definitions/src/index.ts @@ -1,39 +1,34 @@ -export { defineCancelExecution } from './cancellation/cancel-execution.ts'; +export { defineCancelRun } from './cancellation/cancel-run.ts'; export { deferredCanceller, type SettleCancelled } from './cancellation/deferred-cancels.ts'; -export { - executionCanceller, - type CancelExecution, - type CancelReceipt, - type CancelRequest, -} from './cancellation/run-cancels.ts'; -export { defineCreateSpec } from './operations/create-spec.ts'; -export { defineExecuteSpec } from './operations/execute-spec.ts'; -export { defineGetSpec } from './operations/get-spec.ts'; -export { defineListSpecs } from './operations/list-specs.ts'; +export { runCanceller, type CancelRun, type CancelReceipt, type CancelRequest } from './cancellation/run-cancels.ts'; +export { defineCreateDefinition } from './operations/create-definition.ts'; +export { defineRunDefinition } from './operations/run-definition.ts'; +export { defineGetDefinition } from './operations/get-definition.ts'; +export { defineListDefinitions } from './operations/list-definitions.ts'; export { defineStartVersion } from './operations/start-version.ts'; export { cancelledAsAsked, - definePrimitive, + defineCapability, defaultRunWords, type CancelDecision, type CancelledRun, - type Executed, + type CapabilityAnswer, type RunContext, type RunLineage, type Finished, type FinishesLater, type PreparedDefinition, - type Primitive, - type PrimitiveDefinition, - type PrimitiveGuide, - type PrimitiveRejection, + type Capability, + type CapabilityDeclaration, + type CapabilityGuide, + type CapabilityRejection, type DefinitionSummary, type RunAccount, type RunWords, type Standing, type StandingRequest, type ToolCallJournal, -} from './primitive/primitive.ts'; +} from './capability/capability.ts'; export type { CallAnsweredFact, CallStartedFact, @@ -43,7 +38,7 @@ export type { ReplyFact, ReplyRefusedFact, ReplyTakenFact, -} from './execution/execution-commands.ts'; +} from './runs/run-commands.ts'; export { CalledBySchema, CancelRequestKindSchema, @@ -58,13 +53,13 @@ export { type DeliveryEvent, type DeliveryOutcome, type DeliveryStarted, - type ExecutionDeferred, - type ExecutionEvent, + type RunDeferred, + type RunEvent, type RepliesIn, type ReplyIdentity, type ReplyRefusal, -} from './execution/execution-events.ts'; -export { executionEventOf, recordedRunIn, recordedRunInBrain, type RecordedRun } from './run-work/recorded-runs.ts'; +} from './runs/run-events.ts'; +export { runEventOf, recordedRunIn, recordedRunInBrain, type RecordedRun } from './run-work/recorded-runs.ts'; export { outboundCallRecorder, replyRecorder, @@ -74,16 +69,16 @@ export { } from './run-work/outbound-calls.ts'; export { replyBounds } from './run-work/work-decisions.ts'; export { deliveryEnded, deliveryStarted, throughTheTool } from './run-work/delivery-words.ts'; -export { mostCallDepth } from './operations/execution-running.ts'; +export { mostCallDepth } from './operations/run-requests.ts'; export { cancelRequestOf, lastEndingOf, runEndingOf, type CancelRequested, type RunEnding, -} from './execution/run-endings.ts'; -export { defineRetireSpec } from './operations/retire-spec.ts'; -export { defineUpdateSpec } from './operations/update-spec.ts'; +} from './runs/run-endings.ts'; +export { defineRetireDefinition } from './operations/retire-definition.ts'; +export { defineUpdateDefinition } from './operations/update-definition.ts'; export { RunDetailSchema, RunSchema, @@ -93,24 +88,24 @@ export { type WorkflowRun, type Run, type RunDetail, -} from './execution/execution.ts'; +} from './runs/run.ts'; export { brainBoundSettler, - executionSettler, - type ExecutionAddress, - type SettleExecution, + runSettler, + type RunStreamAddress, + type SettleRun, type Settlement, -} from './execution/execution-settler.ts'; -export { answeredByAReply } from './execution/execution-decisions.ts'; -export { defineGetExecution, getExecution } from './operations/get-execution.ts'; -export { defineGetExecutionHistory } from './reading/get-execution-history.ts'; -export { defineListExecutions } from './reading/list-executions.ts'; -export { ListedRunSchema, type ListedRun } from './reading/listed-execution.ts'; -export { ExecutionIdField } from './operations/spec-fields.ts'; -export { makeSpecPresenters } from './presenting/spec-presenters.ts'; +} from './runs/run-settler.ts'; +export { answeredByAReply } from './runs/run-decisions.ts'; +export { defineGetRun, getRun } from './operations/get-run.ts'; +export { defineGetRunHistory } from './reading/get-run-history.ts'; +export { defineListRuns } from './reading/list-runs.ts'; +export { ListedRunSchema, type ListedRun } from './reading/listed-run.ts'; +export { RunIdInputField } from './operations/definition-fields.ts'; +export { makeDefinitionPresenters } from './presenting/definition-presenters.ts'; export { brainEventOf, brainFactOf } from './events/brain-facts.ts'; -export { SpecEventSchema, type SpecEvent } from './registry/spec-events.ts'; -export { specsStreamOf } from './registry/specs-decider.ts'; +export { DefinitionEventSchema, type DefinitionEvent } from './registry/definition-events.ts'; +export { definitionTypeStreamOf } from './registry/definitions-decider.ts'; export { callerSourcePrefix, isReservedSource, @@ -139,7 +134,7 @@ export { export { publishedEventOf, type EventPublished } from './events/published-events.ts'; export { ReactionRefusedSchema, reactionsStreamKind, type ReactionRefused } from './events/reaction-refusals.ts'; export { mostReactingDefinitions } from './registry/registry-decisions.ts'; -export { specChangeOf, type SpecChange } from './registry/spec-changes.ts'; +export { definitionChangeOf, type DefinitionChange } from './registry/definition-changes.ts'; export { EventTriggerSchema, ScheduleTriggerSchema, @@ -150,11 +145,11 @@ export { type StartingTrigger, type Trigger, type TriggerFilter, -} from './registry/spec-triggers.ts'; -export { runStartedOf, type RunStarted } from './execution/run-starts.ts'; +} from './registry/definition-triggers.ts'; +export { runStartedOf, type StartedRun } from './runs/run-starts.ts'; export { inWords, wordsOf } from './plain-language/in-words.ts'; export { triggerNamed } from './plain-language/event-words.ts'; -export { mostInputBytes, mostInputDepth, mostResultBytes } from './execution/recorded-size.ts'; +export { mostInputBytes, mostInputDepth, mostResultBytes } from './runs/recorded-size.ts'; export { ListedDefinitionSchema, DefinitionSchema, @@ -168,15 +163,15 @@ export { type WorkflowDefinition, type ListedDefinition, type Definition, -} from './registry/spec.ts'; -export { makeSpecOperations, type BrainOperation } from './operations/spec-operations.ts'; +} from './registry/definition.ts'; +export { makeDefinitionOperations, type BrainOperation } from './operations/definition-operations.ts'; export { defineGetBrainAnalytics } from './analytics/get-brain-analytics.ts'; export { runOutcomeMapping } from './analytics/run-outcome-mapping.ts'; export { definitionResourceLabel, functionCategoryLabels, functionDescriptions, - functionKindOrder, + functionTypeOrder, functionResourceLabels, - type BrainFunctionKind, -} from './primitive/function-terminology.ts'; + type FunctionType, +} from './capability/function-terminology.ts'; diff --git a/packages/specs/src/json-schema.ts b/packages/definitions/src/json-schema.ts similarity index 100% rename from packages/specs/src/json-schema.ts rename to packages/definitions/src/json-schema.ts diff --git a/packages/specs/src/operations/called-runs.test.ts b/packages/definitions/src/operations/called-runs.test.ts similarity index 64% rename from packages/specs/src/operations/called-runs.test.ts rename to packages/definitions/src/operations/called-runs.test.ts index 355551e13..c7821bf74 100644 --- a/packages/specs/src/operations/called-runs.test.ts +++ b/packages/definitions/src/operations/called-runs.test.ts @@ -1,20 +1,20 @@ import { Effect } from 'effect'; import { describe, expect, it } from 'vitest'; -import { definePrimitive, mostCallDepth } from '../index.ts'; +import { defineCapability, mostCallDepth } from '../index.ts'; import { acmeAdmin } from '../testing/callers.ts'; +import { definitionOperationsFor } from '../testing/definition-operations.ts'; import { harness, toBrain } from '../testing/harness.ts'; import { probe } from '../testing/probe.ts'; -import { specOperationsFor } from '../testing/spec-operations.ts'; const toAlpha = toBrain('acme', 'alpha'); -const executionId = '0199a3c4-7d2e-7c1a-9b3f-2f1e0d9c8b7a'; +const runId = '0199a3c4-7d2e-7c1a-9b3f-2f1e0d9c8b7a'; -const calledBy = { execution_id: '0199a3c4-7d2e-7c1a-9b3f-000000000001', reference: '/do/0/ask', run: 2 } as const; +const calledBy = { run_id: '0199a3c4-7d2e-7c1a-9b3f-000000000001', reference: '/do/0/ask', run: 2 } as const; -const measuring = definePrimitive({ - name: 'measuring', +const measuring = defineCapability({ + type: 'measuring', title: 'Measuring', guide: { name: 'measuring' }, noun: { one: 'measure', other: 'measures' }, @@ -23,34 +23,34 @@ const measuring = definePrimitive({ parse: (source: string) => Effect.succeed(source), summarize: () => ({}), longestRunOf: () => 42_000, - execute: (_source, _input, execution) => + run: (_source, _input, run) => Effect.gen(function* () { const longest = [ - yield* execution.longestRunOf('probe', 'plain'), - yield* execution.longestRunOf('measuring', 'itself'), - yield* execution.longestRunOf('probe', 'missing'), - yield* execution.longestRunOf('probe', 'retired'), - yield* execution.longestRunOf('nothing', 'at-all'), + yield* run.longestRunOf('probe', 'plain'), + yield* run.longestRunOf('measuring', 'itself'), + yield* run.longestRunOf('probe', 'missing'), + yield* run.longestRunOf('probe', 'retired'), + yield* run.longestRunOf('nothing', 'at-all'), ]; - return { output: { longest: longest.map((ms) => ms ?? null), callDepth: execution.callDepth }, record: {} }; + return { output: { longest: longest.map((ms) => ms ?? null), callDepth: run.callDepth }, record: {} }; }), }); async function brainMeasuring() { - const operations = specOperationsFor([measuring, probe().primitive]); - const specs = harness(); - const created = (primitive: string, name: string) => - specs.call(operations.createSpec, toAlpha(acmeAdmin, { primitive, name, source: 'text' })); + const operations = definitionOperationsFor([measuring, probe().capability]); + const definitions = harness(); + const created = (type: string, name: string) => + definitions.call(operations.createDefinition, toAlpha(acmeAdmin, { type, name, source: 'text' })); await created('measuring', 'itself'); await created('probe', 'plain'); await created('probe', 'retired'); - await specs.call(operations.retireSpec, toAlpha(acmeAdmin, { primitive: 'probe', name: 'retired' })); + await definitions.call(operations.retireDefinition, toAlpha(acmeAdmin, { type: 'probe', name: 'retired' })); const measured = (depths: { readonly callDepth?: number; readonly calledBy?: typeof calledBy } = {}) => - specs.call(operations.executeSpec, { - ...toAlpha(acmeAdmin, { primitive: 'measuring', name: 'itself', execution_id: executionId }), + definitions.call(operations.runDefinition, { + ...toAlpha(acmeAdmin, { type: 'measuring', name: 'itself', run_id: runId }), ...depths, }); - return { ...specs, ...operations, measured }; + return { ...definitions, ...operations, measured }; } describe('a run that answers a call of another run', () => { @@ -64,12 +64,12 @@ describe('a run that answers a call of another run', () => { Effect.orDie( ledger.service.readRecorded( { org: 'acme', brain: 'alpha' }, - { kind: 'run', execution: executionId }, + { kind: 'run', run: runId }, { order: 'asc', limit: 10 }, ), ), ); - expect(records[0]?.data).toMatchObject({ type: 'execution_started', call_depth: 3, called_by: calledBy }); + expect(records[0]?.data).toMatchObject({ type: 'run_started', call_depth: 3, called_by: calledBy }); }); it(`may be ${mostCallDepth} calls deep, and the ninth start is refused as a conflict that records nothing`, async () => { @@ -83,7 +83,7 @@ describe('a run that answers a call of another run', () => { detail: 'This run would sit 9 calls below the run at the top of its tree, more than the 8 a run may: workflows that call workflows reach at most 8 calls deep', }); - expect(deeper.ledger.streamNames().filter((stream) => stream.includes('/executions/'))).toEqual([]); + expect(deeper.ledger.streamNames().filter((stream) => stream.includes('/runs/'))).toEqual([]); }); }); diff --git a/packages/specs/src/operations/command-metadata.ts b/packages/definitions/src/operations/command-metadata.ts similarity index 100% rename from packages/specs/src/operations/command-metadata.ts rename to packages/definitions/src/operations/command-metadata.ts diff --git a/packages/specs/src/operations/create-spec.test.ts b/packages/definitions/src/operations/create-definition.test.ts similarity index 51% rename from packages/specs/src/operations/create-spec.test.ts rename to packages/definitions/src/operations/create-definition.test.ts index 5bc844001..eb90e3f64 100644 --- a/packages/specs/src/operations/create-spec.test.ts +++ b/packages/definitions/src/operations/create-definition.test.ts @@ -2,43 +2,43 @@ import { Effect } from 'effect'; import { describe, expect, it } from 'vitest'; import { acmeAdmin } from '../testing/callers.ts'; +import { definitionOperationsFor } from '../testing/definition-operations.ts'; import { echo } from '../testing/echo.ts'; import { firstMoment, harness, toBrain } from '../testing/harness.ts'; import { probe } from '../testing/probe.ts'; -import { specOperationsFor } from '../testing/spec-operations.ts'; -const { createSpec, retireSpec } = specOperationsFor([echo, probe().primitive]); +const { createDefinition, retireDefinition } = definitionOperationsFor([echo, probe().capability]); const toAlpha = toBrain('acme', 'alpha'); const hello = '{"greeting": "Hello", "description": "Greets the caller"}'; function creating(input: object) { - return harness().call(createSpec, toAlpha(acmeAdmin, input)); + return harness().call(createDefinition, toAlpha(acmeAdmin, input)); } function creatingFrom(source: string) { - return creating({ primitive: 'probe', name: 'plain', source }); + return creating({ type: 'probe', name: 'plain', source }); } -describe('create_spec', () => { - it('is a brain command at POST /specs/{primitive} that answers 201', () => { - expect(createSpec.registration).toMatchObject({ +describe('create_definition', () => { + it('is a brain command at POST /definitions/{type} that answers 201', () => { + expect(createDefinition.registration).toMatchObject({ scope: 'brain', kind: 'command', title: 'Create definition', - route: { method: 'POST', path: '/specs/{primitive}' }, - pathParameters: ['primitive'], + route: { method: 'POST', path: '/definitions/{type}' }, + pathParameters: ['type'], successStatus: 201, reasons: ['not_found', 'invalid_input', 'conflict'], }); }); - it('creates an active spec at version 1 with what its primitive says about it', async () => { - expect(await creating({ primitive: 'echo', name: 'greet', source: hello })).toStrictEqual({ + it('creates an active definition at version 1 with what its capability says about it', async () => { + expect(await creating({ type: 'echo', name: 'greet', source: hello })).toStrictEqual({ status: 'succeeded', output: { - primitive: 'echo', + type: 'echo', name: 'greet', version: 1, status: 'active', @@ -59,29 +59,29 @@ describe('create_spec', () => { }); }); -describe('the warnings of a spec', () => { - it('are what its primitive found in the document, shown with the spec', async () => { +describe('the warnings of a definition', () => { + it('are what its capability found in the document, shown with the definition', async () => { const source = '{"greeting": "Hello", "warnings": ["Line 2: greeting may be too warm for some readers"]}'; - expect(await creating({ primitive: 'echo', name: 'greet', source })).toMatchObject({ + expect(await creating({ type: 'echo', name: 'greet', source })).toMatchObject({ output: { warnings: ['Line 2: greeting may be too warm for some readers'] }, }); }); - it('are left out when the primitive found none', async () => { - const created = await creating({ primitive: 'echo', name: 'greet', source: '{"greeting": "Hi", "warnings": []}' }); + it('are left out when the capability found none', async () => { + const created = await creating({ type: 'echo', name: 'greet', source: '{"greeting": "Hi", "warnings": []}' }); expect(created).toMatchObject({ status: 'succeeded' }); expect(created).not.toHaveProperty('output.warnings'); }); }); -describe('the spec create_spec records', () => { - it('leaves out what the primitive does not say about a spec', async () => { - expect(await creating({ primitive: 'probe', name: 'plain', source: 'just text' })).toStrictEqual({ +describe('the definition create_definition records', () => { + it('leaves out what the capability does not say about a definition', async () => { + expect(await creating({ type: 'probe', name: 'plain', source: 'just text' })).toStrictEqual({ status: 'succeeded', output: { - primitive: 'probe', + type: 'probe', name: 'plain', version: 1, status: 'active', @@ -94,22 +94,22 @@ describe('the spec create_spec records', () => { }); }); - it('records the specs of each primitive in a stream of the brain named after the primitive', async () => { + it('records the definitions of each capability in a stream of the brain named after the capability', async () => { const { call, ledger } = harness(); - await call(createSpec, toAlpha(acmeAdmin, { primitive: 'echo', name: 'greet', source: hello })); - await call(createSpec, toAlpha(acmeAdmin, { primitive: 'probe', name: 'plain', source: 'text' })); + await call(createDefinition, toAlpha(acmeAdmin, { type: 'echo', name: 'greet', source: hello })); + await call(createDefinition, toAlpha(acmeAdmin, { type: 'probe', name: 'plain', source: 'text' })); - expect(ledger.streamNames()).toEqual(['brain/acme/alpha/specs/echo', 'brain/acme/alpha/specs/probe']); + expect(ledger.streamNames()).toEqual(['brain/acme/alpha/definitions/echo', 'brain/acme/alpha/definitions/probe']); }); }); -describe('create_spec rejecting a document', () => { - it('that its primitive cannot parse, with the issues under /source, and stores nothing', async () => { +describe('create_definition rejecting a document', () => { + it('that its capability cannot parse, with the issues under /source, and stores nothing', async () => { const { call, ledger } = harness(); expect( - await call(createSpec, toAlpha(acmeAdmin, { primitive: 'probe', name: 'plain', source: 'fine\noops\noops' })), + await call(createDefinition, toAlpha(acmeAdmin, { type: 'probe', name: 'plain', source: 'fine\noops\noops' })), ).toEqual({ status: 'rejected', reason: 'invalid_input', @@ -137,40 +137,41 @@ describe('create_spec rejecting a document', () => { }); }); -describe('the input of create_spec', () => { +describe('the input of create_definition', () => { it('rejects a malformed name and a field the operation does not know', async () => { - expect(await creating({ primitive: 'echo', name: 'Greet', source: hello, colour: 'red' })).toMatchObject({ + expect(await creating({ type: 'echo', name: 'Greet', source: hello, colour: 'red' })).toMatchObject({ reason: 'invalid_input', issues: [{ pointer: '/colour' }, { pointer: '/name' }], }); - expect(await creating({ primitive: 'echo', name: 'go', source: hello })).toMatchObject({ + expect(await creating({ type: 'echo', name: 'go', source: hello })).toMatchObject({ issues: [{ pointer: '/name' }], }); }); - it('rejects a primitive whose name is malformed, and answers not_found for one it does not know', async () => { - expect(await creating({ primitive: 'Echo!', name: 'greet', source: hello })).toMatchObject({ + it('rejects a type that is malformed, and one this server does not run', async () => { + expect(await creating({ type: 'Echo!', name: 'greet', source: hello })).toMatchObject({ reason: 'invalid_input', issues: [ { - pointer: '/primitive', - detail: 'Expected a primitive name: 3 to 32 lowercase letters, digits and hyphens, starting with a letter', + pointer: '/type', + detail: 'Expected a type: 3 to 32 lowercase letters, digits and hyphens, starting with a letter', }, ], }); - expect(await creating({ primitive: 'inference', name: 'greet', source: hello })).toEqual({ + expect(await creating({ type: 'reasoning', name: 'greet', source: hello })).toEqual({ status: 'rejected', - reason: 'not_found', - detail: 'There is no primitive inference', + reason: 'invalid_input', + detail: 'The input does not match the input schema', + issues: [{ pointer: '/type', detail: 'Expected a type this server runs: echo or probe' }], }); }); }); -describe('create_spec rejecting with conflict', () => { - it('a name an active or a retired spec of the primitive holds', async () => { +describe('create_definition rejecting with conflict', () => { + it('a name an active or a retired definition of the capability holds', async () => { const { call } = harness(); const creatingGreet = () => - call(createSpec, toAlpha(acmeAdmin, { primitive: 'echo', name: 'greet', source: hello })); + call(createDefinition, toAlpha(acmeAdmin, { type: 'echo', name: 'greet', source: hello })); await creatingGreet(); expect(await creatingGreet()).toEqual({ @@ -179,7 +180,7 @@ describe('create_spec rejecting with conflict', () => { detail: 'The brain already has the echo definition greet', kind: 'taken', }); - await call(retireSpec, toAlpha(acmeAdmin, { primitive: 'echo', name: 'greet' })); + await call(retireDefinition, toAlpha(acmeAdmin, { type: 'echo', name: 'greet' })); expect(await creatingGreet()).toEqual({ status: 'rejected', reason: 'conflict', @@ -187,17 +188,17 @@ describe('create_spec rejecting with conflict', () => { kind: 'taken', }); expect( - await call(createSpec, toAlpha(acmeAdmin, { primitive: 'probe', name: 'greet', source: 'text' })), + await call(createDefinition, toAlpha(acmeAdmin, { type: 'probe', name: 'greet', source: 'text' })), ).toMatchObject({ status: 'succeeded' }); }); - it('when another change to the specs of the primitive landed at the same moment', async () => { + it('when another change to the definitions of the capability landed at the same moment', async () => { const { dispatch, run } = harness(); - const creatingSpec = (name: string) => - dispatch(createSpec, toAlpha(acmeAdmin, { primitive: 'echo', name, source: hello })); + const creatingDefinition = (name: string) => + dispatch(createDefinition, toAlpha(acmeAdmin, { type: 'echo', name, source: hello })); expect( - await run(Effect.all([creatingSpec('greet'), creatingSpec('wave')], { concurrency: 'unbounded' })), + await run(Effect.all([creatingDefinition('greet'), creatingDefinition('wave')], { concurrency: 'unbounded' })), ).toMatchObject([ { status: 'succeeded', output: { name: 'greet' } }, { status: 'rejected', reason: 'conflict' }, diff --git a/packages/definitions/src/operations/create-definition.ts b/packages/definitions/src/operations/create-definition.ts new file mode 100644 index 000000000..86eb0feea --- /dev/null +++ b/packages/definitions/src/operations/create-definition.ts @@ -0,0 +1,44 @@ +import { defineCommand } from '@beonauto/operations'; +import { Effect, Schema } from 'effect'; + +import type { Capability } from '../capability/capability.ts'; +import { knownCapabilities } from '../capability/known-capabilities.ts'; +import { definitionWordsFor, whatItDoes } from '../plain-language/definition-words.ts'; +import { DefinitionSchema } from '../registry/definition.ts'; +import { SourceField, DefinitionNameField } from './definition-fields.ts'; +import { contentOf, definitionOf } from './definition-views.ts'; +import { recordInRegistry } from './registry-access.ts'; + +export function defineCreateDefinition(capabilities: readonly Capability[]) { + const known = knownCapabilities(capabilities); + const words = definitionWordsFor(capabilities); + return known.publish( + defineCommand('brain', { + name: 'create_definition', + title: 'Create definition', + description: [ + 'Saves a new function or workflow definition in the brain from its document and returns it without running it.', + 'Use it once the person has agreed to the definition; update_definition changes one that exists, and a name is never reused in a brain.', + `\`type\` is the definition's type and \`name\` is how workflows and tools refer to it: ${known.typesWithGuides}.`, + "`source` is the whole document in that type's format, which get_guide gives.", + 'A document that does not fit its format is refused with the line and what is wrong, and nothing is saved.', + ].join(' '), + route: { method: 'POST', path: '/definitions/{type}' }, + successStatus: 201, + inputSchema: Schema.Struct({ type: known.field, name: DefinitionNameField, source: SourceField }), + outputSchema: DefinitionSchema, + reasons: ['not_found', 'invalid_input', 'conflict'], + handle: Effect.fnUntraced(function* ({ type, name, source }) { + const capability = yield* known.capabilityOfType(type); + const content = yield* contentOf(capability, source); + return definitionOf(capability, yield* recordInRegistry(capability, { type: 'create', name, content })); + }), + plainLanguage: { + task: `create a new ${words.kinds}`, + attempt: ({ type, name }) => `create ${words.named(type, name)}`, + outcome: (definition) => + `Created ${words.named(definition.type, definition.name)}.${whatItDoes(definition)} It has been saved but has not been run yet.`, + }, + }), + ); +} diff --git a/packages/specs/src/operations/deferred-execution.test.ts b/packages/definitions/src/operations/deferred-run.test.ts similarity index 63% rename from packages/specs/src/operations/deferred-execution.test.ts rename to packages/definitions/src/operations/deferred-run.test.ts index 0b1dcb13e..65f21e812 100644 --- a/packages/specs/src/operations/deferred-execution.test.ts +++ b/packages/definitions/src/operations/deferred-run.test.ts @@ -6,21 +6,21 @@ import { relayedId, withHandOn } from '../testing/relaying.ts'; const waiting = { status: 'succeeded', output: { - execution_id: relayedId, - primitive: 'relay', + run_id: relayedId, + type: 'relay', name: 'hand-on', - spec_version: 1, + definition_version: 1, status: 'started', started_at: firstMoment, started_by: 'acme-admin', }, }; -describe('an execution whose primitive finishes it after the call returns', () => { +describe('a run whose capability finishes it after the call returns', () => { it('is answered as started, and read as started until it is settled', async () => { - const { executing, reading } = await withHandOn(); + const { running, reading } = await withHandOn(); - expect(await executing()).toStrictEqual(waiting); + expect(await running()).toStrictEqual(waiting); expect(await reading()).toStrictEqual({ status: 'succeeded', output: { ...waiting.output, record: { handed_on: relayedId } }, @@ -28,14 +28,14 @@ describe('an execution whose primitive finishes it after the call returns', () = }); it('is not started a second time by a call with its id while it waits to be settled', async () => { - const { executing, relayer } = await withHandOn(); - await executing(); + const { running, relayer } = await withHandOn(); + await running(); - expect(await executing()).toStrictEqual(waiting); + expect(await running()).toStrictEqual(waiting); expect(relayer.runs()).toBe(1); }); - it('is recorded as waiting when its call is cancelled while the primitive starts it, never left without a result', async () => { + it('is recorded as waiting when its call is cancelled while the capability starts it, never left without a result', async () => { const { executingCancelledOnceStarted, reading, relayer } = await withHandOn(); expect(await executingCancelledOnceStarted({ startingMs: 200 })).toStrictEqual({ status: 'cancelled' }); @@ -46,10 +46,10 @@ describe('an execution whose primitive finishes it after the call returns', () = }); }); - it('fails when what the primitive started takes more than an execution may record', async () => { - const { executing, reading, reported } = await withHandOn(); + it('fails when what the capability started takes more than a run may record', async () => { + const { running, reading, reported } = await withHandOn(); - expect(await executing(1_048_576)).toEqual({ status: 'failed', incident: reported()[0]?.id }); + expect(await running(1_048_576)).toEqual({ status: 'failed', incident: reported()[0]?.id }); expect(await reading()).toMatchObject({ output: { status: 'failed' } }); }); }); diff --git a/packages/specs/src/operations/spec-descriptions.test.ts b/packages/definitions/src/operations/definition-descriptions.test.ts similarity index 58% rename from packages/specs/src/operations/spec-descriptions.test.ts rename to packages/definitions/src/operations/definition-descriptions.test.ts index 1e35fca1f..d67c82914 100644 --- a/packages/specs/src/operations/spec-descriptions.test.ts +++ b/packages/definitions/src/operations/definition-descriptions.test.ts @@ -1,38 +1,32 @@ import { Effect } from 'effect'; import { describe, expect, it } from 'vitest'; -import { definePrimitive, makeSpecOperations } from '../index.ts'; +import { defineCapability, makeDefinitionOperations } from '../index.ts'; import { echo } from '../testing/echo.ts'; import { probe } from '../testing/probe.ts'; -const operations = makeSpecOperations([echo, probe().primitive]).map(({ registration }) => registration); +const operations = makeDefinitionOperations([echo, probe().capability]).map(({ registration }) => registration); -const onRuns = new Set([ - 'get_execution', - 'cancel_execution', - 'list_executions', - 'get_execution_history', - 'get_brain_analytics', -]); +const onRuns = new Set(['get_run', 'cancel_run', 'list_runs', 'get_run_history', 'get_brain_analytics']); -const takingAPrimitive = operations.filter(({ name }) => !onRuns.has(name)); +const takingAType = operations.filter(({ name }) => !onRuns.has(name)); function described(name: string): string { return String(operations.find((operation) => operation.name === name)?.description); } -describe('the description of create_spec', () => { +describe('the description of create_definition', () => { it('names each definition type the brain runs, the kind of definition it is and the guide to its format', () => { - expect(described('create_spec')).toContain( - "`primitive` is the definition's type and `name` is how workflows and tools refer to it: echo, a greeting, guide echo; probe, a probe, guide probe.", + expect(described('create_definition')).toContain( + "`type` is the definition's type and `name` is how workflows and tools refer to it: echo, a greeting, guide echo; probe, a probe, guide probe.", ); }); }); -describe('the kind of definition create_spec names', () => { +describe('the kind of definition create_definition names', () => { it('takes an before a kind that begins with a vowel', () => { - const outlining = definePrimitive({ - name: 'outlining', + const outlining = defineCapability({ + type: 'outlining', title: 'Outlining', guide: { name: 'outline' }, noun: { one: 'outline', other: 'outlines' }, @@ -40,11 +34,13 @@ describe('the kind of definition create_spec names', () => { mediaType: 'text/plain', parse: () => Effect.succeed({}), summarize: () => ({}), - execute: () => Effect.succeed({ output: null, record: {} }), + run: () => Effect.succeed({ output: null, record: {} }), }); - const createSpec = makeSpecOperations([outlining]).find(({ registration }) => registration.name === 'create_spec'); + const createDefinition = makeDefinitionOperations([outlining]).find( + ({ registration }) => registration.name === 'create_definition', + ); - expect(createSpec?.registration.description).toContain('outlining, an outline, guide outline.'); + expect(createDefinition?.registration.description).toContain('outlining, an outline, guide outline.'); }); }); @@ -59,12 +55,12 @@ describe('the description of every operation', () => { }); describe('the JSON Schema of the input of the operations', () => { - it('lists the known primitives for the primitive field as a plain enum, with the kind each names', () => { - for (const { input } of takingAPrimitive) { - expect(input.schema).toHaveProperty(['properties', 'primitive'], { + it('lists the known capabilities for the capability field as a plain enum, with the kind each names', () => { + for (const { input } of takingAType) { + expect(input.schema).toHaveProperty(['properties', 'type'], { type: 'string', enum: ['echo', 'probe'], - description: "The definition's type: echo (greeting) or probe (probe)", + description: "The definition's type: echo or probe", }); expect(input.schema).toMatchObject({ type: 'object', additionalProperties: false }); } @@ -75,45 +71,45 @@ describe('the JSON Schema of the input of the operations', () => { type: 'boolean', description: 'Whether to list retired definitions as well; false when left out', }); - expect(operations[2]?.input.schema).toHaveProperty('required', ['primitive', 'name']); + expect(operations[2]?.input.schema).toHaveProperty('required', ['type', 'name']); }); - it('holds a spec name to its pattern and a document to at most 65536 characters', () => { + it('holds a definition name to its pattern and a document to at most 65536 characters', () => { expect(operations[0]?.input.schema).toMatchObject({ properties: { name: { type: 'string', pattern: '^[a-z][a-z0-9-]{2,47}$' }, source: { type: 'string', maxLength: 65_536 }, }, - required: ['primitive', 'name', 'source'], + required: ['type', 'name', 'source'], }); }); - it('takes any JSON value as the input of an execution, and a UUID as its id', () => { + it('takes any JSON value as the input of a run, and a UUID as its id', () => { expect(operations[5]?.input.schema).toMatchObject({ properties: { input: { description: 'The run input: any JSON value the definition takes, {} when left out, at most 262144 bytes as JSON in UTF-8 and 512 levels deep', }, - execution_id: { type: 'string', format: 'uuid' }, + run_id: { type: 'string', format: 'uuid' }, }, - required: ['primitive', 'name'], + required: ['type', 'name'], }); expect(operations[6]?.input.schema).toMatchObject({ - properties: { execution_id: { type: 'string', format: 'uuid' } }, - required: ['execution_id'], + properties: { run_id: { type: 'string', format: 'uuid' } }, + required: ['run_id'], }); }); }); describe('the JSON Schema of the filters of the runs', () => { it('describes the runs a filter keeps', () => { - const listing = operations.find(({ name }) => name === 'list_executions'); + const listing = operations.find(({ name }) => name === 'list_runs'); expect(listing?.input.schema).toMatchObject({ properties: { - primitive: { - description: 'Only the runs of this type: echo (greeting) or probe (probe)', + type: { + description: 'Only the runs of this type: echo or probe', }, name: { description: 'Only the runs of the definition with this name' }, }, @@ -130,11 +126,11 @@ describe('what the operations declare of a repeated call', () => { ); expect(declared).toEqual({ - create_spec: { irreversible: false, repeatable: false }, - update_spec: { irreversible: false, repeatable: true }, - retire_spec: { irreversible: true, repeatable: true }, - execute_spec: { irreversible: false, repeatable: false }, - cancel_execution: { irreversible: true, repeatable: true }, + create_definition: { irreversible: false, repeatable: false }, + update_definition: { irreversible: false, repeatable: true }, + retire_definition: { irreversible: true, repeatable: true }, + run_definition: { irreversible: false, repeatable: false }, + cancel_run: { irreversible: true, repeatable: true }, }); }); }); diff --git a/packages/specs/src/operations/spec-fields.ts b/packages/definitions/src/operations/definition-fields.ts similarity index 82% rename from packages/specs/src/operations/spec-fields.ts rename to packages/definitions/src/operations/definition-fields.ts index 7f49115f2..379f634a8 100644 --- a/packages/specs/src/operations/spec-fields.ts +++ b/packages/definitions/src/operations/definition-fields.ts @@ -2,7 +2,7 @@ import { Buffer } from 'node:buffer'; import { Schema, SchemaTransformation } from 'effect'; -import { jsonBytesOf, mostInputBytes, mostInputDepth, nestsWithin } from '../execution/recorded-size.ts'; +import { jsonBytesOf, mostInputBytes, mostInputDepth, nestsWithin } from '../runs/recorded-size.ts'; const mostSourceBytes = 65_536; @@ -10,17 +10,17 @@ function fitsInSourceLimit(source: string): boolean { return Buffer.byteLength(source, 'utf8') <= mostSourceBytes; } -const specName = /^[a-z][a-z0-9-]{2,47}$/u; +const definitionName = /^[a-z][a-z0-9-]{2,47}$/u; -function specNameFieldOf(description: string) { - return Schema.String.annotate({ description }).check(Schema.isPattern(specName)); +function definitionNameFieldOf(description: string) { + return Schema.String.annotate({ description }).check(Schema.isPattern(definitionName)); } -export const SpecNameField = specNameFieldOf( +export const DefinitionNameField = definitionNameFieldOf( 'The definition name: 3 to 48 lowercase letters, digits and hyphens, starting with a letter', ); -export const RunsOfNameField = specNameFieldOf('Only the runs of the definition with this name'); +export const RunsOfNameField = definitionNameFieldOf('Only the runs of the definition with this name'); export const SourceField = Schema.String.annotate({ description: `The definition document in its type's format, at most ${mostSourceBytes} bytes in UTF-8`, @@ -31,7 +31,7 @@ export const SourceField = Schema.String.annotate({ }), ); -export const ExecutionIdField = Schema.String.annotate({ +export const RunIdInputField = Schema.String.annotate({ description: "The run's id, a UUID in any case, kept in lowercase", }) .check(Schema.isUUID()) diff --git a/packages/definitions/src/operations/definition-operations.test.ts b/packages/definitions/src/operations/definition-operations.test.ts new file mode 100644 index 000000000..fb79bba79 --- /dev/null +++ b/packages/definitions/src/operations/definition-operations.test.ts @@ -0,0 +1,81 @@ +import { brainOperations } from '@beonauto/brains'; +import { makeCatalog } from '@beonauto/operations'; +import { describe, expect, it } from 'vitest'; + +import { makeDefinitionOperations } from '../index.ts'; +import { echo } from '../testing/echo.ts'; +import { probe } from '../testing/probe.ts'; + +const operations = makeDefinitionOperations([echo, probe().capability]); + +const catalog = makeCatalog(operations); + +describe('the definition operations', () => { + it('make one catalog of eleven brain operations', () => { + expect(catalog.operationsIn('brain').map(({ name, title }) => `${name}: ${title}`)).toEqual([ + 'create_definition: Create definition', + 'list_definitions: List definitions', + 'get_definition: Get definition', + 'update_definition: Update definition', + 'retire_definition: Retire definition', + 'run_definition: Run definition', + 'get_run: Get run', + 'cancel_run: Cancel run', + 'list_runs: List runs', + 'get_run_history: Get run history', + 'get_brain_analytics: Get brain analytics', + ]); + expect(catalog.operationsIn('org')).toEqual([]); + }); + + it('answer at eleven routes relative to the brain', () => { + expect(catalog.operations.map(({ route }) => `${route.method} ${route.path}`)).toEqual([ + 'POST /definitions/{type}', + 'GET /definitions/{type}', + 'GET /definitions/{type}/{name}', + 'PUT /definitions/{type}/{name}', + 'POST /definitions/{type}/{name}/retire', + 'POST /definitions/{type}/{name}/run', + 'GET /runs/{run_id}', + 'POST /runs/{run_id}/cancel', + 'GET /runs', + 'GET /runs/{run_id}/history', + 'GET /analytics', + ]); + }); +}); + +describe('a catalog of the definition operations', () => { + it('takes the brain operations as well, without a clash of names or routes', () => { + expect(makeCatalog([...brainOperations, ...operations]).operations.map(({ name }) => name)).toEqual([ + 'create_brain', + 'list_brains', + 'get_brain', + 'update_brain', + 'retire_brain', + 'create_definition', + 'list_definitions', + 'get_definition', + 'update_definition', + 'retire_definition', + 'run_definition', + 'get_run', + 'cancel_run', + 'list_runs', + 'get_run_history', + 'get_brain_analytics', + ]); + }); +}); + +describe('the definition operations for a list of capabilities', () => { + it('need at least one capability', () => { + expect(() => makeDefinitionOperations([])).toThrow('The definition operations need at least one capability'); + }); + + it('need capabilities of distinct names', () => { + expect(() => makeDefinitionOperations([echo, probe().capability, { ...echo, title: 'Another echo' }])).toThrow( + 'The type echo is used more than once', + ); + }); +}); diff --git a/packages/definitions/src/operations/definition-operations.ts b/packages/definitions/src/operations/definition-operations.ts new file mode 100644 index 000000000..ebe5fc2ff --- /dev/null +++ b/packages/definitions/src/operations/definition-operations.ts @@ -0,0 +1,29 @@ +import type { Presenter, Registration } from '@beonauto/operations'; + +import type { Capability } from '../capability/capability.ts'; +import { runOperations } from '../reading/run-operations.ts'; +import { defineCreateDefinition } from './create-definition.ts'; +import { defineGetDefinition } from './get-definition.ts'; +import { defineListDefinitions } from './list-definitions.ts'; +import { defineRetireDefinition } from './retire-definition.ts'; +import { defineRunDefinition } from './run-definition.ts'; +import { defineUpdateDefinition } from './update-definition.ts'; + +export interface BrainOperation { + readonly registration: Registration<'brain'>; +} + +export function makeDefinitionOperations( + capabilities: readonly Capability[], + presenters?: readonly Presenter[], +): readonly BrainOperation[] { + return [ + defineCreateDefinition(capabilities), + defineListDefinitions(capabilities), + defineGetDefinition(capabilities), + defineUpdateDefinition(capabilities), + defineRetireDefinition(capabilities), + defineRunDefinition(capabilities), + ...runOperations(capabilities, presenters), + ]; +} diff --git a/packages/definitions/src/operations/definition-preparation.ts b/packages/definitions/src/operations/definition-preparation.ts new file mode 100644 index 000000000..87ffd8371 --- /dev/null +++ b/packages/definitions/src/operations/definition-preparation.ts @@ -0,0 +1,53 @@ +import { Conflict } from '@beonauto/operations'; +import { Effect } from 'effect'; + +import type { Capability } from '../capability/capability.ts'; +import { definitionResourceLabel } from '../capability/function-terminology.ts'; +import type { StoredDefinition } from '../registry/definition.ts'; +import { findDefinition } from '../registry/registry-lookup.ts'; +import type { Rejection } from './issue-pointers.ts'; +import { loadRegistry, versionOf } from './registry-access.ts'; + +const activeDefinition = Effect.fnUntraced(function* (type: string, name: string) { + const definition = yield* findDefinition(yield* loadRegistry(type), type, name); + if (definition.status === 'retired') { + return yield* new Conflict({ + detail: `The ${definitionResourceLabel(type)} ${name} is retired and can no longer be run`, + kind: 'retired', + }); + } + return definition; +}); + +function unparseable( + type: string, + { name, version }: Pick, +): (rejection: Rejection) => Conflict { + return ({ detail }) => + new Conflict({ + detail: `The ${definitionResourceLabel(type)} ${name} at version ${version} no longer parses (${detail}); update it`, + kind: 'unworkable', + }); +} + +export interface VersionToRun { + readonly name: string; + readonly version: number; + readonly source: string; +} + +export const preparedDefinition = Effect.fnUntraced(function* (capability: Capability, name: string) { + const definition = yield* activeDefinition(capability.type, name); + const prepared = yield* capability + .prepare(definition.source) + .pipe(Effect.mapError(unparseable(capability.type, definition))); + return { definition, prepared }; +}); + +export const preparedVersion = Effect.fnUntraced(function* (capability: Capability, name: string, version: number) { + const definition = yield* versionOf(capability.type, name, version); + const prepared = yield* capability + .prepare(definition.source) + .pipe(Effect.mapError(unparseable(capability.type, definition))); + return { definition, prepared }; +}); diff --git a/packages/specs/src/operations/spec-views.ts b/packages/definitions/src/operations/definition-views.ts similarity index 59% rename from packages/specs/src/operations/spec-views.ts rename to packages/definitions/src/operations/definition-views.ts index 7fae99734..f2d2c818a 100644 --- a/packages/specs/src/operations/spec-views.ts +++ b/packages/definitions/src/operations/definition-views.ts @@ -1,25 +1,25 @@ import type { InvalidInput } from '@beonauto/operations'; import { Effect, Order, Struct } from 'effect'; -import type { Primitive, DefinitionSummary } from '../primitive/primitive.ts'; -import type { SpecContent } from '../registry/spec-events.ts'; -import type { ListedDefinition, Definition, StoredDefinition } from '../registry/spec.ts'; +import type { Capability, DefinitionSummary } from '../capability/capability.ts'; +import type { DefinitionContent } from '../registry/definition-events.ts'; +import type { ListedDefinition, Definition, StoredDefinition } from '../registry/definition.ts'; import { rejectionOfSource } from './issue-pointers.ts'; export const byName = Order.mapInput(Order.String, ({ name }: StoredDefinition) => name); -export function specOf({ name, mediaType }: Primitive, stored: StoredDefinition): Definition { - return { primitive: name, media_type: mediaType, ...Struct.omit(stored, ['details']) }; +export function definitionOf({ type, mediaType }: Capability, stored: StoredDefinition): Definition { + return { type, media_type: mediaType, ...Struct.omit(stored, ['details']) }; } -export function listedSpecOf(primitive: Primitive, stored: StoredDefinition): ListedDefinition { - return Struct.omit(specOf(primitive, stored), ['source']); +export function listedDefinitionOf(capability: Capability, stored: StoredDefinition): ListedDefinition { + return Struct.omit(definitionOf(capability, stored), ['source']); } function contentFrom( source: string, { description, inputSchema, outputSchema, warnings = [], triggers = [], details }: DefinitionSummary, -): SpecContent { +): DefinitionContent { return { source, ...(description === undefined ? {} : { description }), @@ -31,8 +31,8 @@ function contentFrom( }; } -export function contentOf(primitive: Primitive, source: string): Effect.Effect { - return primitive.prepare(source).pipe( +export function contentOf(capability: Capability, source: string): Effect.Effect { + return capability.prepare(source).pipe( Effect.mapError(rejectionOfSource), Effect.map(({ summary }) => contentFrom(source, summary)), ); diff --git a/packages/definitions/src/operations/get-definition.test.ts b/packages/definitions/src/operations/get-definition.test.ts new file mode 100644 index 000000000..11fad184d --- /dev/null +++ b/packages/definitions/src/operations/get-definition.test.ts @@ -0,0 +1,81 @@ +import { describe, expect, it } from 'vitest'; + +import { acmeAdmin } from '../testing/callers.ts'; +import { definitionOperationsFor } from '../testing/definition-operations.ts'; +import { echo } from '../testing/echo.ts'; +import { asQueryString, firstMoment, harness, toBrain } from '../testing/harness.ts'; +import { probe } from '../testing/probe.ts'; + +const { createDefinition, getDefinition, retireDefinition } = definitionOperationsFor([echo, probe().capability]); + +const toAlpha = toBrain('acme', 'alpha'); + +const later = '2026-10-02T14:15:00.000Z'; + +async function withActiveAndRetiredDefinitions() { + const definitions = harness(); + await definitions.call(createDefinition, toAlpha(acmeAdmin, { type: 'probe', name: 'plain', source: 'text' })); + await definitions.call(createDefinition, toAlpha(acmeAdmin, { type: 'probe', name: 'stale', source: 'old' })); + await definitions.call(retireDefinition, toAlpha(acmeAdmin, { type: 'probe', name: 'stale' }), later); + return definitions; +} + +describe('get_definition', () => { + it('is a brain query at GET /definitions/{type}/{name} that may meet not_found', () => { + expect(getDefinition.registration).toMatchObject({ + scope: 'brain', + kind: 'query', + title: 'Get definition', + route: { method: 'GET', path: '/definitions/{type}/{name}' }, + pathParameters: ['type', 'name'], + successStatus: 200, + reasons: ['not_found'], + }); + }); + + it('reads a definition with its document, active or retired, from JSON and from a query string', async () => { + const { call } = await withActiveAndRetiredDefinitions(); + + expect(await call(getDefinition, toAlpha(acmeAdmin, { type: 'probe', name: 'plain' }))).toStrictEqual({ + status: 'succeeded', + output: { + type: 'probe', + name: 'plain', + version: 1, + status: 'active', + media_type: 'text/plain', + created_at: firstMoment, + created_by: 'acme-admin', + updated_at: firstMoment, + source: 'text', + }, + }); + expect( + await call(getDefinition, asQueryString(toAlpha(acmeAdmin, { type: 'probe', name: 'stale' }))), + ).toMatchObject({ status: 'succeeded', output: { status: 'retired', retired_at: later, source: 'old' } }); + }); +}); + +describe('get_definition rejecting', () => { + it('a definition the capability does not have in the brain, and a type the server does not run', async () => { + const { call } = await withActiveAndRetiredDefinitions(); + + expect(await call(getDefinition, toAlpha(acmeAdmin, { type: 'echo', name: 'plain' }))).toEqual({ + status: 'rejected', + reason: 'not_found', + detail: 'There is no echo definition plain in this brain', + }); + expect(await call(getDefinition, toAlpha(acmeAdmin, { type: 'reason', name: 'plain' }))).toEqual({ + status: 'rejected', + reason: 'invalid_input', + detail: 'The input does not match the input schema', + issues: [{ pointer: '/type', detail: 'Expected a type this server runs: echo or probe' }], + }); + }); + + it('a malformed name and a field the operation does not know', async () => { + expect( + await harness().call(getDefinition, toAlpha(acmeAdmin, { type: 'probe', name: 'no_where', verbose: true })), + ).toMatchObject({ reason: 'invalid_input', issues: [{ pointer: '/verbose' }, { pointer: '/name' }] }); + }); +}); diff --git a/packages/definitions/src/operations/get-definition.ts b/packages/definitions/src/operations/get-definition.ts new file mode 100644 index 000000000..34564618c --- /dev/null +++ b/packages/definitions/src/operations/get-definition.ts @@ -0,0 +1,56 @@ +import { BrainContext, defineQuery } from '@beonauto/operations'; +import { Effect, Schema } from 'effect'; + +import type { Capability } from '../capability/capability.ts'; +import { knownCapabilities } from '../capability/known-capabilities.ts'; +import { definitionStanding, definitionWordsFor, whatItDoes } from '../plain-language/definition-words.ts'; +import { DefinitionSchema } from '../registry/definition.ts'; +import { findDefinition } from '../registry/registry-lookup.ts'; +import { DefinitionNameField } from './definition-fields.ts'; +import { definitionOf } from './definition-views.ts'; +import { loadRegistry, triggersSince } from './registry-access.ts'; + +export function defineGetDefinition(capabilities: readonly Capability[]) { + const known = knownCapabilities(capabilities); + const words = definitionWordsFor(capabilities); + return known.publish( + defineQuery('brain', { + name: 'get_definition', + title: 'Get definition', + description: [ + 'Reads one function or workflow definition with its document and returns it, active or retired, with its version and the input and output its document declares.', + 'For a recall function it also returns the standing of its view: live, rebuilding, waiting or stalled, the events it has folded and how far it lags the brain.', + 'For a workflow it also returns its triggers, the event trigger and the schedules that start it on its own.', + 'Use it to show the person a definition or to learn the input a run takes; list_definitions lists the definitions of a type.', + "`type` is the definition's type and `name` its name.", + ].join(' '), + route: { method: 'GET', path: '/definitions/{type}/{name}' }, + inputSchema: Schema.Struct({ type: known.field, name: DefinitionNameField }), + outputSchema: DefinitionSchema, + reasons: ['not_found'], + handle: Effect.fnUntraced(function* ({ type, name }) { + const capability = yield* known.capabilityOfType(type); + const registry = yield* loadRegistry(capability.type); + const stored = yield* findDefinition(registry, capability.type, name); + const definition = + (stored.triggers ?? []).length > 0 && stored.status === 'active' + ? { ...definitionOf(capability, stored), triggers_since: yield* triggersSince(capability.type, stored) } + : definitionOf(capability, stored); + const { org, brain } = yield* BrainContext; + const standing = yield* capability.standing({ + org, + brain, + name, + version: definition.version, + status: definition.status, + }); + return standing === undefined ? definition : { ...definition, standing }; + }), + plainLanguage: { + task: `look up a ${words.kinds}`, + attempt: ({ type, name }) => `look up ${words.named(type, name)}`, + outcome: (definition) => `${definitionStanding(words, definition)}${whatItDoes(definition)}`, + }, + }), + ); +} diff --git a/packages/definitions/src/operations/get-run.test.ts b/packages/definitions/src/operations/get-run.test.ts new file mode 100644 index 000000000..77b49ed93 --- /dev/null +++ b/packages/definitions/src/operations/get-run.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, it } from 'vitest'; + +import { getRun } from '../index.ts'; +import { acmeAdmin } from '../testing/callers.ts'; +import { definitionOperationsFor } from '../testing/definition-operations.ts'; +import { echo } from '../testing/echo.ts'; +import { asQueryString, harness, toBrain } from '../testing/harness.ts'; + +const { createDefinition, runDefinition } = definitionOperationsFor([echo]); + +const toAlpha = toBrain('acme', 'alpha'); + +const runId = '0199a3c4-7d2e-7c1a-9b3f-2f1e0d9c8b7a'; + +async function withRun() { + const definitions = harness(); + await definitions.call( + createDefinition, + toAlpha(acmeAdmin, { type: 'echo', name: 'greet', source: '{"greeting": "Hi"}' }), + ); + await definitions.call(runDefinition, toAlpha(acmeAdmin, { type: 'echo', name: 'greet', run_id: runId })); + return definitions; +} + +describe('get_run', () => { + it('is a brain query at GET /runs/{run_id} that may meet not_found', () => { + expect(getRun.registration).toMatchObject({ + scope: 'brain', + kind: 'query', + title: 'Get run', + route: { method: 'GET', path: '/runs/{run_id}' }, + pathParameters: ['run_id'], + successStatus: 200, + reasons: ['not_found'], + }); + }); + + it('reads a run by its id in any case, from JSON and from a query string', async () => { + const { call } = await withRun(); + const found = { status: 'succeeded', output: { run_id: runId, status: 'succeeded' } }; + + expect(await call(getRun, toAlpha(acmeAdmin, { run_id: runId.toUpperCase() }))).toMatchObject(found); + expect(await call(getRun, asQueryString(toAlpha(acmeAdmin, { run_id: runId })))).toMatchObject(found); + }); +}); + +describe('get_run rejecting', () => { + it('an id the brain has no run for', async () => { + const { call } = await withRun(); + const unknownId = '0199a3c4-7d2e-7c1a-9b3f-000000000000'; + + expect(await call(getRun, toAlpha(acmeAdmin, { run_id: unknownId }))).toEqual({ + status: 'rejected', + reason: 'not_found', + detail: `There is no run ${unknownId} in this brain`, + }); + }); + + it('an id that is not a UUID and a field the operation does not know', async () => { + expect(await harness().call(getRun, toAlpha(acmeAdmin, { run_id: 'latest', type: 'echo' }))).toMatchObject({ + reason: 'invalid_input', + issues: [{ pointer: '/type' }, { pointer: '/run_id', detail: 'Expected a UUID' }], + }); + }); +}); diff --git a/packages/definitions/src/operations/get-run.ts b/packages/definitions/src/operations/get-run.ts new file mode 100644 index 000000000..9f10ac812 --- /dev/null +++ b/packages/definitions/src/operations/get-run.ts @@ -0,0 +1,36 @@ +import { defineQuery } from '@beonauto/operations'; +import { Effect, Schema } from 'effect'; + +import type { Capability } from '../capability/capability.ts'; +import { runWordsFor } from '../plain-language/run-words.ts'; +import { runDetailOf } from '../runs/run-lookup.ts'; +import { RunDetailSchema } from '../runs/run.ts'; +import { RunIdInputField } from './definition-fields.ts'; +import { loadRun } from './run-access.ts'; + +export function defineGetRun(capabilities: readonly Capability[]) { + const runWords = runWordsFor(capabilities); + return defineQuery('brain', { + name: 'get_run', + title: 'Get run', + description: [ + 'Reads one run of the brain by its id: the definition and version that ran, who started it and when, its status,', + 'and its output or why it did not succeed, with the record its type keeps, such as the prompt and tokens of a reasoning function.', + 'A run is started until it ends as succeeded, rejected or failed.', + 'Use it to tell the person how a run ended; get_run_history shows each step and tool call of the run.', + '`run_id` is the id run_definition answered with or was given.', + ].join(' '), + route: { method: 'GET', path: '/runs/{run_id}' }, + inputSchema: Schema.Struct({ run_id: RunIdInputField }), + outputSchema: RunDetailSchema, + reasons: ['not_found'], + handle: ({ run_id: id }) => loadRun(id).pipe(Effect.flatMap((state) => runDetailOf(id, state))), + plainLanguage: { + task: 'look up a run', + attempt: () => 'look up the run', + outcome: (run) => runWords(run, 'looked up'), + }, + }); +} + +export const getRun = defineGetRun([]); diff --git a/packages/definitions/src/operations/isolation.test.ts b/packages/definitions/src/operations/isolation.test.ts new file mode 100644 index 000000000..51a67aea7 --- /dev/null +++ b/packages/definitions/src/operations/isolation.test.ts @@ -0,0 +1,96 @@ +import { describe, expect, it } from 'vitest'; + +import { acmeAdmin, acmeAlphaKeeper, acmeReader, globexAdmin } from '../testing/callers.ts'; +import { definitionOperationsFor } from '../testing/definition-operations.ts'; +import { echo } from '../testing/echo.ts'; +import { harness, toBrain } from '../testing/harness.ts'; + +const { createDefinition, runDefinition, getRun, getDefinition, listDefinitions, retireDefinition, updateDefinition } = + definitionOperationsFor([echo]); + +const toAlpha = toBrain('acme', 'alpha'); + +const toBeta = toBrain('acme', 'beta'); + +const toGamma = toBrain('globex', 'gamma'); + +const runId = '0199a3c4-7d2e-7c1a-9b3f-2f1e0d9c8b7a'; + +const greet = { type: 'echo', name: 'greet' }; + +const hello = { ...greet, source: '{"greeting": "Hello"}' }; + +describe('the definitions and runs of a brain', () => { + it('are invisible from another brain of the org and from a brain of another org', async () => { + const { call, ledger } = harness(); + await call(createDefinition, toAlpha(acmeAdmin, hello)); + await call(runDefinition, toAlpha(acmeAdmin, { ...greet, run_id: runId })); + + expect(await call(getDefinition, toBeta(acmeAdmin, greet))).toMatchObject({ reason: 'not_found' }); + expect(await call(listDefinitions, toGamma(globexAdmin, { type: 'echo' }))).toEqual({ + status: 'succeeded', + output: { definitions: [] }, + }); + expect(await call(getRun, toBeta(acmeAdmin, { run_id: runId }))).toMatchObject({ + reason: 'not_found', + }); + expect(await call(getRun, toGamma(globexAdmin, { run_id: runId }))).toMatchObject({ + reason: 'not_found', + }); + expect(ledger.streamNames()).toEqual(['brain/acme/alpha/definitions/echo', `brain/acme/alpha/runs/${runId}`]); + }); + + it('are apart from those of another brain that uses the same names and run ids', async () => { + const { call } = harness(); + await call(createDefinition, toAlpha(acmeAdmin, hello)); + await call(createDefinition, toGamma(globexAdmin, { ...greet, source: '{"greeting": "Hola"}' })); + await call(runDefinition, toAlpha(acmeAdmin, { ...greet, run_id: runId })); + + expect(await call(runDefinition, toGamma(globexAdmin, { ...greet, run_id: runId }))).toMatchObject({ + output: { output: { greeting: 'Hola' }, started_by: 'globex-admin' }, + }); + }); + + it('cannot be reached by a caller of another org, whether they exist or not', async () => { + const { call } = harness(); + await call(createDefinition, toAlpha(acmeAdmin, hello)); + const foreign = { status: 'rejected', reason: 'forbidden', detail: 'The caller does not belong to this org' }; + + expect(await call(getDefinition, toAlpha(globexAdmin, greet))).toEqual(foreign); + expect(await call(runDefinition, toAlpha(globexAdmin, greet))).toEqual(foreign); + expect(await call(getRun, toAlpha(globexAdmin, { run_id: runId }))).toEqual(foreign); + }); +}); + +describe('a caller that may only read', () => { + it('reads definitions and runs, and is rejected for the commands, running included', async () => { + const { call } = harness(); + await call(createDefinition, toAlpha(acmeAdmin, hello)); + await call(runDefinition, toAlpha(acmeAdmin, { ...greet, run_id: runId })); + const readOnly = { status: 'rejected', reason: 'forbidden', detail: 'The caller lacks the brain:write permission' }; + + expect(await call(createDefinition, toAlpha(acmeReader, { ...hello, name: 'wave' }))).toEqual(readOnly); + expect(await call(updateDefinition, toAlpha(acmeReader, hello))).toEqual(readOnly); + expect(await call(retireDefinition, toAlpha(acmeReader, greet))).toEqual(readOnly); + expect(await call(runDefinition, toAlpha(acmeReader, greet))).toEqual(readOnly); + expect(await call(getDefinition, toAlpha(acmeReader, greet))).toMatchObject({ status: 'succeeded' }); + expect(await call(listDefinitions, toAlpha(acmeReader, { type: 'echo' }))).toMatchObject({ status: 'succeeded' }); + expect(await call(getRun, toAlpha(acmeReader, { run_id: runId }))).toMatchObject({ + status: 'succeeded', + }); + }); +}); + +describe('a caller limited to some brains', () => { + it('works with the definitions of its brains and is denied every other brain', async () => { + const { call } = harness(); + const denied = { status: 'rejected', reason: 'forbidden', detail: 'The caller may not access this brain' }; + + expect(await call(createDefinition, toAlpha(acmeAlphaKeeper, hello))).toMatchObject({ + output: { created_by: 'acme-alpha-keeper' }, + }); + expect(await call(runDefinition, toAlpha(acmeAlphaKeeper, greet))).toMatchObject({ status: 'succeeded' }); + expect(await call(createDefinition, toBeta(acmeAlphaKeeper, hello))).toEqual(denied); + expect(await call(listDefinitions, toBeta(acmeAlphaKeeper, { type: 'echo' }))).toEqual(denied); + }); +}); diff --git a/packages/specs/src/operations/issue-pointers.ts b/packages/definitions/src/operations/issue-pointers.ts similarity index 100% rename from packages/specs/src/operations/issue-pointers.ts rename to packages/definitions/src/operations/issue-pointers.ts diff --git a/packages/specs/src/operations/kept-definitions.test.ts b/packages/definitions/src/operations/kept-definitions.test.ts similarity index 54% rename from packages/specs/src/operations/kept-definitions.test.ts rename to packages/definitions/src/operations/kept-definitions.test.ts index 4423346a7..d6c78b4f7 100644 --- a/packages/specs/src/operations/kept-definitions.test.ts +++ b/packages/definitions/src/operations/kept-definitions.test.ts @@ -1,18 +1,18 @@ import { Effect } from 'effect'; import { describe, expect, it } from 'vitest'; -import { definePrimitive, type Primitive, type StandingRequest } from '../index.ts'; -import { specsDecider, specsStreamOf } from '../registry/specs-decider.ts'; +import { defineCapability, type Capability, type StandingRequest } from '../index.ts'; +import { definitionsDecider, definitionTypeStreamOf } from '../registry/definitions-decider.ts'; import { acmeAdmin } from '../testing/callers.ts'; +import { definitionOperationsFor } from '../testing/definition-operations.ts'; import { harness, toBrain } from '../testing/harness.ts'; import { probe } from '../testing/probe.ts'; -import { specOperationsFor } from '../testing/spec-operations.ts'; const toAlpha = toBrain('acme', 'alpha'); -function keeper(mostActive: number): Primitive { - return definePrimitive({ - name: 'keeper', +function keeper(mostActive: number): Capability { + return defineCapability({ + type: 'keeper', title: 'Keeper', guide: { name: 'keeper' }, noun: { one: 'keeper', other: 'keepers' }, @@ -20,30 +20,34 @@ function keeper(mostActive: number): Primitive { mediaType: 'text/plain', parse: (source: string) => Effect.succeed({ kept: source }), summarize: ({ kept }) => ({ description: 'Keeps a text', details: { kept, line: 4 } }), - execute: () => Effect.succeed({ output: null, record: {} }), + run: () => Effect.succeed({ output: null, record: {} }), mostActive, standing: ({ org, brain, name, version, status }: StandingRequest) => Effect.succeed(name === 'unkept' ? undefined : { state: 'live', of: `${org}/${brain}/${name}`, version, status }), }); } -function keepersOf(primitive: Primitive) { - const specs = harness(); - const { createSpec, getSpec, listSpecs, updateSpec, retireSpec } = specOperationsFor([primitive, probe().primitive]); +function keepersOf(capability: Capability) { + const definitions = harness(); + const { createDefinition, getDefinition, listDefinitions, updateDefinition, retireDefinition } = + definitionOperationsFor([capability, probe().capability]); const create = (name: string) => - specs.call(createSpec, toAlpha(acmeAdmin, { primitive: 'keeper', name, source: name })); - return { specs, create, getSpec, listSpecs, updateSpec, retireSpec }; + definitions.call(createDefinition, toAlpha(acmeAdmin, { type: 'keeper', name, source: name })); + return { definitions, create, getDefinition, listDefinitions, updateDefinition, retireDefinition }; } describe('what a definition keeps for its runtime adapter', () => { it('is stored with its record and kept in the state of its registry, never in what an operation answers', async () => { - const { specs, create, getSpec, listSpecs } = keepersOf(keeper(32)); + const { definitions, create, getDefinition, listDefinitions } = keepersOf(keeper(32)); const created = await create('reviews'); - const read = await specs.call(getSpec, toAlpha(acmeAdmin, { primitive: 'keeper', name: 'reviews' })); - const listed = await specs.call(listSpecs, toAlpha(acmeAdmin, { primitive: 'keeper' })); + const read = await definitions.call(getDefinition, toAlpha(acmeAdmin, { type: 'keeper', name: 'reviews' })); + const listed = await definitions.call(listDefinitions, toAlpha(acmeAdmin, { type: 'keeper' })); const { state } = await Effect.runPromise( - specs.ledger.service.load(`brain/acme/alpha/${specsStreamOf('keeper')}`, specsDecider('keeper')), + definitions.ledger.service.load( + `brain/acme/alpha/${definitionTypeStreamOf('keeper')}`, + definitionsDecider('keeper'), + ), ); expect(state.get('reviews')?.details).toEqual({ kept: 'reviews', line: 4 }); @@ -51,18 +55,18 @@ describe('what a definition keeps for its runtime adapter', () => { expect(outcome).toMatchObject({ status: 'succeeded', output: { name: 'reviews' } }); expect(outcome).not.toHaveProperty('output.details'); } - expect(listed).not.toHaveProperty('output.specs.0.details'); + expect(listed).not.toHaveProperty('output.definitions.0.details'); }); }); describe('the standing of a definition', () => { - it('is answered by get_spec from its runtime adapter, given the brain, the name, the version and the status', async () => { - const { specs, create, getSpec } = keepersOf(keeper(32)); + it('is answered by get_definition from its runtime adapter, given the brain, the name, the version and the status', async () => { + const { definitions, create, getDefinition } = keepersOf(keeper(32)); await create('reviews'); await create('unkept'); - const read = await specs.call(getSpec, toAlpha(acmeAdmin, { primitive: 'keeper', name: 'reviews' })); - const unkept = await specs.call(getSpec, toAlpha(acmeAdmin, { primitive: 'keeper', name: 'unkept' })); + const read = await definitions.call(getDefinition, toAlpha(acmeAdmin, { type: 'keeper', name: 'reviews' })); + const unkept = await definitions.call(getDefinition, toAlpha(acmeAdmin, { type: 'keeper', name: 'unkept' })); expect(read).toMatchObject({ output: { standing: { state: 'live', of: 'acme/alpha/reviews', version: 1, status: 'active' } }, @@ -73,12 +77,12 @@ describe('the standing of a definition', () => { describe('the active definitions of a type a brain may keep', () => { it('refuse a definition past their bound as a conflict, and take one again once another is retired', async () => { - const { specs, create, retireSpec } = keepersOf(keeper(2)); + const { definitions, create, retireDefinition } = keepersOf(keeper(2)); await create('first'); await create('second'); const third = await create('third'); - await specs.call(retireSpec, toAlpha(acmeAdmin, { primitive: 'keeper', name: 'first' })); + await definitions.call(retireDefinition, toAlpha(acmeAdmin, { type: 'keeper', name: 'first' })); const again = await create('third'); expect(third).toEqual({ @@ -94,19 +98,19 @@ describe('the active definitions of a type a brain may keep', () => { const before = keepersOf(keeper(2)); await before.create('first'); await before.create('second'); - const updated = await before.specs.call( - before.updateSpec, - toAlpha(acmeAdmin, { primitive: 'keeper', name: 'first', source: 'changed' }), + const updated = await before.definitions.call( + before.updateDefinition, + toAlpha(acmeAdmin, { type: 'keeper', name: 'first', source: 'changed' }), ); - const lowered = specOperationsFor([keeper(1)]); + const lowered = definitionOperationsFor([keeper(1)]); - const refused = await before.specs.call( - lowered.updateSpec, - toAlpha(acmeAdmin, { primitive: 'keeper', name: 'first', source: 'again' }), + const refused = await before.definitions.call( + lowered.updateDefinition, + toAlpha(acmeAdmin, { type: 'keeper', name: 'first', source: 'again' }), ); - const retired = await before.specs.call( - lowered.retireSpec, - toAlpha(acmeAdmin, { primitive: 'keeper', name: 'first' }), + const retired = await before.definitions.call( + lowered.retireDefinition, + toAlpha(acmeAdmin, { type: 'keeper', name: 'first' }), ); expect(updated).toMatchObject({ status: 'succeeded', output: { version: 2 } }); diff --git a/packages/definitions/src/operations/list-definitions.test.ts b/packages/definitions/src/operations/list-definitions.test.ts new file mode 100644 index 000000000..b049fb3e7 --- /dev/null +++ b/packages/definitions/src/operations/list-definitions.test.ts @@ -0,0 +1,129 @@ +import { describe, expect, it } from 'vitest'; + +import { acmeAdmin } from '../testing/callers.ts'; +import { definitionOperationsFor } from '../testing/definition-operations.ts'; +import { echo } from '../testing/echo.ts'; +import { asQueryString, firstMoment, harness, toBrain } from '../testing/harness.ts'; +import { probe } from '../testing/probe.ts'; + +const { createDefinition, listDefinitions, retireDefinition } = definitionOperationsFor([echo, probe().capability]); + +const toAlpha = toBrain('acme', 'alpha'); + +const later = '2026-10-02T14:15:00.000Z'; + +const activeOnly = { status: 'succeeded', output: { definitions: [{ name: 'alpha' }, { name: 'gamma' }] } }; + +const retiredToo = { + status: 'succeeded', + output: { + definitions: [{ name: 'alpha' }, { name: 'beta', status: 'retired', retired_at: later }, { name: 'gamma' }], + }, +}; + +function listed(name: string) { + return { + type: 'probe', + name, + version: 1, + status: 'active', + media_type: 'text/plain', + created_at: firstMoment, + created_by: 'acme-admin', + updated_at: firstMoment, + }; +} + +async function withGammaAlphaAndRetiredBeta() { + const definitions = harness(); + const creating = (name: string) => + definitions.call(createDefinition, toAlpha(acmeAdmin, { type: 'probe', name, source: name })); + await creating('gamma'); + await creating('alpha'); + await creating('beta'); + await definitions.call(retireDefinition, toAlpha(acmeAdmin, { type: 'probe', name: 'beta' }), later); + await definitions.call( + createDefinition, + toAlpha(acmeAdmin, { type: 'echo', name: 'delta', source: '{"greeting": "Hi"}' }), + ); + return definitions; +} + +describe('list_definitions', () => { + it('is a brain query at GET /definitions/{type} that may meet not_found', () => { + expect(listDefinitions.registration).toMatchObject({ + scope: 'brain', + kind: 'query', + title: 'List definitions', + route: { method: 'GET', path: '/definitions/{type}' }, + pathParameters: ['type'], + successStatus: 200, + reasons: ['not_found'], + }); + }); + + it('lists nothing for a capability without definitions', async () => { + expect(await harness().call(listDefinitions, toAlpha(acmeAdmin, { type: 'echo' }))).toEqual({ + status: 'succeeded', + output: { definitions: [] }, + }); + }); + + it('lists the active definitions of the capability sorted by name, without their documents', async () => { + const { call } = await withGammaAlphaAndRetiredBeta(); + + expect(await call(listDefinitions, toAlpha(acmeAdmin, { type: 'probe' }))).toStrictEqual({ + status: 'succeeded', + output: { definitions: [listed('alpha'), listed('gamma')] }, + }); + expect(await call(listDefinitions, toAlpha(acmeAdmin, { type: 'echo' }))).toMatchObject({ + output: { definitions: [{ type: 'echo', name: 'delta', media_type: 'application/json' }] }, + }); + }); + + it('rejects a type the server does not run', async () => { + expect(await harness().call(listDefinitions, toAlpha(acmeAdmin, { type: 'reasoning' }))).toEqual({ + status: 'rejected', + reason: 'invalid_input', + detail: 'The input does not match the input schema', + issues: [{ pointer: '/type', detail: 'Expected a type this server runs: echo or probe' }], + }); + }); +}); + +describe('include_retired', () => { + it('lists the retired definitions too when true in JSON', async () => { + const { call } = await withGammaAlphaAndRetiredBeta(); + const listing = (input: object) => call(listDefinitions, toAlpha(acmeAdmin, { type: 'probe', ...input })); + + expect(await listing({ include_retired: true })).toMatchObject(retiredToo); + expect(await listing({ include_retired: false })).toMatchObject(activeOnly); + expect(await listing({ include_retired: 'true' })).toMatchObject({ + reason: 'invalid_input', + issues: [{ pointer: '/include_retired', detail: 'Expected boolean' }], + }); + }); + + it('lists the retired definitions too when true in a query string', async () => { + const { call } = await withGammaAlphaAndRetiredBeta(); + const listing = (input: object) => + call(listDefinitions, asQueryString(toAlpha(acmeAdmin, { type: 'probe', ...input }))); + + expect(await listing({ include_retired: 'true' })).toMatchObject(retiredToo); + expect(await listing({ include_retired: 'false' })).toMatchObject(activeOnly); + expect(await listing({})).toMatchObject(activeOnly); + expect(await listing({ include_retired: 'yes' })).toMatchObject({ + reason: 'invalid_input', + issues: [{ pointer: '/include_retired' }], + }); + }); + + it('and capability are the only fields list_definitions knows', async () => { + expect( + await harness().call(listDefinitions, toAlpha(acmeAdmin, { type: 'probe', status: 'retired' })), + ).toMatchObject({ + reason: 'invalid_input', + issues: [{ pointer: '/status', detail: 'Expected no excess property' }], + }); + }); +}); diff --git a/packages/definitions/src/operations/list-definitions.ts b/packages/definitions/src/operations/list-definitions.ts new file mode 100644 index 000000000..8b6bf561a --- /dev/null +++ b/packages/definitions/src/operations/list-definitions.ts @@ -0,0 +1,46 @@ +import { defineQuery } from '@beonauto/operations'; +import { Effect, Schema } from 'effect'; + +import type { Capability } from '../capability/capability.ts'; +import { knownCapabilities } from '../capability/known-capabilities.ts'; +import { definitionsListed, definitionWordsFor } from '../plain-language/definition-words.ts'; +import { ListedDefinitionSchema } from '../registry/definition.ts'; +import { IncludeRetiredField } from './definition-fields.ts'; +import { byName, listedDefinitionOf } from './definition-views.ts'; +import { loadRegistry } from './registry-access.ts'; + +export function defineListDefinitions(capabilities: readonly Capability[]) { + const known = knownCapabilities(capabilities); + const words = definitionWordsFor(capabilities); + return known.publish( + defineQuery('brain', { + name: 'list_definitions', + title: 'List definitions', + description: [ + 'Lists the definitions of one type in the brain, sorted by name, each with its version, its status and what its document says it does, without the document.', + 'Use it to find a function or workflow the person names, or to see which exist before one is made; get_definition reads one with its document.', + '`type` is the type to list, and `include_retired` adds the retired definitions, which are left out otherwise.', + ].join(' '), + route: { method: 'GET', path: '/definitions/{type}' }, + inputSchema: Schema.Struct({ + type: known.field, + include_retired: Schema.optionalKey(IncludeRetiredField), + }), + outputSchema: Schema.Struct({ definitions: Schema.Array(ListedDefinitionSchema) }), + reasons: ['not_found'], + handle: Effect.fnUntraced(function* ({ type, include_retired: includeRetired = false }) { + const capability = yield* known.capabilityOfType(type); + const registry = yield* loadRegistry(capability.type); + const definitions = [...registry.values()].filter(({ status }) => includeRetired || status === 'active'); + return { + definitions: definitions.toSorted(byName).map((definition) => listedDefinitionOf(capability, definition)), + }; + }), + plainLanguage: { + task: `list the ${words.allKinds}`, + attempt: ({ type }) => `list the ${words.nounOf(type).other}`, + outcome: ({ definitions }, { type }) => definitionsListed(words.nounOf(type), definitions), + }, + }), + ); +} diff --git a/packages/definitions/src/operations/registry-access.ts b/packages/definitions/src/operations/registry-access.ts new file mode 100644 index 000000000..4e5464079 --- /dev/null +++ b/packages/definitions/src/operations/registry-access.ts @@ -0,0 +1,69 @@ +import { + BrainContext, + BrainReader, + BrainWriter, + messageIdOf, + NotFound, + streamPrefixOfBrain, +} from '@beonauto/operations'; +import { Effect } from 'effect'; + +import { definitionResourceLabel } from '../capability/function-terminology.ts'; +import type { DefinitionCommandData } from '../registry/definition-commands.ts'; +import type { DefinitionRegistry } from '../registry/definition-registry.ts'; +import { definitionVersionDecider, type RecordedVersion } from '../registry/definition-versions.ts'; +import type { StoredDefinition } from '../registry/definition.ts'; +import { definitionsDecider, definitionTypeStreamOf } from '../registry/definitions-decider.ts'; +import { findDefinition } from '../registry/registry-lookup.ts'; +import { commandMetadata } from './command-metadata.ts'; + +export function loadRegistry(type: string): Effect.Effect { + return BrainReader.use((reader) => reader.load(definitionTypeStreamOf(type), definitionsDecider(type))).pipe( + Effect.map(({ state }) => state), + ); +} + +function recordedVersionOf( + type: string, + name: string, + version: number, +): Effect.Effect { + return BrainReader.use((reader) => + reader.load(definitionTypeStreamOf(type), definitionVersionDecider(name, version)), + ).pipe( + Effect.flatMap(({ state: { found } }) => + found === undefined + ? Effect.fail( + new NotFound({ + detail: `There is no version ${version} of the ${definitionResourceLabel(type)} ${name} in this brain`, + }), + ) + : Effect.succeed(found), + ), + ); +} + +export const triggersSince = Effect.fnUntraced(function* (type: string, { name, version }: StoredDefinition) { + const { position } = yield* Effect.orDie(recordedVersionOf(type, name, version)); + return messageIdOf(`${streamPrefixOfBrain(yield* BrainContext)}${definitionTypeStreamOf(type)}`, position); +}); + +export function versionOf(type: string, name: string, version: number) { + return Effect.map(recordedVersionOf(type, name, version), ({ source }) => ({ name, version, source })); +} + +export const recordInRegistry = Effect.fnUntraced(function* ( + { type, mostActive }: { readonly type: string; readonly mostActive: number }, + data: DefinitionCommandData, +) { + const metadata = yield* commandMetadata; + const { state } = yield* (yield* BrainWriter).execute( + definitionTypeStreamOf(type), + definitionsDecider(type, mostActive), + { + ...data, + ...metadata, + }, + ); + return yield* Effect.orDie(findDefinition(state, type, data.name)); +}); diff --git a/packages/definitions/src/operations/retire-definition.test.ts b/packages/definitions/src/operations/retire-definition.test.ts new file mode 100644 index 000000000..e6559c191 --- /dev/null +++ b/packages/definitions/src/operations/retire-definition.test.ts @@ -0,0 +1,111 @@ +import { Effect } from 'effect'; +import { describe, expect, it } from 'vitest'; + +import { acmeAdmin } from '../testing/callers.ts'; +import { definitionOperationsFor } from '../testing/definition-operations.ts'; +import { echo } from '../testing/echo.ts'; +import { firstMoment, harness, toBrain } from '../testing/harness.ts'; +import { probe } from '../testing/probe.ts'; + +const { createDefinition, runDefinition, retireDefinition } = definitionOperationsFor([echo, probe().capability]); + +const toAlpha = toBrain('acme', 'alpha'); + +const later = '2026-10-02T14:15:00.000Z'; + +const muchLater = '2026-11-20T08:00:00.000Z'; + +const retiringPlain = toAlpha(acmeAdmin, { type: 'probe', name: 'plain' }); + +const retiredPlain = { + type: 'probe', + name: 'plain', + version: 1, + status: 'retired', + media_type: 'text/plain', + created_at: firstMoment, + created_by: 'acme-admin', + updated_at: later, + retired_at: later, + source: 'text', +}; + +async function withPlain() { + const definitions = harness(); + await definitions.call(createDefinition, toAlpha(acmeAdmin, { type: 'probe', name: 'plain', source: 'text' })); + return definitions; +} + +describe('retire_definition', () => { + it('is a brain command at POST /definitions/{type}/{name}/retire', () => { + expect(retireDefinition.registration).toMatchObject({ + scope: 'brain', + kind: 'command', + title: 'Retire definition', + route: { method: 'POST', path: '/definitions/{type}/{name}/retire' }, + pathParameters: ['type', 'name'], + successStatus: 200, + reasons: ['not_found', 'conflict'], + }); + }); + + it('retires a definition for good, recording when', async () => { + const { call } = await withPlain(); + + expect(await call(retireDefinition, retiringPlain, later)).toStrictEqual({ + status: 'succeeded', + output: retiredPlain, + }); + }); + + it('succeeds and records nothing for a definition that is already retired', async () => { + const { call } = await withPlain(); + await call(retireDefinition, retiringPlain, later); + + expect(await call(retireDefinition, retiringPlain, muchLater)).toStrictEqual({ + status: 'succeeded', + output: retiredPlain, + }); + }); + + it('leaves a definition that can no longer be executed', async () => { + const { call } = await withPlain(); + await call(retireDefinition, retiringPlain); + + expect(await call(runDefinition, retiringPlain)).toEqual({ + status: 'rejected', + reason: 'conflict', + detail: 'The probe definition plain is retired and can no longer be run', + kind: 'retired', + }); + }); +}); + +describe('retire_definition rejecting', () => { + it('a definition the brain does not have, and a type the server does not run', async () => { + const { call } = await withPlain(); + + expect(await call(retireDefinition, toAlpha(acmeAdmin, { type: 'echo', name: 'plain' }))).toEqual({ + status: 'rejected', + reason: 'not_found', + detail: 'There is no echo definition plain in this brain', + }); + expect(await call(retireDefinition, toAlpha(acmeAdmin, { type: 'reason', name: 'plain' }))).toEqual({ + status: 'rejected', + reason: 'invalid_input', + detail: 'The input does not match the input schema', + issues: [{ pointer: '/type', detail: 'Expected a type this server runs: echo or probe' }], + }); + }); + + it('with conflict when another change to the definitions of the capability landed at the same moment', async () => { + const { call, dispatch, run } = await withPlain(); + await call(createDefinition, toAlpha(acmeAdmin, { type: 'probe', name: 'other', source: 'text' })); + const retiring = (name: string) => dispatch(retireDefinition, toAlpha(acmeAdmin, { type: 'probe', name })); + + expect(await run(Effect.all([retiring('plain'), retiring('other')], { concurrency: 'unbounded' }))).toMatchObject([ + { status: 'succeeded', output: { name: 'plain', status: 'retired' } }, + { status: 'rejected', reason: 'conflict' }, + ]); + }); +}); diff --git a/packages/definitions/src/operations/retire-definition.ts b/packages/definitions/src/operations/retire-definition.ts new file mode 100644 index 000000000..f56b2b6b2 --- /dev/null +++ b/packages/definitions/src/operations/retire-definition.ts @@ -0,0 +1,42 @@ +import { defineCommand } from '@beonauto/operations'; +import { Effect, Schema } from 'effect'; + +import type { Capability } from '../capability/capability.ts'; +import { knownCapabilities } from '../capability/known-capabilities.ts'; +import { definitionWordsFor } from '../plain-language/definition-words.ts'; +import { DefinitionSchema } from '../registry/definition.ts'; +import { DefinitionNameField } from './definition-fields.ts'; +import { definitionOf } from './definition-views.ts'; +import { recordInRegistry } from './registry-access.ts'; + +export function defineRetireDefinition(capabilities: readonly Capability[]) { + const known = knownCapabilities(capabilities); + const words = definitionWordsFor(capabilities); + return known.publish( + defineCommand('brain', { + name: 'retire_definition', + title: 'Retire definition', + description: [ + 'Retires a function or workflow definition for good and returns it: it can still be read and listed, but it can no longer run or change, its name is not used again in the brain, and there is no way to restore it.', + 'Use it only when the person asks to retire that definition; update_definition changes it instead.', + '`type` and `name` say which definition, and retiring one already retired changes nothing.', + ].join(' '), + irreversible: true, + repeatable: true, + route: { method: 'POST', path: '/definitions/{type}/{name}/retire' }, + inputSchema: Schema.Struct({ type: known.field, name: DefinitionNameField }), + outputSchema: DefinitionSchema, + reasons: ['not_found', 'conflict'], + handle: Effect.fnUntraced(function* ({ type, name }) { + const capability = yield* known.capabilityOfType(type); + return definitionOf(capability, yield* recordInRegistry(capability, { type: 'retire', name })); + }), + plainLanguage: { + task: `retire a ${words.kinds}`, + attempt: ({ type, name }) => `retire ${words.named(type, name)}`, + outcome: ({ type, name }) => + `Retired ${words.named(type, name)}. It can no longer be run or changed, and its name cannot be used again in this brain.`, + }, + }), + ); +} diff --git a/packages/definitions/src/operations/run-access.ts b/packages/definitions/src/operations/run-access.ts new file mode 100644 index 000000000..e114dcc05 --- /dev/null +++ b/packages/definitions/src/operations/run-access.ts @@ -0,0 +1,41 @@ +import { + BrainContext, + BrainReader, + BrainWriter, + messageIdOf, + randomUUIDv7, + streamPrefixOfBrain, + type Lineage, +} from '@beonauto/operations'; +import { Effect } from 'effect'; + +import type { RunFinish, RunStart } from '../runs/run-commands.ts'; +import { runDecider, runStreamNameOf } from '../runs/run-decider.ts'; +import { startedRunOf, type RunState, type RunStreamState } from '../runs/run-state.ts'; +import { commandMetadata } from './command-metadata.ts'; + +export const newRunId = Effect.sync(() => randomUUIDv7()); + +export function loadRunStream(id: string): Effect.Effect { + return BrainReader.use((reader) => reader.load(runStreamNameOf(id), runDecider)).pipe( + Effect.map(({ state }) => state), + ); +} + +export function loadRun(id: string): Effect.Effect { + return Effect.map(loadRunStream(id), startedRunOf); +} + +export const recordRun = Effect.fnUntraced(function* (id: string, command: RunStart | RunFinish, lineage: Lineage) { + const metadata = yield* commandMetadata; + const { state, version } = yield* (yield* BrainWriter).execute( + runStreamNameOf(id), + runDecider, + { ...command, ...metadata }, + lineage, + ); + return { + state: startedRunOf(state), + messageId: messageIdOf(`${streamPrefixOfBrain(yield* BrainContext)}${runStreamNameOf(id)}`, version), + }; +}); diff --git a/packages/specs/src/operations/execution-attempt.ts b/packages/definitions/src/operations/run-attempt.ts similarity index 52% rename from packages/specs/src/operations/execution-attempt.ts rename to packages/definitions/src/operations/run-attempt.ts index f62bf5888..8e1de7e29 100644 --- a/packages/specs/src/operations/execution-attempt.ts +++ b/packages/definitions/src/operations/run-attempt.ts @@ -1,10 +1,10 @@ import type { ConflictKind, UnavailableBecause, UnavailableKind } from '@beonauto/operations'; import { Effect, Schema } from 'effect'; -import type { ExecutionOutcome, ExecutionResult, InterruptedAttempt } from '../execution/execution-commands.ts'; -import type { ExecutionRejection } from '../execution/execution.ts'; -import { withinResultLimit } from '../execution/recorded-size.ts'; -import type { Executed, PrimitiveRejection } from '../primitive/primitive.ts'; +import type { CapabilityAnswer, CapabilityRejection } from '../capability/capability.ts'; +import { withinResultLimit } from '../runs/recorded-size.ts'; +import type { RunOutcome, RunResult, InterruptedAttempt } from '../runs/run-commands.ts'; +import type { RunRejection } from '../runs/run.ts'; import { issuesUnder, type Rejection } from './issue-pointers.ts'; const decodeExecuted = Schema.decodeUnknownEffect( @@ -14,26 +14,24 @@ const decodeExecuted = Schema.decodeUnknownEffect( ]), ); -export const failedAttempt: ExecutionResult = { type: 'execution_failed' }; +export const failedAttempt: RunResult = { type: 'run_failed' }; -export const interruptedAttempt: InterruptedAttempt = { type: 'execution_interrupted' }; +export const interruptedAttempt: InterruptedAttempt = { type: 'run_interrupted' }; -function recordedOutcome(executed: Executed): Effect.Effect { - return 'finishesLater' in executed - ? withinResultLimit(executed.record).pipe( - Effect.map((): ExecutionOutcome => ({ type: 'execution_deferred', record: executed.record })), - ) - : withinResultLimit(executed.output, executed.record).pipe( - Effect.map((): ExecutionOutcome => ({ - type: 'execution_succeeded', - output: executed.output, - record: executed.record, +function recordedOutcome(ran: CapabilityAnswer): Effect.Effect { + return 'finishesLater' in ran + ? withinResultLimit(ran.record).pipe(Effect.map((): RunOutcome => ({ type: 'run_deferred', record: ran.record }))) + : withinResultLimit(ran.output, ran.record).pipe( + Effect.map((): RunOutcome => ({ + type: 'run_succeeded', + output: ran.output, + record: ran.record, })), ); } -function outcomeOf(executed: Executed): Effect.Effect { - return decodeExecuted(executed).pipe(Effect.orDie, Effect.flatMap(recordedOutcome)); +function outcomeOf(ran: CapabilityAnswer): Effect.Effect { + return decodeExecuted(ran).pipe(Effect.orDie, Effect.flatMap(recordedOutcome)); } const decodeRecord = Schema.decodeUnknownEffect(Schema.JsonObject); @@ -42,18 +40,18 @@ interface Recorded { readonly record?: Schema.JsonObject; } -function rejectedWith(rejection: ExecutionRejection, { record }: Recorded): Effect.Effect { +function rejectedWith(rejection: RunRejection, { record }: Recorded): Effect.Effect { if (record === undefined) { - return Effect.succeed({ type: 'execution_rejected', rejection }); + return Effect.succeed({ type: 'run_rejected', rejection }); } return decodeRecord(record).pipe( Effect.orDie, Effect.tap((checked) => withinResultLimit(checked)), - Effect.map((checked): ExecutionResult => ({ type: 'execution_rejected', rejection, record: checked })), + Effect.map((checked): RunResult => ({ type: 'run_rejected', rejection, record: checked })), ); } -function rejectedForInput(rejection: Rejection & Recorded): Effect.Effect { +function rejectedForInput(rejection: Rejection & Recorded): Effect.Effect { const { detail, issues } = rejection; return rejectedWith({ reason: 'invalid_input', detail, issues: issuesUnder('input', issues) }, rejection); } @@ -64,7 +62,7 @@ interface Unavailability extends Recorded { readonly because?: UnavailableBecause; } -function rejectedAsUnavailable(rejection: Unavailability): Effect.Effect { +function rejectedAsUnavailable(rejection: Unavailability): Effect.Effect { const { detail, kind, because } = rejection; return rejectedWith( { @@ -82,13 +80,13 @@ interface Clash extends Recorded { readonly kind?: ConflictKind; } -function rejectedAsConflict(rejection: Clash): Effect.Effect { +function rejectedAsConflict(rejection: Clash): Effect.Effect { const { detail, kind } = rejection; return rejectedWith({ reason: 'conflict', detail, ...(kind === undefined ? {} : { kind }) }, rejection); } -export function attempt(executing: Effect.Effect): Effect.Effect { - return executing.pipe( +export function attempt(running: Effect.Effect): Effect.Effect { + return running.pipe( Effect.flatMap(outcomeOf), Effect.catchTags({ invalid_input: rejectedForInput, diff --git a/packages/definitions/src/operations/run-definition.test.ts b/packages/definitions/src/operations/run-definition.test.ts new file mode 100644 index 000000000..1dbc991e7 --- /dev/null +++ b/packages/definitions/src/operations/run-definition.test.ts @@ -0,0 +1,188 @@ +import { describe, expect, it } from 'vitest'; + +import { acmeAdmin } from '../testing/callers.ts'; +import { definitionOperationsFor } from '../testing/definition-operations.ts'; +import { echo } from '../testing/echo.ts'; +import { firstMoment, harness, toBrain } from '../testing/harness.ts'; +import { probe } from '../testing/probe.ts'; + +const { createDefinition, runDefinition, getRun, updateDefinition } = definitionOperationsFor([ + echo, + probe().capability, +]); + +const toAlpha = toBrain('acme', 'alpha'); + +const later = '2026-10-02T14:15:00.000Z'; + +const uuidV7 = /^[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/u; + +const runId = '0199a3c4-7d2e-7c1a-9b3f-2f1e0d9c8b7a'; + +const greeted = { + run_id: runId, + type: 'echo', + name: 'greet', + definition_version: 1, + status: 'succeeded', + output: { greeting: 'Hello', input: {} }, + started_at: firstMoment, + started_by: 'acme-admin', + finished_at: firstMoment, +}; + +async function withGreetAndPlain() { + const definitions = harness(); + await definitions.call( + createDefinition, + toAlpha(acmeAdmin, { type: 'echo', name: 'greet', source: '{"greeting": "Hello"}' }), + ); + await definitions.call(createDefinition, toAlpha(acmeAdmin, { type: 'probe', name: 'plain', source: 'text' })); + return definitions; +} + +function running(name: string, input?: object) { + const type = name === 'greet' ? 'echo' : 'probe'; + return toAlpha(acmeAdmin, input === undefined ? { type, name } : { type, name, ...input }); +} + +describe('run_definition', () => { + it('is a brain command at POST /definitions/{type}/{name}/run', () => { + expect(runDefinition.registration).toMatchObject({ + scope: 'brain', + kind: 'command', + title: 'Run definition', + route: { method: 'POST', path: '/definitions/{type}/{name}/run' }, + pathParameters: ['type', 'name'], + successStatus: 200, + reasons: ['not_found', 'conflict', 'invalid_input', 'unavailable', 'cancelled', 'unanswered'], + }); + }); + + it('runs a definition with an input and answers with the run it recorded under a new id', async () => { + const { call, ledger } = await withGreetAndPlain(); + + const outcome = await call(runDefinition, running('greet', { input: { who: 'Ada' } }), later); + const [, , runStream] = ledger.streamNames(); + const id = String(runStream).replace('brain/acme/alpha/runs/', ''); + + expect(id).toMatch(uuidV7); + expect(outcome).toStrictEqual({ + status: 'succeeded', + output: { + run_id: id, + type: 'echo', + name: 'greet', + definition_version: 1, + status: 'succeeded', + output: { greeting: 'Hello', input: { who: 'Ada' } }, + started_at: later, + started_by: 'acme-admin', + finished_at: later, + }, + }); + }); + + it('answers with the run that get_run reads, without the record that get_run adds', async () => { + const { call } = await withGreetAndPlain(); + + expect(await call(runDefinition, running('greet', { run_id: runId }))).toStrictEqual({ + status: 'succeeded', + output: greeted, + }); + expect(await call(getRun, toAlpha(acmeAdmin, { run_id: runId }))).toStrictEqual({ + status: 'succeeded', + output: { ...greeted, record: { greeting: 'Hello' } }, + }); + }); +}); + +describe('the run that run_definition runs', () => { + it('gives the capability an empty object when the input is left out', async () => { + const { call } = await withGreetAndPlain(); + + expect(await call(runDefinition, running('greet'))).toMatchObject({ + output: { output: { greeting: 'Hello', input: {} } }, + }); + }); + + it('tells the capability the run id, the org, the brain, the caller and the definition it runs', async () => { + const { call } = await withGreetAndPlain(); + + expect(await call(runDefinition, running('plain', { input: 7, run_id: runId }))).toMatchObject({ + output: { + output: { + input: 7, + run: { + id: runId, + org: 'acme', + brain: 'alpha', + caller: acmeAdmin, + definition: { name: 'plain', version: 1 }, + }, + }, + }, + }); + }); + + it('runs the active latest version of the definition', async () => { + const { call } = await withGreetAndPlain(); + await call(updateDefinition, toAlpha(acmeAdmin, { type: 'echo', name: 'greet', source: '{"greeting": "Howdy"}' })); + + expect(await call(runDefinition, running('greet'))).toMatchObject({ + output: { definition_version: 2, output: { greeting: 'Howdy' } }, + }); + }); + + it('keeps the run id it is given, in lowercase', async () => { + const { call } = await withGreetAndPlain(); + + expect(await call(runDefinition, running('greet', { run_id: runId.toUpperCase() }))).toMatchObject({ + output: { run_id: runId }, + }); + }); +}); + +describe('run_definition rejected by the capability', () => { + it('for invalid input, with the issues under /input, and records the rejection', async () => { + const { call } = await withGreetAndPlain(); + + expect(await call(runDefinition, running('plain', { input: { reject: true }, run_id: runId }))).toEqual({ + status: 'rejected', + reason: 'invalid_input', + detail: 'The probe rejects the input', + issues: [{ detail: 'Expected anything but reject', pointer: '/input/reject' }], + }); + expect(await call(getRun, toAlpha(acmeAdmin, { run_id: runId }))).toStrictEqual({ + status: 'succeeded', + output: { + run_id: runId, + type: 'probe', + name: 'plain', + definition_version: 1, + status: 'rejected', + rejection: { + reason: 'invalid_input', + detail: 'The probe rejects the input', + issues: [{ detail: 'Expected anything but reject', pointer: '/input/reject' }], + }, + started_at: firstMoment, + started_by: 'acme-admin', + finished_at: firstMoment, + }, + }); + }); + + it('for input that is not what the capability takes at its root', async () => { + const { call } = await withGreetAndPlain(); + const notAnObject = { + status: 'rejected', + reason: 'invalid_input', + detail: 'The input of an echo definition must be a JSON object', + issues: [{ detail: 'Expected a JSON object', pointer: '/input' }], + }; + + expect(await call(runDefinition, running('greet', { input: 'Ada' }))).toEqual(notAnObject); + expect(await call(runDefinition, running('greet', { input: ['Ada'] }))).toEqual(notAnObject); + }); +}); diff --git a/packages/definitions/src/operations/run-definition.ts b/packages/definitions/src/operations/run-definition.ts new file mode 100644 index 000000000..eabbe65f1 --- /dev/null +++ b/packages/definitions/src/operations/run-definition.ts @@ -0,0 +1,51 @@ +import { defineCommand } from '@beonauto/operations'; +import { Effect, Schema } from 'effect'; + +import type { Capability } from '../capability/capability.ts'; +import { knownCapabilities } from '../capability/known-capabilities.ts'; +import { runPlainLanguage } from '../plain-language/run-words.ts'; +import { RunSchema } from '../runs/run.ts'; +import { RunIdInputField, InputField, DefinitionNameField } from './definition-fields.ts'; +import { runRequest } from './run-requests.ts'; + +const description = [ + 'Runs a function or workflow with an input and records the run.', + 'A function answers its result; a workflow or an interaction function answers started with a run_id unless it ends before its first wait, and get_run shows how it ended.', + "Use it to run a saved definition at the person's request; a workflow's steps call it the same way.", + '`type` and `name` say which definition, `input` is the value it takes, as the input_schema get_definition shows,', + 'and `run_id` is optional: give the same id to retry safely, since a run that ended or waits answers as it stands and one that failed runs again.', + 'A reasoning function that names tools may change something outside the brain, so a run of one that did not succeed is never run again under its id;', + 'get_run_history shows what it called.', +].join(' '); + +export function defineRunDefinition(capabilities: readonly Capability[]) { + const known = knownCapabilities(capabilities); + return known.publish( + defineCommand('brain', { + name: 'run_definition', + title: 'Run definition', + description, + route: { method: 'POST', path: '/definitions/{type}/{name}/run' }, + reachesOutside: capabilities.some(({ reachesOutside }) => reachesOutside), + mayChangeOutside: capabilities.some(({ mayChangeOutside }) => mayChangeOutside), + inputSchema: Schema.Struct({ + type: known.field, + name: DefinitionNameField, + input: Schema.optionalKey(InputField), + run_id: Schema.optionalKey(RunIdInputField), + }), + outputSchema: RunSchema, + reasons: ['not_found', 'conflict', 'invalid_input', 'unavailable', 'cancelled', 'unanswered'], + handle: Effect.fnUntraced(function* ({ type, name, input = {}, run_id: suppliedId }) { + const capability = yield* known.capabilityOfType(type); + return yield* runRequest( + capabilities, + capability, + { definition_type: capability.type, name, input }, + suppliedId, + ); + }), + plainLanguage: runPlainLanguage(capabilities), + }), + ); +} diff --git a/packages/definitions/src/operations/run-failures.test.ts b/packages/definitions/src/operations/run-failures.test.ts new file mode 100644 index 000000000..edc0eb3d9 --- /dev/null +++ b/packages/definitions/src/operations/run-failures.test.ts @@ -0,0 +1,217 @@ +import { Schema } from 'effect'; +import { describe, expect, it } from 'vitest'; + +import { acmeAdmin } from '../testing/callers.ts'; +import { definitionOperationsFor } from '../testing/definition-operations.ts'; +import { firstMoment, harness, toBrain } from '../testing/harness.ts'; +import { probe, spentUsage } from '../testing/probe.ts'; + +const toAlpha = toBrain('acme', 'alpha'); + +const runId = '0199a3c4-7d2e-7c1a-9b3f-2f1e0d9c8b7a'; + +const readingTheRun = toAlpha(acmeAdmin, { run_id: runId }); + +async function withPlain() { + const prober = probe(); + const operations = definitionOperationsFor([prober.capability]); + const definitions = harness(); + await definitions.call( + operations.createDefinition, + toAlpha(acmeAdmin, { type: 'probe', name: 'plain', source: 'text' }), + ); + const running = (input: object) => + definitions.call( + operations.runDefinition, + toAlpha(acmeAdmin, { type: 'probe', name: 'plain', run_id: runId, ...input }), + ); + return { ...definitions, ...operations, prober, running }; +} + +const failedRun = { + run_id: runId, + type: 'probe', + name: 'plain', + definition_version: 1, + status: 'failed', + started_at: firstMoment, + started_by: 'acme-admin', + finished_at: firstMoment, +}; + +describe('a run the capability cannot serve now', () => { + it('is rejected with unavailable, and the rejection is recorded', async () => { + const { call, running, getRun, prober } = await withPlain(); + prober.sufferOnNextRun('unavailable'); + + expect(await running({})).toEqual({ + status: 'rejected', + reason: 'unavailable', + detail: 'The probe cannot answer now', + }); + expect(await call(getRun, readingTheRun)).toMatchObject({ + output: { status: 'rejected', rejection: { reason: 'unavailable', detail: 'The probe cannot answer now' } }, + }); + }); +}); + +describe('a run that names a model the server is not set up for, while it can use others', () => { + it('is rejected with unavailable of that kind and why, both recorded and answered again', async () => { + const { call, running, getRun, prober } = await withPlain(); + prober.sufferOnNextRun('unoffered'); + + expect(await running({})).toEqual({ + status: 'rejected', + reason: 'unavailable', + detail: 'The probe cannot reach that model, only others', + kind: 'model_not_offered', + because: 'provider_not_configured', + }); + expect(await call(getRun, readingTheRun)).toMatchObject({ + output: { + status: 'rejected', + rejection: { + reason: 'unavailable', + detail: 'The probe cannot reach that model, only others', + kind: 'model_not_offered', + because: 'provider_not_configured', + }, + }, + }); + }); +}); + +describe('a run of a definition the capability cannot run as written', () => { + it('is rejected with conflict, and the rejection is recorded and answered without a kind, as it was given', async () => { + const { call, running, getRun, prober } = await withPlain(); + prober.sufferOnNextRun('conflict'); + + expect(await running({})).toEqual({ + status: 'rejected', + reason: 'conflict', + detail: 'The probe cannot run this definition as written; update it', + }); + expect(await call(getRun, readingTheRun)).toStrictEqual({ + status: 'succeeded', + output: { + ...failedRun, + status: 'rejected', + rejection: { reason: 'conflict', detail: 'The probe cannot run this definition as written; update it' }, + }, + }); + }); +}); + +describe('a run whose capability finds, by running it, that its definition is unworkable', () => { + it('is rejected with conflict of that kind, which is recorded and answered again', async () => { + const { call, running, getRun, prober } = await withPlain(); + prober.sufferOnNextRun('unworkable'); + const rejection = { + reason: 'conflict', + detail: 'The program of the probe raised an error on line 2: stop', + kind: 'unworkable', + }; + + expect(await running({})).toEqual({ status: 'rejected', ...rejection }); + expect(await call(getRun, readingTheRun)).toStrictEqual({ + status: 'succeeded', + output: { ...failedRun, status: 'rejected', rejection }, + }); + }); +}); + +describe('a run whose capability breaks down', () => { + it('fails with an incident that holds the defect, and is recorded as failed', async () => { + const { call, running, getRun, prober, reported } = await withPlain(); + prober.sufferOnNextRun('breakdown'); + + expect(await running({})).toEqual({ status: 'failed', incident: reported()[0]?.id }); + expect(reported().map(({ original }) => original)).toEqual([new Error('The probe broke down')]); + expect(await call(getRun, readingTheRun)).toStrictEqual({ + status: 'succeeded', + output: failedRun, + }); + }); + + it('fails the same way when the capability answers with output that is not JSON', async () => { + const { call, running, getRun, reported } = await withPlain(); + + expect(await running({ input: { unmeasurable: true } })).toEqual({ + status: 'failed', + incident: reported()[0]?.id, + }); + expect(Schema.isSchemaError(reported()[0]?.original)).toBe(true); + expect(await call(getRun, readingTheRun)).toStrictEqual({ + status: 'succeeded', + output: failedRun, + }); + }); +}); + +describe('a run the capability rejects after it spent something', () => { + it('keeps what the rejection recorded on the run, as get_run shows it', async () => { + const { call, running, getRun, prober } = await withPlain(); + prober.sufferOnNextRun('spent'); + + expect(await running({})).toEqual({ + status: 'rejected', + reason: 'unavailable', + detail: 'The probe was answered, but not usably', + }); + expect(await call(getRun, readingTheRun)).toMatchObject({ + output: { status: 'rejected', record: { usage: spentUsage, duration_ms: 25 } }, + }); + }); + + it('fails with an incident when what it recorded is more than a run may record', async () => { + const { call, running, getRun, prober, reported } = await withPlain(); + prober.sufferOnNextRun('overspent'); + + expect(await running({})).toEqual({ status: 'failed', incident: reported()[0]?.id }); + expect(await call(getRun, readingTheRun)).toStrictEqual({ + status: 'succeeded', + output: failedRun, + }); + }); +}); + +describe('run_definition rejecting', () => { + it('a definition the brain does not have, and a type the server does not run', async () => { + const { call, runDefinition } = await withPlain(); + + expect(await call(runDefinition, toAlpha(acmeAdmin, { type: 'probe', name: 'ghost' }))).toEqual({ + status: 'rejected', + reason: 'not_found', + detail: 'There is no probe definition ghost in this brain', + }); + expect(await call(runDefinition, toAlpha(acmeAdmin, { type: 'echo', name: 'plain' }))).toEqual({ + status: 'rejected', + reason: 'invalid_input', + detail: 'The input does not match the input schema', + issues: [{ pointer: '/type', detail: 'Expected a type this server runs: probe' }], + }); + }); + + it('a definition whose document its capability no longer parses, recording nothing', async () => { + const { running, ledger, prober } = await withPlain(); + prober.rejectEveryDocument(); + + expect(await running({})).toEqual({ + status: 'rejected', + reason: 'conflict', + detail: + 'The probe definition plain at version 1 no longer parses (The probe document has lines it does not accept); update it', + kind: 'unworkable', + }); + expect(ledger.streamNames()).toEqual(['brain/acme/alpha/definitions/probe']); + }); + + it('a run id that is not a UUID', async () => { + const { running } = await withPlain(); + + expect(await running({ run_id: 'run-1' })).toMatchObject({ + reason: 'invalid_input', + issues: [{ pointer: '/run_id', detail: 'Expected a UUID' }], + }); + }); +}); diff --git a/packages/definitions/src/operations/run-idempotency.test.ts b/packages/definitions/src/operations/run-idempotency.test.ts new file mode 100644 index 000000000..5d9a3aa21 --- /dev/null +++ b/packages/definitions/src/operations/run-idempotency.test.ts @@ -0,0 +1,135 @@ +import { Effect } from 'effect'; +import { describe, expect, it } from 'vitest'; + +import { acmeAdmin } from '../testing/callers.ts'; +import { definitionOperationsFor } from '../testing/definition-operations.ts'; +import { echo } from '../testing/echo.ts'; +import { firstMoment, harness, toBrain } from '../testing/harness.ts'; +import { probe } from '../testing/probe.ts'; + +const toAlpha = toBrain('acme', 'alpha'); + +const runId = '0199a3c4-7d2e-7c1a-9b3f-2f1e0d9c8b7a'; + +const later = '2026-10-02T14:15:00.000Z'; + +async function withPlain() { + const prober = probe(); + const operations = definitionOperationsFor([prober.capability, echo]); + const definitions = harness(); + const creating = (name: string) => + definitions.call(operations.createDefinition, toAlpha(acmeAdmin, { type: 'probe', name, source: name })); + await creating('plain'); + await creating('other'); + const runningOf = (type: string, name: string, input: object, at?: string) => + definitions.call(operations.runDefinition, toAlpha(acmeAdmin, { type, name, input, run_id: runId }), at); + const running = (input: object = {}, at?: string) => runningOf('probe', 'plain', input, at); + return { ...definitions, ...operations, prober, running, runningOf }; +} + +describe('a run that succeeded', () => { + it('is answered again for its id without running the capability again', async () => { + const { running, prober } = await withPlain(); + const first = await running({ who: 'Ada' }); + + expect(first).toMatchObject({ status: 'succeeded', output: { run_id: runId } }); + expect(await running({ who: 'Ada' }, later)).toEqual(first); + expect(prober.runs()).toBe(1); + }); + + it('is answered again after its definition changed or was retired', async () => { + const { call, running, prober, retireDefinition, updateDefinition } = await withPlain(); + const first = await running(); + await call(updateDefinition, toAlpha(acmeAdmin, { type: 'probe', name: 'plain', source: 'newer' })); + + expect(await running()).toEqual(first); + await call(retireDefinition, toAlpha(acmeAdmin, { type: 'probe', name: 'plain' })); + expect(await running()).toEqual(first); + expect(prober.runs()).toBe(1); + }); +}); + +describe('a run whose input the capability rejected', () => { + it('is rejected again for its id the same way, without running the capability again', async () => { + const { running, prober } = await withPlain(); + const first = await running({ reject: true }); + + expect(first).toMatchObject({ status: 'rejected', reason: 'invalid_input' }); + expect(await running({ reject: true })).toEqual(first); + expect(prober.runs()).toBe(1); + }); +}); + +describe('a run id', () => { + it('belongs to one definition and one input: another type, definition or input meets conflict', async () => { + const { running, runningOf, prober } = await withPlain(); + await running({ who: 'Ada' }); + const taken = { + status: 'rejected', + reason: 'conflict', + detail: 'The run id belongs to a run of another definition or with another input', + }; + + expect(await runningOf('probe', 'other', { who: 'Ada' })).toEqual(taken); + expect(await runningOf('echo', 'plain', { who: 'Ada' })).toEqual(taken); + expect(await running({ who: 'Bob' })).toEqual(taken); + expect(prober.runs()).toBe(1); + }); + + it('lets one of two calls that start it at the same moment record the start, and the other meets conflict', async () => { + const { dispatch, runDefinition, prober, run } = await withPlain(); + const running = dispatch(runDefinition, toAlpha(acmeAdmin, { type: 'probe', name: 'plain', run_id: runId })); + + expect(await run(Effect.all([running, running], { concurrency: 'unbounded' }))).toMatchObject([ + { status: 'succeeded' }, + { status: 'rejected', reason: 'conflict' }, + ]); + expect(prober.runs()).toBe(1); + }); +}); + +describe('a run without a final result', () => { + it('is recorded failed when its call is cancelled while the capability runs, and runs again for its id', async () => { + const { call, callCancelledWhen, runDefinition, running, getRun, prober } = await withPlain(); + prober.sufferOnNextRun('stall'); + + expect( + await callCancelledWhen( + prober.stalled, + runDefinition, + toAlpha(acmeAdmin, { type: 'probe', name: 'plain', input: {}, run_id: runId }), + ), + ).toEqual({ status: 'cancelled' }); + expect(await call(getRun, toAlpha(acmeAdmin, { run_id: runId }))).toMatchObject({ + output: { status: 'failed', finished_at: firstMoment }, + }); + expect(await running({}, later)).toMatchObject({ + output: { status: 'succeeded', started_at: later, finished_at: later }, + }); + expect(prober.runs()).toBe(2); + }); + + it('runs the updated definition again for its id after the capability found it could not run as written', async () => { + const { call, running, prober, updateDefinition } = await withPlain(); + prober.sufferOnNextRun('conflict'); + + expect(await running()).toMatchObject({ status: 'rejected', reason: 'conflict' }); + await call(updateDefinition, toAlpha(acmeAdmin, { type: 'probe', name: 'plain', source: 'fixed' })); + expect(await running()).toMatchObject({ + status: 'succeeded', + output: { definition_version: 2, status: 'succeeded' }, + }); + expect(prober.runs()).toBe(2); + }); + + it('runs again for its id after the capability was unavailable or broke down', async () => { + const { running, prober } = await withPlain(); + prober.sufferOnNextRun('unavailable'); + await running(); + prober.sufferOnNextRun('breakdown'); + await running(); + + expect(await running()).toMatchObject({ status: 'succeeded', output: { status: 'succeeded' } }); + expect(prober.runs()).toBe(3); + }); +}); diff --git a/packages/definitions/src/operations/run-limits.test.ts b/packages/definitions/src/operations/run-limits.test.ts new file mode 100644 index 000000000..0b8d7a2ac --- /dev/null +++ b/packages/definitions/src/operations/run-limits.test.ts @@ -0,0 +1,97 @@ +import { describe, expect, it } from 'vitest'; + +import { acmeAdmin } from '../testing/callers.ts'; +import { definitionOperationsFor } from '../testing/definition-operations.ts'; +import { harness, toBrain } from '../testing/harness.ts'; +import { probe } from '../testing/probe.ts'; + +const toAlpha = toBrain('acme', 'alpha'); + +const runId = '0199a3c4-7d2e-7c1a-9b3f-2f1e0d9c8b7a'; + +async function withPlain() { + const operations = definitionOperationsFor([probe().capability]); + const definitions = harness(); + await definitions.call( + operations.createDefinition, + toAlpha(acmeAdmin, { type: 'probe', name: 'plain', source: 'text' }), + ); + const running = (input: unknown, id = runId) => + definitions.call(operations.runDefinition, toAlpha(acmeAdmin, { type: 'probe', name: 'plain', input, run_id: id })); + return { ...definitions, ...operations, running }; +} + +const inputTooLarge = { + status: 'rejected', + reason: 'invalid_input', + detail: 'The input does not match the input schema', + issues: [{ detail: 'Expected an input of at most 262144 bytes as JSON in UTF-8', pointer: '/input' }], +}; + +describe('the input of a run', () => { + it('may take 262144 bytes as JSON in UTF-8, whatever its length in characters', async () => { + const { running } = await withPlain(); + + expect(await running('a'.repeat(262_142))).toMatchObject({ status: 'succeeded' }); + expect(await running('é'.repeat(131_071), '0199a3c4-7d2e-7c1a-9b3f-000000000002')).toMatchObject({ + status: 'succeeded', + }); + }); + + it('is rejected above that, before anything is recorded', async () => { + const { running, ledger } = await withPlain(); + + expect(await running('a'.repeat(262_143))).toEqual(inputTooLarge); + expect(await running({ text: 'é'.repeat(131_070) })).toEqual(inputTooLarge); + expect(ledger.streamNames()).toEqual(['brain/acme/alpha/definitions/probe']); + }); +}); + +function nested(levels: number, innermost: unknown = 1): unknown { + return levels === 0 ? innermost : nested(levels - 1, levels % 2 === 0 ? [innermost] : { inner: innermost }); +} + +describe('the nesting of the input of a run', () => { + it('may go 512 levels deep, as deep as a workflow holds a value', async () => { + const { running } = await withPlain(); + + expect(await running(nested(512))).toMatchObject({ status: 'succeeded' }); + }); + + it('is rejected deeper than that, before anything is recorded, however deep it goes', async () => { + const { running, ledger } = await withPlain(); + const tooDeep = { + status: 'rejected', + reason: 'invalid_input', + detail: 'The input does not match the input schema', + issues: [{ detail: 'Expected an input that nests at most 512 levels deep', pointer: '/input' }], + }; + + expect(await running(nested(513))).toEqual(tooDeep); + expect(await running(nested(3000))).toEqual(tooDeep); + expect(ledger.streamNames()).toEqual(['brain/acme/alpha/definitions/probe']); + }); +}); + +describe('the output and the record of a run', () => { + it('may take 1048576 bytes together as JSON in UTF-8', async () => { + const { running } = await withPlain(); + + expect(await running({ bulk: 1_048_572 })).toMatchObject({ + status: 'succeeded', + output: { status: 'succeeded' }, + }); + }); + + it('fail the run above that, as a breakdown of the capability that is recorded', async () => { + const { call, running, getRun, reported } = await withPlain(); + + expect(await running({ bulk: 1_048_573 })).toEqual({ status: 'failed', incident: reported()[0]?.id }); + expect(reported().map(({ original }) => original)).toEqual([ + new Error('The capability answered with 1048577 bytes to record, more than the 1048576 allowed'), + ]); + expect(await call(getRun, toAlpha(acmeAdmin, { run_id: runId }))).toMatchObject({ + output: { status: 'failed' }, + }); + }); +}); diff --git a/packages/definitions/src/operations/run-requests.ts b/packages/definitions/src/operations/run-requests.ts new file mode 100644 index 000000000..0951e18b2 --- /dev/null +++ b/packages/definitions/src/operations/run-requests.ts @@ -0,0 +1,159 @@ +import { BrainContext, BrainReader, CallLineage, Caller, Conflict, type GivenLineage } from '@beonauto/operations'; +import { Cause, Effect, Option } from 'effect'; + +import type { Capability, PreparedDefinition, RunContext, RunLineage } from '../capability/capability.ts'; +import type { RunOutcome, RunRequest, InterruptedAttempt } from '../runs/run-commands.ts'; +import { claimOf, runTaken } from '../runs/run-decisions.ts'; +import { answerOf } from '../runs/run-lookup.ts'; +import { toolCallJournal, type RunJournal } from '../tool-calls/tool-call-journal.ts'; +import { preparedDefinition, preparedVersion, type VersionToRun } from './definition-preparation.ts'; +import { loadRun, loadRunStream, newRunId, recordRun } from './run-access.ts'; +import { attempt, failedAttempt, interruptedAttempt } from './run-attempt.ts'; + +export const mostCallDepth = 8; + +interface JournalledRun extends RunContext { + readonly journal: RunJournal; +} + +interface Called extends GivenLineage { + readonly capabilities: readonly Capability[]; +} + +function finishedBy(id: string, context: JournalledRun, outcome: RunOutcome | InterruptedAttempt) { + return Effect.gen(function* () { + const causationId = outcome.type === 'run_deferred' ? context.lineage.startId : yield* context.journal.latest; + const lineage = { causationId, correlationId: context.lineage.correlationId }; + return yield* recordRun(id, { type: 'finish', result: outcome }, lineage); + }); +} + +interface Prepared { + readonly definition: VersionToRun; + readonly prepared: PreparedDefinition; + readonly createOnly?: true; +} + +function tooDeep(callDepth: number): Conflict { + return new Conflict({ + detail: `This run would sit ${callDepth} calls below the run at the top of its tree, more than the ${mostCallDepth} a run may: workflows that call workflows reach at most ${mostCallDepth} calls deep`, + }); +} + +const longestRunIn = Effect.fnUntraced(function* (capability: Capability, name: string) { + const { prepared } = yield* preparedDefinition(capability, name); + return prepared.longestRunMs; +}); + +const longestRunsIn = Effect.fnUntraced(function* (capabilities: readonly Capability[]) { + const reader = yield* BrainReader; + return (type: string, name: string): Effect.Effect => { + const capability = capabilities.find((candidate) => candidate.type === type); + return capability === undefined + ? Effect.undefined + : longestRunIn(capability, name).pipe( + Effect.option, + Effect.map(Option.getOrUndefined), + Effect.provideService(BrainReader, reader), + ); + }; +}); + +function startOf(request: RunRequest, { definition, prepared, createOnly }: Prepared, given: GivenLineage) { + const { depth, callDepth, calledBy, trigger } = given; + return { + type: 'start' as const, + ...request, + definition_version: definition.version, + calls_tools: prepared.callsTools, + finishes_later: prepared.finishesLater, + depth, + call_depth: callDepth, + ...(calledBy === null ? {} : { called_by: calledBy }), + ...(trigger === null ? {} : { trigger }), + ...(createOnly === true ? { createOnly } : {}), + }; +} + +const contextOf = Effect.fnUntraced(function* ( + id: string, + { definition }: Prepared, + lineage: RunLineage, + given: Called, +) { + const { org, brain } = yield* BrainContext; + const context: JournalledRun = { + id, + org, + brain, + caller: yield* Caller, + definition: { name: definition.name, version: definition.version }, + journal: yield* toolCallJournal(id, lineage), + lineage, + depth: given.depth, + callDepth: given.callDepth, + longestRunOf: yield* longestRunsIn(given.capabilities), + }; + return context; +}); + +const runPrepared = Effect.fnUntraced(function* ( + capabilities: readonly Capability[], + id: string, + request: RunRequest, + run: Prepared, +) { + const given: Called = { ...(yield* CallLineage), capabilities }; + if (given.callDepth > mostCallDepth) { + return yield* tooDeep(given.callDepth); + } + const correlationId = given.lineage?.correlationId ?? id; + const recorded = yield* Effect.uninterruptibleMask((restore) => + Effect.gen(function* () { + const { messageId } = yield* recordRun(id, startOf(request, run, given), { + causationId: given.lineage?.causationId ?? null, + correlationId, + }); + const context = yield* contextOf(id, run, { startId: messageId, correlationId }, given); + const running = run.prepared.run(request.input, context); + const result = yield* attempt(run.prepared.whenCancelled === 'finish' ? running : restore(running)).pipe( + Effect.onError((cause) => + Effect.ignore(finishedBy(id, context, Cause.hasInterruptsOnly(cause) ? interruptedAttempt : failedAttempt)), + ), + ); + return yield* finishedBy(id, context, result); + }), + ); + return yield* answerOf(id, recorded.state); +}); + +export const runRequest = Effect.fnUntraced(function* ( + capabilities: readonly Capability[], + capability: Capability, + request: RunRequest, + suppliedId: string | undefined, +) { + const id = suppliedId ?? (yield* newRunId); + const recorded = yield* loadRunStream(id); + const claim = yield* Effect.fromResult(claimOf(recorded, request)); + return claim === 'answer' + ? yield* answerOf(id, recorded) + : yield* runPrepared(capabilities, id, request, yield* preparedDefinition(capability, request.name)); +}); + +function isTaken(error: unknown): error is Conflict { + return error instanceof Conflict && error.kind === runTaken.kind; +} + +export const startVersionOnce = Effect.fnUntraced(function* ( + capabilities: readonly Capability[], + capability: Capability, + request: RunRequest & { readonly version: number }, + id: string, +) { + const { version, ...asked } = request; + const run = yield* preparedVersion(capability, asked.name, version); + return yield* runPrepared(capabilities, id, asked, { ...run, createOnly: true }).pipe( + Effect.catchIf(isTaken, () => Effect.flatMap(loadRun(id), (recorded) => answerOf(id, recorded))), + ); +}); diff --git a/packages/specs/src/operations/start-version.test.ts b/packages/definitions/src/operations/start-version.test.ts similarity index 55% rename from packages/specs/src/operations/start-version.test.ts rename to packages/definitions/src/operations/start-version.test.ts index e1004a677..350ecb982 100644 --- a/packages/specs/src/operations/start-version.test.ts +++ b/packages/definitions/src/operations/start-version.test.ts @@ -4,12 +4,12 @@ import { describe, expect, it } from 'vitest'; import { defineStartVersion } from '../index.ts'; import { acmeAdmin } from '../testing/callers.ts'; +import { definitionOperationsFor } from '../testing/definition-operations.ts'; import { echo } from '../testing/echo.ts'; import { firstMoment, harness, toBrain } from '../testing/harness.ts'; import { probe } from '../testing/probe.ts'; -import { specOperationsFor } from '../testing/spec-operations.ts'; -const { createSpec, updateSpec, retireSpec, getExecution } = specOperationsFor([echo]); +const { createDefinition, updateDefinition, retireDefinition, getRun } = definitionOperationsFor([echo]); const startVersion = defineStartVersion([echo]); @@ -17,36 +17,39 @@ const toAlpha = toBrain('acme', 'alpha'); const theBrain = brainCallerOf({ org: 'acme', brain: 'alpha' }); -const executionId = '0199a3c4-7d2e-7c1a-9b3f-2f1e0d9c8b7a'; +const runId = '0199a3c4-7d2e-7c1a-9b3f-2f1e0d9c8b7a'; async function greetAtTwoVersions() { - const specs = harness(); - await specs.call( - createSpec, - toAlpha(acmeAdmin, { primitive: 'echo', name: 'greet', source: '{"greeting": "Hello"}' }), + const definitions = harness(); + await definitions.call( + createDefinition, + toAlpha(acmeAdmin, { type: 'echo', name: 'greet', source: '{"greeting": "Hello"}' }), ); - await specs.call(updateSpec, toAlpha(acmeAdmin, { primitive: 'echo', name: 'greet', source: '{"greeting": "Hi"}' })); - return specs; + await definitions.call( + updateDefinition, + toAlpha(acmeAdmin, { type: 'echo', name: 'greet', source: '{"greeting": "Hi"}' }), + ); + return definitions; } -function starting(version: number, input: object = {}, id = executionId) { - return toAlpha(theBrain, { primitive: 'echo', name: 'greet', version, input, execution_id: id }); +function starting(version: number, input: object = {}, id = runId) { + return toAlpha(theBrain, { type: 'echo', name: 'greet', version, input, run_id: id }); } describe('a start of one version of a definition, once', () => { it('runs the version it names, as the brain, with the depth and lineage of its request', async () => { - const specs = await greetAtTwoVersions(); - const lineage = { causationId: '5d0e9f6a-1b2c-5d3e-8f4a-6b7c8d9e0f1a', correlationId: executionId }; + const definitions = await greetAtTwoVersions(); + const lineage = { causationId: '5d0e9f6a-1b2c-5d3e-8f4a-6b7c8d9e0f1a', correlationId: runId }; - const started = await specs.call(startVersion, { ...starting(1, { name: 'Ada' }), lineage, depth: 2 }); + const started = await definitions.call(startVersion, { ...starting(1, { name: 'Ada' }), lineage, depth: 2 }); expect(started).toEqual({ status: 'succeeded', output: { - execution_id: executionId, - primitive: 'echo', + run_id: runId, + type: 'echo', name: 'greet', - spec_version: 1, + definition_version: 1, status: 'succeeded', output: { greeting: 'Hello', input: { name: 'Ada' } }, started_at: firstMoment, @@ -54,23 +57,23 @@ describe('a start of one version of a definition, once', () => { finished_at: firstMoment, }, }); - expect(specs.ledger.streamNames().filter((stream) => stream.endsWith(executionId))).toEqual([ - `brain/acme/alpha/executions/${executionId}`, + expect(definitions.ledger.streamNames().filter((stream) => stream.endsWith(runId))).toEqual([ + `brain/acme/alpha/runs/${runId}`, ]); }); }); describe('a start of a version that a trigger asked for', () => { it('records the trigger on the start and the ending, while the brain stays who started it', async () => { - const specs = await greetAtTwoVersions(); + const definitions = await greetAtTwoVersions(); const trigger = { kind: 'every' as const, reference: '/schedule/every' }; - const started = await specs.call(startVersion, { ...starting(2), trigger }); - const { records } = await specs.run( + const started = await definitions.call(startVersion, { ...starting(2), trigger }); + const { records } = await definitions.run( Effect.orDie( - specs.ledger.service.readRecorded( + definitions.ledger.service.readRecorded( { org: 'acme', brain: 'alpha' }, - { kind: 'run', execution: executionId }, + { kind: 'run', run: runId }, { order: 'asc', limit: 10 }, ), ), @@ -78,35 +81,35 @@ describe('a start of a version that a trigger asked for', () => { expect(started).toMatchObject({ status: 'succeeded', output: { started_by: 'brain:alpha' } }); expect(records.map(({ data }) => data)).toMatchObject([ - { type: 'execution_started', trigger }, - { type: 'execution_succeeded', trigger }, + { type: 'run_started', trigger }, + { type: 'run_succeeded', trigger }, ]); }); }); describe('a start of a version once, under the id of a run', () => { it('answers the run that exists under its id and starts nothing, whatever it is asked', async () => { - const specs = await greetAtTwoVersions(); - await specs.call(startVersion, starting(1)); + const definitions = await greetAtTwoVersions(); + await definitions.call(startVersion, starting(1)); - const again = await specs.call(startVersion, starting(2, { other: true })); - const read = await specs.call(getExecution, toAlpha(acmeAdmin, { execution_id: executionId })); + const again = await definitions.call(startVersion, starting(2, { other: true })); + const read = await definitions.call(getRun, toAlpha(acmeAdmin, { run_id: runId })); expect([again, read]).toMatchObject([ - { status: 'succeeded', output: { spec_version: 1, output: { greeting: 'Hello', input: {} } } }, - { status: 'succeeded', output: { spec_version: 1 } }, + { status: 'succeeded', output: { definition_version: 1, output: { greeting: 'Hello', input: {} } } }, + { status: 'succeeded', output: { definition_version: 1 } }, ]); }); it('starts a version that has since been retired, and is not_found for a version the definition never had', async () => { - const specs = await greetAtTwoVersions(); - await specs.call(retireSpec, toAlpha(acmeAdmin, { primitive: 'echo', name: 'greet' })); + const definitions = await greetAtTwoVersions(); + await definitions.call(retireDefinition, toAlpha(acmeAdmin, { type: 'echo', name: 'greet' })); - const retired = await specs.call(startVersion, starting(2)); - const missing = await specs.call(startVersion, starting(3, {}, '0199a3c4-7d2e-7c1a-9b3f-2f1e0d9c8b7b')); + const retired = await definitions.call(startVersion, starting(2)); + const missing = await definitions.call(startVersion, starting(3, {}, '0199a3c4-7d2e-7c1a-9b3f-2f1e0d9c8b7b')); expect([retired, missing]).toMatchObject([ - { status: 'succeeded', output: { spec_version: 2, output: { greeting: 'Hi' } } }, + { status: 'succeeded', output: { definition_version: 2, output: { greeting: 'Hi' } } }, { status: 'rejected', reason: 'not_found', @@ -118,14 +121,14 @@ describe('a start of a version once, under the id of a run', () => { async function probedOnce() { const prober = probe(); - const { createSpec: creating } = specOperationsFor([prober.primitive]); - const specs = harness(); - await specs.call(creating, toAlpha(acmeAdmin, { primitive: 'probe', name: 'react', source: 'react' })); - const startingProbe = defineStartVersion([prober.primitive]); + const { createDefinition: creating } = definitionOperationsFor([prober.capability]); + const definitions = harness(); + await definitions.call(creating, toAlpha(acmeAdmin, { type: 'probe', name: 'react', source: 'react' })); + const startingProbe = defineStartVersion([prober.capability]); const startOnce = () => - specs.call( + definitions.call( startingProbe, - toAlpha(theBrain, { primitive: 'probe', name: 'react', version: 1, input: {}, execution_id: executionId }), + toAlpha(theBrain, { type: 'probe', name: 'react', version: 1, input: {}, run_id: runId }), ); return { prober, startOnce }; } @@ -163,11 +166,11 @@ describe('the words of a start of a version once', () => { it('name what it starts and why', () => { expect( startVersion.registration.plainLanguage?.attempt({ - primitive: 'echo', + type: 'echo', name: 'greet', version: 1, input: {}, - execution_id: executionId, + run_id: runId, }), ).toBe('start the greeting “greet” once, for one of its triggers'); }); @@ -180,27 +183,27 @@ const triggers = [ describe('the definitions of a brain that start on their own', () => { it('show their triggers, and a read of one names the record its current version was made by', async () => { - const specs = harness(); - const { getSpec, listSpecs } = specOperationsFor([echo]); + const definitions = harness(); + const { getDefinition, listDefinitions } = definitionOperationsFor([echo]); const source = JSON.stringify({ greeting: 'Hello', triggers }); - await specs.call(createSpec, toAlpha(acmeAdmin, { primitive: 'echo', name: 'greet', source })); - await specs.call( - createSpec, - toAlpha(acmeAdmin, { primitive: 'echo', name: 'plain', source: '{"greeting": "Hi"}' }), + await definitions.call(createDefinition, toAlpha(acmeAdmin, { type: 'echo', name: 'greet', source })); + await definitions.call( + createDefinition, + toAlpha(acmeAdmin, { type: 'echo', name: 'plain', source: '{"greeting": "Hi"}' }), ); - const read = await specs.call(getSpec, toAlpha(acmeAdmin, { primitive: 'echo', name: 'greet' })); - const plain = await specs.call(getSpec, toAlpha(acmeAdmin, { primitive: 'echo', name: 'plain' })); - const listed = await specs.call(listSpecs, toAlpha(acmeAdmin, { primitive: 'echo' })); + const read = await definitions.call(getDefinition, toAlpha(acmeAdmin, { type: 'echo', name: 'greet' })); + const plain = await definitions.call(getDefinition, toAlpha(acmeAdmin, { type: 'echo', name: 'plain' })); + const listed = await definitions.call(listDefinitions, toAlpha(acmeAdmin, { type: 'echo' })); expect(read).toMatchObject({ status: 'succeeded', - output: { triggers, triggers_since: messageIdOf('brain/acme/alpha/specs/echo', 1) }, + output: { triggers, triggers_since: messageIdOf('brain/acme/alpha/definitions/echo', 1) }, }); expect(plain).not.toMatchObject({ output: { triggers } }); expect(listed).toMatchObject({ status: 'succeeded', - output: { specs: [{ name: 'greet', triggers }, { name: 'plain' }] }, + output: { definitions: [{ name: 'greet', triggers }, { name: 'plain' }] }, }); }); }); diff --git a/packages/definitions/src/operations/start-version.ts b/packages/definitions/src/operations/start-version.ts new file mode 100644 index 000000000..beceffac7 --- /dev/null +++ b/packages/definitions/src/operations/start-version.ts @@ -0,0 +1,52 @@ +import { defineCommand } from '@beonauto/operations'; +import { Effect, Schema } from 'effect'; + +import type { Capability } from '../capability/capability.ts'; +import { knownCapabilities } from '../capability/known-capabilities.ts'; +import { definitionWordsFor } from '../plain-language/definition-words.ts'; +import { runPlainLanguage } from '../plain-language/run-words.ts'; +import { RunSchema } from '../runs/run.ts'; +import { RunIdInputField, InputField, DefinitionNameField } from './definition-fields.ts'; +import { startVersionOnce } from './run-requests.ts'; + +const description = [ + 'Starts a run of one version of a definition under a run id, once: when the brain has a run under that id,', + 'it answers that run as it stands and starts nothing. The workflow host calls it in the process for the runs', + 'that triggers start, as the brain itself, with their reaction depth, lineage and trigger in the request; no transport serves it.', +].join(' '); + +export function defineStartVersion(capabilities: readonly Capability[]) { + const known = knownCapabilities(capabilities); + const words = runPlainLanguage(capabilities); + const definitionWords = definitionWordsFor(capabilities); + return known.publish( + defineCommand('brain', { + name: 'start_definition_version', + title: 'Start a version once', + description, + route: { method: 'POST', path: '/definitions/{type}/{name}/start-once' }, + inputSchema: Schema.Struct({ + type: known.field, + name: DefinitionNameField, + version: Schema.Int.check(Schema.isGreaterThanOrEqualTo(1)), + input: InputField, + run_id: RunIdInputField, + }), + outputSchema: RunSchema, + reasons: ['not_found', 'conflict', 'invalid_input', 'unavailable', 'cancelled', 'unanswered'], + handle: Effect.fnUntraced(function* ({ type, name, version, input, run_id: id }) { + const capability = yield* known.capabilityOfType(type); + return yield* startVersionOnce( + capabilities, + capability, + { definition_type: capability.type, name, version, input }, + id, + ); + }), + plainLanguage: { + ...words, + attempt: ({ type, name }) => `start ${definitionWords.named(type, name)} once, for one of its triggers`, + }, + }), + ); +} diff --git a/packages/specs/src/operations/update-spec.test.ts b/packages/definitions/src/operations/update-definition.test.ts similarity index 51% rename from packages/specs/src/operations/update-spec.test.ts rename to packages/definitions/src/operations/update-definition.test.ts index 56db39cbe..d6e221f94 100644 --- a/packages/specs/src/operations/update-spec.test.ts +++ b/packages/definitions/src/operations/update-definition.test.ts @@ -2,12 +2,15 @@ import { Effect } from 'effect'; import { describe, expect, it } from 'vitest'; import { acmeAdmin, acmeAlphaKeeper } from '../testing/callers.ts'; +import { definitionOperationsFor } from '../testing/definition-operations.ts'; import { echo } from '../testing/echo.ts'; import { firstMoment, harness, toBrain } from '../testing/harness.ts'; import { probe } from '../testing/probe.ts'; -import { specOperationsFor } from '../testing/spec-operations.ts'; -const { createSpec, getSpec, retireSpec, updateSpec } = specOperationsFor([echo, probe().primitive]); +const { createDefinition, getDefinition, retireDefinition, updateDefinition } = definitionOperationsFor([ + echo, + probe().capability, +]); const toAlpha = toBrain('acme', 'alpha'); @@ -16,35 +19,35 @@ const later = '2026-10-02T14:15:00.000Z'; const hello = '{"greeting": "Hello", "description": "Greets the caller"}'; async function withGreet() { - const specs = harness(); - await specs.call(createSpec, toAlpha(acmeAdmin, { primitive: 'echo', name: 'greet', source: hello })); - return specs; + const definitions = harness(); + await definitions.call(createDefinition, toAlpha(acmeAdmin, { type: 'echo', name: 'greet', source: hello })); + return definitions; } function updatingGreet(source: string) { - return toAlpha(acmeAlphaKeeper, { primitive: 'echo', name: 'greet', source }); + return toAlpha(acmeAlphaKeeper, { type: 'echo', name: 'greet', source }); } -describe('update_spec', () => { - it('is a brain command at PUT /specs/{primitive}/{name}', () => { - expect(updateSpec.registration).toMatchObject({ +describe('update_definition', () => { + it('is a brain command at PUT /definitions/{type}/{name}', () => { + expect(updateDefinition.registration).toMatchObject({ scope: 'brain', kind: 'command', title: 'Update definition', - route: { method: 'PUT', path: '/specs/{primitive}/{name}' }, - pathParameters: ['primitive', 'name'], + route: { method: 'PUT', path: '/definitions/{type}/{name}' }, + pathParameters: ['type', 'name'], successStatus: 200, reasons: ['not_found', 'invalid_input', 'conflict'], }); }); - it('replaces the document and what the primitive says about it, at a new version', async () => { + it('replaces the document and what the capability says about it, at a new version', async () => { const { call } = await withGreet(); - expect(await call(updateSpec, updatingGreet('{"greeting": "Howdy"}'), later)).toStrictEqual({ + expect(await call(updateDefinition, updatingGreet('{"greeting": "Howdy"}'), later)).toStrictEqual({ status: 'succeeded', output: { - primitive: 'echo', + type: 'echo', name: 'greet', version: 2, status: 'active', @@ -61,7 +64,7 @@ describe('update_spec', () => { source: '{"greeting": "Howdy"}', }, }); - expect(await call(updateSpec, updatingGreet(hello), later)).toMatchObject({ + expect(await call(updateDefinition, updatingGreet(hello), later)).toMatchObject({ output: { version: 3, description: 'Greets the caller' }, }); }); @@ -69,55 +72,56 @@ describe('update_spec', () => { it('succeeds and records nothing when the document is the same', async () => { const { call } = await withGreet(); - expect(await call(updateSpec, updatingGreet(hello), later)).toMatchObject({ + expect(await call(updateDefinition, updatingGreet(hello), later)).toMatchObject({ status: 'succeeded', output: { version: 1, updated_at: firstMoment }, }); }); }); -describe('update_spec rejecting', () => { - it('a document its primitive cannot parse, keeping the spec as it was', async () => { +describe('update_definition rejecting', () => { + it('a document its capability cannot parse, keeping the definition as it was', async () => { const { call } = await withGreet(); - expect(await call(updateSpec, updatingGreet('{"greeting": 7}'))).toEqual({ + expect(await call(updateDefinition, updatingGreet('{"greeting": 7}'))).toEqual({ status: 'rejected', reason: 'invalid_input', detail: 'The echo document is not a JSON object with a string greeting', issues: [{ detail: 'Expected string\n at ["greeting"]', pointer: '/source' }], }); - expect(await call(getSpec, toAlpha(acmeAdmin, { primitive: 'echo', name: 'greet' }))).toMatchObject({ + expect(await call(getDefinition, toAlpha(acmeAdmin, { type: 'echo', name: 'greet' }))).toMatchObject({ output: { version: 1, source: hello }, }); }); - it('a spec the brain does not have, a retired spec and a primitive it does not know', async () => { + it('a definition the brain does not have, a retired definition and a type the server does not run', async () => { const { call } = await withGreet(); - await call(retireSpec, toAlpha(acmeAdmin, { primitive: 'echo', name: 'greet' })); + await call(retireDefinition, toAlpha(acmeAdmin, { type: 'echo', name: 'greet' })); - expect(await call(updateSpec, toAlpha(acmeAdmin, { primitive: 'echo', name: 'wave', source: hello }))).toEqual({ + expect(await call(updateDefinition, toAlpha(acmeAdmin, { type: 'echo', name: 'wave', source: hello }))).toEqual({ status: 'rejected', reason: 'not_found', detail: 'There is no echo definition wave in this brain', }); - expect(await call(updateSpec, updatingGreet('{"greeting": "Howdy"}'))).toEqual({ + expect(await call(updateDefinition, updatingGreet('{"greeting": "Howdy"}'))).toEqual({ status: 'rejected', reason: 'conflict', detail: 'The echo definition greet is retired and can no longer change', kind: 'retired', }); - expect(await call(updateSpec, toAlpha(acmeAdmin, { primitive: 'reason', name: 'greet', source: hello }))).toEqual({ + expect(await call(updateDefinition, toAlpha(acmeAdmin, { type: 'reason', name: 'greet', source: hello }))).toEqual({ status: 'rejected', - reason: 'not_found', - detail: 'There is no primitive reason', + reason: 'invalid_input', + detail: 'The input does not match the input schema', + issues: [{ pointer: '/type', detail: 'Expected a type this server runs: echo or probe' }], }); }); - it('with conflict when another change to the specs of the primitive landed at the same moment', async () => { + it('with conflict when another change to the definitions of the capability landed at the same moment', async () => { const { call, dispatch, run } = await withGreet(); - await call(createSpec, toAlpha(acmeAdmin, { primitive: 'echo', name: 'wave', source: hello })); + await call(createDefinition, toAlpha(acmeAdmin, { type: 'echo', name: 'wave', source: hello })); const updating = (name: string) => - dispatch(updateSpec, toAlpha(acmeAdmin, { primitive: 'echo', name, source: '{"greeting": "Yo"}' })); + dispatch(updateDefinition, toAlpha(acmeAdmin, { type: 'echo', name, source: '{"greeting": "Yo"}' })); expect(await run(Effect.all([updating('greet'), updating('wave')], { concurrency: 'unbounded' }))).toMatchObject([ { status: 'succeeded', output: { name: 'greet', version: 2 } }, diff --git a/packages/definitions/src/operations/update-definition.ts b/packages/definitions/src/operations/update-definition.ts new file mode 100644 index 000000000..05e1e7c53 --- /dev/null +++ b/packages/definitions/src/operations/update-definition.ts @@ -0,0 +1,44 @@ +import { defineCommand } from '@beonauto/operations'; +import { Effect, Schema } from 'effect'; + +import type { Capability } from '../capability/capability.ts'; +import { knownCapabilities } from '../capability/known-capabilities.ts'; +import { definitionWordsFor, whatItDoes } from '../plain-language/definition-words.ts'; +import { DefinitionSchema } from '../registry/definition.ts'; +import { SourceField, DefinitionNameField } from './definition-fields.ts'; +import { contentOf, definitionOf } from './definition-views.ts'; +import { recordInRegistry } from './registry-access.ts'; + +export function defineUpdateDefinition(capabilities: readonly Capability[]) { + const known = knownCapabilities(capabilities); + const words = definitionWordsFor(capabilities); + return known.publish( + defineCommand('brain', { + name: 'update_definition', + title: 'Update definition', + description: [ + 'Replaces the whole document of an active function or workflow definition and returns its new version, which every run uses from then on.', + 'Use it when the person changes a saved definition; create_definition saves a new one, and a retired definition cannot change.', + "`type` and `name` say which definition, and `source` is the whole new document in its type's format, which get_guide gives.", + 'A document that does not fit its format is refused with the line and what is wrong, and one the same as the saved document records nothing.', + "A new version of a recall function builds its view again from the brain's history.", + ].join(' '), + repeatable: true, + route: { method: 'PUT', path: '/definitions/{type}/{name}' }, + inputSchema: Schema.Struct({ type: known.field, name: DefinitionNameField, source: SourceField }), + outputSchema: DefinitionSchema, + reasons: ['not_found', 'invalid_input', 'conflict'], + handle: Effect.fnUntraced(function* ({ type, name, source }) { + const capability = yield* known.capabilityOfType(type); + const content = yield* contentOf(capability, source); + return definitionOf(capability, yield* recordInRegistry(capability, { type: 'update', name, content })); + }), + plainLanguage: { + task: `update a ${words.kinds}`, + attempt: ({ type, name }) => `update ${words.named(type, name)}`, + outcome: (definition) => + `Updated ${words.named(definition.type, definition.name)}.${whatItDoes(definition)} The change applies from its next run.`, + }, + }), + ); +} diff --git a/packages/specs/src/plain-language/spec-words.test.ts b/packages/definitions/src/plain-language/definition-words.test.ts similarity index 50% rename from packages/specs/src/plain-language/spec-words.test.ts rename to packages/definitions/src/plain-language/definition-words.test.ts index 95091e6c6..3fce6ccf3 100644 --- a/packages/specs/src/plain-language/spec-words.test.ts +++ b/packages/definitions/src/plain-language/definition-words.test.ts @@ -1,11 +1,11 @@ import type { Registration } from '@beonauto/operations'; import { describe, expect, it } from 'vitest'; -import { makeSpecOperations } from '../index.ts'; +import { makeDefinitionOperations } from '../index.ts'; import { echo } from '../testing/echo.ts'; import { probe } from '../testing/probe.ts'; -const operations = makeSpecOperations([echo, probe().primitive]); +const operations = makeDefinitionOperations([echo, probe().capability]); function registrationOf(name: string): Registration { const found = operations.find(({ registration }) => registration.name === name); @@ -24,7 +24,7 @@ function attemptOf(name: string, input: unknown): string | undefined { } const greet = { - primitive: 'echo', + type: 'echo', name: 'greet', version: 1, status: 'active', @@ -35,10 +35,10 @@ const greet = { updated_at: '2026-10-02T09:00:00.000Z', }; -const greetInput = { primitive: 'echo', name: 'greet', source: '{"greeting":"Hello"}' }; +const greetInput = { type: 'echo', name: 'greet', source: '{"greeting":"Hello"}' }; -function listed(spec: Readonly>): Readonly> { - return Object.fromEntries(Object.entries(spec).filter(([key]: readonly [string, unknown]) => key !== 'source')); +function listed(definition: Readonly>): Readonly> { + return Object.fromEntries(Object.entries(definition).filter(([key]: readonly [string, unknown]) => key !== 'source')); } const describing: ReadonlyArray>, string]> = [ @@ -56,50 +56,50 @@ const describing: ReadonlyArray { - it.each(describing)('says what the new spec does from %s', (_case, content, words) => { - expect(outcomeOf('create_spec', { ...greet, ...content }, greetInput)).toBe( +describe('the plain language of create_definition', () => { + it.each(describing)('says what the new definition does from %s', (_case, content, words) => { + expect(outcomeOf('create_definition', { ...greet, ...content }, greetInput)).toBe( `Created the greeting “greet”.${words} It has been saved but has not been run yet.`, ); }); - it('names the spec it tried to create in the words of its primitive, or what it tried', () => { + it('names the definition it tried to create in the words of its capability, or what it tried for a type the server does not run', () => { expect([ - attemptOf('create_spec', greetInput), - attemptOf('create_spec', { ...greetInput, primitive: 'nowhere' }), - attemptOf('create_spec', { name: 'greet' }), - ]).toEqual(['create the greeting “greet”', 'create the item “greet”', 'create a new greeting or probe']); + attemptOf('create_definition', greetInput), + attemptOf('create_definition', { ...greetInput, type: 'nowhere' }), + attemptOf('create_definition', { name: 'greet' }), + ]).toEqual(['create the greeting “greet”', 'create a new greeting or probe', 'create a new greeting or probe']); }); }); -describe('the plain language of update_spec', () => { +describe('the plain language of update_definition', () => { it('says the change applies from the next run', () => { - expect(outcomeOf('update_spec', { ...greet, version: 2, description: 'Greets' }, greetInput)).toBe( + expect(outcomeOf('update_definition', { ...greet, version: 2, description: 'Greets' }, greetInput)).toBe( 'Updated the greeting “greet”. What it does: Greets. The change applies from its next run.', ); }); - it('names the spec it tried to update', () => { - expect([attemptOf('update_spec', greetInput), attemptOf('update_spec', {})]).toEqual([ + it('names the definition it tried to update', () => { + expect([attemptOf('update_definition', greetInput), attemptOf('update_definition', {})]).toEqual([ 'update the greeting “greet”', 'update a greeting or probe', ]); }); }); -describe('the plain language of get_spec', () => { - it('says whether the spec is in use and what it does', () => { +describe('the plain language of get_definition', () => { + it('says whether the definition is in use and what it does', () => { expect([ - outcomeOf('get_spec', { ...greet, description: 'Greets' }, { primitive: 'echo', name: 'greet' }), - outcomeOf('get_spec', { ...greet, status: 'retired' }, { primitive: 'echo', name: 'greet' }), + outcomeOf('get_definition', { ...greet, description: 'Greets' }, { type: 'echo', name: 'greet' }), + outcomeOf('get_definition', { ...greet, status: 'retired' }, { type: 'echo', name: 'greet' }), ]).toEqual([ 'The greeting “greet” is in use. What it does: Greets.', 'The greeting “greet” has been retired; it can no longer be run or changed.', ]); }); - it('names the spec it looked for', () => { - expect([attemptOf('get_spec', { primitive: 'probe', name: 'plain' }), attemptOf('get_spec', {})]).toEqual([ + it('names the definition it looked for', () => { + expect([attemptOf('get_definition', { type: 'probe', name: 'plain' }), attemptOf('get_definition', {})]).toEqual([ 'look up the probe “plain”', 'look up a greeting or probe', ]); @@ -110,7 +110,7 @@ function greeting(name: string, status = 'active'): Readonly = [ +const definitionListings: ReadonlyArray = [ [[], 'This brain has no greetings in use yet.'], [[greeting('greet')], 'This brain has 1 greeting: “greet”.'], [[greeting('greet'), greeting('wave')], 'This brain has 2 greetings: “greet” and “wave”.'], @@ -120,36 +120,36 @@ const specListings: ReadonlyArray = [ ], ]; -describe('the plain language of list_specs', () => { - it.each(specListings)('describes %j', (specs, words) => { - expect(outcomeOf('list_specs', { specs }, { primitive: 'echo' })).toBe(words); +describe('the plain language of list_definitions', () => { + it.each(definitionListings)('describes %j', (definitions, words) => { + expect(outcomeOf('list_definitions', { definitions }, { type: 'echo' })).toBe(words); }); - it('names at most twenty specs and counts the rest', () => { - const specs = Array.from({ length: 21 }, (_, index) => greeting(`greet-${index}`)); + it('names at most twenty definitions and counts the rest', () => { + const definitions = Array.from({ length: 21 }, (_, index) => greeting(`greet-${index}`)); - expect(outcomeOf('list_specs', { specs }, { primitive: 'echo' })).toMatch(/“greet-19”, and 1 more\.$/u); + expect(outcomeOf('list_definitions', { definitions }, { type: 'echo' })).toMatch(/“greet-19”, and 1 more\.$/u); }); - it('names the kind of spec it tried to list', () => { - expect([attemptOf('list_specs', { primitive: 'probe' }), attemptOf('list_specs', {})]).toEqual([ + it('names the kind of definition it tried to list', () => { + expect([attemptOf('list_definitions', { type: 'probe' }), attemptOf('list_definitions', {})]).toEqual([ 'list the probes', 'list the greetings and probes', ]); }); }); -describe('the plain language of retire_spec', () => { - it('says the spec can no longer be run or changed, nor its name used again', () => { - expect(outcomeOf('retire_spec', { ...greet, status: 'retired' }, { primitive: 'echo', name: 'greet' })).toBe( +describe('the plain language of retire_definition', () => { + it('says the definition can no longer be run or changed, nor its name used again', () => { + expect(outcomeOf('retire_definition', { ...greet, status: 'retired' }, { type: 'echo', name: 'greet' })).toBe( 'Retired the greeting “greet”. It can no longer be run or changed, and its name cannot be used again in this brain.', ); }); - it('names the spec it tried to retire', () => { - expect([attemptOf('retire_spec', { primitive: 'echo', name: 'greet' }), attemptOf('retire_spec', {})]).toEqual([ - 'retire the greeting “greet”', - 'retire a greeting or probe', - ]); + it('names the definition it tried to retire', () => { + expect([ + attemptOf('retire_definition', { type: 'echo', name: 'greet' }), + attemptOf('retire_definition', {}), + ]).toEqual(['retire the greeting “greet”', 'retire a greeting or probe']); }); }); diff --git a/packages/definitions/src/plain-language/definition-words.ts b/packages/definitions/src/plain-language/definition-words.ts new file mode 100644 index 000000000..e9711fa6f --- /dev/null +++ b/packages/definitions/src/plain-language/definition-words.ts @@ -0,0 +1,87 @@ +import { alternatives, asSentence, capitalized, counted, listed, quoted, type Noun } from '@beonauto/operations'; +import { Option, Schema } from 'effect'; + +import { defaultRunWords, type Capability, type RunWords } from '../capability/capability.ts'; +import type { ListedDefinition } from '../registry/definition.ts'; +import { wordsOf } from './in-words.ts'; + +const mostNamed = 20; + +const propertiesOf = Schema.decodeUnknownOption( + Schema.Struct({ properties: Schema.Record(Schema.String, Schema.Unknown) }), +); + +export interface DefinitionWords { + readonly kinds: string; + readonly allKinds: string; + readonly nounOf: (type: string) => Noun; + readonly named: (type: string, name: string) => string; + readonly runWordsOf: (type: string) => RunWords; + readonly deferralTypes: readonly string[]; +} + +const unknownNoun: Noun = { one: 'item', other: 'items' }; + +export function definitionWordsFor(capabilities: readonly Capability[]): DefinitionWords { + const nounOf = (name: string) => capabilities.find((capability) => capability.type === name)?.noun ?? unknownNoun; + return { + kinds: alternatives(capabilities.map(({ noun }) => noun.one)), + allKinds: listed(capabilities.map(({ noun }) => noun.other)), + nounOf, + named: (type, name) => `the ${nounOf(type).one} ${quoted(name)}`, + runWordsOf: (name) => capabilities.find((capability) => capability.type === name)?.runWords ?? defaultRunWords, + deferralTypes: [ + ...new Set([defaultRunWords.deferralType, ...capabilities.map(({ runWords }) => runWords.deferralType)]), + ], + }; +} + +function propertyWordsOf(schema: Schema.JsonObject | undefined): readonly string[] { + return Option.match(propertiesOf(schema), { + onNone: () => [], + onSome: ({ properties }) => Object.keys(properties).map((name) => wordsOf(name)), + }); +} + +function takesAndGives(inputs: readonly string[], outputs: readonly string[]): string { + const gives = outputs.length === 0 ? '' : `gives back ${listed(outputs)}`; + if (inputs.length === 0) { + return gives === '' ? '' : ` It ${gives}.`; + } + return ` It takes ${listed(inputs)}${gives === '' ? '' : `, and ${gives}`}.`; +} + +export function whatItDoes( + definition: Pick, +): string { + return definition.description === undefined + ? takesAndGives(propertyWordsOf(definition.input_schema), propertyWordsOf(definition.output_schema)) + : ` What it does: ${asSentence(definition.description)}`; +} + +export function definitionStanding( + words: DefinitionWords, + { type, name, status }: Pick, +): string { + const named = capitalized(words.named(type, name)); + return status === 'active' ? `${named} is in use.` : `${named} has been retired; it can no longer be run or changed.`; +} + +function namesOf(definitions: readonly ListedDefinition[]): string { + const named = definitions.slice(0, mostNamed).map(({ name }) => quoted(name)); + const others = definitions.length - named.length; + return listed(others === 0 ? named : [...named, `${others} more`]); +} + +export function definitionsListed(noun: Noun, definitions: readonly ListedDefinition[]): string { + const active = definitions.filter(({ status }) => status === 'active'); + const retired = definitions.filter(({ status }) => status === 'retired'); + const inUse = + active.length === 0 + ? `This brain has no ${noun.other} in use yet.` + : `This brain has ${counted(active.length, noun)}: ${namesOf(active)}.`; + const retiredNoun = { one: `retired ${noun.one}`, other: `retired ${noun.other}` }; + return retired.length === 0 + ? inUse + : `${inUse} Also listed, ${counted(retired.length, retiredNoun)}: ${namesOf(retired)}.`; +} diff --git a/packages/specs/src/plain-language/event-words.ts b/packages/definitions/src/plain-language/event-words.ts similarity index 65% rename from packages/specs/src/plain-language/event-words.ts rename to packages/definitions/src/plain-language/event-words.ts index 29257faff..1340ffe41 100644 --- a/packages/specs/src/plain-language/event-words.ts +++ b/packages/definitions/src/plain-language/event-words.ts @@ -1,12 +1,12 @@ import { answeredInWords } from '@beonauto/mcp'; import { capitalized, explanationOf, plainNumber, quoted } from '@beonauto/operations'; -import type { CancelRequestKind, ToolCallAnswered } from '../execution/execution-events.ts'; -import type { ExecutionRejection } from '../execution/execution.ts'; -import type { StartingTrigger } from '../registry/spec-triggers.ts'; +import type { StartingTrigger } from '../registry/definition-triggers.ts'; +import type { CancelRequestKind, ToolCallAnswered } from '../runs/run-events.ts'; +import type { RunRejection } from '../runs/run.ts'; +import type { DefinitionWords } from './definition-words.ts'; import { wordsOf } from './in-words.ts'; import { explainedRejectionOf } from './run-words.ts'; -import type { SpecWords } from './spec-words.ts'; export const runFinished = 'A run finished.'; @@ -32,26 +32,26 @@ export function triggerNamed(kind: StartingTrigger['kind']): string { return triggerNames[kind]; } -export function runStarted(words: SpecWords, primitive: string, name: string, trigger?: StartingTrigger): string { +export function runStarted(words: DefinitionWords, type: string, name: string, trigger?: StartingTrigger): string { return trigger === undefined - ? `A run of ${words.named(primitive, name)} started.` - : `A run of ${words.named(primitive, name)} was started by its ${triggerNamed(trigger.kind)}.`; + ? `A run of ${words.named(type, name)} started.` + : `A run of ${words.named(type, name)} was started by its ${triggerNamed(trigger.kind)}.`; } -export function runRejected(rejection: ExecutionRejection): string { +export function runRejected(rejection: RunRejection): string { return `A run did not go through: ${explanationOf(explainedRejectionOf(rejection)).why}.`; } -export function specCreated(words: SpecWords, primitive: string, name: string): string { - return `${capitalized(words.named(primitive, name))} was created.`; +export function definitionCreated(words: DefinitionWords, type: string, name: string): string { + return `${capitalized(words.named(type, name))} was created.`; } -export function specUpdated(words: SpecWords, primitive: string, name: string, version: number): string { - return `${capitalized(words.named(primitive, name))} was updated to version ${plainNumber(version)}.`; +export function definitionUpdated(words: DefinitionWords, type: string, name: string, version: number): string { + return `${capitalized(words.named(type, name))} was updated to version ${plainNumber(version)}.`; } -export function specRetired(words: SpecWords, primitive: string, name: string): string { - return `${capitalized(words.named(primitive, name))} was retired.`; +export function definitionRetired(words: DefinitionWords, type: string, name: string): string { + return `${capitalized(words.named(type, name))} was retired.`; } const notLettersOrDigits = /[^A-Za-z0-9]+/gu; diff --git a/packages/specs/src/plain-language/in-words.test.ts b/packages/definitions/src/plain-language/in-words.test.ts similarity index 100% rename from packages/specs/src/plain-language/in-words.test.ts rename to packages/definitions/src/plain-language/in-words.test.ts diff --git a/packages/specs/src/plain-language/in-words.ts b/packages/definitions/src/plain-language/in-words.ts similarity index 100% rename from packages/specs/src/plain-language/in-words.ts rename to packages/definitions/src/plain-language/in-words.ts diff --git a/packages/specs/src/plain-language/reading-words.test.ts b/packages/definitions/src/plain-language/reading-words.test.ts similarity index 68% rename from packages/specs/src/plain-language/reading-words.test.ts rename to packages/definitions/src/plain-language/reading-words.test.ts index a5d07e7f8..a41172a44 100644 --- a/packages/specs/src/plain-language/reading-words.test.ts +++ b/packages/definitions/src/plain-language/reading-words.test.ts @@ -1,27 +1,27 @@ import { describe, expect, it } from 'vitest'; +import { definitionOperationsFor } from '../testing/definition-operations.ts'; import { echo } from '../testing/echo.ts'; -import { specOperationsFor } from '../testing/spec-operations.ts'; -const { listExecutions, getExecutionHistory } = specOperationsFor([echo]); +const { listRuns, getRunHistory } = definitionOperationsFor([echo]); -const executionId = '0199a3c4-7d2e-7c1a-9b3f-2f1e0d9c8b7a'; +const runId = '0199a3c4-7d2e-7c1a-9b3f-2f1e0d9c8b7a'; function run(status: string): Readonly> { return { - execution_id: executionId, - primitive: 'echo', + run_id: runId, + type: 'echo', name: 'greet', - spec_version: 1, + definition_version: 1, status, started_at: '2026-10-01T09:00:00.000Z', started_by: 'acme-admin', }; } -function listed(executions: readonly unknown[], filters: object = {}, hasMore = false): string | undefined { - return listExecutions.registration.plainLanguage?.outcome( - { executions, has_more: hasMore, next_cursor: hasMore ? 'WyJicmFpbiJd' : null }, +function listed(runs: readonly unknown[], filters: object = {}, hasMore = false): string | undefined { + return listRuns.registration.plainLanguage?.outcome( + { runs, has_more: hasMore, next_cursor: hasMore ? 'WyJicmFpbiJd' : null }, filters, ); } @@ -31,19 +31,19 @@ const event = { cursor: 'WyJicmFpbiJd', causation_id: null, at: '2026-10-01T09:00:00.000Z', - type: 'execution_started', + type: 'run_started', summary: 'A run started.', data: {}, }; function found(events: readonly unknown[], input: object = {}, hasMore = false): string | undefined { - return getExecutionHistory.registration.plainLanguage?.outcome( + return getRunHistory.registration.plainLanguage?.outcome( { events, has_more: hasMore, next_cursor: hasMore ? 'WyJicmFpbiJd' : null }, - { execution_id: executionId, ...input }, + { run_id: runId, ...input }, ); } -describe('the plain language of list_executions', () => { +describe('the plain language of list_runs', () => { it('says how many runs it listed and how they ended', () => { expect([ listed([run('started'), run('succeeded'), run('rejected')]), @@ -65,9 +65,9 @@ describe('the plain language of list_executions', () => { it('says there are none, none more, or none on this page with more to look through', () => { expect([ listed([]), - listed([], { primitive: 'echo' }), + listed([], { type: 'echo' }), listed([], { cursor: 'WyJicmFpbiJd', status: 'started' }), - listed([], { primitive: 'echo', name: 'greet', status: 'succeeded' }, true), + listed([], { type: 'echo', name: 'greet', status: 'succeeded' }, true), ]).toEqual([ 'This brain has no runs yet.', 'This brain has no runs of greetings.', @@ -78,14 +78,14 @@ describe('the plain language of list_executions', () => { it('names the runs it tried to list', () => { expect([ - listExecutions.registration.plainLanguage?.attempt({}), - listExecutions.registration.plainLanguage?.attempt({ primitive: 'echo', status: 'started' }), - listExecutions.registration.plainLanguage?.attempt({ status: 'sideways' }), + listRuns.registration.plainLanguage?.attempt({}), + listRuns.registration.plainLanguage?.attempt({ type: 'echo', status: 'started' }), + listRuns.registration.plainLanguage?.attempt({ status: 'sideways' }), ]).toEqual(['list the runs', 'list the runs of greetings that are still running', 'list the runs']); }); }); -describe('the plain language of get_execution_history', () => { +describe('the plain language of get_run_history', () => { it('says how many events of the run it found, in which order', () => { expect([found([event, event]), found([event], { order: 'desc' }, true)]).toEqual([ 'Found 2 events in the history of the run, oldest first.', @@ -103,8 +103,8 @@ describe('the plain language of get_execution_history', () => { it('names what it tried', () => { expect([ - getExecutionHistory.registration.plainLanguage?.attempt({ execution_id: executionId }), - getExecutionHistory.registration.plainLanguage?.attempt({}), + getRunHistory.registration.plainLanguage?.attempt({ run_id: runId }), + getRunHistory.registration.plainLanguage?.attempt({}), ]).toEqual(['read the history of the run', 'read the history of a run']); }); }); diff --git a/packages/specs/src/plain-language/reading-words.ts b/packages/definitions/src/plain-language/reading-words.ts similarity index 66% rename from packages/specs/src/plain-language/reading-words.ts rename to packages/definitions/src/plain-language/reading-words.ts index ae0d17b55..53ed833b5 100644 --- a/packages/specs/src/plain-language/reading-words.ts +++ b/packages/definitions/src/plain-language/reading-words.ts @@ -1,12 +1,12 @@ import { counted, listed, plainNumber, quoted, type Noun, type RecordedOrder } from '@beonauto/operations'; -import type { ExecutionStatus } from '../reading/execution-status.ts'; -import type { SpecWords } from './spec-words.ts'; +import type { RunStatus } from '../reading/run-status.ts'; +import type { DefinitionWords } from './definition-words.ts'; export interface RunFilters { - readonly primitive?: string; + readonly type?: string; readonly name?: string; - readonly status?: ExecutionStatus; + readonly status?: RunStatus; readonly cursor?: string; } @@ -15,7 +15,7 @@ interface Page { } interface ListedRuns extends Page { - readonly executions: readonly { readonly status: ExecutionStatus }[]; + readonly runs: readonly { readonly status: RunStatus }[]; } interface History extends Page { @@ -33,7 +33,7 @@ const eventNoun: Noun = { one: 'event', other: 'events' }; const moreRemain = ' More remain after these.'; -export const endingsInWords: Readonly> = { +export const endingsInWords: Readonly> = { started: 'still running', succeeded: 'finished', rejected: 'did not go through', @@ -42,26 +42,26 @@ export const endingsInWords: Readonly> = { const orderInWords: Readonly> = { asc: 'oldest first', desc: 'newest first' }; -export function runsOfWhat(words: SpecWords, { primitive, name }: RunFilters): string { - if (primitive === undefined) { +export function runsOfWhat(words: DefinitionWords, { type, name }: RunFilters): string { + if (type === undefined) { return name === undefined ? '' : ` of anything named ${quoted(name)}`; } - return name === undefined ? ` of ${words.nounOf(primitive).other}` : ` of ${words.named(primitive, name)}`; + return name === undefined ? ` of ${words.nounOf(type).other}` : ` of ${words.named(type, name)}`; } -function filtersInWords(words: SpecWords, filters: RunFilters): string { +function filtersInWords(words: DefinitionWords, filters: RunFilters): string { const { status } = filters; const endedSo = status === undefined ? '' : ` that ${status === 'started' ? 'are ' : ''}${endingsInWords[status]}`; return `${runsOfWhat(words, filters)}${endedSo}`; } -export function runsToList(words: SpecWords, filters: RunFilters): string { +export function runsToList(words: DefinitionWords, filters: RunFilters): string { return `list the runs${filtersInWords(words, filters)}`; } -function howTheyEnded(executions: ListedRuns['executions']): string { +function howTheyEnded(runs: ListedRuns['runs']): string { const endings = Object.entries(endingsInWords).flatMap(([status, ending]: readonly [string, string]) => { - const count = executions.filter((execution) => execution.status === status).length; + const count = runs.filter((run) => run.status === status).length; return count === 0 ? [] : [`${plainNumber(count)} ${ending}`]; }); return `: ${listed(endings)}`; @@ -77,14 +77,14 @@ function noRuns(described: string, { has_more }: Page, { cursor }: RunFilters): return described === '' ? 'This brain has no runs yet.' : `This brain has no runs${described}.`; } -export function runsListed(words: SpecWords, page: ListedRuns, filters: RunFilters): string { +export function runsListed(words: DefinitionWords, page: ListedRuns, filters: RunFilters): string { const described = filtersInWords(words, filters); - const { executions, has_more: hasMore } = page; - if (executions.length === 0) { + const { runs, has_more: hasMore } = page; + if (runs.length === 0) { return noRuns(described, page, filters); } - const endings = filters.status === undefined ? howTheyEnded(executions) : ''; - return `Listed ${counted(executions.length, runNoun)}${described}, newest first${endings}.${hasMore ? moreRemain : ''}`; + const endings = filters.status === undefined ? howTheyEnded(runs) : ''; + return `Listed ${counted(runs.length, runNoun)}${described}, newest first${endings}.${hasMore ? moreRemain : ''}`; } export function historyFound( diff --git a/packages/specs/src/plain-language/run-words.test.ts b/packages/definitions/src/plain-language/run-words.test.ts similarity index 78% rename from packages/specs/src/plain-language/run-words.test.ts rename to packages/definitions/src/plain-language/run-words.test.ts index bfe1a3404..0fc9ccb93 100644 --- a/packages/specs/src/plain-language/run-words.test.ts +++ b/packages/definitions/src/plain-language/run-words.test.ts @@ -1,14 +1,14 @@ import type { Registration } from '@beonauto/operations'; import { describe, expect, it } from 'vitest'; -import { makeSpecOperations } from '../index.ts'; +import { makeDefinitionOperations } from '../index.ts'; import { echo } from '../testing/echo.ts'; import { probe } from '../testing/probe.ts'; import { relay } from '../testing/relay.ts'; -const relaying = relay().primitive; +const relaying = relay().capability; -const operations = makeSpecOperations([echo, probe().primitive, relaying]); +const operations = makeDefinitionOperations([echo, probe().capability, relaying]); function registrationOf(name: string): Registration { const found = operations.find(({ registration }) => registration.name === name); @@ -18,13 +18,13 @@ function registrationOf(name: string): Registration { return found.registration; } -const executionId = '0199a3c4-7d2e-7c1a-9b3f-2f1e0d9c8b7a'; +const runId = '0199a3c4-7d2e-7c1a-9b3f-2f1e0d9c8b7a'; const run = { - execution_id: executionId, - primitive: 'echo', + run_id: runId, + type: 'echo', name: 'greet', - spec_version: 1, + definition_version: 1, status: 'succeeded', output: { greeting: 'Hello' }, started_at: '2026-10-02T09:00:00.000Z', @@ -32,35 +32,35 @@ const run = { finished_at: '2026-10-02T09:00:01.000Z', }; -const executing = { primitive: 'echo', name: 'greet' }; +const running = { type: 'echo', name: 'greet' }; -function executed(execution: Readonly>): string | undefined { - return registrationOf('execute_spec').plainLanguage?.outcome(execution, executing); +function ran(answer: Readonly>): string | undefined { + return registrationOf('run_definition').plainLanguage?.outcome(answer, running); } -function lookedUp(execution: Readonly>): string | undefined { - return registrationOf('get_execution').plainLanguage?.outcome(execution, { execution_id: executionId }); +function lookedUp(answer: Readonly>): string | undefined { + return registrationOf('get_run').plainLanguage?.outcome(answer, { run_id: runId }); } const withoutOutput = Object.fromEntries( Object.entries(run).filter(([key]: readonly [string, unknown]) => key !== 'output'), ); -describe('the plain language of execute_spec', () => { - it('says the spec ran and what came back, in the words of its primitive', () => { - expect(executed(run)).toBe('Ran the greeting “greet”. It answered with its greeting.'); +describe('the plain language of run_definition', () => { + it('says the definition ran and what came back, in the words of its capability', () => { + expect(ran(run)).toBe('Ran the greeting “greet”. It answered with its greeting.'); }); it('says a run that goes on after the call has started and carries on', () => { - expect(executed({ ...withoutOutput, primitive: 'relay', name: 'pass', status: 'started' })).toBe( + expect(ran({ ...withoutOutput, type: 'relay', name: 'pass', status: 'started' })).toBe( 'The relay “pass” has started and is still running. It carries on by itself, and how it ends can be looked up later.', ); }); - it('names the spec it tried to run, or what it tried', () => { + it('names the definition it tried to run, or what it tried', () => { expect([ - registrationOf('execute_spec').plainLanguage?.attempt(executing), - registrationOf('execute_spec').plainLanguage?.attempt({}), + registrationOf('run_definition').plainLanguage?.attempt(running), + registrationOf('run_definition').plainLanguage?.attempt({}), ]).toEqual(['run the greeting “greet”', 'run a greeting, probe, or relay']); }); }); @@ -92,7 +92,7 @@ const rejections: ReadonlyArray { +describe('the plain language of get_run', () => { it('says how a run ended and what came back', () => { expect(lookedUp(run)).toBe('The run of the greeting “greet” finished. It answered with its greeting.'); }); @@ -143,7 +143,7 @@ describe('the plain language of get_execution', () => { it('points to the details for a result it has no words for', () => { expect([ - lookedUp({ ...run, primitive: 'gone' }), + lookedUp({ ...run, type: 'gone' }), lookedUp(withoutOutput), lookedUp({ ...withoutOutput, status: 'rejected' }), ]).toEqual([ @@ -155,15 +155,15 @@ describe('the plain language of get_execution', () => { it('says what it tried', () => { expect([ - registrationOf('get_execution').plainLanguage?.attempt({ execution_id: executionId }), - registrationOf('get_execution').plainLanguage?.attempt({}), + registrationOf('get_run').plainLanguage?.attempt({ run_id: runId }), + registrationOf('get_run').plainLanguage?.attempt({}), ]).toEqual(['look up the run', 'look up a run']); }); }); -describe('the words of the test primitives for what came back', () => { +describe('the words of the test capabilities for what came back', () => { it('are a sentence each', () => { - expect([probe().primitive.describeOutput(null), relaying.describeOutput(null)]).toEqual([ + expect([probe().capability.describeOutput(null), relaying.describeOutput(null)]).toEqual([ 'It answered.', 'It handed its input on.', ]); diff --git a/packages/specs/src/plain-language/run-words.ts b/packages/definitions/src/plain-language/run-words.ts similarity index 53% rename from packages/specs/src/plain-language/run-words.ts rename to packages/definitions/src/plain-language/run-words.ts index a32dfab9f..580a3116e 100644 --- a/packages/specs/src/plain-language/run-words.ts +++ b/packages/definitions/src/plain-language/run-words.ts @@ -1,31 +1,31 @@ import { capitalized, explanationOf, type ExplainedRejection, type PlainLanguage } from '@beonauto/operations'; -import type { Run } from '../execution/execution.ts'; -import type { Primitive } from '../primitive/primitive.ts'; -import { specWordsFor } from './spec-words.ts'; +import type { Capability } from '../capability/capability.ts'; +import type { Run } from '../runs/run.ts'; +import { definitionWordsFor } from './definition-words.ts'; export type RunMoment = 'just started' | 'looked up'; -type DescribedExecution = Pick; +type DescribedRun = Pick; interface RunContext { readonly named: string; - readonly primitive: Primitive | undefined; + readonly capability: Capability | undefined; readonly moment: RunMoment; } -interface SpecAddress { - readonly primitive: string; +interface DefinitionAddress { + readonly type: string; readonly name: string; } const outputBeyondWords = 'Its result is in the details below.'; -function describedOutput(primitive: Primitive | undefined, { output }: DescribedExecution): string { - return primitive === undefined || output === undefined ? outputBeyondWords : primitive.describeOutput(output); +function describedOutput(capability: Capability | undefined, { output }: DescribedRun): string { + return capability === undefined || output === undefined ? outputBeyondWords : capability.describeOutput(output); } -export function explainedRejectionOf(rejection: DescribedExecution['rejection']): ExplainedRejection { +export function explainedRejectionOf(rejection: DescribedRun['rejection']): ExplainedRejection { if (rejection === undefined) { return { reason: 'conflict', kind: 'unworkable' }; } @@ -43,40 +43,38 @@ export function explainedRejectionOf(rejection: DescribedExecution['rejection']) return because === undefined ? { reason: 'unavailable', kind } : { reason: 'unavailable', kind, because }; } -function rejectionWords({ named }: RunContext, { rejection }: DescribedExecution): string { +function rejectionWords({ named }: RunContext, { rejection }: DescribedRun): string { const { why, remedy } = explanationOf(explainedRejectionOf(rejection)); return `The run of ${named} did not go through: ${why}. ${remedy}`; } -const wordsByStatus: Readonly string>> = { +const wordsByStatus: Readonly string>> = { started: ({ named, moment }) => moment === 'just started' ? `${capitalized(named)} has started and is still running. It carries on by itself, and how it ends can be looked up later.` : `${capitalized(named)} is still running; how it ends can be looked up again later.`, - succeeded: ({ named, primitive, moment }, execution) => - `${moment === 'just started' ? `Ran ${named}.` : `The run of ${named} finished.`} ${describedOutput(primitive, execution)}`, + succeeded: ({ named, capability, moment }, run) => + `${moment === 'just started' ? `Ran ${named}.` : `The run of ${named} finished.`} ${describedOutput(capability, run)}`, rejected: rejectionWords, failed: ({ named }) => `The run of ${named} broke down because of a problem inside the server; it was not caused by anything you did.`, }; -export function runWordsFor( - primitives: readonly Primitive[], -): (execution: DescribedExecution, moment: RunMoment) => string { - const words = specWordsFor(primitives); - return (execution, moment) => { - const named = words.named(execution.primitive, execution.name); - const primitive = primitives.find(({ name }) => name === execution.primitive); - return wordsByStatus[execution.status]({ named, primitive, moment }, execution); +export function runWordsFor(capabilities: readonly Capability[]): (run: DescribedRun, moment: RunMoment) => string { + const words = definitionWordsFor(capabilities); + return (run, moment) => { + const named = words.named(run.type, run.name); + const capability = capabilities.find(({ type }) => type === run.type); + return wordsByStatus[run.status]({ named, capability, moment }, run); }; } -export function runPlainLanguage(primitives: readonly Primitive[]): PlainLanguage { - const words = specWordsFor(primitives); - const runWords = runWordsFor(primitives); +export function runPlainLanguage(capabilities: readonly Capability[]): PlainLanguage { + const words = definitionWordsFor(capabilities); + const runWords = runWordsFor(capabilities); return { task: `run a ${words.kinds}`, - attempt: ({ primitive, name }) => `run ${words.named(primitive, name)}`, - outcome: (execution) => runWords(execution, 'just started'), + attempt: ({ type, name }) => `run ${words.named(type, name)}`, + outcome: (run) => runWords(run, 'just started'), }; } diff --git a/packages/specs/src/presenting/spec-presenter.test.ts b/packages/definitions/src/presenting/definition-presenter.test.ts similarity index 51% rename from packages/specs/src/presenting/spec-presenter.test.ts rename to packages/definitions/src/presenting/definition-presenter.test.ts index 03ca03167..eb20608ce 100644 --- a/packages/specs/src/presenting/spec-presenter.test.ts +++ b/packages/definitions/src/presenting/definition-presenter.test.ts @@ -2,23 +2,23 @@ import { presentationOf, type RecordedEvent } from '@beonauto/operations'; import { Schema } from 'effect'; import { describe, expect, it } from 'vitest'; -import { makeSpecPresenters } from '../index.ts'; -import { SpecEventSchema, type SpecEvent } from '../registry/spec-events.ts'; +import { makeDefinitionPresenters } from '../index.ts'; +import { DefinitionEventSchema, type DefinitionEvent } from '../registry/definition-events.ts'; import { echo } from '../testing/echo.ts'; -const { present } = presentationOf(makeSpecPresenters([echo])); +const { present } = presentationOf(makeDefinitionPresenters([echo])); -const encode = Schema.encodeSync(Schema.toCodecJson(SpecEventSchema)); +const encode = Schema.encodeSync(Schema.toCodecJson(DefinitionEventSchema)); const fact = { name: 'greet', by: 'acme-admin', at: '2026-10-01T09:00:00.000Z' }; -function presented(event: SpecEvent, primitive = 'echo') { +function presented(event: DefinitionEvent, type = 'echo') { const record: RecordedEvent = { id: '0b1c2d3e-4f50-5a6b-8c7d-8e9fa0b1c2d3', cursor: 'WyJicmFpbi9hY21lL2FscGhhLyIsIjEiXQ', causationId: null, correlationId: null, - stream: `specs/${primitive}`, + stream: `definitions/${type}`, version: 1, type: event.type, data: encode(event), @@ -34,8 +34,8 @@ const shown = { at: '2026-10-01T09:00:00.000Z', }; -describe('the presenter of the specs of a primitive', () => { - it('presents a spec created with the size of its document and what its primitive said of it', () => { +describe('the presenter of the definitions of a capability', () => { + it('presents a definition created with the size of its document and what its capability said of it', () => { const content = { source: '{"greeting":"Hé"}', description: 'Greets', @@ -44,12 +44,12 @@ describe('the presenter of the specs of a primitive', () => { warnings: ['one', 'two'], }; - expect(presented({ type: 'spec_created', version: 1, content, ...fact })).toEqual({ + expect(presented({ type: 'definition_created', version: 1, content, ...fact })).toEqual({ ...shown, - type: 'spec_created', + type: 'definition_created', summary: 'The greeting “greet” was created.', data: { - primitive: 'echo', + definition_type: 'echo', name: 'greet', by: 'acme-admin', version: 1, @@ -63,22 +63,29 @@ describe('the presenter of the specs of a primitive', () => { }); }); -describe('the presenter of the changes to a spec', () => { - it('presents an update with its version, and a document its primitive said little of', () => { - expect(presented({ type: 'spec_updated', version: 150, content: { source: 'Hi' }, ...fact })).toEqual({ +describe('the presenter of the changes to a definition', () => { + it('presents an update with its version, and a document its capability said little of', () => { + expect(presented({ type: 'definition_updated', version: 150, content: { source: 'Hi' }, ...fact })).toEqual({ ...shown, - type: 'spec_updated', + type: 'definition_updated', summary: 'The greeting “greet” was updated to version one hundred and fifty.', - data: { primitive: 'echo', name: 'greet', by: 'acme-admin', version: 150, source_bytes: 2, warning_count: 0 }, + data: { + definition_type: 'echo', + name: 'greet', + by: 'acme-admin', + version: 150, + source_bytes: 2, + warning_count: 0, + }, }); }); - it('presents a retirement, and names a spec of a primitive the server does not offer as an item', () => { - expect(presented({ type: 'spec_retired', ...fact }, 'gone')).toEqual({ + it('presents a retirement, and names a definition of a capability the server does not offer as an item', () => { + expect(presented({ type: 'definition_retired', ...fact }, 'gone')).toEqual({ ...shown, - type: 'spec_retired', + type: 'definition_retired', summary: 'The item “greet” was retired.', - data: { primitive: 'gone', name: 'greet', by: 'acme-admin' }, + data: { definition_type: 'gone', name: 'greet', by: 'acme-admin' }, }); }); @@ -86,7 +93,7 @@ describe('the presenter of the changes to a spec', () => { const description = `${'😀'.repeat(299)}ab`; expect( - presented({ type: 'spec_created', version: 1, content: { source: 'Hi', description }, ...fact }), + presented({ type: 'definition_created', version: 1, content: { source: 'Hi', description }, ...fact }), ).toMatchObject({ data: { description: `${'😀'.repeat(299)}a` } }); }); }); diff --git a/packages/definitions/src/presenting/definition-presenter.ts b/packages/definitions/src/presenting/definition-presenter.ts new file mode 100644 index 000000000..881b6f1f7 --- /dev/null +++ b/packages/definitions/src/presenting/definition-presenter.ts @@ -0,0 +1,49 @@ +import { Buffer } from 'node:buffer'; + +import type { Presenter } from '@beonauto/operations'; + +import type { DefinitionWords } from '../plain-language/definition-words.ts'; +import { definitionCreated, definitionRetired, definitionUpdated } from '../plain-language/event-words.ts'; +import { DefinitionEventSchema, type DefinitionContent, type DefinitionEvent } from '../registry/definition-events.ts'; +import { jsonBytesOf } from '../runs/recorded-size.ts'; +import { cutAtCodePoint, firstCharacters, mostCallerBytes, mostDescriptionCharacters } from './event-data.ts'; +import { eventPresenter, type Account } from './event-presenter.ts'; + +function contentShown({ source, description, input_schema, output_schema, warnings = [] }: DefinitionContent) { + return { + source_bytes: Buffer.byteLength(source, 'utf8'), + ...(description === undefined ? {} : { description: firstCharacters(description, mostDescriptionCharacters) }), + ...(input_schema === undefined ? {} : { input_schema_bytes: jsonBytesOf(input_schema) }), + ...(output_schema === undefined ? {} : { output_schema_bytes: jsonBytesOf(output_schema) }), + warning_count: warnings.length, + }; +} + +function accountOf(words: DefinitionWords, event: DefinitionEvent, definitionType: string): Account { + const { name } = event; + const fact = { definition_type: definitionType, name, by: cutAtCodePoint(event.by, mostCallerBytes) }; + if (event.type === 'definition_retired') { + return { summary: definitionRetired(words, definitionType, name), data: fact }; + } + const { version, content } = event; + return { + summary: + event.type === 'definition_created' + ? definitionCreated(words, definitionType, name) + : definitionUpdated(words, definitionType, name, version), + data: { ...fact, version, ...contentShown(content) }, + }; +} + +export function definitionPresenter(words: DefinitionWords): Presenter { + return eventPresenter({ + streamKind: 'definitions', + eventSchema: DefinitionEventSchema, + publicNames: { + definition_created: ['definition_created'], + definition_updated: ['definition_updated'], + definition_retired: ['definition_retired'], + }, + account: (event, definitionType) => accountOf(words, event, definitionType), + }); +} diff --git a/packages/definitions/src/presenting/definition-presenters.ts b/packages/definitions/src/presenting/definition-presenters.ts new file mode 100644 index 000000000..bf8ae742f --- /dev/null +++ b/packages/definitions/src/presenting/definition-presenters.ts @@ -0,0 +1,13 @@ +import type { Presenter } from '@beonauto/operations'; + +import type { Capability } from '../capability/capability.ts'; +import { definitionWordsFor } from '../plain-language/definition-words.ts'; +import { definitionPresenter } from './definition-presenter.ts'; +import { publishedEventPresenter } from './published-event-presenter.ts'; +import { reactionRefusedPresenter } from './reaction-refused-presenter.ts'; +import { runPresenter } from './run-presenter.ts'; + +export function makeDefinitionPresenters(capabilities: readonly Capability[]): readonly Presenter[] { + const words = definitionWordsFor(capabilities); + return [runPresenter(words), definitionPresenter(words), publishedEventPresenter, reactionRefusedPresenter]; +} diff --git a/packages/specs/src/presenting/event-data.test.ts b/packages/definitions/src/presenting/event-data.test.ts similarity index 100% rename from packages/specs/src/presenting/event-data.test.ts rename to packages/definitions/src/presenting/event-data.test.ts diff --git a/packages/specs/src/presenting/event-data.ts b/packages/definitions/src/presenting/event-data.ts similarity index 100% rename from packages/specs/src/presenting/event-data.ts rename to packages/definitions/src/presenting/event-data.ts diff --git a/packages/specs/src/presenting/event-presenter.ts b/packages/definitions/src/presenting/event-presenter.ts similarity index 100% rename from packages/specs/src/presenting/event-presenter.ts rename to packages/definitions/src/presenting/event-presenter.ts diff --git a/packages/specs/src/presenting/presenter-catalog.test.ts b/packages/definitions/src/presenting/presenter-catalog.test.ts similarity index 63% rename from packages/specs/src/presenting/presenter-catalog.test.ts rename to packages/definitions/src/presenting/presenter-catalog.test.ts index 54bad57ba..e40b7e368 100644 --- a/packages/specs/src/presenting/presenter-catalog.test.ts +++ b/packages/definitions/src/presenting/presenter-catalog.test.ts @@ -9,26 +9,26 @@ import { Result, Schema, SchemaAST } from 'effect'; import { describe, expect, it } from 'vitest'; import { publishedEventDecider, publishedEventStreamOf, type EventPublished } from '../events/published-events.ts'; -import { executionDecider, executionStreamOf } from '../execution/execution-decider.ts'; -import type { ExecutionEvent } from '../execution/execution-events.ts'; -import { mostInputBytes, mostResultBytes } from '../execution/recorded-size.ts'; -import { makeSpecPresenters } from '../index.ts'; -import { specsDecider, specsStreamOf } from '../registry/specs-decider.ts'; +import { makeDefinitionPresenters } from '../index.ts'; +import { definitionsDecider, definitionTypeStreamOf } from '../registry/definitions-decider.ts'; +import { mostInputBytes, mostResultBytes } from '../runs/recorded-size.ts'; +import { runDecider, runStreamNameOf } from '../runs/run-decider.ts'; +import type { RunEvent } from '../runs/run-events.ts'; import { echo } from '../testing/echo.ts'; -const executionId = '0199a3c4-7d2e-7c1a-9b3f-2f1e0d9c8b7a'; +const runId = '0199a3c4-7d2e-7c1a-9b3f-2f1e0d9c8b7a'; -const longestPrimitive = 'p'.repeat(32); +const longestType = 'p'.repeat(32); -const executionStream = executionStreamOf(executionId); +const runStream = runStreamNameOf(runId); -const specStream = specsStreamOf(longestPrimitive); +const definitionStream = definitionTypeStreamOf(longestType); -const specsOfTheLongestPrimitive = specsDecider(longestPrimitive); +const definitionsOfTheLongestType = definitionsDecider(longestType); const eventStream = publishedEventStreamOf('/ledger/eu', 'm-1'); -type SpecEvent = typeof specsOfTheLongestPrimitive.eventSchema.Type; +type DefinitionEvent = typeof definitionsOfTheLongestType.eventSchema.Type; function storedTypesOf(ast: SchemaAST.AST): readonly string[] { const members = SchemaAST.isUnion(ast) ? ast.types : [ast]; @@ -42,12 +42,12 @@ function storedTypesOf(ast: SchemaAST.AST): readonly string[] { } const catalog = [ - { stream: executionStream, storedTypes: storedTypesOf(executionDecider.eventSchema.ast) }, - { stream: specStream, storedTypes: storedTypesOf(specsOfTheLongestPrimitive.eventSchema.ast) }, + { stream: runStream, storedTypes: storedTypesOf(runDecider.eventSchema.ast) }, + { stream: definitionStream, storedTypes: storedTypesOf(definitionsOfTheLongestType.eventSchema.ast) }, { stream: eventStream, storedTypes: storedTypesOf(publishedEventDecider.eventSchema.ast) }, ] as const; -const presenters = makeSpecPresenters([echo]); +const presenters = makeDefinitionPresenters([echo]); const { present } = presentationOf(presenters); @@ -59,31 +59,35 @@ const awkward = '\u0000'.repeat(64 * 1024); const fact = { by: awkward, at: '2026-10-01T09:00:00.000Z' }; -const ofTheLongestNames = { primitive: longestPrimitive, name: 'n'.repeat(48), spec_version: Number.MAX_SAFE_INTEGER }; +const ofTheLongestNames = { + definition_type: longestType, + name: 'n'.repeat(48), + definition_version: Number.MAX_SAFE_INTEGER, +}; const largestJson = { text: 'x'.repeat(mostResultBytes - 16) }; const manyIssues = Array.from({ length: 100 }, () => ({ detail: awkward, pointer: awkward })); -const largestExecutionEvents: readonly ExecutionEvent[] = [ +const largestRunEvents: readonly RunEvent[] = [ { - type: 'execution_started', - primitive: longestPrimitive, + type: 'run_started', + definition_type: longestType, name: 'n'.repeat(48), - spec_version: Number.MAX_SAFE_INTEGER, + definition_version: Number.MAX_SAFE_INTEGER, input: { text: 'x'.repeat(mostInputBytes - 16) }, ...fact, }, - { type: 'execution_succeeded', output: largestJson, record: largestJson, ...ofTheLongestNames, ...fact }, + { type: 'run_succeeded', output: largestJson, record: largestJson, ...ofTheLongestNames, ...fact }, { - type: 'execution_rejected', + type: 'run_rejected', rejection: { reason: 'invalid_input', detail: awkward, issues: manyIssues }, record: largestJson, ...ofTheLongestNames, ...fact, }, { - type: 'execution_rejected', + type: 'run_rejected', rejection: { reason: 'unavailable', detail: awkward, @@ -93,8 +97,8 @@ const largestExecutionEvents: readonly ExecutionEvent[] = [ ...ofTheLongestNames, ...fact, }, - { type: 'execution_rejected', rejection: { reason: 'conflict', detail: awkward }, ...ofTheLongestNames, ...fact }, - { type: 'execution_failed', ...ofTheLongestNames, ...fact }, + { type: 'run_rejected', rejection: { reason: 'conflict', detail: awkward }, ...ofTheLongestNames, ...fact }, + { type: 'run_failed', ...ofTheLongestNames, ...fact }, { type: 'tool_call_started', number: Number.MAX_SAFE_INTEGER, @@ -128,10 +132,16 @@ const largestContent = { warnings: Array.from({ length: 10_000 }, () => awkward.slice(0, 100)), }; -const largestSpecEvents: readonly SpecEvent[] = [ - { type: 'spec_created', name: 'n'.repeat(48), version: 1, content: largestContent, ...fact }, - { type: 'spec_updated', name: 'n'.repeat(48), version: Number.MAX_SAFE_INTEGER, content: largestContent, ...fact }, - { type: 'spec_retired', name: 'n'.repeat(48), ...fact }, +const largestDefinitionEvents: readonly DefinitionEvent[] = [ + { type: 'definition_created', name: 'n'.repeat(48), version: 1, content: largestContent, ...fact }, + { + type: 'definition_updated', + name: 'n'.repeat(48), + version: Number.MAX_SAFE_INTEGER, + content: largestContent, + ...fact, + }, + { type: 'definition_retired', name: 'n'.repeat(48), ...fact }, ]; const awkwardText = (most: number) => '"'.repeat(most); @@ -151,9 +161,9 @@ const largestEventPublished: EventPublished = { ...fact, }; -const encodeExecutionEvent = Schema.encodeSync(Schema.toCodecJson(executionDecider.eventSchema)); +const encodeRunEvent = Schema.encodeSync(Schema.toCodecJson(runDecider.eventSchema)); -const encodeSpecEvent = Schema.encodeSync(Schema.toCodecJson(specsOfTheLongestPrimitive.eventSchema)); +const encodeDefinitionEvent = Schema.encodeSync(Schema.toCodecJson(definitionsOfTheLongestType.eventSchema)); const encodeEventPublished = Schema.encodeSync(Schema.toCodecJson(publishedEventDecider.eventSchema)); @@ -186,27 +196,27 @@ describe('the presenters of the stream kinds of a brain', () => { }); it('hide a stream kind none of them presents', () => { - const failed: ExecutionEvent = { type: 'execution_failed', ...ofTheLongestNames, by: 'acme-admin', at: fact.at }; + const failed: RunEvent = { type: 'run_failed', ...ofTheLongestNames, by: 'acme-admin', at: fact.at }; - expect(present(recordOf(`runs/${executionId}`, failed.type, encodeExecutionEvent(failed)))).toEqual([]); + expect(present(recordOf(`run-logs/${runId}`, failed.type, encodeRunEvent(failed)))).toEqual([]); }); }); describe('the largest record of every stored type', () => { - it.each(largestExecutionEvents.map((event) => [event.type, event] as const))( - 'of an execution, %s, presents within the bound of public data', + it.each(largestRunEvents.map((event) => [event.type, event] as const))( + 'of a run, %s, presents within the bound of public data', (type, event) => { - const [bytes, conforms] = presentedSizeOf(recordOf(executionStream, type, encodeExecutionEvent(event))); + const [bytes, conforms] = presentedSizeOf(recordOf(runStream, type, encodeRunEvent(event))); expect(bytes).toBeLessThanOrEqual(mostPublicEventDataBytes); expect(conforms).toBe(true); }, ); - it.each(largestSpecEvents.map((event) => [event.type, event] as const))( - 'of the specs of a primitive, %s, presents within the bound of public data', + it.each(largestDefinitionEvents.map((event) => [event.type, event] as const))( + 'of the definitions of a capability, %s, presents within the bound of public data', (type, event) => { - const [bytes, conforms] = presentedSizeOf(recordOf(specStream, type, encodeSpecEvent(event))); + const [bytes, conforms] = presentedSizeOf(recordOf(definitionStream, type, encodeDefinitionEvent(event))); expect(bytes).toBeLessThanOrEqual(mostPublicEventDataBytes); expect(conforms).toBe(true); diff --git a/packages/specs/src/presenting/published-event-presenter.test.ts b/packages/definitions/src/presenting/published-event-presenter.test.ts similarity index 94% rename from packages/specs/src/presenting/published-event-presenter.test.ts rename to packages/definitions/src/presenting/published-event-presenter.test.ts index 921e35e0f..1d4d1b735 100644 --- a/packages/specs/src/presenting/published-event-presenter.test.ts +++ b/packages/definitions/src/presenting/published-event-presenter.test.ts @@ -3,10 +3,10 @@ import { Schema } from 'effect'; import { describe, expect, it } from 'vitest'; import { EventPublishedSchema, publishedEventStreamOf, type EventPublished } from '../events/published-events.ts'; -import { makeSpecPresenters } from '../index.ts'; +import { makeDefinitionPresenters } from '../index.ts'; import { echo } from '../testing/echo.ts'; -const { present } = presentationOf(makeSpecPresenters([echo])); +const { present } = presentationOf(makeDefinitionPresenters([echo])); const encode = Schema.encodeSync(Schema.toCodecJson(EventPublishedSchema)); diff --git a/packages/specs/src/presenting/published-event-presenter.ts b/packages/definitions/src/presenting/published-event-presenter.ts similarity index 93% rename from packages/specs/src/presenting/published-event-presenter.ts rename to packages/definitions/src/presenting/published-event-presenter.ts index 24fcd756c..18549c7e9 100644 --- a/packages/specs/src/presenting/published-event-presenter.ts +++ b/packages/definitions/src/presenting/published-event-presenter.ts @@ -1,8 +1,8 @@ import type { Presenter } from '@beonauto/operations'; import { EventPublishedSchema, type EventPublished } from '../events/published-events.ts'; -import { jsonBytesOf } from '../execution/recorded-size.ts'; import { eventEmitted, eventPublished } from '../plain-language/event-words.ts'; +import { jsonBytesOf } from '../runs/recorded-size.ts'; import { cutAtCodePoint, mostCallerBytes, mostDetailBytes, mostNameBytes } from './event-data.ts'; import { eventPresenter, type Account } from './event-presenter.ts'; @@ -22,7 +22,7 @@ function accountOf({ event, filled, emitted_by: emitter, depth, by }: EventPubli ? {} : { emitted_by: { - execution_id: emitter.execution_id, + run_id: emitter.run_id, workflow: cutAtCodePoint(emitter.workflow, mostNameBytes), version: emitter.version, }, diff --git a/packages/specs/src/presenting/reaction-refused-presenter.test.ts b/packages/definitions/src/presenting/reaction-refused-presenter.test.ts similarity index 93% rename from packages/specs/src/presenting/reaction-refused-presenter.test.ts rename to packages/definitions/src/presenting/reaction-refused-presenter.test.ts index eba36292f..08fbce246 100644 --- a/packages/specs/src/presenting/reaction-refused-presenter.test.ts +++ b/packages/definitions/src/presenting/reaction-refused-presenter.test.ts @@ -3,10 +3,10 @@ import { Schema } from 'effect'; import { describe, expect, it } from 'vitest'; import { ReactionRefusedSchema, type ReactionRefused } from '../events/reaction-refusals.ts'; -import { makeSpecPresenters } from '../index.ts'; +import { makeDefinitionPresenters } from '../index.ts'; import { echo } from '../testing/echo.ts'; -const { present } = presentationOf(makeSpecPresenters([echo])); +const { present } = presentationOf(makeDefinitionPresenters([echo])); const encode = Schema.encodeSync(Schema.toCodecJson(ReactionRefusedSchema)); diff --git a/packages/specs/src/presenting/reaction-refused-presenter.ts b/packages/definitions/src/presenting/reaction-refused-presenter.ts similarity index 100% rename from packages/specs/src/presenting/reaction-refused-presenter.ts rename to packages/definitions/src/presenting/reaction-refused-presenter.ts diff --git a/packages/specs/src/presenting/execution-presenter.test.ts b/packages/definitions/src/presenting/run-presenter.test.ts similarity index 69% rename from packages/specs/src/presenting/execution-presenter.test.ts rename to packages/definitions/src/presenting/run-presenter.test.ts index 4f6816ced..dcb051d8e 100644 --- a/packages/specs/src/presenting/execution-presenter.test.ts +++ b/packages/definitions/src/presenting/run-presenter.test.ts @@ -2,27 +2,27 @@ import { presentationOf, type RecordedEvent } from '@beonauto/operations'; import { Schema } from 'effect'; import { describe, expect, it } from 'vitest'; -import { ExecutionEventSchema, type ExecutionEvent } from '../execution/execution-events.ts'; -import { makeSpecPresenters } from '../index.ts'; +import { makeDefinitionPresenters } from '../index.ts'; +import { RunEventSchema, type RunEvent } from '../runs/run-events.ts'; import { echo } from '../testing/echo.ts'; -const { present } = presentationOf(makeSpecPresenters([echo])); +const { present } = presentationOf(makeDefinitionPresenters([echo])); -const encode = Schema.encodeSync(Schema.toCodecJson(ExecutionEventSchema)); +const encode = Schema.encodeSync(Schema.toCodecJson(RunEventSchema)); -const executionId = '0199a3c4-7d2e-7c1a-9b3f-2f1e0d9c8b7a'; +const runId = '0199a3c4-7d2e-7c1a-9b3f-2f1e0d9c8b7a'; const fact = { by: 'acme-admin', at: '2026-10-01T09:00:01.000Z' }; -const ofGreet = { primitive: 'echo', name: 'greet', spec_version: 2 }; +const ofGreet = { definition_type: 'echo', name: 'greet', definition_version: 2 }; -function recorded(event: ExecutionEvent): RecordedEvent { +function recorded(event: RunEvent): RecordedEvent { return { id: '0b1c2d3e-4f50-5a6b-8c7d-8e9fa0b1c2d3', cursor: 'WyJicmFpbi9hY21lL2FscGhhLyIsIjEiXQ', causationId: '1c2d3e4f-5a6b-5c7d-8e9f-a0b1c2d3e4f5', - correlationId: executionId, - stream: `executions/${executionId}`, + correlationId: runId, + stream: `runs/${runId}`, version: 1, type: event.type, data: encode(event), @@ -30,7 +30,7 @@ function recorded(event: ExecutionEvent): RecordedEvent { }; } -function presented(event: ExecutionEvent) { +function presented(event: RunEvent) { return present(recorded(event)).at(0); } @@ -41,27 +41,27 @@ const shown = { at: '2026-10-01T09:00:01.000Z', }; -describe('the presenter of the events of an execution', () => { - it('presents a start with the spec that ran and the size of its input, at the time of the start', () => { +describe('the presenter of the events of a run', () => { + it('presents a start with the definition that ran and the size of its input, at the time of the start', () => { expect( presented({ - type: 'execution_started', - primitive: 'echo', + type: 'run_started', + definition_type: 'echo', name: 'greet', - spec_version: 2, + definition_version: 2, input: { who: 'Ada' }, ...fact, }), ).toEqual({ ...shown, - type: 'execution_started', + type: 'run_started', summary: 'A run of the greeting “greet” started.', data: { - execution_id: executionId, + run_id: runId, by: 'acme-admin', - primitive: 'echo', + definition_type: 'echo', name: 'greet', - spec_version: 2, + definition_version: 2, input_bytes: 13, }, }); @@ -70,54 +70,52 @@ describe('the presenter of the events of an execution', () => { describe('the presenter of work that finishes later', () => { it('presents nothing for it when its capability gives no words of it, so a history goes on from the start', () => { - const deferred: ExecutionEvent = { type: 'execution_deferred', record: { run: 'r-1' }, ...ofGreet, ...fact }; - const executions = makeSpecPresenters([echo]).find(({ streamKind }) => streamKind === 'executions'); + const deferred: RunEvent = { type: 'run_deferred', record: { run: 'r-1' }, ...ofGreet, ...fact }; + const runs = makeDefinitionPresenters([echo]).find(({ streamKind }) => streamKind === 'runs'); - expect([ - present(recorded(deferred)), - executions?.present(recorded(deferred)), - executions?.publicNames['execution_deferred'], - ]).toEqual([[], [], ['execution_deferred']]); + expect([present(recorded(deferred)), runs?.present(recorded(deferred)), runs?.publicNames['run_deferred']]).toEqual( + [[], [], ['run_deferred']], + ); }); }); -describe('the presenter of the end of an execution', () => { +describe('the presenter of the end of a run', () => { it('presents a success with the sizes of what it recorded', () => { - expect(presented({ type: 'execution_succeeded', output: 'Hello', record: {}, ...ofGreet, ...fact })).toEqual({ + expect(presented({ type: 'run_succeeded', output: 'Hello', record: {}, ...ofGreet, ...fact })).toEqual({ ...shown, - type: 'execution_succeeded', + type: 'run_succeeded', summary: 'A run finished.', - data: { execution_id: executionId, by: 'acme-admin', output_bytes: 7, record_bytes: 2 }, + data: { run_id: runId, by: 'acme-admin', output_bytes: 7, record_bytes: 2 }, }); }); it('presents a failure without blame', () => { - expect(presented({ type: 'execution_failed', ...ofGreet, ...fact })).toEqual({ + expect(presented({ type: 'run_failed', ...ofGreet, ...fact })).toEqual({ ...shown, - type: 'execution_failed', + type: 'run_failed', summary: 'A run broke down because of a problem inside the server.', - data: { execution_id: executionId, by: 'acme-admin' }, + data: { run_id: runId, by: 'acme-admin' }, }); }); }); -describe('the presenter of a rejected execution', () => { +describe('the presenter of a rejected run', () => { it('counts the issues of input it did not accept and shows the first five', () => { const issues = Array.from({ length: 6 }, (_, index) => ({ detail: `Issue ${index}`, pointer: `/field${index}` })); expect( presented({ - type: 'execution_rejected', + type: 'run_rejected', rejection: { reason: 'invalid_input', detail: 'Bad', issues }, ...ofGreet, ...fact, }), ).toEqual({ ...shown, - type: 'execution_rejected', + type: 'run_rejected', summary: 'A run did not go through: what was given does not fit what it needs.', data: { - execution_id: executionId, + run_id: runId, by: 'acme-admin', reason: 'invalid_input', detail: 'Bad', @@ -128,37 +126,37 @@ describe('the presenter of a rejected execution', () => { }); }); -describe('the presenter of an execution rejected for a conflict', () => { +describe('the presenter of a run rejected for a conflict', () => { it('shows the kind of the conflict, when it was given', () => { const conflict = { reason: 'conflict', detail: 'The program raised an error on line 2: stop' } as const; expect([ - presented({ type: 'execution_rejected', rejection: { ...conflict, kind: 'unworkable' }, ...ofGreet, ...fact }), - presented({ type: 'execution_rejected', rejection: conflict, ...ofGreet, ...fact }), + presented({ type: 'run_rejected', rejection: { ...conflict, kind: 'unworkable' }, ...ofGreet, ...fact }), + presented({ type: 'run_rejected', rejection: conflict, ...ofGreet, ...fact }), ]).toMatchObject([ { summary: 'A run did not go through: it cannot work as it is written.', - data: { execution_id: executionId, by: 'acme-admin', ...conflict, kind: 'unworkable' }, + data: { run_id: runId, by: 'acme-admin', ...conflict, kind: 'unworkable' }, }, - { data: { execution_id: executionId, by: 'acme-admin', ...conflict } }, + { data: { run_id: runId, by: 'acme-admin', ...conflict } }, ]); }); }); -describe('the presenter of an execution rejected for something it relies on', () => { +describe('the presenter of a run rejected for something it relies on', () => { it('shows the kind and the reason of something unavailable, when it was given, and the size of a record it kept', () => { const unavailable = { reason: 'unavailable', detail: 'No model' } as const; const record = { usage: { total: 320 }, duration_ms: 41 }; expect([ presented({ - type: 'execution_rejected', + type: 'run_rejected', rejection: { ...unavailable, kind: 'model_not_offered', because: 'model_not_allowed' }, ...ofGreet, ...fact, }), - presented({ type: 'execution_rejected', rejection: unavailable, ...ofGreet, ...fact }), - presented({ type: 'execution_rejected', rejection: unavailable, record, ...ofGreet, ...fact }), + presented({ type: 'run_rejected', rejection: unavailable, ...ofGreet, ...fact }), + presented({ type: 'run_rejected', rejection: unavailable, record, ...ofGreet, ...fact }), ]).toMatchObject([ { summary: @@ -167,15 +165,15 @@ describe('the presenter of an execution rejected for something it relies on', () }, { summary: 'A run did not go through: something the server relies on is not available right now.', - data: { execution_id: executionId, by: 'acme-admin', ...unavailable }, + data: { run_id: runId, by: 'acme-admin', ...unavailable }, }, - { data: { execution_id: executionId, by: 'acme-admin', ...unavailable, record_bytes: 40 } }, + { data: { run_id: runId, by: 'acme-admin', ...unavailable, record_bytes: 40 } }, ]); }); - it('shows a spec that cannot run as written, and cuts a long detail and caller at a code point', () => { + it('shows a definition that cannot run as written, and cuts a long detail and caller at a code point', () => { const conflict = presented({ - type: 'execution_rejected', + type: 'run_rejected', rejection: { reason: 'conflict', detail: '😀'.repeat(1000), kind: 'unworkable' }, ...ofGreet, by: 'c'.repeat(300), @@ -218,7 +216,7 @@ describe('the presenter of the start of a tool call', () => { type: 'tool_call_started', summary: 'A run made tool call 3, to the get lifelogs tool of graph.', data: { - execution_id: executionId, + run_id: runId, by: 'acme-admin', number: 3, call_id: 'toolu_03', @@ -254,7 +252,7 @@ describe('the presenter of the answer to a tool call', () => { type: 'tool_call_answered', summary: 'Tool call 3 answered.', data: { - execution_id: executionId, + run_id: runId, by: 'acme-admin', number: 3, outcome: 'result', diff --git a/packages/specs/src/presenting/execution-presenter.ts b/packages/definitions/src/presenting/run-presenter.ts similarity index 66% rename from packages/specs/src/presenting/execution-presenter.ts rename to packages/definitions/src/presenting/run-presenter.ts index 0546dcfa8..5c56ea7ca 100644 --- a/packages/specs/src/presenting/execution-presenter.ts +++ b/packages/definitions/src/presenting/run-presenter.ts @@ -1,21 +1,7 @@ import type { Presenter } from '@beonauto/operations'; import { Schema } from 'effect'; -import { - ExecutionEventSchema, - type CalledBy, - type DeliveryEvent, - type ExecutionCancelRequested, - type ExecutionDeferred, - type ExecutionEvent, - type ExecutionStarted, - type ReplyEvent, - type ToolCallAnswered, - type ToolCallEvent, - type ToolCallStarted, -} from '../execution/execution-events.ts'; -import type { ExecutionRejection } from '../execution/execution.ts'; -import { jsonBytesOf } from '../execution/recorded-size.ts'; +import type { DefinitionWords } from '../plain-language/definition-words.ts'; import { cancelAsked, runBrokeDown, @@ -25,8 +11,22 @@ import { toolAnswered, toolCalled, } from '../plain-language/event-words.ts'; -import type { SpecWords } from '../plain-language/spec-words.ts'; import { deferralAccount, deliveryAccount, replyAccount, type TypedAccount } from '../run-work/run-work-accounts.ts'; +import { jsonBytesOf } from '../runs/recorded-size.ts'; +import { + RunEventSchema, + type CalledBy, + type DeliveryEvent, + type RunCancelRequested, + type RunDeferred, + type RunEvent, + type RunStarted, + type ReplyEvent, + type ToolCallAnswered, + type ToolCallEvent, + type ToolCallStarted, +} from '../runs/run-events.ts'; +import type { RunRejection } from '../runs/run.ts'; import { cutAtCodePoint, issuesShown, @@ -38,14 +38,14 @@ import { } from './event-data.ts'; import type { Account } from './event-presenter.ts'; -type ShownExecutionEvent = Exclude; +type ShownRunEvent = Exclude; interface Fact { - readonly execution_id: string; + readonly run_id: string; readonly by: string; } -function rejectionShown(rejection: ExecutionRejection) { +function rejectionShown(rejection: RunRejection) { const detail = cutAtCodePoint(rejection.detail, mostDetailBytes); if (rejection.reason === 'invalid_input') { return { reason: rejection.reason, detail, ...issuesShown(rejection.issues) }; @@ -66,17 +66,17 @@ function calledByShown(calledBy: CalledBy | undefined) { if (calledBy === undefined) { return {}; } - const { execution_id, reference, run } = calledBy; - return { called_by: { execution_id, reference: cutAtCodePoint(reference, mostNameBytes), run } }; + const { run_id, reference, run } = calledBy; + return { called_by: { run_id, reference: cutAtCodePoint(reference, mostNameBytes), run } }; } -function triggerShown(trigger: ExecutionStarted['trigger']) { +function triggerShown(trigger: RunStarted['trigger']) { return trigger === undefined ? {} : { trigger: { kind: trigger.kind, reference: cutAtCodePoint(trigger.reference, mostNameBytes) } }; } -function cancelAskedAccount({ kind, reason }: ExecutionCancelRequested, fact: Fact): Account { +function cancelAskedAccount({ kind, reason }: RunCancelRequested, fact: Fact): Account { return { summary: cancelAsked(kind), data: { ...fact, kind, reason: cutAtCodePoint(reason, mostDetailBytes) } }; } @@ -131,34 +131,34 @@ function toolCallAccount(event: ToolCallEvent, fact: Fact): Account { return event.type === 'tool_call_started' ? callStartedAccount(event, fact) : callAnsweredAccount(event, fact); } -function accountOf(words: SpecWords, event: ShownExecutionEvent, executionId: string): Account { - const fact = { execution_id: executionId, by: cutAtCodePoint(event.by, mostCallerBytes) }; +function accountOf(words: DefinitionWords, event: ShownRunEvent, runId: string): Account { + const fact = { run_id: runId, by: cutAtCodePoint(event.by, mostCallerBytes) }; if (event.type === 'tool_call_started' || event.type === 'tool_call_answered') { return toolCallAccount(event, fact); } - if (event.type === 'execution_started') { - const { primitive, name, spec_version, input, called_by: calledBy, trigger } = event; + if (event.type === 'run_started') { + const { definition_type: definitionType, name, definition_version, input, called_by: calledBy, trigger } = event; return { - summary: runStarted(words, primitive, name, trigger), + summary: runStarted(words, definitionType, name, trigger), data: { ...fact, - primitive, + definition_type: definitionType, name, - spec_version, + definition_version, input_bytes: jsonBytesOf(input), ...calledByShown(calledBy), ...triggerShown(trigger), }, }; } - if (event.type === 'execution_cancel_requested') { + if (event.type === 'run_cancel_requested') { return cancelAskedAccount(event, fact); } - if (event.type === 'execution_succeeded') { + if (event.type === 'run_succeeded') { const sizes = { output_bytes: jsonBytesOf(event.output), record_bytes: jsonBytesOf(event.record) }; return { summary: runFinished, data: { ...fact, ...sizes } }; } - if (event.type === 'execution_rejected') { + if (event.type === 'run_rejected') { const { rejection, record } = event; const recordSize = record === undefined ? {} : { record_bytes: jsonBytesOf(record) }; return { summary: runRejected(rejection), data: { ...fact, ...rejectionShown(rejection), ...recordSize } }; @@ -167,14 +167,14 @@ function accountOf(words: SpecWords, event: ShownExecutionEvent, executionId: st return { summary: runBrokeDown, data: incident === undefined ? fact : { ...fact, incident } }; } -const executionsKind = 'executions'; +const runStreamsKind = 'runs'; -const shownNames: Readonly['type'], readonly [string]>> = { - execution_started: ['execution_started'], - execution_succeeded: ['execution_succeeded'], - execution_rejected: ['execution_rejected'], - execution_failed: ['execution_failed'], - execution_cancel_requested: ['execution_cancel_requested'], +const shownNames: Readonly['type'], readonly [string]>> = { + run_started: ['run_started'], + run_succeeded: ['run_succeeded'], + run_rejected: ['run_rejected'], + run_failed: ['run_failed'], + run_cancel_requested: ['run_cancel_requested'], tool_call_started: ['tool_call_started'], tool_call_answered: ['tool_call_answered'], delivery_started: ['delivery_started'], @@ -183,29 +183,29 @@ const shownNames: Readonly['ty reply_refused: ['reply_refused'], }; -const decodeExecutionEvent = Schema.decodeUnknownSync(Schema.toCodecJson(ExecutionEventSchema)); +const decodeRunEvent = Schema.decodeUnknownSync(Schema.toCodecJson(RunEventSchema)); -function presentedAccount(words: SpecWords, event: ExecutionEvent, executionId: string): TypedAccount | undefined { - const fact = { execution_id: executionId, by: cutAtCodePoint(event.by, mostCallerBytes) }; - if (event.type === 'execution_deferred') { - return deferralAccount(words.runWordsOf(event.primitive), event, fact); +function presentedAccount(words: DefinitionWords, event: RunEvent, runId: string): TypedAccount | undefined { + const fact = { run_id: runId, by: cutAtCodePoint(event.by, mostCallerBytes) }; + if (event.type === 'run_deferred') { + return deferralAccount(words.runWordsOf(event.definition_type), event, fact); } if (event.type === 'delivery_started' || event.type === 'delivery_ended') { - return deliveryAccount(words.runWordsOf(event.primitive), event, fact); + return deliveryAccount(words.runWordsOf(event.definition_type), event, fact); } if (event.type === 'reply_taken' || event.type === 'reply_refused') { return replyAccount(event, fact); } - return { type: event.type, ...accountOf(words, event, executionId) }; + return { type: event.type, ...accountOf(words, event, runId) }; } -export function executionPresenter(words: SpecWords): Presenter { +export function runPresenter(words: DefinitionWords): Presenter { return { - streamKind: executionsKind, - publicNames: { ...shownNames, execution_deferred: words.deferralTypes }, + streamKind: runStreamsKind, + publicNames: { ...shownNames, run_deferred: words.deferralTypes }, present: ({ id, cursor, causationId, stream, data }) => { - const event = decodeExecutionEvent(data); - const account = presentedAccount(words, event, stream.slice(executionsKind.length + 1)); + const event = decodeRunEvent(data); + const account = presentedAccount(words, event, stream.slice(runStreamsKind.length + 1)); return account === undefined ? [] : [{ id, cursor, causation_id: causationId, at: event.at, ...account }]; }, }; diff --git a/packages/specs/src/presenting/run-starts-presenter.test.ts b/packages/definitions/src/presenting/run-starts-presenter.test.ts similarity index 72% rename from packages/specs/src/presenting/run-starts-presenter.test.ts rename to packages/definitions/src/presenting/run-starts-presenter.test.ts index ddcb57458..2b8882b33 100644 --- a/packages/specs/src/presenting/run-starts-presenter.test.ts +++ b/packages/definitions/src/presenting/run-starts-presenter.test.ts @@ -2,27 +2,27 @@ import { presentationOf, type RecordedEvent } from '@beonauto/operations'; import { Schema } from 'effect'; import { describe, expect, it } from 'vitest'; -import { ExecutionEventSchema, type ExecutionEvent } from '../execution/execution-events.ts'; -import { makeSpecPresenters } from '../index.ts'; +import { makeDefinitionPresenters } from '../index.ts'; +import { RunEventSchema, type RunEvent } from '../runs/run-events.ts'; import { echo } from '../testing/echo.ts'; -const { present } = presentationOf(makeSpecPresenters([echo])); +const { present } = presentationOf(makeDefinitionPresenters([echo])); -const encode = Schema.encodeSync(Schema.toCodecJson(ExecutionEventSchema)); +const encode = Schema.encodeSync(Schema.toCodecJson(RunEventSchema)); -const executionId = '0199a3c4-7d2e-7c1a-9b3f-2f1e0d9c8b7a'; +const runId = '0199a3c4-7d2e-7c1a-9b3f-2f1e0d9c8b7a'; const fact = { by: 'acme-admin', at: '2026-10-01T09:00:01.000Z' }; -const ofGreet = { primitive: 'echo', name: 'greet', spec_version: 2 }; +const ofGreet = { definition_type: 'echo', name: 'greet', definition_version: 2 }; -function recorded(event: ExecutionEvent): RecordedEvent { +function recorded(event: RunEvent): RecordedEvent { return { id: '0b1c2d3e-4f50-5a6b-8c7d-8e9fa0b1c2d3', cursor: 'WyJicmFpbi9hY21lL2FscGhhLyIsIjEiXQ', causationId: '1c2d3e4f-5a6b-5c7d-8e9f-a0b1c2d3e4f5', - correlationId: executionId, - stream: `executions/${executionId}`, + correlationId: runId, + stream: `runs/${runId}`, version: 1, type: event.type, data: encode(event), @@ -30,7 +30,7 @@ function recorded(event: ExecutionEvent): RecordedEvent { }; } -function presented(event: ExecutionEvent) { +function presented(event: RunEvent) { return present(recorded(event)).at(0); } @@ -44,17 +44,17 @@ const shown = { describe('the presenter of a run that answers a call of another run', () => { it('shows the call it answers on its start, its reference cut at 256 bytes', () => { const calledBy = { - execution_id: '0199a3c4-7d2e-7c1a-9b3f-000000000001', + run_id: '0199a3c4-7d2e-7c1a-9b3f-000000000001', reference: `/do/0/${'x'.repeat(300)}`, run: 2, }; expect( presented({ - type: 'execution_started', - primitive: 'echo', + type: 'run_started', + definition_type: 'echo', name: 'greet', - spec_version: 2, + definition_version: 2, input: {}, call_depth: 1, called_by: calledBy, @@ -72,25 +72,23 @@ describe('the presenter of a cancel request', () => { ['deadline', 'The step that waited for the run ran out of time, so the run is being cancelled.'], ['parent_ended', 'The run that waited for this run ended first, so this run is being cancelled.'], ] as const)('says who asked for a cancel of the kind %s, with the reason cut at 1 KiB', (kind, summary) => { - expect( - presented({ type: 'execution_cancel_requested', kind, reason: 'r'.repeat(2000), ...ofGreet, ...fact }), - ).toEqual({ + expect(presented({ type: 'run_cancel_requested', kind, reason: 'r'.repeat(2000), ...ofGreet, ...fact })).toEqual({ ...shown, - type: 'execution_cancel_requested', + type: 'run_cancel_requested', summary, - data: { execution_id: executionId, by: 'acme-admin', kind, reason: 'r'.repeat(1024) }, + data: { run_id: runId, by: 'acme-admin', kind, reason: 'r'.repeat(1024) }, }); }); it('shows a cancelled run with its kind, and a failure with its incident', () => { expect([ presented({ - type: 'execution_rejected', + type: 'run_rejected', rejection: { reason: 'cancelled', detail: 'Not needed', kind: 'requested' }, ...ofGreet, ...fact, }), - presented({ type: 'execution_failed', incident: 'incident-1', ...ofGreet, ...fact }), + presented({ type: 'run_failed', incident: 'incident-1', ...ofGreet, ...fact }), ]).toMatchObject([ { summary: 'A run did not go through: it was cancelled at the request of someone allowed to change the brain.', @@ -104,7 +102,7 @@ describe('the presenter of a cancel request', () => { describe('the presenter of the start of a run a trigger started', () => { it('says which kind of trigger started it, and gives the trigger with its place in the document in the data', () => { const startedBy = (kind: 'event' | 'cron' | 'every', reference: string) => - presented({ type: 'execution_started', ...ofGreet, input: [], trigger: { kind, reference }, ...fact }); + presented({ type: 'run_started', ...ofGreet, input: [], trigger: { kind, reference }, ...fact }); expect( [ @@ -118,7 +116,7 @@ describe('the presenter of the start of a run a trigger started', () => { 'A run of the greeting “greet” was started by its every schedule.', ]); expect(startedBy('every', '/schedule/every')?.data).toEqual({ - execution_id: executionId, + run_id: runId, by: 'acme-admin', ...ofGreet, input_bytes: 2, diff --git a/packages/specs/src/reading/cancelled-runs.test.ts b/packages/definitions/src/reading/cancelled-runs.test.ts similarity index 59% rename from packages/specs/src/reading/cancelled-runs.test.ts rename to packages/definitions/src/reading/cancelled-runs.test.ts index 1f3d12c6d..c9d44fd34 100644 --- a/packages/specs/src/reading/cancelled-runs.test.ts +++ b/packages/definitions/src/reading/cancelled-runs.test.ts @@ -1,29 +1,27 @@ import { Effect } from 'effect'; import { describe, expect, it } from 'vitest'; -import { executionSettler } from '../index.ts'; +import { runSettler } from '../index.ts'; import { acmeAdmin } from '../testing/callers.ts'; import { toBrain } from '../testing/harness.ts'; import { relayedId, withHandOn } from '../testing/relaying.ts'; describe('a cancelled run in the list of runs', () => { it('shows the reason cancelled with its kind, as rejected, and is kept by the status rejected', async () => { - const { call, executing, ledger, listExecutions, run } = await withHandOn(); - await executing(); + const { call, running, ledger, listRuns, run } = await withHandOn(); + await running(); await run( Effect.orDie( - executionSettler(ledger.service)( + runSettler(ledger.service)( { org: 'acme', brain: 'alpha', id: relayedId }, { status: 'rejected', reason: 'cancelled', detail: 'Not needed', kind: 'parent_ended' }, ), ), ); - expect(await call(listExecutions, toBrain('acme', 'alpha')(acmeAdmin, { status: 'rejected' }))).toMatchObject({ + expect(await call(listRuns, toBrain('acme', 'alpha')(acmeAdmin, { status: 'rejected' }))).toMatchObject({ output: { - executions: [ - { execution_id: relayedId, status: 'rejected', rejection: { reason: 'cancelled', kind: 'parent_ended' } }, - ], + runs: [{ run_id: relayedId, status: 'rejected', rejection: { reason: 'cancelled', kind: 'parent_ended' } }], }, }); }); diff --git a/packages/specs/src/reading/get-execution-history.test.ts b/packages/definitions/src/reading/get-run-history.test.ts similarity index 67% rename from packages/specs/src/reading/get-execution-history.test.ts rename to packages/definitions/src/reading/get-run-history.test.ts index c5848b0b2..7b5e7c72a 100644 --- a/packages/specs/src/reading/get-execution-history.test.ts +++ b/packages/definitions/src/reading/get-run-history.test.ts @@ -2,38 +2,38 @@ import type { Decider, Presenter } from '@beonauto/operations'; import { Effect, Result, Schema } from 'effect'; import { describe, expect, it } from 'vitest'; -import { makeSpecPresenters } from '../index.ts'; +import { makeDefinitionPresenters } from '../index.ts'; import { acmeAdmin } from '../testing/callers.ts'; +import { definitionOperationsFor } from '../testing/definition-operations.ts'; import { echo } from '../testing/echo.ts'; import { asQueryString, harness, toBrain } from '../testing/harness.ts'; import { relayedId, settledAt, withHandOn } from '../testing/relaying.ts'; -import { specOperationsFor } from '../testing/spec-operations.ts'; const toAlpha = toBrain('acme', 'alpha'); -const executionId = '0199a3c4-7d2e-7c1a-9b3f-2f1e0d9c8b7a'; +const runId = '0199a3c4-7d2e-7c1a-9b3f-2f1e0d9c8b7a'; const otherId = '0199a3c4-7d2e-7c1a-9b3f-000000000001'; -const RunLogEventSchema = Schema.Struct({ +const StandInLogEventSchema = Schema.Struct({ type: Schema.Literals(['input_applied', 'state_patched']), key: Schema.String, at: Schema.String, }); -type RunLogEvent = typeof RunLogEventSchema.Type; +type StandInLogEvent = typeof StandInLogEventSchema.Type; -const runLog: Decider = { +const runLog: Decider = { initialState: null, evolve: (state) => state, decide: (event) => Result.succeed([event]), - eventSchema: RunLogEventSchema, + eventSchema: StandInLogEventSchema, }; -const decodeRunLogEvent = Schema.decodeUnknownSync(RunLogEventSchema); +const decodeRunLogEvent = Schema.decodeUnknownSync(StandInLogEventSchema); const runLogPresenter: Presenter = { - streamKind: 'runs', + streamKind: 'run-logs', publicNames: { input_applied: ['input_applied'], state_patched: [] }, present: ({ id, cursor, causationId, data }) => { const { key, at } = decodeRunLogEvent(data); @@ -51,23 +51,23 @@ const runLogPresenter: Presenter = { }, }; -async function brainWithRun(presenters: readonly Presenter[] = makeSpecPresenters([echo])) { - const operations = specOperationsFor([echo], presenters); - const specs = harness(); - await specs.call( - operations.createSpec, - toAlpha(acmeAdmin, { primitive: 'echo', name: 'greet', source: '{"greeting":"Hi"}' }), +async function brainWithRun(presenters: readonly Presenter[] = makeDefinitionPresenters([echo])) { + const operations = definitionOperationsFor([echo], presenters); + const definitions = harness(); + await definitions.call( + operations.createDefinition, + toAlpha(acmeAdmin, { type: 'echo', name: 'greet', source: '{"greeting":"Hi"}' }), ); - const executing = (id: string, at: string) => - specs.call(operations.executeSpec, toAlpha(acmeAdmin, { primitive: 'echo', name: 'greet', execution_id: id }), at); + const running = (id: string, at: string) => + definitions.call(operations.runDefinition, toAlpha(acmeAdmin, { type: 'echo', name: 'greet', run_id: id }), at); const reading = (input: object) => - specs.call(operations.getExecutionHistory, toAlpha(acmeAdmin, { execution_id: executionId, ...input })); - const logging = (event: RunLogEvent, recordedAt: string) => - specs.run( - Effect.orDie(specs.ledger.service.execute(`brain/acme/alpha/runs/${executionId}`, runLog, event)), + definitions.call(operations.getRunHistory, toAlpha(acmeAdmin, { run_id: runId, ...input })); + const logging = (event: StandInLogEvent, recordedAt: string) => + definitions.run( + Effect.orDie(definitions.ledger.service.execute(`brain/acme/alpha/run-logs/${runId}`, runLog, event)), recordedAt, ); - return { ...specs, ...operations, executing, reading, logging }; + return { ...definitions, ...operations, running, reading, logging }; } function withCursor(cursor: string | undefined): object { @@ -84,16 +84,16 @@ function typesIn(outcome: unknown): readonly string[] { )(outcome).output.events.map(({ type }) => type); } -describe('get_execution_history', () => { - it('is a brain query at GET /executions/{execution_id}/history that may meet not_found', async () => { - const { getExecutionHistory } = await brainWithRun(); +describe('get_run_history', () => { + it('is a brain query at GET /runs/{run_id}/history that may meet not_found', async () => { + const { getRunHistory } = await brainWithRun(); - expect(getExecutionHistory.registration).toMatchObject({ + expect(getRunHistory.registration).toMatchObject({ scope: 'brain', kind: 'query', title: 'Get run history', - route: { method: 'GET', path: '/executions/{execution_id}/history' }, - pathParameters: ['execution_id'], + route: { method: 'GET', path: '/runs/{run_id}/history' }, + pathParameters: ['run_id'], reasons: ['not_found', 'invalid_input'], }); }); @@ -101,8 +101,8 @@ describe('get_execution_history', () => { describe('the history of a run', () => { it('holds the facts of the run oldest first, each at its own time', async () => { - const { executing, reading } = await brainWithRun(); - await executing(executionId, '2026-10-01T09:00:10.000Z'); + const { running, reading } = await brainWithRun(); + await running(runId, '2026-10-01T09:00:10.000Z'); expect(await reading({})).toMatchObject({ status: 'succeeded', @@ -110,22 +110,22 @@ describe('the history of a run', () => { events: [ { at: '2026-10-01T09:00:10.000Z', - type: 'execution_started', + type: 'run_started', summary: 'A run of the greeting “greet” started.', data: { - execution_id: executionId, + run_id: runId, by: 'acme-admin', - primitive: 'echo', + definition_type: 'echo', name: 'greet', - spec_version: 1, + definition_version: 1, input_bytes: 2, }, }, { at: '2026-10-01T09:00:10.000Z', - type: 'execution_succeeded', + type: 'run_succeeded', summary: 'A run finished.', - data: { execution_id: executionId, by: 'acme-admin', output_bytes: 28, record_bytes: 17 }, + data: { run_id: runId, by: 'acme-admin', output_bytes: 28, record_bytes: 17 }, }, ], has_more: false, @@ -135,23 +135,23 @@ describe('the history of a run', () => { }); it('reads a run that finished later, newest first, from a query string with its id in any case, without its deferral', async () => { - const { call, executing, getExecutionHistory, settling } = await withHandOn(); - await executing(); + const { call, running, getRunHistory, settling } = await withHandOn(); + await running(); await settling({ status: 'succeeded', output: 'done', record: {} }); const read = await call( - getExecutionHistory, - asQueryString(toAlpha(acmeAdmin, { execution_id: relayedId.toUpperCase(), order: 'desc' })), + getRunHistory, + asQueryString(toAlpha(acmeAdmin, { run_id: relayedId.toUpperCase(), order: 'desc' })), ); - expect(typesIn(read)).toEqual(['execution_succeeded', 'execution_started']); + expect(typesIn(read)).toEqual(['run_succeeded', 'run_started']); expect(read).toMatchObject({ output: { events: [{ at: settledAt }, {}] } }); }); }); async function runWithLog(presenters?: readonly Presenter[]) { const brain = await brainWithRun(presenters); - await brain.executing(executionId, '2026-10-01T09:00:10.000Z'); + await brain.running(runId, '2026-10-01T09:00:10.000Z'); await brain.logging( { type: 'input_applied', key: 'early', at: '2026-10-01T09:00:05.000Z' }, '2026-10-01T09:00:11.000Z', @@ -193,7 +193,7 @@ function cursorsIn(outcome: unknown): readonly string[] { return cursorsOf(outcome).output.events.map(({ cursor }) => cursor); } -const withRunLog = [...makeSpecPresenters([echo]), runLogPresenter]; +const withRunLog = [...makeDefinitionPresenters([echo]), runLogPresenter]; async function everyKeyAndType( read: (cursor: string | undefined) => Promise, @@ -208,7 +208,7 @@ async function everyKeyAndType( describe('the history of a run with a log of its own', () => { it('follows the order the brain recorded both streams in, whatever the time of each event', async () => { const { reading } = await runWithLog(withRunLog); - const recorded = ['execution_started', 'execution_succeeded', 'early', 'same', 'late']; + const recorded = ['run_started', 'run_succeeded', 'early', 'same', 'late']; expect(keysAndTypesIn(await reading({}))).toEqual(recorded); expect(keysAndTypesIn(await reading({ order: 'desc' }))).toEqual(recorded.toReversed()); @@ -220,7 +220,7 @@ describe('the history of a run with a log of its own', () => { const cursors = cursorsIn(await reading({ order })); return Promise.all(cursors.map(async (cursor) => keysAndTypesIn(await reading({ order, cursor })))); }; - const recorded = ['execution_started', 'execution_succeeded', 'early', 'same', 'late']; + const recorded = ['run_started', 'run_succeeded', 'early', 'same', 'late']; expect([await after('asc'), await after('desc')]).toEqual([ recorded.map((_, index) => recorded.slice(index + 1)), @@ -233,36 +233,36 @@ describe('the history of a run with a log of its own', () => { const pages = await everyKeyAndType((cursor) => reading({ limit: 2, ...withCursor(cursor) })); - expect(pages).toEqual([['execution_started', 'execution_succeeded'], ['early'], ['same', 'late']]); + expect(pages).toEqual([['run_started', 'run_succeeded'], ['early'], ['same', 'late']]); }); it('shows the facts of the run alone when no presenter presents its log', async () => { const { reading } = await runWithLog(); - expect(keysAndTypesIn(await reading({}))).toEqual(['execution_started', 'execution_succeeded']); + expect(keysAndTypesIn(await reading({}))).toEqual(['run_started', 'run_succeeded']); }); }); const emptyAndEnded = { status: 'succeeded', output: { events: [], has_more: false, next_cursor: null } }; -describe('get_execution_history rejecting', () => { +describe('get_run_history rejecting', () => { it('a run the brain does not have, with a cursor or without', async () => { - const { executing, reading } = await brainWithRun(); - await executing(otherId, '2026-10-01T09:00:10.000Z'); + const { running, reading } = await brainWithRun(); + await running(otherId, '2026-10-01T09:00:10.000Z'); const notFound = { status: 'rejected', reason: 'not_found', - detail: `There is no run ${executionId} in this brain`, + detail: `There is no run ${runId} in this brain`, }; - const [firstOfAnother] = cursorsIn(await reading({ execution_id: otherId })); + const [firstOfAnother] = cursorsIn(await reading({ run_id: otherId })); expect(await reading({})).toEqual(notFound); expect(await reading({ cursor: String(firstOfAnother) })).toEqual(notFound); }); it('a cursor that does not decode', async () => { - const { executing, reading } = await brainWithRun(); - await executing(executionId, '2026-10-01T09:00:10.000Z'); + const { running, reading } = await brainWithRun(); + await running(runId, '2026-10-01T09:00:10.000Z'); expect(await reading({ cursor: 'not-a-cursor' })).toMatchObject({ status: 'rejected', @@ -274,8 +274,8 @@ describe('get_execution_history rejecting', () => { describe('the end of the history of a run', () => { it('is an empty page without a cursor', async () => { - const { executing, reading } = await brainWithRun(); - await executing(executionId, '2026-10-01T09:00:10.000Z'); + const { running, reading } = await brainWithRun(); + await running(runId, '2026-10-01T09:00:10.000Z'); const [, last] = cursorsIn(await reading({})); expect(await reading({ cursor: String(last) })).toEqual(emptyAndEnded); @@ -284,8 +284,8 @@ describe('the end of the history of a run', () => { describe('a page of the history of a run whose records are all hidden', () => { it('is empty, and the run is found', async () => { - const { executing, reading } = await brainWithRun([]); - await executing(executionId, '2026-10-01T09:00:10.000Z'); + const { running, reading } = await brainWithRun([]); + await running(runId, '2026-10-01T09:00:10.000Z'); expect(await reading({})).toEqual(emptyAndEnded); }); diff --git a/packages/specs/src/reading/get-execution-history.ts b/packages/definitions/src/reading/get-run-history.ts similarity index 75% rename from packages/specs/src/reading/get-execution-history.ts rename to packages/definitions/src/reading/get-run-history.ts index 2a960c198..12a7e6035 100644 --- a/packages/specs/src/reading/get-execution-history.ts +++ b/packages/definitions/src/reading/get-run-history.ts @@ -14,20 +14,20 @@ import { } from '@beonauto/operations'; import { Effect, Schema } from 'effect'; -import { noRunCalled } from '../execution/execution-lookup.ts'; -import { ExecutionIdField } from '../operations/spec-fields.ts'; +import { RunIdInputField } from '../operations/definition-fields.ts'; import { historyFound } from '../plain-language/reading-words.ts'; +import { noRunCalled } from '../runs/run-lookup.ts'; const description = [ 'Reads what happened in one run, a page at a time, oldest first: when it started and ended,', 'each tool call a reasoning function made with its outcome, and each step of a workflow with what it waited for and the runs it started.', - 'Inputs, outputs and results appear as their sizes; get_execution reads the output and the record.', + 'Inputs, outputs and results appear as their sizes; get_run reads the output and the record.', 'Use it to see what a run did, such as which tools it called before it did not succeed.', - "`execution_id` is the run's id, `order` reads newest first when desc, and `cursor` is the next_cursor of the page before.", + "`run_id` is the run's id, `order` reads newest first when desc, and `cursor` is the next_cursor of the page before.", ].join(' '); -const ExecutionHistoryInput = Schema.Struct({ - execution_id: ExecutionIdField, +const RunHistoryInput = Schema.Struct({ + run_id: RunIdInputField, order: PagingInputFields.order, limit: PagingInputFields.limit, cursor: PagingInputFields.cursor, @@ -35,7 +35,7 @@ const ExecutionHistoryInput = Schema.Struct({ const EventsPage = Schema.Struct({ events: Schema.Array(PublicEventSchema), ...PagingOutputFields }); -const cancelRequested = 'execution_cancel_requested'; +const cancelRequested = 'run_cancel_requested'; const newestHeadAlone: RecordedPageRequest = { order: 'desc', limit: 1, dataOf: [] }; @@ -54,20 +54,20 @@ function holdsNoRun(heads: readonly RecordedEvent[]): boolean { function newestHeadOf(id: string) { return Effect.gen(function* () { - const { records } = yield* (yield* BrainReader).readRecorded({ kind: 'run', execution: id }, newestHeadAlone); + const { records } = yield* (yield* BrainReader).readRecorded({ kind: 'run', run: id }, newestHeadAlone); return records; }); } function historyReader(presentation: Presentation) { return Effect.fnUntraced(function* ({ - execution_id: id, + run_id: id, order = 'asc', limit = defaultPageLimit, cursor, - }: typeof ExecutionHistoryInput.Type) { + }: typeof RunHistoryInput.Type) { const paging = { order, limit, ...(cursor === undefined ? {} : { cursor }) }; - const page = yield* (yield* BrainReader).readRecorded({ kind: 'run', execution: id }, paging); + const page = yield* (yield* BrainReader).readRecorded({ kind: 'run', run: id }, paging); if (page.records.every((record) => isACancel(record)) && holdsNoRun(yield* newestHeadOf(id))) { return yield* Effect.fail(noRunCalled(id)); } @@ -80,13 +80,13 @@ function historyReader(presentation: Presentation) { }); } -export function defineGetExecutionHistory(presenters: readonly Presenter[]) { +export function defineGetRunHistory(presenters: readonly Presenter[]) { return defineQuery('brain', { - name: 'get_execution_history', + name: 'get_run_history', title: 'Get run history', description, - route: { method: 'GET', path: '/executions/{execution_id}/history' }, - inputSchema: ExecutionHistoryInput, + route: { method: 'GET', path: '/runs/{run_id}/history' }, + inputSchema: RunHistoryInput, outputSchema: EventsPage, reasons: ['not_found', 'invalid_input'], handle: historyReader(presentationOf(presenters)), diff --git a/packages/specs/src/reading/history-reads.test.ts b/packages/definitions/src/reading/history-reads.test.ts similarity index 78% rename from packages/specs/src/reading/history-reads.test.ts rename to packages/definitions/src/reading/history-reads.test.ts index a68c85a66..c161f4c68 100644 --- a/packages/specs/src/reading/history-reads.test.ts +++ b/packages/definitions/src/reading/history-reads.test.ts @@ -1,7 +1,7 @@ import { Effect } from 'effect'; import { describe, expect, it } from 'vitest'; -import { executionCanceller } from '../index.ts'; +import { runCanceller } from '../index.ts'; import { acmeAdmin } from '../testing/callers.ts'; import { toBrain, type LedgerRead } from '../testing/harness.ts'; import { relayedId, withHandOn } from '../testing/relaying.ts'; @@ -14,9 +14,9 @@ const unknownId = '0199a3c4-7d2e-7c1a-9b3f-0000000000d1'; const lineage = { causationId: '5d0e9f6a-1b2c-5d3e-8f4a-6b7c8d9e0f1a', correlationId: childId }; -const pageOf = (execution: string, page: object) => ({ selection: { kind: 'run', execution }, page }); +const pageOf = (run: string, page: object) => ({ selection: { kind: 'run', run }, page }); -const newestHeadOf = (execution: string) => pageOf(execution, { order: 'desc', limit: 1, dataOf: [] }); +const newestHeadOf = (run: string) => pageOf(run, { order: 'desc', limit: 1, dataOf: [] }); async function readsOf(ledgerReads: () => readonly LedgerRead[], read: () => Promise) { const before = ledgerReads().length; @@ -27,7 +27,7 @@ async function readsOf(ledgerReads: () => readonly LedgerRead[], read: () => describe('a page of the history of a run', () => { it('reads the page alone, however large the run, and never the whole run', async () => { const handed = await withHandOn(); - await handed.executing(262_000); + await handed.running(262_000); const { answer, reads } = await readsOf(handed.ledgerReads, handed.history); @@ -37,28 +37,27 @@ describe('a page of the history of a run', () => { it('that holds nothing but a cancel reads the newest head of the run, without its data, to tell it holds a run', async () => { const handed = await withHandOn(); - await handed.executing(); + await handed.running(); await handed.cancelling({ reason: 'Not needed' }); const { answer, reads } = await readsOf(handed.ledgerReads, () => - handed.call(handed.getExecutionHistory, toAlpha(acmeAdmin, { execution_id: relayedId, order: 'desc', limit: 1 })), + handed.call(handed.getRunHistory, toAlpha(acmeAdmin, { run_id: relayedId, order: 'desc', limit: 1 })), ); - expect(answer).toMatchObject({ status: 'succeeded', output: { events: [{ type: 'execution_cancel_requested' }] } }); + expect(answer).toMatchObject({ status: 'succeeded', output: { events: [{ type: 'run_cancel_requested' }] } }); expect(reads).toEqual([pageOf(relayedId, { order: 'desc', limit: 1 }), newestHeadOf(relayedId)]); }); it('of a stream that holds its caller’s cancel alone, or of no stream, is not found, from one head read', async () => { const handed = await withHandOn(); await Effect.runPromise( - executionCanceller(handed.ledger.service)( + runCanceller(handed.ledger.service)( { org: 'acme', brain: 'alpha', id: childId }, { kind: 'parent_ended', reason: 'The run that waited for it ended first' }, lineage, ), ); - const historyOf = (id: string) => () => - handed.call(handed.getExecutionHistory, toAlpha(acmeAdmin, { execution_id: id })); + const historyOf = (id: string) => () => handed.call(handed.getRunHistory, toAlpha(acmeAdmin, { run_id: id })); const cancelledFirst = await readsOf(handed.ledgerReads, historyOf(childId)); const unknown = await readsOf(handed.ledgerReads, historyOf(unknownId)); diff --git a/packages/definitions/src/reading/list-runs.test.ts b/packages/definitions/src/reading/list-runs.test.ts new file mode 100644 index 000000000..6889dcfb7 --- /dev/null +++ b/packages/definitions/src/reading/list-runs.test.ts @@ -0,0 +1,300 @@ +import type { Outcome } from '@beonauto/operations'; +import { Schema } from 'effect'; +import { describe, expect, it } from 'vitest'; + +import { acmeAdmin, globexAdmin } from '../testing/callers.ts'; +import { definitionOperationsFor } from '../testing/definition-operations.ts'; +import { echo } from '../testing/echo.ts'; +import { asQueryString, harness, toBrain } from '../testing/harness.ts'; +import { probe } from '../testing/probe.ts'; +import { relay } from '../testing/relay.ts'; + +const toAlpha = toBrain('acme', 'alpha'); + +const callerChosen = 'ffffffff-7d2e-7c1a-9b3f-2f1e0d9c8b7a'; + +const hashedChild = '6b1e2f30-9c4d-5a8b-8e7f-0a1b2c3d4e5f'; + +const startedTwice = '00000000-7d2e-7c1a-9b3f-2f1e0d9c8b7a'; + +const listedIdsOf = Schema.decodeUnknownSync( + Schema.Struct({ + output: Schema.Struct({ runs: Schema.Array(Schema.Struct({ run_id: Schema.String })) }), + }), +); + +function idStartsIn(outcome: Outcome): readonly string[] { + return listedIdsOf(outcome).output.runs.map(({ run_id: id }) => id.slice(0, 8)); +} + +type DefinitionName = readonly [type: string, name: string]; + +const greet: DefinitionName = ['echo', 'greet']; + +const wave: DefinitionName = ['echo', 'wave']; + +const plain: DefinitionName = ['probe', 'plain']; + +const handOn: DefinitionName = ['relay', 'hand-on']; + +async function brainWithRuns() { + const prober = probe(); + const relayer = relay(); + const operations = definitionOperationsFor([echo, prober.capability, relayer.capability]); + const definitions = harness(); + const creating = (type: string, name: string, source: string) => + definitions.call(operations.createDefinition, toAlpha(acmeAdmin, { type, name, source })); + await creating('echo', 'greet', '{"greeting":"Hi"}'); + await creating('echo', 'wave', '{"greeting":"Hey"}'); + await creating('probe', 'plain', 'plain'); + await creating('relay', 'hand-on', 'text'); + const running = ([type, name]: DefinitionName, input: object, runId: string, at: string) => + definitions.call(operations.runDefinition, toAlpha(acmeAdmin, { type, name, input, run_id: runId }), at); + const listing = (input: object = {}) => definitions.call(operations.listRuns, toAlpha(acmeAdmin, input)); + return { ...definitions, ...operations, prober, running, listing }; +} + +async function brainWithEveryEnding() { + const brain = await brainWithRuns(); + const { running, prober } = brain; + await running(greet, { who: 'Ada' }, callerChosen, '2026-10-01T09:01:00.000Z'); + await running(plain, { reject: true }, hashedChild, '2026-10-01T09:02:00.000Z'); + prober.sufferOnNextRun('unavailable'); + await running(plain, {}, startedTwice, '2026-10-01T09:03:00.000Z'); + await running(handOn, {}, '22222222-7d2e-7c1a-9b3f-2f1e0d9c8b7a', '2026-10-01T09:04:00.000Z'); + prober.sufferOnNextRun('breakdown'); + await running(plain, {}, '33333333-7d2e-7c1a-9b3f-2f1e0d9c8b7a', '2026-10-01T09:05:00.000Z'); + await running(wave, {}, '44444444-7d2e-7c1a-9b3f-2f1e0d9c8b7a', '2026-10-01T09:06:00.000Z'); + await running(plain, {}, startedTwice, '2026-10-01T09:07:00.000Z'); + return brain; +} + +const everyEndingNewestFirst = [ + { + run_id: '44444444-7d2e-7c1a-9b3f-2f1e0d9c8b7a', + type: 'echo', + name: 'wave', + definition_version: 1, + status: 'succeeded', + started_at: '2026-10-01T09:06:00.000Z', + started_by: 'acme-admin', + finished_at: '2026-10-01T09:06:00.000Z', + }, + { + run_id: '33333333-7d2e-7c1a-9b3f-2f1e0d9c8b7a', + type: 'probe', + name: 'plain', + definition_version: 1, + status: 'failed', + started_at: '2026-10-01T09:05:00.000Z', + started_by: 'acme-admin', + finished_at: '2026-10-01T09:05:00.000Z', + }, + { + run_id: '22222222-7d2e-7c1a-9b3f-2f1e0d9c8b7a', + type: 'relay', + name: 'hand-on', + definition_version: 1, + status: 'started', + started_at: '2026-10-01T09:04:00.000Z', + started_by: 'acme-admin', + }, + { + run_id: startedTwice, + type: 'probe', + name: 'plain', + definition_version: 1, + status: 'succeeded', + started_at: '2026-10-01T09:03:00.000Z', + started_by: 'acme-admin', + finished_at: '2026-10-01T09:07:00.000Z', + }, + { + run_id: hashedChild, + type: 'probe', + name: 'plain', + definition_version: 1, + status: 'rejected', + rejection: { reason: 'invalid_input' }, + started_at: '2026-10-01T09:02:00.000Z', + started_by: 'acme-admin', + finished_at: '2026-10-01T09:02:00.000Z', + }, + { + run_id: callerChosen, + type: 'echo', + name: 'greet', + definition_version: 1, + status: 'succeeded', + started_at: '2026-10-01T09:01:00.000Z', + started_by: 'acme-admin', + finished_at: '2026-10-01T09:01:00.000Z', + }, +]; + +describe('list_runs', () => { + it('is a brain query at GET /runs that names the capabilities it may filter by', () => { + const { listRuns } = definitionOperationsFor([echo, probe().capability]); + + expect(listRuns.registration).toMatchObject({ + scope: 'brain', + kind: 'query', + title: 'List runs', + route: { method: 'GET', path: '/runs' }, + reasons: ['invalid_input'], + }); + expect(listRuns.registration.input.schema).toMatchObject({ + properties: { + type: { + type: 'string', + enum: ['echo', 'probe'], + description: 'Only the runs of this type: echo or probe', + }, + }, + }); + }); + + it('lists the runs newest first by their first start, whatever their ids, without outputs, records or issues', async () => { + const { listing } = await brainWithEveryEnding(); + + expect(await listing()).toEqual({ + status: 'succeeded', + output: { + runs: everyEndingNewestFirst, + has_more: false, + next_cursor: null, + }, + }); + }); +}); + +describe('a run listed after it started again', () => { + it('shows a run started again and still running by its latest start, in the place of its first', async () => { + const { callCancelledWhen, runDefinition, running, listing, prober, call, updateDefinition } = + await brainWithRuns(); + prober.sufferOnNextRun('unavailable'); + await running(plain, {}, startedTwice, '2026-10-01T09:01:00.000Z'); + await running(greet, {}, callerChosen, '2026-10-01T09:02:00.000Z'); + await call(updateDefinition, toAlpha(acmeAdmin, { type: 'probe', name: 'plain', source: 'newer' })); + prober.sufferOnNextRun('stall'); + const finishing = Promise.withResolvers(); + const again = toAlpha(acmeAdmin, { type: 'probe', name: 'plain', input: {}, run_id: startedTwice }); + const runningAgain = callCancelledWhen(finishing.promise, runDefinition, again); + await prober.stalled; + + const listed = await listing(); + finishing.resolve(); + await runningAgain; + + expect(listed).toMatchObject({ + output: { + runs: [ + { run_id: callerChosen }, + { run_id: startedTwice, status: 'started', definition_version: 2, started_at: '2026-10-01T09:00:00.000Z' }, + ], + }, + }); + }); +}); + +describe('a run listed after it did not go through', () => { + it('shows the reason, kind and because of a rejection, and never its detail', async () => { + const { running, listing, prober } = await brainWithRuns(); + prober.sufferOnNextRun('unoffered'); + await running(plain, {}, callerChosen, '2026-10-01T09:01:00.000Z'); + prober.sufferOnNextRun('unworkable'); + await running(plain, {}, hashedChild, '2026-10-01T09:02:00.000Z'); + prober.sufferOnNextRun('unavailable'); + await running(plain, {}, startedTwice, '2026-10-01T09:03:00.000Z'); + + expect(await listing()).toMatchObject({ + output: { + runs: [ + { run_id: startedTwice, rejection: { reason: 'unavailable' } }, + { run_id: hashedChild, rejection: { reason: 'conflict', kind: 'unworkable' } }, + { + run_id: callerChosen, + rejection: { reason: 'unavailable', kind: 'model_not_offered', because: 'provider_not_configured' }, + }, + ], + }, + }); + }); +}); + +describe('a conflict in the list of runs', () => { + it('shows its reason alone when it has no kind', async () => { + const { running, listing, prober } = await brainWithRuns(); + prober.sufferOnNextRun('conflict'); + await running(plain, {}, callerChosen, '2026-10-01T09:01:00.000Z'); + + expect(await listing()).toMatchObject({ + output: { runs: [{ run_id: callerChosen, rejection: { reason: 'conflict' } }] }, + }); + expect(await listing()).not.toMatchObject({ output: { runs: [{ rejection: { kind: 'unworkable' } }] } }); + }); +}); + +describe('list_runs filtering', () => { + it.each([ + [{ status: 'succeeded' }, ['44444444', '00000000', 'ffffffff']], + [{ status: 'rejected' }, ['6b1e2f30']], + [{ status: 'failed' }, ['33333333']], + [{ status: 'started' }, ['22222222']], + [{ type: 'echo' }, ['44444444', 'ffffffff']], + [{ name: 'plain' }, ['33333333', '00000000', '6b1e2f30']], + [{ type: 'probe', name: 'plain', status: 'succeeded' }, ['00000000']], + [{ type: 'echo', name: 'plain' }, []], + ] as const)('keeps the runs %j', async (filters, kept) => { + const { listing } = await brainWithEveryEnding(); + + const listed = await listing(filters); + + expect(listed).toMatchObject({ status: 'succeeded', output: { has_more: false, next_cursor: null } }); + expect(idStartsIn(listed)).toEqual(kept); + }); + + it('refuses a type the server does not run, naming the types it runs', async () => { + const { listing } = await brainWithEveryEnding(); + + expect(await listing({ type: 'gone' })).toMatchObject({ + status: 'rejected', + reason: 'invalid_input', + issues: [{ pointer: '/type', detail: 'Expected a type this server runs: echo, probe, or relay' }], + }); + }); + + it('reads its filters, limit and cursor from a query string', async () => { + const { call, listRuns } = await brainWithEveryEnding(); + + expect(await call(listRuns, asQueryString(toAlpha(acmeAdmin, { status: 'succeeded', limit: '1' })))).toMatchObject({ + status: 'succeeded', + output: { runs: [{ run_id: '44444444-7d2e-7c1a-9b3f-2f1e0d9c8b7a' }], has_more: true }, + }); + }); + + it('refuses a status, name or limit it does not know', async () => { + const { listing } = await brainWithRuns(); + + expect(await listing({ status: 'deferred', name: 'No', limit: 0 })).toMatchObject({ + status: 'rejected', + reason: 'invalid_input', + issues: [{ pointer: '/name' }, { pointer: '/status' }, { pointer: '/limit' }], + }); + }); +}); + +describe('the runs of a brain', () => { + it('are its own alone', async () => { + const { listing, call, listRuns } = await brainWithEveryEnding(); + + expect(await call(listRuns, toBrain('globex', 'gamma')(globexAdmin))).toEqual({ + status: 'succeeded', + output: { runs: [], has_more: false, next_cursor: null }, + }); + expect(await call(listRuns, toBrain('acme', 'beta')(acmeAdmin))).toMatchObject({ + output: { runs: [] }, + }); + expect(await listing()).toMatchObject({ output: { runs: { length: 6 } } }); + }); +}); diff --git a/packages/definitions/src/reading/list-runs.ts b/packages/definitions/src/reading/list-runs.ts new file mode 100644 index 000000000..2077c89dd --- /dev/null +++ b/packages/definitions/src/reading/list-runs.ts @@ -0,0 +1,92 @@ +import { + BrainReader, + PagingInputFields, + PagingOutputFields, + defaultPageLimit, + defineQuery, + mostExaminedInAPage, + type RecordedSelection, +} from '@beonauto/operations'; +import { Effect, Schema } from 'effect'; + +import type { Capability } from '../capability/capability.ts'; +import { knownCapabilities, type KnownCapabilities } from '../capability/known-capabilities.ts'; +import { RunsOfNameField } from '../operations/definition-fields.ts'; +import { definitionWordsFor } from '../plain-language/definition-words.ts'; +import { runsListed, runsToList } from '../plain-language/reading-words.ts'; +import { RunSchema } from '../runs/run.ts'; +import { ListedRunSchema, listedRunsOf } from './listed-run.ts'; +import { storedTypesByStatus } from './run-status.ts'; + +const description = [ + 'Lists the runs of the brain a page at a time, newest first, each with its definition, its status, who started it and when it ended, without its output.', + 'Use it to find a run the person means, such as the runs of a scheduled workflow; get_run reads one run in full.', + '`type` and `name` keep the runs of one definition and `status` those in one status.', + `A filtered page looks at up to ${mostExaminedInAPage} runs, so it may hold fewer runs than \`limit\`, or none, while has_more is true.`, + '`cursor` is the next_cursor of the page before.', +].join(' '); + +function listRunsInputOf(typeField: KnownCapabilities['field']) { + return Schema.Struct({ + type: Schema.optionalKey(typeField), + name: Schema.optionalKey(RunsOfNameField), + status: Schema.optionalKey( + RunSchema.fields.status.annotate({ + description: 'Only runs in this status: started, succeeded, rejected or failed', + }), + ), + limit: PagingInputFields.limit, + cursor: PagingInputFields.cursor, + }); +} + +type ListRunsInput = ReturnType['Type']; + +const ListedRunsPage = Schema.Struct({ + runs: Schema.Array(ListedRunSchema), + ...PagingOutputFields, +}); + +function streamsOfRuns(type: string | undefined, name: string | undefined): RecordedSelection { + return { + kind: 'runs', + notBeginningWith: ['run_cancel_requested'], + ...(type === undefined ? {} : { definitionType: type }), + ...(name === undefined ? {} : { name }), + }; +} + +const listRuns = Effect.fnUntraced(function* ({ type, name, status, limit = defaultPageLimit, cursor }: ListRunsInput) { + const page = yield* (yield* BrainReader).readRecorded(streamsOfRuns(type, name), { + order: 'desc', + limit, + ...(cursor === undefined ? {} : { cursor }), + ...(status === undefined ? {} : { types: storedTypesByStatus[status] }), + }); + return { + runs: yield* listedRunsOf(page.records), + has_more: page.hasMore, + next_cursor: page.nextCursor, + }; +}); + +export function defineListRuns(capabilities: readonly Capability[]) { + const known = knownCapabilities(capabilities); + const words = definitionWordsFor(capabilities); + const operation = defineQuery('brain', { + name: 'list_runs', + title: 'List runs', + description, + route: { method: 'GET', path: '/runs' }, + inputSchema: listRunsInputOf(known.field), + outputSchema: ListedRunsPage, + reasons: ['invalid_input'], + handle: listRuns, + plainLanguage: { + task: 'list the runs', + attempt: (filters) => runsToList(words, filters), + outcome: (page, filters) => runsListed(words, page, filters), + }, + }); + return known.publish(operation, 'Only the runs of this type'); +} diff --git a/packages/specs/src/reading/listed-execution.ts b/packages/definitions/src/reading/listed-run.ts similarity index 63% rename from packages/specs/src/reading/listed-execution.ts rename to packages/definitions/src/reading/listed-run.ts index 0c4fee35e..f379ab743 100644 --- a/packages/specs/src/reading/listed-execution.ts +++ b/packages/definitions/src/reading/listed-run.ts @@ -8,11 +8,11 @@ import { } from '@beonauto/operations'; import { Effect, Schema, Struct } from 'effect'; -import { executionDecider, executionStreamOf } from '../execution/execution-decider.ts'; -import { ExecutionEventSchema, type ExecutionEvent } from '../execution/execution-events.ts'; -import { executionOf } from '../execution/execution-lookup.ts'; -import { evolveExecution, runOf, type ExecutionStreamState } from '../execution/execution-state.ts'; -import { RunSchema, type Run, type ExecutionRejection } from '../execution/execution.ts'; +import { runDecider, runStreamNameOf } from '../runs/run-decider.ts'; +import { RunEventSchema, type RunEvent } from '../runs/run-events.ts'; +import { runOf } from '../runs/run-lookup.ts'; +import { evolveRun, startedRunOf, type RunStreamState } from '../runs/run-state.ts'; +import { RunSchema, type Run, type RunRejection } from '../runs/run.ts'; const ListedRejectionSchema = Schema.Struct({ reason: Schema.Literals(['invalid_input', 'unavailable', 'conflict', 'cancelled', 'unanswered']), @@ -37,9 +37,9 @@ export type ListedRun = typeof ListedRunSchema.Type; type ListedRejection = NonNullable; -const decodeEvent = Schema.decodeUnknownEffect(Schema.toCodecJson(ExecutionEventSchema)); +const decodeEvent = Schema.decodeUnknownEffect(Schema.toCodecJson(RunEventSchema)); -const executionStreamPrefix = executionStreamOf(''); +const runStreamPrefix = runStreamNameOf(''); function runsOf(records: readonly RecordedEvent[]): ReadonlyMap { const runs = new Map(); @@ -49,7 +49,7 @@ function runsOf(records: readonly RecordedEvent[]): ReadonlyMap { +function listedRunOf(stream: string, heads: readonly RecordedEvent[]): Effect.Effect { return Effect.forEach(heads, ({ data }) => decodeEvent(data)).pipe( - Effect.map((events: readonly ExecutionEvent[]) => - events.reduce( - (state: ExecutionStreamState, event: ExecutionEvent) => evolveExecution(state, event), - executionDecider.initialState, - ), + Effect.map((events: readonly RunEvent[]) => + events.reduce((state: RunStreamState, event: RunEvent) => evolveRun(state, event), runDecider.initialState), ), - Effect.flatMap((state) => executionOf(stream.slice(executionStreamPrefix.length), runOf(state))), + Effect.flatMap((state) => runOf(stream.slice(runStreamPrefix.length), startedRunOf(state))), Effect.orDie, Effect.map(listedOf), ); } -export function listedExecutionsOf(records: readonly RecordedEvent[]): Effect.Effect { +export function listedRunsOf(records: readonly RecordedEvent[]): Effect.Effect { return Effect.forEach(runsOf(records), ([stream, heads]: readonly [string, readonly RecordedEvent[]]) => - listedExecutionOf(stream, heads), + listedRunOf(stream, heads), ); } diff --git a/packages/specs/src/reading/paging.test.ts b/packages/definitions/src/reading/paging.test.ts similarity index 62% rename from packages/specs/src/reading/paging.test.ts rename to packages/definitions/src/reading/paging.test.ts index a4633d0c2..b5053a5d6 100644 --- a/packages/specs/src/reading/paging.test.ts +++ b/packages/definitions/src/reading/paging.test.ts @@ -2,18 +2,19 @@ import { mostExaminedInAPage, type Outcome } from '@beonauto/operations'; import { Effect, Schema } from 'effect'; import { describe, expect, it } from 'vitest'; -import { executionDecider } from '../execution/execution-decider.ts'; -import { mostInputBytes } from '../execution/recorded-size.ts'; +import { mostInputBytes } from '../runs/recorded-size.ts'; +import { runDecider } from '../runs/run-decider.ts'; import { acmeAdmin, globexAdmin } from '../testing/callers.ts'; +import { definitionOperationsFor } from '../testing/definition-operations.ts'; import { echo } from '../testing/echo.ts'; import { harness, toBrain } from '../testing/harness.ts'; -import { specOperationsFor } from '../testing/spec-operations.ts'; +import { relay } from '../testing/relay.ts'; const toAlpha = toBrain('acme', 'alpha'); const PageSchema = Schema.Struct({ output: Schema.Struct({ - executions: Schema.optionalKey(Schema.Array(Schema.Struct({ execution_id: Schema.String }))), + runs: Schema.optionalKey(Schema.Array(Schema.Struct({ run_id: Schema.String }))), events: Schema.optionalKey(Schema.Array(Schema.Struct({ id: Schema.String, type: Schema.String }))), has_more: Schema.Boolean, next_cursor: Schema.NullOr(Schema.String), @@ -27,27 +28,27 @@ function idOf(index: number): string { } async function brainWithEchoes(count: number, input: object = {}) { - const operations = specOperationsFor([echo]); - const specs = harness(); - await specs.call( - operations.createSpec, - toAlpha(acmeAdmin, { primitive: 'echo', name: 'greet', source: '{"greeting":"Hi"}' }), + const operations = definitionOperationsFor([echo, relay().capability]); + const definitions = harness(); + await definitions.call( + operations.createDefinition, + toAlpha(acmeAdmin, { type: 'echo', name: 'greet', source: '{"greeting":"Hi"}' }), ); - const execution = (index: number) => - specs.dispatch( - operations.executeSpec, - toAlpha(acmeAdmin, { primitive: 'echo', name: 'greet', input, execution_id: idOf(index) }), + const run = (index: number) => + definitions.dispatch( + operations.runDefinition, + toAlpha(acmeAdmin, { type: 'echo', name: 'greet', input, run_id: idOf(index) }), ); - await specs.run( + await definitions.run( Effect.forEach( Array.from({ length: count }, (_, index) => index + 1), - execution, + run, ), ); - const executing = (index: number) => specs.run(execution(index)); - const listing = (request: object) => specs.call(operations.listExecutions, toAlpha(acmeAdmin, request)); - const reading = (request: object) => specs.call(operations.getExecutionHistory, toAlpha(acmeAdmin, request)); - return { ...specs, ...operations, executing, listing, reading }; + const running = (index: number) => definitions.run(run(index)); + const listing = (request: object) => definitions.call(operations.listRuns, toAlpha(acmeAdmin, request)); + const reading = (request: object) => definitions.call(operations.getRunHistory, toAlpha(acmeAdmin, request)); + return { ...definitions, ...operations, running, listing, reading }; } type Page = (typeof PageSchema.Type)['output']; @@ -73,18 +74,18 @@ function withCursor(cursor: string | undefined): object { describe('paging through the runs of a brain', () => { it('delivers every run once, newest first, while more runs start', async () => { - const { executing, listing } = await brainWithEchoes(7); + const { running, listing } = await brainWithEchoes(7); let next = 8; const pages = await everyPage( (cursor) => listing({ limit: 2, ...withCursor(cursor) }), async () => { - await executing(next); + await running(next); next += 1; }, ); - expect(pages.map(({ executions = [] }) => executions.map(({ execution_id: id }) => id))).toEqual([ + expect(pages.map(({ runs = [] }) => runs.map(({ run_id: id }) => id))).toEqual([ [idOf(7), idOf(6)], [idOf(5), idOf(4)], [idOf(3), idOf(2)], @@ -100,23 +101,23 @@ describe('the bounds of a page of runs', { timeout: 30_000 }, () => { const pages = await everyPage((cursor) => listing(withCursor(cursor))); - expect(pages.map(({ executions = [] }) => executions.length)).toEqual([7, 2]); + expect(pages.map(({ runs = [] }) => runs.length)).toEqual([7, 2]); expect(pages.map(({ has_more: hasMore, next_cursor: next }) => [hasMore, next === null])).toEqual([ [true, false], [false, true], ]); }); - it.each([[{ status: 'failed' }], [{ primitive: 'relay' }], [{ name: 'wave' }]] as const)( + it.each([[{ status: 'failed' }], [{ type: 'relay' }], [{ name: 'wave' }]] as const)( 'end a page filtered by %j after looking at a thousand runs, though none matched', async (filter) => { const { ledger, listing, run } = await brainWithEchoes(0); const starting = Array.from({ length: mostExaminedInAPage + 1 }, (_, index) => - ledger.service.execute(`brain/acme/alpha/executions/${idOf(index)}`, executionDecider, { + ledger.service.execute(`brain/acme/alpha/runs/${idOf(index)}`, runDecider, { type: 'start', - primitive: 'echo', + definition_type: 'echo', name: 'greet', - spec_version: 1, + definition_version: 1, calls_tools: false, input: {}, by: 'acme-admin', @@ -127,9 +128,7 @@ describe('the bounds of a page of runs', { timeout: 30_000 }, () => { const pages = await everyPage((cursor) => listing({ ...filter, ...withCursor(cursor) })); - expect( - pages.map(({ executions, has_more: hasMore, next_cursor: next }) => [executions, hasMore, next === null]), - ).toEqual([ + expect(pages.map(({ runs, has_more: hasMore, next_cursor: next }) => [runs, hasMore, next === null])).toEqual([ [[], true, false], [[], false, true], ]); @@ -139,26 +138,22 @@ describe('the bounds of a page of runs', { timeout: 30_000 }, () => { describe('paging through the runs of one definition', () => { it('fills a page of one name from the runs behind newer runs of another, and has no more after the last', async () => { - const { call, createSpec, executeSpec, listing } = await brainWithEchoes(3); - await call(createSpec, toAlpha(acmeAdmin, { primitive: 'echo', name: 'wave', source: '{"greeting":"Hey"}' })); - await call(executeSpec, toAlpha(acmeAdmin, { primitive: 'echo', name: 'wave', execution_id: idOf(4) })); - await call(executeSpec, toAlpha(acmeAdmin, { primitive: 'echo', name: 'greet', execution_id: idOf(5) })); - await call(executeSpec, toAlpha(acmeAdmin, { primitive: 'echo', name: 'greet', execution_id: idOf(6) })); + const { call, createDefinition, runDefinition, listing } = await brainWithEchoes(3); + await call(createDefinition, toAlpha(acmeAdmin, { type: 'echo', name: 'wave', source: '{"greeting":"Hey"}' })); + await call(runDefinition, toAlpha(acmeAdmin, { type: 'echo', name: 'wave', run_id: idOf(4) })); + await call(runDefinition, toAlpha(acmeAdmin, { type: 'echo', name: 'greet', run_id: idOf(5) })); + await call(runDefinition, toAlpha(acmeAdmin, { type: 'echo', name: 'greet', run_id: idOf(6) })); const pages = await everyPage((cursor) => listing({ name: 'greet', limit: 2, ...withCursor(cursor) })); const { output: wave } = pageOf(await listing({ name: 'wave', limit: 2 })); - expect(pages.map(({ executions = [] }) => executions.map(({ execution_id: id }) => id))).toEqual([ + expect(pages.map(({ runs = [] }) => runs.map(({ run_id: id }) => id))).toEqual([ [idOf(6), idOf(5)], [idOf(3), idOf(2)], [idOf(1)], ]); expect(pages.map(({ has_more: hasMore }) => hasMore)).toEqual([true, true, false]); - expect([wave.executions?.map(({ execution_id: id }) => id), wave.has_more, wave.next_cursor]).toEqual([ - [idOf(4)], - false, - null, - ]); + expect([wave.runs?.map(({ run_id: id }) => id), wave.has_more, wave.next_cursor]).toEqual([[idOf(4)], false, null]); }); }); @@ -166,14 +161,14 @@ describe('paging through the history of a run', () => { it('delivers every event once in either order', async () => { const { reading } = await brainWithEchoes(1); const historyOf = (order: string) => - everyPage((cursor) => reading({ execution_id: idOf(1), order, limit: 1, ...withCursor(cursor) })); + everyPage((cursor) => reading({ run_id: idOf(1), order, limit: 1, ...withCursor(cursor) })); const oldestFirst = await historyOf('asc'); const newestFirst = await historyOf('desc'); expect(oldestFirst.flatMap(({ events = [] }) => events.map(({ type }) => type))).toEqual([ - 'execution_started', - 'execution_succeeded', + 'run_started', + 'run_succeeded', ]); expect(newestFirst.flatMap(({ events = [] }) => events.map(({ id }) => id))).toEqual( oldestFirst.flatMap(({ events = [] }) => events.map(({ id }) => id)).toReversed(), @@ -183,13 +178,13 @@ describe('paging through the history of a run', () => { describe('a cursor', () => { it('that does not decode, or that another brain gave, is refused at /cursor', async () => { - const { call, createSpec, executeSpec, listExecutions, listing } = await brainWithEchoes(2); + const { call, createDefinition, runDefinition, listRuns, listing } = await brainWithEchoes(2); const toGamma = toBrain('globex', 'gamma'); - await call(createSpec, toGamma(globexAdmin, { primitive: 'echo', name: 'greet', source: '{"greeting":"Hi"}' })); - await call(executeSpec, toGamma(globexAdmin, { primitive: 'echo', name: 'greet' })); - await call(executeSpec, toGamma(globexAdmin, { primitive: 'echo', name: 'greet' })); + await call(createDefinition, toGamma(globexAdmin, { type: 'echo', name: 'greet', source: '{"greeting":"Hi"}' })); + await call(runDefinition, toGamma(globexAdmin, { type: 'echo', name: 'greet' })); + await call(runDefinition, toGamma(globexAdmin, { type: 'echo', name: 'greet' })); const ofGamma = await listing({ - cursor: cursorOf(pageOf(await call(listExecutions, toGamma(globexAdmin, { limit: 1 })))), + cursor: cursorOf(pageOf(await call(listRuns, toGamma(globexAdmin, { limit: 1 })))), }); expect([await listing({ cursor: 'not-a-cursor' }), ofGamma]).toEqual([ { diff --git a/packages/definitions/src/reading/run-operations.ts b/packages/definitions/src/reading/run-operations.ts new file mode 100644 index 000000000..a376c2689 --- /dev/null +++ b/packages/definitions/src/reading/run-operations.ts @@ -0,0 +1,22 @@ +import type { Presenter } from '@beonauto/operations'; + +import { defineGetBrainAnalytics } from '../analytics/get-brain-analytics.ts'; +import { defineCancelRun } from '../cancellation/cancel-run.ts'; +import type { Capability } from '../capability/capability.ts'; +import { defineGetRun } from '../operations/get-run.ts'; +import { makeDefinitionPresenters } from '../presenting/definition-presenters.ts'; +import { defineGetRunHistory } from './get-run-history.ts'; +import { defineListRuns } from './list-runs.ts'; + +export function runOperations( + capabilities: readonly Capability[], + presenters: readonly Presenter[] = makeDefinitionPresenters(capabilities), +) { + return [ + defineGetRun(capabilities), + defineCancelRun(capabilities), + defineListRuns(capabilities), + defineGetRunHistory(presenters), + defineGetBrainAnalytics(capabilities), + ]; +} diff --git a/packages/specs/src/reading/execution-status.test.ts b/packages/definitions/src/reading/run-status.test.ts similarity index 61% rename from packages/specs/src/reading/execution-status.test.ts rename to packages/definitions/src/reading/run-status.test.ts index 9a544e779..baa8579ea 100644 --- a/packages/specs/src/reading/execution-status.test.ts +++ b/packages/definitions/src/reading/run-status.test.ts @@ -1,36 +1,36 @@ import { describe, expect, it } from 'vitest'; -import { executionDecider } from '../execution/execution-decider.ts'; -import type { ExecutionEvent } from '../execution/execution-events.ts'; -import { evolveExecution, runOf } from '../execution/execution-state.ts'; -import { storedTypesByStatus } from './execution-status.ts'; +import { runDecider } from '../runs/run-decider.ts'; +import type { RunEvent } from '../runs/run-events.ts'; +import { evolveRun, startedRunOf } from '../runs/run-state.ts'; +import { storedTypesByStatus } from './run-status.ts'; const fact = { by: 'acme-admin', at: '2026-10-01T09:00:00.000Z' }; -const start: ExecutionEvent = { - type: 'execution_started', - primitive: 'echo', +const start: RunEvent = { + type: 'run_started', + definition_type: 'echo', name: 'greet', - spec_version: 1, + definition_version: 1, input: {}, ...fact, }; -const ofGreet = { primitive: 'echo', name: 'greet', spec_version: 1 }; +const ofGreet = { definition_type: 'echo', name: 'greet', definition_version: 1 }; -const latestOfEveryType: Readonly> = { - execution_started: start, - execution_deferred: { type: 'execution_deferred', record: {}, ...ofGreet, ...fact }, - execution_succeeded: { type: 'execution_succeeded', output: null, record: {}, ...ofGreet, ...fact }, - execution_rejected: { - type: 'execution_rejected', +const latestOfEveryType: Readonly> = { + run_started: start, + run_deferred: { type: 'run_deferred', record: {}, ...ofGreet, ...fact }, + run_succeeded: { type: 'run_succeeded', output: null, record: {}, ...ofGreet, ...fact }, + run_rejected: { + type: 'run_rejected', rejection: { reason: 'conflict', detail: 'x' }, ...ofGreet, ...fact, }, - execution_failed: { type: 'execution_failed', ...ofGreet, ...fact }, - execution_cancel_requested: { - type: 'execution_cancel_requested', + run_failed: { type: 'run_failed', ...ofGreet, ...fact }, + run_cancel_requested: { + type: 'run_cancel_requested', kind: 'requested', reason: 'Not needed', ...ofGreet, @@ -98,18 +98,16 @@ const statusByStoredType = Object.entries(storedTypesByStatus).flatMap( ([status, types]: readonly [string, readonly string[]]) => types.map((type) => [type, status] as const), ); -const latestByType = new Map( - Object.values(latestOfEveryType).map((event) => [event.type, event]), -); +const latestByType = new Map(Object.values(latestOfEveryType).map((event) => [event.type, event])); function statusAfter(type: string): string | undefined { const latest = latestByType.get(type); - const started = evolveExecution(executionDecider.initialState, start); - return latest === undefined ? undefined : runOf(evolveExecution(started, latest))?.execution.status; + const started = evolveRun(runDecider.initialState, start); + return latest === undefined ? undefined : startedRunOf(evolveRun(started, latest))?.run.status; } describe('the stored types of a status', () => { - it('name every fact of an execution once', () => { + it('name every fact of a run once', () => { expect(statusByStoredType.map(([type]) => type).toSorted()).toEqual(Object.keys(latestOfEveryType).toSorted()); }); diff --git a/packages/definitions/src/reading/run-status.ts b/packages/definitions/src/reading/run-status.ts new file mode 100644 index 000000000..64907d230 --- /dev/null +++ b/packages/definitions/src/reading/run-status.ts @@ -0,0 +1,20 @@ +import type { Run } from '../runs/run.ts'; + +export type RunStatus = Run['status']; + +export const storedTypesByStatus: Readonly> = { + started: [ + 'run_started', + 'run_deferred', + 'run_cancel_requested', + 'tool_call_started', + 'tool_call_answered', + 'delivery_started', + 'delivery_ended', + 'reply_taken', + 'reply_refused', + ], + succeeded: ['run_succeeded'], + rejected: ['run_rejected'], + failed: ['run_failed'], +}; diff --git a/packages/definitions/src/reading/tenant-data.test.ts b/packages/definitions/src/reading/tenant-data.test.ts new file mode 100644 index 000000000..814020770 --- /dev/null +++ b/packages/definitions/src/reading/tenant-data.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from 'vitest'; + +import { acmeAdmin } from '../testing/callers.ts'; +import { definitionOperationsFor } from '../testing/definition-operations.ts'; +import { echo } from '../testing/echo.ts'; +import { harness, toBrain } from '../testing/harness.ts'; + +const toAlpha = toBrain('acme', 'alpha'); + +const runId = '0199a3c4-7d2e-7c1a-9b3f-2f1e0d9c8b7a'; + +describe('a run whose input and output hold U+0000', () => { + it('lists, filters and reads like any other', async () => { + const { createDefinition, runDefinition, getRunHistory, listRuns } = definitionOperationsFor([echo]); + const { call } = harness(); + await call( + createDefinition, + toAlpha(acmeAdmin, { type: 'echo', name: 'greet', source: '{"greeting":"Hi\\u0000"}' }), + ); + await call( + runDefinition, + toAlpha(acmeAdmin, { type: 'echo', name: 'greet', input: { text: 'a\u0000b' }, run_id: runId }), + ); + + expect( + await call(listRuns, toAlpha(acmeAdmin, { status: 'succeeded', name: 'greet', type: 'echo' })), + ).toMatchObject({ status: 'succeeded', output: { runs: [{ run_id: runId }] } }); + expect(await call(getRunHistory, toAlpha(acmeAdmin, { run_id: runId }))).toMatchObject({ + status: 'succeeded', + output: { events: [{ data: { input_bytes: 19 } }, { data: { output_bytes: 51 } }] }, + }); + }); +}); diff --git a/packages/specs/src/reading/undecodable-events.test.ts b/packages/definitions/src/reading/undecodable-events.test.ts similarity index 53% rename from packages/specs/src/reading/undecodable-events.test.ts rename to packages/definitions/src/reading/undecodable-events.test.ts index 394d61139..0cd69fecc 100644 --- a/packages/specs/src/reading/undecodable-events.test.ts +++ b/packages/definitions/src/reading/undecodable-events.test.ts @@ -3,15 +3,15 @@ import { Effect, Result, Schema } from 'effect'; import { describe, expect, it } from 'vitest'; import { acmeAdmin } from '../testing/callers.ts'; +import { definitionOperationsFor } from '../testing/definition-operations.ts'; import { echo } from '../testing/echo.ts'; import { harness, toBrain } from '../testing/harness.ts'; -import { specOperationsFor } from '../testing/spec-operations.ts'; const toAlpha = toBrain('acme', 'alpha'); -const executionId = '0199a3c4-7d2e-7c1a-9b3f-2f1e0d9c8b7a'; +const runId = '0199a3c4-7d2e-7c1a-9b3f-2f1e0d9c8b7a'; -const BrokenSchema = Schema.Struct({ type: Schema.Literal('execution_started'), at: Schema.String }); +const BrokenSchema = Schema.Struct({ type: Schema.Literal('run_started'), at: Schema.String }); const broken: Decider = { initialState: null, @@ -20,22 +20,22 @@ const broken: Decider eventSchema: BrokenSchema, }; -describe('a stored event of an execution that no longer decodes', () => { +describe('a stored event of a run that no longer decodes', () => { it('fails the list of runs and the read of its history', async () => { - const { getExecutionHistory, listExecutions } = specOperationsFor([echo]); + const { getRunHistory, listRuns } = definitionOperationsFor([echo]); const { call, ledger, run } = harness(); await run( Effect.orDie( - ledger.service.execute(`brain/acme/alpha/executions/${executionId}`, broken, { - type: 'execution_started', + ledger.service.execute(`brain/acme/alpha/runs/${runId}`, broken, { + type: 'run_started', at: '2026-10-01T09:00:00.000Z', }), ), ); - expect(await call(getExecutionHistory, toAlpha(acmeAdmin, { execution_id: executionId }))).toMatchObject({ + expect(await call(getRunHistory, toAlpha(acmeAdmin, { run_id: runId }))).toMatchObject({ status: 'failed', }); - expect(await call(listExecutions, toAlpha(acmeAdmin))).toMatchObject({ status: 'failed' }); + expect(await call(listRuns, toAlpha(acmeAdmin))).toMatchObject({ status: 'failed' }); }); }); diff --git a/packages/definitions/src/registry/definition-changes.test.ts b/packages/definitions/src/registry/definition-changes.test.ts new file mode 100644 index 000000000..425a4a554 --- /dev/null +++ b/packages/definitions/src/registry/definition-changes.test.ts @@ -0,0 +1,40 @@ +import { Schema } from 'effect'; +import { describe, expect, it } from 'vitest'; + +import { definitionChangeOf } from './definition-changes.ts'; +import { DefinitionEventSchema, type DefinitionContent, type DefinitionEvent } from './definition-events.ts'; + +const encode = Schema.encodeSync(Schema.toCodecJson(DefinitionEventSchema)); + +const at = { by: 'acme-admin', at: '2026-10-01T09:00:00.000Z' }; + +const triggers: DefinitionContent['triggers'] = [ + { kind: 'cron', reference: '/schedule/cron', expression: '0 9 * * 1-5' }, + { kind: 'every', reference: '/schedule/every', milliseconds: 900_000 }, +]; + +const reacting: DefinitionContent = { source: 'schedule: ...', triggers }; + +const plain = { source: 'do: []' }; + +function changeOf(event: DefinitionEvent) { + return definitionChangeOf(encode(event)); +} + +describe('the change a record of a definition makes to what starts on its own', () => { + it('activates a version with triggers, and deactivates the definition at a version without or at its retirement', () => { + expect([ + changeOf({ type: 'definition_created', name: 'close', version: 1, content: reacting, ...at }), + changeOf({ type: 'definition_updated', name: 'close', version: 2, content: plain, ...at }), + changeOf({ type: 'definition_retired', name: 'close', ...at }), + changeOf({ type: 'definition_created', name: 'plain', version: 1, content: plain, ...at }), + definitionChangeOf({ type: 'definition_created' }), + ]).toEqual([ + { kind: 'activated', name: 'close', version: 1, triggers, at: at.at }, + { kind: 'deactivated', name: 'close' }, + { kind: 'deactivated', name: 'close' }, + { kind: 'unchanged' }, + { kind: 'unreadable' }, + ]); + }); +}); diff --git a/packages/specs/src/registry/spec-changes.ts b/packages/definitions/src/registry/definition-changes.ts similarity index 51% rename from packages/specs/src/registry/spec-changes.ts rename to packages/definitions/src/registry/definition-changes.ts index 63383762a..a0f268490 100644 --- a/packages/specs/src/registry/spec-changes.ts +++ b/packages/definitions/src/registry/definition-changes.ts @@ -1,9 +1,9 @@ import { Option, Schema } from 'effect'; -import { SpecEventSchema } from './spec-events.ts'; -import type { Trigger } from './spec-triggers.ts'; +import { DefinitionEventSchema } from './definition-events.ts'; +import type { Trigger } from './definition-triggers.ts'; -export type SpecChange = +export type DefinitionChange = | { readonly kind: 'activated'; readonly name: string; @@ -15,15 +15,15 @@ export type SpecChange = | { readonly kind: 'unchanged' } | { readonly kind: 'unreadable' }; -const decodeSpecEvent = Schema.decodeUnknownOption(Schema.toCodecJson(SpecEventSchema)); +const decodeDefinitionEvent = Schema.decodeUnknownOption(Schema.toCodecJson(DefinitionEventSchema)); -const unreadable: SpecChange = { kind: 'unreadable' }; +const unreadable: DefinitionChange = { kind: 'unreadable' }; -export function specChangeOf(data: unknown): SpecChange { - return Option.match(decodeSpecEvent(data), { +export function definitionChangeOf(data: unknown): DefinitionChange { + return Option.match(decodeDefinitionEvent(data), { onNone: () => unreadable, - onSome: (event): SpecChange => { - if (event.type === 'spec_retired') { + onSome: (event): DefinitionChange => { + if (event.type === 'definition_retired') { return { kind: 'deactivated', name: event.name }; } const { name, version, content, at } = event; @@ -31,7 +31,7 @@ export function specChangeOf(data: unknown): SpecChange { if (triggers.length > 0) { return { kind: 'activated', name, version, triggers, at }; } - return event.type === 'spec_updated' ? { kind: 'deactivated', name } : { kind: 'unchanged' }; + return event.type === 'definition_updated' ? { kind: 'deactivated', name } : { kind: 'unchanged' }; }, }); } diff --git a/packages/definitions/src/registry/definition-commands.ts b/packages/definitions/src/registry/definition-commands.ts new file mode 100644 index 000000000..069debc68 --- /dev/null +++ b/packages/definitions/src/registry/definition-commands.ts @@ -0,0 +1,27 @@ +import type { DefinitionContent } from './definition-events.ts'; + +export interface DefinitionCreation { + readonly type: 'create'; + readonly name: string; + readonly content: DefinitionContent; +} + +export interface DefinitionUpdate { + readonly type: 'update'; + readonly name: string; + readonly content: DefinitionContent; +} + +export interface DefinitionRetirement { + readonly type: 'retire'; + readonly name: string; +} + +export type DefinitionCommandData = DefinitionCreation | DefinitionUpdate | DefinitionRetirement; + +export interface CommandMetadata { + readonly by: string; + readonly at: string; +} + +export type DefinitionCommand = DefinitionCommandData & CommandMetadata; diff --git a/packages/definitions/src/registry/definition-events.ts b/packages/definitions/src/registry/definition-events.ts new file mode 100644 index 000000000..c2f37ff4a --- /dev/null +++ b/packages/definitions/src/registry/definition-events.ts @@ -0,0 +1,47 @@ +import { Schema } from 'effect'; + +import { TriggerSchema } from './definition-triggers.ts'; + +const DefinitionContentSchema = Schema.Struct({ + source: Schema.String, + description: Schema.optionalKey(Schema.String), + input_schema: Schema.optionalKey(Schema.JsonObject), + output_schema: Schema.optionalKey(Schema.JsonObject), + warnings: Schema.optionalKey(Schema.Array(Schema.String)), + triggers: Schema.optionalKey(Schema.Array(TriggerSchema)), + details: Schema.optionalKey(Schema.JsonObject), +}); + +export type DefinitionContent = typeof DefinitionContentSchema.Type; + +const fact = { name: Schema.String, by: Schema.String, at: Schema.String }; + +const DefinitionCreatedSchema = Schema.Struct({ + type: Schema.Literal('definition_created'), + ...fact, + version: Schema.Int, + content: DefinitionContentSchema, +}); + +const DefinitionUpdatedSchema = Schema.Struct({ + type: Schema.Literal('definition_updated'), + ...fact, + version: Schema.Int, + content: DefinitionContentSchema, +}); + +const DefinitionRetiredSchema = Schema.Struct({ type: Schema.Literal('definition_retired'), ...fact }); + +export const DefinitionEventSchema = Schema.Union([ + DefinitionCreatedSchema, + DefinitionUpdatedSchema, + DefinitionRetiredSchema, +]); + +export type DefinitionEvent = typeof DefinitionEventSchema.Type; + +export type DefinitionCreated = Extract; + +export type DefinitionUpdated = Extract; + +export type DefinitionRetired = Extract; diff --git a/packages/definitions/src/registry/definition-registry.ts b/packages/definitions/src/registry/definition-registry.ts new file mode 100644 index 000000000..02aa901c6 --- /dev/null +++ b/packages/definitions/src/registry/definition-registry.ts @@ -0,0 +1,39 @@ +import type { DefinitionCreated, DefinitionEvent, DefinitionRetired, DefinitionUpdated } from './definition-events.ts'; +import type { StoredDefinition } from './definition.ts'; + +export type DefinitionRegistry = ReadonlyMap; + +export const initialRegistry: DefinitionRegistry = new Map(); + +function createdDefinition({ name, version, content, by, at }: DefinitionCreated): StoredDefinition { + return { name, version, status: 'active', ...content, created_at: at, created_by: by, updated_at: at }; +} + +function updatedDefinition( + { name, status, created_at, created_by }: StoredDefinition, + { version, content, at }: DefinitionUpdated, +): StoredDefinition { + return { name, version, status, ...content, created_at, created_by, updated_at: at }; +} + +function retiredDefinition(definition: StoredDefinition, { at }: DefinitionRetired): StoredDefinition { + return { ...definition, status: 'retired', updated_at: at, retired_at: at }; +} + +function withDefinition(registry: DefinitionRegistry, definition: StoredDefinition): DefinitionRegistry { + return new Map(registry).set(definition.name, definition); +} + +export function evolveRegistry(registry: DefinitionRegistry, event: DefinitionEvent): DefinitionRegistry { + if (event.type === 'definition_created') { + return withDefinition(registry, createdDefinition(event)); + } + const definition = registry.get(event.name); + if (definition === undefined) { + return registry; + } + return withDefinition( + registry, + event.type === 'definition_updated' ? updatedDefinition(definition, event) : retiredDefinition(definition, event), + ); +} diff --git a/packages/specs/src/registry/spec-triggers.ts b/packages/definitions/src/registry/definition-triggers.ts similarity index 100% rename from packages/specs/src/registry/spec-triggers.ts rename to packages/definitions/src/registry/definition-triggers.ts diff --git a/packages/specs/src/registry/spec-versions.test.ts b/packages/definitions/src/registry/definition-versions.test.ts similarity index 50% rename from packages/specs/src/registry/spec-versions.test.ts rename to packages/definitions/src/registry/definition-versions.test.ts index a0c8abd80..d3063eeff 100644 --- a/packages/specs/src/registry/spec-versions.test.ts +++ b/packages/definitions/src/registry/definition-versions.test.ts @@ -1,21 +1,21 @@ import { Result } from 'effect'; import { describe, expect, it } from 'vitest'; -import type { SpecEvent } from './spec-events.ts'; -import { specVersionDecider } from './spec-versions.ts'; +import type { DefinitionEvent } from './definition-events.ts'; +import { definitionVersionDecider } from './definition-versions.ts'; const at = { by: 'acme-admin', at: '2026-10-01T09:00:00.000Z' }; -const history: readonly SpecEvent[] = [ - { type: 'spec_created', name: 'greet', version: 1, content: { source: 'one' }, ...at }, - { type: 'spec_created', name: 'other', version: 1, content: { source: 'else' }, ...at }, - { type: 'spec_updated', name: 'greet', version: 2, content: { source: 'two' }, ...at }, - { type: 'spec_retired', name: 'greet', ...at }, +const history: readonly DefinitionEvent[] = [ + { type: 'definition_created', name: 'greet', version: 1, content: { source: 'one' }, ...at }, + { type: 'definition_created', name: 'other', version: 1, content: { source: 'else' }, ...at }, + { type: 'definition_updated', name: 'greet', version: 2, content: { source: 'two' }, ...at }, + { type: 'definition_retired', name: 'greet', ...at }, ]; describe('a search for one version of a definition', () => { it('finds its document and the position of the record that made it, and decides nothing', () => { - const search = specVersionDecider('greet', 2); + const search = definitionVersionDecider('greet', 2); expect(history.reduce((found, event) => search.evolve(found, event), search.initialState)).toEqual({ position: 4, diff --git a/packages/specs/src/registry/spec-versions.ts b/packages/definitions/src/registry/definition-versions.ts similarity index 56% rename from packages/specs/src/registry/spec-versions.ts rename to packages/definitions/src/registry/definition-versions.ts index 3262836f8..fa0599e79 100644 --- a/packages/specs/src/registry/spec-versions.ts +++ b/packages/definitions/src/registry/definition-versions.ts @@ -1,7 +1,7 @@ import type { Decider } from '@beonauto/operations'; import { Result } from 'effect'; -import { SpecEventSchema, type SpecEvent } from './spec-events.ts'; +import { DefinitionEventSchema, type DefinitionEvent } from './definition-events.ts'; export interface RecordedVersion { readonly source: string; @@ -13,19 +13,22 @@ export interface VersionSearch { readonly found: RecordedVersion | undefined; } -function evolvedSearch(name: string, version: number): (search: VersionSearch, event: SpecEvent) => VersionSearch { +function evolvedSearch( + name: string, + version: number, +): (search: VersionSearch, event: DefinitionEvent) => VersionSearch { return ({ position, found }, event) => { const at = position + 1; - const isTheVersion = event.type !== 'spec_retired' && event.name === name && event.version === version; + const isTheVersion = event.type !== 'definition_retired' && event.name === name && event.version === version; return { position: at, found: isTheVersion ? { source: event.content.source, position: at } : found }; }; } -export function specVersionDecider(name: string, version: number): Decider { +export function definitionVersionDecider(name: string, version: number): Decider { return { initialState: { position: 0, found: undefined }, evolve: evolvedSearch(name, version), decide: () => Result.succeed([]), - eventSchema: SpecEventSchema, + eventSchema: DefinitionEventSchema, }; } diff --git a/packages/specs/src/registry/spec.test.ts b/packages/definitions/src/registry/definition.test.ts similarity index 76% rename from packages/specs/src/registry/spec.test.ts rename to packages/definitions/src/registry/definition.test.ts index 1a3a2fc54..0f7efe12a 100644 --- a/packages/specs/src/registry/spec.test.ts +++ b/packages/definitions/src/registry/definition.test.ts @@ -9,7 +9,7 @@ import { } from '../index.ts'; const reasoningFunction: Definition = { - primitive: 'inference', + type: 'reasoning', name: 'review-campaign', version: 3, status: 'active', @@ -20,13 +20,13 @@ const reasoningFunction: Definition = { updated_at: '2026-09-02T00:00:00.000Z', }; -const interactionFunction: Definition = { ...reasoningFunction, primitive: 'interaction', source: 'Approve?' }; +const interactionFunction: Definition = { ...reasoningFunction, type: 'interaction', source: 'Approve?' }; -const computationFunction: Definition = { ...reasoningFunction, primitive: 'computation', source: '.a + 1' }; +const computationFunction: Definition = { ...reasoningFunction, type: 'computation', source: '.a + 1' }; -const recallFunction: Definition = { ...reasoningFunction, primitive: 'recollection', source: '. + 1' }; +const recallFunction: Definition = { ...reasoningFunction, type: 'recall', source: '. + 1' }; -const workflow: Definition = { ...reasoningFunction, primitive: 'orchestration', media_type: 'application/yaml' }; +const workflow: Definition = { ...reasoningFunction, type: 'workflow', media_type: 'application/yaml' }; describe('a saved definition', () => { it('is a brain function when it is a reasoning, an interaction, a computation or a recall function, and a workflow when it is a workflow', () => { @@ -43,10 +43,10 @@ describe('a saved definition', () => { ]); }); - it.each(['echo', 'reason', 'workflow', 'interact', 'prediction', 'recall', 'compute'])( + it.each(['echo', 'reason', 'interact', 'prediction', 'compute'])( 'is neither of a custom adapter or a planned function type: %s', - (primitive) => { - const definition = { ...reasoningFunction, primitive }; + (type) => { + const definition = { ...reasoningFunction, type }; expect([isBrainFunctionDefinition(definition), isWorkflowDefinition(definition)]).toEqual([false, false]); }, diff --git a/packages/specs/src/registry/spec.ts b/packages/definitions/src/registry/definition.ts similarity index 83% rename from packages/specs/src/registry/spec.ts rename to packages/definitions/src/registry/definition.ts index 8c6b23b35..855f78178 100644 --- a/packages/specs/src/registry/spec.ts +++ b/packages/definitions/src/registry/definition.ts @@ -1,9 +1,11 @@ import { Schema } from 'effect'; -import { TriggerSchema } from './spec-triggers.ts'; +import { TriggerSchema } from './definition-triggers.ts'; const listedDefinitionFields = { - primitive: Schema.String.annotate({ description: 'The API type identifier of the definition' }), + type: Schema.String.annotate({ + description: 'The type of the definition: reasoning, interaction, computation, recall or workflow', + }), name: Schema.String.annotate({ description: 'The definition name, unique among definitions of its type in the brain and never reused', }), @@ -67,13 +69,13 @@ export const DefinitionSchema = Schema.Struct({ export type Definition = typeof DefinitionSchema.Type; -export type ReasoningFunctionDefinition = Definition & { readonly primitive: 'inference' }; +export type ReasoningFunctionDefinition = Definition & { readonly type: 'reasoning' }; -export type InteractionFunctionDefinition = Definition & { readonly primitive: 'interaction' }; +export type InteractionFunctionDefinition = Definition & { readonly type: 'interaction' }; -export type ComputationFunctionDefinition = Definition & { readonly primitive: 'computation' }; +export type ComputationFunctionDefinition = Definition & { readonly type: 'computation' }; -export type RecallFunctionDefinition = Definition & { readonly primitive: 'recollection' }; +export type RecallFunctionDefinition = Definition & { readonly type: 'recall' }; export type BrainFunctionDefinition = | ReasoningFunctionDefinition @@ -81,20 +83,20 @@ export type BrainFunctionDefinition = | ComputationFunctionDefinition | RecallFunctionDefinition; -const brainFunctionTypes: ReadonlySet = new Set(['inference', 'interaction', 'computation', 'recollection']); +const brainFunctionTypes: ReadonlySet = new Set(['reasoning', 'interaction', 'computation', 'recall']); -export type WorkflowDefinition = Definition & { readonly primitive: 'orchestration' }; +export type WorkflowDefinition = Definition & { readonly type: 'workflow' }; export function isBrainFunctionDefinition(definition: Definition): definition is BrainFunctionDefinition { - return brainFunctionTypes.has(definition.primitive); + return brainFunctionTypes.has(definition.type); } export function isWorkflowDefinition(definition: Definition): definition is WorkflowDefinition { - return definition.primitive === 'orchestration'; + return definition.type === 'workflow'; } export type ListedDefinition = typeof ListedDefinitionSchema.Type; -export type StoredDefinition = Omit & { +export type StoredDefinition = Omit & { readonly details?: Schema.JsonObject; }; diff --git a/packages/specs/src/registry/specs-decider.test.ts b/packages/definitions/src/registry/definitions-decider.test.ts similarity index 60% rename from packages/specs/src/registry/specs-decider.test.ts rename to packages/definitions/src/registry/definitions-decider.test.ts index 24dfa6e95..4a2635c54 100644 --- a/packages/specs/src/registry/specs-decider.test.ts +++ b/packages/definitions/src/registry/definitions-decider.test.ts @@ -2,59 +2,71 @@ import { Conflict, NotFound } from '@beonauto/operations'; import { Result } from 'effect'; import { describe, expect, it } from 'vitest'; -import type { SpecCommand } from './spec-commands.ts'; -import type { SpecContent, SpecEvent } from './spec-events.ts'; -import { specsDecider, specsStreamOf } from './specs-decider.ts'; +import type { DefinitionCommand } from './definition-commands.ts'; +import type { DefinitionContent, DefinitionEvent } from './definition-events.ts'; +import { definitionsDecider, definitionTypeStreamOf } from './definitions-decider.ts'; -const echoSpecs = specsDecider('echo'); +const echoDefinitions = definitionsDecider('echo'); const creation = { by: 'acme-admin', at: '2026-10-01T09:00:00.000Z' }; const change = { by: 'acme-editor', at: '2026-10-02T10:30:00.000Z' }; -const hello: SpecContent = { +const hello: DefinitionContent = { source: '{"greeting": "Hello"}', description: 'Greets', input_schema: { type: 'object' }, output_schema: { type: 'object' }, }; -const howdy: SpecContent = { source: '{"greeting": "Howdy"}' }; +const howdy: DefinitionContent = { source: '{"greeting": "Howdy"}' }; -const greetCreated: SpecEvent = { type: 'spec_created', name: 'greet', version: 1, content: hello, ...creation }; +const greetCreated: DefinitionEvent = { + type: 'definition_created', + name: 'greet', + version: 1, + content: hello, + ...creation, +}; -const greetUpdated: SpecEvent = { type: 'spec_updated', name: 'greet', version: 2, content: howdy, ...change }; +const greetUpdated: DefinitionEvent = { + type: 'definition_updated', + name: 'greet', + version: 2, + content: howdy, + ...change, +}; -const greetRetired: SpecEvent = { type: 'spec_retired', name: 'greet', ...change }; +const greetRetired: DefinitionEvent = { type: 'definition_retired', name: 'greet', ...change }; -function registryAfter(...events: readonly SpecEvent[]) { - return events.reduce((registry, event) => echoSpecs.evolve(registry, event), echoSpecs.initialState); +function registryAfter(...events: readonly DefinitionEvent[]) { + return events.reduce((registry, event) => echoDefinitions.evolve(registry, event), echoDefinitions.initialState); } -function decided(command: SpecCommand, ...history: readonly SpecEvent[]) { - return echoSpecs.decide(command, registryAfter(...history)); +function decided(command: DefinitionCommand, ...history: readonly DefinitionEvent[]) { + return echoDefinitions.decide(command, registryAfter(...history)); } -const creatingGreet: SpecCommand = { type: 'create', name: 'greet', content: hello, ...creation }; +const creatingGreet: DefinitionCommand = { type: 'create', name: 'greet', content: hello, ...creation }; -function updatingGreet(content: SpecContent): SpecCommand { +function updatingGreet(content: DefinitionContent): DefinitionCommand { return { type: 'update', name: 'greet', content, ...change }; } -const retiringGreet: SpecCommand = { type: 'retire', name: 'greet', ...change }; +const retiringGreet: DefinitionCommand = { type: 'retire', name: 'greet', ...change }; -describe('creating a spec', () => { +describe('creating a definition', () => { it('records it at version 1 with its content, who created it and when', () => { expect(decided(creatingGreet)).toStrictEqual(Result.succeed([greetCreated])); }); - it('is rejected while an active spec holds the name', () => { + it('is rejected while an active definition holds the name', () => { expect(decided(creatingGreet, greetCreated)).toEqual( Result.fail(new Conflict({ detail: 'The brain already has the echo definition greet', kind: 'taken' })), ); }); - it('is rejected for the name of a retired spec, because a name is never reused', () => { + it('is rejected for the name of a retired definition, because a name is never reused', () => { expect(decided(creatingGreet, greetCreated, greetRetired)).toEqual( Result.fail( new Conflict({ @@ -66,11 +78,11 @@ describe('creating a spec', () => { }); }); -describe('updating a spec', () => { +describe('updating a definition', () => { it('records a new version, one more than the last', () => { expect(decided(updatingGreet(howdy), greetCreated)).toStrictEqual(Result.succeed([greetUpdated])); expect(decided(updatingGreet(hello), greetCreated, greetUpdated)).toStrictEqual( - Result.succeed([{ type: 'spec_updated', name: 'greet', version: 3, content: hello, ...change }]), + Result.succeed([{ type: 'definition_updated', name: 'greet', version: 3, content: hello, ...change }]), ); }); @@ -78,13 +90,13 @@ describe('updating a spec', () => { expect(decided(updatingGreet(hello), greetCreated)).toStrictEqual(Result.succeed([])); }); - it('is rejected for a spec the brain does not have', () => { + it('is rejected for a definition the brain does not have', () => { expect(decided(updatingGreet(howdy))).toEqual( Result.fail(new NotFound({ detail: 'There is no echo definition greet in this brain' })), ); }); - it('is rejected for a retired spec', () => { + it('is rejected for a retired definition', () => { expect(decided(updatingGreet(howdy), greetCreated, greetRetired)).toEqual( Result.fail( new Conflict({ detail: 'The echo definition greet is retired and can no longer change', kind: 'retired' }), @@ -93,32 +105,32 @@ describe('updating a spec', () => { }); }); -describe('retiring a spec', () => { - it('records the retirement of an active spec', () => { +describe('retiring a definition', () => { + it('records the retirement of an active definition', () => { expect(decided(retiringGreet, greetCreated)).toStrictEqual(Result.succeed([greetRetired])); }); - it('records nothing for a spec that is already retired', () => { + it('records nothing for a definition that is already retired', () => { expect(decided(retiringGreet, greetCreated, greetRetired)).toStrictEqual(Result.succeed([])); }); - it('is rejected for a spec the brain does not have', () => { + it('is rejected for a definition the brain does not have', () => { expect(decided(retiringGreet)).toEqual( Result.fail(new NotFound({ detail: 'There is no echo definition greet in this brain' })), ); }); }); -describe('the specs of a primitive', () => { - it('start empty and live in one stream named after the primitive', () => { - expect(echoSpecs.initialState.size).toBe(0); - expect(specsStreamOf('echo')).toBe('specs/echo'); +describe('the definitions of a capability', () => { + it('start empty and live in one stream named after the capability', () => { + expect(echoDefinitions.initialState.size).toBe(0); + expect(definitionTypeStreamOf('echo')).toBe('definitions/echo'); }); - it('hold each spec as its events left it, its content replaced by every update', () => { + it('hold each definition as its events left it, its content replaced by every update', () => { const registry = registryAfter( greetCreated, - { type: 'spec_created', name: 'wave', version: 1, content: howdy, ...creation }, + { type: 'definition_created', name: 'wave', version: 1, content: howdy, ...creation }, greetUpdated, { ...greetRetired, at: '2026-10-03T08:00:00.000Z' }, ); @@ -146,30 +158,30 @@ describe('the specs of a primitive', () => { ]); }); - it('ignore an event about a spec they never saw created', () => { + it('ignore an event about a definition they never saw created', () => { expect(registryAfter(greetUpdated, greetRetired).size).toBe(0); }); it('leave the registry they evolve from untouched', () => { const before = registryAfter(greetCreated); - echoSpecs.evolve(before, greetRetired); + echoDefinitions.evolve(before, greetRetired); expect(before.get('greet')?.status).toBe('active'); }); }); -const threeTriggers: SpecContent['triggers'] = [ +const threeTriggers: DefinitionContent['triggers'] = [ { kind: 'event', reference: '/schedule/on', filters: [{ reference: '/schedule/on/one', type: 'x', attributes: {} }] }, { kind: 'cron', reference: '/schedule/cron', expression: '0 9 * * *' }, { kind: 'every', reference: '/schedule/every', milliseconds: 60_000 }, ]; -const reacting: SpecContent = { source: '{"greeting": "Hello", "triggers": "three"}', triggers: threeTriggers }; +const reacting: DefinitionContent = { source: '{"greeting": "Hello", "triggers": "three"}', triggers: threeTriggers }; -function reactingSpecs(count: number): readonly SpecEvent[] { +function reactingDefinitions(count: number): readonly DefinitionEvent[] { return Array.from({ length: count }, (_, index) => ({ - type: 'spec_created', + type: 'definition_created', name: `reacting-${index}`, version: 1, content: reacting, @@ -184,7 +196,7 @@ const beyondTheBound = new Conflict({ describe('the definitions of a brain that start on their own', () => { it('are 1,024 at most, however many triggers each has: one more is refused, created or made so by an update', () => { - const full = reactingSpecs(1024); + const full = reactingDefinitions(1024); expect([ decided({ ...creatingGreet, content: reacting }, ...full), @@ -194,9 +206,9 @@ describe('the definitions of a brain that start on their own', () => { }); it('are counted after a version replaces another, and without the retired ones', () => { - const almost = reactingSpecs(1023); - const retired: SpecEvent = { type: 'spec_retired', name: 'reacting-0', ...change }; - const reactingGreet: SpecEvent = { ...greetCreated, content: reacting }; + const almost = reactingDefinitions(1023); + const retired: DefinitionEvent = { type: 'definition_retired', name: 'reacting-0', ...change }; + const reactingGreet: DefinitionEvent = { ...greetCreated, content: reacting }; expect([ Result.isSuccess( @@ -207,7 +219,7 @@ describe('the definitions of a brain that start on their own', () => { ), ), Result.isSuccess( - decided({ ...creatingGreet, name: 'other', content: reacting }, ...reactingSpecs(1024), retired), + decided({ ...creatingGreet, name: 'other', content: reacting }, ...reactingDefinitions(1024), retired), ), ]).toEqual([true, true]); }); diff --git a/packages/definitions/src/registry/definitions-decider.ts b/packages/definitions/src/registry/definitions-decider.ts new file mode 100644 index 000000000..d51cb0695 --- /dev/null +++ b/packages/definitions/src/registry/definitions-decider.ts @@ -0,0 +1,22 @@ +import type { Decider } from '@beonauto/operations'; + +import type { DefinitionCommand } from './definition-commands.ts'; +import { DefinitionEventSchema, type DefinitionEvent } from './definition-events.ts'; +import { evolveRegistry, initialRegistry, type DefinitionRegistry } from './definition-registry.ts'; +import { decideOnDefinitions } from './registry-decisions.ts'; + +export function definitionsDecider( + type: string, + mostActive = Number.POSITIVE_INFINITY, +): Decider { + return { + initialState: initialRegistry, + evolve: evolveRegistry, + decide: (command, registry) => decideOnDefinitions({ type, mostActive }, command, registry), + eventSchema: DefinitionEventSchema, + }; +} + +export function definitionTypeStreamOf(type: string): string { + return `definitions/${type}`; +} diff --git a/packages/definitions/src/registry/registry-decisions.ts b/packages/definitions/src/registry/registry-decisions.ts new file mode 100644 index 000000000..fad1b53ef --- /dev/null +++ b/packages/definitions/src/registry/registry-decisions.ts @@ -0,0 +1,146 @@ +import { Conflict, plainNumber, type Rejection } from '@beonauto/operations'; +import { Result } from 'effect'; + +import { definitionResourceLabel } from '../capability/function-terminology.ts'; +import type { + CommandMetadata, + DefinitionCommand, + DefinitionCreation, + DefinitionRetirement, + DefinitionUpdate, +} from './definition-commands.ts'; +import type { DefinitionEvent } from './definition-events.ts'; +import type { DefinitionRegistry } from './definition-registry.ts'; +import { hasTriggers } from './definition-triggers.ts'; +import type { StoredDefinition } from './definition.ts'; +import { definitionNotFound } from './registry-lookup.ts'; + +type Decision = Result.Result>; + +const nothingToRecord: Decision = Result.succeed([]); + +export const mostReactingDefinitions = 1024; + +function reactingOtherThan(registry: DefinitionRegistry, name: string): number { + return [...registry.values()].filter( + (definition) => definition.status === 'active' && hasTriggers(definition) && definition.name !== name, + ).length; +} + +function beyondTheReactingBound( + type: string, + registry: DefinitionRegistry, + { name, content }: Pick, +): Conflict | undefined { + return hasTriggers(content) && reactingOtherThan(registry, name) >= mostReactingDefinitions + ? new Conflict({ + detail: `The brain already has ${mostReactingDefinitions} ${definitionResourceLabel(type)}s that start on their own, the most a brain holds; retire one, or save this one without its schedule`, + }) + : undefined; +} + +function recording(event: DefinitionEvent): Decision { + return Result.succeed([event]); +} + +function takenBy(type: string, { name, status }: StoredDefinition): Conflict { + return new Conflict({ + detail: + status === 'active' + ? `The brain already has the ${definitionResourceLabel(type)} ${name}` + : `The ${definitionResourceLabel(type)} ${name} was retired, and a definition name is never reused`, + kind: 'taken', + }); +} + +export interface RegistryRules { + readonly type: string; + readonly mostActive: number; +} + +function activeIn(registry: DefinitionRegistry): number { + return [...registry.values()].filter(({ status }) => status === 'active').length; +} + +function tooMany({ type, mostActive }: RegistryRules, active: number, saving: string): Conflict { + const label = definitionResourceLabel(type); + return new Conflict({ + detail: `The brain keeps ${plainNumber(active)} active ${label}s, and a brain may keep at most ${plainNumber(mostActive)}; retire one before ${saving}`, + }); +} + +function decideCreation( + rules: RegistryRules, + { name, content, by, at }: DefinitionCreation & CommandMetadata, + registry: DefinitionRegistry, +): Decision { + const existing = registry.get(name); + if (existing !== undefined) { + return Result.fail(takenBy(rules.type, existing)); + } + const active = activeIn(registry); + if (active >= rules.mostActive) { + return Result.fail(tooMany(rules, active, 'creating another')); + } + const beyond = beyondTheReactingBound(rules.type, registry, { name, content }); + return beyond === undefined + ? recording({ type: 'definition_created', name, version: 1, content, by, at }) + : Result.fail(beyond); +} + +function decideUpdate( + rules: RegistryRules, + { name, content, by, at }: DefinitionUpdate & CommandMetadata, + registry: DefinitionRegistry, +): Decision { + const { type } = rules; + const existing = registry.get(name); + if (existing === undefined) { + return Result.fail(definitionNotFound(type, name)); + } + if (existing.status === 'retired') { + return Result.fail( + new Conflict({ + detail: `The ${definitionResourceLabel(type)} ${name} is retired and can no longer change`, + kind: 'retired', + }), + ); + } + if (existing.source === content.source) { + return nothingToRecord; + } + const active = activeIn(registry); + if (active > rules.mostActive) { + return Result.fail(tooMany(rules, active, 'saving another version')); + } + const beyond = beyondTheReactingBound(type, registry, { name, content }); + return beyond === undefined + ? recording({ type: 'definition_updated', name, version: existing.version + 1, content, by, at }) + : Result.fail(beyond); +} + +function decideRetirement( + type: string, + { name, by, at }: DefinitionRetirement & CommandMetadata, + registry: DefinitionRegistry, +): Decision { + const existing = registry.get(name); + if (existing === undefined) { + return Result.fail(definitionNotFound(type, name)); + } + return existing.status === 'retired' ? nothingToRecord : recording({ type: 'definition_retired', name, by, at }); +} + +export function decideOnDefinitions( + rules: RegistryRules, + command: DefinitionCommand, + registry: DefinitionRegistry, +): Decision { + if (command.type === 'create') { + return decideCreation(rules, command, registry); + } + if (command.type === 'update') { + return decideUpdate(rules, command, registry); + } + return decideRetirement(rules.type, command, registry); +} diff --git a/packages/definitions/src/registry/registry-lookup.ts b/packages/definitions/src/registry/registry-lookup.ts new file mode 100644 index 000000000..8c51ceac9 --- /dev/null +++ b/packages/definitions/src/registry/registry-lookup.ts @@ -0,0 +1,19 @@ +import { NotFound } from '@beonauto/operations'; +import { Effect } from 'effect'; + +import { definitionResourceLabel } from '../capability/function-terminology.ts'; +import type { DefinitionRegistry } from './definition-registry.ts'; +import type { StoredDefinition } from './definition.ts'; + +export function definitionNotFound(type: string, name: string): NotFound { + return new NotFound({ detail: `There is no ${definitionResourceLabel(type)} ${name} in this brain` }); +} + +export function findDefinition( + registry: DefinitionRegistry, + type: string, + name: string, +): Effect.Effect { + const definition = registry.get(name); + return definition === undefined ? Effect.fail(definitionNotFound(type, name)) : Effect.succeed(definition); +} diff --git a/packages/specs/src/run-work/delivery-words.test.ts b/packages/definitions/src/run-work/delivery-words.test.ts similarity index 93% rename from packages/specs/src/run-work/delivery-words.test.ts rename to packages/definitions/src/run-work/delivery-words.test.ts index ce88d6bf5..c1cd9d7f4 100644 --- a/packages/specs/src/run-work/delivery-words.test.ts +++ b/packages/definitions/src/run-work/delivery-words.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from 'vitest'; -import type { DeliveryEndedFact } from '../execution/execution-commands.ts'; -import type { DeliveryBecause } from '../execution/execution-events.ts'; +import type { DeliveryEndedFact } from '../runs/run-commands.ts'; +import type { DeliveryBecause } from '../runs/run-events.ts'; import { deliveryEnded, deliveryStarted, throughTheTool } from './delivery-words.ts'; const failed: DeliveryEndedFact = { type: 'delivery_ended', number: 2, outcome: 'failed', duration_ms: 10 }; diff --git a/packages/specs/src/run-work/delivery-words.ts b/packages/definitions/src/run-work/delivery-words.ts similarity index 97% rename from packages/specs/src/run-work/delivery-words.ts rename to packages/definitions/src/run-work/delivery-words.ts index 97ccdc0c2..88ffe5636 100644 --- a/packages/specs/src/run-work/delivery-words.ts +++ b/packages/definitions/src/run-work/delivery-words.ts @@ -1,6 +1,6 @@ import { plainNumber } from '@beonauto/operations'; -import type { DeliveryBecause, DeliveryEnded, DeliveryStarted } from '../execution/execution-events.ts'; +import type { DeliveryBecause, DeliveryEnded, DeliveryStarted } from '../runs/run-events.ts'; const becauseWords: Readonly> = { timed_out: 'the tool server did not answer in time', diff --git a/packages/specs/src/run-work/outbound-call-decisions.test.ts b/packages/definitions/src/run-work/outbound-call-decisions.test.ts similarity index 72% rename from packages/specs/src/run-work/outbound-call-decisions.test.ts rename to packages/definitions/src/run-work/outbound-call-decisions.test.ts index 253513acd..b1ac4aef0 100644 --- a/packages/specs/src/run-work/outbound-call-decisions.test.ts +++ b/packages/definitions/src/run-work/outbound-call-decisions.test.ts @@ -5,32 +5,32 @@ import { describe, expect, it } from 'vitest'; import type { DeliveryEndedFact, DeliveryStartedFact, - ExecutionCommand, - ExecutionResult, + RunCommand, + RunResult, OutboundCallFact, -} from '../execution/execution-commands.ts'; -import { executionDecider } from '../execution/execution-decider.ts'; -import { answeredByAReply } from '../execution/execution-decisions.ts'; -import type { ExecutionEvent } from '../execution/execution-events.ts'; +} from '../runs/run-commands.ts'; +import { runDecider } from '../runs/run-decider.ts'; +import { answeredByAReply } from '../runs/run-decisions.ts'; +import type { RunEvent } from '../runs/run-events.ts'; const start = { by: 'acme-admin', at: '2026-10-01T09:00:00.000Z' }; const during = { by: 'brain:alpha', at: '2026-10-01T09:00:02.000Z' }; -const request = { primitive: 'interaction', name: 'approve-brief', input: { owner: 'ada' } }; +const request = { definition_type: 'interaction', name: 'approve-brief', input: { owner: 'ada' } }; -const ofApproval = { primitive: 'interaction', name: 'approve-brief', spec_version: 3 }; +const ofApproval = { definition_type: 'interaction', name: 'approve-brief', definition_version: 3 }; -const started: ExecutionEvent = { - type: 'execution_started', +const started: RunEvent = { + type: 'run_started', ...request, - spec_version: 3, + definition_version: 3, finishes_later: true, ...start, }; -const deferred: ExecutionEvent = { - type: 'execution_deferred', +const deferred: RunEvent = { + type: 'run_deferred', record: { to: 'ada', message: 'Approve?', @@ -41,10 +41,10 @@ const deferred: ExecutionEvent = { ...during, }; -const succeeded: ExecutionEvent = { type: 'execution_succeeded', output: {}, record: {}, ...ofApproval, ...during }; +const succeeded: RunEvent = { type: 'run_succeeded', output: {}, record: {}, ...ofApproval, ...during }; -const unavailable: ExecutionEvent = { - type: 'execution_rejected', +const unavailable: RunEvent = { + type: 'run_rejected', rejection: { reason: 'unavailable', detail: 'The tool server is gone' }, ...ofApproval, ...during, @@ -66,19 +66,19 @@ const ended: DeliveryEndedFact = { duration_ms: 40, }; -const attemptStarted: ExecutionEvent = { ...attempt, ...ofApproval, ...during }; +const attemptStarted: RunEvent = { ...attempt, ...ofApproval, ...during }; -const attemptEnded: ExecutionEvent = { ...ended, ...ofApproval, ...during }; +const attemptEnded: RunEvent = { ...ended, ...ofApproval, ...during }; -function stateAfter(...events: readonly ExecutionEvent[]) { - return events.reduce((state, event) => executionDecider.evolve(state, event), executionDecider.initialState); +function stateAfter(...events: readonly RunEvent[]) { + return events.reduce((state, event) => runDecider.evolve(state, event), runDecider.initialState); } -function decided(command: ExecutionCommand, ...history: readonly ExecutionEvent[]) { - return executionDecider.decide(command, stateAfter(...history)); +function decided(command: RunCommand, ...history: readonly RunEvent[]) { + return runDecider.decide(command, stateAfter(...history)); } -function recording(fact: OutboundCallFact): ExecutionCommand { +function recording(fact: OutboundCallFact): RunCommand { return { type: 'outbound_call', fact, ...during }; } @@ -97,7 +97,7 @@ describe('the deferral of a run', () => { { type: 'finish', result: { - type: 'execution_deferred', + type: 'run_deferred', record: { to: 'ada', message: 'Approve?', @@ -152,8 +152,8 @@ describe('the end of a delivery', () => { describe('the deliveries of a run asked to cancel', () => { it('starts no attempt once a cancel is asked, though the attempt in flight may still end', () => { - const cancelAsked: ExecutionEvent = { - type: 'execution_cancel_requested', + const cancelAsked: RunEvent = { + type: 'run_cancel_requested', kind: 'requested', reason: 'No longer needed', ...during, @@ -170,7 +170,7 @@ describe('the deliveries of a run asked to cancel', () => { describe('a run a reply answered', () => { it('leaves the run to be settled with that answer alone, whoever settles it and however', () => { - const taken: ExecutionEvent = { + const taken: RunEvent = { type: 'reply_taken', server: 'chat', tool: 'thread_replies', @@ -179,14 +179,14 @@ describe('a run a reply answered', () => { ...ofApproval, ...during, }; - const settling = (result: ExecutionResult): ExecutionCommand => ({ type: 'settle', result, ...during }); - const withTheAnswer = settling({ type: 'execution_succeeded', output: { choice: 'approve' }, record: {} }); + const settling = (result: RunResult): RunCommand => ({ type: 'settle', result, ...during }); + const withTheAnswer = settling({ type: 'run_succeeded', output: { choice: 'approve' }, record: {} }); const history = [started, deferred, attemptStarted, taken]; expect([ - decided(settling({ type: 'execution_succeeded', output: { choice: 'reject' }, record: {} }), ...history), + decided(settling({ type: 'run_succeeded', output: { choice: 'reject' }, record: {} }), ...history), decided( - settling({ type: 'execution_rejected', rejection: { reason: 'cancelled', kind: 'requested', detail: 'Off' } }), + settling({ type: 'run_rejected', rejection: { reason: 'cancelled', kind: 'requested', detail: 'Off' } }), ...history, ), decided(withTheAnswer, ...history), @@ -194,15 +194,15 @@ describe('a run a reply answered', () => { ]).toMatchObject([ Result.fail(answeredByAReply), Result.fail(answeredByAReply), - Result.succeed([{ type: 'execution_succeeded', output: { choice: 'approve' } }]), - Result.succeed([{ type: 'execution_succeeded', output: { choice: 'approve' } }]), + Result.succeed([{ type: 'run_succeeded', output: { choice: 'approve' } }]), + Result.succeed([{ type: 'run_succeeded', output: { choice: 'approve' } }]), ]); }); }); describe('the end of a delivery that delivered', () => { it('is kept by the run as when it was delivered, for a cancel of a notification, and brings no answer', () => { - const delivered: ExecutionEvent = { ...ended, outcome: 'delivered', ...ofApproval, ...during }; + const delivered: RunEvent = { ...ended, outcome: 'delivered', ...ofApproval, ...during }; expect(stateAfter(started, deferred, attemptStarted, delivered)).toMatchObject({ broughtAnswer: null, @@ -222,10 +222,10 @@ describe('the end of a delivery of a run that ended or starts again', () => { lastCall: 1, mayHaveChanged: false, }); - const startingAgain: ExecutionCommand = { + const startingAgain: RunCommand = { type: 'start', ...request, - spec_version: 3, + definition_version: 3, calls_tools: false, finishes_later: true, ...start, diff --git a/packages/specs/src/run-work/outbound-calls.test.ts b/packages/definitions/src/run-work/outbound-calls.test.ts similarity index 84% rename from packages/specs/src/run-work/outbound-calls.test.ts rename to packages/definitions/src/run-work/outbound-calls.test.ts index 3a71da17b..fc3c581d4 100644 --- a/packages/specs/src/run-work/outbound-calls.test.ts +++ b/packages/definitions/src/run-work/outbound-calls.test.ts @@ -3,26 +3,26 @@ import { memoryLedger } from '@beonauto/operations/testing'; import { Effect, Result } from 'effect'; import { describe, expect, it } from 'vitest'; -import type { DeliveryStartedFact } from '../execution/execution-commands.ts'; -import { executionDecider } from '../execution/execution-decider.ts'; -import { brainBoundSettler } from '../execution/execution-settler.ts'; +import type { DeliveryStartedFact } from '../runs/run-commands.ts'; +import { runDecider } from '../runs/run-decider.ts'; +import { brainBoundSettler } from '../runs/run-settler.ts'; import { outboundCallRecorder, replyRecorder } from './outbound-calls.ts'; -import { executionEventOf, recordedRunIn, recordedRunInBrain } from './recorded-runs.ts'; +import { runEventOf, recordedRunIn, recordedRunInBrain } from './recorded-runs.ts'; const run = { org: 'acme', brain: 'alpha', id: '0199a3c4-7d2e-7c1a-9b3f-2f1e0d9c8b7a' }; -const stream = `brain/acme/alpha/executions/${run.id}`; +const stream = `brain/acme/alpha/runs/${run.id}`; const fact = { by: 'acme-admin', at: '2026-10-01T09:00:00.000Z' }; -const ofApproval = { primitive: 'interaction', name: 'approve-brief', spec_version: 1 }; +const ofApproval = { definition_type: 'interaction', name: 'approve-brief', definition_version: 1 }; const lineage = { causationId: 'request-1', correlationId: run.id }; async function aDeferredRun() { const ledger = memoryLedger(); await Effect.runPromise( - ledger.service.execute(stream, executionDecider, { + ledger.service.execute(stream, runDecider, { ...ofApproval, type: 'start', input: { owner: 'ada' }, @@ -32,10 +32,10 @@ async function aDeferredRun() { }), ); await Effect.runPromise( - ledger.service.execute(stream, executionDecider, { + ledger.service.execute(stream, runDecider, { type: 'finish', result: { - type: 'execution_deferred', + type: 'run_deferred', record: { to: 'ada', message: 'Approve?', @@ -78,17 +78,15 @@ describe('the outbound calls of a run', () => { ); const endedId = ended.id; const { records } = await Effect.runPromise( - ledger.service.readRecorded(run, { kind: 'run', execution: run.id }, { order: 'asc', limit: 10 }), + ledger.service.readRecorded(run, { kind: 'run', run: run.id }, { order: 'asc', limit: 10 }), ); expect([startedId, endedId]).toEqual([messageIdOf(stream, 3), messageIdOf(stream, 4)]); - expect( - records.slice(2).map(({ type, causationId, data }) => [type, causationId, executionEventOf(data)?.by]), - ).toEqual([ + expect(records.slice(2).map(({ type, causationId, data }) => [type, causationId, runEventOf(data)?.by])).toEqual([ ['delivery_started', 'request-1', 'brain:alpha'], ['delivery_ended', startedId, 'brain:alpha'], ]); - expect(records.slice(2).map(({ data }) => executionEventOf(data)?.at)).toEqual([started.at, ended.at]); + expect(records.slice(2).map(({ data }) => runEventOf(data)?.at)).toEqual([started.at, ended.at]); }); }); @@ -121,7 +119,7 @@ describe('a run as it was recorded', () => { expect(read).toMatchObject({ run: { - execution_id: run.id, + run_id: run.id, status: 'started', record: { to: 'ada', @@ -141,7 +139,7 @@ describe('a run as it was recorded', () => { expect( await Effect.runPromise(recordedRunIn(ledger.service, { ...run, id: '0199a3c4-7d2e-7c1a-9b3f-000000000000' })), ).toBeUndefined(); - expect(executionEventOf({ type: 'not_an_event' })).toBeUndefined(); + expect(runEventOf({ type: 'not_an_event' })).toBeUndefined(); }); }); diff --git a/packages/specs/src/run-work/outbound-calls.ts b/packages/definitions/src/run-work/outbound-calls.ts similarity index 75% rename from packages/specs/src/run-work/outbound-calls.ts rename to packages/definitions/src/run-work/outbound-calls.ts index bccb6dd3b..1e5fa22d2 100644 --- a/packages/specs/src/run-work/outbound-calls.ts +++ b/packages/definitions/src/run-work/outbound-calls.ts @@ -10,14 +10,9 @@ import { } from '@beonauto/operations'; import { DateTime, Effect, Schema } from 'effect'; -import type { - CommandMetadata, - ExecutionCommand, - OutboundCallFact, - ReplyFact, -} from '../execution/execution-commands.ts'; -import { executionDecider, executionStreamOf } from '../execution/execution-decider.ts'; -import type { ExecutionAddress } from '../execution/execution-settler.ts'; +import type { CommandMetadata, RunCommand, OutboundCallFact, ReplyFact } from '../runs/run-commands.ts'; +import { runDecider, runStreamNameOf } from '../runs/run-decider.ts'; +import type { RunStreamAddress } from '../runs/run-settler.ts'; export interface RecordedOutboundCall { readonly id: string; @@ -25,7 +20,7 @@ export interface RecordedOutboundCall { } export type RecordRunWork = ( - run: ExecutionAddress, + run: RunStreamAddress, fact: Fact, lineage: Lineage, ) => Effect.Effect; @@ -38,7 +33,7 @@ const isWellFormed = Schema.is( const noSuchRun = new Conflict({ detail: 'There is no such run in this brain, so it records no work' }); -type CommandOf = (fact: Fact, recorded: CommandMetadata) => ExecutionCommand; +type CommandOf = (fact: Fact, recorded: CommandMetadata) => RunCommand; function runWorkRecorder(ledger: StreamWriter, commandOf: CommandOf): RecordRunWork { return (run, fact, lineage) => @@ -46,10 +41,10 @@ function runWorkRecorder(ledger: StreamWriter, commandOf: CommandOf) if (!isWellFormed(run)) { return yield* noSuchRun; } - const stream = `${streamPrefixOfBrain(run)}${executionStreamOf(run.id.toLowerCase())}`; + const stream = `${streamPrefixOfBrain(run)}${runStreamNameOf(run.id.toLowerCase())}`; const at = DateTime.formatIso(yield* DateTime.now); const { version } = yield* ledger - .execute(stream, executionDecider, commandOf(fact, { by: brainCallerOf(run).id, at }), lineage) + .execute(stream, runDecider, commandOf(fact, { by: brainCallerOf(run).id, at }), lineage) .pipe(Effect.catchTags({ not_found: Effect.die, cancelled: Effect.die })); return { id: messageIdOf(stream, version), at }; }); diff --git a/packages/definitions/src/run-work/recorded-runs.ts b/packages/definitions/src/run-work/recorded-runs.ts new file mode 100644 index 000000000..9e7d9bfdf --- /dev/null +++ b/packages/definitions/src/run-work/recorded-runs.ts @@ -0,0 +1,53 @@ +import { BrainIdSchema, OrgIdSchema, streamPrefixOfBrain, type StreamReader } from '@beonauto/operations'; +import { Effect, Option, Schema } from 'effect'; + +import { runDecider, runStreamNameOf } from '../runs/run-decider.ts'; +import { RunEventSchema, type RunEvent } from '../runs/run-events.ts'; +import { runDetailOf } from '../runs/run-lookup.ts'; +import type { RunStreamAddress } from '../runs/run-settler.ts'; +import { startedRunOf, takesSettlement, type RunStreamState } from '../runs/run-state.ts'; +import type { RunDetail } from '../runs/run.ts'; + +export interface RecordedRun { + readonly run: RunDetail; + readonly input: Schema.Json; + readonly awaitsSettlement: boolean; + readonly lastCall: number; +} + +const isWellFormed = Schema.is( + Schema.Struct({ org: OrgIdSchema, brain: BrainIdSchema, id: Schema.String.check(Schema.isUUID()) }), +); + +const decodeRunEvent = Schema.decodeUnknownOption(Schema.toCodecJson(RunEventSchema)); + +export function runEventOf(data: unknown): RunEvent | undefined { + return Option.getOrUndefined(decodeRunEvent(data)); +} + +function recordedOf(id: string, state: RunStreamState): Effect.Effect { + const run = startedRunOf(state); + return run === undefined + ? Effect.undefined + : Effect.map(Effect.orDie(runDetailOf(id, state)), (detail) => ({ + run: detail, + input: run.input, + awaitsSettlement: takesSettlement(run), + lastCall: run.lastCall, + })); +} + +export function recordedRunIn(reader: StreamReader, address: RunStreamAddress): Effect.Effect { + if (!isWellFormed(address)) { + return Effect.undefined; + } + const id = address.id.toLowerCase(); + return reader + .load(`${streamPrefixOfBrain(address)}${runStreamNameOf(id)}`, runDecider) + .pipe(Effect.flatMap(({ state }) => recordedOf(id, state))); +} + +export function recordedRunInBrain(reader: StreamReader, id: string): Effect.Effect { + const run = id.toLowerCase(); + return reader.load(runStreamNameOf(run), runDecider).pipe(Effect.flatMap(({ state }) => recordedOf(run, state))); +} diff --git a/packages/specs/src/run-work/reply-decisions.test.ts b/packages/definitions/src/run-work/reply-decisions.test.ts similarity index 80% rename from packages/specs/src/run-work/reply-decisions.test.ts rename to packages/definitions/src/run-work/reply-decisions.test.ts index e02daca15..e790c58f7 100644 --- a/packages/specs/src/run-work/reply-decisions.test.ts +++ b/packages/definitions/src/run-work/reply-decisions.test.ts @@ -3,40 +3,40 @@ import { Result } from 'effect'; import { describe, expect, it } from 'vitest'; import { brainFactOf } from '../events/brain-facts.ts'; -import type { ExecutionCommand, ReplyFact } from '../execution/execution-commands.ts'; -import { executionDecider } from '../execution/execution-decider.ts'; -import type { ExecutionEvent } from '../execution/execution-events.ts'; +import type { RunCommand, ReplyFact } from '../runs/run-commands.ts'; +import { runDecider } from '../runs/run-decider.ts'; +import type { RunEvent } from '../runs/run-events.ts'; const start = { by: 'acme-admin', at: '2026-10-01T09:00:00.000Z' }; const during = { by: 'brain:alpha', at: '2026-10-01T09:00:07.000Z' }; -const ofApproval = { primitive: 'interaction', name: 'approve-brief', spec_version: 3 }; +const ofApproval = { definition_type: 'interaction', name: 'approve-brief', definition_version: 3 }; -const started: ExecutionEvent = { - type: 'execution_started', +const started: RunEvent = { + type: 'run_started', ...ofApproval, input: { owner: 'U024BE7LH' }, finishes_later: true, ...start, }; -const deferred: ExecutionEvent = { - type: 'execution_deferred', +const deferred: RunEvent = { + type: 'run_deferred', record: { to: 'U024BE7LH' }, ...ofApproval, ...during, }; -const cancelAsked: ExecutionEvent = { - type: 'execution_cancel_requested', +const cancelAsked: RunEvent = { + type: 'run_cancel_requested', kind: 'requested', reason: 'No longer needed', ...ofApproval, ...during, }; -const succeeded: ExecutionEvent = { type: 'execution_succeeded', output: {}, record: {}, ...ofApproval, ...during }; +const succeeded: RunEvent = { type: 'run_succeeded', output: {}, record: {}, ...ofApproval, ...during }; function reply(id: string) { return { id, sender: 'U024BE7LH' }; @@ -55,19 +55,19 @@ function refusal(id: string): ReplyFact { return { type: 'reply_refused', ...reading, reply: reply(id), because: 'not_an_answer', told: true }; } -function stateAfter(...events: readonly ExecutionEvent[]) { - return events.reduce((state, event) => executionDecider.evolve(state, event), executionDecider.initialState); +function stateAfter(...events: readonly RunEvent[]) { + return events.reduce((state, event) => runDecider.evolve(state, event), runDecider.initialState); } -function replying(fact: ReplyFact): ExecutionCommand { +function replying(fact: ReplyFact): RunCommand { return { type: 'reply', fact, ...during }; } -function decided(fact: ReplyFact, ...history: readonly ExecutionEvent[]) { - return executionDecider.decide(replying(fact), stateAfter(...history)); +function decided(fact: ReplyFact, ...history: readonly RunEvent[]) { + return runDecider.decide(replying(fact), stateAfter(...history)); } -function recorded(fact: ReplyFact): ExecutionEvent { +function recorded(fact: ReplyFact): RunEvent { return { ...fact, ...ofApproval, ...during }; } @@ -137,7 +137,7 @@ function recordOf(fact: ReplyFact) { cursor: 'WyJicmFpbi9hY21lL2FscGhhLyIsIjEiXQ', causationId: null, correlationId: null, - stream: 'executions/0199a3c4-7d2e-7c1a-9b3f-2f1e0d9c8b7a', + stream: 'runs/0199a3c4-7d2e-7c1a-9b3f-2f1e0d9c8b7a', version: 3, type: fact.type, data: recorded(fact), diff --git a/packages/specs/src/run-work/reply-words.ts b/packages/definitions/src/run-work/reply-words.ts similarity index 85% rename from packages/specs/src/run-work/reply-words.ts rename to packages/definitions/src/run-work/reply-words.ts index 700c435bc..3f40cc372 100644 --- a/packages/specs/src/run-work/reply-words.ts +++ b/packages/definitions/src/run-work/reply-words.ts @@ -1,4 +1,4 @@ -import type { ReplyEvent } from '../execution/execution-events.ts'; +import type { ReplyEvent } from '../runs/run-events.ts'; import { throughTheTool } from './delivery-words.ts'; export function replyInWords(event: ReplyEvent): string { diff --git a/packages/specs/src/run-work/run-work-accounts.ts b/packages/definitions/src/run-work/run-work-accounts.ts similarity index 92% rename from packages/specs/src/run-work/run-work-accounts.ts rename to packages/definitions/src/run-work/run-work-accounts.ts index 5c8a9ab23..fc110958c 100644 --- a/packages/specs/src/run-work/run-work-accounts.ts +++ b/packages/definitions/src/run-work/run-work-accounts.ts @@ -1,13 +1,4 @@ -import type { - DeliveredAs, - DeliveryEnded, - DeliveryEvent, - DeliveryStarted, - ExecutionDeferred, - RepliesIn, - ReplyEvent, -} from '../execution/execution-events.ts'; -import { jsonBytesOf } from '../execution/recorded-size.ts'; +import type { RunWords } from '../capability/capability.ts'; import { cutAtCodePoint, issuesShown, @@ -17,11 +8,20 @@ import { mostNameBytes, } from '../presenting/event-data.ts'; import type { Account } from '../presenting/event-presenter.ts'; -import type { RunWords } from '../primitive/primitive.ts'; +import { jsonBytesOf } from '../runs/recorded-size.ts'; +import type { + DeliveredAs, + DeliveryEnded, + DeliveryEvent, + DeliveryStarted, + RunDeferred, + RepliesIn, + ReplyEvent, +} from '../runs/run-events.ts'; import { replyInWords } from './reply-words.ts'; interface Fact { - readonly execution_id: string; + readonly run_id: string; readonly by: string; } @@ -29,7 +29,7 @@ export interface TypedAccount extends Account { readonly type: string; } -export function deferralAccount(words: RunWords, event: ExecutionDeferred, fact: Fact): TypedAccount | undefined { +export function deferralAccount(words: RunWords, event: RunDeferred, fact: Fact): TypedAccount | undefined { const account = words.deferral(event.record); return account === undefined ? undefined diff --git a/packages/specs/src/run-work/run-work-presenter.test.ts b/packages/definitions/src/run-work/run-work-presenter.test.ts similarity index 81% rename from packages/specs/src/run-work/run-work-presenter.test.ts rename to packages/definitions/src/run-work/run-work-presenter.test.ts index c21fb9f53..65630a672 100644 --- a/packages/specs/src/run-work/run-work-presenter.test.ts +++ b/packages/definitions/src/run-work/run-work-presenter.test.ts @@ -2,14 +2,14 @@ import { presentationOf, type RecordedEvent } from '@beonauto/operations'; import { Schema } from 'effect'; import { describe, expect, it } from 'vitest'; -import { ExecutionEventSchema, type ExecutionEvent } from '../execution/execution-events.ts'; -import { makeSpecPresenters } from '../presenting/spec-presenters.ts'; -import { defaultRunWords, type Primitive } from '../primitive/primitive.ts'; +import { defaultRunWords, type Capability } from '../capability/capability.ts'; +import { makeDefinitionPresenters } from '../presenting/definition-presenters.ts'; +import { RunEventSchema, type RunEvent } from '../runs/run-events.ts'; import { echo } from '../testing/echo.ts'; -const asking: Primitive = { +const asking: Capability = { ...echo, - name: 'asking', + type: 'asking', runWords: { ...defaultRunWords, deferralType: 'interaction_requested', @@ -20,23 +20,23 @@ const asking: Primitive = { }, }; -const { present, storedTypesOf } = presentationOf(makeSpecPresenters([echo, asking])); +const { present, storedTypesOf } = presentationOf(makeDefinitionPresenters([echo, asking])); -const encode = Schema.encodeSync(Schema.toCodecJson(ExecutionEventSchema)); +const encode = Schema.encodeSync(Schema.toCodecJson(RunEventSchema)); -const executionId = '0199a3c4-7d2e-7c1a-9b3f-2f1e0d9c8b7a'; +const runId = '0199a3c4-7d2e-7c1a-9b3f-2f1e0d9c8b7a'; const fact = { by: 'brain:alpha', at: '2026-10-01T09:00:01.000Z' }; -const ofAsking = { primitive: 'asking', name: 'approve', spec_version: 1 }; +const ofAsking = { definition_type: 'asking', name: 'approve', definition_version: 1 }; -function presented(event: ExecutionEvent) { +function presented(event: RunEvent) { const record: RecordedEvent = { id: '0b1c2d3e-4f50-5a6b-8c7d-8e9fa0b1c2d3', cursor: 'WyJicmFpbi9hY21lL2FscGhhLyIsIjEiXQ', causationId: 'request-1', - correlationId: executionId, - stream: `executions/${executionId}`, + correlationId: runId, + stream: `runs/${runId}`, version: 2, type: event.type, data: encode(event), @@ -47,16 +47,16 @@ function presented(event: ExecutionEvent) { describe('the deferral of a run whose capability gives words of it', () => { it('is named by the public type the capability gives it, beside the one every other capability shows', () => { - expect([storedTypesOf('interaction_requested'), storedTypesOf('execution_deferred')]).toEqual([ - ['execution_deferred'], - ['execution_deferred'], + expect([storedTypesOf('interaction_requested'), storedTypesOf('run_deferred')]).toEqual([ + ['run_deferred'], + ['run_deferred'], ]); }); it('is the request, in the words of the capability, with the size of its record', () => { expect( presented({ - type: 'execution_deferred', + type: 'run_deferred', record: { to: 'ada', message: 'Approve?', @@ -70,7 +70,7 @@ describe('the deferral of a run whose capability gives words of it', () => { { type: 'interaction_requested', summary: 'A request is waiting for an answer.', - data: { execution_id: executionId, by: 'brain:alpha', record_bytes: 115, to: 'ada' }, + data: { run_id: runId, by: 'brain:alpha', record_bytes: 115, to: 'ada' }, }, ]); }); @@ -78,7 +78,7 @@ describe('the deferral of a run whose capability gives words of it', () => { const delivery = { delivery: { server: 'chat', tool: 'post_message' } }; -const started: ExecutionEvent = { +const started: RunEvent = { type: 'delivery_started', number: 1, target: 'ada', @@ -91,7 +91,7 @@ const started: ExecutionEvent = { ...fact, }; -const ended: ExecutionEvent = { +const ended: RunEvent = { type: 'delivery_ended', number: 1, outcome: 'failed', @@ -114,7 +114,7 @@ describe('the start of a delivery', () => { type: 'delivery_started', summary: 'Delivery attempt 1 of the request started, through the tool post_message of chat.', data: { - execution_id: executionId, + run_id: runId, by: 'brain:alpha', number: 1, ...delivery, @@ -128,7 +128,7 @@ describe('the start of a delivery', () => { }); it('is its attempt, the tool and its target alone for an attempt that made no call', () => { - const bare: ExecutionEvent = { + const bare: RunEvent = { type: 'delivery_started', number: 2, target: 'ada', @@ -139,7 +139,7 @@ describe('the start of a delivery', () => { }; expect(presented(bare)).toMatchObject([ - { data: { execution_id: executionId, by: 'brain:alpha', number: 2, ...delivery, target: 'ada' } }, + { data: { run_id: runId, by: 'brain:alpha', number: 2, ...delivery, target: 'ada' } }, ]); }); }); @@ -152,7 +152,7 @@ describe('the end of a delivery', () => { summary: 'Delivery attempt 1 failed, because the tool server failed; another follows on the schedule, unless it was the last.', data: { - execution_id: executionId, + run_id: runId, by: 'brain:alpha', number: 1, outcome: 'failed', @@ -172,7 +172,7 @@ describe('the end of a delivery', () => { describe('the end of a delivery that answered', () => { it('shows what the tool answered, at 2 KiB, and what the message was delivered as and where replies are read', () => { - const delivered: ExecutionEvent = { + const delivered: RunEvent = { type: 'delivery_ended', number: 1, outcome: 'delivered', @@ -206,14 +206,14 @@ describe('the end of a delivery that answered', () => { describe('the end of a delivery of a capability the server no longer has', () => { it('is in the words every capability gives', () => { - const delivered: ExecutionEvent = { + const delivered: RunEvent = { type: 'delivery_ended', number: 2, outcome: 'delivered', duration_ms: 5, - primitive: 'gone', + definition_type: 'gone', name: 'approve', - spec_version: 1, + definition_version: 1, ...fact, }; @@ -221,7 +221,7 @@ describe('the end of a delivery of a capability the server no longer has', () => { type: 'delivery_ended', summary: 'Delivery attempt 2 was delivered.', - data: { execution_id: executionId, by: 'brain:alpha', number: 2, outcome: 'delivered', duration_ms: 5 }, + data: { run_id: runId, by: 'brain:alpha', number: 2, outcome: 'delivered', duration_ms: 5 }, }, ]); }); @@ -239,7 +239,7 @@ describe('a reply the run took or refused', () => { { type: 'reply_taken', summary: 'A reply from the party answered the request, read through the tool thread_replies of chat.', - data: { execution_id: executionId, by: 'brain:alpha', ...shownReply, answer_bytes: 20 }, + data: { run_id: runId, by: 'brain:alpha', ...shownReply, answer_bytes: 20 }, }, ]); }); @@ -261,13 +261,13 @@ describe('a reply the run refused', () => { { type: 'reply_refused', summary: 'A reply from the party was not an answer the function takes, and the party was told how to answer.', - data: { execution_id: executionId, by: 'brain:alpha', ...shownReply, because: 'not_an_answer', told: true }, + data: { run_id: runId, by: 'brain:alpha', ...shownReply, because: 'not_an_answer', told: true }, }, { type: 'reply_refused', summary: 'A reply from the party was not an answer the function takes, and nobody was told.', data: { - execution_id: executionId, + run_id: runId, by: 'brain:alpha', ...shownReply, because: 'invalid', diff --git a/packages/specs/src/run-work/work-decisions.ts b/packages/definitions/src/run-work/work-decisions.ts similarity index 67% rename from packages/specs/src/run-work/work-decisions.ts rename to packages/definitions/src/run-work/work-decisions.ts index f42de7ebe..de3d5a8c7 100644 --- a/packages/specs/src/run-work/work-decisions.ts +++ b/packages/definitions/src/run-work/work-decisions.ts @@ -1,25 +1,20 @@ import { Conflict, type Rejection } from '@beonauto/operations'; import { Result } from 'effect'; -import type { - CommandMetadata, - ExecutionOutboundCall, - ExecutionReply, - ExecutionToolCall, -} from '../execution/execution-commands.ts'; -import type { ExecutionEvent } from '../execution/execution-events.ts'; -import { isRunning, type ExecutionState, type RecordedExecution } from '../execution/execution-state.ts'; +import type { CommandMetadata, RunOutboundCall, RunReply, RunToolCall } from '../runs/run-commands.ts'; +import type { RunEvent } from '../runs/run-events.ts'; +import { isRunning, type RunState, type RecordedRunState } from '../runs/run-state.ts'; -type Decision = Result.Result>; +type Decision = Result.Result>; const runEnded = new Conflict({ detail: 'The run has ended, so it records no more of its work' }); -export function ofTheDefinition({ execution }: RecordedExecution) { - const { primitive, name, spec_version } = execution; - return { primitive, name, spec_version }; +export function ofTheDefinition({ run }: RecordedRunState) { + const { type, name, definition_version } = run; + return { definition_type: type, name, definition_version }; } -function nextCallOf(state: RecordedExecution, number: number | undefined): Result.Result { +function nextCallOf(state: RecordedRunState, number: number | undefined): Result.Result { const next = state.lastCall + 1; return number === undefined || number === next ? Result.succeed(next) @@ -30,7 +25,7 @@ function nextCallOf(state: RecordedExecution, number: number | undefined): Resul ); } -export function decideToolCall({ fact, by, at }: ExecutionToolCall & CommandMetadata, state: ExecutionState): Decision { +export function decideToolCall({ fact, by, at }: RunToolCall & CommandMetadata, state: RunState): Decision { if (state === undefined || !isRunning(state)) { return Result.fail(runEnded); } @@ -40,7 +35,7 @@ export function decideToolCall({ fact, by, at }: ExecutionToolCall & CommandMeta return Result.map(nextCallOf(state, fact.number), (number) => [{ ...fact, number, by, at }]); } -function attemptEndable(state: RecordedExecution, number: number): Result.Result { +function attemptEndable(state: RecordedRunState, number: number): Result.Result { return state.deliveryInFlight === number ? Result.succeed(number) : Result.fail( @@ -52,14 +47,11 @@ function attemptEndable(state: RecordedExecution, number: number): Result.Result const beingCancelled = new Conflict({ detail: 'The run is being cancelled, so it starts no more deliveries' }); -function attemptStartable(state: RecordedExecution, number: number): Result.Result { +function attemptStartable(state: RecordedRunState, number: number): Result.Result { return state.cancel === undefined ? nextCallOf(state, number) : Result.fail(beingCancelled); } -export function decideOutboundCall( - { fact, by, at }: ExecutionOutboundCall & CommandMetadata, - state: ExecutionState, -): Decision { +export function decideOutboundCall({ fact, by, at }: RunOutboundCall & CommandMetadata, state: RunState): Decision { if (state === undefined || !isRunning(state)) { return Result.fail(runEnded); } @@ -83,7 +75,7 @@ const refusedEnough = new Conflict({ detail: `The run has refused ${replyBounds.refusals} replies, the most it records, so it records no more`, }); -function replyRefusal(state: RecordedExecution, fact: ExecutionReply['fact']): Conflict | undefined { +function replyRefusal(state: RecordedRunState, fact: RunReply['fact']): Conflict | undefined { if (state.cancel !== undefined) { return replyWhileCancelling; } @@ -96,7 +88,7 @@ function replyRefusal(state: RecordedExecution, fact: ExecutionReply['fact']): C return fact.type === 'reply_taken' || state.replyRefusals < replyBounds.refusals ? undefined : refusedEnough; } -export function decideReply({ fact, by, at }: ExecutionReply & CommandMetadata, state: ExecutionState): Decision { +export function decideReply({ fact, by, at }: RunReply & CommandMetadata, state: RunState): Decision { if (state === undefined || !isRunning(state)) { return Result.fail(runEnded); } diff --git a/packages/specs/src/execution/caller-cancels.test.ts b/packages/definitions/src/runs/caller-cancels.test.ts similarity index 60% rename from packages/specs/src/execution/caller-cancels.test.ts rename to packages/definitions/src/runs/caller-cancels.test.ts index 89524c62d..77b4677be 100644 --- a/packages/specs/src/execution/caller-cancels.test.ts +++ b/packages/definitions/src/runs/caller-cancels.test.ts @@ -2,33 +2,33 @@ import { Conflict, NotFound, RunCancelled } from '@beonauto/operations'; import { Result } from 'effect'; import { describe, expect, it } from 'vitest'; -import type { ExecutionCommand } from './execution-commands.ts'; -import { executionDecider } from './execution-decider.ts'; -import type { ExecutionEvent } from './execution-events.ts'; +import type { RunCommand } from './run-commands.ts'; +import { runDecider } from './run-decider.ts'; +import type { RunEvent } from './run-events.ts'; const start = { by: 'acme-admin', at: '2026-10-01T09:00:00.000Z' }; const later = '2026-10-01T11:00:00.000Z'; -const greeting = { primitive: 'probe', name: 'plain', input: {} }; +const greeting = { definition_type: 'probe', name: 'plain', input: {} }; -const ofPlain = { primitive: 'probe', name: 'plain', spec_version: 1 }; +const ofPlain = { definition_type: 'probe', name: 'plain', definition_version: 1 }; -const started: ExecutionEvent = { type: 'execution_started', ...greeting, spec_version: 1, ...start }; +const started: RunEvent = { type: 'run_started', ...greeting, definition_version: 1, ...start }; const reason = 'The run that waited for it ended first'; -const cancelFirst: ExecutionEvent = { type: 'execution_cancel_requested', kind: 'parent_ended', reason, ...start }; +const cancelFirst: RunEvent = { type: 'run_cancel_requested', kind: 'parent_ended', reason, ...start }; -function stateAfter(...events: readonly ExecutionEvent[]) { - return events.reduce((state, event) => executionDecider.evolve(state, event), executionDecider.initialState); +function stateAfter(...events: readonly RunEvent[]) { + return events.reduce((state, event) => runDecider.evolve(state, event), runDecider.initialState); } -function decided(command: ExecutionCommand, ...history: readonly ExecutionEvent[]) { - return executionDecider.decide(command, stateAfter(...history)); +function decided(command: RunCommand, ...history: readonly RunEvent[]) { + return runDecider.decide(command, stateAfter(...history)); } -function cancelling(byItsCaller: boolean): ExecutionCommand { +function cancelling(byItsCaller: boolean): RunCommand { return { type: 'cancel', kind: 'parent_ended', @@ -38,7 +38,7 @@ function cancelling(byItsCaller: boolean): ExecutionCommand { }; } -const starting: ExecutionCommand = { type: 'start', ...greeting, spec_version: 1, calls_tools: false, ...start }; +const starting: RunCommand = { type: 'start', ...greeting, definition_version: 1, calls_tools: false, ...start }; const cancelledFirst = new RunCancelled({ detail: reason, kind: 'parent_ended' }); @@ -57,11 +57,11 @@ describe('a cancel from the caller of a run that has not started', () => { it('records nothing more when asked again, and leaves the stream without a run for every other command', () => { expect(decided(cancelling(true), cancelFirst)).toStrictEqual(Result.succeed([])); - expect(decided({ type: 'finish', result: { type: 'execution_failed' }, ...start }, cancelFirst)).toStrictEqual( + expect(decided({ type: 'finish', result: { type: 'run_failed' }, ...start }, cancelFirst)).toStrictEqual( Result.succeed([]), ); expect( - decided({ type: 'settle', result: { type: 'execution_failed' }, by: 'brain:alpha', at: later }, cancelFirst), + decided({ type: 'settle', result: { type: 'run_failed' }, by: 'brain:alpha', at: later }, cancelFirst), ).toStrictEqual(Result.fail(new NotFound({ detail: 'There is no such run in this brain' }))); expect(stateAfter(cancelFirst, cancelFirst)).toStrictEqual({ cancelledBeforeStart: { kind: 'parent_ended', reason, by: 'acme-admin' }, @@ -70,8 +70,8 @@ describe('a cancel from the caller of a run that has not started', () => { }); describe('a cancel from the caller of a run within its call', () => { - const askedOf: ExecutionEvent = { - type: 'execution_cancel_requested', + const askedOf: RunEvent = { + type: 'run_cancel_requested', kind: 'parent_ended', reason, ...ofPlain, @@ -84,27 +84,25 @@ describe('a cancel from the caller of a run within its call', () => { Result.fail( new Conflict({ detail: - 'The run runs within the call that started it, which no server can interrupt from outside, so it cannot be cancelled; it ends when that call does', + 'The run takes place within the call that started it, which no server can interrupt from outside, so it cannot be cancelled; it ends when that call does', }), ), ); }); it('turns the interruption of the run into its cancellation, and an interruption nobody asked for into a failure', () => { - const interrupted: ExecutionCommand = { type: 'finish', result: { type: 'execution_interrupted' }, ...start }; + const interrupted: RunCommand = { type: 'finish', result: { type: 'run_interrupted' }, ...start }; expect(decided(interrupted, started, askedOf)).toStrictEqual( Result.succeed([ { - type: 'execution_rejected', + type: 'run_rejected', rejection: { reason: 'cancelled', kind: 'parent_ended', detail: reason }, ...ofPlain, ...start, }, ]), ); - expect(decided(interrupted, started)).toStrictEqual( - Result.succeed([{ type: 'execution_failed', ...ofPlain, ...start }]), - ); + expect(decided(interrupted, started)).toStrictEqual(Result.succeed([{ type: 'run_failed', ...ofPlain, ...start }])); }); }); diff --git a/packages/specs/src/execution/recorded-size.ts b/packages/definitions/src/runs/recorded-size.ts similarity index 87% rename from packages/specs/src/execution/recorded-size.ts rename to packages/definitions/src/runs/recorded-size.ts index 0083772a1..9b082d38e 100644 --- a/packages/specs/src/execution/recorded-size.ts +++ b/packages/definitions/src/runs/recorded-size.ts @@ -24,6 +24,6 @@ export function withinResultLimit(...values: readonly Schema.Json[]): Effect.Eff return bytes <= mostResultBytes ? Effect.void : Effect.die( - new Error(`The primitive answered with ${bytes} bytes to record, more than the ${mostResultBytes} allowed`), + new Error(`The capability answered with ${bytes} bytes to record, more than the ${mostResultBytes} allowed`), ); } diff --git a/packages/specs/src/execution/execution-calls.test.ts b/packages/definitions/src/runs/run-calls.test.ts similarity index 63% rename from packages/specs/src/execution/execution-calls.test.ts rename to packages/definitions/src/runs/run-calls.test.ts index 3dfb76414..8c44c1ea8 100644 --- a/packages/specs/src/execution/execution-calls.test.ts +++ b/packages/definitions/src/runs/run-calls.test.ts @@ -2,55 +2,55 @@ import { Conflict } from '@beonauto/operations'; import { Result } from 'effect'; import { describe, expect, it } from 'vitest'; -import type { CallAnsweredFact, CallStartedFact, ExecutionCommand, ToolCallFact } from './execution-commands.ts'; -import { executionDecider } from './execution-decider.ts'; -import type { ExecutionEvent } from './execution-events.ts'; -import { lastCallOf, runOf } from './execution-state.ts'; +import type { CallAnsweredFact, CallStartedFact, RunCommand, ToolCallFact } from './run-commands.ts'; +import { runDecider } from './run-decider.ts'; +import type { RunEvent } from './run-events.ts'; +import { lastCallOf, startedRunOf } from './run-state.ts'; const start = { by: 'acme-admin', at: '2026-10-01T09:00:00.000Z' }; const finish = { by: 'acme-admin', at: '2026-10-01T09:00:05.000Z' }; -const greeting = { primitive: 'echo', name: 'greet', input: { who: 'Ada', tags: ['a', 'b'] } }; +const greeting = { definition_type: 'echo', name: 'greet', input: { who: 'Ada', tags: ['a', 'b'] } }; -const started: ExecutionEvent = { type: 'execution_started', ...greeting, spec_version: 1, ...start }; +const started: RunEvent = { type: 'run_started', ...greeting, definition_version: 1, ...start }; -const ofGreet = { primitive: 'echo', name: 'greet', spec_version: 1 }; +const ofGreet = { definition_type: 'echo', name: 'greet', definition_version: 1 }; -const succeeded: ExecutionEvent = { - type: 'execution_succeeded', +const succeeded: RunEvent = { + type: 'run_succeeded', output: 'Hello Ada', record: {}, ...ofGreet, ...finish, }; -const rejectedInput: ExecutionEvent = { - type: 'execution_rejected', +const rejectedInput: RunEvent = { + type: 'run_rejected', rejection: { reason: 'invalid_input', detail: 'No', issues: [] }, ...ofGreet, ...finish, }; -const unavailable: ExecutionEvent = { - type: 'execution_rejected', +const unavailable: RunEvent = { + type: 'run_rejected', rejection: { reason: 'unavailable', detail: 'The model is busy' }, ...ofGreet, ...finish, }; -const failed: ExecutionEvent = { type: 'execution_failed', ...ofGreet, ...finish }; +const failed: RunEvent = { type: 'run_failed', ...ofGreet, ...finish }; -function stateAfter(...events: readonly ExecutionEvent[]) { - return events.reduce((state, event) => executionDecider.evolve(state, event), executionDecider.initialState); +function stateAfter(...events: readonly RunEvent[]) { + return events.reduce((state, event) => runDecider.evolve(state, event), runDecider.initialState); } -function decided(command: ExecutionCommand, ...history: readonly ExecutionEvent[]) { - return executionDecider.decide(command, stateAfter(...history)); +function decided(command: RunCommand, ...history: readonly RunEvent[]) { + return runDecider.decide(command, stateAfter(...history)); } -function starting(): ExecutionCommand { - return { type: 'start', ...greeting, calls_tools: false, spec_version: 1, ...start }; +function starting(): RunCommand { + return { type: 'start', ...greeting, calls_tools: false, definition_version: 1, ...start }; } const called: CallStartedFact = { @@ -74,26 +74,26 @@ const answered: CallAnsweredFact = { const during = { by: 'acme-admin', at: '2026-10-01T09:00:02.000Z' }; -function recordingCall(fact: ToolCallFact): ExecutionCommand { +function recordingCall(fact: ToolCallFact): RunCommand { return { type: 'tool_call', fact, ...during }; } -const callStarted: ExecutionEvent = { ...called, number: 1, ...during }; +const callStarted: RunEvent = { ...called, number: 1, ...during }; -const callAnswered: ExecutionEvent = { ...answered, ...during }; +const callAnswered: RunEvent = { ...answered, ...during }; -const deferred: ExecutionEvent = { type: 'execution_deferred', record: { run: 'x' }, ...ofGreet, ...during }; +const deferred: RunEvent = { type: 'run_deferred', record: { run: 'x' }, ...ofGreet, ...during }; const toolsWereCalled = new Conflict({ detail: - 'The run called tools and did not succeed, so it is not run again under its id, since a tool may have changed something; start a new run with another run id, and read with get_execution_history what it called', + 'The run called tools and did not succeed, so it is not run again under its id, since a tool may have changed something; start a new run with another run id, and read with get_run_history what it called', kind: 'tools_called', }); const noMoreWork = new Conflict({ detail: 'The run has ended, so it records no more of its work' }); -describe('a tool call of an execution', () => { - it('is recorded while the execution runs, numbered by the decider, with who and when', () => { +describe('a tool call of a run', () => { + it('is recorded while the run runs, numbered by the decider, with who and when', () => { expect(decided(recordingCall(called), started)).toStrictEqual(Result.succeed([callStarted])); expect(decided(recordingCall(answered), started, callStarted)).toStrictEqual(Result.succeed([callAnswered])); expect(decided(recordingCall(called), started, callStarted, callAnswered)).toStrictEqual( @@ -115,7 +115,7 @@ describe('a tool call of an execution', () => { ); }); - it('is refused once the execution has ended, however it ended, and before it started', () => { + it('is refused once the run has ended, however it ended, and before it started', () => { expect(decided(recordingCall(answered), started, callStarted, failed)).toEqual(Result.fail(noMoreWork)); expect(decided(recordingCall(called), started, succeeded)).toEqual(Result.fail(noMoreWork)); expect(decided(recordingCall(called), started, unavailable)).toEqual(Result.fail(noMoreWork)); @@ -128,19 +128,19 @@ describe('a tool call of an execution', () => { }); }); -describe('the calls an execution recorded', () => { - it('leave the execution started, keeping the number of its last call and that it may have changed something', () => { +describe('the calls a run recorded', () => { + it('leave the run started, keeping the number of its last call and that it may have changed something', () => { const running = stateAfter(started, callStarted, callAnswered, { ...callStarted, number: 2 }); - expect(running).toMatchObject({ execution: { status: 'started' }, lastCall: 2, mayHaveChanged: true }); + expect(running).toMatchObject({ run: { status: 'started' }, lastCall: 2, mayHaveChanged: true }); expect(stateAfter(started, callStarted, failed)).toMatchObject({ - execution: { status: 'failed' }, + run: { status: 'failed' }, lastCall: 1, mayHaveChanged: true, }); }); - it('keep the execution from running again under its id unless it succeeded or its input was rejected', () => { + it('keep the run from running again under its id unless it succeeded or its input was rejected', () => { expect(decided(starting(), started, callStarted)).toEqual(Result.fail(toolsWereCalled)); expect(decided(starting(), started, callStarted, failed)).toEqual(Result.fail(toolsWereCalled)); expect(decided(starting(), started, callStarted, unavailable)).toEqual(Result.fail(toolsWereCalled)); @@ -149,7 +149,7 @@ describe('the calls an execution recorded', () => { }); it('name no last call for a run that never started', () => { - expect(lastCallOf(runOf(stateAfter()))).toBe(0); + expect(lastCallOf(startedRunOf(stateAfter()))).toBe(0); }); it('are counted across a start that was recorded again, so numbers never repeat under one id', () => { diff --git a/packages/specs/src/execution/execution-commands.ts b/packages/definitions/src/runs/run-commands.ts similarity index 58% rename from packages/specs/src/execution/execution-commands.ts rename to packages/definitions/src/runs/run-commands.ts index cda6bc3ea..a218380e3 100644 --- a/packages/specs/src/execution/execution-commands.ts +++ b/packages/definitions/src/runs/run-commands.ts @@ -1,28 +1,28 @@ import type { Schema } from 'effect'; -import type { StartingTrigger } from '../registry/spec-triggers.ts'; +import type { StartingTrigger } from '../registry/definition-triggers.ts'; import type { CalledBy, CancelRequestKind, DeliveryEnded, DeliveryStarted, - ExecutionDeferred, - ExecutionFinished, + RunDeferred, + RunFinished, ReplyRefused, ReplyTaken, ToolCallAnswered, ToolCallStarted, -} from './execution-events.ts'; +} from './run-events.ts'; -export interface ExecutionRequest { - readonly primitive: string; +export interface RunRequest { + readonly definition_type: string; readonly name: string; readonly input: Schema.Json; } -export interface ExecutionStart extends ExecutionRequest { +export interface RunStart extends RunRequest { readonly type: 'start'; - readonly spec_version: number; + readonly definition_version: number; readonly calls_tools: boolean; readonly finishes_later?: boolean; readonly depth?: number; @@ -35,19 +35,19 @@ export interface ExecutionStart extends ExecutionRequest { type CopiedFromTheStart = | 'by' | 'at' - | 'primitive' + | 'definition_type' | 'name' - | 'spec_version' + | 'definition_version' | 'depth' | 'call_depth' | 'called_by' | 'trigger'; -type WithoutFact = Event extends ExecutionFinished | ExecutionDeferred ? Omit : never; +type WithoutFact = Event extends RunFinished | RunDeferred ? Omit : never; -export type ExecutionResult = WithoutFact; +export type RunResult = WithoutFact; -export type ExecutionOutcome = WithoutFact; +export type RunOutcome = WithoutFact; export type CallStartedFact = Omit; @@ -55,7 +55,7 @@ export type CallAnsweredFact = Omit; export type ToolCallFact = (CallStartedFact & { readonly number?: number }) | CallAnsweredFact; -type OfTheRun = 'by' | 'at' | 'primitive' | 'name' | 'spec_version'; +type OfTheRun = 'by' | 'at' | 'definition_type' | 'name' | 'definition_version'; export type DeliveryStartedFact = Omit; @@ -70,25 +70,25 @@ export type ReplyRefusedFact = Omit; export type ReplyFact = ReplyTakenFact | ReplyRefusedFact; export interface InterruptedAttempt { - readonly type: 'execution_interrupted'; + readonly type: 'run_interrupted'; } -export interface ExecutionFinish { +export interface RunFinish { readonly type: 'finish'; - readonly result: ExecutionOutcome | InterruptedAttempt; + readonly result: RunOutcome | InterruptedAttempt; } -export interface ExecutionToolCall { +export interface RunToolCall { readonly type: 'tool_call'; readonly fact: ToolCallFact; } -export interface ExecutionOutboundCall { +export interface RunOutboundCall { readonly type: 'outbound_call'; readonly fact: OutboundCallFact; } -export interface ExecutionReply { +export interface RunReply { readonly type: 'reply'; readonly fact: ReplyFact; } @@ -98,19 +98,19 @@ export interface CommandMetadata { readonly at: string; } -export interface ExecutionSettlement extends CommandMetadata { +export interface RunSettlement extends CommandMetadata { readonly type: 'settle'; - readonly result: ExecutionResult; + readonly result: RunResult; } -export interface ExecutionCancel extends CommandMetadata { +export interface RunCancel extends CommandMetadata { readonly type: 'cancel'; readonly kind: CancelRequestKind; readonly reason: string; readonly byItsCaller?: true; } -export type ExecutionCommand = - | ((ExecutionStart | ExecutionFinish | ExecutionToolCall | ExecutionOutboundCall | ExecutionReply) & CommandMetadata) - | ExecutionSettlement - | ExecutionCancel; +export type RunCommand = + | ((RunStart | RunFinish | RunToolCall | RunOutboundCall | RunReply) & CommandMetadata) + | RunSettlement + | RunCancel; diff --git a/packages/specs/src/execution/execution-decider.test.ts b/packages/definitions/src/runs/run-decider.test.ts similarity index 51% rename from packages/specs/src/execution/execution-decider.test.ts rename to packages/definitions/src/runs/run-decider.test.ts index 6de50a4e0..cdb63c032 100644 --- a/packages/specs/src/execution/execution-decider.test.ts +++ b/packages/definitions/src/runs/run-decider.test.ts @@ -2,65 +2,65 @@ import { Conflict } from '@beonauto/operations'; import { Result } from 'effect'; import { describe, expect, it } from 'vitest'; -import type { ExecutionCommand, ExecutionResult } from './execution-commands.ts'; -import { executionDecider, executionStreamOf } from './execution-decider.ts'; -import type { ExecutionEvent } from './execution-events.ts'; -import { runOf } from './execution-state.ts'; +import type { RunCommand, RunResult } from './run-commands.ts'; +import { runDecider, runStreamNameOf } from './run-decider.ts'; +import type { RunEvent } from './run-events.ts'; +import { startedRunOf } from './run-state.ts'; const start = { by: 'acme-admin', at: '2026-10-01T09:00:00.000Z' }; const finish = { by: 'acme-admin', at: '2026-10-01T09:00:05.000Z' }; -const greeting = { primitive: 'echo', name: 'greet', input: { who: 'Ada', tags: ['a', 'b'] } }; +const greeting = { definition_type: 'echo', name: 'greet', input: { who: 'Ada', tags: ['a', 'b'] } }; -const started: ExecutionEvent = { type: 'execution_started', ...greeting, spec_version: 1, ...start }; +const started: RunEvent = { type: 'run_started', ...greeting, definition_version: 1, ...start }; -const ofGreet = { primitive: 'echo', name: 'greet', spec_version: 1 }; +const ofGreet = { definition_type: 'echo', name: 'greet', definition_version: 1 }; -const succeeded: ExecutionEvent = { - type: 'execution_succeeded', +const succeeded: RunEvent = { + type: 'run_succeeded', output: 'Hello Ada', record: { model: 'x' }, ...ofGreet, ...finish, }; -const rejectedInput: ExecutionEvent = { - type: 'execution_rejected', +const rejectedInput: RunEvent = { + type: 'run_rejected', rejection: { reason: 'invalid_input', detail: 'No', issues: [{ detail: 'Expected a name', pointer: '/input/who' }] }, ...ofGreet, ...finish, }; -const unavailable: ExecutionEvent = { - type: 'execution_rejected', +const unavailable: RunEvent = { + type: 'run_rejected', rejection: { reason: 'unavailable', detail: 'The model is busy' }, ...ofGreet, ...finish, }; -const conflicted: ExecutionEvent = { - type: 'execution_rejected', - rejection: { reason: 'conflict', detail: 'The model takes no seed; update the spec' }, +const conflicted: RunEvent = { + type: 'run_rejected', + rejection: { reason: 'conflict', detail: 'The model takes no seed; update the definition' }, ...ofGreet, ...finish, }; -const failed: ExecutionEvent = { type: 'execution_failed', ...ofGreet, ...finish }; +const failed: RunEvent = { type: 'run_failed', ...ofGreet, ...finish }; -function stateAfter(...events: readonly ExecutionEvent[]) { - return events.reduce((state, event) => executionDecider.evolve(state, event), executionDecider.initialState); +function stateAfter(...events: readonly RunEvent[]) { + return events.reduce((state, event) => runDecider.evolve(state, event), runDecider.initialState); } -function decided(command: ExecutionCommand, ...history: readonly ExecutionEvent[]) { - return executionDecider.decide(command, stateAfter(...history)); +function decided(command: RunCommand, ...history: readonly RunEvent[]) { + return runDecider.decide(command, stateAfter(...history)); } -function starting(request: object = {}, version = 1): ExecutionCommand { - return { type: 'start', ...greeting, calls_tools: false, ...request, spec_version: version, ...start }; +function starting(request: object = {}, version = 1): RunCommand { + return { type: 'start', ...greeting, calls_tools: false, ...request, definition_version: version, ...start }; } -function finishing(result: ExecutionResult): ExecutionCommand { +function finishing(result: RunResult): RunCommand { return { type: 'finish', result, ...finish }; } @@ -68,24 +68,24 @@ const anotherRequest = new Conflict({ detail: 'The run id belongs to a run of another definition or with another input', }); -describe('starting an execution', () => { - it('records the spec, its version, the input, who started it and when', () => { +describe('starting a run', () => { + it('records the definition, its version, the input, who started it and when', () => { expect(decided(starting())).toStrictEqual(Result.succeed([started])); }); it('records it again when it never finished, at the version given', () => { - expect(decided(starting({}, 2), started)).toStrictEqual(Result.succeed([{ ...started, spec_version: 2 }])); + expect(decided(starting({}, 2), started)).toStrictEqual(Result.succeed([{ ...started, definition_version: 2 }])); }); it('records it again after unavailable, a conflict or a failure, which are no final result', () => { expect(decided(starting(), started, unavailable)).toStrictEqual(Result.succeed([started])); expect(decided(starting({}, 2), started, conflicted)).toStrictEqual( - Result.succeed([{ ...started, spec_version: 2 }]), + Result.succeed([{ ...started, definition_version: 2 }]), ); expect(decided(starting(), started, failed)).toStrictEqual(Result.succeed([started])); }); - it('records nothing once the execution has a final result', () => { + it('records nothing once the run has a final result', () => { expect(decided(starting(), started, succeeded)).toStrictEqual(Result.succeed([])); expect(decided(starting(), started, rejectedInput)).toStrictEqual(Result.succeed([])); }); @@ -96,8 +96,8 @@ describe('starting an execution', () => { ); }); - it('is rejected for another primitive, another spec or another input under the same id', () => { - expect(decided(starting({ primitive: 'probe' }), started)).toEqual(Result.fail(anotherRequest)); + it('is rejected for another type, another definition or another input under the same id', () => { + expect(decided(starting({ definition_type: 'probe' }), started)).toEqual(Result.fail(anotherRequest)); expect(decided(starting({ name: 'wave' }), started, succeeded)).toEqual(Result.fail(anotherRequest)); expect(decided(starting({ input: { who: 'Bob' } }), started, failed)).toEqual(Result.fail(anotherRequest)); expect(decided(starting({ input: { who: 'Ada', tags: ['b', 'a'] } }), started)).toEqual( @@ -106,52 +106,46 @@ describe('starting an execution', () => { }); }); -describe('finishing an execution', () => { - it('records how a started execution ended, with who finished it and when', () => { +describe('finishing a run', () => { + it('records how a started run ended, with who finished it and when', () => { expect( - decided(finishing({ type: 'execution_succeeded', output: 'Hello Ada', record: { model: 'x' } }), started), + decided(finishing({ type: 'run_succeeded', output: 'Hello Ada', record: { model: 'x' } }), started), ).toStrictEqual(Result.succeed([succeeded])); - expect(decided(finishing({ type: 'execution_failed' }), started)).toStrictEqual(Result.succeed([failed])); + expect(decided(finishing({ type: 'run_failed' }), started)).toStrictEqual(Result.succeed([failed])); }); - it('records the primitive, the name and the version of the definition the latest attempt ran', () => { + it('records the type, the name and the version of the definition the latest attempt ran', () => { expect( - decided(finishing({ type: 'execution_failed' }), started, unavailable, { ...started, spec_version: 2 }), - ).toStrictEqual(Result.succeed([{ ...failed, spec_version: 2 }])); + decided(finishing({ type: 'run_failed' }), started, unavailable, { ...started, definition_version: 2 }), + ).toStrictEqual(Result.succeed([{ ...failed, definition_version: 2 }])); }); it('records the result of another attempt after unavailable or a failure', () => { expect( - decided( - finishing({ type: 'execution_succeeded', output: 'Hello Ada', record: { model: 'x' } }), - started, - unavailable, - ), + decided(finishing({ type: 'run_succeeded', output: 'Hello Ada', record: { model: 'x' } }), started, unavailable), ).toStrictEqual(Result.succeed([succeeded])); }); - it('records nothing once the execution has a final result, nor for an execution that never started', () => { - expect(decided(finishing({ type: 'execution_failed' }), started, succeeded)).toStrictEqual(Result.succeed([])); - expect(decided(finishing({ type: 'execution_failed' }), started, rejectedInput)).toStrictEqual(Result.succeed([])); - expect(decided(finishing({ type: 'execution_failed' }))).toStrictEqual(Result.succeed([])); + it('records nothing once the run has a final result, nor for a run that never started', () => { + expect(decided(finishing({ type: 'run_failed' }), started, succeeded)).toStrictEqual(Result.succeed([])); + expect(decided(finishing({ type: 'run_failed' }), started, rejectedInput)).toStrictEqual(Result.succeed([])); + expect(decided(finishing({ type: 'run_failed' }))).toStrictEqual(Result.succeed([])); }); }); -describe('an execution', () => { +describe('a run', () => { it('lives in a stream of its own, named after its id', () => { - expect(executionStreamOf('0199a3c4-7d2e-7c1a-9b3f-2f1e0d9c8b7a')).toBe( - 'executions/0199a3c4-7d2e-7c1a-9b3f-2f1e0d9c8b7a', - ); + expect(runStreamNameOf('0199a3c4-7d2e-7c1a-9b3f-2f1e0d9c8b7a')).toBe('runs/0199a3c4-7d2e-7c1a-9b3f-2f1e0d9c8b7a'); }); it('starts unknown and holds its input and how its latest attempt went', () => { expect(stateAfter()).toBeUndefined(); expect(stateAfter(started, unavailable)).toStrictEqual({ input: greeting.input, - execution: { - primitive: 'echo', + run: { + type: 'echo', name: 'greet', - spec_version: 1, + definition_version: 1, status: 'rejected', rejection: { reason: 'unavailable', detail: 'The model is busy' }, started_at: start.at, @@ -170,19 +164,19 @@ describe('an execution', () => { replyRefusals: 0, depth: 0, callDepth: 0, - result: { type: 'execution_rejected', rejection: { reason: 'unavailable', detail: 'The model is busy' } }, + result: { type: 'run_rejected', rejection: { reason: 'unavailable', detail: 'The model is busy' } }, }); }); }); -describe('an execution started again', () => { +describe('a run started again', () => { it('holds how its latest attempt went, at the version that attempt ran', () => { - expect(stateAfter(started, unavailable, { ...started, spec_version: 2 }, succeeded)).toStrictEqual({ + expect(stateAfter(started, unavailable, { ...started, definition_version: 2 }, succeeded)).toStrictEqual({ input: greeting.input, - execution: { - primitive: 'echo', + run: { + type: 'echo', name: 'greet', - spec_version: 2, + definition_version: 2, status: 'succeeded', output: 'Hello Ada', started_at: start.at, @@ -202,17 +196,17 @@ describe('an execution started again', () => { depth: 0, callDepth: 0, record: { model: 'x' }, - result: { type: 'execution_succeeded', output: 'Hello Ada', record: { model: 'x' } }, + result: { type: 'run_succeeded', output: 'Hello Ada', record: { model: 'x' } }, }); }); }); -describe('the attempts of an execution', () => { +describe('the attempts of a run', () => { it('hold a failure without an output or a rejection', () => { - expect(runOf(stateAfter(started, failed))?.execution).toStrictEqual({ - primitive: 'echo', + expect(startedRunOf(stateAfter(started, failed))?.run).toStrictEqual({ + type: 'echo', name: 'greet', - spec_version: 1, + definition_version: 1, status: 'failed', started_at: start.at, started_by: start.by, @@ -220,7 +214,7 @@ describe('the attempts of an execution', () => { }); }); - it('are ignored when the execution was never seen to start', () => { + it('are ignored when the run was never seen to start', () => { expect(stateAfter(succeeded, failed)).toBeUndefined(); }); }); diff --git a/packages/definitions/src/runs/run-decider.ts b/packages/definitions/src/runs/run-decider.ts new file mode 100644 index 000000000..90c4dbecb --- /dev/null +++ b/packages/definitions/src/runs/run-decider.ts @@ -0,0 +1,41 @@ +import { Conflict, type Decider } from '@beonauto/operations'; +import { Result } from 'effect'; + +import type { RunCommand } from './run-commands.ts'; +import { decideOnRun } from './run-decisions.ts'; +import { RunEventSchema, type RunEvent } from './run-events.ts'; +import { evolveRun, type RunStreamState } from './run-state.ts'; + +export const runDecider: Decider = { + initialState: undefined, + evolve: evolveRun, + decide: decideOnRun, + eventSchema: RunEventSchema, +}; + +interface RunAsRead { + readonly state: RunStreamState; + readonly version: number; +} + +interface CommandAsRead { + readonly readAt: number; + readonly command: RunCommand; +} + +export const changedSinceRead = new Conflict({ + detail: 'The run changed since it was read, so it is read again', + kind: 'concurrent_change', +}); + +export const runDeciderAsRead: Decider = { + initialState: { state: undefined, version: 0 }, + evolve: ({ state, version }, event) => ({ state: evolveRun(state, event), version: version + 1 }), + decide: ({ readAt, command }, { state, version }) => + readAt === version ? decideOnRun(command, state) : Result.fail(changedSinceRead), + eventSchema: RunEventSchema, +}; + +export function runStreamNameOf(id: string): string { + return `runs/${id}`; +} diff --git a/packages/specs/src/execution/execution-decisions.ts b/packages/definitions/src/runs/run-decisions.ts similarity index 61% rename from packages/specs/src/execution/execution-decisions.ts rename to packages/definitions/src/runs/run-decisions.ts index 238498edf..2424e95b3 100644 --- a/packages/specs/src/execution/execution-decisions.ts +++ b/packages/definitions/src/runs/run-decisions.ts @@ -4,31 +4,31 @@ import { Equal, Result } from 'effect'; import { decideOutboundCall, decideReply, decideToolCall, ofTheDefinition } from '../run-work/work-decisions.ts'; import type { CommandMetadata, - ExecutionCancel, - ExecutionCommand, - ExecutionFinish, - ExecutionOutcome, - ExecutionRequest, - ExecutionSettlement, - ExecutionStart, + RunCancel, + RunCommand, + RunFinish, + RunOutcome, + RunRequest, + RunSettlement, + RunStart, InterruptedAttempt, -} from './execution-commands.ts'; -import type { ExecutionEvent } from './execution-events.ts'; +} from './run-commands.ts'; +import type { RunEvent } from './run-events.ts'; import { awaitsSettlement, cancelBeforeStartOf, hasFinalResult, isRunning, mayHaveChangedSomething, - runOf, + startedRunOf, takesSettlement, - type ExecutionState, - type ExecutionStreamState, - type RecordedExecution, -} from './execution-state.ts'; + type RunState, + type RunStreamState, + type RecordedRunState, +} from './run-state.ts'; import { settlementKeyOf, succeedsWith } from './settlement-keys.ts'; -type Decision = Result.Result>; +type Decision = Result.Result>; export type Claim = 'run' | 'answer'; @@ -36,13 +36,13 @@ const nothingToRecord: Decision = Result.succeed([]); const toolsWereCalled = new Conflict({ detail: - 'The run called tools and did not succeed, so it is not run again under its id, since a tool may have changed something; start a new run with another run id, and read with get_execution_history what it called', + 'The run called tools and did not succeed, so it is not run again under its id, since a tool may have changed something; start a new run with another run id, and read with get_run_history what it called', kind: 'tools_called', }); const startedCallingTools = new Conflict({ detail: - 'The run has started and its definition calls tools, so it is not run again under its id: it may still be in progress, or have stopped without recording how it ended, and its tools may have changed something; start a new run with another run id, and read with get_execution_history what it has called so far', + 'The run has started and its definition calls tools, so it is not run again under its id: it may still be in progress, or have stopped without recording how it ended, and its tools may have changed something; start a new run with another run id, and read with get_run_history what it has called so far', kind: 'tools_called', }); @@ -59,40 +59,35 @@ export const endedBeforeCancelling = new Conflict({ const runsWithinItsCall = new Conflict({ detail: - 'The run runs within the call that started it, which no server can interrupt from outside, so it cannot be cancelled; it ends when that call does', + 'The run takes place within the call that started it, which no server can interrupt from outside, so it cannot be cancelled; it ends when that call does', }); -function isSameRequest({ input, execution }: RecordedExecution, request: ExecutionRequest): boolean { - return ( - execution.primitive === request.primitive && execution.name === request.name && Equal.equals(input, request.input) - ); +function isSameRequest({ input, run }: RecordedRunState, request: RunRequest): boolean { + return run.type === request.definition_type && run.name === request.name && Equal.equals(input, request.input); } -function needsNoRun(state: RecordedExecution): boolean { +function needsNoRun(state: RecordedRunState): boolean { return hasFinalResult(state) || awaitsSettlement(state); } -function claimOfRecorded(state: RecordedExecution): Result.Result { +function claimOfRecorded(state: RecordedRunState): Result.Result { if (needsNoRun(state)) { return Result.succeed('answer'); } return mayHaveChangedSomething(state) ? Result.fail(toolsWereCalled) : Result.succeed('run'); } -function cancelledBeforeItsStart(state: ExecutionStreamState): RunCancelled | undefined { +function cancelledBeforeItsStart(state: RunStreamState): RunCancelled | undefined { const cancel = cancelBeforeStartOf(state); return cancel === undefined ? undefined : new RunCancelled({ detail: cancel.reason, kind: cancel.kind }); } -export function claimOf( - state: ExecutionStreamState, - request: ExecutionRequest, -): Result.Result { +export function claimOf(state: RunStreamState, request: RunRequest): Result.Result { const cancelled = cancelledBeforeItsStart(state); if (cancelled !== undefined) { return Result.fail(cancelled); } - const run = runOf(state); + const run = startedRunOf(state); if (run === undefined) { return Result.succeed('run'); } @@ -104,7 +99,7 @@ export function claimOf( return claimOfRecorded(run); } -function startedCallingToolsBefore(start: ExecutionStart, state: ExecutionState): boolean { +function startedCallingToolsBefore(start: RunStart, state: RunState): boolean { return state !== undefined && isRunning(state) && (state.callsTools || start.calls_tools); } @@ -112,14 +107,14 @@ function counted(name: 'depth' | 'call_depth', count: number): Readonly 0 ? { [name]: count } : {}; } -function startedEvent(start: ExecutionStart & CommandMetadata): ExecutionEvent { - const { primitive, name, spec_version, input, calls_tools, finishes_later, by, at } = start; +function startedEvent(start: RunStart & CommandMetadata): RunEvent { + const { definition_type, name, definition_version, input, calls_tools, finishes_later, by, at } = start; const { depth = 0, call_depth: callDepth = 0, called_by: calledBy, trigger } = start; return { - type: 'execution_started', - primitive, + type: 'run_started', + definition_type, name, - spec_version, + definition_version, input, ...(calls_tools ? { calls_tools } : {}), ...(finishes_later === true ? { finishes_later } : {}), @@ -132,40 +127,40 @@ function startedEvent(start: ExecutionStart & CommandMetadata): ExecutionEvent { }; } -function startsAgain(start: ExecutionStart, state: RecordedExecution): boolean { +function startsAgain(start: RunStart, state: RecordedRunState): boolean { return !isRunning(state) && !needsNoRun(state) && !mayHaveChangedSomething(state) && isSameRequest(state, start); } -function decideCreateOnly(start: ExecutionStart & CommandMetadata, state: ExecutionState): Decision { +function decideCreateOnly(start: RunStart & CommandMetadata, state: RunState): Decision { return state === undefined || startsAgain(start, state) ? Result.succeed([startedEvent(start)]) : Result.fail(runTaken); } -function decideStart(start: ExecutionStart & CommandMetadata, state: ExecutionStreamState): Decision { +function decideStart(start: RunStart & CommandMetadata, state: RunStreamState): Decision { const cancelled = cancelledBeforeItsStart(state); if (cancelled !== undefined) { return Result.fail(cancelled); } if (start.createOnly === true) { - return decideCreateOnly(start, runOf(state)); + return decideCreateOnly(start, startedRunOf(state)); } return Result.flatMap(claimOf(state, start), (claim): Decision => { if (claim === 'answer') { return nothingToRecord; } - return startedCallingToolsBefore(start, runOf(state)) + return startedCallingToolsBefore(start, startedRunOf(state)) ? Result.fail(startedCallingTools) : Result.succeed([startedEvent(start)]); }); } -function ofTheStart({ execution, depth, callDepth, calledBy, trigger }: RecordedExecution) { - const { primitive, name, spec_version } = execution; +function ofTheStart({ run, depth, callDepth, calledBy, trigger }: RecordedRunState) { + const { type: definitionType, name, definition_version } = run; return { - primitive, + definition_type: definitionType, name, - spec_version, + definition_version, ...counted('depth', depth), ...counted('call_depth', callDepth), ...(calledBy === undefined ? {} : { called_by: calledBy }), @@ -173,33 +168,26 @@ function ofTheStart({ execution, depth, callDepth, calledBy, trigger }: Recorded }; } -function recordedOutcome( - result: ExecutionOutcome, - state: RecordedExecution, - metadata: CommandMetadata, -): ExecutionEvent { - return result.type === 'execution_deferred' +function recordedOutcome(result: RunOutcome, state: RecordedRunState, metadata: CommandMetadata): RunEvent { + return result.type === 'run_deferred' ? { ...result, ...ofTheDefinition(state), ...metadata } : { ...result, ...ofTheStart(state), ...metadata }; } -function isDeferralAfterItsResult(result: ExecutionOutcome, state: RecordedExecution): boolean { - return result.type === 'execution_deferred' && state.result !== undefined; +function isDeferralAfterItsResult(result: RunOutcome, state: RecordedRunState): boolean { + return result.type === 'run_deferred' && state.result !== undefined; } -function outcomeOfAttempt( - result: ExecutionOutcome | InterruptedAttempt, - { cancel }: RecordedExecution, -): ExecutionOutcome { - if (result.type !== 'execution_interrupted') { +function outcomeOfAttempt(result: RunOutcome | InterruptedAttempt, { cancel }: RecordedRunState): RunOutcome { + if (result.type !== 'run_interrupted') { return result; } return cancel === undefined - ? { type: 'execution_failed' } - : { type: 'execution_rejected', rejection: { reason: 'cancelled', kind: cancel.kind, detail: cancel.reason } }; + ? { type: 'run_failed' } + : { type: 'run_rejected', rejection: { reason: 'cancelled', kind: cancel.kind, detail: cancel.reason } }; } -function decideFinish({ result, by, at }: ExecutionFinish & CommandMetadata, state: ExecutionState): Decision { +function decideFinish({ result, by, at }: RunFinish & CommandMetadata, state: RunState): Decision { if (state === undefined) { return nothingToRecord; } @@ -215,17 +203,17 @@ export const answeredByAReply = new Conflict({ detail: 'The run was answered by a reply, so that answer alone settles it', }); -function answeredOtherwise({ broughtAnswer }: RecordedExecution, { result }: ExecutionSettlement): boolean { +function answeredOtherwise({ broughtAnswer }: RecordedRunState, { result }: RunSettlement): boolean { return broughtAnswer !== null && !succeedsWith(result, broughtAnswer.answer); } -function settledAlready(state: RecordedExecution, settlement: ExecutionSettlement): Decision { +function settledAlready(state: RecordedRunState, settlement: RunSettlement): Decision { return state.result !== undefined && settlementKeyOf(state.result) === settlementKeyOf(settlement.result) ? nothingToRecord : Result.fail(endedWithAnotherResult); } -function decideSettlement(settlement: ExecutionSettlement, state: ExecutionState): Decision { +function decideSettlement(settlement: RunSettlement, state: RunState): Decision { if (state === undefined) { return Result.fail(noSuchRun); } @@ -243,7 +231,7 @@ function decideSettlement(settlement: ExecutionSettlement, state: ExecutionState ); } -function cancelOfARun(cancel: ExecutionCancel, run: RecordedExecution): Decision { +function cancelOfARun(cancel: RunCancel, run: RecordedRunState): Decision { if (!isRunning(run)) { return Result.fail(endedBeforeCancelling); } @@ -251,34 +239,45 @@ function cancelOfARun(cancel: ExecutionCancel, run: RecordedExecution): Decision return Result.fail(runsWithinItsCall); } const { kind, reason, by, at } = cancel; - const { primitive, name, spec_version } = run.execution; + const { type: definitionType, name, definition_version } = run.run; return run.cancel === undefined - ? Result.succeed([{ type: 'execution_cancel_requested', kind, reason, primitive, name, spec_version, by, at }]) + ? Result.succeed([ + { + type: 'run_cancel_requested', + kind, + reason, + definition_type: definitionType, + name, + definition_version, + by, + at, + }, + ]) : nothingToRecord; } -function decideCancel(cancel: ExecutionCancel, state: ExecutionStreamState): Decision { +function decideCancel(cancel: RunCancel, state: RunStreamState): Decision { if (cancelBeforeStartOf(state) !== undefined) { return nothingToRecord; } - const run = runOf(state); + const run = startedRunOf(state); if (run !== undefined) { return cancelOfARun(cancel, run); } const { kind, reason, by, at } = cancel; return cancel.byItsCaller === true - ? Result.succeed([{ type: 'execution_cancel_requested', kind, reason, by, at }]) + ? Result.succeed([{ type: 'run_cancel_requested', kind, reason, by, at }]) : Result.fail(noSuchRun); } -export function decideOnExecution(command: ExecutionCommand, state: ExecutionStreamState): Decision { +export function decideOnRun(command: RunCommand, state: RunStreamState): Decision { if (command.type === 'start') { return decideStart(command, state); } if (command.type === 'cancel') { return decideCancel(command, state); } - const run = runOf(state); + const run = startedRunOf(state); if (command.type === 'tool_call') { return decideToolCall(command, run); } diff --git a/packages/specs/src/execution/execution-deferral.test.ts b/packages/definitions/src/runs/run-deferral.test.ts similarity index 65% rename from packages/specs/src/execution/execution-deferral.test.ts rename to packages/definitions/src/runs/run-deferral.test.ts index 82699f159..297c2a894 100644 --- a/packages/specs/src/execution/execution-deferral.test.ts +++ b/packages/definitions/src/runs/run-deferral.test.ts @@ -2,70 +2,70 @@ import { Conflict, NotFound } from '@beonauto/operations'; import { Result } from 'effect'; import { describe, expect, it } from 'vitest'; -import type { ExecutionCommand, ExecutionOutcome, ExecutionResult } from './execution-commands.ts'; -import { executionDecider } from './execution-decider.ts'; -import type { ExecutionEvent } from './execution-events.ts'; +import type { RunCommand, RunOutcome, RunResult } from './run-commands.ts'; +import { runDecider } from './run-decider.ts'; +import type { RunEvent } from './run-events.ts'; const start = { by: 'acme-admin', at: '2026-10-01T09:00:00.000Z' }; const later = '2026-10-01T11:00:00.000Z'; -const greeting = { primitive: 'relay', name: 'hand-on', input: { who: 'Ada' } }; +const greeting = { definition_type: 'relay', name: 'hand-on', input: { who: 'Ada' } }; -const started: ExecutionEvent = { type: 'execution_started', ...greeting, spec_version: 1, ...start }; +const started: RunEvent = { type: 'run_started', ...greeting, definition_version: 1, ...start }; -const ofHandOn = { primitive: 'relay', name: 'hand-on', spec_version: 1 }; +const ofHandOn = { definition_type: 'relay', name: 'hand-on', definition_version: 1 }; -const deferred: ExecutionEvent = { type: 'execution_deferred', record: { run: 'r-1' }, ...ofHandOn, ...start }; +const deferred: RunEvent = { type: 'run_deferred', record: { run: 'r-1' }, ...ofHandOn, ...start }; -const success: ExecutionResult = { type: 'execution_succeeded', output: 'done', record: { steps: 3 } }; +const success: RunResult = { type: 'run_succeeded', output: 'done', record: { steps: 3 } }; -const unavailability: ExecutionResult = { - type: 'execution_rejected', +const unavailability: RunResult = { + type: 'run_rejected', rejection: { reason: 'unavailable', detail: 'The worker is gone' }, }; -function stateAfter(...events: readonly ExecutionEvent[]) { - return events.reduce((state, event) => executionDecider.evolve(state, event), executionDecider.initialState); +function stateAfter(...events: readonly RunEvent[]) { + return events.reduce((state, event) => runDecider.evolve(state, event), runDecider.initialState); } -function decided(command: ExecutionCommand, ...history: readonly ExecutionEvent[]) { - return executionDecider.decide(command, stateAfter(...history)); +function decided(command: RunCommand, ...history: readonly RunEvent[]) { + return runDecider.decide(command, stateAfter(...history)); } -function finishing(result: ExecutionOutcome): ExecutionCommand { +function finishing(result: RunOutcome): RunCommand { return { type: 'finish', result, ...start }; } -function settling(result: ExecutionResult, by = 'brain:alpha'): ExecutionCommand { +function settling(result: RunResult, by = 'brain:alpha'): RunCommand { return { type: 'settle', result, by, at: later }; } -const starting: ExecutionCommand = { type: 'start', ...greeting, spec_version: 1, calls_tools: false, ...start }; +const starting: RunCommand = { type: 'start', ...greeting, definition_version: 1, calls_tools: false, ...start }; -describe('deferring an execution', () => { - it('records what the primitive started for a started execution', () => { - expect(decided(finishing({ type: 'execution_deferred', record: { run: 'r-1' } }), started)).toStrictEqual( +describe('deferring a run', () => { + it('records what the capability started for a started run', () => { + expect(decided(finishing({ type: 'run_deferred', record: { run: 'r-1' } }), started)).toStrictEqual( Result.succeed([deferred]), ); }); - it('leaves the execution started, waiting to be settled, and distinct from one that never finished', () => { - expect(stateAfter(started, deferred)).toMatchObject({ execution: { status: 'started' }, deferred: true }); - expect(stateAfter(started)).toMatchObject({ execution: { status: 'started' }, deferred: false }); + it('leaves the run started, waiting to be settled, and distinct from one that never finished', () => { + expect(stateAfter(started, deferred)).toMatchObject({ run: { status: 'started' }, deferred: true }); + expect(stateAfter(started)).toMatchObject({ run: { status: 'started' }, deferred: false }); }); it('keeps a call with its id from starting it again, or finishing it otherwise', () => { expect(decided(starting, started, deferred)).toStrictEqual(Result.succeed([])); expect(decided(finishing(success), started, deferred)).toStrictEqual(Result.succeed([])); - expect(decided(finishing({ type: 'execution_deferred', record: {} }), started, deferred)).toStrictEqual( + expect(decided(finishing({ type: 'run_deferred', record: {} }), started, deferred)).toStrictEqual( Result.succeed([]), ); }); }); -describe('settling an execution', () => { - it('records the result of a deferred execution, by the actor who settled it', () => { +describe('settling a run', () => { + it('records the result of a deferred run, by the actor who settled it', () => { expect(decided(settling(success), started, deferred)).toStrictEqual( Result.succeed([{ ...success, ...ofHandOn, by: 'brain:alpha', at: later }]), ); @@ -75,10 +75,10 @@ describe('settling an execution', () => { }); it('records nothing when it already ended with a settlement of the same key, from any actor', () => { - const landed: ExecutionEvent = { ...success, ...ofHandOn, ...start }; - const sameOutputInAnotherOrder: ExecutionResult = { ...success, output: 'done', record: { other: true } }; - const cancelled: ExecutionResult = { - type: 'execution_rejected', + const landed: RunEvent = { ...success, ...ofHandOn, ...start }; + const sameOutputInAnotherOrder: RunResult = { ...success, output: 'done', record: { other: true } }; + const cancelled: RunResult = { + type: 'run_rejected', rejection: { reason: 'cancelled', detail: 'Asked by the caller', kind: 'requested' }, }; @@ -95,9 +95,9 @@ describe('settling an execution', () => { }); it('keys a success by the digest of its output, whatever the order of its keys', () => { - const landed: ExecutionEvent = { ...success, output: { a: 1, b: [1, { c: 2, d: 3 }] }, ...ofHandOn, ...start }; - const reordered: ExecutionResult = { ...success, output: { b: [1, { d: 3, c: 2 }], a: 1 } }; - const another: ExecutionResult = { ...success, output: { a: 1, b: [{ c: 2, d: 3 }, 1] } }; + const landed: RunEvent = { ...success, output: { a: 1, b: [1, { c: 2, d: 3 }] }, ...ofHandOn, ...start }; + const reordered: RunResult = { ...success, output: { b: [1, { d: 3, c: 2 }], a: 1 } }; + const another: RunResult = { ...success, output: { a: 1, b: [{ c: 2, d: 3 }, 1] } }; expect(decided(settling(reordered), started, deferred, landed)).toStrictEqual(Result.succeed([])); expect(decided(settling(another), started, deferred, landed)).toEqual( @@ -107,13 +107,13 @@ describe('settling an execution', () => { }); describe('a settlement that does not land', () => { - it('is rejected for an execution that ended with another result', () => { + it('is rejected for a run that ended with another result', () => { expect(decided(settling(unavailability), started, deferred, { ...success, ...ofHandOn, ...start })).toEqual( Result.fail(new Conflict({ detail: 'The run already ended with another result' })), ); }); - it('is rejected for an execution that runs within its call, and for one the brain does not have', () => { + it('is rejected for a run that runs within its call, and for one the brain does not have', () => { expect(decided(settling(success), started)).toEqual( Result.fail( new Conflict({ detail: 'The run executes within the call that started it, so it cannot be settled' }), @@ -125,7 +125,7 @@ describe('a settlement that does not land', () => { }); it('keeps the record of what was started until a call with its id starts it again', () => { - const unavailable: ExecutionEvent = { ...unavailability, ...ofHandOn, ...start }; + const unavailable: RunEvent = { ...unavailability, ...ofHandOn, ...start }; expect(stateAfter(started, deferred)).toMatchObject({ record: { run: 'r-1' } }); expect(stateAfter(started, deferred, { ...success, ...ofHandOn, ...start })).toMatchObject({ @@ -145,7 +145,7 @@ describe('a settlement that does not land', () => { }); }); -const startedLater: ExecutionEvent = { ...started, finishes_later: true }; +const startedLater: RunEvent = { ...started, finishes_later: true }; describe('a run whose start says it finishes later', () => { it('is recorded with that on its start', () => { @@ -160,9 +160,9 @@ describe('a run whose start says it finishes later', () => { }); it('records no deferral once a result exists, whatever the result', () => { - const unavailable: ExecutionEvent = { ...unavailability, ...ofHandOn, ...start }; + const unavailable: RunEvent = { ...unavailability, ...ofHandOn, ...start }; - expect(decided(finishing({ type: 'execution_deferred', record: {} }), startedLater, unavailable)).toStrictEqual( + expect(decided(finishing({ type: 'run_deferred', record: {} }), startedLater, unavailable)).toStrictEqual( Result.succeed([]), ); }); @@ -173,11 +173,11 @@ describe('a run whose start says it finishes later', () => { }); }); -const calledBy = { execution_id: '0199a3c4-7d2e-7c1a-9b3f-2f1e0d9c8b7a', reference: '/do/0/ask', run: 1 }; +const calledBy = { run_id: '0199a3c4-7d2e-7c1a-9b3f-2f1e0d9c8b7a', reference: '/do/0/ask', run: 1 }; describe('a run that answers a call of another run', () => { it('records the call and the calls above it on its start, and copies them on every ending', () => { - const calledStart: ExecutionEvent = { ...startedLater, call_depth: 2, called_by: calledBy }; + const calledStart: RunEvent = { ...startedLater, call_depth: 2, called_by: calledBy }; expect(decided({ ...starting, finishes_later: true, call_depth: 2, called_by: calledBy })).toStrictEqual( Result.succeed([calledStart]), @@ -189,12 +189,12 @@ describe('a run that answers a call of another run', () => { }); }); -function cancelling(kind: 'requested' | 'deadline' | 'parent_ended' = 'requested'): ExecutionCommand { +function cancelling(kind: 'requested' | 'deadline' | 'parent_ended' = 'requested'): RunCommand { return { type: 'cancel', kind, reason: 'Not needed any more', by: 'acme-admin', at: later }; } -const cancelAsked: ExecutionEvent = { - type: 'execution_cancel_requested', +const cancelAsked: RunEvent = { + type: 'run_cancel_requested', kind: 'requested', reason: 'Not needed any more', ...ofHandOn, @@ -213,7 +213,7 @@ describe('cancelling a run', () => { it('records nothing more when it was asked before, and leaves the run started until it ends', () => { expect(decided(cancelling(), started, deferred, cancelAsked)).toStrictEqual(Result.succeed([])); expect(stateAfter(started, deferred, cancelAsked)).toMatchObject({ - execution: { status: 'started' }, + run: { status: 'started' }, cancel: { kind: 'requested', reason: 'Not needed any more', by: 'acme-admin' }, }); }); @@ -226,7 +226,7 @@ describe('cancelling a run', () => { Result.fail( new Conflict({ detail: - 'The run runs within the call that started it, which no server can interrupt from outside, so it cannot be cancelled; it ends when that call does', + 'The run takes place within the call that started it, which no server can interrupt from outside, so it cannot be cancelled; it ends when that call does', }), ), ); @@ -234,8 +234,8 @@ describe('cancelling a run', () => { }); it('ends as cancelled, a final result for its id that a call with its id answers again', () => { - const ended: ExecutionEvent = { - type: 'execution_rejected', + const ended: RunEvent = { + type: 'run_rejected', rejection: { reason: 'cancelled', detail: 'Not needed any more', kind: 'requested' }, ...ofHandOn, by: 'acme-admin', diff --git a/packages/definitions/src/runs/run-depth.test.ts b/packages/definitions/src/runs/run-depth.test.ts new file mode 100644 index 000000000..be83c544f --- /dev/null +++ b/packages/definitions/src/runs/run-depth.test.ts @@ -0,0 +1,136 @@ +import { Result } from 'effect'; +import { describe, expect, it } from 'vitest'; + +import type { RunCommand } from './run-commands.ts'; +import { runDecider } from './run-decider.ts'; +import { runTaken } from './run-decisions.ts'; +import type { RunEvent } from './run-events.ts'; + +const start = { by: 'brain:alpha', at: '2026-10-01T09:00:00.000Z' }; + +const finish = { by: 'brain:alpha', at: '2026-10-01T09:00:05.000Z' }; + +const greeting = { definition_type: 'echo', name: 'greet', input: { who: 'Ada' } }; + +function stateAfter(...events: readonly RunEvent[]) { + return events.reduce((state, event) => runDecider.evolve(state, event), runDecider.initialState); +} + +interface StartOptions { + readonly depth?: number; + readonly createOnly?: true; + readonly trigger?: { readonly kind: 'event' | 'cron' | 'every'; readonly reference: string }; +} + +function starting(options: StartOptions): RunCommand { + return { type: 'start', ...greeting, calls_tools: false, definition_version: 1, ...start, ...options }; +} + +const startedDeep: RunEvent = { type: 'run_started', ...greeting, definition_version: 1, depth: 2, ...start }; + +const finishing: RunCommand = { + type: 'finish', + result: { type: 'run_succeeded', output: 'Hi', record: {} }, + ...finish, +}; + +describe('the reaction depth of a run', () => { + it('is recorded on its start when it is above 0, and on its ending, from the start', () => { + expect([ + runDecider.decide(starting({ depth: 2 }), runDecider.initialState), + runDecider.decide(starting({ depth: 0 }), runDecider.initialState), + runDecider.decide(finishing, stateAfter(startedDeep)), + ]).toEqual([ + Result.succeed([startedDeep]), + Result.succeed([{ type: 'run_started', ...greeting, definition_version: 1, ...start }]), + Result.succeed([ + { + type: 'run_succeeded', + output: 'Hi', + record: {}, + definition_type: 'echo', + name: 'greet', + definition_version: 1, + depth: 2, + ...finish, + }, + ]), + ]); + }); +}); + +describe('the trigger that started a run', () => { + it('is recorded on its start, and copied from the start onto every ending', () => { + const trigger = { kind: 'cron' as const, reference: '/schedule/cron' }; + const startedByCron: RunEvent = { + type: 'run_started', + ...greeting, + definition_version: 1, + trigger, + ...start, + }; + const failing: RunCommand = { type: 'finish', result: { type: 'run_failed' }, ...finish }; + const ofTheRun = { definition_type: 'echo', name: 'greet', definition_version: 1, trigger, ...finish }; + + expect([ + runDecider.decide(starting({ trigger }), runDecider.initialState), + runDecider.decide(finishing, stateAfter(startedByCron)), + runDecider.decide(failing, stateAfter(startedByCron)), + ]).toEqual([ + Result.succeed([startedByCron]), + Result.succeed([{ type: 'run_succeeded', output: 'Hi', record: {}, ...ofTheRun }]), + Result.succeed([{ type: 'run_failed', ...ofTheRun }]), + ]); + }); +}); + +describe('a start that only creates', () => { + it('starts a run that does not exist, and again one that ended without a result, under the same request', () => { + const failed: RunEvent = { + type: 'run_failed', + definition_type: 'echo', + name: 'greet', + definition_version: 1, + ...finish, + }; + + expect([ + runDecider.decide(starting({ createOnly: true, depth: 2 }), runDecider.initialState), + runDecider.decide(starting({ createOnly: true, depth: 2 }), stateAfter(startedDeep, failed)), + ]).toEqual([Result.succeed([startedDeep]), Result.succeed([startedDeep])]); + }); + + it('is refused as taken for a run that goes, that ended with a result, or that another request ended', () => { + const succeeded: RunEvent = { + type: 'run_succeeded', + definition_type: 'echo', + name: 'greet', + definition_version: 1, + output: 'Hi', + record: {}, + ...finish, + }; + const failed: RunEvent = { + type: 'run_failed', + definition_type: 'echo', + name: 'greet', + definition_version: 1, + ...finish, + }; + const other: RunCommand = { + type: 'start', + ...greeting, + input: { who: 'Grace' }, + calls_tools: false, + definition_version: 1, + ...start, + createOnly: true, + }; + + expect([ + runDecider.decide(starting({ createOnly: true }), stateAfter(startedDeep)), + runDecider.decide(starting({ createOnly: true }), stateAfter(startedDeep, succeeded)), + runDecider.decide(other, stateAfter(startedDeep, failed)), + ]).toEqual([Result.fail(runTaken), Result.fail(runTaken), Result.fail(runTaken)]); + }); +}); diff --git a/packages/specs/src/execution/run-endings.test.ts b/packages/definitions/src/runs/run-endings.test.ts similarity index 69% rename from packages/specs/src/execution/run-endings.test.ts rename to packages/definitions/src/runs/run-endings.test.ts index 3c42f1b63..bba43dbc9 100644 --- a/packages/specs/src/execution/run-endings.test.ts +++ b/packages/definitions/src/runs/run-endings.test.ts @@ -2,20 +2,20 @@ import { Schema } from 'effect'; import { describe, expect, it } from 'vitest'; import { cancelRequestOf, lastEndingOf, runEndingOf } from '../index.ts'; -import { ExecutionEventSchema, type ExecutionEvent } from './execution-events.ts'; +import { RunEventSchema, type RunEvent } from './run-events.ts'; -const encode = Schema.encodeSync(Schema.toCodecJson(ExecutionEventSchema)); +const encode = Schema.encodeSync(Schema.toCodecJson(RunEventSchema)); const fact = { by: 'brain:alpha', at: '2026-10-01T09:00:00.000Z' }; -const ofCheck = { primitive: 'orchestration', name: 'check', spec_version: 1 }; +const ofCheck = { definition_type: 'workflow', name: 'check', definition_version: 1 }; -const calledBy = { execution_id: '0199a3c4-7d2e-7c1a-9b3f-000000000001', reference: '/do/0/check', run: 1 }; +const calledBy = { run_id: '0199a3c4-7d2e-7c1a-9b3f-000000000001', reference: '/do/0/check', run: 1 }; -const started: ExecutionEvent = { type: 'execution_started', ...ofCheck, input: {}, called_by: calledBy, ...fact }; +const started: RunEvent = { type: 'run_started', ...ofCheck, input: {}, called_by: calledBy, ...fact }; -const succeeded: ExecutionEvent = { - type: 'execution_succeeded', +const succeeded: RunEvent = { + type: 'run_succeeded', output: 'done', record: {}, ...ofCheck, @@ -23,8 +23,8 @@ const succeeded: ExecutionEvent = { ...fact, }; -const cancelAsked: ExecutionEvent = { - type: 'execution_cancel_requested', +const cancelAsked: RunEvent = { + type: 'run_cancel_requested', kind: 'deadline', reason: 'Out', ...ofCheck, diff --git a/packages/definitions/src/runs/run-endings.ts b/packages/definitions/src/runs/run-endings.ts new file mode 100644 index 000000000..01424c869 --- /dev/null +++ b/packages/definitions/src/runs/run-endings.ts @@ -0,0 +1,29 @@ +import { Option, Schema } from 'effect'; + +import { RunEventSchema, type RunCancelRequested, type RunFinished } from './run-events.ts'; + +export type RunEnding = RunFinished; + +export type CancelRequested = RunCancelRequested; + +const decodeRunEvent = Schema.decodeUnknownOption(Schema.toCodecJson(RunEventSchema)); + +function isEnding(event: Schema.Schema.Type): event is RunEnding { + return event.type === 'run_succeeded' || event.type === 'run_rejected' || event.type === 'run_failed'; +} + +export function runEndingOf(data: unknown): RunEnding | undefined { + return Option.getOrUndefined(Option.filter(decodeRunEvent(data), isEnding)); +} + +export function lastEndingOf(events: readonly unknown[]): RunEnding | undefined { + return runEndingOf(events.at(-1)); +} + +export function cancelRequestOf(data: unknown): CancelRequested | undefined { + return Option.getOrUndefined( + Option.flatMap(decodeRunEvent(data), (event) => + event.type === 'run_cancel_requested' ? Option.some(event) : Option.none(), + ), + ); +} diff --git a/packages/specs/src/execution/execution-events.ts b/packages/definitions/src/runs/run-events.ts similarity index 69% rename from packages/specs/src/execution/execution-events.ts rename to packages/definitions/src/runs/run-events.ts index b772240ed..da11ccb1f 100644 --- a/packages/specs/src/execution/execution-events.ts +++ b/packages/definitions/src/runs/run-events.ts @@ -2,16 +2,16 @@ import { CallAnsweredSchema, CallStartedSchema } from '@beonauto/mcp'; import { IssueSchema } from '@beonauto/operations'; import { Schema } from 'effect'; -import { StartingTriggerSchema } from '../registry/spec-triggers.ts'; -import { ExecutionRejectionSchema } from './execution.ts'; +import { StartingTriggerSchema } from '../registry/definition-triggers.ts'; +import { RunRejectionSchema } from './run.ts'; const fact = { by: Schema.String, at: Schema.String }; -const ofTheDefinition = { primitive: Schema.String, name: Schema.String, spec_version: Schema.Int }; +const ofTheDefinition = { definition_type: Schema.String, name: Schema.String, definition_version: Schema.Int }; const Counted = Schema.Int.check(Schema.isGreaterThanOrEqualTo(1)); -export const CalledBySchema = Schema.Struct({ execution_id: Schema.String, reference: Schema.String, run: Counted }); +export const CalledBySchema = Schema.Struct({ run_id: Schema.String, reference: Schema.String, run: Counted }); export type CalledBy = typeof CalledBySchema.Type; @@ -22,11 +22,11 @@ const ofTheChain = { trigger: Schema.optionalKey(StartingTriggerSchema), }; -const ExecutionStartedSchema = Schema.Struct({ - type: Schema.Literal('execution_started'), - primitive: Schema.String, +const RunStartedSchema = Schema.Struct({ + type: Schema.Literal('run_started'), + definition_type: Schema.String, name: Schema.String, - spec_version: Schema.Int, + definition_version: Schema.Int, input: Schema.Json, calls_tools: Schema.optionalKey(Schema.Literal(true)), finishes_later: Schema.optionalKey(Schema.Literal(true)), @@ -34,15 +34,15 @@ const ExecutionStartedSchema = Schema.Struct({ ...fact, }); -const ExecutionDeferredSchema = Schema.Struct({ - type: Schema.Literal('execution_deferred'), +const RunDeferredSchema = Schema.Struct({ + type: Schema.Literal('run_deferred'), record: Schema.JsonObject, ...ofTheDefinition, ...fact, }); -const ExecutionSucceededSchema = Schema.Struct({ - type: Schema.Literal('execution_succeeded'), +const RunSucceededSchema = Schema.Struct({ + type: Schema.Literal('run_succeeded'), output: Schema.Json, record: Schema.JsonObject, ...ofTheDefinition, @@ -50,17 +50,17 @@ const ExecutionSucceededSchema = Schema.Struct({ ...fact, }); -const ExecutionRejectedSchema = Schema.Struct({ - type: Schema.Literal('execution_rejected'), - rejection: ExecutionRejectionSchema, +const RunRejectedSchema = Schema.Struct({ + type: Schema.Literal('run_rejected'), + rejection: RunRejectionSchema, record: Schema.optionalKey(Schema.JsonObject), ...ofTheDefinition, ...ofTheChain, ...fact, }); -const ExecutionFailedSchema = Schema.Struct({ - type: Schema.Literal('execution_failed'), +const RunFailedSchema = Schema.Struct({ + type: Schema.Literal('run_failed'), incident: Schema.optionalKey(Schema.String), ...ofTheDefinition, ...ofTheChain, @@ -69,13 +69,13 @@ const ExecutionFailedSchema = Schema.Struct({ export const CancelRequestKindSchema = Schema.Literals(['requested', 'deadline', 'parent_ended']); -const ExecutionCancelRequestedSchema = Schema.Struct({ - type: Schema.Literal('execution_cancel_requested'), +const RunCancelRequestedSchema = Schema.Struct({ + type: Schema.Literal('run_cancel_requested'), kind: CancelRequestKindSchema, reason: Schema.String, - primitive: Schema.optionalKey(Schema.String), + definition_type: Schema.optionalKey(Schema.String), name: Schema.optionalKey(Schema.String), - spec_version: Schema.optionalKey(Schema.Int), + definition_version: Schema.optionalKey(Schema.Int), ...fact, }); @@ -159,13 +159,13 @@ const ReplyRefusedSchema = Schema.Struct({ ...fact, }); -export const ExecutionEventSchema = Schema.Union([ - ExecutionStartedSchema, - ExecutionDeferredSchema, - ExecutionSucceededSchema, - ExecutionRejectedSchema, - ExecutionFailedSchema, - ExecutionCancelRequestedSchema, +export const RunEventSchema = Schema.Union([ + RunStartedSchema, + RunDeferredSchema, + RunSucceededSchema, + RunRejectedSchema, + RunFailedSchema, + RunCancelRequestedSchema, ToolCallStartedSchema, ToolCallAnsweredSchema, DeliveryStartedSchema, @@ -174,23 +174,23 @@ export const ExecutionEventSchema = Schema.Union([ ReplyRefusedSchema, ]); -export type ExecutionEvent = typeof ExecutionEventSchema.Type; +export type RunEvent = typeof RunEventSchema.Type; -export type ExecutionStarted = Extract; +export type RunStarted = Extract; -export type ExecutionDeferred = Extract; +export type RunDeferred = Extract; -export type ExecutionCancelRequested = Extract; +export type RunCancelRequested = Extract; -export type CancelRequestKind = ExecutionCancelRequested['kind']; +export type CancelRequestKind = RunCancelRequested['kind']; -export type ToolCallEvent = Extract; +export type ToolCallEvent = Extract; export type ToolCallStarted = Extract; export type ToolCallAnswered = Extract; -export type DeliveryEvent = Extract; +export type DeliveryEvent = Extract; export type DeliveryStarted = Extract; @@ -202,7 +202,7 @@ export type RepliesIn = typeof RepliesInSchema.Type; export type ReplyIdentity = typeof ReplyIdentitySchema.Type; -export type ReplyEvent = Extract; +export type ReplyEvent = Extract; export type ReplyTaken = Extract; @@ -214,7 +214,7 @@ export type DeliveryOutcome = typeof DeliveryOutcomeSchema.Type; export type DeliveryBecause = typeof DeliveryBecauseSchema.Type; -export type ExecutionFinished = Exclude< - ExecutionEvent, - ExecutionStarted | ExecutionDeferred | ExecutionCancelRequested | ToolCallEvent | DeliveryEvent | ReplyEvent +export type RunFinished = Exclude< + RunEvent, + RunStarted | RunDeferred | RunCancelRequested | ToolCallEvent | DeliveryEvent | ReplyEvent >; diff --git a/packages/definitions/src/runs/run-lookup.ts b/packages/definitions/src/runs/run-lookup.ts new file mode 100644 index 000000000..96330fb10 --- /dev/null +++ b/packages/definitions/src/runs/run-lookup.ts @@ -0,0 +1,58 @@ +import { Conflict, InvalidInput, NotFound, RunCancelled, RunUnanswered, Unavailable } from '@beonauto/operations'; +import { Effect } from 'effect'; + +import { startedRunOf, type RunStreamState, type RecordedRunState } from './run-state.ts'; +import type { Run, RunDetail, RunRejection } from './run.ts'; + +export function noRunCalled(id: string): NotFound { + return new NotFound({ detail: `There is no run ${id} in this brain` }); +} + +function recorded(id: string, stream: RunStreamState): Effect.Effect { + const state = startedRunOf(stream); + return state === undefined ? Effect.fail(noRunCalled(id)) : Effect.succeed(state); +} + +export function runOf(id: string, state: RunStreamState): Effect.Effect { + return recorded(id, state).pipe(Effect.map(({ run }) => ({ run_id: id, ...run }))); +} + +function detailOf(id: string, { run, record }: RecordedRunState): RunDetail { + return record === undefined ? { run_id: id, ...run } : { run_id: id, ...run, record }; +} + +export function runDetailOf(id: string, state: RunStreamState): Effect.Effect { + return recorded(id, state).pipe(Effect.map((run) => detailOf(id, run))); +} + +type ReplayedRejection = InvalidInput | Unavailable | Conflict | RunCancelled | RunUnanswered; + +function replayed(rejection: RunRejection): ReplayedRejection { + if (rejection.reason === 'invalid_input') { + return new InvalidInput({ detail: rejection.detail, issues: rejection.issues }); + } + if (rejection.reason === 'unavailable') { + const { detail, kind, because } = rejection; + return new Unavailable({ + detail, + ...(kind === undefined ? {} : { kind }), + ...(because === undefined ? {} : { because }), + }); + } + if (rejection.reason === 'cancelled') { + return new RunCancelled({ detail: rejection.detail, kind: rejection.kind }); + } + if (rejection.reason === 'unanswered') { + return new RunUnanswered({ detail: rejection.detail, kind: rejection.kind }); + } + const { detail, kind } = rejection; + return new Conflict(kind === undefined ? { detail } : { detail, kind }); +} + +function answerWith(run: Run): Effect.Effect { + return run.rejection === undefined ? Effect.succeed(run) : Effect.fail(replayed(run.rejection)); +} + +export function answerOf(id: string, state: RunStreamState): Effect.Effect { + return runOf(id, state).pipe(Effect.flatMap(answerWith)); +} diff --git a/packages/specs/src/execution/execution-settler.test.ts b/packages/definitions/src/runs/run-settler.test.ts similarity index 72% rename from packages/specs/src/execution/execution-settler.test.ts rename to packages/definitions/src/runs/run-settler.test.ts index 11b050fe4..d7d9aa1a7 100644 --- a/packages/specs/src/execution/execution-settler.test.ts +++ b/packages/definitions/src/runs/run-settler.test.ts @@ -8,10 +8,10 @@ import { firstMoment, toBrain } from '../testing/harness.ts'; import { relayedId, settledAt, withHandOn } from '../testing/relaying.ts'; const settled = { - execution_id: relayedId, - primitive: 'relay', + run_id: relayedId, + type: 'relay', name: 'hand-on', - spec_version: 1, + definition_version: 1, started_at: firstMoment, started_by: 'acme-admin', finished_at: settledAt, @@ -19,12 +19,12 @@ const settled = { const success: Settlement = { status: 'succeeded', output: 'handed on', record: { steps: 3 } }; -const noSuchExecution = Result.fail(new NotFound({ detail: 'There is no such run in this brain' })); +const noSuchRun = Result.fail(new NotFound({ detail: 'There is no such run in this brain' })); -describe('settling a deferred execution', () => { +describe('settling a deferred run', () => { it('records its success, which a read and a call with its id then answer, without running it again', async () => { - const { executing, reading, relayer, settling } = await withHandOn(); - await executing(); + const { running, reading, relayer, settling } = await withHandOn(); + await running(); expect(await settling(success)).toStrictEqual( Result.succeed({ ...settled, status: 'succeeded', output: 'handed on' }), @@ -33,7 +33,7 @@ describe('settling a deferred execution', () => { status: 'succeeded', output: { ...settled, status: 'succeeded', output: 'handed on', record: { steps: 3 } }, }); - expect(await executing()).toStrictEqual({ + expect(await running()).toStrictEqual({ status: 'succeeded', output: { ...settled, status: 'succeeded', output: 'handed on' }, }); @@ -41,8 +41,8 @@ describe('settling a deferred execution', () => { }); it('is quiet when it is settled again the same way, and a conflict when settled another way', async () => { - const { executing, settling } = await withHandOn(); - await executing(); + const { running, settling } = await withHandOn(); + await running(); const first = await settling(success); expect(await settling(success)).toStrictEqual(first); @@ -52,10 +52,10 @@ describe('settling a deferred execution', () => { }); }); -describe('settling a deferred execution as rejected or failed', () => { +describe('settling a deferred run as rejected or failed', () => { it('records a rejection of its input, which a call with its id answers again', async () => { - const { executing, relayer, settling } = await withHandOn(); - await executing(); + const { running, relayer, settling } = await withHandOn(); + await running(); expect(await settling({ status: 'rejected', reason: 'invalid_input', detail: 'No such customer' })).toMatchObject( Result.succeed({ @@ -63,7 +63,7 @@ describe('settling a deferred execution as rejected or failed', () => { rejection: { reason: 'invalid_input', detail: 'No such customer', issues: [] }, }), ); - expect(await executing()).toEqual({ + expect(await running()).toEqual({ status: 'rejected', reason: 'invalid_input', detail: 'No such customer', @@ -73,46 +73,43 @@ describe('settling a deferred execution as rejected or failed', () => { }); it('as unavailable or failed lets a call with its id run it again', async () => { - const { executing, relayer, settling } = await withHandOn(); - await executing(); + const { running, relayer, settling } = await withHandOn(); + await running(); await settling({ status: 'rejected', reason: 'unavailable', detail: 'The worker is gone' }); - await executing(); + await running(); await settling({ status: 'failed' }); - expect(await executing()).toMatchObject({ output: { status: 'started' } }); + expect(await running()).toMatchObject({ output: { status: 'started' } }); expect(relayer.runs()).toBe(3); }); }); -describe('settling an execution', () => { - it('reaches only the execution of its own org, brain and id', async () => { - const { executing, settling } = await withHandOn(); - await executing(); +describe('settling a run', () => { + it('reaches only the run of its own org, brain and id', async () => { + const { running, settling } = await withHandOn(); + await running(); - expect(await settling(success, { org: 'acme', brain: 'beta', id: relayedId })).toEqual(noSuchExecution); - expect(await settling(success, { org: 'globex', brain: 'alpha', id: relayedId })).toEqual(noSuchExecution); + expect(await settling(success, { org: 'acme', brain: 'beta', id: relayedId })).toEqual(noSuchRun); + expect(await settling(success, { org: 'globex', brain: 'alpha', id: relayedId })).toEqual(noSuchRun); expect( await settling(success, { org: 'acme', brain: 'alpha', id: '0199a3c4-7d2e-7c1a-9b3f-000000000000' }), - ).toEqual(noSuchExecution); + ).toEqual(noSuchRun); expect(await settling(success, { org: 'acme', brain: 'alpha', id: relayedId.toUpperCase() })).toMatchObject( - Result.succeed({ execution_id: relayedId, status: 'succeeded' }), + Result.succeed({ run_id: relayedId, status: 'succeeded' }), ); }); it('is not found for an address that is not well formed', async () => { const { settling } = await withHandOn(); - expect(await settling(success, { org: 'ac/me', brain: 'alpha', id: relayedId })).toEqual(noSuchExecution); - expect(await settling(success, { org: 'acme', brain: 'alpha/../beta', id: relayedId })).toEqual(noSuchExecution); - expect(await settling(success, { org: 'acme', brain: 'alpha', id: 'latest' })).toEqual(noSuchExecution); + expect(await settling(success, { org: 'ac/me', brain: 'alpha', id: relayedId })).toEqual(noSuchRun); + expect(await settling(success, { org: 'acme', brain: 'alpha/../beta', id: relayedId })).toEqual(noSuchRun); + expect(await settling(success, { org: 'acme', brain: 'alpha', id: 'latest' })).toEqual(noSuchRun); }); - it('is a conflict for an execution that ran within its call', async () => { - const { call, executeSpec, settling } = await withHandOn(); - await call( - executeSpec, - toBrain('acme', 'alpha')(acmeAdmin, { primitive: 'probe', name: 'plain', execution_id: relayedId }), - ); + it('is a conflict for a run that ran within its call', async () => { + const { call, runDefinition, settling } = await withHandOn(); + await call(runDefinition, toBrain('acme', 'alpha')(acmeAdmin, { type: 'probe', name: 'plain', run_id: relayedId })); expect(await settling(success)).toEqual( Result.fail(new Conflict({ detail: 'The run already ended with another result' })), @@ -121,19 +118,19 @@ describe('settling an execution', () => { }); describe('a settlement whose output is too large or is not JSON', () => { - it('is a breakdown of the primitive that fails the execution', async () => { - const { breakingDown, executing, reading } = await withHandOn(); - await executing(); + it('is a breakdown of the capability that fails the run', async () => { + const { breakingDown, running, reading } = await withHandOn(); + await running(); expect(await breakingDown({ status: 'succeeded', output: 'x'.repeat(1_048_576), record: {} })).toEqual( - new Error('The primitive answered with 1048580 bytes to record, more than the 1048576 allowed'), + new Error('The capability answered with 1048580 bytes to record, more than the 1048576 allowed'), ); expect(await reading()).toMatchObject({ output: { status: 'failed', finished_at: settledAt } }); }); it('is a breakdown as well when the output is not JSON', async () => { - const { breakingDown, executing, reading } = await withHandOn(); - await executing(); + const { breakingDown, running, reading } = await withHandOn(); + await running(); expect(Schema.isSchemaError(await breakingDown({ status: 'succeeded', output: Number.NaN, record: {} }))).toBe( true, @@ -150,8 +147,8 @@ const everything = { kind: 'everything' } as const; describe('a settlement', () => { it('carries every kind and because of an unavailable run, those of a step included', async () => { - const { executing, settling } = await withHandOn(); - await executing(); + const { running, settling } = await withHandOn(); + await running(); const detail = 'A tool server could not be used'; expect( @@ -172,8 +169,8 @@ describe('a settlement', () => { }); it('carries the issues of a rejected input and the record of a rejection', async () => { - const { executing, reading, settling } = await withHandOn(); - await executing(); + const { running, reading, settling } = await withHandOn(); + await running(); const issues = [{ detail: 'Expected a customer', pointer: '/input/customer' }]; await settling({ @@ -196,12 +193,12 @@ describe('a settlement', () => { describe('a settlement of a conflict or a cancellation', () => { it('records a conflict with its kind as given, and one without a kind without one', async () => { - const { executing, reading, settling } = await withHandOn(); - await executing(); + const { running, reading, settling } = await withHandOn(); + await running(); const detail = 'The output takes more than a run records'; await settling({ status: 'rejected', reason: 'conflict', detail, kind: 'oversized' }); const withKind = await reading(); - await executing(); + await running(); await settling({ status: 'rejected', reason: 'conflict', detail: 'Clashed' }); expect([withKind, await reading()]).toMatchObject([ @@ -212,34 +209,34 @@ describe('a settlement of a conflict or a cancellation', () => { }); it('records a cancellation with its kind, a final result a call with its id answers again', async () => { - const { executing, relayer, settling } = await withHandOn(); - await executing(); + const { running, relayer, settling } = await withHandOn(); + await running(); const detail = 'The step that waited for it ran out of time'; expect(await settling({ status: 'rejected', reason: 'cancelled', detail, kind: 'deadline' })).toStrictEqual( Result.succeed({ ...settled, status: 'rejected', rejection: { reason: 'cancelled', detail, kind: 'deadline' } }), ); - expect(await executing()).toEqual({ status: 'rejected', reason: 'cancelled', detail, kind: 'deadline' }); + expect(await running()).toEqual({ status: 'rejected', reason: 'cancelled', detail, kind: 'deadline' }); expect(relayer.runs()).toBe(1); }); it('records a request nobody answered with its kind, a final result a call with its id answers again', async () => { - const { executing, relayer, settling } = await withHandOn(); - await executing(); + const { running, relayer, settling } = await withHandOn(); + await running(); const detail = 'Nobody answered before the request expired'; expect(await settling({ status: 'rejected', reason: 'unanswered', detail, kind: 'expired' })).toStrictEqual( Result.succeed({ ...settled, status: 'rejected', rejection: { reason: 'unanswered', detail, kind: 'expired' } }), ); - expect(await executing()).toEqual({ status: 'rejected', reason: 'unanswered', detail, kind: 'expired' }); + expect(await running()).toEqual({ status: 'rejected', reason: 'unanswered', detail, kind: 'expired' }); expect(relayer.runs()).toBe(1); }); }); describe('what a settlement records', () => { it('is an empty record for a success that names none, as the workflow host settles a run', async () => { - const { executing, reading, settling } = await withHandOn(); - await executing(); + const { running, reading, settling } = await withHandOn(); + await running(); await settling({ status: 'succeeded', output: 'handed on' }); @@ -247,8 +244,8 @@ describe('what a settlement records', () => { }); it('records a failure with its incident', async () => { - const { executing, ledger, run, settling } = await withHandOn(); - await executing(); + const { running, ledger, run, settling } = await withHandOn(); + await running(); await settling({ status: 'failed', incident: 'incident-1' }); const page = await run( @@ -257,21 +254,21 @@ describe('what a settlement records', () => { ), ); - expect(finishIn(page)).toMatchObject({ type: 'execution_failed', incident: 'incident-1' }); + expect(finishIn(page)).toMatchObject({ type: 'run_failed', incident: 'incident-1' }); }); it('records the actor who settled it, the brain itself when none is named', async () => { - const { executing, ledger, run, settling } = await withHandOn(); + const { running, ledger, run, settling } = await withHandOn(); const read = () => run( Effect.orDie( ledger.service.readRecorded({ org: 'acme', brain: 'alpha' }, everything, { order: 'asc', limit: 20 }), ), ); - await executing(); + await running(); await settling({ status: 'rejected', reason: 'unavailable', detail: 'Gone' }); const bySelf = finishIn(await read()); - await executing(); + await running(); await settling({ ...success, by: 'acme-admin' }); expect([bySelf, finishIn(await read())]).toMatchObject([{ by: 'brain:alpha' }, { by: 'acme-admin' }]); diff --git a/packages/specs/src/execution/execution-settler.ts b/packages/definitions/src/runs/run-settler.ts similarity index 58% rename from packages/specs/src/execution/execution-settler.ts rename to packages/definitions/src/runs/run-settler.ts index 6e048183f..8e423a942 100644 --- a/packages/specs/src/execution/execution-settler.ts +++ b/packages/definitions/src/runs/run-settler.ts @@ -14,29 +14,29 @@ import { } from '@beonauto/operations'; import { DateTime, Effect, Schema } from 'effect'; -import type { ExecutionResult, ExecutionSettlement } from './execution-commands.ts'; -import { executionDecider, executionDeciderAsRead, executionStreamOf } from './execution-decider.ts'; -import { executionOf } from './execution-lookup.ts'; -import type { ExecutionStreamState } from './execution-state.ts'; -import type { ExecutionRejection, Run } from './execution.ts'; import { withinResultLimit } from './recorded-size.ts'; +import type { RunResult, RunSettlement } from './run-commands.ts'; +import { runDecider, runDeciderAsRead, runStreamNameOf } from './run-decider.ts'; +import { runOf } from './run-lookup.ts'; +import type { RunStreamState } from './run-state.ts'; +import type { RunRejection, Run } from './run.ts'; export type { Settlement } from '@beonauto/operations'; -export interface ExecutionAddress { +export interface RunStreamAddress { readonly org: string; readonly brain: string; readonly id: string; } -export type SettleExecution = ( - execution: ExecutionAddress, +export type SettleRun = ( + run: RunStreamAddress, settlement: Settlement, lineage?: Lineage, ) => Effect.Effect; type SettleAsRead = ( - execution: ExecutionAddress, + run: RunStreamAddress, settlement: Settlement, readAt: number, lineage?: Lineage, @@ -44,9 +44,9 @@ type SettleAsRead = ( type SettledOn = ( stream: string, - command: ExecutionSettlement, + command: RunSettlement, lineage: Lineage | undefined, -) => Effect.Effect, Rejection<'not_found' | 'conflict' | 'cancelled'>>; +) => Effect.Effect, Rejection<'not_found' | 'conflict' | 'cancelled'>>; const isWellFormed = Schema.is( Schema.Struct({ org: OrgIdSchema, brain: BrainIdSchema, id: Schema.String.check(Schema.isUUID()) }), @@ -54,9 +54,9 @@ const isWellFormed = Schema.is( const decodeSuccess = Schema.decodeUnknownEffect(Schema.Struct({ output: Schema.Json, record: Schema.JsonObject })); -const failure: ExecutionResult = { type: 'execution_failed' }; +const failure: RunResult = { type: 'run_failed' }; -function rejectionOf(settlement: SettledRejection): ExecutionRejection { +function rejectionOf(settlement: SettledRejection): RunRejection { if (settlement.reason === 'invalid_input') { return { reason: settlement.reason, detail: settlement.detail, issues: settlement.issues ?? [] }; } @@ -76,48 +76,48 @@ function rejectionOf(settlement: SettledRejection): ExecutionRejection { return { reason, detail, kind }; } -function rejectedWith(settlement: SettledRejection): Effect.Effect { +function rejectedWith(settlement: SettledRejection): Effect.Effect { const { record } = settlement; const rejection = rejectionOf(settlement); return record === undefined - ? Effect.succeed({ type: 'execution_rejected', rejection }) - : Effect.as(withinResultLimit(record), { type: 'execution_rejected', rejection, record }); + ? Effect.succeed({ type: 'run_rejected', rejection }) + : Effect.as(withinResultLimit(record), { type: 'run_rejected', rejection, record }); } const noSuchRun = new NotFound({ detail: 'There is no such run in this brain' }); -function streamOf(address: ExecutionAddress): Effect.Effect { +function streamOf(address: RunStreamAddress): Effect.Effect { return isWellFormed(address) - ? Effect.succeed(`${streamPrefixOfBrain(address)}${executionStreamOf(address.id.toLowerCase())}`) + ? Effect.succeed(`${streamPrefixOfBrain(address)}${runStreamNameOf(address.id.toLowerCase())}`) : Effect.fail(noSuchRun); } -function brainStreamOf(address: ExecutionAddress): Effect.Effect { - return isWellFormed(address) ? Effect.succeed(executionStreamOf(address.id.toLowerCase())) : Effect.fail(noSuchRun); +function brainStreamOf(address: RunStreamAddress): Effect.Effect { + return isWellFormed(address) ? Effect.succeed(runStreamNameOf(address.id.toLowerCase())) : Effect.fail(noSuchRun); } -function resultOf(settlement: Settlement): Effect.Effect { +function resultOf(settlement: Settlement): Effect.Effect { if (settlement.status === 'succeeded') { return decodeSuccess({ output: settlement.output, record: settlement.record ?? {} }).pipe( Effect.orDie, Effect.tap(({ output, record }) => withinResultLimit(output, record)), - Effect.map(({ output, record }): ExecutionResult => ({ type: 'execution_succeeded', output, record })), + Effect.map(({ output, record }): RunResult => ({ type: 'run_succeeded', output, record })), ); } if (settlement.status === 'rejected') { return rejectedWith(settlement); } const { incident } = settlement; - return Effect.succeed(incident === undefined ? failure : { type: 'execution_failed', incident }); + return Effect.succeed(incident === undefined ? failure : { type: 'run_failed', incident }); } function settledOnLatest(ledger: StreamWriter): SettledOn { - return (stream, command, lineage) => ledger.execute(stream, executionDecider, command, lineage); + return (stream, command, lineage) => ledger.execute(stream, runDecider, command, lineage); } function settledOnRead(ledger: StreamWriter, readAt: number): SettledOn { return (stream, command, lineage) => - Effect.map(ledger.execute(stream, executionDeciderAsRead, { readAt, command }, lineage), ({ state, version }) => ({ + Effect.map(ledger.execute(stream, runDeciderAsRead, { readAt, command }, lineage), ({ state, version }) => ({ state: state.state, version, })); @@ -125,35 +125,35 @@ function settledOnRead(ledger: StreamWriter, readAt: number): SettledOn { function settlerOver( settledOn: SettledOn, - streamNamed: (address: ExecutionAddress) => Effect.Effect, -): SettleExecution { - const settle = Effect.fnUntraced(function* (stream: string, result: ExecutionResult, by: string, lineage?: Lineage) { + streamNamed: (address: RunStreamAddress) => Effect.Effect, +): SettleRun { + const settle = Effect.fnUntraced(function* (stream: string, result: RunResult, by: string, lineage?: Lineage) { const at = DateTime.formatIso(yield* DateTime.now); return yield* settledOn(stream, { type: 'settle', result, by, at }, lineage).pipe( Effect.catchTag('cancelled', Effect.die), ); }); - return (execution, settlement, lineage) => + return (run, settlement, lineage) => Effect.gen(function* () { - const stream = yield* streamNamed(execution); - const by = settlement.by ?? brainCallerOf(execution).id; + const stream = yield* streamNamed(run); + const by = settlement.by ?? brainCallerOf(run).id; const result = yield* resultOf(settlement).pipe( Effect.tapDefect(() => Effect.ignore(settle(stream, failure, by, lineage))), ); const { state } = yield* settle(stream, result, by, lineage); - return yield* executionOf(execution.id.toLowerCase(), state); + return yield* runOf(run.id.toLowerCase(), state); }); } -export function executionSettler(ledger: StreamWriter): SettleExecution { +export function runSettler(ledger: StreamWriter): SettleRun { return settlerOver(settledOnLatest(ledger), streamOf); } -export function executionSettlerAsRead(ledger: StreamWriter): SettleAsRead { - return (execution, settlement, readAt, lineage) => - settlerOver(settledOnRead(ledger, readAt), streamOf)(execution, settlement, lineage); +export function runSettlerAsRead(ledger: StreamWriter): SettleAsRead { + return (run, settlement, readAt, lineage) => + settlerOver(settledOnRead(ledger, readAt), streamOf)(run, settlement, lineage); } -export function brainBoundSettler(writer: StreamWriter): SettleExecution { +export function brainBoundSettler(writer: StreamWriter): SettleRun { return settlerOver(settledOnLatest(writer), brainStreamOf); } diff --git a/packages/definitions/src/runs/run-starts.test.ts b/packages/definitions/src/runs/run-starts.test.ts new file mode 100644 index 000000000..fcf51b782 --- /dev/null +++ b/packages/definitions/src/runs/run-starts.test.ts @@ -0,0 +1,46 @@ +import { Schema } from 'effect'; +import { describe, expect, it } from 'vitest'; + +import { RunEventSchema } from './run-events.ts'; +import { runStartedOf } from './run-starts.ts'; + +const encode = Schema.encodeSync(Schema.toCodecJson(RunEventSchema)); + +const fact = { by: 'brain:alpha', at: '2026-10-01T09:00:00.000Z' }; + +describe('the start of a run read from its record', () => { + it('names what ran and its reaction depth, and nothing for any other record', () => { + expect([ + runStartedOf( + encode({ + type: 'run_started', + definition_type: 'workflow', + name: 'close', + definition_version: 1, + input: {}, + depth: 2, + ...fact, + }), + ), + runStartedOf( + encode({ + type: 'run_started', + definition_type: 'reasoning', + name: 'sum', + definition_version: 1, + input: {}, + ...fact, + }), + ), + runStartedOf( + encode({ type: 'run_failed', definition_type: 'reasoning', name: 'sum', definition_version: 1, ...fact }), + ), + runStartedOf('not a record'), + ]).toEqual([ + { definitionType: 'workflow', name: 'close', depth: 2 }, + { definitionType: 'reasoning', name: 'sum', depth: 0 }, + undefined, + undefined, + ]); + }); +}); diff --git a/packages/definitions/src/runs/run-starts.ts b/packages/definitions/src/runs/run-starts.ts new file mode 100644 index 000000000..cd9a955b9 --- /dev/null +++ b/packages/definitions/src/runs/run-starts.ts @@ -0,0 +1,21 @@ +import { Option, Schema } from 'effect'; + +import { RunEventSchema } from './run-events.ts'; + +export interface StartedRun { + readonly definitionType: string; + readonly name: string; + readonly depth: number; +} + +const decodeRunEvent = Schema.decodeUnknownOption(Schema.toCodecJson(RunEventSchema)); + +export function runStartedOf(data: unknown): StartedRun | undefined { + return Option.getOrUndefined( + Option.flatMap(decodeRunEvent(data), (event) => + event.type === 'run_started' + ? Option.some({ definitionType: event.definition_type, name: event.name, depth: event.depth ?? 0 }) + : Option.none(), + ), + ); +} diff --git a/packages/specs/src/execution/execution-state.ts b/packages/definitions/src/runs/run-state.ts similarity index 53% rename from packages/specs/src/execution/execution-state.ts rename to packages/definitions/src/runs/run-state.ts index 30e072e81..737a7851e 100644 --- a/packages/specs/src/execution/execution-state.ts +++ b/packages/definitions/src/runs/run-state.ts @@ -1,18 +1,18 @@ import type { Schema } from 'effect'; -import type { StartingTrigger } from '../registry/spec-triggers.ts'; -import type { ExecutionResult } from './execution-commands.ts'; +import type { StartingTrigger } from '../registry/definition-triggers.ts'; +import type { RunResult } from './run-commands.ts'; import type { CalledBy, CancelRequestKind, DeliveryEnded, - ExecutionEvent, - ExecutionFinished, - ExecutionStarted, + RunEvent, + RunFinished, + RunStarted, ReplyEvent, ReplyIdentity, -} from './execution-events.ts'; -import type { ExecutionRecord } from './execution.ts'; +} from './run-events.ts'; +import type { RunRecord } from './run.ts'; export interface AskedCancel { readonly kind: CancelRequestKind; @@ -26,9 +26,9 @@ export interface BroughtAnswer { readonly reply: ReplyIdentity; } -export interface RecordedExecution { +export interface RecordedRunState { readonly input: Schema.Json; - readonly execution: ExecutionRecord; + readonly run: RunRecord; readonly finishesLater: boolean; readonly deferred: boolean; readonly callsTools: boolean; @@ -45,31 +45,41 @@ export interface RecordedExecution { readonly calledBy?: CalledBy; readonly trigger?: StartingTrigger; readonly record?: Schema.JsonObject; - readonly result?: ExecutionResult; + readonly result?: RunResult; } -export type ExecutionState = RecordedExecution | undefined; +export type RunState = RecordedRunState | undefined; interface CancelledBeforeStart { readonly cancelledBeforeStart: AskedCancel; } -export type ExecutionStreamState = ExecutionState | CancelledBeforeStart; +export type RunStreamState = RunState | CancelledBeforeStart; -export function runOf(state: ExecutionStreamState): ExecutionState { +export function startedRunOf(state: RunStreamState): RunState { return state === undefined || 'cancelledBeforeStart' in state ? undefined : state; } -export function cancelBeforeStartOf(state: ExecutionStreamState): AskedCancel | undefined { +export function cancelBeforeStartOf(state: RunStreamState): AskedCancel | undefined { return state !== undefined && 'cancelledBeforeStart' in state ? state.cancelledBeforeStart : undefined; } -function startedExecution(event: ExecutionStarted, earlier: ExecutionState): RecordedExecution { - const { primitive, name, spec_version, input, calls_tools, finishes_later, depth = 0, by, at } = event; +function startedRun(event: RunStarted, earlier: RunState): RecordedRunState { + const { + definition_type: type, + name, + definition_version, + input, + calls_tools, + finishes_later, + depth = 0, + by, + at, + } = event; const { call_depth: callDepth = 0, called_by: calledBy, trigger } = event; return { input, - execution: { primitive, name, spec_version, status: 'started', started_at: at, started_by: by }, + run: { type, name, definition_version, status: 'started', started_at: at, started_by: by }, finishesLater: finishes_later === true, deferred: false, callsTools: calls_tools === true, @@ -87,48 +97,48 @@ function startedExecution(event: ExecutionStarted, earlier: ExecutionState): Rec }; } -function resultOf(event: ExecutionFinished): ExecutionResult { - if (event.type === 'execution_succeeded') { +function resultOf(event: RunFinished): RunResult { + if (event.type === 'run_succeeded') { return { type: event.type, output: event.output, record: event.record }; } - if (event.type === 'execution_rejected') { + if (event.type === 'run_rejected') { return { type: event.type, rejection: event.rejection }; } return event.incident === undefined ? { type: event.type } : { type: event.type, incident: event.incident }; } function finishedRecord( - { primitive, name, spec_version, started_at, started_by }: ExecutionRecord, - result: ExecutionResult, + { type, name, definition_version, started_at, started_by }: RunRecord, + result: RunResult, at: string, -): ExecutionRecord { - const attempt = { primitive, name, spec_version, started_at, started_by, finished_at: at }; - if (result.type === 'execution_succeeded') { +): RunRecord { + const attempt = { type, name, definition_version, started_at, started_by, finished_at: at }; + if (result.type === 'run_succeeded') { return { ...attempt, status: 'succeeded', output: result.output }; } - if (result.type === 'execution_rejected') { + if (result.type === 'run_rejected') { return { ...attempt, status: 'rejected', rejection: result.rejection }; } return { ...attempt, status: 'failed' }; } -function recordOf(event: ExecutionFinished): Schema.JsonObject | undefined { - return event.type === 'execution_failed' ? undefined : event.record; +function recordOf(event: RunFinished): Schema.JsonObject | undefined { + return event.type === 'run_failed' ? undefined : event.record; } -function finishedExecution(state: RecordedExecution, event: ExecutionFinished): RecordedExecution { +function finishedRun(state: RecordedRunState, event: RunFinished): RecordedRunState { const result = resultOf(event); - const finished = { ...state, execution: finishedRecord(state.execution, result, event.at), result }; + const finished = { ...state, run: finishedRecord(state.run, result, event.at), result }; const record = recordOf(event); return record === undefined ? finished : { ...finished, record }; } -function endedDelivery(state: RecordedExecution, { outcome, at }: DeliveryEnded): RecordedExecution { +function endedDelivery(state: RecordedRunState, { outcome, at }: DeliveryEnded): RecordedRunState { const ended = { ...state, deliveryInFlight: null }; return outcome === 'delivered' ? { ...ended, deliveredAt: at } : ended; } -function repliedTo(state: RecordedExecution, event: ReplyEvent): RecordedExecution { +function repliedTo(state: RecordedRunState, event: ReplyEvent): RecordedRunState { const seen = { ...state, repliesSeen: [...state.repliesSeen, event.reply.id] }; if (event.type === 'reply_refused') { return { ...seen, replyRefusals: state.replyRefusals + 1 }; @@ -137,7 +147,7 @@ function repliedTo(state: RecordedExecution, event: ReplyEvent): RecordedExecuti return { ...seen, broughtAnswer: { answer, at, reply } }; } -function evolveStarted(state: RecordedExecution, event: Exclude): RecordedExecution { +function evolveStarted(state: RecordedRunState, event: Exclude): RecordedRunState { if (event.type === 'reply_taken' || event.type === 'reply_refused') { return repliedTo(state, event); } @@ -153,53 +163,49 @@ function evolveStarted(state: RecordedExecution, event: Exclude executionDecider.evolve(state, event), executionDecider.initialState); +function stateAfter(...events: readonly RunEvent[]) { + return events.reduce((state, event) => runDecider.evolve(state, event), runDecider.initialState); } -function decided(command: ExecutionCommand, ...history: readonly ExecutionEvent[]) { - return executionDecider.decide(command, stateAfter(...history)); +function decided(command: RunCommand, ...history: readonly RunEvent[]) { + return runDecider.decide(command, stateAfter(...history)); } -function starting(request: object = {}): ExecutionCommand { - return { type: 'start', ...greeting, calls_tools: false, ...request, spec_version: 1, ...start }; +function starting(request: object = {}): RunCommand { + return { type: 'start', ...greeting, calls_tools: false, ...request, definition_version: 1, ...start }; } const startedCallingTools = new Conflict({ detail: - 'The run has started and its definition calls tools, so it is not run again under its id: it may still be in progress, or have stopped without recording how it ended, and its tools may have changed something; start a new run with another run id, and read with get_execution_history what it has called so far', + 'The run has started and its definition calls tools, so it is not run again under its id: it may still be in progress, or have stopped without recording how it ended, and its tools may have changed something; start a new run with another run id, and read with get_run_history what it has called so far', kind: 'tools_called', }); -describe('starting an execution whose spec calls tools', () => { +describe('starting a run whose definition calls tools', () => { const callingTools = starting({ calls_tools: true }); - const startedWithTools: ExecutionEvent = { ...started, calls_tools: true }; + const startedWithTools: RunEvent = { ...started, calls_tools: true }; it('records it the first time, with the fact that it calls tools', () => { expect(decided(callingTools)).toStrictEqual(Result.succeed([startedWithTools])); @@ -68,7 +68,7 @@ describe('starting an execution whose spec calls tools', () => { expect(decided(callingTools, started)).toEqual(Result.fail(startedCallingTools)); }); - it('refuses any start while an attempt recorded as calling tools is started, though its spec has lost its tools since', () => { + it('refuses any start while an attempt recorded as calling tools is started, though its definition has lost its tools since', () => { expect(decided(starting(), startedWithTools)).toEqual(Result.fail(startedCallingTools)); expect(stateAfter(startedWithTools)).toMatchObject({ callsTools: true }); expect(stateAfter(started)).toMatchObject({ callsTools: false }); @@ -79,7 +79,7 @@ describe('starting an execution whose spec calls tools', () => { expect(decided(callingTools, started, failed)).toStrictEqual(Result.succeed([startedWithTools])); }); - it('answers a finished execution again, and refuses another request under its id as any other', () => { + it('answers a finished run again, and refuses another request under its id as any other', () => { expect(decided(callingTools, started, succeeded)).toStrictEqual(Result.succeed([])); expect(decided(starting({ calls_tools: true, name: 'wave' }), started)).toEqual(Result.fail(anotherRequest)); }); diff --git a/packages/specs/src/execution/execution.test.ts b/packages/definitions/src/runs/run.test.ts similarity index 65% rename from packages/specs/src/execution/execution.test.ts rename to packages/definitions/src/runs/run.test.ts index ad6bba457..f2753d5e3 100644 --- a/packages/specs/src/execution/execution.test.ts +++ b/packages/definitions/src/runs/run.test.ts @@ -3,10 +3,10 @@ import { describe, expect, expectTypeOf, it } from 'vitest'; import { isFunctionRun, isWorkflowRun, type FunctionRun, type Run, type WorkflowRun } from '../index.ts'; const functionRun: Run = { - execution_id: '0199a3c4-7d2e-7c1a-9b3f-2f1e0d9c8b7a', - primitive: 'inference', + run_id: '0199a3c4-7d2e-7c1a-9b3f-2f1e0d9c8b7a', + type: 'reasoning', name: 'review-campaign', - spec_version: 3, + definition_version: 3, status: 'succeeded', output: 'The brief meets the criteria.', started_at: '2026-09-02T00:00:00.000Z', @@ -14,13 +14,13 @@ const functionRun: Run = { finished_at: '2026-09-02T00:01:00.000Z', }; -const computationRun: Run = { ...functionRun, primitive: 'computation', output: { total_spend_cents: 17_628 } }; +const computationRun: Run = { ...functionRun, type: 'computation', output: { total_spend_cents: 17_628 } }; -const recallRun: Run = { ...functionRun, primitive: 'recollection', output: [{ verdict: 'approve' }] }; +const recallRun: Run = { ...functionRun, type: 'recall', output: [{ verdict: 'approve' }] }; -const interactionRun: Run = { ...functionRun, primitive: 'interaction', output: { choice: 'approve' } }; +const interactionRun: Run = { ...functionRun, type: 'interaction', output: { choice: 'approve' } }; -const workflowRun: Run = { ...functionRun, primitive: 'orchestration' }; +const workflowRun: Run = { ...functionRun, type: 'workflow' }; describe('a run', () => { it('is a function run when it ran a reasoning, an interaction, a computation or a recall function, and a workflow run when it ran a workflow', () => { @@ -37,10 +37,10 @@ describe('a run', () => { ]); }); - it.each(['echo', 'reason', 'workflow', 'interact', 'prediction', 'recall', 'compute'])( + it.each(['echo', 'reason', 'interact', 'prediction', 'compute'])( 'is neither of a custom adapter or a planned function type: %s', - (primitive) => { - const run = { ...functionRun, primitive }; + (type) => { + const run = { ...functionRun, type }; expect([isFunctionRun(run), isWorkflowRun(run)]).toEqual([false, false]); }, diff --git a/packages/specs/src/execution/execution.ts b/packages/definitions/src/runs/run.ts similarity index 80% rename from packages/specs/src/execution/execution.ts rename to packages/definitions/src/runs/run.ts index 0fb15ceed..245aa52c4 100644 --- a/packages/specs/src/execution/execution.ts +++ b/packages/definitions/src/runs/run.ts @@ -8,10 +8,10 @@ import { } from '@beonauto/operations'; import { Schema } from 'effect'; -import type { BrainFunctionDefinition, WorkflowDefinition } from '../registry/spec.ts'; +import type { BrainFunctionDefinition, WorkflowDefinition } from '../registry/definition.ts'; import { mostResultBytes } from './recorded-size.ts'; -export const ExecutionRejectionSchema = Schema.Union([ +export const RunRejectionSchema = Schema.Union([ Schema.Struct({ reason: Schema.Literal('invalid_input'), detail: Schema.String, issues: Schema.Array(IssueSchema) }), Schema.Struct({ reason: Schema.Literal('unavailable'), @@ -44,7 +44,7 @@ export const ExecutionRejectionSchema = Schema.Union([ detail: Schema.String, kind: CancelledKindSchema.annotate({ description: - 'Why the run was cancelled: requested when someone allowed to change the brain asked for it with cancel_execution; deadline when the step that waited for it ran out of time; overrun when a workflow ran as long as a workflow may run; parent_ended when the run that waited for it ended first, or its branch of a race lost', + 'Why the run was cancelled: requested when someone allowed to change the brain asked for it with cancel_run; deadline when the step that waited for it ran out of time; overrun when a workflow ran as long as a workflow may run; parent_ended when the run that waited for it ended first, or its branch of a race lost', }), }), Schema.Struct({ @@ -57,13 +57,15 @@ export const ExecutionRejectionSchema = Schema.Union([ }), ]).annotate({ description: 'Why the run was rejected, that it was cancelled, or that nobody answered it' }); -export type ExecutionRejection = typeof ExecutionRejectionSchema.Type; +export type RunRejection = typeof RunRejectionSchema.Type; export const RunSchema = Schema.Struct({ - execution_id: Schema.String.annotate({ description: 'The run id, a UUID' }), - primitive: Schema.String.annotate({ description: 'The API type identifier of the definition' }), + run_id: Schema.String.annotate({ description: 'The run id, a UUID' }), + type: Schema.String.annotate({ + description: 'The type of the definition: reasoning, interaction, computation, recall or workflow', + }), name: Schema.String.annotate({ description: 'The definition name' }), - spec_version: Schema.Int.annotate({ description: 'The definition version that ran' }), + definition_version: Schema.Int.annotate({ description: 'The definition version that ran' }), status: Schema.Literals(['started', 'succeeded', 'rejected', 'failed']).annotate({ description: 'started while it runs, while work it started finishes later, or when it never finished; then succeeded, rejected or failed', @@ -73,7 +75,7 @@ export const RunSchema = Schema.Struct({ description: `The result, when the run succeeded: with the record, at most ${mostResultBytes} bytes as JSON in UTF-8`, }), ), - rejection: Schema.optionalKey(ExecutionRejectionSchema), + rejection: Schema.optionalKey(RunRejectionSchema), started_at: Schema.String.annotate({ description: 'When the run started, in ISO 8601 UTC' }), started_by: Schema.String.annotate({ description: 'The id of the caller who started the run' }), finished_at: Schema.optionalKey(Schema.String.annotate({ description: 'When the run finished, in ISO 8601 UTC' })), @@ -81,21 +83,21 @@ export const RunSchema = Schema.Struct({ export type Run = typeof RunSchema.Type; -export type FunctionRun = Run & Pick; +export type FunctionRun = Run & Pick; -export type WorkflowRun = Run & Pick; +export type WorkflowRun = Run & Pick; -const functionTypes: ReadonlySet = new Set(['inference', 'interaction', 'computation', 'recollection']); +const functionTypes: ReadonlySet = new Set(['reasoning', 'interaction', 'computation', 'recall']); export function isFunctionRun(run: Run): run is FunctionRun { - return functionTypes.has(run.primitive); + return functionTypes.has(run.type); } export function isWorkflowRun(run: Run): run is WorkflowRun { - return run.primitive === 'orchestration'; + return run.type === 'workflow'; } -export type ExecutionRecord = Omit; +export type RunRecord = Omit; export const RunDetailSchema = Schema.Struct({ ...RunSchema.fields, diff --git a/packages/specs/src/execution/settlement-keys.ts b/packages/definitions/src/runs/settlement-keys.ts similarity index 72% rename from packages/specs/src/execution/settlement-keys.ts rename to packages/definitions/src/runs/settlement-keys.ts index 20ded3d1a..065734034 100644 --- a/packages/specs/src/execution/settlement-keys.ts +++ b/packages/definitions/src/runs/settlement-keys.ts @@ -2,7 +2,7 @@ import { createHash } from 'node:crypto'; import type { Schema } from 'effect'; -import type { ExecutionResult } from './execution-commands.ts'; +import type { RunResult } from './run-commands.ts'; function canonical(value: Schema.Json): Schema.Json { if (typeof value !== 'object' || value === null) { @@ -26,15 +26,15 @@ function digestOf(value: Schema.Json): string { .digest('hex'); } -export function succeedsWith(result: ExecutionResult, output: Schema.Json): boolean { - return result.type === 'execution_succeeded' && digestOf(result.output) === digestOf(output); +export function succeedsWith(result: RunResult, output: Schema.Json): boolean { + return result.type === 'run_succeeded' && digestOf(result.output) === digestOf(output); } -export function settlementKeyOf(result: ExecutionResult): string { - if (result.type === 'execution_succeeded') { +export function settlementKeyOf(result: RunResult): string { + if (result.type === 'run_succeeded') { return JSON.stringify([result.type, digestOf(result.output)]); } - if (result.type === 'execution_rejected') { + if (result.type === 'run_rejected') { const { rejection } = result; const kind = 'kind' in rejection ? rejection.kind : undefined; return JSON.stringify([result.type, rejection.reason, kind ?? null]); diff --git a/packages/specs/src/template.ts b/packages/definitions/src/template.ts similarity index 100% rename from packages/specs/src/template.ts rename to packages/definitions/src/template.ts diff --git a/packages/specs/src/template/engine-failure.ts b/packages/definitions/src/template/engine-failure.ts similarity index 100% rename from packages/specs/src/template/engine-failure.ts rename to packages/definitions/src/template/engine-failure.ts diff --git a/packages/specs/src/template/output-text.test.ts b/packages/definitions/src/template/output-text.test.ts similarity index 100% rename from packages/specs/src/template/output-text.test.ts rename to packages/definitions/src/template/output-text.test.ts diff --git a/packages/specs/src/template/output-text.ts b/packages/definitions/src/template/output-text.ts similarity index 100% rename from packages/specs/src/template/output-text.ts rename to packages/definitions/src/template/output-text.ts diff --git a/packages/specs/src/template/template-engine.test.ts b/packages/definitions/src/template/template-engine.test.ts similarity index 100% rename from packages/specs/src/template/template-engine.test.ts rename to packages/definitions/src/template/template-engine.test.ts diff --git a/packages/specs/src/template/template-engine.ts b/packages/definitions/src/template/template-engine.ts similarity index 100% rename from packages/specs/src/template/template-engine.ts rename to packages/definitions/src/template/template-engine.ts diff --git a/packages/specs/src/template/template-parsing.test.ts b/packages/definitions/src/template/template-parsing.test.ts similarity index 100% rename from packages/specs/src/template/template-parsing.test.ts rename to packages/definitions/src/template/template-parsing.test.ts diff --git a/packages/specs/src/template/template-parsing.ts b/packages/definitions/src/template/template-parsing.ts similarity index 100% rename from packages/specs/src/template/template-parsing.ts rename to packages/definitions/src/template/template-parsing.ts diff --git a/packages/specs/src/template/template-rendering.test.ts b/packages/definitions/src/template/template-rendering.test.ts similarity index 100% rename from packages/specs/src/template/template-rendering.test.ts rename to packages/definitions/src/template/template-rendering.test.ts diff --git a/packages/specs/src/template/template-rendering.ts b/packages/definitions/src/template/template-rendering.ts similarity index 100% rename from packages/specs/src/template/template-rendering.ts rename to packages/definitions/src/template/template-rendering.ts diff --git a/packages/specs/src/template/template-variables.test.ts b/packages/definitions/src/template/template-variables.test.ts similarity index 100% rename from packages/specs/src/template/template-variables.test.ts rename to packages/definitions/src/template/template-variables.test.ts diff --git a/packages/specs/src/template/template-variables.ts b/packages/definitions/src/template/template-variables.ts similarity index 100% rename from packages/specs/src/template/template-variables.ts rename to packages/definitions/src/template/template-variables.ts diff --git a/packages/specs/src/testing/callers.ts b/packages/definitions/src/testing/callers.ts similarity index 100% rename from packages/specs/src/testing/callers.ts rename to packages/definitions/src/testing/callers.ts diff --git a/packages/definitions/src/testing/definition-operations.ts b/packages/definitions/src/testing/definition-operations.ts new file mode 100644 index 000000000..6d0ee591c --- /dev/null +++ b/packages/definitions/src/testing/definition-operations.ts @@ -0,0 +1,34 @@ +import type { Presenter } from '@beonauto/operations'; + +import { + defineCancelRun, + defineCreateDefinition, + defineRunDefinition, + defineGetRunHistory, + defineGetDefinition, + defineListRuns, + defineListDefinitions, + defineRetireDefinition, + defineUpdateDefinition, + getRun, + makeDefinitionPresenters, + type Capability, +} from '../index.ts'; + +export function definitionOperationsFor( + capabilities: readonly Capability[], + presenters: readonly Presenter[] = makeDefinitionPresenters(capabilities), +) { + return { + createDefinition: defineCreateDefinition(capabilities), + listDefinitions: defineListDefinitions(capabilities), + getDefinition: defineGetDefinition(capabilities), + updateDefinition: defineUpdateDefinition(capabilities), + retireDefinition: defineRetireDefinition(capabilities), + runDefinition: defineRunDefinition(capabilities), + getRun, + cancelRun: defineCancelRun(capabilities), + listRuns: defineListRuns(capabilities), + getRunHistory: defineGetRunHistory(presenters), + }; +} diff --git a/packages/specs/src/testing/echo.ts b/packages/definitions/src/testing/echo.ts similarity index 87% rename from packages/specs/src/testing/echo.ts rename to packages/definitions/src/testing/echo.ts index 7ac12f0fb..29f2d6fbb 100644 --- a/packages/specs/src/testing/echo.ts +++ b/packages/definitions/src/testing/echo.ts @@ -1,8 +1,8 @@ import { InvalidInput } from '@beonauto/operations'; import { Effect, Predicate, Result, Schema, SchemaIssue } from 'effect'; -import { definePrimitive } from '../index.ts'; -import { TriggerSchema } from '../registry/spec-triggers.ts'; +import { defineCapability } from '../index.ts'; +import { TriggerSchema } from '../registry/definition-triggers.ts'; const decodeDocument = Schema.decodeUnknownEffect( Schema.fromJsonString( @@ -30,12 +30,12 @@ const parseDocument = Effect.fnUntraced(function* (source: string) { }); const notAnObject = new InvalidInput({ - detail: 'The input of an echo spec must be a JSON object', + detail: 'The input of an echo definition must be a JSON object', issues: [{ detail: 'Expected a JSON object', pointer: '' }], }); -export const echo = definePrimitive({ - name: 'echo', +export const echo = defineCapability({ + type: 'echo', title: 'Echo', guide: { name: 'echo' }, noun: { one: 'greeting', other: 'greetings' }, @@ -53,7 +53,7 @@ export const echo = definePrimitive({ required: ['greeting', 'input'], }, }), - execute: ({ greeting }, input) => + run: ({ greeting }, input) => Predicate.isObject(input) && !Array.isArray(input) ? Effect.succeed({ output: { greeting, input }, record: { greeting } }) : Effect.fail(notAnObject), diff --git a/packages/specs/src/testing/harness.ts b/packages/definitions/src/testing/harness.ts similarity index 100% rename from packages/specs/src/testing/harness.ts rename to packages/definitions/src/testing/harness.ts diff --git a/packages/specs/src/testing/index.ts b/packages/definitions/src/testing/index.ts similarity index 100% rename from packages/specs/src/testing/index.ts rename to packages/definitions/src/testing/index.ts diff --git a/packages/specs/src/testing/longest-runs.ts b/packages/definitions/src/testing/longest-runs.ts similarity index 100% rename from packages/specs/src/testing/longest-runs.ts rename to packages/definitions/src/testing/longest-runs.ts diff --git a/packages/specs/src/testing/probe.ts b/packages/definitions/src/testing/probe.ts similarity index 81% rename from packages/specs/src/testing/probe.ts rename to packages/definitions/src/testing/probe.ts index 0069a6efa..ee3b58423 100644 --- a/packages/specs/src/testing/probe.ts +++ b/packages/definitions/src/testing/probe.ts @@ -1,7 +1,13 @@ import { Conflict, InvalidInput, Unavailable } from '@beonauto/operations'; import { Effect, Predicate, type Schema } from 'effect'; -import { definePrimitive, type Executed, type RunContext, type Primitive, type PrimitiveRejection } from '../index.ts'; +import { + defineCapability, + type CapabilityAnswer, + type RunContext, + type Capability, + type CapabilityRejection, +} from '../index.ts'; type Mishap = 'stall' | 'unavailable' | 'unoffered' | 'conflict' | 'unworkable' | 'breakdown' | 'spent' | 'overspent'; @@ -11,7 +17,7 @@ export const spentUsage = { }; export interface Probe { - readonly primitive: Primitive; + readonly capability: Capability; readonly runs: () => number; readonly sufferOnNextRun: (mishap: Mishap) => void; readonly stalled: Promise; @@ -38,7 +44,7 @@ const mishaps: Readonly { +function answerTo(input: Schema.Json, run: RunContext, runs: number): Effect.Effect { if (Predicate.hasProperty(input, 'reject')) { return Effect.fail( new InvalidInput({ @@ -69,9 +75,9 @@ function answerTo(input: Schema.Json, execution: RunContext, runs: number): Effe if (Predicate.hasProperty(input, 'bulk') && Predicate.isNumber(input.bulk)) { return Effect.succeed({ output: 'x'.repeat(input.bulk), record: {} }); } - const { id, org, brain, caller, spec } = execution; + const { id, org, brain, caller, definition } = run; return Effect.succeed({ - output: { input, execution: { id, org, brain, caller: { ...caller }, spec: { ...spec } } }, + output: { input, run: { id, org, brain, caller: { ...caller }, definition: { ...definition } } }, record: { runs }, }); } @@ -81,8 +87,8 @@ export function probe(): Probe { let nextMishap: Mishap | undefined; let rejecting = false; const stalling = Promise.withResolvers(); - const primitive = definePrimitive({ - name: 'probe', + const capability = defineCapability({ + type: 'probe', title: 'Probe', guide: { name: 'probe' }, noun: { one: 'probe', other: 'probes' }, @@ -90,19 +96,19 @@ export function probe(): Probe { mediaType: 'text/plain', parse: (source: string) => linesOf(source, rejecting), summarize: () => ({}), - execute: (_lines, input, execution) => - Effect.suspend((): Effect.Effect => { + run: (_lines, input, run) => + Effect.suspend((): Effect.Effect => { runs += 1; const mishap = nextMishap; nextMishap = undefined; if (mishap === 'stall') { stalling.resolve(); } - return mishap === undefined ? answerTo(input, execution, runs) : mishaps[mishap]; + return mishap === undefined ? answerTo(input, run, runs) : mishaps[mishap]; }), }); return { - primitive, + capability, runs: () => runs, sufferOnNextRun: (mishap) => { nextMishap = mishap; diff --git a/packages/specs/src/testing/recording-journal.ts b/packages/definitions/src/testing/recording-journal.ts similarity index 100% rename from packages/specs/src/testing/recording-journal.ts rename to packages/definitions/src/testing/recording-journal.ts diff --git a/packages/specs/src/testing/relay.ts b/packages/definitions/src/testing/relay.ts similarity index 73% rename from packages/specs/src/testing/relay.ts rename to packages/definitions/src/testing/relay.ts index a88184511..ac81df7fb 100644 --- a/packages/specs/src/testing/relay.ts +++ b/packages/definitions/src/testing/relay.ts @@ -2,10 +2,10 @@ import { setTimeout } from 'node:timers/promises'; import { Effect, Predicate } from 'effect'; -import { definePrimitive, type FinishesLater, type Primitive } from '../index.ts'; +import { defineCapability, type FinishesLater, type Capability } from '../index.ts'; export interface Relay { - readonly primitive: Primitive; + readonly capability: Capability; readonly runs: () => number; readonly started: Promise; } @@ -13,8 +13,8 @@ export interface Relay { export function relay(): Relay { let runs = 0; const starting = Promise.withResolvers(); - const primitive = definePrimitive({ - name: 'relay', + const capability = defineCapability({ + type: 'relay', title: 'Relay', guide: { name: 'relay' }, noun: { one: 'relay', other: 'relays' }, @@ -22,7 +22,7 @@ export function relay(): Relay { mediaType: 'text/plain', parse: (source: string) => Effect.succeed(source), summarize: () => ({}), - execute: (_document, input, execution) => + run: (_document, input, run) => Effect.promise(() => { starting.resolve(); return setTimeout(startingMs(input)); @@ -30,12 +30,12 @@ export function relay(): Relay { Effect.map((): FinishesLater => { runs += 1; const padding = Predicate.isNumber(input) ? { padding: 'x'.repeat(input) } : {}; - return { finishesLater: true, record: { handed_on: execution.id, ...padding } }; + return { finishesLater: true, record: { handed_on: run.id, ...padding } }; }), ), whenCancelled: 'finish', }); - return { primitive, runs: () => runs, started: starting.promise }; + return { capability, runs: () => runs, started: starting.promise }; } function startingMs(input: unknown): number { diff --git a/packages/definitions/src/testing/relaying.ts b/packages/definitions/src/testing/relaying.ts new file mode 100644 index 000000000..30c2098b9 --- /dev/null +++ b/packages/definitions/src/testing/relaying.ts @@ -0,0 +1,59 @@ +import type { BrainRequest, Lineage } from '@beonauto/operations'; +import { Effect } from 'effect'; + +import { runSettler, type RunStreamAddress, type Settlement } from '../index.ts'; +import { acmeAdmin } from './callers.ts'; +import { definitionOperationsFor } from './definition-operations.ts'; +import { harness, toBrain } from './harness.ts'; +import { probe } from './probe.ts'; +import { relay } from './relay.ts'; + +export const relayedId = '0199a3c4-7d2e-7c1a-9b3f-2f1e0d9c8b7a'; + +export const settledAt = '2026-10-01T11:00:00.000Z'; + +export const cancelledAt = '2026-10-01T10:00:00.000Z'; + +const toAlpha = toBrain('acme', 'alpha'); + +const relayed: RunStreamAddress = { org: 'acme', brain: 'alpha', id: relayedId }; + +function handingOn(input: unknown): BrainRequest { + return toAlpha(acmeAdmin, { type: 'relay', name: 'hand-on', input, run_id: relayedId }); +} + +export async function withHandOn() { + const relayer = relay(); + const prober = probe(); + const operations = definitionOperationsFor([relayer.capability, prober.capability]); + const definitions = harness(); + await definitions.call( + operations.createDefinition, + toAlpha(acmeAdmin, { type: 'relay', name: 'hand-on', source: 'text' }), + ); + await definitions.call( + operations.createDefinition, + toAlpha(acmeAdmin, { type: 'probe', name: 'plain', source: 'text' }), + ); + const settle = runSettler(definitions.ledger.service); + return { + ...definitions, + ...operations, + relayer, + prober, + running: (input: unknown = {}) => definitions.call(operations.runDefinition, handingOn(input)), + executingCancelledOnceStarted: (input: unknown) => + definitions.callCancelledWhen(relayer.started, operations.runDefinition, handingOn(input)), + reading: () => definitions.call(operations.getRun, toAlpha(acmeAdmin, { run_id: relayedId })), + cancelling: (input: object = {}, id: string = relayedId) => + definitions.call(operations.cancelRun, toAlpha(acmeAdmin, { run_id: id, ...input }), cancelledAt), + history: () => definitions.call(operations.getRunHistory, toAlpha(acmeAdmin, { run_id: relayedId, limit: 100 })), + settling: (settlement: Settlement, address: RunStreamAddress = relayed, lineage?: Lineage) => + definitions.run(Effect.result(settle(address, settlement, lineage)), settledAt), + breakingDown: (settlement: Settlement) => + definitions.run( + Effect.catchDefect(Effect.result(settle(relayed, settlement)), (defect) => Effect.succeed(defect)), + settledAt, + ), + }; +} diff --git a/packages/specs/src/testing/tool-user.ts b/packages/definitions/src/testing/tool-user.ts similarity index 89% rename from packages/specs/src/testing/tool-user.ts rename to packages/definitions/src/testing/tool-user.ts index f102ca8d1..c87bab829 100644 --- a/packages/specs/src/testing/tool-user.ts +++ b/packages/definitions/src/testing/tool-user.ts @@ -2,19 +2,19 @@ import { Conflict, Unavailable } from '@beonauto/operations'; import { Effect, Schema } from 'effect'; import { - definePrimitive, - type Executed, + defineCapability, + type CapabilityAnswer, type CallAnsweredFact, type CallStartedFact, - type PrimitiveRejection, - type Primitive, + type CapabilityRejection, + type Capability, type ToolCallJournal, } from '../index.ts'; type Ending = 'succeed' | 'unavailable' | 'conflict' | 'stall'; export interface ToolUser { - readonly primitive: Primitive; + readonly capability: Capability; readonly stalled: Promise; readonly recordedLate: () => Promise; readonly startedLate: () => Promise; @@ -69,7 +69,10 @@ function everyAnswer(count: number, journal: ToolCallJournal) { } const endings: Readonly< - Record, (recorded: readonly boolean[]) => Effect.Effect> + Record< + Exclude, + (recorded: readonly boolean[]) => Effect.Effect + > > = { succeed: (recorded) => Effect.succeed({ output: { recorded: [...recorded] }, record: {} }), unavailable: () => Effect.fail(new Unavailable({ detail: 'The tools stopped answering' })), @@ -79,8 +82,8 @@ const endings: Readonly< export function toolUser(): ToolUser { const journals: ToolCallJournal[] = []; const stalling = Promise.withResolvers(); - const primitive = definePrimitive({ - name: 'tool-user', + const capability = defineCapability({ + type: 'tool-user', title: 'Tool user', guide: { name: 'tool-user' }, noun: { one: 'tool user', other: 'tool users' }, @@ -88,7 +91,7 @@ export function toolUser(): ToolUser { mediaType: 'text/plain', parse: (source: string) => Effect.succeed(source), summarize: () => ({}), - execute: (_document, input, { journal }) => + run: (_document, input, { journal }) => Effect.gen(function* () { journals.push(journal); const { calls, ending = 'succeed' } = decodeInput(input); @@ -105,7 +108,7 @@ export function toolUser(): ToolUser { callsTools: () => true, }); return { - primitive, + capability, stalled: stalling.promise, recordedLate: () => Promise.all(journals.map((journal) => Effect.runPromise(journal.answered(answerOfCall(1))))), startedLate: () => Promise.all(journals.map((journal) => Effect.runPromise(journal.started(startOfCall(9))))), diff --git a/packages/specs/src/tool-calls/run-lineage.test.ts b/packages/definitions/src/tool-calls/run-lineage.test.ts similarity index 51% rename from packages/specs/src/tool-calls/run-lineage.test.ts rename to packages/definitions/src/tool-calls/run-lineage.test.ts index c4dcbeedf..a72b211d4 100644 --- a/packages/specs/src/tool-calls/run-lineage.test.ts +++ b/packages/definitions/src/tool-calls/run-lineage.test.ts @@ -2,19 +2,19 @@ import { BrainContext, BrainWriter, Caller, messageIdOf, type Lineage, type Reco import { Effect } from 'effect'; import { describe, expect, it } from 'vitest'; -import { executionDecider, executionStreamOf } from '../execution/execution-decider.ts'; +import { runDecider, runStreamNameOf } from '../runs/run-decider.ts'; import { acmeAdmin } from '../testing/callers.ts'; +import { definitionOperationsFor } from '../testing/definition-operations.ts'; import { harness, toBrain, type Harness } from '../testing/harness.ts'; import { relayedId, withHandOn } from '../testing/relaying.ts'; -import { specOperationsFor } from '../testing/spec-operations.ts'; import { answerOfCall, toolUser } from '../testing/tool-user.ts'; import { toolCallJournal } from './tool-call-journal.ts'; const toAlpha = toBrain('acme', 'alpha'); -const executionId = '0199a3c4-7d2e-7c1a-9b3f-2f1e0d9c8b7a'; +const runId = '0199a3c4-7d2e-7c1a-9b3f-2f1e0d9c8b7a'; -const stream = `brain/acme/alpha/${executionStreamOf(executionId)}`; +const stream = `brain/acme/alpha/${runStreamNameOf(runId)}`; const idAt = (position: number): string => messageIdOf(stream, position); @@ -34,7 +34,7 @@ async function linksIn({ ledger, run }: Harness): Promise { Effect.orDie( ledger.service.readRecorded( { org: 'acme', brain: 'alpha' }, - { kind: 'run', execution: executionId }, + { kind: 'run', run: runId }, { order: 'asc', limit: 100 }, ), ), @@ -44,62 +44,65 @@ async function linksIn({ ledger, run }: Harness): Promise { async function aToolUser() { const user = toolUser(); - const operations = specOperationsFor([user.primitive]); - const specs = harness(); - await specs.call(operations.createSpec, toAlpha(acmeAdmin, { primitive: 'tool-user', name: 'caller', source: 'x' })); - const executing = (input: object, lineage?: Lineage) => - specs.call(operations.executeSpec, { - ...toAlpha(acmeAdmin, { primitive: 'tool-user', name: 'caller', input, execution_id: executionId }), + const operations = definitionOperationsFor([user.capability]); + const definitions = harness(); + await definitions.call( + operations.createDefinition, + toAlpha(acmeAdmin, { type: 'tool-user', name: 'caller', source: 'x' }), + ); + const running = (input: object, lineage?: Lineage) => + definitions.call(operations.runDefinition, { + ...toAlpha(acmeAdmin, { type: 'tool-user', name: 'caller', input, run_id: runId }), ...(lineage === undefined ? {} : { lineage }), }); - return { ...specs, executing }; + return { ...definitions, running }; } describe('the lineage of a run that calls tools', () => { it('starts with no cause, links each call to the answer before it and each answer to its call, and the finish to the last answer', async () => { - const specs = await aToolUser(); - await specs.executing({ calls: 2 }); - - expect(await linksIn(specs)).toEqual([ - { type: 'execution_started', id: idAt(1), causationId: null, correlationId: executionId }, - { type: 'tool_call_started', id: idAt(2), causationId: idAt(1), correlationId: executionId }, - { type: 'tool_call_started', id: idAt(3), causationId: idAt(1), correlationId: executionId }, - { type: 'tool_call_answered', id: idAt(4), causationId: idAt(2), correlationId: executionId }, - { type: 'tool_call_answered', id: idAt(5), causationId: idAt(3), correlationId: executionId }, - { type: 'execution_succeeded', id: idAt(6), causationId: idAt(5), correlationId: executionId }, + const definitions = await aToolUser(); + await definitions.running({ calls: 2 }); + + expect(await linksIn(definitions)).toEqual([ + { type: 'run_started', id: idAt(1), causationId: null, correlationId: runId }, + { type: 'tool_call_started', id: idAt(2), causationId: idAt(1), correlationId: runId }, + { type: 'tool_call_started', id: idAt(3), causationId: idAt(1), correlationId: runId }, + { type: 'tool_call_answered', id: idAt(4), causationId: idAt(2), correlationId: runId }, + { type: 'tool_call_answered', id: idAt(5), causationId: idAt(3), correlationId: runId }, + { type: 'run_succeeded', id: idAt(6), causationId: idAt(5), correlationId: runId }, ]); }); it('finishes caused by its start when it called no tool, and a failure too', async () => { - const specs = await aToolUser(); - await specs.executing({ calls: 0, ending: 'unavailable' }); + const definitions = await aToolUser(); + await definitions.running({ calls: 0, ending: 'unavailable' }); - expect((await linksIn(specs)).map(({ type, causationId }) => [type, causationId])).toEqual([ - ['execution_started', null], - ['execution_rejected', idAt(1)], + expect((await linksIn(definitions)).map(({ type, causationId }) => [type, causationId])).toEqual([ + ['run_started', null], + ['run_rejected', idAt(1)], ]); }); it('started by another run, is caused by what it was given and belongs to the run it was given', async () => { - const specs = await aToolUser(); + const definitions = await aToolUser(); const lineage = { causationId: '5d0e9f6a-1b2c-5d3e-8f4a-6b7c8d9e0f1a', correlationId: 'root' }; - await specs.executing({ calls: 0 }, lineage); + await definitions.running({ calls: 0 }, lineage); - expect((await linksIn(specs)).map(({ causationId, correlationId }) => [causationId, correlationId])).toEqual([ + expect((await linksIn(definitions)).map(({ causationId, correlationId }) => [causationId, correlationId])).toEqual([ [lineage.causationId, 'root'], [idAt(1), 'root'], ]); }); - it('is never taken from the input of execute_spec, which refuses a field it does not know', async () => { - const specs = await aToolUser(); + it('is never taken from the input of run_definition, which refuses a field it does not know', async () => { + const definitions = await aToolUser(); const lineage = { causationId: '5d0e9f6a-1b2c-5d3e-8f4a-6b7c8d9e0f1a', correlationId: 'root' }; - expect(await specs.executing({ calls: 0, lineage })).toMatchObject({ status: 'succeeded' }); + expect(await definitions.running({ calls: 0, lineage })).toMatchObject({ status: 'succeeded' }); expect( - await specs.call( - specOperationsFor([toolUser().primitive]).executeSpec, - toAlpha(acmeAdmin, { primitive: 'tool-user', name: 'caller', input: { calls: 0 }, lineage }), + await definitions.call( + definitionOperationsFor([toolUser().capability]).runDefinition, + toAlpha(acmeAdmin, { type: 'tool-user', name: 'caller', input: { calls: 0 }, lineage }), ), ).toMatchObject({ status: 'rejected', reason: 'invalid_input', issues: [{ pointer: '/lineage' }] }); }); @@ -107,63 +110,63 @@ describe('the lineage of a run that calls tools', () => { describe('the journal of a run, given an answer to a call it has no start of', () => { it('links the answer to the answer before it, or to the start of the run', async () => { - const specs = harness(); + const definitions = harness(); const recording = Effect.gen(function* () { - yield* specs.ledger.service.execute(stream, executionDecider, { + yield* definitions.ledger.service.execute(stream, runDecider, { type: 'start', - primitive: 'tool-user', + definition_type: 'tool-user', name: 'caller', input: {}, - spec_version: 1, + definition_version: 1, calls_tools: true, by: 'acme-admin', at: '2026-10-01T09:00:00.000Z', }); - const journal = yield* toolCallJournal(executionId, { startId: idAt(1), correlationId: executionId }); + const journal = yield* toolCallJournal(runId, { startId: idAt(1), correlationId: runId }); return yield* journal.answered(answerOfCall(9)); }).pipe( Effect.provideService(BrainWriter, { execute: (relative, decider, command, lineage) => - specs.ledger.service.execute(`brain/acme/alpha/${relative}`, decider, command, lineage), + definitions.ledger.service.execute(`brain/acme/alpha/${relative}`, decider, command, lineage), }), Effect.provideService(Caller, acmeAdmin), Effect.provideService(BrainContext, { org: 'acme', brain: 'alpha' }), ); - expect(await specs.run(Effect.orDie(recording))).toBe(true); - expect((await linksIn(specs)).at(-1)).toEqual({ + expect(await definitions.run(Effect.orDie(recording))).toBe(true); + expect((await linksIn(definitions)).at(-1)).toEqual({ type: 'tool_call_answered', id: idAt(2), causationId: idAt(1), - correlationId: executionId, + correlationId: runId, }); }); }); describe('the lineage of a run that finishes later', () => { it('defers caused by its start, and settles caused by what settled it', async () => { - const specs = await withHandOn(); - await specs.executing(); + const definitions = await withHandOn(); + await definitions.running(); const settledBy = { causationId: '5d0e9f6a-1b2c-5d3e-8f4a-6b7c8d9e0f1a', correlationId: relayedId }; - await specs.settling({ status: 'succeeded', output: 'done', record: {} }, undefined, settledBy); + await definitions.settling({ status: 'succeeded', output: 'done', record: {} }, undefined, settledBy); - expect((await linksIn(specs)).map(({ type, causationId }) => [type, causationId])).toEqual([ - ['execution_started', null], - ['execution_deferred', idAt(1)], - ['execution_succeeded', settledBy.causationId], + expect((await linksIn(definitions)).map(({ type, causationId }) => [type, causationId])).toEqual([ + ['run_started', null], + ['run_deferred', idAt(1)], + ['run_succeeded', settledBy.causationId], ]); }); it('started again after it was rejected, is caused by its latest start from then on', async () => { - const specs = await aToolUser(); - await specs.executing({ calls: 0, ending: 'unavailable' }); - await specs.executing({ calls: 0, ending: 'unavailable' }); - - expect((await linksIn(specs)).map(({ type, causationId }) => [type, causationId])).toEqual([ - ['execution_started', null], - ['execution_rejected', idAt(1)], - ['execution_started', null], - ['execution_rejected', idAt(3)], + const definitions = await aToolUser(); + await definitions.running({ calls: 0, ending: 'unavailable' }); + await definitions.running({ calls: 0, ending: 'unavailable' }); + + expect((await linksIn(definitions)).map(({ type, causationId }) => [type, causationId])).toEqual([ + ['run_started', null], + ['run_rejected', idAt(1)], + ['run_started', null], + ['run_rejected', idAt(3)], ]); }); }); diff --git a/packages/definitions/src/tool-calls/tool-call-journal.test.ts b/packages/definitions/src/tool-calls/tool-call-journal.test.ts new file mode 100644 index 000000000..31991d485 --- /dev/null +++ b/packages/definitions/src/tool-calls/tool-call-journal.test.ts @@ -0,0 +1,178 @@ +import { Effect, Schema } from 'effect'; +import { describe, expect, it } from 'vitest'; + +import type { RunCommand } from '../runs/run-commands.ts'; +import { runDecider, runStreamNameOf } from '../runs/run-decider.ts'; +import { acmeAdmin } from '../testing/callers.ts'; +import { definitionOperationsFor } from '../testing/definition-operations.ts'; +import { harness, toBrain } from '../testing/harness.ts'; +import { startOfCall, toolUser } from '../testing/tool-user.ts'; + +const toAlpha = toBrain('acme', 'alpha'); + +const runId = '0199a3c4-7d2e-7c1a-9b3f-2f1e0d9c8b7a'; + +const at = { by: 'acme-admin', at: '2026-10-01T09:00:00.000Z' }; + +const typesOf = Schema.decodeUnknownSync( + Schema.Struct({ output: Schema.Struct({ events: Schema.Array(Schema.Struct({ type: Schema.String })) }) }), +); + +async function brainWithToolUser() { + const user = toolUser(); + const operations = definitionOperationsFor([user.capability]); + const definitions = harness(); + await definitions.call( + operations.createDefinition, + toAlpha(acmeAdmin, { type: 'tool-user', name: 'caller', source: 'x' }), + ); + const running = (input: object) => + definitions.call( + operations.runDefinition, + toAlpha(acmeAdmin, { type: 'tool-user', name: 'caller', input, run_id: runId }), + ); + const history = async () => + typesOf( + await definitions.call(operations.getRunHistory, toAlpha(acmeAdmin, { run_id: runId, limit: 100 })), + ).output.events.map(({ type }) => type); + const recordedDirectly = (...commands: readonly RunCommand[]) => + definitions.run( + Effect.forEach(commands, (command) => + Effect.orDie( + definitions.ledger.service.execute(`brain/acme/alpha/${runStreamNameOf(runId)}`, runDecider, command), + ), + ), + ); + return { ...definitions, ...operations, user, running, history, recordedDirectly }; +} + +const calls = (count: number, type: string): readonly string[] => Array.from({ length: count }, () => type); + +describe('the journal of a run', () => { + it('records ten calls made at once, one append at a time, each start before its answer', async () => { + const { running, history, user } = await brainWithToolUser(); + + expect(await running({ calls: 10 })).toMatchObject({ + status: 'succeeded', + output: { output: { recorded: Array.from({ length: 20 }, () => true) } }, + }); + expect(await history()).toEqual([ + 'run_started', + ...calls(10, 'tool_call_started'), + ...calls(10, 'tool_call_answered'), + 'run_succeeded', + ]); + expect(user.capability.describeOutput({ recorded: [] })).toBe('It called its tools.'); + }); + + it('numbers the calls made at once in the order their starts land, each from the run, never twice', async () => { + const { call, running, getRunHistory } = await brainWithToolUser(); + await running({ calls: 10 }); + + const read = await call(getRunHistory, toAlpha(acmeAdmin, { run_id: runId, limit: 100 })); + const { events } = Schema.decodeUnknownSync( + Schema.Struct({ + output: Schema.Struct({ + events: Schema.Array( + Schema.Struct({ type: Schema.String, data: Schema.Struct({ number: Schema.optionalKey(Schema.Int) }) }), + ), + }), + }), + )(read).output; + + expect(events.filter(({ type }) => type === 'tool_call_started').map(({ data }) => data.number)).toEqual([ + 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, + ]); + }); +}); + +describe('the journal of a run that has finished', () => { + it('records nothing more', async () => { + const { running, history, user } = await brainWithToolUser(); + await running({ calls: 1 }); + + expect(await user.recordedLate()).toEqual([false]); + expect(await user.startedLate()).toEqual([undefined]); + expect(await history()).toEqual(['run_started', 'tool_call_started', 'tool_call_answered', 'run_succeeded']); + }); + + it('leaves a call in flight when the run is cancelled with a start and no answer', async () => { + const { callCancelledWhen, runDefinition, history, user } = await brainWithToolUser(); + const request = toAlpha(acmeAdmin, { + type: 'tool-user', + name: 'caller', + input: { calls: 1, ending: 'stall' }, + run_id: runId, + }); + + expect(await callCancelledWhen(user.stalled, runDefinition, request)).toEqual({ status: 'cancelled' }); + expect(await user.recordedLate()).toEqual([false]); + expect(await history()).toEqual(['run_started', 'tool_call_started', 'run_failed']); + }); +}); + +describe('a run that called tools', () => { + it.each(['unavailable', 'conflict'])( + 'is not run again under its id after it ended %s, and says to start a new run', + async (ending) => { + const { running, user } = await brainWithToolUser(); + await running({ calls: 1, ending }); + + expect(await running({ calls: 1, ending })).toMatchObject({ + status: 'rejected', + reason: 'conflict', + kind: 'tools_called', + }); + expect(await user.recordedLate()).toEqual([false]); + }, + ); + + it('is answered again when it succeeded', async () => { + const { running, user } = await brainWithToolUser(); + const first = await running({ calls: 2 }); + + expect(await running({ calls: 2 })).toEqual(first); + expect(await user.recordedLate()).toEqual([false]); + }); + + it('left started by a server that died stays started, is listed as running, and is not run again', async () => { + const { call, running, getRun, listRuns, recordedDirectly } = await brainWithToolUser(); + await recordedDirectly( + { + type: 'start', + definition_type: 'tool-user', + name: 'caller', + input: {}, + definition_version: 1, + calls_tools: true, + ...at, + }, + { type: 'tool_call', fact: startOfCall(1), ...at }, + ); + + expect(await running({})).toMatchObject({ status: 'rejected', reason: 'conflict', kind: 'tools_called' }); + expect(await call(getRun, toAlpha(acmeAdmin, { run_id: runId }))).toMatchObject({ + output: { status: 'started' }, + }); + expect(await call(listRuns, toAlpha(acmeAdmin, { status: 'started' }))).toMatchObject({ + output: { runs: [{ run_id: runId, status: 'started' }] }, + }); + }); +}); + +describe('a run of a definition that calls tools, still in progress', () => { + it('is not run again under its id while an earlier call still runs it, before any tool was called', async () => { + const { callCancelledWhen, runDefinition, running, history, user } = await brainWithToolUser(); + const first = toAlpha(acmeAdmin, { + type: 'tool-user', + name: 'caller', + input: { calls: 0, ending: 'stall' }, + run_id: runId, + }); + const retried = user.stalled.then(() => running({ calls: 0, ending: 'stall' })); + + expect(await callCancelledWhen(retried, runDefinition, first)).toEqual({ status: 'cancelled' }); + expect(await retried).toMatchObject({ status: 'rejected', reason: 'conflict', kind: 'tools_called' }); + expect(await history()).toEqual(['run_started', 'run_failed']); + }); +}); diff --git a/packages/specs/src/tool-calls/tool-call-journal.ts b/packages/definitions/src/tool-calls/tool-call-journal.ts similarity index 76% rename from packages/specs/src/tool-calls/tool-call-journal.ts rename to packages/definitions/src/tool-calls/tool-call-journal.ts index af66ea062..b00c7dda1 100644 --- a/packages/specs/src/tool-calls/tool-call-journal.ts +++ b/packages/definitions/src/tool-calls/tool-call-journal.ts @@ -1,10 +1,10 @@ import { BrainContext, BrainWriter, Caller, messageIdOf, streamPrefixOfBrain } from '@beonauto/operations'; import { DateTime, Effect, Exit, Ref, Semaphore } from 'effect'; -import type { ToolCallFact } from '../execution/execution-commands.ts'; -import { executionDecider, executionStreamOf } from '../execution/execution-decider.ts'; -import { lastCallOf, runOf, type ExecutionState } from '../execution/execution-state.ts'; -import type { RunLineage, ToolCallJournal } from '../primitive/primitive.ts'; +import type { RunLineage, ToolCallJournal } from '../capability/capability.ts'; +import type { ToolCallFact } from '../runs/run-commands.ts'; +import { runDecider, runStreamNameOf } from '../runs/run-decider.ts'; +import { lastCallOf, startedRunOf, type RunState } from '../runs/run-state.ts'; export interface RunJournal extends ToolCallJournal { readonly latest: Effect.Effect; @@ -19,7 +19,7 @@ function causeOf(fact: ToolCallFact, calls: Calls): string { return fact.type === 'tool_call_answered' ? (calls.started.get(fact.number) ?? calls.answered) : calls.answered; } -function numberOf(fact: ToolCallFact, state: ExecutionState): number { +function numberOf(fact: ToolCallFact, state: RunState): number { return fact.type === 'tool_call_answered' ? fact.number : lastCallOf(state); } @@ -31,7 +31,7 @@ function noted(fact: ToolCallFact, calls: Calls, number: number, id: string): Ca export const toolCallJournal = Effect.fnUntraced(function* (id: string, lineage: RunLineage) { const writer = yield* BrainWriter; - const stream = `${streamPrefixOfBrain(yield* BrainContext)}${executionStreamOf(id)}`; + const stream = `${streamPrefixOfBrain(yield* BrainContext)}${runStreamNameOf(id)}`; const { id: by } = yield* Caller; const permit = yield* Semaphore.make(1); const calls = yield* Ref.make({ answered: lineage.startId, started: new Map() }); @@ -41,12 +41,12 @@ export const toolCallJournal = Effect.fnUntraced(function* (id: string, lineage: const at = DateTime.formatIso(yield* DateTime.now); const known = yield* Ref.get(calls); const { state, version } = yield* writer.execute( - executionStreamOf(id), - executionDecider, + runStreamNameOf(id), + runDecider, { type: 'tool_call', fact, by, at }, { causationId: causeOf(fact, known), correlationId: lineage.correlationId }, ); - const number = numberOf(fact, runOf(state)); + const number = numberOf(fact, startedRunOf(state)); yield* Ref.set(calls, noted(fact, known, number, messageIdOf(stream, version))); return number; }), diff --git a/primitives/computation/tsconfig.json b/packages/definitions/tsconfig.json similarity index 100% rename from primitives/computation/tsconfig.json rename to packages/definitions/tsconfig.json diff --git a/primitives/orchestration/vitest.config.ts b/packages/definitions/vitest.config.ts similarity index 86% rename from primitives/orchestration/vitest.config.ts rename to packages/definitions/vitest.config.ts index af99ee13f..ed929c361 100644 --- a/primitives/orchestration/vitest.config.ts +++ b/packages/definitions/vitest.config.ts @@ -2,4 +2,4 @@ import { defineConfig, mergeConfig } from 'vitest/config'; import { sharedConfig } from '../../vitest.shared.ts'; -export default mergeConfig(sharedConfig, defineConfig({ test: { name: 'orchestration' } })); +export default mergeConfig(sharedConfig, defineConfig({ test: { name: 'definitions' } })); diff --git a/packages/ledger/README.md b/packages/ledger/README.md index 58f360f70..800d5f2d8 100644 --- a/packages/ledger/README.md +++ b/packages/ledger/README.md @@ -1,14 +1,14 @@ # @beonauto/ledger -The ledger keeps track of every single action and interaction that the brain does. It is the log of the inputs and outputs to all the primitives. +The ledger keeps track of every single action and interaction that the brain does. It is the log of the inputs and outputs to all the capabilities. This package is the event store behind the `Ledger` port of `@beonauto/operations`. It stores events with [Emmett](https://event-driven-io.github.io/emmett/) on SQLite or on PostgreSQL. ## What it stores -- **Streams of events.** The application layer names each stream, for example `org/acme/brains` or `brain/acme/sales/specs/inference`. Names are opaque: the ledger never changes their case, trims them or normalises their Unicode, so two names that differ in any character are two streams. +- **Streams of events.** The application layer names each stream, for example `org/acme/brains` or `brain/acme/sales/definitions/reasoning`. Names are opaque: the ledger never changes their case, trims them or normalises their Unicode, so two names that differ in any character are two streams. - **One version per stream.** A stream's version is the number of events in it, and 0 when nobody has written it. -- **Keyed projections.** One row per key in a table of each projection the ledger is opened with, kept inside the append of each fact of the stream kinds the projection names: the outcomes of runs in `run_outcomes_2`, which a brain's analytics read, and any other projection the composition registers, such as the open requests of interaction functions (see [Keyed projections](#keyed-projections)). +- **Keyed projections.** One row per key in a table of each projection the ledger is opened with, kept inside the append of each fact of the stream kinds the projection names: the outcomes of runs in `run_outcomes_3`, which a brain's analytics read, and any other projection the composition registers, such as the open requests of interaction functions (see [Keyed projections](#keyed-projections)). - **Events as Emmett stores them.** Each event becomes `{ type, data, metadata }`. `type` is the event's own `type`. `data` is the whole event encoded with `Schema.toCodecJson(decider.eventSchema)`, which must give a JSON object. Loading decodes `data` with the same codec, so an event comes back exactly as it was decided, dates and big integers included. A stored event that no longer decodes is a defect. - **An id, a cause and a correlation for every message.** The store's append writes, in each message's metadata, `messageId`, which Emmett also keeps as the message's `message_id` and honours when the caller gives it, `causationId` and `correlationId`. The id is `messageIdOf(stream, position)` of `@beonauto/operations`, a version 5 UUID of the stream and the position the message takes, which the append knows before it writes since every append passes the version it expects; so no two messages share an id, and anyone who knows a stream and a position names its message. The cause and the correlation are the `Lineage` the command was executed with, `null` when it was given none. Messages written before the ledger set its own ids keep the random ids Emmett gave them, and no cause or correlation. @@ -31,7 +31,7 @@ Code that keeps its own streams, such as `@beonauto/workflow-engine`, uses the s - `EventStore.mostEventsInOneAppend` is the most events the store takes in one append. - `eventAppenderOf(store, eventSchema)` encodes and appends events with an expected version and, when given, their lineage, at most `store.mostEventsInOneAppend` in one append, and fails with `VersionConflict` when another writer appended first. - `retriedOnVersionConflict(attempt)` runs a load-decide-append attempt again after a version conflict, up to three more times, and then fails with `Conflict`. -- `EventStore.definitionStreams(type)` names the streams of one type of definition in every brain, such as each brain's `specs/recollection`, with the version of each, read from Emmett's table of streams through an index of their own (see [The indexes](#the-indexes)), so a reader that follows the definitions of one type, as the workflow host follows recall functions, finds a brain whose first definition of the type was saved after it started. +- `EventStore.definitionStreams(type)` names the streams of one type of definition in every brain, such as each brain's `definitions/recall`, with the version of each, read from Emmett's table of streams through an index of their own (see [The indexes](#the-indexes)), so a reader that follows the definitions of one type, as the workflow host follows recall functions, finds a brain whose first definition of the type was saved after it started. - `appendSignal()` is an in-process signal of appends to brains: `raise(stream)` tells every listener the brain key of a stream, its name through the third `/`, and nothing for a stream of no brain, and `listen(listener)` answers the function that stops listening. `signalledOn(store, signal)` raises it after each append the store recorded, never after one that failed, and the layers take it as `appends`, `ledgerLayer({ fileName, appends })` and `postgresqlLedgerLayer({ connectionString, appends })`, so code in the same process that follows a brain, as the workflow host's projector does, wakes when something was recorded rather than at its next sweep. Appends made by another process raise nothing here. - `decisionLoop(load, append, decider)` is the load-decide-append loop itself, the one `Ledger.execute` runs: it loads, decides, appends the decided events with the loaded version expected, giving the append what the load gave too, retries with `retriedOnVersionConflict`, and answers with what the load gave, the events and the folded state. The ledger's load folds the whole stream; a caller with snapshots passes a load that folds a snapshot and its tail. @@ -48,7 +48,7 @@ Every stream of a brain is named `brain///…`. The store takes the | SQLite | `substr(stream_id, 1, …)` to the third `/`, found with nested `instr`, since SQLite has no regular expressions | | PostgreSQL | `substring(stream_id FROM '^(?:[^/]*/){3}')`, null for a name with fewer than three `/` | -The same expression taken to the fourth `/` gives the key of a stream's kind within its brain, such as `brain/acme/sales/executions/`. A run is a stream whose kind key ends in `executions/`, so no other stream may be nested under `executions/`. +The same expression taken to the fourth `/` gives the key of a stream's kind within its brain, such as `brain/acme/sales/runs/`. A run is a stream whose kind key ends in `runs/`, so no other stream may be nested under `runs/`. ### The indexes @@ -65,11 +65,11 @@ The read of one run reads each of its two streams through the third index from t The read by correlation walks the fifth index, whose second column is the message's correlation taken from its metadata by an expression, `json_extract(message_metadata, '$.correlationId')` on SQLite and `(message_metadata ->> 'correlationId')` on PostgreSQL, as the other indexes take their keys from the stream name, so a page of a run's whole tree costs the same however much else the brain recorded. On the ledger of [Measurement](#measurement), where every message of a run carries the run as its correlation, a page of the tree of a run of 21 messages took 0.28 to 0.29 ms on SQLite and 1.99 to 2.04 ms on PostgreSQL at the median, a page from the middle of the tree of a run of 100,001 messages 0.26 ms and 1.44 to 2.19 ms, and, behind a million newer messages of other brains, 0.26 to 0.31 ms and 1.52 to 1.81 ms, as a page of one run took. -The sixth index is on Emmett's table of streams, `emt_streams`, not on its messages: its key is the type a definition stream holds, the part of a stream's name after `brain///specs/`, such as `recollection`, taken with `CASE WHEN … = 'specs/' THEN substr(…) END` over the brain key on SQLite and `substring(stream_id FROM '^(?:[^/]*/){3}specs/([^/]+)$')` on PostgreSQL, and it is partial, holding the streams whose key is not null, so it holds one entry for each brain and type of definition however many runs and other streams the ledger keeps. `definitionStreams(type)` matches the same expression with `=`, which both planners take as implying the index's `IS NOT NULL`, so the read walks the index; a test reads the plan on each store. Without it the read scans every stream of the ledger. +The sixth index is on Emmett's table of streams, `emt_streams`, not on its messages: its key is the type a definition stream holds, the part of a stream's name after `brain///definitions/`, such as `recall`, taken with `CASE WHEN … = 'definitions/' THEN substr(…) END` over the brain key on SQLite and `substring(stream_id FROM '^(?:[^/]*/){3}definitions/([^/]+)$')` on PostgreSQL, and it is partial, holding the streams whose key is not null, so it holds one entry for each brain and type of definition however many runs and other streams the ledger keeps. `definitionStreams(type)` matches the same expression with `=`, which both planners take as implying the index's `IS NOT NULL`, so the read walks the index; a test reads the plan on each store. Without it the read scans every stream of the ledger. -The list of runs walks the fourth index through the first message of each run, so a page of runs costs the same however many messages the run logs of a brain hold between two runs. A selection that names `notBeginningWith` leaves out, in the same walk, the streams whose first message is of those types, before the page counts its runs, so the page is full and has no more only once the runs are read; `@beonauto/specs` leaves out a stream that begins with a caller's cancel, which holds no run, and the ledger itself names no event. Without it, on a ledger of 598,362 messages, PostgreSQL estimated 845 first messages of runs where there were 60,000, and answered a page of runs filtered by status with a sequential scan of the whole table, in 122 to 126 ms; with it, the same pages took 1.4 to 3.5 ms. On SQLite the index holds the first messages alone. On PostgreSQL it holds every message, with its position in its stream as the second column, which the read asks to be 1, because the planner takes no statistics from the expression of an index with a `WHERE` clause: with the index partial, as on SQLite, on the ledger of [Measurement](#measurement), it planned a deep page of failed runs oldest first as a bitmap scan of the brain's runs and a sort of 49,999 of them, in 73 ms; whole, and with the table analysed, the same page took 4.4 ms. +The list of runs walks the fourth index through the first message of each run, so a page of runs costs the same however many messages the run logs of a brain hold between two runs. A selection that names `notBeginningWith` leaves out, in the same walk, the streams whose first message is of those types, before the page counts its runs, so the page is full and has no more only once the runs are read; `@beonauto/definitions` leaves out a stream that begins with a caller's cancel, which holds no run, and the ledger itself names no event. Without it, on a ledger of 598,362 messages, PostgreSQL estimated 845 first messages of runs where there were 60,000, and answered a page of runs filtered by status with a sequential scan of the whole table, in 122 to 126 ms; with it, the same pages took 1.4 to 3.5 ms. On SQLite the index holds the first messages alone. On PostgreSQL it holds every message, with its position in its stream as the second column, which the read asks to be 1, because the planner takes no statistics from the expression of an index with a `WHERE` clause: with the index partial, as on SQLite, on the ledger of [Measurement](#measurement), it planned a deep page of failed runs oldest first as a bitmap scan of the brain's runs and a sort of 49,999 of them, in 73 ms; whole, and with the table analysed, the same page took 4.4 ms. -A selection that names `primitive` or `name` keeps the runs whose first message holds that value at the top of its data, as a run's start, `execution_started`, records them: the walk reads the two fields out of the first message of each run it examines and the page counts only the runs that hold them, so a page of the runs of one definition is full while the walk finds them, and has no more once the runs are read. On SQLite the walk compares `json_extract(message_data, '$.primitive')` and `json_extract(message_data, '$.name')` with `IS`, so a first message without them is no match. On PostgreSQL it first looks with `strpos` for each field asked in the JSON text inside the wrapper, as `JSON.stringify` writes it, `"name":` and the value written as JSON, and only when it finds every one does it ask `@>` of that text as `jsonb`, after `regexp_replace` with the pattern `(\\\\)|\\u(?:0000|d[89a-f][0-9a-f]{2})`, the replacement `\1` and the flags `gi` takes out the escapes `jsonb` refuses, of U+0000 and of a surrogate, which `JSON.stringify` writes only for one without its pair; the pattern matches an escaped backslash first and keeps it, so a backslash followed by the text `u0000` stays as it was; without it a run whose start holds U+0000 or an unpaired surrogate anywhere fails the whole page, `unsupported Unicode escape sequence`, as the suite's start that holds both showed when the statement was run without it. A start is written by `JSON.stringify`, which writes a field it holds exactly that way, so a run that holds the value is always found, and a text that holds it elsewhere, such as in its input, goes on to the exact check. SQLite does not look first: `instr` over a start costs more than `json_extract` parsing it, as [Measurement of the run filters](#measurement-of-the-run-filters) shows. A field that holds U+0000 or an unpaired surrogate itself is read without them, which no definition's name or primitive does. Unlike the status of a run, which the walk reads from the type of the latest message, these fields cost the reading of each first message examined, whose input may be up to 256 KiB, and on PostgreSQL the parse of each one that holds them as written. +A selection that names `definitionType` or `name` keeps the runs whose first message holds that value, as `definition_type` or `name`, at the top of its data, as a run's start, `run_started`, records them: the walk reads the two fields out of the first message of each run it examines and the page counts only the runs that hold them, so a page of the runs of one definition is full while the walk finds them, and has no more once the runs are read. On SQLite the walk compares `json_extract(message_data, '$.definition_type')` and `json_extract(message_data, '$.name')` with `IS`, so a first message without them is no match. On PostgreSQL it first looks with `strpos` for each field asked in the JSON text inside the wrapper, as `JSON.stringify` writes it, `"name":` and the value written as JSON, and only when it finds every one does it ask `@>` of that text as `jsonb`, after `regexp_replace` with the pattern `(\\\\)|\\u(?:0000|d[89a-f][0-9a-f]{2})`, the replacement `\1` and the flags `gi` takes out the escapes `jsonb` refuses, of U+0000 and of a surrogate, which `JSON.stringify` writes only for one without its pair; the pattern matches an escaped backslash first and keeps it, so a backslash followed by the text `u0000` stays as it was; without it a run whose start holds U+0000 or an unpaired surrogate anywhere fails the whole page, `unsupported Unicode escape sequence`, as the suite's start that holds both showed when the statement was run without it. A start is written by `JSON.stringify`, which writes a field it holds exactly that way, so a run that holds the value is always found, and a text that holds it elsewhere, such as in its input, goes on to the exact check. SQLite does not look first: `instr` over a start costs more than `json_extract` parsing it, as [Measurement of the run filters](#measurement-of-the-run-filters) shows. A field that holds U+0000 or an unpaired surrogate itself is read without them, which no definition's name or type does. Unlike the status of a run, which the walk reads from the type of the latest message, these fields cost the reading of each first message examined, whose input may be up to 256 KiB, and on PostgreSQL the parse of each one that holds them as written. The ledger creates the indexes when it opens, but first looks them up by name in the catalog, `pg_class` on PostgreSQL and `sqlite_master` on SQLite, which takes no lock on the messages table, and creates only those missing, with `CREATE INDEX IF NOT EXISTS`. A start that finds them all issues no `CREATE`. That matters on PostgreSQL, where `CREATE INDEX`, even on an index that exists, takes a lock on the messages table that waits for the appends in flight and holds every new append behind it: with an append left open for 2 s, `CREATE INDEX IF NOT EXISTS` on an index that existed took 2,013 ms, and an append begun 200 ms after it waited 1,821 ms; a start that looked first took 15 to 17 ms with the append still open, and a test checks that such a start is not held up. On PostgreSQL the lookup and the creation run in Emmett's `onAfterSchemaCreated` hook, inside the migration's transaction and its migration lock, so servers that start together build each index once; a start that created any index then analyses both tables, `ANALYZE emt_messages, emt_streams`, so the planner has statistics on the new key expressions at once rather than when autovacuum next analyses the table. On SQLite, which serialises writers, they run just after Emmett's migration, on the same connections, and the hooks a caller passes to `sqliteEventStore` are kept as given. @@ -107,11 +107,11 @@ On PostgreSQL a size is `octet_length(message_data ->> 'json')`, which takes the LEDGER_MEASURE_POSTGRESQL_URL=postgresql://postgres:ledger-test@127.0.0.1:19632/postgres pnpm --filter @beonauto/ledger measure:heads ``` -| The page | Measuring every record it examines | Measuring the records it loads | -| ----------------------------------------------------------------- | ---------------------------------- | ------------------------------ | -| a page of heads, `dataOf: []` | 3,598 buffers, 29.6 ms | 1 buffer, 0.40 ms | -| the workflow host's follower, which loads the data of specs alone | 3,598 buffers, 30.4 ms | 1 buffer, 0.20 ms | -| every record with its data | 3,598 buffers, 28.1 ms | 3,598 buffers, 17.8 ms | +| The page | Measuring every record it examines | Measuring the records it loads | +| ----------------------------------------------------------------------- | ---------------------------------- | ------------------------------ | +| a page of heads, `dataOf: []` | 3,598 buffers, 29.6 ms | 1 buffer, 0.40 ms | +| the workflow host's follower, which loads the data of definitions alone | 3,598 buffers, 30.4 ms | 1 buffer, 0.20 ms | +| every record with its data | 3,598 buffers, 28.1 ms | 3,598 buffers, 17.8 ms | The times are medians, measured on 2026-10-06 on the machine and with the PostgreSQL of [Measurement](#measurement), while the machine ran other work at a load average of about 105, so they are higher than an idle machine gives and vary from one run to the next; the buffers, the pages of 8 KiB the statement touched, do not depend on the load. On SQLite `octet_length(message_data)` reads the length alone, as [The bounds of a page](#the-bounds-of-a-page) says, and the statement measures the same records. @@ -119,10 +119,10 @@ The times are medians, measured on 2026-10-06 on the machine and with the Postgr A page examines records in order and ends at the first of its bounds, with `nextCursor`: -- `limit`, 1 to 100 records answered: without a filter, the page examines that many; with a filter of types, it examines up to 1,000 records, or 1,000 runs for a list of runs filtered by the type of their latest message or by the primitive or the name of their first, and answers those that match, possibly none. -- 4 MiB of data loaded, counted from the length of the stored JSON of each record the page loads, before any is loaded: `octet_length(message_data)` on SQLite, which reads the length alone, and `octet_length(message_data ->> 'json')` on PostgreSQL, the text inside the wrapper, so no SQL parses an event's own JSON to measure it; only a list of runs filtered by `primitive` or `name` parses the first message of each run it examines (see [The indexes](#the-indexes)). The first record a page wants is always loaded, so a record larger than the bound ends a page of its own. +- `limit`, 1 to 100 records answered: without a filter, the page examines that many; with a filter of types, it examines up to 1,000 records, or 1,000 runs for a list of runs filtered by the type of their latest message or by the definition type or the name of their first, and answers those that match, possibly none. +- 4 MiB of data loaded, counted from the length of the stored JSON of each record the page loads, before any is loaded: `octet_length(message_data)` on SQLite, which reads the length alone, and `octet_length(message_data ->> 'json')` on PostgreSQL, the text inside the wrapper, so no SQL parses an event's own JSON to measure it; only a list of runs filtered by `definitionType` or `name` parses the first message of each run it examines (see [The indexes](#the-indexes)). The first record a page wants is always loaded, so a record larger than the bound ends a page of its own. -A page takes at most three statements: when `since` is given, one that finds where the page starts; one that examines the page without loading data; and one that loads the data of the records it delivers, by position. Every statement binds at most 15 parameters, far below the 100 a hosted SQLite takes, but the statement of a list of runs given both `since` and `dataOf`, which no operation asks of it: 18 on SQLite and 17 on PostgreSQL with `primitive`, `name`, a status, `notBeginningWith` and a cursor, against 15 and 14 as `list_executions` reads it, counted from the parameters each statement binds; SQLite takes each list, of stored types or positions, as one JSON parameter through `json_each`. None needs a transaction. +A page takes at most three statements: when `since` is given, one that finds where the page starts; one that examines the page without loading data; and one that loads the data of the records it delivers, by position. Every statement binds at most 15 parameters, far below the 100 a hosted SQLite takes, but the statement of a list of runs given both `since` and `dataOf`, which no operation asks of it: 18 on SQLite and 17 on PostgreSQL with `definitionType`, `name`, a status, `notBeginningWith` and a cursor, against 15 and 14 as `list_runs` reads it, counted from the parameters each statement binds; SQLite takes each list, of stored types or positions, as one JSON parameter through `json_each`. None needs a transaction. ### Times and data @@ -132,7 +132,7 @@ The data of a record is decoded as the store's `read` decodes it: on SQLite the ### Measurement -`measure.ts` at the root of this package records these numbers again, writing the data of `measure/dataset.ts`. The dataset is also the subpath `@beonauto/ledger/dataset`, so the measurement of the outcomes in `@beonauto/specs` fills from the same ledger (see [Measurement of the outcomes](#measurement-of-the-outcomes)): +`measure.ts` at the root of this package records these numbers again, writing the data of `measure/dataset.ts`. The dataset is also the subpath `@beonauto/ledger/dataset`, so the measurement of the outcomes in `@beonauto/definitions` fills from the same ledger (see [Measurement of the outcomes](#measurement-of-the-outcomes)): ```bash LEDGER_MEASURE_POSTGRESQL_URL=postgresql://postgres:ledger-test@127.0.0.1:19632/postgres pnpm --filter @beonauto/ledger measure @@ -187,47 +187,47 @@ Every page, unfiltered or filtered by status, answered within 11 ms at the media ### Measurement of the run filters -`measure-run-filters.ts` at the root of this package measures the list of runs filtered by `primitive` and `name` beside the list unfiltered and filtered by status, on a ledger of its own, with the database and the reads of [Measurement](#measurement): +`measure-run-filters.ts` at the root of this package measures the list of runs filtered by `definitionType` and `name` beside the list unfiltered and filtered by status, on a ledger of its own, with the database and the reads of [Measurement](#measurement): ```bash LEDGER_MEASURE_POSTGRESQL_URL=postgresql://postgres:ledger-test@127.0.0.1:19632/postgres pnpm --filter @beonauto/ledger measure:run-filters ``` -One brain holds 10,000 runs, each a start and a finish, one in ten of the primitive `orchestration` and one in ten of each name from `spec-0` to `spec-9`, so a filter of either keeps one run in ten; one in 20 is rejected and one in 100 failed; every 200th takes an input of 256 KiB and the others of 320 bytes. Another brain holds 1,000 runs that each take an input of 256 KiB, nine in ten of the primitive `inference`, for the pages that read the most a filtered page can: 1,000 first messages of the largest input a run takes, which hold the value asked for nowhere, or nine in ten of them at the top. A deep page starts from run 5,000. Each page was read through `Ledger.readRecorded` with the selection `list_executions` asks for, 20 runs to a page unless the table says 100, three times to warm and then 20 times; the table gives the median and the slowest of the 20 in milliseconds, the runs the page answered and the data they held. +One brain holds 10,000 runs, each a start and a finish, one in ten of the type `workflow` and one in ten of each name from `definition-0` to `definition-9`, so a filter of either keeps one run in ten; one in 20 is rejected and one in 100 failed; every 200th takes an input of 256 KiB and the others of 320 bytes. Another brain holds 1,000 runs that each take an input of 256 KiB, nine in ten of the type `reasoning`, for the pages that read the most a filtered page can: 1,000 first messages of the largest input a run takes, which hold the value asked for nowhere, or nine in ten of them at the top. A deep page starts from run 5,000. Each page was read through `Ledger.readRecorded` with the selection `list_runs` asks for, 20 runs to a page unless the table says 100, three times to warm and then 20 times; the table gives the median and the slowest of the 20 in milliseconds, the runs the page answered and the data they held. Measured on 2026-10-07 on an Apple M4 Max with 16 cores, with Node 26.10, SQLite 3.52.0 through `sqlite3` 6.0.1 on a file, and PostgreSQL 18.6 in a local container with its default settings, at a load average of 7 to 10 from other work on the machine. -| Page | SQLite, median (slowest) | PostgreSQL, median (slowest) | Runs | Data | -| ----------------------------------------------------------------------------------------- | ------------------------ | ---------------------------- | ---- | -------- | -| Runs, newest first | 0.73 (0.85) | 1.71 (1.96) | 20 | 34 KiB | -| Runs that were rejected, newest first | 2.26 (11.13) | 4.53 (5.78) | 20 | 23 KiB | -| Runs of one primitive, newest first | 2.50 (2.72) | 5.17 (6.49) | 20 | 29 KiB | -| Runs of one name, newest first | 2.58 (3.23) | 12.00 (13.61) | 20 | 290 KiB | -| Runs of one primitive and name, newest first | 2.52 (2.64) | 6.24 (7.81) | 20 | 29 KiB | -| Runs of one primitive, deep page, newest first | 2.45 (3.21) | 5.79 (7.34) | 20 | 29 KiB | -| Runs of one primitive, oldest first | 2.40 (2.50) | 6.13 (7.26) | 20 | 29 KiB | -| Runs of one primitive, page of 100 | 3.48 (4.00) | 6.53 (8.57) | 100 | 145 KiB | -| Runs of one primitive that were rejected | 2.80 (3.72) | 5.70 (6.64) | 20 | 23 KiB | -| Runs of a name none has, 1,000 examined | 2.05 (2.17) | 3.88 (5.82) | 0 | 0 KiB | -| Runs of a name none has, 1,000 examined, each run with an input of 256 KiB | 110.01 (115.26) | 245.24 (248.30) | 0 | 0 KiB | -| Runs of the primitive nine in ten have, 1,000 examined, each run with an input of 256 KiB | 113.39 (116.78) | 999.60 (1026.62) | 15 | 3861 KiB | - -A page filtered by `primitive` or `name` answered within 3.5 ms at the median on SQLite and 12.0 ms on PostgreSQL, under the bar of 50 ms, at any depth and in either order, with or without a status, and filled every page with 20 runs, or 100, where a filter applied to the 20 runs of a page after the read, as `list_executions` did, kept two in ten. The page of one name on PostgreSQL holds a run with an input of 256 KiB, which it loads. Both stores read the first message of each of the 1,000 runs a filtered page may examine in the walk itself, so a page that finds its runs early costs about what a page that finds none costs. A page of the primitive nine in ten have answered 15 runs, the most that 4 MiB holds. +| Page | SQLite, median (slowest) | PostgreSQL, median (slowest) | Runs | Data | +| ------------------------------------------------------------------------------------ | ------------------------ | ---------------------------- | ---- | -------- | +| Runs, newest first | 0.73 (0.85) | 1.71 (1.96) | 20 | 34 KiB | +| Runs that were rejected, newest first | 2.26 (11.13) | 4.53 (5.78) | 20 | 23 KiB | +| Runs of one type, newest first | 2.50 (2.72) | 5.17 (6.49) | 20 | 29 KiB | +| Runs of one name, newest first | 2.58 (3.23) | 12.00 (13.61) | 20 | 290 KiB | +| Runs of one type and name, newest first | 2.52 (2.64) | 6.24 (7.81) | 20 | 29 KiB | +| Runs of one type, deep page, newest first | 2.45 (3.21) | 5.79 (7.34) | 20 | 29 KiB | +| Runs of one type, oldest first | 2.40 (2.50) | 6.13 (7.26) | 20 | 29 KiB | +| Runs of one type, page of 100 | 3.48 (4.00) | 6.53 (8.57) | 100 | 145 KiB | +| Runs of one type that were rejected | 2.80 (3.72) | 5.70 (6.64) | 20 | 23 KiB | +| Runs of a name none has, 1,000 examined | 2.05 (2.17) | 3.88 (5.82) | 0 | 0 KiB | +| Runs of a name none has, 1,000 examined, each run with an input of 256 KiB | 110.01 (115.26) | 245.24 (248.30) | 0 | 0 KiB | +| Runs of the type nine in ten have, 1,000 examined, each run with an input of 256 KiB | 113.39 (116.78) | 999.60 (1026.62) | 15 | 3861 KiB | + +A page filtered by `definitionType` or `name` answered within 3.5 ms at the median on SQLite and 12.0 ms on PostgreSQL, under the bar of 50 ms, at any depth and in either order, with or without a status, and filled every page with 20 runs, or 100, where a filter applied to the 20 runs of a page after the read, as `list_runs` did, kept two in ten. The page of one name on PostgreSQL holds a run with an input of 256 KiB, which it loads. Both stores read the first message of each of the 1,000 runs a filtered page may examine in the walk itself, so a page that finds its runs early costs about what a page that finds none costs. A page of the type nine in ten have answered 15 runs, the most that 4 MiB holds. On PostgreSQL the page parses only the starts that hold the fields asked for as written, so its cost follows the runs it examines that hold them. Over starts of 256 KiB, a page that finds the value in none of the 1,000 cost 245 ms, a `strpos` over each, and one that finds it in nine in ten of them, which it parses, cost 1,000 ms, over the bar. On SQLite both cost 110 to 113 ms, since `json_extract` parses every start the page examines. Before and after the look with `strpos`, in two runs of each at a load average of 9 to 23, the medians were: -| Page | SQLite, parse alone | SQLite, `instr` first | PostgreSQL, parse alone | PostgreSQL, `strpos` first | -| ----------------------------------------------------------------------------------------- | ------------------- | --------------------- | ----------------------- | -------------------------- | -| Runs of one primitive, newest first | 2.42 to 2.44 | 2.75 to 2.79 | 11.47 | 4.96 to 5.54 | -| Runs of a name none has, 1,000 examined | 2.01 to 2.12 | 2.37 to 2.39 | 9.53 | 3.76 to 4.05 | -| Runs of a name none has, 1,000 examined, each run with an input of 256 KiB | 108.31 to 110.23 | 188.85 to 192.63 | 938.99 to 978.12 | 222.58 to 226.52 | -| Runs of the primitive nine in ten have, 1,000 examined, each run with an input of 256 KiB | 112.21 to 113.33 | 123.26 to 129.00 | 951.62 to 970.66 | 1,000.58 to 1,049.54 | +| Page | SQLite, parse alone | SQLite, `instr` first | PostgreSQL, parse alone | PostgreSQL, `strpos` first | +| ------------------------------------------------------------------------------------ | ------------------- | --------------------- | ----------------------- | -------------------------- | +| Runs of one type, newest first | 2.42 to 2.44 | 2.75 to 2.79 | 11.47 | 4.96 to 5.54 | +| Runs of a name none has, 1,000 examined | 2.01 to 2.12 | 2.37 to 2.39 | 9.53 | 3.76 to 4.05 | +| Runs of a name none has, 1,000 examined, each run with an input of 256 KiB | 108.31 to 110.23 | 188.85 to 192.63 | 938.99 to 978.12 | 222.58 to 226.52 | +| Runs of the type nine in ten have, 1,000 examined, each run with an input of 256 KiB | 112.21 to 113.33 | 123.26 to 129.00 | 951.62 to 970.66 | 1,000.58 to 1,049.54 | On PostgreSQL one of the two runs with the parse alone was made just after the database started and was about twice as slow on the small pages, 19.00 and 21.38 ms, so the small pages give one run. `instr` on SQLite made every filtered page slower, by 0.3 ms on the small pages and by 80 ms on starts of 256 KiB that hold nothing asked for, so SQLite parses without looking first. Of what PostgreSQL spends on a start it parses, over 1,000 texts of 262,249 bytes in a table of their own at a load average of 75 to 135: taking them out of the wrapper cost 256 to 296 ms, parsing them as `jsonb` as well 563 to 599 ms, and the `regexp_replace` before the parse 1,394 to 1,403 ms in all. ## Keyed projections -The ledger keeps a table for each projection it is opened with, one row per key, inside the appends of the streams of the kinds the projection names: a `KeyedProjection` of `@beonauto/operations`, which names its table, its version, the stream kinds it folds, the stored types that change a row, its columns, its indexes, the key of the row a fact changes, the columns its reader advances and the mapping from a row and an event to the next row, and which the package that owns what a row means supplies at composition: `ledgerLayer({ fileName, projections })` and `postgresqlLedgerLayer({ connectionString, projections })`. The ledger knows the streams of a brain, named `/` (see [The brain key](#the-brain-key)), and nothing of their events. A projection of runs names the kind `executions` and keys its row by the stream's id, the run's; a projection may name other kinds and key its row by what a fact carries, through `keyOf(event, stream)`, as `topicRows` of `@beonauto/operations/testing` does over two kinds for the ledger's tests. The outcomes of runs are one such projection, which the ledger declares itself over the run outcome mapping it is given as `runOutcomes`, and which keeps its own grouped read, `Ledger.readRunOutcomes(brain, window, selection)`, beside the generic ones. +The ledger keeps a table for each projection it is opened with, one row per key, inside the appends of the streams of the kinds the projection names: a `KeyedProjection` of `@beonauto/operations`, which names its table, its version, the stream kinds it folds, the stored types that change a row, its columns, its indexes, the key of the row a fact changes, the columns its reader advances and the mapping from a row and an event to the next row, and which the package that owns what a row means supplies at composition: `ledgerLayer({ fileName, projections })` and `postgresqlLedgerLayer({ connectionString, projections })`. The ledger knows the streams of a brain, named `/` (see [The brain key](#the-brain-key)), and nothing of their events. A projection of runs names the kind `runs` and keys its row by the stream's id, the run's; a projection may name other kinds and key its row by what a fact carries, through `keyOf(event, stream)`, as `topicRows` of `@beonauto/operations/testing` does over two kinds for the ledger's tests. The outcomes of runs are one such projection, which the ledger declares itself over the run outcome mapping it is given as `runOutcomes`, and which keeps its own grouped read, `Ledger.readRunOutcomes(brain, window, selection)`, beside the generic ones. ### Inline projections @@ -249,29 +249,29 @@ A table holds `brain_key` and `row_key`, its key, and the projection's columns: | --------------------------------------------------------------- | -------------------------------------------------------------------------------------- | | `brain_key`, `row_key` | The brain key of the run's stream and its id, the table's only key | | `started_day`, `started_at`, `last_started_at` | The day and time of the run's first start, and the time of its latest | -| `primitive`, `name`, `status` | The run's definition and how it stands: `started`, `succeeded`, `failed` or `rejected` | +| `definition_type`, `name`, `status` | The run's definition and how it stands: `started`, `succeeded`, `failed` or `rejected` | | `duration_ms`, `input_tokens`, `output_tokens`, `cached_tokens` | Its duration and the tokens it used, each null when unknown | -The table is `run_outcomes_2`, with the index `run_outcomes_2_by_brain_and_day` on `(brain_key, started_day)`. Days and times are text, `YYYY-MM-DD` and ISO 8601, so that the two stores compare them alike; the numbers are `INTEGER` on SQLite and `bigint` on PostgreSQL. The columns are the ledger's; their meaning is the mapping's, which `@beonauto/specs` documents. +The table is `run_outcomes_3`, with the index `run_outcomes_3_by_brain_and_day` on `(brain_key, started_day)`. Days and times are text, `YYYY-MM-DD` and ISO 8601, so that the two stores compare them alike; the numbers are `INTEGER` on SQLite and `bigint` on PostgreSQL. The columns are the ledger's; their meaning is the mapping's, which `@beonauto/definitions` documents. ### Creating and filling a table -When the ledger opens with a projection, it looks its table up by name in the catalog, as it does its indexes, and does nothing more when it finds it. When it does not, it creates the table and its indexes, fills it by replaying every run stream of the store, drops the tables of earlier versions, such as `run_outcomes_0` and below, and analyses the new one, all in one transaction, so that a fill that is interrupted, by a mapping that throws or a process that stops, leaves nothing behind and is done again at the next open. On SQLite this runs after Emmett's migration and the indexes, on the same connections; on PostgreSQL in `onAfterSchemaCreated`, inside the migration's transaction and under its advisory lock, so that servers that start together fill the table once. Emmett's migrator waits 10 s for that lock, `defaultDatabaseLockOptions` in `@event-driven-io/dumbo` 0.13.0-beta.56, `dist/pg.js` line 673, less than the fill of a large ledger takes, and the typed options of Emmett's store and of its `schema.migrate()` offer no longer wait. So the ledger takes the same lock first, in `onBeforeSchemaCreated`, inside the migration's transaction (`migrationLockTakenWithin` in `src/postgresql/postgresql-run-outcomes.ts`): it tries `pg_try_advisory_xact_lock` every 100 ms for up to 60 s, past the longest fill measured below, 42.7 s, and once it has the lock holds it through the migration and the fill until the transaction commits; the migrator, in the same transaction, takes it again at once. A server that starts while another fills a large ledger waits for it, and stops at start, saying why, only when the lock is still held after 60 s. +When the ledger opens with a projection, it looks its table up by name in the catalog, as it does its indexes, and does nothing more when it finds it. When it does not, it creates the table and its indexes, fills it by replaying every run stream of the store, drops the tables of earlier versions, such as `run_outcomes_2` and below, and analyses the new one, all in one transaction, so that a fill that is interrupted, by a mapping that throws or a process that stops, leaves nothing behind and is done again at the next open. On SQLite this runs after Emmett's migration and the indexes, on the same connections; on PostgreSQL in `onAfterSchemaCreated`, inside the migration's transaction and under its advisory lock, so that servers that start together fill the table once. Emmett's migrator waits 10 s for that lock, `defaultDatabaseLockOptions` in `@event-driven-io/dumbo` 0.13.0-beta.56, `dist/pg.js` line 673, less than the fill of a large ledger takes, and the typed options of Emmett's store and of its `schema.migrate()` offer no longer wait. So the ledger takes the same lock first, in `onBeforeSchemaCreated`, inside the migration's transaction (`migrationLockTakenWithin` in `src/postgresql/postgresql-run-outcomes.ts`): it tries `pg_try_advisory_xact_lock` every 100 ms for up to 60 s, past the longest fill measured below, 42.7 s, and once it has the lock holds it through the migration and the fill until the transaction commits; the migrator, in the same transaction, takes it again at once. A server that starts while another fills a large ledger waits for it, and stops at start, saying why, only when the lock is still held after 60 s. -A projection keyed by its stream is filled a stream at a time. No index lists the streams of a kind across brains, so the fill scans the store's streams, `emt_streams`, for the names of the streams of the projection's kinds in the order of their names, with the size of their messages of the mapping's types, and takes of them as many as hold at most 16 MiB of those messages, at least one, so that a ledger of records of 1 MiB does not hold hundreds of megabytes at once. It lists as many streams as the batch before showed fit: one at first, then as many as 16 MiB holds at the bytes per stream of the streams the batch before took, at most twice as many as it listed before and at most 100. On PostgreSQL the size is the length of each record's text, which reads the record, so listing 100 streams of records of 1 MiB would read 100 MiB to load 16 MiB; listed this way, the fill sizes about as many bytes as it loads. For each batch it reads, in one statement, those messages in their order, decodes each as the store's reads do, folds the messages of each stream through the mapping, and writes the rows in upserts of as many rows as 100 parameters hold on SQLite, within the 100 of a hosted SQLite, 8 for the outcomes of runs, and of 500 on PostgreSQL. A change to what a table keeps is a new version, `run_outcomes_3`, which the next server fills this way and whose fill drops `run_outcomes_2`. Servers of different versions do not share a database at once. +A projection keyed by its stream is filled a stream at a time. No index lists the streams of a kind across brains, so the fill scans the store's streams, `emt_streams`, for the names of the streams of the projection's kinds in the order of their names, with the size of their messages of the mapping's types, and takes of them as many as hold at most 16 MiB of those messages, at least one, so that a ledger of records of 1 MiB does not hold hundreds of megabytes at once. It lists as many streams as the batch before showed fit: one at first, then as many as 16 MiB holds at the bytes per stream of the streams the batch before took, at most twice as many as it listed before and at most 100. On PostgreSQL the size is the length of each record's text, which reads the record, so listing 100 streams of records of 1 MiB would read 100 MiB to load 16 MiB; listed this way, the fill sizes about as many bytes as it loads. For each batch it reads, in one statement, those messages in their order, decodes each as the store's reads do, folds the messages of each stream through the mapping, and writes the rows in upserts of as many rows as 100 parameters hold on SQLite, within the 100 of a hosted SQLite, 8 for the outcomes of runs, and of 500 on PostgreSQL. A change to what a table keeps is a new version, `run_outcomes_4`, which the next server fills this way and whose fill drops `run_outcomes_3`. Servers of different versions do not share a database at once. A projection keyed by its mapping is filled in the order the facts were appended, since the facts of many streams change one row and the last of them must win: the fill reads the messages of its kinds and types 256 at a time, in the order of their position, `global_position` on SQLite and `(transaction_id, global_position)` on PostgreSQL, after the last message of the batch before, folds each into its row, read from the table being filled when the batch has not changed it yet, and writes the rows the batch changed. 256 messages of at most 64 KiB, the most an answer within a delivery takes, hold at most 16 MiB. ### Reading the outcomes of runs -The read answers, in one statement over the index, the rows of the brain whose `started_day` lies in the window, with the selection's `primitive` and `name`, grouped by day, primitive, name and status: each group with its count, its token sums, `coalesce(sum(…), 0)`, and the durations that are not null as a JSON array, `json_group_array(duration_ms) FILTER (WHERE duration_ms IS NOT NULL)` on SQLite and `json_agg` with the same filter on PostgreSQL. The brain key is matched with `=`, and the statement binds five parameters at most. +The read answers, in one statement over the index, the rows of the brain whose `started_day` lies in the window, with the selection's `definitionType` and `name`, grouped by day, definition type, name and status: each group with its count, its token sums, `coalesce(sum(…), 0)`, and the durations that are not null as a JSON array, `json_group_array(duration_ms) FILTER (WHERE duration_ms IS NOT NULL)` on SQLite and `json_agg` with the same filter on PostgreSQL. The brain key is matched with `=`, and the statement binds five parameters at most. ### Measurement of the outcomes -`measure-outcomes.ts` in the `measure` folder of `@beonauto/specs` records these numbers again, writing the data of `measure/outcomes-dataset.ts` there and, for the fill, this package's `measure/dataset.ts`, through `@beonauto/ledger/dataset`. It lives with `runOutcomeMapping`, the mapping it opens the ledger with, so that this package depends on specs for nothing, not even a script: +`measure-outcomes.ts` in the `measure` folder of `@beonauto/definitions` records these numbers again, writing the data of `measure/outcomes-dataset.ts` there and, for the fill, this package's `measure/dataset.ts`, through `@beonauto/ledger/dataset`. It lives with `runOutcomeMapping`, the mapping it opens the ledger with, so that this package depends on definitions for nothing, not even a script: ```bash -LEDGER_MEASURE_POSTGRESQL_URL=postgresql://postgres:ledger-test@127.0.0.1:19632/postgres pnpm --filter @beonauto/specs measure:outcomes +LEDGER_MEASURE_POSTGRESQL_URL=postgresql://postgres:ledger-test@127.0.0.1:19632/postgres pnpm --filter @beonauto/definitions measure:outcomes ``` `LEDGER_MEASURE_OUTCOME_RUNS` sets the sizes, `10000,100000` when left out, and `LEDGER_MEASURE_FILL_TICKS` the ticks of the ledger the fill replays, 100,000 when left out. `LEDGER_MEASURE_PARTS` picks what it measures, `read,append,fill,large-fill` when left out. @@ -331,7 +331,7 @@ import { ledgerLayer } from '@beonauto/ledger/sqlite3'; const layer = ledgerLayer({ fileName: '/data/ledger.db' }); ``` -`runOutcomes`, optional on both layers, is the run outcome mapping the ledger keeps the outcomes of runs with, and `projections` the other projections it keeps (see [Keyed projections](#keyed-projections)); the server gives the mapping of `@beonauto/specs` and the projection of the open requests of `@beonauto/interaction`. Building the layer creates the directory of the database file if it is missing, then opens the database and migrates its tables before the ledger is ready; a directory that cannot be created or a database that cannot be opened is a defect at that point. Disposing the runtime closes every connection. `fileName: ':memory:'` gives a private in-memory database. +`runOutcomes`, optional on both layers, is the run outcome mapping the ledger keeps the outcomes of runs with, and `projections` the other projections it keeps (see [Keyed projections](#keyed-projections)); the server gives the mapping of `@beonauto/definitions` and the projection of the open requests of `@beonauto/interaction`. Building the layer creates the directory of the database file if it is missing, then opens the database and migrates its tables before the ledger is ready; a directory that cannot be created or a database that cannot be opened is a defect at that point. Disposing the runtime closes every connection. `fileName: ':memory:'` gives a private in-memory database. Each SQLite connection may cache up to 8 MiB of pages and maps none of the file into memory, where the driver's defaults allow about 1 GB of cache and 256 MiB of mapped file per connection. Every connection, the tests' temporary files included, runs in WAL mode with `synchronous=NORMAL`, the defaults of Emmett's connection layer, dumbo: a committed append survives a crash of the process, and only the latest commits can be lost if the machine loses power, since in that mode SQLite syncs the file at each checkpoint rather than at each commit. Measured on 2026-10-05 on an Apple M4 Max through `sqlite3` 6.0.1, an append of one event of 1.8 KB to a temporary file took 0.20 to 0.23 ms at the median with either setting, since macOS syncs only to the drive's cache; with `fullfsync` on, so that each sync reaches the drive as on a disk that honours syncs, it took 5.9 ms with `FULL` and 0.20 to 0.24 ms with `NORMAL`, whose syncs at checkpoints showed as 5.2 ms at p99. @@ -341,7 +341,7 @@ import { postgresqlLedgerLayer } from '@beonauto/ledger/postgresql'; const layer = postgresqlLedgerLayer({ connectionString: 'postgresql://brains:@db.internal:5432/brains' }); ``` -Building the layer connects to the database and creates or migrates Emmett's tables, functions and sequence in its `public` schema, the way Emmett documents it (`schema.migrate()` with automatic migration off), before the ledger is ready. A database that cannot be reached is a defect at that point, and its message names the host and port, never the password. Emmett's migrator holds an advisory lock on the database while it migrates, so servers that start together on an empty database migrate it once; the ledger takes that lock first and waits for it up to 60 s (see [Creating and filling it](#creating-and-filling-it)). The connections come from `pg`, the pure-JavaScript driver, through one pool for each ledger, of at most 10 connections, `pg`'s default; disposing the runtime ends the pool. When the database ends a connection, as a restart, a failover or `pg_terminate_backend` does, the ledger logs a warning with the database's message and the pool opens another connection on the next call; the server never stops for it. +Building the layer connects to the database and creates or migrates Emmett's tables, functions and sequence in its `public` schema, the way Emmett documents it (`schema.migrate()` with automatic migration off), before the ledger is ready. A database that cannot be reached is a defect at that point, and its message names the host and port, never the password. Emmett's migrator holds an advisory lock on the database while it migrates, so servers that start together on an empty database migrate it once; the ledger takes that lock first and waits for it up to 60 s (see [Creating and filling a table](#creating-and-filling-a-table)). The connections come from `pg`, the pure-JavaScript driver, through one pool for each ledger, of at most 10 connections, `pg`'s default; disposing the runtime ends the pool. When the database ends a connection, as a restart, a failover or `pg_terminate_backend` does, the ledger logs a warning with the database's message and the pool opens another connection on the next call; the server never stops for it. The database's user needs `CREATE` on the `public` schema at every start, not only the first: the ledger migrates at every start, and the migrator runs `CREATE TABLE IF NOT EXISTS` on its own table, which PostgreSQL checks for the privilege before it looks whether the table exists, so a user that may only read and write the tables fails to start with `permission denied for schema public`. It also needs to read and write the ledger's tables, and to create its functions and sequence on an empty database. @@ -354,11 +354,11 @@ The ledger on PostgreSQL behaves as it does on SQLite; one suite of tests runs a - **`STREAM_DOES_NOT_EXIST` is no check at all,** as on SQLite: Emmett turns it, `STREAM_EXISTS` and `NO_CONCURRENCY_CHECK` into no expected version and appends at the end. The ledger therefore always appends with a number. - **A read from a version is exact.** Reading after version 3 of a stream at 5 gives events 4 and 5 and the version 5; past the end, Emmett gives no events and version 0, which `read` answers with the version asked after. - **Message ids are not deduplicated.** The store keeps two events with the same message id, and the same event appended twice is two events with two ids. -- **The store keeps no JSON as written.** Emmett stores `data` in a `jsonb` column, and `jsonb` refuses the character U+0000 and unpaired surrogates and puts the keys of every object in its own order. The PostgreSQL entry therefore keeps each event's JSON text in that column, as `{"json": ""}`, so an event comes back exactly as it was decided, as it does from SQLite's text column. This is a deliberate trade: an exact round trip of tenant data matters more than `jsonb`'s operators. What an operator gives up is querying and indexing events by their fields in SQL: reading one takes `(message_data->>'json')::jsonb`, which fails for an event that holds U+0000, and an expression index over event fields would have to parse the text the same way. The list of runs filtered by `primitive` or `name` reads those two fields so, after taking out the escapes `jsonb` refuses (see [The indexes](#the-indexes)). A stream name cannot hold U+0000 on PostgreSQL; the application layer's stream names are made of ids that never do. +- **The store keeps no JSON as written.** Emmett stores `data` in a `jsonb` column, and `jsonb` refuses the character U+0000 and unpaired surrogates and puts the keys of every object in its own order. The PostgreSQL entry therefore keeps each event's JSON text in that column, as `{"json": ""}`, so an event comes back exactly as it was decided, as it does from SQLite's text column. This is a deliberate trade: an exact round trip of tenant data matters more than `jsonb`'s operators. What an operator gives up is querying and indexing events by their fields in SQL: reading one takes `(message_data->>'json')::jsonb`, which fails for an event that holds U+0000, and an expression index over event fields would have to parse the text the same way. The list of runs filtered by `definition_type` or `name` reads those two fields so, after taking out the escapes `jsonb` refuses (see [The indexes](#the-indexes)). A stream name cannot hold U+0000 on PostgreSQL; the application layer's stream names are made of ids that never do. An append on PostgreSQL binds ten parameters whatever the number of events, one array per column, so PostgreSQL's own limit of 65,535 parameters in one statement never binds. The ledger still bounds a decision, at 64 events, eight times SQLite's eight, so that a decider that runs away is a defect rather than one long transaction. Code that must run on both stores keeps its decisions to eight events. -Every command is a decision appended under an expected version, so concurrent writers never lose an update to brains and specs. Every server runs a workflow host, and one of them runs the database's workflows at a time, under a claim the workflow host keeps in its own table, so several servers may share the database. +Every command is a decision appended under an expected version, so concurrent writers never lose an update to brains and definitions. Every server runs a workflow host, and one of them runs the database's workflows at a time, under a claim the workflow host keeps in its own table, so several servers may share the database. ## Portability @@ -370,7 +370,7 @@ What every store must provide, for the ledger to run at all: - The read of what a brain recorded, with the indexes the ledger creates when it opens; each statement binds at most 15 parameters, and none needs a transaction. - An append on SQLite carries at most eight events: Emmett binds ten parameters for each event it inserts, and such a database binds at most 100 in one statement. - A SQLite database must provide what the reads use: `octet_length` (SQLite 3.43 and later), window functions (3.25 and later), the JSON functions, of which the reads use `json_each` and `json_group_array` (built in since 3.38), and partial and expression indexes (3.8 and 3.9). -- No adapter or application may nest a stream under `executions/` within a brain: the key of a stream's kind ends at the fourth `/`, so the list of runs would take any stream named `executions//…` for a run. +- No adapter or application may nest a stream under `runs/` within a brain: the key of a stream's kind ends at the fourth `/`, so the list of runs would take any stream named `runs//…` for a run. - Only the entries `src/sqlite3.ts` and `src/postgresql/postgresql-ledger.ts` know which driver is in use; the main entry loads neither `sqlite3` nor `pg`. What a store must provide besides, to keep the keyed projections, the outcomes of runs that a brain's analytics read and the open requests of interaction functions among them: @@ -383,7 +383,7 @@ A runtime whose store cannot provide both does not keep the tables and does not ## Testing -The ledger's behaviour is one suite, in `src/testing/ledger-behaviour.ts`, that `src/ledger.test.ts` runs on SQLite and `src/postgresql/ledger-on-postgresql.test.ts` runs on PostgreSQL. Its part on the outcomes of runs, `src/outcomes/run-outcomes-behaviour.ts`, also runs on the in-memory ledger, in `src/outcomes/run-outcomes-in-memory.test.ts`, with the `runTallies` mapping of `@beonauto/operations/testing`; `src/outcomes/run-outcome-table-behaviour.ts` holds what only a store does: an append whose projection throws, of which nothing is kept, a ledger opened without the projection, a table filled at open, an interrupted fill done again, and a table found and not filled again. That ledgers that start together fill the table once is tested on PostgreSQL alone, in `src/outcomes/run-outcomes-on-postgresql.test.ts`. That a server waits past the migrator's 10 s for a migration lock another server holds is tested with a fake lock and a fake clock in `src/postgresql/postgresql-run-outcomes.test.ts`, and on PostgreSQL in `src/postgresql/connections-on-postgresql.test.ts`, where a client holds the lock for 12 s. The part on the keyed projections, `src/projections/projections-behaviour.ts`, the rows kept, the reads by columns, in order and from where a page ended, the counts, the rows due across brains and the next due time, the rows a mapping keys over two stream kinds and the reader's advance, which a fold leaves as it stands unless it sets the columns, runs on both stores and on the in-memory ledger, in `src/projections/projections-in-memory.test.ts`, with the `runTallyRows` and `topicRows` projections of `@beonauto/operations/testing`, and `src/projections/projection-table-behaviour.ts` holds what only a store does: an append whose projection throws, of which nothing is kept, a table made with its indexes and filled at open with the version before it dropped, a table found and not filled again, and a table keyed by its mapping filled in the order its facts were appended, over more than one batch. `src/postgresql/postgresql-projections.test.ts` checks the statements of the reads, the advance, the ordered fill and the fold's upsert on PostgreSQL without a database, and `src/postgresql/advances-on-postgresql.test.ts`, on PostgreSQL alone, a fold that read a row before an advance committed. The server tests the mapping of `@beonauto/specs` on both stores, through the ledger it composes. Its part on reading what a brain recorded, `src/testing/recorded-behaviour.ts`, `src/testing/runs-behaviour.ts`, `src/run-filters/run-filters-behaviour.ts`, `src/lineage/lineage-behaviour.ts` and `src/heads/heads-behaviour.ts`, the ids, causes and correlations a read gives, the runs of one primitive or name, the read by correlation, the read from inside a record, and the versions and the read of heads, also runs on the in-memory ledger of `@beonauto/operations`, in `src/recorded/recorded-in-memory.test.ts`, so the three agree. A read behind an append whose transaction is still open, the same read by correlation, the same read newest first, a list of runs oldest first behind such an append, a write open in another database that hides nothing, and a start that finds the indexes while an append is open are tested on PostgreSQL alone, since SQLite serialises appends. Before each read of what a brain recorded, the PostgreSQL entry of the suite waits until every committed message of its own database is older than every write open on the server, polling every 20 ms for at most 10 s and failing the test past that; the tests that wait have a timeout of 30 s, above vitest's 5 s. Waiting for the ledger's own horizon is not enough when other tests write on the same server: a write in another database that began before a message and ends while a read runs is in doubt to that read, which then stays behind it and may answer nothing, as a probe that ended such a write during a read confirmed. The test of a write open in another database waits for every write but that one. The SQLite entry and the in-memory ledger do not wait. `src/testing` holds 15 files, the most a folder holds, so the suite of lineage opened `src/lineage`, the suite of heads `src/heads` and the suite of the runs of one definition `src/run-filters`. `src/postgresql-reads/postgresql-runs.test.ts` checks the statement of that read on PostgreSQL without a database. `src/signal/append-signal.test.ts` tests the signal of an append on SQLite. +The ledger's behaviour is one suite, in `src/testing/ledger-behaviour.ts`, that `src/ledger.test.ts` runs on SQLite and `src/postgresql/ledger-on-postgresql.test.ts` runs on PostgreSQL. Its part on the outcomes of runs, `src/outcomes/run-outcomes-behaviour.ts`, also runs on the in-memory ledger, in `src/outcomes/run-outcomes-in-memory.test.ts`, with the `runTallies` mapping of `@beonauto/operations/testing`; `src/outcomes/run-outcome-table-behaviour.ts` holds what only a store does: an append whose projection throws, of which nothing is kept, a ledger opened without the projection, a table filled at open, an interrupted fill done again, and a table found and not filled again. That ledgers that start together fill the table once is tested on PostgreSQL alone, in `src/outcomes/run-outcomes-on-postgresql.test.ts`. That a server waits past the migrator's 10 s for a migration lock another server holds is tested with a fake lock and a fake clock in `src/postgresql/postgresql-run-outcomes.test.ts`, and on PostgreSQL in `src/postgresql/connections-on-postgresql.test.ts`, where a client holds the lock for 12 s. The part on the keyed projections, `src/projections/projections-behaviour.ts`, the rows kept, the reads by columns, in order and from where a page ended, the counts, the rows due across brains and the next due time, the rows a mapping keys over two stream kinds and the reader's advance, which a fold leaves as it stands unless it sets the columns, runs on both stores and on the in-memory ledger, in `src/projections/projections-in-memory.test.ts`, with the `runTallyRows` and `topicRows` projections of `@beonauto/operations/testing`, and `src/projections/projection-table-behaviour.ts` holds what only a store does: an append whose projection throws, of which nothing is kept, a table made with its indexes and filled at open with the version before it dropped, a table found and not filled again, and a table keyed by its mapping filled in the order its facts were appended, over more than one batch. `src/postgresql/postgresql-projections.test.ts` checks the statements of the reads, the advance, the ordered fill and the fold's upsert on PostgreSQL without a database, and `src/postgresql/advances-on-postgresql.test.ts`, on PostgreSQL alone, a fold that read a row before an advance committed. The server tests the mapping of `@beonauto/definitions` on both stores, through the ledger it composes. Its part on reading what a brain recorded, `src/testing/recorded-behaviour.ts`, `src/testing/runs-behaviour.ts`, `src/run-filters/run-filters-behaviour.ts`, `src/lineage/lineage-behaviour.ts` and `src/heads/heads-behaviour.ts`, the ids, causes and correlations a read gives, the runs of one capability or name, the read by correlation, the read from inside a record, and the versions and the read of heads, also runs on the in-memory ledger of `@beonauto/operations`, in `src/recorded/recorded-in-memory.test.ts`, so the three agree. A read behind an append whose transaction is still open, the same read by correlation, the same read newest first, a list of runs oldest first behind such an append, a write open in another database that hides nothing, and a start that finds the indexes while an append is open are tested on PostgreSQL alone, since SQLite serialises appends. Before each read of what a brain recorded, the PostgreSQL entry of the suite waits until every committed message of its own database is older than every write open on the server, polling every 20 ms for at most 10 s and failing the test past that; the tests that wait have a timeout of 30 s, above vitest's 5 s. Waiting for the ledger's own horizon is not enough when other tests write on the same server: a write in another database that began before a message and ends while a read runs is in doubt to that read, which then stays behind it and may answer nothing, as a probe that ended such a write during a read confirmed. The test of a write open in another database waits for every write but that one. The SQLite entry and the in-memory ledger do not wait. `src/testing` holds 15 files, the most a folder holds, so the suite of lineage opened `src/lineage`, the suite of heads `src/heads` and the suite of the runs of one definition `src/run-filters`. `src/postgresql-reads/postgresql-runs.test.ts` checks the statement of that read on PostgreSQL without a database. `src/signal/append-signal.test.ts` tests the signal of an append on SQLite. ```bash docker run --detach --name ledger-pg-test --publish 127.0.0.1:19632:5432 --env POSTGRES_PASSWORD=ledger-test postgres:18.6-alpine diff --git a/packages/ledger/measure-heads.ts b/packages/ledger/measure-heads.ts index 6c3a1997d..6fb744c34 100644 --- a/packages/ledger/measure-heads.ts +++ b/packages/ledger/measure-heads.ts @@ -21,10 +21,10 @@ const warmReads = 3; const measuredReads = 20; -const specTypes = ['spec_created', 'spec_updated', 'spec_retired']; +const definitionTypes = ['definition_created', 'definition_updated', 'definition_retired']; interface Explained { - readonly 'Execution Time': number; + readonly 'Run Time': number; readonly Plan: { readonly 'Shared Hit Blocks': number; readonly 'Shared Read Blocks': number }; } @@ -35,7 +35,7 @@ interface Examination { const cases: readonly (readonly [string, StoredPageRequest])[] = [ ['a page of heads, no data', { order: 'asc', limit: 100, dataOf: [] }], - ["the follower's glance, the data of specs alone", { order: 'asc', limit: 100, dataOf: specTypes }], + ["the follower's glance, the data of definitions alone", { order: 'asc', limit: 100, dataOf: definitionTypes }], ['every record with its data', { order: 'asc', limit: 100 }], ]; @@ -75,7 +75,7 @@ function explaining(client: Querying, examined: (examination: Examination) => vo ); const [plan] = rows[0]?.['QUERY PLAN'] ?? []; examined({ - milliseconds: plan?.['Execution Time'] ?? 0, + milliseconds: plan?.['Run Time'] ?? 0, buffers: (plan?.Plan['Shared Hit Blocks'] ?? 0) + (plan?.Plan['Shared Read Blocks'] ?? 0), }); } diff --git a/packages/ledger/measure-run-filters.ts b/packages/ledger/measure-run-filters.ts index 0628c9620..d774bba61 100644 --- a/packages/ledger/measure-run-filters.ts +++ b/packages/ledger/measure-run-filters.ts @@ -54,7 +54,7 @@ function timeOf(second: number): string { } function streamOf(brain: string, run: number): string { - return `brain/o1/${brain}/executions/${String(run).padStart(8, '0')}`; + return `brain/o1/${brain}/runs/${String(run).padStart(8, '0')}`; } interface Ending { @@ -64,23 +64,29 @@ interface Ending { function endingOf(run: number): Ending { if (run % 20 === 10) { - return { type: 'execution_rejected', rejection: { reason: 'unavailable', detail: 'try again later' } }; + return { type: 'run_rejected', rejection: { reason: 'unavailable', detail: 'try again later' } }; } - return run % 100 === 7 ? { type: 'execution_failed' } : { type: 'execution_succeeded', output: { text: text(640) } }; + return run % 100 === 7 ? { type: 'run_failed' } : { type: 'run_succeeded', output: { text: text(640) } }; } function rowsOfRun(brain: string, run: number, inputCharacters: number): readonly Row[] { const created = timeOf(run); const start = { - type: 'execution_started', - primitive: run % 10 === 0 ? 'orchestration' : 'inference', - name: `spec-${run % 10}`, - spec_version: 1, + type: 'run_started', + definition_type: run % 10 === 0 ? 'workflow' : 'reasoning', + name: `definition-${run % 10}`, + definition_version: 1, input: { text: text(inputCharacters) }, by: 'user-1', at: created, }; - const ending = { ...endingOf(run), primitive: start.primitive, name: start.name, by: 'user-1', at: created }; + const ending = { + ...endingOf(run), + definition_type: start.definition_type, + name: start.name, + by: 'user-1', + at: created, + }; const stream = streamOf(brain, run); return [ { stream, position: 1, type: start.type, data: JSON.stringify(start), created }, @@ -112,49 +118,49 @@ function aPage(order: 'asc' | 'desc', limit: number, more: Omit, 'kind'>): Selection { - return { kind: 'executions', notBeginningWith, ...asked }; +function runsOf(asked: Omit, 'kind'>): Selection { + return { kind: 'runs', notBeginningWith, ...asked }; } function cases(deepCursor: string): readonly Case[] { return [ ['Runs, newest first', 'big', runsOf({}), aPage('desc', 20)], - ['Runs that were rejected, newest first', 'big', runsOf({}), aPage('desc', 20, { types: ['execution_rejected'] })], - ['Runs of one primitive, newest first', 'big', runsOf({ primitive: 'orchestration' }), aPage('desc', 20)], - ['Runs of one name, newest first', 'big', runsOf({ name: 'spec-3' }), aPage('desc', 20)], + ['Runs that were rejected, newest first', 'big', runsOf({}), aPage('desc', 20, { types: ['run_rejected'] })], + ['Runs of one type, newest first', 'big', runsOf({ definitionType: 'workflow' }), aPage('desc', 20)], + ['Runs of one name, newest first', 'big', runsOf({ name: 'definition-3' }), aPage('desc', 20)], [ - 'Runs of one primitive and name, newest first', + 'Runs of one type and name, newest first', 'big', - runsOf({ primitive: 'orchestration', name: 'spec-0' }), + runsOf({ definitionType: 'workflow', name: 'definition-0' }), aPage('desc', 20), ], [ - 'Runs of one primitive, deep page, newest first', + 'Runs of one type, deep page, newest first', 'big', - runsOf({ primitive: 'orchestration' }), + runsOf({ definitionType: 'workflow' }), aPage('desc', 20, { cursor: deepCursor }), ], - ['Runs of one primitive, oldest first', 'big', runsOf({ primitive: 'orchestration' }), aPage('asc', 20)], - ['Runs of one primitive, page of 100', 'big', runsOf({ primitive: 'orchestration' }), aPage('desc', 100)], + ['Runs of one type, oldest first', 'big', runsOf({ definitionType: 'workflow' }), aPage('asc', 20)], + ['Runs of one type, page of 100', 'big', runsOf({ definitionType: 'workflow' }), aPage('desc', 100)], [ - 'Runs of one primitive that were rejected', + 'Runs of one type that were rejected', 'big', - runsOf({ primitive: 'orchestration' }), - aPage('desc', 20, { types: ['execution_rejected'] }), + runsOf({ definitionType: 'workflow' }), + aPage('desc', 20, { types: ['run_rejected'] }), ], - ['Runs of a name none has, 1,000 examined', 'big', runsOf({ name: 'spec-none' }), aPage('desc', 20)], + ['Runs of a name none has, 1,000 examined', 'big', runsOf({ name: 'definition-none' }), aPage('desc', 20)], [ 'Runs of a name none has, 1,000 examined, each run with an input of 256 KiB', 'large', - runsOf({ name: 'spec-none' }), + runsOf({ name: 'definition-none' }), aPage('desc', 20), ], [ - 'Runs of the primitive nine in ten have, 1,000 examined, each run with an input of 256 KiB', + 'Runs of the type nine in ten have, 1,000 examined, each run with an input of 256 KiB', 'large', - runsOf({ primitive: 'inference' }), + runsOf({ definitionType: 'reasoning' }), aPage('desc', 20), ], ]; diff --git a/packages/ledger/measure.ts b/packages/ledger/measure.ts index 8eeb81531..1685c5327 100644 --- a/packages/ledger/measure.ts +++ b/packages/ledger/measure.ts @@ -5,7 +5,7 @@ import { Effect, type Layer } from 'effect'; import { Client } from 'pg'; import { - executions, + datasetRunStream, longRun, longRunId, othersInPostgreSQL, @@ -39,8 +39,8 @@ const brain = { org: 'o1', brain: 'big' }; const brainKey = 'brain/o1/big/'; const needed = [ - [`${brainKey}executions/00050000`, 1], - [`${brainKey}executions/00050015`, 1], + [`${brainKey}runs/00050000`, 1], + [`${brainKey}runs/00050015`, 1], [longRun, 50_000], ] as const; @@ -84,7 +84,7 @@ function treeCases(middleOfTheLongRun: string): readonly Case[] { } function cases(pointOf: (stream: string, at: number) => Point): readonly Case[] { - const cursor = cursorOf(brainKey, pointOf(executions(50_000), 1)); + const cursor = cursorOf(brainKey, pointOf(datasetRunStream(50_000), 1)); const middleOfTheLongRun = cursorOf(brainKey, pointOf(longRun, 50_000)); const since = timeOf(50_000); return [ @@ -96,34 +96,34 @@ function cases(pointOf: (stream: string, at: number) => Point): readonly Case[] ['The brain, deep page of 100, newest first', aPage('desc', 100, { cursor })], [ 'The brain, a page holding a run of 1.25 MiB', - aPage('desc', 20, { cursor: cursorOf(brainKey, pointOf(executions(50_015), 1)) }), + aPage('desc', 20, { cursor: cursorOf(brainKey, pointOf(datasetRunStream(50_015), 1)) }), ], - ['The brain, of one rare type, newest first', aPage('desc', 20, { types: ['execution_failed'] })], + ['The brain, of one rare type, newest first', aPage('desc', 20, { types: ['run_failed'] })], ['The brain since a time, oldest first', aPage('asc', 20, { since })], ['The brain since a time, newest first', aPage('desc', 20, { since })], ]), - ...casesOf({ kind: 'run', execution: '00050000' }, [ + ...casesOf({ kind: 'run', run: '00050000' }, [ ['One run of 21 messages, oldest first', aPage('asc', 20)], ['One run of 21 messages, newest first', aPage('desc', 20)], ]), - ...casesOf({ kind: 'run', execution: 'long-running' }, [ + ...casesOf({ kind: 'run', run: 'long-running' }, [ ['A run of 100,001 messages, first page, oldest first', aPage('asc', 20)], ['A run of 100,001 messages, first page, newest first', aPage('desc', 20)], ['A run of 100,001 messages, from its middle, oldest first', aPage('asc', 20, { cursor: middleOfTheLongRun })], ['A run of 100,001 messages, from its middle, newest first', aPage('desc', 20, { cursor: middleOfTheLongRun })], ]), ...treeCases(middleOfTheLongRun), - ...casesOf({ kind: 'executions' }, [ + ...casesOf({ kind: 'runs' }, [ ['Runs, first page, newest first', aPage('desc', 20)], ['Runs, first page, oldest first', aPage('asc', 20)], ['Runs, deep page, newest first', aPage('desc', 20, { cursor })], ['Runs, deep page, oldest first', aPage('asc', 20, { cursor })], ['Runs, deep page of 100, newest first', aPage('desc', 100, { cursor })], - ['Runs that succeeded, newest first', aPage('desc', 20, { types: ['execution_succeeded'] })], - ['Runs that succeeded, deep page, oldest first', aPage('asc', 20, { cursor, types: ['execution_succeeded'] })], - ['Runs that failed, newest first', aPage('desc', 20, { types: ['execution_failed'] })], - ['Runs that failed, deep page, oldest first', aPage('asc', 20, { cursor, types: ['execution_failed'] })], - ['Runs of a status none has, 1,000 examined', aPage('desc', 20, { types: ['execution_unknown'] })], + ['Runs that succeeded, newest first', aPage('desc', 20, { types: ['run_succeeded'] })], + ['Runs that succeeded, deep page, oldest first', aPage('asc', 20, { cursor, types: ['run_succeeded'] })], + ['Runs that failed, newest first', aPage('desc', 20, { types: ['run_failed'] })], + ['Runs that failed, deep page, oldest first', aPage('asc', 20, { cursor, types: ['run_failed'] })], + ['Runs of a status none has, 1,000 examined', aPage('desc', 20, { types: ['run_unknown'] })], ]), ]; } @@ -132,12 +132,8 @@ const behindOthers = 'behind a million newer messages of other brains'; const quietCases: readonly Case[] = [ [`The brain, newest first, ${behindOthers}`, { kind: 'everything' }, aPage('desc', 20)], - [`Runs, newest first, ${behindOthers}`, { kind: 'executions' }, aPage('desc', 20)], - [ - `A run of 100,001 messages, newest first, ${behindOthers}`, - { kind: 'run', execution: 'long-running' }, - aPage('desc', 20), - ], + [`Runs, newest first, ${behindOthers}`, { kind: 'runs' }, aPage('desc', 20)], + [`A run of 100,001 messages, newest first, ${behindOthers}`, { kind: 'run', run: 'long-running' }, aPage('desc', 20)], [ `The tree of a run of 21 messages, oldest first, ${behindOthers}`, { kind: 'correlated', correlation: runIdOf(50_000) }, diff --git a/packages/ledger/measure/dataset.ts b/packages/ledger/measure/dataset.ts index 712a12841..33a8851d1 100644 --- a/packages/ledger/measure/dataset.ts +++ b/packages/ledger/measure/dataset.ts @@ -12,7 +12,7 @@ const largeEvery = 200; export const longRunId = 'long-running'; -export const longRun = `brain/o1/big/runs/${longRunId}`; +export const longRun = `brain/o1/big/run-logs/${longRunId}`; const statuses: readonly (readonly [number, number, string])[] = [ [largeEvery, 13, 'succeeded'], @@ -38,8 +38,8 @@ export function runIdOf(run: number): string { return String(run).padStart(8, '0'); } -export function executions(run: number): string { - return `brain/o1/big/executions/${runIdOf(run)}`; +export function datasetRunStream(run: number): string { + return `brain/o1/big/runs/${runIdOf(run)}`; } function metadataOf(correlation: string | undefined): string { @@ -63,23 +63,29 @@ function finishOf(run: number, at: string): readonly Row[] { deferred: { record: {} }, succeeded: { output: { text: text(run % largeEvery === 13 ? 1_048_576 : 640) }, record: {} }, }; - const finished = { type: `execution_${status}`, ...facts[status], by: 'user-1', at }; - return run < 0 || status === 'running' ? [] : [row(executions(run), 2, finished, runIdOf(run))]; + const finished = { type: `run_${status}`, ...facts[status], by: 'user-1', at }; + return run < 0 || status === 'running' ? [] : [row(datasetRunStream(run), 2, finished, runIdOf(run))]; } export function tick(t: number): readonly Row[] { const at = timeOf(t); const input = { text: text(t % largeEvery === 13 ? 262_144 : 320) }; - const start = { type: 'execution_started', primitive: 'inference', name: `spec-${t % 50}`, spec_version: 1, input }; + const start = { + type: 'run_started', + definition_type: 'reasoning', + name: `definition-${t % 50}`, + definition_version: 1, + input, + }; const inputs = Array.from({ length: t % 10 === 0 ? 20 : 0 }, (_, index) => - row(`brain/o1/big/runs/${runIdOf(t)}`, index + 1, { type: 'input_applied', patch: text(2048) }, runIdOf(t)), + row(`brain/o1/big/run-logs/${runIdOf(t)}`, index + 1, { type: 'input_applied', patch: text(2048) }, runIdOf(t)), ); const others = Array.from({ length: 7 }, (_, index) => - row(`brain/o2/other-${t % 99}/executions/${t}-${index}`, 1, { type: 'execution_started', input: text(512) }), + row(`brain/o2/other-${t % 99}/runs/${t}-${index}`, 1, { type: 'run_started', input: text(512) }), ); - const started = row(executions(t), 1, { ...start, by: 'user-1', at }, runIdOf(t)); + const started = row(datasetRunStream(t), 1, { ...start, by: 'user-1', at }, runIdOf(t)); const longStart = - t === 0 ? [{ ...started, stream: `brain/o1/big/executions/${longRunId}`, metadata: metadataOf(longRunId) }] : []; + t === 0 ? [{ ...started, stream: `brain/o1/big/runs/${longRunId}`, metadata: metadataOf(longRunId) }] : []; return [ started, ...finishOf(t - 1, at), @@ -93,11 +99,11 @@ export function tick(t: number): readonly Row[] { export const othersInSQLite = `WITH RECURSIVE n(i) AS (SELECT 1 UNION ALL SELECT i + 1 FROM n WHERE i < 1000000) INSERT INTO emt_messages (stream_id, stream_position, partition, message_data, message_metadata, message_schema_version, message_type, message_id) - SELECT 'brain/o3/other-' || (i % 50) || '/executions/' || i, 1, 'emt:default', '{"type":"execution_started"}', '{}', - '1', 'execution_started', 'later-' || i FROM n`; + SELECT 'brain/o3/other-' || (i % 50) || '/runs/' || i, 1, 'emt:default', '{"type":"run_started"}', '{}', + '1', 'run_started', 'later-' || i FROM n`; export const othersInPostgreSQL = `INSERT INTO emt_messages (stream_id, stream_position, message_data, message_metadata, message_schema_version, message_type, message_id, transaction_id) - SELECT 'brain/o3/other-' || (i % 50) || '/executions/' || i, 1, jsonb_build_object('json', '{"type":"execution_started"}'), - '{}', '1', 'execution_started', 'later-' || i, pg_current_xact_id() + SELECT 'brain/o3/other-' || (i % 50) || '/runs/' || i, 1, jsonb_build_object('json', '{"type":"run_started"}'), + '{}', '1', 'run_started', 'later-' || i, pg_current_xact_id() FROM generate_series(1, 1000000) AS i`; diff --git a/packages/ledger/src/appends/append-signal.test.ts b/packages/ledger/src/appends/append-signal.test.ts index e91a27e2d..3708459d3 100644 --- a/packages/ledger/src/appends/append-signal.test.ts +++ b/packages/ledger/src/appends/append-signal.test.ts @@ -58,7 +58,7 @@ describe('the signal of an append to a brain', () => { const store = failingStore(); expect([ - brainKeyOfStream('brain/acme/alpha/executions/run-1'), + brainKeyOfStream('brain/acme/alpha/runs/run-1'), brainKeyOfStream('brain/acme/alpha'), signalledOn(store) === store, ]).toEqual(['brain/acme/alpha/', undefined, true]); diff --git a/packages/ledger/src/definitions/definition-streams-behaviour.ts b/packages/ledger/src/definitions/definition-streams-behaviour.ts index 89d82ac12..19110fbd7 100644 --- a/packages/ledger/src/definitions/definition-streams-behaviour.ts +++ b/packages/ledger/src/definitions/definition-streams-behaviour.ts @@ -2,7 +2,7 @@ import { describe, expect, it, onTestFinished } from 'vitest'; import type { LedgerEntry } from '../testing/ledger-entry.ts'; -const created = [{ type: 'spec_created', data: { name: 'reviews' } }]; +const created = [{ type: 'definition_created', data: { name: 'reviews' } }]; export function definitionStreamsBehaviour(entry: LedgerEntry): void { describe('the definition streams of a type', () => { @@ -11,17 +11,17 @@ export function definitionStreamsBehaviour(entry: LedgerEntry): void { const store = entry.storeOn(database); onTestFinished(() => store.close()); await store.migrate(); - await store.append('brain/acme/alpha/specs/recollection', [...created, ...created], 0); - await store.append('brain/acme/beta/specs/recollection', created, 0); - await store.append('brain/acme/alpha/specs/inference', created, 0); - await store.append('brain/acme/alpha/executions/run-1', created, 0); - await store.append('brain/acme/gamma/notes/specs/recollection', created, 0); - await store.append('brain/acme/gamma/specs/recollection/nested', created, 0); - await store.append('org/acme/specs', created, 0); + await store.append('brain/acme/alpha/definitions/recall', [...created, ...created], 0); + await store.append('brain/acme/beta/definitions/recall', created, 0); + await store.append('brain/acme/alpha/definitions/reasoning', created, 0); + await store.append('brain/acme/alpha/runs/run-1', created, 0); + await store.append('brain/acme/gamma/notes/definitions/recall', created, 0); + await store.append('brain/acme/gamma/definitions/recall/nested', created, 0); + await store.append('org/acme/definitions', created, 0); - expect(await store.definitionStreams('recollection')).toEqual([ - { stream: 'brain/acme/alpha/specs/recollection', version: 2 }, - { stream: 'brain/acme/beta/specs/recollection', version: 1 }, + expect(await store.definitionStreams('recall')).toEqual([ + { stream: 'brain/acme/alpha/definitions/recall', version: 2 }, + { stream: 'brain/acme/beta/definitions/recall', version: 1 }, ]); expect(await store.definitionStreams('computation')).toEqual([]); expect(await entry.definitionStreamsIndexed(database)).toBe(true); diff --git a/packages/ledger/src/heads/heads-behaviour.ts b/packages/ledger/src/heads/heads-behaviour.ts index 5ef3148dc..bf63d8d85 100644 --- a/packages/ledger/src/heads/heads-behaviour.ts +++ b/packages/ledger/src/heads/heads-behaviour.ts @@ -16,21 +16,19 @@ function theVersionOfARecord(aLedger: LedgerMaker): void { it('is given with every record, of the whole brain and of its runs', async () => { const ledger = await aLedger(); await happen(ledger, inAlpha('notes'), noted('noted', 1), noted('noted', 2)); - await happen(ledger, inAlpha('executions/r1'), noted('execution_started')); + await happen(ledger, inAlpha('runs/r1'), noted('run_started')); await happen(ledger, inAlpha('notes'), noted('noted', 3)); - await happen(ledger, inAlpha('executions/r1'), noted('execution_succeeded')); + await happen(ledger, inAlpha('runs/r1'), noted('run_succeeded')); const brain = await reading(ledger, everything, { order: 'asc', limit: 10 }); - const runs = await reading(ledger, { kind: 'executions' }, { order: 'asc', limit: 10 }); + const runs = await reading(ledger, { kind: 'runs' }, { order: 'asc', limit: 10 }); expect([ brain.records.map(({ stream, version }) => `${stream} ${version}`), runs.records.map(({ type, version }) => `${type} ${version}`), ]).toEqual([ - ['notes 1', 'notes 2', 'executions/r1 1', 'notes 3', 'executions/r1 2'].map( - (head) => `brain/acme/alpha/${head}`, - ), - ['execution_started 1', 'execution_succeeded 2'], + ['notes 1', 'notes 2', 'runs/r1 1', 'notes 3', 'runs/r1 2'].map((head) => `brain/acme/alpha/${head}`), + ['run_started 1', 'run_succeeded 2'], ]); }); }); @@ -83,17 +81,13 @@ function theBoundsOfAReadOfHeads(aLedger: LedgerMaker): void { it('loads, of a run, the first and the latest message only when their types are asked for', async () => { const ledger = await aLedger(); - await happen(ledger, inAlpha('executions/r1'), noted('execution_started'), noted('execution_failed')); + await happen(ledger, inAlpha('runs/r1'), noted('run_started'), noted('run_failed')); - const { records } = await reading( - ledger, - { kind: 'executions' }, - { order: 'asc', limit: 10, dataOf: ['execution_failed'] }, - ); + const { records } = await reading(ledger, { kind: 'runs' }, { order: 'asc', limit: 10, dataOf: ['run_failed'] }); expect(headsOf(records)).toEqual([ - [inAlpha('executions/r1'), 1, 'execution_started', false], - [inAlpha('executions/r1'), 2, 'execution_failed', true], + [inAlpha('runs/r1'), 1, 'run_started', false], + [inAlpha('runs/r1'), 2, 'run_failed', true], ]); }); }); diff --git a/packages/ledger/src/ledger.test.ts b/packages/ledger/src/ledger.test.ts index f43f1891c..3e4742be0 100644 --- a/packages/ledger/src/ledger.test.ts +++ b/packages/ledger/src/ledger.test.ts @@ -23,7 +23,7 @@ function queried(fileName: string, statement: string): Promise { const pool = dumbo(sqlite3EventStoreDriver.mapToDumboOptions({ fileName })); try { - const { rows } = await pool.execute.query(SQL`EXPLAIN QUERY PLAN ${definitionStreamsQuery('recollection')}`); + const { rows } = await pool.execute.query(SQL`EXPLAIN QUERY PLAN ${definitionStreamsQuery('recall')}`); return JSON.stringify(rows).includes('USING INDEX ledger_definition_streams'); } finally { await pool.close(); @@ -48,6 +48,7 @@ const onSQLite: LedgerEntry = { outcomeTables: "SELECT name FROM sqlite_master WHERE type = 'table' AND name GLOB 'run_outcomes_*' ORDER BY name", projectionTables: "SELECT name FROM sqlite_master WHERE type = 'table' AND name GLOB 'run_tallies_*' ORDER BY name", projectionIndexes: "SELECT name FROM sqlite_master WHERE type = 'index' AND name GLOB 'run_tallies_*' ORDER BY name", + topicTables: "SELECT name FROM sqlite_master WHERE type = 'table' AND name GLOB 'topics_*' ORDER BY name", }; describe('The ledger on SQLite', () => { diff --git a/packages/ledger/src/lineage/lineage-behaviour.ts b/packages/ledger/src/lineage/lineage-behaviour.ts index 7de9f41f0..bbe4dde0c 100644 --- a/packages/ledger/src/lineage/lineage-behaviour.ts +++ b/packages/ledger/src/lineage/lineage-behaviour.ts @@ -28,17 +28,19 @@ function idsOfARead(aLedger: LedgerMaker): void { describe('the lineage of what a brain recorded', () => { it('names each message by its stream and position, with the cause and correlation it was written with', async () => { const ledger = await aLedger(); - await happenWith(ledger, inAlpha(`executions/${root}`), { causationId: null, correlationId: root }, 'start'); - const start = messageIdOf(inAlpha(`executions/${root}`), 1); - await happenWith(ledger, inAlpha(`runs/${root}`), { causationId: start, correlationId: root }, 'input'); - await Effect.runPromise(ledger.execute(inAlpha('specs/inference'), happenings, [noted('noted', 'spec')])); + await happenWith(ledger, inAlpha(`runs/${root}`), { causationId: null, correlationId: root }, 'start'); + const start = messageIdOf(inAlpha(`runs/${root}`), 1); + await happenWith(ledger, inAlpha(`run-logs/${root}`), { causationId: start, correlationId: root }, 'input'); + await Effect.runPromise( + ledger.execute(inAlpha('definitions/reasoning'), happenings, [noted('noted', 'definition')]), + ); const { records } = await reading(ledger, { kind: 'everything' }, { order: 'asc', limit: 10 }); expect(lineagesOf(records)).toEqual([ { id: start, causationId: null, correlationId: root }, - { id: messageIdOf(inAlpha(`runs/${root}`), 1), causationId: start, correlationId: root }, - { id: messageIdOf(inAlpha('specs/inference'), 1), causationId: null, correlationId: null }, + { id: messageIdOf(inAlpha(`run-logs/${root}`), 1), causationId: start, correlationId: root }, + { id: messageIdOf(inAlpha('definitions/reasoning'), 1), causationId: null, correlationId: null }, ]); }); }); @@ -49,11 +51,11 @@ function aReadByCorrelation(aLedger: LedgerMaker): void { it('answers what a run and the runs it caused recorded, in the order of the brain, and nothing for a child', async () => { const ledger = await aLedger(); const ofRoot = { causationId: null, correlationId: root }; - await happenWith(ledger, inAlpha(`executions/${root}`), ofRoot, 'root started'); + await happenWith(ledger, inAlpha(`runs/${root}`), ofRoot, 'root started'); await happenWith(ledger, inAlpha('notes'), { causationId: null, correlationId: null }, 'elsewhere'); - await happenWith(ledger, inAlpha(`executions/${child}`), ofRoot, 'child started'); + await happenWith(ledger, inAlpha(`runs/${child}`), ofRoot, 'child started'); await happenWith(ledger, 'brain/acme/beta/notes', ofRoot, 'another brain'); - await happenWith(ledger, inAlpha(`executions/${root}`), ofRoot, 'root finished'); + await happenWith(ledger, inAlpha(`runs/${root}`), ofRoot, 'root finished'); const pages = await Promise.all([ reading(ledger, { kind: 'correlated', correlation: root }, { order: 'asc', limit: 2 }), diff --git a/packages/ledger/src/outcomes/recorded-fill.ts b/packages/ledger/src/outcomes/recorded-fill.ts index f6462fa35..10c6b611e 100644 --- a/packages/ledger/src/outcomes/recorded-fill.ts +++ b/packages/ledger/src/outcomes/recorded-fill.ts @@ -27,7 +27,7 @@ export function runIdsOf(count: number): readonly string[] { function storedOf(sizes: readonly number[]): readonly StoredRunStream[] { const runs = runIdsOf(sizes.length); - return sizes.map((size, index) => ({ stream: `brain/acme/alpha/executions/${String(runs[index])}`, size })); + return sizes.map((size, index) => ({ stream: `brain/acme/alpha/runs/${String(runs[index])}`, size })); } export function aRecordedFillOf( diff --git a/packages/ledger/src/outcomes/run-outcome-groups.ts b/packages/ledger/src/outcomes/run-outcome-groups.ts index d6532681a..9e0ea8eab 100644 --- a/packages/ledger/src/outcomes/run-outcome-groups.ts +++ b/packages/ledger/src/outcomes/run-outcome-groups.ts @@ -3,7 +3,7 @@ import { Schema } from 'effect'; export const groupFields = { day: Schema.String, - primitive: Schema.String, + definition_type: Schema.String, name: Schema.String, status: RunOutcomeStatusSchema, runs: Schema.Number, @@ -14,7 +14,7 @@ export const groupFields = { export interface GroupRow { readonly day: string; - readonly primitive: string; + readonly definition_type: string; readonly name: string; readonly status: RunOutcomeGroup['status']; readonly runs: number; @@ -27,7 +27,7 @@ export interface GroupRow { export function groupOf(row: GroupRow): RunOutcomeGroup { return { day: row.day, - primitive: row.primitive, + definitionType: row.definition_type, name: row.name, status: row.status, runs: row.runs, diff --git a/packages/ledger/src/outcomes/run-outcome-projection.ts b/packages/ledger/src/outcomes/run-outcome-projection.ts index 908e9e5c3..38a31c22e 100644 --- a/packages/ledger/src/outcomes/run-outcome-projection.ts +++ b/packages/ledger/src/outcomes/run-outcome-projection.ts @@ -7,7 +7,7 @@ import { } from '@beonauto/operations'; import { Schema } from 'effect'; -const runOutcomesVersion = 2; +const runOutcomesVersion = 3; export const runOutcomesTable = `run_outcomes_${runOutcomesVersion}`; @@ -15,7 +15,7 @@ const KeptOutcomeSchema = Schema.Struct({ started_day: Schema.String, started_at: Schema.String, last_started_at: Schema.String, - primitive: Schema.String, + definition_type: Schema.String, name: Schema.String, status: RunOutcomeStatusSchema, duration_ms: Schema.NullOr(Schema.Number), @@ -32,7 +32,7 @@ function outcomeOf(row: ProjectedRow): RunOutcome { startedDay: kept.started_day, startedAt: kept.started_at, lastStartedAt: kept.last_started_at, - primitive: kept.primitive, + definitionType: kept.definition_type, name: kept.name, status: kept.status, durationMs: kept.duration_ms, @@ -47,7 +47,7 @@ function rowOf(outcome: RunOutcome): ProjectedRow { started_day: outcome.startedDay, started_at: outcome.startedAt, last_started_at: outcome.lastStartedAt, - primitive: outcome.primitive, + definition_type: outcome.definitionType, name: outcome.name, status: outcome.status, duration_ms: outcome.durationMs, @@ -61,13 +61,13 @@ function runOutcomeProjectionOf({ types, rowAfter }: RunOutcomeMapping): KeyedPr return { name: 'run_outcomes', version: runOutcomesVersion, - kinds: ['executions'], + kinds: ['runs'], types, columns: [ { name: 'started_day', kind: 'text' }, { name: 'started_at', kind: 'text' }, { name: 'last_started_at', kind: 'text' }, - { name: 'primitive', kind: 'text' }, + { name: 'definition_type', kind: 'text' }, { name: 'name', kind: 'text' }, { name: 'status', kind: 'text' }, { name: 'duration_ms', kind: 'integer' }, diff --git a/packages/ledger/src/outcomes/run-outcome-table-behaviour.ts b/packages/ledger/src/outcomes/run-outcome-table-behaviour.ts index a9773284d..988cb8e68 100644 --- a/packages/ledger/src/outcomes/run-outcome-table-behaviour.ts +++ b/packages/ledger/src/outcomes/run-outcome-table-behaviour.ts @@ -38,7 +38,7 @@ async function manyRuns(entry: LedgerEntry, database: string, count: number): Pr await Effect.runPromise( Effect.forEach( Array.from({ length: count }, (_, index) => index), - (index) => ledger.execute(`brain/acme/alpha/executions/many-${index}`, runFacts, [began('many')]), + (index) => ledger.execute(`brain/acme/alpha/runs/many-${index}`, runFacts, [began('many')]), { concurrency: 8, discard: true }, ), ); @@ -48,17 +48,17 @@ function aProjectionThatBreaksDown(entry: LedgerEntry): void { describe('a projection that breaks down inside an append', () => { it('fails the append, which keeps nothing, the row it changed before included', async () => { const ledger = await aLedger(entry, undefined, failingOnAFailure); - await noting(ledger, 'brain/acme/alpha/executions/r1', began('triage')); + await noting(ledger, 'brain/acme/alpha/runs/r1', began('triage')); await expect( - noting(ledger, 'brain/acme/alpha/executions/r1', ended('succeeded', 1), ended('failed', 2)), - ).rejects.toThrow(breakingDown); - await expect( - noting(ledger, 'brain/acme/alpha/executions/r2', began('triage'), ended('failed', 2)), + noting(ledger, 'brain/acme/alpha/runs/r1', ended('succeeded', 1), ended('failed', 2)), ).rejects.toThrow(breakingDown); + await expect(noting(ledger, 'brain/acme/alpha/runs/r2', began('triage'), ended('failed', 2))).rejects.toThrow( + breakingDown, + ); expect(runsOf(await reading(ledger))).toEqual(['2026-10-01 triage started 1']); - expect(await Effect.runPromise(ledger.load('brain/acme/alpha/executions/r2', runFacts))).toEqual({ + expect(await Effect.runPromise(ledger.load('brain/acme/alpha/runs/r2', runFacts))).toEqual({ state: null, version: 0, }); @@ -71,13 +71,13 @@ function aStoreWithoutTheProjection(entry: LedgerEntry): void { it('neither creates the table nor changes it, and reads no outcomes', async () => { const database = await entry.aDatabase(); const keeping = await aLedger(entry, database, runTallies); - await noting(keeping, 'brain/acme/alpha/executions/r1', began('triage')); + await noting(keeping, 'brain/acme/alpha/runs/r1', began('triage')); const fresh = await entry.aDatabase(); const freshWithout = await aLedger(entry, fresh); - await noting(freshWithout, 'brain/acme/alpha/executions/r2', began('triage')); + await noting(freshWithout, 'brain/acme/alpha/runs/r2', began('triage')); const without = await aLedger(entry, database); - await noting(without, 'brain/acme/alpha/executions/r1', ended('succeeded', 5)); + await noting(without, 'brain/acme/alpha/runs/r1', ended('succeeded', 5)); expect(runsOf(await reading(keeping))).toEqual(['2026-10-01 triage started 1']); expect([await reading(without), await reading(freshWithout)]).toEqual([[], []]); @@ -95,11 +95,11 @@ function aNewTableVersion(entry: LedgerEntry): void { const database = await entry.aDatabase(); const writing = await aLedger(entry, database); await fourRuns(writing); - await noting(writing, 'brain/acme/alpha/executions/r6', ended('failed', 1), { type: 'run_noted' }); - await noting(writing, 'brain/acme/alpha/executions/r7/nested', began('triage')); - await noting(writing, 'brain/acme/alpha/executions/r8', { type: 'run_noted' }); + await noting(writing, 'brain/acme/alpha/runs/r6', ended('failed', 1), { type: 'run_noted' }); + await noting(writing, 'brain/acme/alpha/runs/r7/nested', began('triage')); + await noting(writing, 'brain/acme/alpha/runs/r8', { type: 'run_noted' }); await manyRuns(entry, database, 1000); - await entry.queried(database, 'CREATE TABLE run_outcomes_1 (brain_key text, run_id text)'); + await entry.queried(database, 'CREATE TABLE run_outcomes_2 (brain_key text, row_key text)'); const filled = await aLedger(entry, database, runTallies); @@ -108,7 +108,7 @@ function aNewTableVersion(entry: LedgerEntry): void { { runs: 1000 }, ]); expect((await reading(filled)).filter(({ name }) => name !== 'many')).toEqual(fourRunsKept); - expect(await tablesIn(entry, database)).toEqual([{ name: 'run_outcomes_2' }]); + expect(await tablesIn(entry, database)).toEqual([{ name: 'run_outcomes_3' }]); }, ); }); @@ -119,12 +119,7 @@ function aFillInterruptedOrDone(entry: LedgerEntry): void { it('is done again at the next open when it was interrupted, which left nothing behind', async () => { const database = await entry.aDatabase(); await fourRuns(await aLedger(entry, database)); - await noting( - await aLedger(entry, database), - 'brain/acme/alpha/executions/r5', - began('triage'), - ended('failed', 1), - ); + await noting(await aLedger(entry, database), 'brain/acme/alpha/runs/r5', began('triage'), ended('failed', 1)); await expect(openLedgerWith(entry.ledgerOn(database, failingOnAFailure))).rejects.toThrow(breakingDown); const tablesAfterTheInterruption = await tablesIn(entry, database); @@ -142,7 +137,7 @@ function aFillInterruptedOrDone(entry: LedgerEntry): void { it('is not done again by a ledger that finds the table', async () => { const database = await entry.aDatabase(); await fourRuns(await aLedger(entry, database, runTallies)); - await entry.queried(database, "DELETE FROM run_outcomes_2 WHERE row_key = 'r4'"); + await entry.queried(database, "DELETE FROM run_outcomes_3 WHERE row_key = 'r4'"); const reopened = await aLedger(entry, database, runTallies); @@ -171,7 +166,7 @@ function aFillOfLargeRecords(entry: LedgerEntry): void { notes, (note, index) => Effect.promise(() => - noting(writing, `brain/acme/alpha/executions/large-${index}`, began('large'), largeEnd(index, note)), + noting(writing, `brain/acme/alpha/runs/large-${index}`, began('large'), largeEnd(index, note)), ), { concurrency: 4, discard: true }, ), @@ -190,17 +185,17 @@ function aFillOfAnOversizedRun(entry: LedgerEntry): void { const database = await entry.aDatabase(); const writing = await aLedger(entry, database); const note = 'x'.repeat(6 * mebibyte); - await noting(writing, 'brain/acme/alpha/executions/over-0', began('over'), largeEnd(0, 'small')); + await noting(writing, 'brain/acme/alpha/runs/over-0', began('over'), largeEnd(0, 'small')); await noting( writing, - 'brain/acme/alpha/executions/over-1', + 'brain/acme/alpha/runs/over-1', began('over'), largeEnd(1, note), largeEnd(2, note), largeEnd(3, note), ); - await noting(writing, 'brain/acme/alpha/executions/over-2', began('over'), largeEnd(4, 'small')); - await noting(writing, 'brain/acme/alpha/executions/over-3', began('over'), largeEnd(5, 'small')); + await noting(writing, 'brain/acme/alpha/runs/over-2', began('over'), largeEnd(4, 'small')); + await noting(writing, 'brain/acme/alpha/runs/over-3', began('over'), largeEnd(5, 'small')); const filled = await aLedger(entry, database, runTallies); diff --git a/packages/ledger/src/outcomes/run-outcomes-behaviour.ts b/packages/ledger/src/outcomes/run-outcomes-behaviour.ts index b855d2baa..e6b935688 100644 --- a/packages/ledger/src/outcomes/run-outcomes-behaviour.ts +++ b/packages/ledger/src/outcomes/run-outcomes-behaviour.ts @@ -56,17 +56,17 @@ export function runsOf(groups: readonly RunOutcomeGroup[]): readonly string[] { } export async function fourRuns(ledger: AnyLedger): Promise { - await noting(ledger, 'brain/acme/alpha/executions/r1', began('triage'), ended('succeeded', 120, 40)); - await noting(ledger, 'brain/acme/alpha/executions/r2', began('triage')); - await noting(ledger, 'brain/acme/alpha/executions/r2', ended('succeeded', 80, 2)); - await noting(ledger, 'brain/acme/alpha/executions/r3', began('triage'), ended('rejected', null)); - await noting(ledger, 'brain/acme/alpha/executions/r4', began('draft', '2026-10-02T23:59:59.999Z')); + await noting(ledger, 'brain/acme/alpha/runs/r1', began('triage'), ended('succeeded', 120, 40)); + await noting(ledger, 'brain/acme/alpha/runs/r2', began('triage')); + await noting(ledger, 'brain/acme/alpha/runs/r2', ended('succeeded', 80, 2)); + await noting(ledger, 'brain/acme/alpha/runs/r3', began('triage'), ended('rejected', null)); + await noting(ledger, 'brain/acme/alpha/runs/r4', began('draft', '2026-10-02T23:59:59.999Z')); } export const fourRunsKept: readonly RunOutcomeGroup[] = [ { day: '2026-10-01', - primitive: 'tally', + definitionType: 'tally', name: 'triage', status: 'rejected', runs: 1, @@ -77,7 +77,7 @@ export const fourRunsKept: readonly RunOutcomeGroup[] = [ }, { day: '2026-10-01', - primitive: 'tally', + definitionType: 'tally', name: 'triage', status: 'succeeded', runs: 2, @@ -88,7 +88,7 @@ export const fourRunsKept: readonly RunOutcomeGroup[] = [ }, { day: '2026-10-02', - primitive: 'tally', + definitionType: 'tally', name: 'draft', status: 'started', runs: 1, @@ -110,12 +110,12 @@ function theRowOfEachRun(aLedger: LedgerKeeping): void { it('keep to the days of the window, the selection, and the brain matched exactly', async () => { const ledger = await aLedger(runTallies); - await noting(ledger, 'brain/acme/alpha/executions/r1', began('triage', '2026-09-30T23:59:59.999Z')); - await noting(ledger, 'brain/acme/alpha/executions/r2', began('triage', '2026-10-01T00:00:00.000Z')); - await noting(ledger, 'brain/acme/alpha/executions/r3', began('draft', '2026-10-01T00:00:00.000Z')); + await noting(ledger, 'brain/acme/alpha/runs/r1', began('triage', '2026-09-30T23:59:59.999Z')); + await noting(ledger, 'brain/acme/alpha/runs/r2', began('triage', '2026-10-01T00:00:00.000Z')); + await noting(ledger, 'brain/acme/alpha/runs/r3', began('draft', '2026-10-01T00:00:00.000Z')); await Promise.all( - ['brain/acme/alpha2/executions/r4', 'brain/acme/Alpha/executions/r5', 'brain/acme/alph_/executions/r6'].map( - (stream) => noting(ledger, stream, began('triage', '2026-10-01T00:00:00.000Z')), + ['brain/acme/alpha2/runs/r4', 'brain/acme/Alpha/runs/r5', 'brain/acme/alph_/runs/r6'].map((stream) => + noting(ledger, stream, began('triage', '2026-10-01T00:00:00.000Z')), ), ); @@ -123,8 +123,8 @@ function theRowOfEachRun(aLedger: LedgerKeeping): void { reading(ledger, { from: '2026-10-01', to: '2026-10-01' }), reading(ledger, { from: '2026-09-30', to: '2026-09-30' }), reading(ledger, october, { name: 'draft' }), - reading(ledger, october, { primitive: 'other' }), - reading(ledger, october, { primitive: 'tally', name: 'triage' }), + reading(ledger, october, { definitionType: 'other' }), + reading(ledger, october, { definitionType: 'tally', name: 'triage' }), reading(ledger, october, {}, { org: 'acme', brain: 'alph_' }), ]); @@ -140,9 +140,9 @@ function theRowOfEachRun(aLedger: LedgerKeeping): void { it('leave out other streams, a stream nested under a run, other types, and what the mapping keeps nothing of', async () => { const ledger = await aLedger(runTallies); - await noting(ledger, 'brain/acme/alpha/runs/r1', began('triage')); - await noting(ledger, 'brain/acme/alpha/executions/r2/nested', began('triage')); - await noting(ledger, 'brain/acme/alpha/executions/r3', ended('failed', 5), { type: 'run_noted' }); + await noting(ledger, 'brain/acme/alpha/run-logs/r1', began('triage')); + await noting(ledger, 'brain/acme/alpha/runs/r2/nested', began('triage')); + await noting(ledger, 'brain/acme/alpha/runs/r3', ended('failed', 5), { type: 'run_noted' }); expect(await reading(ledger)).toEqual([]); }); diff --git a/packages/ledger/src/outcomes/sqlite-run-outcomes.ts b/packages/ledger/src/outcomes/sqlite-run-outcomes.ts index 7d581ff64..a0c107f07 100644 --- a/packages/ledger/src/outcomes/sqlite-run-outcomes.ts +++ b/packages/ledger/src/outcomes/sqlite-run-outcomes.ts @@ -13,9 +13,9 @@ const GroupRows = Schema.Array( Schema.Struct({ ...groupFields, durations: Schema.fromJsonString(Schema.Array(Schema.Number)) }), ); -function selected({ primitive, name }: RunOutcomeSelection): SQL { +function selected({ definitionType, name }: RunOutcomeSelection): SQL { return SQL.concat( - primitive === undefined ? SQL.EMPTY : SQL` AND primitive = ${primitive}`, + definitionType === undefined ? SQL.EMPTY : SQL` AND definition_type = ${definitionType}`, name === undefined ? SQL.EMPTY : SQL` AND name = ${name}`, ); } @@ -23,13 +23,13 @@ function selected({ primitive, name }: RunOutcomeSelection): SQL { function sqliteRunOutcomesReader(execute: StatementExecutor): RunOutcomesStore['readRunOutcomes'] { return async (brainKey, { from, to }, selection) => { const { rows } = await execute.query( - SQL`SELECT started_day AS day, primitive, name, status, count(*) AS runs, + SQL`SELECT started_day AS day, definition_type, name, status, count(*) AS runs, coalesce(sum(input_tokens), 0) AS input_tokens, coalesce(sum(output_tokens), 0) AS output_tokens, coalesce(sum(cached_tokens), 0) AS cached_tokens, json_group_array(duration_ms) FILTER (WHERE duration_ms IS NOT NULL) AS durations FROM ${table} WHERE brain_key = ${brainKey} AND started_day BETWEEN ${from} AND ${to}${selected(selection)} - GROUP BY started_day, primitive, name, status`, + GROUP BY started_day, definition_type, name, status`, ); return Schema.decodeUnknownSync(GroupRows)(rows).map((row) => groupOf(row)); }; diff --git a/packages/ledger/src/postgresql-reads/brain-indexes.ts b/packages/ledger/src/postgresql-reads/brain-indexes.ts index 201271fba..06f968be7 100644 --- a/packages/ledger/src/postgresql-reads/brain-indexes.ts +++ b/packages/ledger/src/postgresql-reads/brain-indexes.ts @@ -9,7 +9,7 @@ export const kindKeyOfStream = "substring(stream_id FROM '^(?:[^/]*/){4}')"; export const correlationOfMessage = "(message_metadata ->> 'correlationId')"; -export const definitionTypeOfStream = "substring(stream_id FROM '^(?:[^/]*/){3}specs/([^/]+)$')"; +export const definitionTypeOfStream = "substring(stream_id FROM '^(?:[^/]*/){3}definitions/([^/]+)$')"; const NameRows = Schema.Array(Schema.Struct({ name: Schema.String })); diff --git a/packages/ledger/src/postgresql-reads/index-checks.ts b/packages/ledger/src/postgresql-reads/index-checks.ts index 33019b4d9..5a5d63c19 100644 --- a/packages/ledger/src/postgresql-reads/index-checks.ts +++ b/packages/ledger/src/postgresql-reads/index-checks.ts @@ -2,14 +2,14 @@ import { definitionStreamsStatement } from './postgresql-definition-streams.ts'; export const definitionStreamsPlan = { explained: `EXPLAIN ${definitionStreamsStatement}`, - values: ['recollection', 'emt:default'], - throughTheIndex: `Index Cond: ("substring"(stream_id, '^(?:[^/]*/){3}specs/([^/]+)$'::text) = `, + values: ['recall', 'emt:default'], + throughTheIndex: `Index Cond: ("substring"(stream_id, '^(?:[^/]*/){3}definitions/([^/]+)$'::text) = `, }; export const theBrainIndexes = [ { indexname: 'ledger_definition_streams', - indexdef: `CREATE INDEX ledger_definition_streams ON ONLY public.emt_streams USING btree ("substring"(stream_id, '^(?:[^/]*/){3}specs/([^/]+)$'::text)) WHERE ("substring"(stream_id, '^(?:[^/]*/){3}specs/([^/]+)$'::text) IS NOT NULL)`, + indexdef: `CREATE INDEX ledger_definition_streams ON ONLY public.emt_streams USING btree ("substring"(stream_id, '^(?:[^/]*/){3}definitions/([^/]+)$'::text)) WHERE ("substring"(stream_id, '^(?:[^/]*/){3}definitions/([^/]+)$'::text) IS NOT NULL)`, }, { indexname: 'ledger_first_messages_by_kind', diff --git a/packages/ledger/src/postgresql-reads/postgresql-definition-streams.test.ts b/packages/ledger/src/postgresql-reads/postgresql-definition-streams.test.ts index 2a603758c..2c188955a 100644 --- a/packages/ledger/src/postgresql-reads/postgresql-definition-streams.test.ts +++ b/packages/ledger/src/postgresql-reads/postgresql-definition-streams.test.ts @@ -7,12 +7,12 @@ describe('the definition streams of a type on PostgreSQL', () => { const asked: { readonly text: string; readonly values: readonly unknown[] }[] = []; const store = postgresqlDefinitionStreams((text, values) => { asked.push({ text, values }); - return Promise.resolve([{ stream: 'brain/acme/alpha/specs/recollection', version: '3' }]); + return Promise.resolve([{ stream: 'brain/acme/alpha/definitions/recall', version: '3' }]); }); - const streams = await store.definitionStreams('recollection'); + const streams = await store.definitionStreams('recall'); - expect(streams).toEqual([{ stream: 'brain/acme/alpha/specs/recollection', version: 3 }]); - expect(asked).toEqual([{ text: definitionStreamsStatement, values: ['recollection', 'emt:default'] }]); + expect(streams).toEqual([{ stream: 'brain/acme/alpha/definitions/recall', version: 3 }]); + expect(asked).toEqual([{ text: definitionStreamsStatement, values: ['recall', 'emt:default'] }]); }); }); diff --git a/packages/ledger/src/postgresql-reads/postgresql-recorded.test.ts b/packages/ledger/src/postgresql-reads/postgresql-recorded.test.ts index 2811ada4f..a56fb8250 100644 --- a/packages/ledger/src/postgresql-reads/postgresql-recorded.test.ts +++ b/packages/ledger/src/postgresql-reads/postgresql-recorded.test.ts @@ -111,8 +111,8 @@ describe('the size a read on PostgreSQL measures', () => { await store.readRecorded(alpha, { kind: 'everything' }, { order: 'asc', limit: 5, dataOf: ['noted'] }); await store.readRecorded(alpha, { kind: 'everything' }, { order: 'asc', limit: 5, dataOf: [] }); - await store.readRecorded(alpha, { kind: 'executions' }, { order: 'desc', limit: 5, dataOf: ['noted'] }); - await store.readRecorded(alpha, { kind: 'executions' }, { order: 'desc', limit: 5, dataOf: [] }); + await store.readRecorded(alpha, { kind: 'runs' }, { order: 'desc', limit: 5, dataOf: ['noted'] }); + await store.readRecorded(alpha, { kind: 'runs' }, { order: 'desc', limit: 5, dataOf: [] }); expect(asked[0]?.text).toContain( "CASE WHEN wanted AND type = ANY($3::text[]) THEN octet_length(numbered.message_data ->> 'json') ELSE 0 END AS size", @@ -140,7 +140,7 @@ describe('a read of one run on PostgreSQL, newest first', () => { const page = await postgresqlRecordedStore(query).readRecorded( alpha, - { kind: 'run', execution: 'r1' }, + { kind: 'run', run: 'r1' }, { order: 'desc', limit: 5, since: at, after: ['30', '40'] }, ); @@ -160,8 +160,8 @@ describe('a read of one run on PostgreSQL, newest first', () => { '7', '8', 6, - [`${alpha}executions/r1`], [`${alpha}runs/r1`], + [`${alpha}run-logs/r1`], 5, 6, ]); @@ -214,25 +214,22 @@ describe('a read on PostgreSQL from a time', () => { describe('a read of runs on PostgreSQL', () => { it('gives the first and the latest message of each run, and the first alone for a run of one message', async () => { const { query, asked } = answering( - [ - run('3', '4', ['3', '4', 'execution_started']), - { ...run('1', '2', ['5', '6', 'execution_succeeded']), examined: 2 }, - ], + [run('3', '4', ['3', '4', 'run_started']), { ...run('1', '2', ['5', '6', 'run_succeeded']), examined: 2 }], [stored('3', '4', 'r2'), stored('1', '2', 'r1'), stored('5', '6', 'r1 done')], ); - const runsOnly = { kind: 'executions', notBeginningWith: ['execution_cancel_requested'] } as const; + const runsOnly = { kind: 'runs', notBeginningWith: ['run_cancel_requested'] } as const; const page = await postgresqlRecordedStore(query).readRecorded(alpha, runsOnly, { order: 'desc', limit: 5 }); expect(page.records.map(({ type, data, id, causationId }) => [type, data, id, causationId])).toEqual([ ['noted', { type: 'noted', detail: 'r2' }, 'message-4', null], ['noted', { type: 'noted', detail: 'r1' }, 'message-2', null], - ['execution_succeeded', { type: 'noted', detail: 'r1 done' }, 'message-6', 'message-2'], + ['run_succeeded', { type: 'noted', detail: 'r1 done' }, 'message-6', 'message-2'], ]); expect(asked[0]?.text).toContain(`WHERE ${kindKey} = ANY($2::text[]) AND stream_position = 1`); expect([asked[0]?.text.includes('AND NOT message_type = ANY($3::text[])'), asked[0]?.values[2]]).toEqual([ true, - ['execution_cancel_requested'], + ['run_cancel_requested'], ]); expect(asked[0]?.text).toContain(`ORDER BY ${kindKey} DESC, transaction_id DESC, global_position DESC`); }); @@ -264,7 +261,7 @@ describe("the brain's indexes on PostgreSQL", () => { 'CREATE INDEX IF NOT EXISTS ledger_messages_by_stream ON emt_messages (stream_id, transaction_id, global_position)', "CREATE INDEX IF NOT EXISTS ledger_first_messages_by_kind ON emt_messages ((substring(stream_id FROM '^(?:[^/]*/){4}')), stream_position, transaction_id, global_position)", "CREATE INDEX IF NOT EXISTS ledger_messages_by_brain_and_correlation ON emt_messages ((substring(stream_id FROM '^(?:[^/]*/){3}')), (message_metadata ->> 'correlationId'), transaction_id, global_position)", - "CREATE INDEX IF NOT EXISTS ledger_definition_streams ON emt_streams ((substring(stream_id FROM '^(?:[^/]*/){3}specs/([^/]+)$'))) WHERE (substring(stream_id FROM '^(?:[^/]*/){3}specs/([^/]+)$')) IS NOT NULL", + "CREATE INDEX IF NOT EXISTS ledger_definition_streams ON emt_streams ((substring(stream_id FROM '^(?:[^/]*/){3}definitions/([^/]+)$'))) WHERE (substring(stream_id FROM '^(?:[^/]*/){3}definitions/([^/]+)$')) IS NOT NULL", 'ANALYZE emt_messages, emt_streams', ]); }); diff --git a/packages/ledger/src/postgresql-reads/postgresql-runs.test.ts b/packages/ledger/src/postgresql-reads/postgresql-runs.test.ts index a9baf9698..b6e0e08cd 100644 --- a/packages/ledger/src/postgresql-reads/postgresql-runs.test.ts +++ b/packages/ledger/src/postgresql-reads/postgresql-runs.test.ts @@ -21,9 +21,9 @@ function answeringNothing(): { readonly query: Query; readonly asked: Asked[] } const alpha = 'brain/acme/alpha/'; describe('a read of the runs of one definition on PostgreSQL', () => { - it('reads the name and the primitive at the top of the first message, as jsonb without the escapes jsonb refuses, once its text holds both as written, among a thousand runs', async () => { + it('reads the name and the definition type at the top of the first message, as jsonb without the escapes jsonb refuses, once its text holds both as written, among a thousand runs', async () => { const { query, asked } = answeringNothing(); - const ofOneDefinition = { kind: 'executions', primitive: 'orchestration', name: 'qualify-enquiry' } as const; + const ofOneDefinition = { kind: 'runs', definitionType: 'workflow', name: 'qualify-enquiry' } as const; await postgresqlRecordedStore(query).readRecorded(alpha, ofOneDefinition, { order: 'desc', limit: 5 }); @@ -36,11 +36,11 @@ describe('a read of the runs of one definition on PostgreSQL', () => { expect(asked[0]?.values).toEqual([ 'emt:default', '"name":"qualify-enquiry"', - '"primitive":"orchestration"', + '"definition_type":"workflow"', String.raw`(\\\\)|\\u(?:0000|d[89a-f][0-9a-f]{2})`, String.raw`\1`, - '{"name":"qualify-enquiry","primitive":"orchestration"}', - [`${alpha}executions/`], + '{"name":"qualify-enquiry","definition_type":"workflow"}', + [`${alpha}runs/`], 1001, 1000, 7, diff --git a/packages/ledger/src/postgresql-reads/postgresql-runs.ts b/packages/ledger/src/postgresql-reads/postgresql-runs.ts index 826c07f5f..402b431be 100644 --- a/packages/ledger/src/postgresql-reads/postgresql-runs.ts +++ b/packages/ledger/src/postgresql-reads/postgresql-runs.ts @@ -70,7 +70,7 @@ function firstMessagesOfRuns(bind: Bind, partition: string, scope: ExaminationSc message_type AS type, ${timeOf('created')} AS recorded, ${lineageColumns}, message_data, ${ofTheDefinitionAsked(bind, runs)} AS of_the_definition FROM emt_messages - WHERE ${matchedThroughItsIndex(bind, kindKeyOfStream, `${scope.brainKey}executions/`)} AND stream_position = 1 + WHERE ${matchedThroughItsIndex(bind, kindKeyOfStream, `${scope.brainKey}runs/`)} AND stream_position = 1 AND partition = ${partition} AND is_archived = FALSE${notOfTypes(bind, runs.notBeginningWith ?? [])} ${horizonOf(scope)}${bounds(bind, scope)} ORDER BY ${orderedThroughItsIndex(kindKeyOfStream, scope)} diff --git a/packages/ledger/src/postgresql/advances-on-postgresql.test.ts b/packages/ledger/src/postgresql/advances-on-postgresql.test.ts index 4b68d006d..dc751fe75 100644 --- a/packages/ledger/src/postgresql/advances-on-postgresql.test.ts +++ b/packages/ledger/src/postgresql/advances-on-postgresql.test.ts @@ -74,13 +74,11 @@ describe.skipIf(skipped)(`A fold beside an advance of the same row on PostgreSQL ); onTestFinished(dispose); await Effect.runPromise( - ledger.execute('brain/acme/alpha/executions/r1', topicFacts, [ - { type: 'topic_opened', topic: 'spring', at: 1000 }, - ]), + ledger.execute('brain/acme/alpha/runs/r1', topicFacts, [{ type: 'topic_opened', topic: 'spring', at: 1000 }]), ); const advancing = await connected(database); await advancing.query('BEGIN'); - await advancing.query("UPDATE topics_1 SET open = false, next_at = NULL, due_at = NULL WHERE row_key = 'spring'"); + await advancing.query("UPDATE topics_2 SET open = false, next_at = NULL, due_at = NULL WHERE row_key = 'spring'"); const folding = Effect.runPromise( ledger.execute('brain/acme/alpha/notes/n1', topicFacts, [ @@ -92,7 +90,7 @@ describe.skipIf(skipped)(`A fold beside an advance of the same row on PostgreSQL await folding; expect( - await queried(database, "SELECT note, open, next_at, due_at FROM topics_1 WHERE row_key = 'spring'"), + await queried(database, "SELECT note, open, next_at, due_at FROM topics_2 WHERE row_key = 'spring'"), ).toEqual([{ note: 'during', open: false, next_at: null, due_at: null }]); }); }); diff --git a/packages/ledger/src/postgresql/ledger-on-postgresql.test.ts b/packages/ledger/src/postgresql/ledger-on-postgresql.test.ts index 88924f08a..fd8b6d863 100644 --- a/packages/ledger/src/postgresql/ledger-on-postgresql.test.ts +++ b/packages/ledger/src/postgresql/ledger-on-postgresql.test.ts @@ -110,6 +110,8 @@ const onPostgreSQL: LedgerEntry = { "SELECT relname AS name FROM pg_class WHERE relkind IN ('r', 'p') AND relname ~ '^run_tallies_[0-9]+$' ORDER BY relname", projectionIndexes: "SELECT indexname AS name FROM pg_indexes WHERE indexname ~ '^run_tallies_[0-9]+_' AND indexname !~ '_pkey$' ORDER BY indexname", + topicTables: + "SELECT relname AS name FROM pg_class WHERE relkind IN ('r', 'p') AND relname ~ '^topics_[0-9]+$' ORDER BY relname", }; const skipped = server === ''; @@ -160,7 +162,7 @@ function noting(ledger: OpenLedger['ledger'], stream: string, type: string, deta return Effect.runPromise(ledger.execute(`brain/acme/alpha/${stream}`, happenings, [{ type, detail }])); } -const root = 'brain/acme/alpha/executions/root'; +const root = 'brain/acme/alpha/runs/root'; const ofTheRoot = { causationId: null, correlationId: 'root' }; function notingOfRoot(ledger: OpenLedger['ledger'], type: string, detail: string): Promise { @@ -178,7 +180,7 @@ async function aLedgerOnItsOwnDatabase(): Promise { const everything: RecordedSelection = { kind: 'everything' }; -const runs: RecordedSelection = { kind: 'executions' }; +const runs: RecordedSelection = { kind: 'runs' }; describe.skipIf(skipped)(`A read on PostgreSQL while an append is still open${notice}`, { timeout: 30_000 }, () => { it('oldest first, stays behind it, and delivers every message once after it commits', async () => { @@ -215,11 +217,11 @@ describe.skipIf(skipped)(`A read on PostgreSQL while an append is still open${no it('by correlation, stays behind it oldest first, and delivers the message committed late once after', async () => { const { database, ledger } = await aLedgerOnItsOwnDatabase(); const correlated: RecordedSelection = { kind: 'correlated', correlation: 'root' }; - await notingOfRoot(ledger, 'execution_started', 'before'); + await notingOfRoot(ledger, 'run_started', 'before'); await untilReadable(database); - const child = 'brain/acme/alpha/executions/child'; - const open = await anAppendLeftOpen(database, child, 'execution_started', { correlationId: 'root' }); - await notingOfRoot(ledger, 'execution_succeeded', 'after'); + const child = 'brain/acme/alpha/runs/child'; + const open = await anAppendLeftOpen(database, child, 'run_started', { correlationId: 'root' }); + await notingOfRoot(ledger, 'run_succeeded', 'after'); const whileOpen = await reading(ledger, correlated, 'asc'); await open.query('COMMIT'); @@ -236,10 +238,10 @@ describe.skipIf(skipped)( () => { it('lists runs oldest first behind it, and every run once after it commits', async () => { const { database, ledger } = await aLedgerOnItsOwnDatabase(); - await noting(ledger, 'executions/r-before', 'execution_started', 'before'); + await noting(ledger, 'runs/r-before', 'run_started', 'before'); await untilReadable(database); - const open = await anAppendLeftOpen(database, 'brain/acme/alpha/executions/r-late', 'execution_started'); - await noting(ledger, 'executions/r-after', 'execution_started', 'after'); + const open = await anAppendLeftOpen(database, 'brain/acme/alpha/runs/r-late', 'run_started'); + await noting(ledger, 'runs/r-after', 'run_started', 'after'); const whileOpen = await reading(ledger, runs, 'asc'); await open.query('COMMIT'); diff --git a/packages/ledger/src/postgresql/postgresql-projections.test.ts b/packages/ledger/src/postgresql/postgresql-projections.test.ts index 064d84925..23c845a8c 100644 --- a/packages/ledger/src/postgresql/postgresql-projections.test.ts +++ b/packages/ledger/src/postgresql/postgresql-projections.test.ts @@ -78,7 +78,7 @@ describe('the reads of a projection on PostgreSQL', () => { ]); expect(asked).toEqual([ { - text: 'SELECT brain_key, row_key, fn, began_at::float8 AS began_at, status, facts::float8 AS facts, open, due_at::float8 AS due_at, last_message FROM run_tallies_1 WHERE brain_key = $1 AND open = $2 AND (began_at, row_key) < ($3, $4) ORDER BY began_at DESC NULLS LAST, row_key DESC NULLS LAST LIMIT $5', + text: 'SELECT brain_key, row_key, fn, began_at::float8 AS began_at, status, facts::float8 AS facts, open, due_at::float8 AS due_at, last_message FROM run_tallies_2 WHERE brain_key = $1 AND open = $2 AND (began_at, row_key) < ($3, $4) ORDER BY began_at DESC NULLS LAST, row_key DESC NULLS LAST LIMIT $5', values: ['brain/acme/alpha/', true, 2000, 'r9', 20], }, ]); @@ -103,9 +103,9 @@ describe('the counts and due times of a projection on PostgreSQL', () => { ).toMatchObject([{ key: 'r1', row: { open: true } }]); expect(await Effect.runPromise(readerOf(soonest.query).nextDueOf('run_tallies', 'due_at', 0))).toBe(61_000); expect([...counts.asked, ...due.asked, ...soonest.asked].map(({ text }) => text)).toEqual([ - 'SELECT CAST(count(*) AS INTEGER) AS count FROM run_tallies_1 WHERE brain_key = $1', - 'SELECT brain_key, row_key, fn, began_at::float8 AS began_at, status, facts::float8 AS facts, open, due_at::float8 AS due_at, last_message FROM run_tallies_1 WHERE due_at IS NOT NULL AND due_at <= $1 ORDER BY due_at, brain_key, row_key LIMIT $2', - 'SELECT min(due_at)::float8 AS due FROM run_tallies_1 WHERE due_at IS NOT NULL AND due_at > $1', + 'SELECT CAST(count(*) AS INTEGER) AS count FROM run_tallies_2 WHERE brain_key = $1', + 'SELECT brain_key, row_key, fn, began_at::float8 AS began_at, status, facts::float8 AS facts, open, due_at::float8 AS due_at, last_message FROM run_tallies_2 WHERE due_at IS NOT NULL AND due_at <= $1 ORDER BY due_at, brain_key, row_key LIMIT $2', + 'SELECT min(due_at)::float8 AS due FROM run_tallies_2 WHERE due_at IS NOT NULL AND due_at > $1', ]); }); @@ -144,7 +144,7 @@ describe('the advance of a row of a projection on PostgreSQL', () => { expect(Exit.isFailure(refused)).toBe(true); expect(asked).toEqual([ { - text: 'UPDATE topics_1 SET open = $1, next_at = $2 WHERE brain_key = $3 AND row_key = $4 AND last_message = $5', + text: 'UPDATE topics_2 SET open = $1, next_at = $2 WHERE brain_key = $3 AND row_key = $4 AND last_message = $5', values: [false, 9000, 'brain/acme/alpha/', 'spring', 'm-1'], }, ]); @@ -219,9 +219,9 @@ describe('the fill of a projection keyed by its mapping on PostgreSQL', () => { expect(fill.queries.filter((statement) => statement.includes('ORDER BY m.transaction_id'))).toHaveLength(2); expect(fill.queries.find((statement) => statement.includes('split_part("0/0"'))).toContain( - `split_part(m.stream_id, '/', 4) IN (SELECT jsonb_array_elements_text("[\\"executions\\",\\"notes\\"]"::jsonb))`, + `split_part(m.stream_id, '/', 4) IN (SELECT jsonb_array_elements_text("[\\"runs\\",\\"notes\\"]"::jsonb))`, ); - expect(fill.commands.filter((command) => command.startsWith('INSERT INTO topics_1')).join(' ')).toMatch( + expect(fill.commands.filter((command) => command.startsWith('INSERT INTO topics_2')).join(' ')).toMatch( /"t0".*"t1".*"t2"/u, ); }); diff --git a/packages/ledger/src/postgresql/postgresql-run-outcomes.test.ts b/packages/ledger/src/postgresql/postgresql-run-outcomes.test.ts index 55984e345..9808bffeb 100644 --- a/packages/ledger/src/postgresql/postgresql-run-outcomes.test.ts +++ b/packages/ledger/src/postgresql/postgresql-run-outcomes.test.ts @@ -63,11 +63,11 @@ function aLedgerWithOneRun(tables: readonly string[]): Answers { return tables.map((name) => ({ name })); } if (statement.includes('FROM emt_streams')) { - return statement.includes('s.stream_id > ""') ? [{ stream: 'brain/acme/alpha/executions/r1', size: 120 }] : []; + return statement.includes('s.stream_id > ""') ? [{ stream: 'brain/acme/alpha/runs/r1', size: 120 }] : []; } return [ { - stream: 'brain/acme/alpha/executions/r1', + stream: 'brain/acme/alpha/runs/r1', type: 'run_began', data: { json: JSON.stringify(began) }, position: 1, @@ -77,7 +77,7 @@ function aLedgerWithOneRun(tables: readonly string[]): Answers { } const keptRow = - 'INSERT INTO run_outcomes_2 (brain_key, row_key, started_day, started_at, last_started_at, primitive, name, status, duration_ms, input_tokens, output_tokens, cached_tokens) VALUES ("brain/acme/alpha/", "r1", "2026-10-01", "2026-10-01T09:00:00.000Z", "2026-10-01T09:00:00.000Z", "tally", "triage", "started", null, null, null, null) ON CONFLICT (brain_key, row_key) DO UPDATE SET started_day = excluded.started_day,'; + 'INSERT INTO run_outcomes_3 (brain_key, row_key, started_day, started_at, last_started_at, definition_type, name, status, duration_ms, input_tokens, output_tokens, cached_tokens) VALUES ("brain/acme/alpha/", "r1", "2026-10-01", "2026-10-01T09:00:00.000Z", "2026-10-01T09:00:00.000Z", "tally", "triage", "started", null, null, null, null) ON CONFLICT (brain_key, row_key) DO UPDATE SET started_day = excluded.started_day,'; describe('the table of the outcomes of runs on PostgreSQL, as the ledger opens', () => { it("is created after the brain's indexes, filled from the stored run streams, and analysed", async () => { @@ -86,16 +86,16 @@ describe('the table of the outcomes of runs on PostgreSQL, as the ledger opens', await tallied.afterTheSchema({ execute }); expect(commands.map((command) => command.split(' ').slice(0, 6).join(' '))).toEqual([ - 'CREATE TABLE IF NOT EXISTS run_outcomes_2', - 'CREATE INDEX IF NOT EXISTS run_outcomes_2_by_brain_and_day', - 'INSERT INTO run_outcomes_2 (brain_key, row_key, started_day,', - 'ANALYZE run_outcomes_2', + 'CREATE TABLE IF NOT EXISTS run_outcomes_3', + 'CREATE INDEX IF NOT EXISTS run_outcomes_3_by_brain_and_day', + 'INSERT INTO run_outcomes_3 (brain_key, row_key, started_day,', + 'ANALYZE run_outcomes_3', ]); expect(commands[2]?.startsWith(keptRow)).toBe(true); }); it('is left as it is when it is found, and never made by a ledger without the projection', async () => { - const found = recording(aLedgerWithOneRun(['run_outcomes_2'])); + const found = recording(aLedgerWithOneRun(['run_outcomes_3'])); const without = recording(aLedgerWithOneRun([])); await tallied.afterTheSchema({ execute: found.execute }); @@ -138,7 +138,7 @@ describe('the projection of the outcomes of runs on PostgreSQL', () => { const message = { type: 'run_began', data: { json: JSON.stringify(began) }, - metadata: { streamName: 'brain/acme/alpha/executions/r1', messageId: 'm1', streamPosition: 1n }, + metadata: { streamName: 'brain/acme/alpha/runs/r1', messageId: 'm1', streamPosition: 1n }, }; await registration?.projection.handle([message], { execute }); @@ -167,7 +167,7 @@ function answering(rows: readonly unknown[]): { readonly query: Query; readonly const group = { day: '2026-10-01', - primitive: 'tally', + definition_type: 'tally', name: 'triage', status: 'succeeded', runs: 2, @@ -183,13 +183,13 @@ describe('the read of the outcomes of runs on PostgreSQL', () => { const read = postgresqlRunOutcomesReader(query); const window = { from: '2026-10-01', to: '2026-10-07' }; - const groups = await read('brain/acme/alpha/', window, { primitive: 'tally', name: 'triage' }); + const groups = await read('brain/acme/alpha/', window, { definitionType: 'tally', name: 'triage' }); await read('brain/acme/alpha/', window, {}); expect(groups).toEqual([ { day: '2026-10-01', - primitive: 'tally', + definitionType: 'tally', name: 'triage', status: 'succeeded', runs: 2, @@ -204,7 +204,7 @@ describe('the read of the outcomes of runs on PostgreSQL', () => { ['brain/acme/alpha/', '2026-10-01', '2026-10-07'], ]); expect(asked[0]?.text).toContain( - 'WHERE brain_key = $1 AND started_day BETWEEN $2 AND $3 AND primitive = $4 AND name = $5 GROUP BY started_day, primitive, name, status', + 'WHERE brain_key = $1 AND started_day BETWEEN $2 AND $3 AND definition_type = $4 AND name = $5 GROUP BY started_day, definition_type, name, status', ); expect(asked[1]?.text).toContain('BETWEEN $2 AND $3 GROUP BY'); }); diff --git a/packages/ledger/src/postgresql/postgresql-run-outcomes.ts b/packages/ledger/src/postgresql/postgresql-run-outcomes.ts index c8495921e..3353da284 100644 --- a/packages/ledger/src/postgresql/postgresql-run-outcomes.ts +++ b/packages/ledger/src/postgresql/postgresql-run-outcomes.ts @@ -10,23 +10,23 @@ import { binding, type Bind, type Query } from '../postgresql-reads/recorded-par const GroupRows = Schema.Array(Schema.Struct({ ...groupFields, durations: Schema.Array(Schema.Number) })); -function selected(bind: Bind, { primitive, name }: RunOutcomeSelection): string { - const ofPrimitive = primitive === undefined ? '' : ` AND primitive = ${bind(primitive)}`; - return name === undefined ? ofPrimitive : `${ofPrimitive} AND name = ${bind(name)}`; +function selected(bind: Bind, { definitionType, name }: RunOutcomeSelection): string { + const ofDefinitionType = definitionType === undefined ? '' : ` AND definition_type = ${bind(definitionType)}`; + return name === undefined ? ofDefinitionType : `${ofDefinitionType} AND name = ${bind(name)}`; } export function postgresqlRunOutcomesReader(query: Query): RunOutcomesStore['readRunOutcomes'] { return async (brainKey, { from, to }, selection) => { const { values, bind } = binding(); const rows = await query( - `SELECT started_day AS day, primitive, name, status, count(*)::int AS runs, + `SELECT started_day AS day, definition_type, name, status, count(*)::int AS runs, coalesce(sum(input_tokens), 0)::float8 AS input_tokens, coalesce(sum(output_tokens), 0)::float8 AS output_tokens, coalesce(sum(cached_tokens), 0)::float8 AS cached_tokens, coalesce(json_agg(duration_ms) FILTER (WHERE duration_ms IS NOT NULL), '[]'::json) AS durations FROM ${runOutcomesTable} WHERE brain_key = ${bind(brainKey)} AND started_day BETWEEN ${bind(from)} AND ${bind(to)}${selected(bind, selection)} - GROUP BY started_day, primitive, name, status`, + GROUP BY started_day, definition_type, name, status`, values, ); return Schema.decodeUnknownSync(GroupRows)(rows).map((row) => groupOf(row)); diff --git a/packages/ledger/src/projections/projection-table-behaviour.ts b/packages/ledger/src/projections/projection-table-behaviour.ts index 4600228cb..1871430f0 100644 --- a/packages/ledger/src/projections/projection-table-behaviour.ts +++ b/packages/ledger/src/projections/projection-table-behaviour.ts @@ -31,18 +31,18 @@ function noting(ledger: Awaited>, stream: string, ... function anAppendThatBreaksDown(entry: LedgerEntry): void { describe('a projection that breaks down inside an append', () => { it('fails the append, which keeps nothing, the row it changed before included', async () => { - const breaking = tallyRowsOf(1, (row) => row['status'] === 'failed'); + const breaking = tallyRowsOf(2, (row) => row['status'] === 'failed'); const ledger = await aLedger(entry, undefined, undefined, [breaking]); - await noting(ledger, 'brain/acme/alpha/executions/r1', began); + await noting(ledger, 'brain/acme/alpha/runs/r1', began); - await expect(noting(ledger, 'brain/acme/alpha/executions/r1', { type: 'run_noted' }, ended)).rejects.toThrow( + await expect(noting(ledger, 'brain/acme/alpha/runs/r1', { type: 'run_noted' }, ended)).rejects.toThrow( breakingDown, ); expect( (await Effect.runPromise(ledger.readProjectedRows('run_tallies', alpha, everyRow))).map(({ row }) => row), ).toMatchObject([{ status: 'started', facts: 1 }]); - expect((await Effect.runPromise(ledger.load('brain/acme/alpha/executions/r1', runFacts))).version).toBe(1); + expect((await Effect.runPromise(ledger.load('brain/acme/alpha/runs/r1', runFacts))).version).toBe(1); }); }); } @@ -52,26 +52,26 @@ function aTableNotThereYet(entry: LedgerEntry): void { it('is made with its indexes and filled when the ledger opens, from every run stream, and earlier versions dropped', async () => { const database = await entry.aDatabase(); const first = await aLedger(entry, database, undefined, [runTallyRows]); - await noting(first, 'brain/acme/alpha/executions/r1', began, { type: 'run_noted' }); - await noting(first, 'brain/acme/alpha/executions/r2', ended); - await noting(first, 'brain/acme/alpha/executions/r3/nested', began); + await noting(first, 'brain/acme/alpha/runs/r1', began, { type: 'run_noted' }); + await noting(first, 'brain/acme/alpha/runs/r2', ended); + await noting(first, 'brain/acme/alpha/runs/r3/nested', began); - const next = await aLedger(entry, database, undefined, [tallyRowsOf(2)]); + const next = await aLedger(entry, database, undefined, [tallyRowsOf(3)]); expect(await Effect.runPromise(next.readProjectedRows('run_tallies', alpha, everyRow))).toMatchObject([ - { key: 'r1', row: { facts: 2, last_message: messageIdOf('brain/acme/alpha/executions/r1', 2) } }, + { key: 'r1', row: { facts: 2, last_message: messageIdOf('brain/acme/alpha/runs/r1', 2) } }, ]); - expect(await entry.queried(database, entry.projectionTables)).toEqual([{ name: 'run_tallies_2' }]); + expect(await entry.queried(database, entry.projectionTables)).toEqual([{ name: 'run_tallies_3' }]); expect(await entry.queried(database, entry.projectionIndexes)).toEqual([ - { name: 'run_tallies_2_by_brain_and_status' }, - { name: 'run_tallies_2_due' }, + { name: 'run_tallies_3_by_brain_and_status' }, + { name: 'run_tallies_3_due' }, ]); }); it('is left as it is by a ledger that finds it', async () => { const database = await entry.aDatabase(); - await noting(await aLedger(entry, database, undefined, [runTallyRows]), 'brain/acme/alpha/executions/r1', began); - await entry.queried(database, "DELETE FROM run_tallies_1 WHERE row_key = 'r1'"); + await noting(await aLedger(entry, database, undefined, [runTallyRows]), 'brain/acme/alpha/runs/r1', began); + await entry.queried(database, "DELETE FROM run_tallies_2 WHERE row_key = 'r1'"); const reopened = await aLedger(entry, database, undefined, [runTallyRows]); @@ -105,16 +105,17 @@ function notedMany(ledger: Awaited>, stream: string) function aKeyedTableNotThereYet(entry: LedgerEntry): void { describe('the table of a projection keyed by its mapping that is not there yet', () => { - it('is filled from the facts of every stream of its kinds in the order they were appended', async () => { + it('is filled from the facts of every stream of its kinds in the order they were appended, and earlier versions dropped', async () => { const database = await entry.aDatabase(); const first = await aLedger(entry, database); await topics(first, 'brain/acme/alpha/notes/w1', { type: 'topic_noted', topic: 'winter', note: 'never opened' }); - await topics(first, 'brain/acme/alpha/executions/r9', { type: 'topic_opened', topic: 'spring', at: 1000 }); + await topics(first, 'brain/acme/alpha/runs/r9', { type: 'topic_opened', topic: 'spring', at: 1000 }); await topics(first, 'brain/acme/alpha/notes/z9', { type: 'topic_noted', topic: 'spring', note: 'first' }); await topics(first, 'brain/acme/alpha/notes/a1', { type: 'topic_noted', topic: 'spring', note: 'second' }); await topics(first, 'brain/acme/alpha/others/o1', { type: 'topic_noted', topic: 'spring', note: 'other' }); - await topics(first, 'brain/acme/alpha/executions/r8', { type: 'topic_opened', topic: 'autumn', at: 2000 }); + await topics(first, 'brain/acme/alpha/runs/r8', { type: 'topic_opened', topic: 'autumn', at: 2000 }); await notedMany(first, 'brain/acme/alpha/notes/n1'); + await entry.queried(database, 'CREATE TABLE topics_1 (brain_key text, row_key text)'); const next = await aLedger(entry, database, undefined, [topicRows]); @@ -122,6 +123,7 @@ function aKeyedTableNotThereYet(entry: LedgerEntry): void { ['autumn', `note ${manyNotes - 1}`, true, 7000], ['spring', 'second', true, 6000], ]); + expect(await entry.queried(database, entry.topicTables)).toEqual([{ name: 'topics_2' }]); }); }); } diff --git a/packages/ledger/src/projections/projections-behaviour.ts b/packages/ledger/src/projections/projections-behaviour.ts index 00f7fbbac..864b65cfa 100644 --- a/packages/ledger/src/projections/projections-behaviour.ts +++ b/packages/ledger/src/projections/projections-behaviour.ts @@ -50,7 +50,7 @@ const twoRowsKept = [ facts: 1, open: true, due_at: nine + minute + tallyDueAfterMs, - last_message: messageIdOf('brain/acme/alpha/executions/r2', 1), + last_message: messageIdOf('brain/acme/alpha/runs/r2', 1), }, }, { @@ -64,7 +64,7 @@ const twoRowsKept = [ facts: 3, open: false, due_at: null, - last_message: messageIdOf('brain/acme/alpha/executions/r1', 3), + last_message: messageIdOf('brain/acme/alpha/runs/r1', 3), }, }, ]; @@ -73,18 +73,18 @@ function rowsKept(open: ProjectingLedger): void { describe('a projection of runs', () => { it('keeps one row a run, the id of the message it last took among them, and its values as they were', async () => { const ledger = await open([runTallyRows]); - await noting(ledger, 'brain/acme/alpha/executions/r1', began('triage'), { type: 'run_noted' }); - await noting(ledger, 'brain/acme/alpha/executions/r1', ended); - await noting(ledger, 'brain/acme/alpha/executions/r2', began('review', 1)); - await noting(ledger, 'brain/acme/alpha/runs/r3', began('ignored')); - await noting(ledger, 'brain/acme/alpha/executions', began('ignored')); + await noting(ledger, 'brain/acme/alpha/runs/r1', began('triage'), { type: 'run_noted' }); + await noting(ledger, 'brain/acme/alpha/runs/r1', ended); + await noting(ledger, 'brain/acme/alpha/runs/r2', began('review', 1)); + await noting(ledger, 'brain/acme/alpha/run-logs/r3', began('ignored')); + await noting(ledger, 'brain/acme/alpha/runs', began('ignored')); expect(await Effect.runPromise(ledger.readProjectedRows('run_tallies', alpha, newestFirst))).toEqual(twoRowsKept); }); it('answers nothing of a projection it does not keep', async () => { const ledger = await open([]); - await noting(ledger, 'brain/acme/alpha/executions/r1', began('triage')); + await noting(ledger, 'brain/acme/alpha/runs/r1', began('triage')); expect(await runsOf(ledger)).toEqual([]); expect(await Effect.runPromise(ledger.countProjectedRows('run_tallies', alpha, []))).toBe(0); @@ -100,10 +100,10 @@ function rowsOfABrain(open: ProjectingLedger): void { describe('a read of the rows of a brain', () => { it('reads by columns, in order either way, from where a page ended, and counts them', async () => { const ledger = await open([runTallyRows]); - await noting(ledger, 'brain/acme/alpha/executions/r1', began('triage', 0)); - await noting(ledger, 'brain/acme/alpha/executions/r2', began('triage', 1), ended); - await noting(ledger, 'brain/acme/alpha/executions/r3', began('review', 2)); - await noting(ledger, 'brain/acme/beta/executions/r4', began('triage', 3)); + await noting(ledger, 'brain/acme/alpha/runs/r1', began('triage', 0)); + await noting(ledger, 'brain/acme/alpha/runs/r2', began('triage', 1), ended); + await noting(ledger, 'brain/acme/alpha/runs/r3', began('review', 2)); + await noting(ledger, 'brain/acme/beta/runs/r4', began('triage', 3)); const stillOpen = { column: 'open', equals: true }; expect(await runsOf(ledger)).toEqual(['r3', 'r2', 'r1']); @@ -121,8 +121,8 @@ function rowsOfABrain(open: ProjectingLedger): void { it('orders a column that is not set before every value that is', async () => { const ledger = await open([runTallyRows]); - await noting(ledger, 'brain/acme/alpha/executions/r1', began('triage', 0)); - await noting(ledger, 'brain/acme/alpha/executions/r2', began('triage', 1), ended); + await noting(ledger, 'brain/acme/alpha/runs/r1', began('triage', 0)); + await noting(ledger, 'brain/acme/alpha/runs/r2', began('triage', 1), ended); const byDue = { where: [], orderBy: ['due_at'], limit: 10 }; expect(await runsOf(ledger, { ...byDue, order: 'asc' })).toEqual(['r2', 'r1']); @@ -135,9 +135,9 @@ function dueRows(open: ProjectingLedger): void { describe('a read of the rows due by a time', () => { it('reads the rows of every brain whose time is set and has come, the soonest first, and the next time', async () => { const ledger = await open([runTallyRows]); - await noting(ledger, 'brain/acme/alpha/executions/r1', began('triage', 2)); - await noting(ledger, 'brain/globex/gamma/executions/r2', began('triage', 0)); - await noting(ledger, 'brain/acme/beta/executions/r3', began('triage', 1), ended); + await noting(ledger, 'brain/acme/alpha/runs/r1', began('triage', 2)); + await noting(ledger, 'brain/globex/gamma/runs/r2', began('triage', 0)); + await noting(ledger, 'brain/acme/beta/runs/r3', began('triage', 1), ended); const due = async (through: number, limit = 10) => (await Effect.runPromise(ledger.readDueRows('run_tallies', { column: 'due_at', through, limit }))).map( ({ org, brain, key }) => `${org}/${brain}/${key}`, @@ -169,7 +169,7 @@ function rowsKeyedByTheirMapping(open: ProjectingLedger): void { describe('a projection keyed by what its mapping says, over the stream kinds it names', () => { it('keeps one row a key from every stream of its kinds, and nothing of another kind', async () => { const ledger = await open([topicRows]); - await topics(ledger, 'brain/acme/alpha/executions/r1', { type: 'topic_opened', topic: 'spring', at: nine }); + await topics(ledger, 'brain/acme/alpha/runs/r1', { type: 'topic_opened', topic: 'spring', at: nine }); await topics(ledger, 'brain/acme/alpha/notes/n1', { type: 'topic_noted', topic: 'spring', note: 'first' }); await topics(ledger, 'brain/acme/alpha/notes/n2', { type: 'topic_noted', topic: 'autumn', note: 'none' }); await topics(ledger, 'brain/acme/alpha/others/o1', { type: 'topic_noted', topic: 'spring', note: 'other' }); @@ -197,13 +197,13 @@ function rowsAdvanced(open: ProjectingLedger): void { describe('the advance of a row of a projection keyed by its mapping', () => { it('lets its reader advance the columns it declares, which a fold leaves as they stand unless it sets them', async () => { const ledger = await open([topicRows]); - await topics(ledger, 'brain/acme/alpha/executions/r1', { type: 'topic_opened', topic: 'spring', at: nine }); + await topics(ledger, 'brain/acme/alpha/runs/r1', { type: 'topic_opened', topic: 'spring', at: nine }); await Effect.runPromise( ledger.advanceRow('topics', alpha, 'spring', { set: { open: false, next_at: null, due_at: null }, when: [] }), ); await topics(ledger, 'brain/acme/alpha/notes/n1', { type: 'topic_noted', topic: 'spring', note: 'after' }); const advanced = await topicsOf(ledger); - await topics(ledger, 'brain/acme/alpha/executions/r2', { + await topics(ledger, 'brain/acme/alpha/runs/r2', { type: 'topic_opened', topic: 'spring', at: nine + minute, @@ -224,7 +224,7 @@ function rowsAdvanced(open: ProjectingLedger): void { it('refuses to advance a column the projection does not declare, and advances no row that is not there', async () => { const ledger = await open([topicRows]); - await topics(ledger, 'brain/acme/alpha/executions/r1', { type: 'topic_opened', topic: 'spring', at: nine }); + await topics(ledger, 'brain/acme/alpha/runs/r1', { type: 'topic_opened', topic: 'spring', at: nine }); const refused = await Effect.runPromiseExit( ledger.advanceRow('topics', alpha, 'spring', { set: { note: 'advanced' }, when: [] }), @@ -244,8 +244,8 @@ function rowsAdvancedWhileUnchanged(open: ProjectingLedger): void { describe('the advance of a row that its fold changed since its reader read it', () => { it('advances a row only while the columns it is told to compare still hold what its reader read', async () => { const ledger = await open([topicRows]); - await topics(ledger, 'brain/acme/alpha/executions/r1', { type: 'topic_opened', topic: 'spring', at: nine }); - const readAt = messageIdOf('brain/acme/alpha/executions/r1', 1); + await topics(ledger, 'brain/acme/alpha/runs/r1', { type: 'topic_opened', topic: 'spring', at: nine }); + const readAt = messageIdOf('brain/acme/alpha/runs/r1', 1); await topics(ledger, 'brain/acme/alpha/notes/n1', { type: 'topic_noted', topic: 'spring', note: 'meanwhile' }); const folded = messageIdOf('brain/acme/alpha/notes/n1', 1); diff --git a/packages/ledger/src/recorded/recorded-statements.ts b/packages/ledger/src/recorded/recorded-statements.ts index 1ecf78e64..58a934796 100644 --- a/packages/ledger/src/recorded/recorded-statements.ts +++ b/packages/ledger/src/recorded/recorded-statements.ts @@ -48,10 +48,10 @@ export interface RecordedStatements { readonly dataAt: (points: readonly RecordedPoint[]) => Promise>; } -export type RunsSelected = Extract; +export type RunsSelected = Extract; export interface FieldAsked { - readonly field: 'primitive' | 'name'; + readonly field: 'definition_type' | 'name'; readonly value: string; readonly asWritten: string; } @@ -60,8 +60,8 @@ function fieldAsked(field: FieldAsked['field'], value: string | undefined): read return value === undefined ? [] : [{ field, value, asWritten: `"${field}":${JSON.stringify(value)}` }]; } -export function fieldsAskedOf({ name, primitive }: RunsSelected): readonly FieldAsked[] { - return [...fieldAsked('name', name), ...fieldAsked('primitive', primitive)]; +export function fieldsAskedOf({ name, definitionType }: RunsSelected): readonly FieldAsked[] { + return [...fieldAsked('name', name), ...fieldAsked('definition_type', definitionType)]; } export type RecordsSelected = @@ -74,7 +74,7 @@ export function pointKey(point: RecordedPoint): string { } function asksForOneDefinition(selection: RecordedSelection): boolean { - return selection.kind === 'executions' && fieldsAskedOf(selection).length > 0; + return selection.kind === 'runs' && fieldsAskedOf(selection).length > 0; } function scopeOf( @@ -97,12 +97,12 @@ function scopeOf( function selectedOf( brainKey: string, - selection: Exclude, + selection: Exclude, ): RecordsSelected { if (selection.kind === 'run') { return { kind: 'streams', - streams: [`${brainKey}executions/${selection.execution}`, `${brainKey}runs/${selection.execution}`], + streams: [`${brainKey}runs/${selection.run}`, `${brainKey}run-logs/${selection.run}`], }; } return selection.kind === 'correlated' ? selection : { kind: 'brain' }; @@ -113,7 +113,7 @@ function examine( selection: RecordedSelection, scope: ExaminationScope, ): Promise { - if (selection.kind === 'executions') { + if (selection.kind === 'runs') { return statements.examineRuns(scope, selection); } return statements.examineRecords(selectedOf(scope.brainKey, selection), scope); diff --git a/packages/ledger/src/recorded/sqlite-indexes.ts b/packages/ledger/src/recorded/sqlite-indexes.ts index 2498feafb..a37ac53f3 100644 --- a/packages/ledger/src/recorded/sqlite-indexes.ts +++ b/packages/ledger/src/recorded/sqlite-indexes.ts @@ -17,8 +17,10 @@ export const brainKeyOfStream = SQL.plain(brainKeyText); export const kindKeyOfStream = SQL.plain(prefixThroughSlashes(4)); +const definitionsKind = 'definitions/'; + export const definitionTypeOfStream = SQL.plain( - `CASE WHEN substr(stream_id, length(${brainKeyText}) + 1, 6) = 'specs/' THEN substr(stream_id, length(${brainKeyText}) + 7) END`, + `CASE WHEN substr(stream_id, length(${brainKeyText}) + 1, ${definitionsKind.length}) = '${definitionsKind}' THEN substr(stream_id, length(${brainKeyText}) + ${definitionsKind.length + 1}) END`, ); export const correlationOfMessage = SQL.plain("json_extract(message_metadata, '$.correlationId')"); diff --git a/packages/ledger/src/recorded/sqlite-recorded.ts b/packages/ledger/src/recorded/sqlite-recorded.ts index 70c72c0e4..f9c1839ce 100644 --- a/packages/ledger/src/recorded/sqlite-recorded.ts +++ b/packages/ledger/src/recorded/sqlite-recorded.ts @@ -180,9 +180,11 @@ function notOfTypes(column: string, types: readonly string[]): SQL { return types.length === 0 ? SQL`` : SQL` AND NOT ${ofTypes(column, types)}`; } -function ofTheDefinitionAsked({ primitive, name }: RunsSelected): SQL { +function ofTheDefinitionAsked({ definitionType, name }: RunsSelected): SQL { const asked: SQL[] = [ - ...(primitive === undefined ? [] : [SQL`json_extract(message_data, '$.primitive') IS ${primitive}`]), + ...(definitionType === undefined + ? [] + : [SQL`json_extract(message_data, '$.definition_type') IS ${definitionType}`]), ...(name === undefined ? [] : [SQL`json_extract(message_data, '$.name') IS ${name}`]), ]; return asked.length === 0 ? SQL`1` : SQL.merge(asked, ' AND '); @@ -198,7 +200,7 @@ function firstMessagesOfRuns(scope: ExaminationScope, runs: RunsSelected): SQL { ${correlationOfMessage} AS correlation, octet_length(message_data) AS size, ${ofTheDefinitionAsked(runs)} AS of_the_definition FROM emt_messages - WHERE ${kindKeyOfStream} = ${`${scope.brainKey}executions/`} AND stream_position = 1 + WHERE ${kindKeyOfStream} = ${`${scope.brainKey}runs/`} AND stream_position = 1 AND partition = ${defaultPartition} AND is_archived = FALSE${bounds(scope)}${notOfTypes('message_type', runs.notBeginningWith ?? [])} ORDER BY global_position ${direction(scope)} LIMIT ${scope.examineAtMost + 1} diff --git a/packages/ledger/src/run-filters/run-filters-behaviour.ts b/packages/ledger/src/run-filters/run-filters-behaviour.ts index a29024798..59ad612b9 100644 --- a/packages/ledger/src/run-filters/run-filters-behaviour.ts +++ b/packages/ledger/src/run-filters/run-filters-behaviour.ts @@ -6,7 +6,7 @@ import { details, inAlpha, reading, type AnyLedger, type LedgerMaker } from '../ const RunFactSchema = Schema.Struct({ type: Schema.String, - primitive: Schema.optionalKey(Schema.String), + definition_type: Schema.optionalKey(Schema.String), name: Schema.optionalKey(Schema.String), detail: Schema.optionalKey(Schema.Json), }); @@ -24,18 +24,18 @@ const runsOfTheReport = Array.from({ length: 28 }, (_, index) => index); function startOfTheReport(index: number): RunFact { return { - type: 'execution_started', - primitive: index % 4 === 0 ? 'orchestration' : 'inference', + type: 'run_started', + definition_type: index % 4 === 0 ? 'workflow' : 'reasoning', name: index % 4 === 1 ? 'qualify-enquiry' : 'summary', }; } function endingOfTheReport(index: number): RunFact { - return { type: index % 9 === 2 ? 'execution_rejected' : 'execution_succeeded' }; + return { type: index % 9 === 2 ? 'run_rejected' : 'run_succeeded' }; } function recordedRun(ledger: AnyLedger, run: string, facts: readonly RunFact[]): Effect.Effect { - return ledger.execute(inAlpha(`executions/${run}`), runFacts, facts); + return ledger.execute(inAlpha(`runs/${run}`), runFacts, facts); } async function twentyEightRuns(aLedger: LedgerMaker): Promise { @@ -53,7 +53,7 @@ async function twentyEightRuns(aLedger: LedgerMaker): Promise { type RunsOnAPage = readonly [runs: readonly string[], hasMore: boolean]; function runsOn({ records, hasMore }: RecordedPage): RunsOnAPage { - const runsListed = new Set(records.map(({ stream }) => stream.slice(inAlpha('executions/').length))); + const runsListed = new Set(records.map(({ stream }) => stream.slice(inAlpha('runs/').length))); return [[...runsListed], hasMore]; } @@ -68,16 +68,16 @@ async function everyPageOfRuns( : [runsOn(read), ...(await everyPageOfRuns(ledger, selection, { ...page, cursor: read.nextCursor }))]; } -const runsOfOrchestration: RecordedSelection = { kind: 'executions', primitive: 'orchestration' }; +const runsOfWorkflows: RecordedSelection = { kind: 'runs', definitionType: 'workflow' }; -function theRunsOfOnePrimitive(aLedger: LedgerMaker): void { +function theRunsOfOneType(aLedger: LedgerMaker): void { it( - 'fill every page with the runs of the primitive asked for, five then two, and have no more after the last', + 'fill every page with the runs of the definition type asked for, five then two, and have no more after the last', { timeout: 60_000 }, async () => { const ledger = await twentyEightRuns(aLedger); - const pages = await everyPageOfRuns(ledger, runsOfOrchestration, { order: 'desc', limit: 5 }); + const pages = await everyPageOfRuns(ledger, runsOfWorkflows, { order: 'desc', limit: 5 }); expect(pages).toEqual([ [['run-24', 'run-20', 'run-16', 'run-12', 'run-8'], true], @@ -91,11 +91,7 @@ function theRunsOfOneName(aLedger: LedgerMaker): void { it('page through the seven runs of the name asked for, two at a time', { timeout: 60_000 }, async () => { const ledger = await twentyEightRuns(aLedger); - const pages = await everyPageOfRuns( - ledger, - { kind: 'executions', name: 'qualify-enquiry' }, - { order: 'desc', limit: 2 }, - ); + const pages = await everyPageOfRuns(ledger, { kind: 'runs', name: 'qualify-enquiry' }, { order: 'desc', limit: 2 }); expect(pages).toEqual([ [['run-25', 'run-21'], true], @@ -112,11 +108,11 @@ function theRejectedRuns(aLedger: LedgerMaker): void { { timeout: 60_000 }, async () => { const ledger = await twentyEightRuns(aLedger); - const rejectedOneAtATime = { order: 'desc', limit: 1, types: ['execution_rejected'] } as const; + const rejectedOneAtATime = { order: 'desc', limit: 1, types: ['run_rejected'] } as const; const pages = await Promise.all([ - everyPageOfRuns(ledger, { kind: 'executions' }, rejectedOneAtATime), - everyPageOfRuns(ledger, runsOfOrchestration, rejectedOneAtATime), + everyPageOfRuns(ledger, { kind: 'runs' }, rejectedOneAtATime), + everyPageOfRuns(ledger, runsOfWorkflows, rejectedOneAtATime), ]); expect(pages).toEqual([ @@ -143,19 +139,19 @@ const awkwardText = { function theDefinitionOfAnAwkwardRun(aLedger: LedgerMaker): void { it('is read at the top of the first message alone, exactly as recorded, whatever else the message holds', async () => { const ledger = await aLedger(); - const awkward = { primitive: 'orchestration', name: 'qualify-enquiry', text: awkwardText }; - const start = { type: 'execution_started', primitive: 'inference', name: 'summary', detail: awkward }; + const awkward = { definition_type: 'workflow', name: 'qualify-enquiry', text: awkwardText }; + const start = { type: 'run_started', definition_type: 'reasoning', name: 'summary', detail: awkward }; const namedAwkwardly = { ...start, name: awkwardText.escapedNul, detail: 'named awkwardly' }; await Effect.runPromise(recordedRun(ledger, 'awkward', [start])); await Effect.runPromise(recordedRun(ledger, 'named-awkwardly', [namedAwkwardly])); const newest = { order: 'desc', limit: 10 } as const; const pages = await Promise.all([ - reading(ledger, { kind: 'executions', primitive: 'inference', name: 'summary' }, newest), - reading(ledger, runsOfOrchestration, newest), - reading(ledger, { kind: 'executions', name: 'qualify-enquiry' }, newest), - reading(ledger, { kind: 'executions', name: awkwardText.escapedNul }, newest), - reading(ledger, { kind: 'executions', name: 'a' }, newest), + reading(ledger, { kind: 'runs', definitionType: 'reasoning', name: 'summary' }, newest), + reading(ledger, runsOfWorkflows, newest), + reading(ledger, { kind: 'runs', name: 'qualify-enquiry' }, newest), + reading(ledger, { kind: 'runs', name: awkwardText.escapedNul }, newest), + reading(ledger, { kind: 'runs', name: 'a' }, newest), ]); expect(pages.map((page) => details(page))).toEqual([[awkward], [], [], ['named awkwardly'], []]); @@ -164,7 +160,7 @@ function theDefinitionOfAnAwkwardRun(aLedger: LedgerMaker): void { export function runFiltersBehaviour(aLedger: LedgerMaker): void { describe('the runs of one definition', () => { - theRunsOfOnePrimitive(aLedger); + theRunsOfOneType(aLedger); theRunsOfOneName(aLedger); theRejectedRuns(aLedger); theDefinitionOfAnAwkwardRun(aLedger); diff --git a/packages/ledger/src/signal/append-signal.test.ts b/packages/ledger/src/signal/append-signal.test.ts index f00211239..aea8abacc 100644 --- a/packages/ledger/src/signal/append-signal.test.ts +++ b/packages/ledger/src/signal/append-signal.test.ts @@ -35,9 +35,9 @@ describe('the signal an append raises', () => { await store.append('brain/acme/alpha/events/e1', one, 0); await store.append('org/acme/brains', one, 0); - await store.append('brain/acme/Beta_2/runs/r1', one, 0); + await store.append('brain/acme/Beta_2/run-logs/r1', one, 0); - expect(heard).toEqual(['brain/acme/alpha/events/e1', 'org/acme/brains', 'brain/acme/Beta_2/runs/r1']); + expect(heard).toEqual(['brain/acme/alpha/events/e1', 'org/acme/brains', 'brain/acme/Beta_2/run-logs/r1']); }); it('is not raised by an append that met a version conflict, nor heard once a listener has stopped', async () => { diff --git a/packages/ledger/src/testing/events-behaviour.ts b/packages/ledger/src/testing/events-behaviour.ts index 25f8841e7..2cd6cebbd 100644 --- a/packages/ledger/src/testing/events-behaviour.ts +++ b/packages/ledger/src/testing/events-behaviour.ts @@ -84,9 +84,9 @@ const names = [ ' org/acme/brains', 'org/acme-x', 'org/acme-y', - 'brain/acme/sales/specs/inference', - 'brain/acme/sales-specs/inference', - 'brain/acme/sales_specs/inference', + 'brain/acme/sales/definitions/reasoning', + 'brain/acme/sales-definitions/reasoning', + 'brain/acme/sales_definitions/reasoning', 'org/café/brains'.normalize('NFC'), 'org/café/brains'.normalize('NFD'), ]; diff --git a/packages/ledger/src/testing/ledger-entry.ts b/packages/ledger/src/testing/ledger-entry.ts index 1981431e1..c9ec38a04 100644 --- a/packages/ledger/src/testing/ledger-entry.ts +++ b/packages/ledger/src/testing/ledger-entry.ts @@ -22,6 +22,7 @@ export interface LedgerEntry { readonly outcomeTables: string; readonly projectionTables: string; readonly projectionIndexes: string; + readonly topicTables: string; } export async function aLedger( diff --git a/packages/ledger/src/testing/recorded-behaviour.ts b/packages/ledger/src/testing/recorded-behaviour.ts index 8fb32d992..1e38c97c6 100644 --- a/packages/ledger/src/testing/recorded-behaviour.ts +++ b/packages/ledger/src/testing/recorded-behaviour.ts @@ -194,10 +194,10 @@ function theLastRecordExamined(aLedger: LedgerMaker): void { it('is the record a page that is cut short reads on after', async () => { const ledger = await aLedger(); await happen(ledger, inAlpha('notes'), noted('noted', 1), noted('noted', 2), noted('noted', 3)); - await happen(ledger, inAlpha('executions/run-1'), noted('execution_started')); + await happen(ledger, inAlpha('runs/run-1'), noted('run_started')); const cut = await reading(ledger, everything, { order: 'asc', limit: 2 }); - const runs = await reading(ledger, { kind: 'executions' }, { order: 'asc', limit: 10 }); + const runs = await reading(ledger, { kind: 'runs' }, { order: 'asc', limit: 10 }); expect([cut.lastExamined?.cursor, runs.lastExamined?.cursor]).toEqual([cut.nextCursor, runs.records[0]?.cursor]); }); diff --git a/packages/ledger/src/testing/runs-behaviour.ts b/packages/ledger/src/testing/runs-behaviour.ts index e6493a2e4..1094108ad 100644 --- a/packages/ledger/src/testing/runs-behaviour.ts +++ b/packages/ledger/src/testing/runs-behaviour.ts @@ -14,7 +14,7 @@ import { type LedgerMaker, } from './happenings.ts'; -const runs: RecordedSelection = { kind: 'executions' }; +const runs: RecordedSelection = { kind: 'runs' }; function theRunsAPageExamines(aLedger: LedgerMaker): void { describe('the runs a page of runs examines', () => { @@ -23,26 +23,23 @@ function theRunsAPageExamines(aLedger: LedgerMaker): void { { timeout: 60_000 }, async () => { const ledger = await aLedger(); - await happen(ledger, inAlpha('executions/oldest'), noted('execution_started'), noted('execution_failed')); + await happen(ledger, inAlpha('runs/oldest'), noted('run_started'), noted('run_failed')); await Effect.runPromise( Effect.forEach( Array.from({ length: 1000 }, (_, index) => index), - (index) => ledger.execute(inAlpha(`executions/run-${index}`), happenings, [noted('execution_started')]), + (index) => ledger.execute(inAlpha(`runs/run-${index}`), happenings, [noted('run_started')]), { concurrency: 8, discard: true }, ), ); - const page = { order: 'desc', limit: 20, types: ['execution_failed'] } as const; + const page = { order: 'desc', limit: 20, types: ['run_failed'] } as const; const first = await reading(ledger, runs, page); const rest = await reading(ledger, runs, { ...page, cursor: String(first.nextCursor) }); expect([first.records.length, first.hasMore, streamsAndTypes(rest), rest.nextCursor]).toEqual([ 0, true, - [ - 'brain/acme/alpha/executions/oldest execution_started', - 'brain/acme/alpha/executions/oldest execution_failed', - ], + ['brain/acme/alpha/runs/oldest run_started', 'brain/acme/alpha/runs/oldest run_failed'], null, ]); }, @@ -52,41 +49,41 @@ function theRunsAPageExamines(aLedger: LedgerMaker): void { function theStreamsOfOneRun(aLedger: LedgerMaker): void { describe('the read of one run', () => { - it('gives the messages of its execution stream and its run log, in order, and nothing else', async () => { + it('gives the messages of its run stream and its run log, in order, and nothing else', async () => { const ledger = await aLedger(); - await happen(ledger, inAlpha('executions/r1'), noted('execution_started')); - await happen(ledger, inAlpha('runs/r1'), noted('input_applied')); - await happen(ledger, inAlpha('executions/r10'), noted('execution_started')); - await happen(ledger, inAlpha('runs/r1x'), noted('input_applied')); - await happen(ledger, 'brain/acme/beta/executions/r1', noted('execution_started')); - await happen(ledger, inAlpha('executions/r1'), noted('execution_succeeded')); - - const page = await reading(ledger, { kind: 'run', execution: 'r1' }, { order: 'asc', limit: 10 }); + await happen(ledger, inAlpha('runs/r1'), noted('run_started')); + await happen(ledger, inAlpha('run-logs/r1'), noted('input_applied')); + await happen(ledger, inAlpha('runs/r10'), noted('run_started')); + await happen(ledger, inAlpha('run-logs/r1x'), noted('input_applied')); + await happen(ledger, 'brain/acme/beta/runs/r1', noted('run_started')); + await happen(ledger, inAlpha('runs/r1'), noted('run_succeeded')); + + const page = await reading(ledger, { kind: 'run', run: 'r1' }, { order: 'asc', limit: 10 }); const succeeded = await reading( ledger, - { kind: 'run', execution: 'r1' }, - { order: 'desc', limit: 10, types: ['execution_succeeded'] }, + { kind: 'run', run: 'r1' }, + { order: 'desc', limit: 10, types: ['run_succeeded'] }, ); - expect(streamsAndTypes(succeeded)).toEqual(['brain/acme/alpha/executions/r1 execution_succeeded']); + expect(streamsAndTypes(succeeded)).toEqual(['brain/acme/alpha/runs/r1 run_succeeded']); expect(streamsAndTypes(page)).toEqual([ - 'brain/acme/alpha/executions/r1 execution_started', - 'brain/acme/alpha/runs/r1 input_applied', - 'brain/acme/alpha/executions/r1 execution_succeeded', + 'brain/acme/alpha/runs/r1 run_started', + 'brain/acme/alpha/run-logs/r1 input_applied', + 'brain/acme/alpha/runs/r1 run_succeeded', ]); }); }); } async function threeRuns(ledger: AnyLedger): Promise { - await happen(ledger, inAlpha('executions/r1'), noted('execution_started', 'r1')); - await happen(ledger, inAlpha('runs/r1'), noted('input_applied')); - await happen(ledger, inAlpha('executions/r2'), noted('execution_started', 'r2')); - await happen(ledger, inAlpha('executions/r3'), noted('execution_started', 'r3')); + await happen(ledger, inAlpha('runs/r1'), noted('run_started', 'r1')); + await happen(ledger, inAlpha('run-logs/r1'), noted('input_applied')); + await happen(ledger, inAlpha('runs/r2'), noted('run_started', 'r2')); + await happen(ledger, inAlpha('runs/r3'), noted('run_started', 'r3')); await happen(ledger, inAlpha('notes'), noted('noted')); - await happen(ledger, inAlpha('executions/r1'), noted('execution_succeeded', 'r1 done')); - await happen(ledger, inAlpha('executions/r3'), noted('execution_failed', 'r3 done')); - await happen(ledger, 'brain/acme/alpha2/executions/r4', noted('execution_started', 'r4')); + await happen(ledger, inAlpha('runs/r1'), noted('run_succeeded', 'r1 done')); + await happen(ledger, inAlpha('runs/r3'), noted('run_failed', 'r3 done')); + await happen(ledger, 'brain/acme/alpha2/runs/r4', noted('run_started', 'r4')); } function theRunsOfABrain(aLedger: LedgerMaker): void { @@ -111,8 +108,8 @@ function theRunsOfABrain(aLedger: LedgerMaker): void { await threeRuns(ledger); const pages = await Promise.all( - ([['execution_failed'], ['execution_started'], ['execution_succeeded', 'execution_failed']] as const).map( - (types) => reading(ledger, runs, { order: 'desc', limit: 10, types }), + ([['run_failed'], ['run_started'], ['run_succeeded', 'run_failed']] as const).map((types) => + reading(ledger, runs, { order: 'desc', limit: 10, types }), ), ); @@ -125,28 +122,25 @@ function theRunsOfABrain(aLedger: LedgerMaker): void { }); } -const runsOnly: RecordedSelection = { kind: 'executions', notBeginningWith: ['execution_cancel_requested'] }; +const runsOnly: RecordedSelection = { kind: 'runs', notBeginningWith: ['run_cancel_requested'] }; const newestHundred = { order: 'desc', limit: 100 } as const; function streamsBeginningWith(ledger: AnyLedger, firstTypes: readonly string[]): Promise { return Effect.runPromise( - Effect.forEach( - firstTypes, - (type, index) => ledger.execute(inAlpha(`executions/s${index}`), happenings, [noted(type)]), - { concurrency: 8, discard: true }, - ), + Effect.forEach(firstTypes, (type, index) => ledger.execute(inAlpha(`runs/s${index}`), happenings, [noted(type)]), { + concurrency: 8, + discard: true, + }), ); } function runsWithOneLeftOutEvery(every: number, count: number): readonly string[] { - return Array.from({ length: count }, (_, index) => - index % every === 0 ? 'execution_cancel_requested' : 'execution_started', - ); + return Array.from({ length: count }, (_, index) => (index % every === 0 ? 'run_cancel_requested' : 'run_started')); } function pageFigures({ records, hasMore }: Awaited>): readonly [number, boolean, boolean] { - return [records.length, hasMore, records.some(({ type }) => type === 'execution_cancel_requested')]; + return [records.length, hasMore, records.some(({ type }) => type === 'run_cancel_requested')]; } function theRunsLeavingOutSomeStreams(aLedger: LedgerMaker): void { @@ -162,7 +156,7 @@ function theRunsLeavingOutSomeStreams(aLedger: LedgerMaker): void { it('has no more after the last run when the oldest stream is left out', { timeout: 60_000 }, async () => { const ledger = await aLedger(); - await happen(ledger, inAlpha('executions/oldest'), noted('execution_cancel_requested')); + await happen(ledger, inAlpha('runs/oldest'), noted('run_cancel_requested')); await streamsBeginningWith(ledger, runsWithOneLeftOutEvery(101, 101).slice(1)); const pages = await Promise.all([reading(ledger, runsOnly, newestHundred), reading(ledger, runs, newestHundred)]); @@ -181,13 +175,13 @@ function aRunHoldingU0000(aLedger: LedgerMaker): void { describe('a run whose input and output hold U+0000', () => { it('is listed, filtered and read, its data exactly as it was decided', async () => { const ledger = await aLedger(); - await happen(ledger, inAlpha('executions/r1'), noted('execution_started', { input: withNul })); - await happen(ledger, inAlpha('executions/r1'), noted('execution_succeeded', { output: withNul })); + await happen(ledger, inAlpha('runs/r1'), noted('run_started', { input: withNul })); + await happen(ledger, inAlpha('runs/r1'), noted('run_succeeded', { output: withNul })); const pages = await Promise.all([ reading(ledger, runs, { order: 'desc', limit: 10 }), - reading(ledger, runs, { order: 'desc', limit: 10, types: ['execution_succeeded'] }), - reading(ledger, { kind: 'run', execution: 'r1' }, { order: 'asc', limit: 10 }), + reading(ledger, runs, { order: 'desc', limit: 10, types: ['run_succeeded'] }), + reading(ledger, { kind: 'run', run: 'r1' }, { order: 'asc', limit: 10 }), ]); const decided = [{ input: withNul }, { output: withNul }]; diff --git a/packages/ledger/src/testing/store-behaviour.ts b/packages/ledger/src/testing/store-behaviour.ts index a31475860..353c379c3 100644 --- a/packages/ledger/src/testing/store-behaviour.ts +++ b/packages/ledger/src/testing/store-behaviour.ts @@ -84,7 +84,7 @@ function readingWhatWasAppended(entry: LedgerEntry): void { const before = await store.readAppended(undefined, 100); await store.append('brain/acme/alpha/events/e1', numbered(1, 1), 0); await store.append('brain/acme/alpha/events/e2', numbered(1, 2), 0); - await store.append('brain/acme/alpha/runs/r1', numbered(1, 1), 0); + await store.append('brain/acme/alpha/run-logs/r1', numbered(1, 1), 0); await store.append('org/acme/brains', numbered(2, 1), 1); await readable(); @@ -93,7 +93,7 @@ function readingWhatWasAppended(entry: LedgerEntry): void { expect(appended.streams.toSorted()).toEqual([ 'brain/acme/alpha/events/', - 'brain/acme/alpha/runs/', + 'brain/acme/alpha/run-logs/', 'org/acme/brains', ]); expect(appended.more).toBe(false); diff --git a/packages/mcp/README.md b/packages/mcp/README.md index b3cfc414a..beb274313 100644 --- a/packages/mcp/README.md +++ b/packages/mcp/README.md @@ -4,8 +4,8 @@ How a brain reaches the outside world: the MCP servers the operator configures, ## Entry points -- `@beonauto/mcp`: the settings, `makeToolAccess`, `defineListToolServers`, `defineListToolServersInOrg`, `defineTestToolCall` and `toolTestPresenter`, and the facts of a call that `@beonauto/specs` builds the run's events from. The access loads the MCP client, its OAuth providers and `node:child_process` (`src/access/linked-access.ts`) through a dynamic import when a run first opens its tools, a listing first asks a server or a test first calls a tool, so a server that never does any of them never loads them: the server's start loaded 55 files and 0.94 MiB more with them (measured 2026-10-06 with a module load hook, 1,241 files against main's 1,186). Without them it loads 34 files and 77 KiB of this package, the operations `list_tool_servers`, at the brain and at the org, and `test_tool_call` among them (measured 2026-10-08 with a module load hook over both entries, against 33 files and 71 KiB before the org's listing, and 20 files and 42 KiB before `test_tool_call`). -- `@beonauto/mcp/policy`: the pure helpers a caller needs without connecting to anything (`toolReferenceOf`, `toolReferenceShape`, `writtenOf`, `runBoundMs` and `executionIdKey`). It loads no transport code, so the reasoning function adapter, which parses a function's `tools` and bounds its run, does not load the MCP client wherever it is bundled; it takes `ToolAccess` as a type only. +- `@beonauto/mcp`: the settings, `makeToolAccess`, `defineListToolServers`, `defineListToolServersInOrg`, `defineTestToolCall` and `toolTestPresenter`, and the facts of a call that `@beonauto/definitions` builds the run's events from. The access loads the MCP client, its OAuth providers and `node:child_process` (`src/access/linked-access.ts`) through a dynamic import when a run first opens its tools, a listing first asks a server or a test first calls a tool, so a server that never does any of them never loads them: the server's start loaded 55 files and 0.94 MiB more with them (measured 2026-10-06 with a module load hook, 1,241 files against main's 1,186). Without them it loads 34 files and 77 KiB of this package, the operations `list_tool_servers`, at the brain and at the org, and `test_tool_call` among them (measured 2026-10-08 with a module load hook over both entries, against 33 files and 71 KiB before the org's listing, and 20 files and 42 KiB before `test_tool_call`). +- `@beonauto/mcp/policy`: the pure helpers a caller needs without connecting to anything (`toolReferenceOf`, `toolReferenceShape`, `writtenOf`, `runBoundMs` and `runIdKey`). It loads no transport code, so the reasoning function adapter, which parses a function's `tools` and bounds its run, does not load the MCP client wherever it is bundled; it takes `ToolAccess` as a type only. - `@beonauto/mcp/testing`: the fake server and the helpers of the tests (see Testing). ## Settings @@ -41,9 +41,9 @@ Nothing is learned from a server when the settings are read: the server starts w `makeToolAccess(settings, { reportServerMessage, reportUntestable, fetch?, now?, timing? })` holds one link per configured server and answers: -- `configured`: whether any server is configured, which makes `execute_spec` destructive. +- `configured`: whether any server is configured, which makes `run_definition` destructive. - `testing`: each server's name with its `allowed` and `testable`, which `test_tool_call` checks a tool against on the entry of the server it names. -- `open(context, references)`: the tools of one caller, for its id, org, brain and journal and the metadata its calls carry, and the `server/tool` references it names. A run gives its execution id as `com.beonauto/execution_id` (`executionIdKey`, which `@beonauto/mcp/policy` exports). +- `open(context, references)`: the tools of one caller, for its id, org, brain and journal and the metadata its calls carry, and the `server/tool` references it names. A run gives its run id as `com.beonauto/run_id` (`runIdKey`, which `@beonauto/mcp/policy` exports). - `named(address, references)`: whether every reference names a server that serves the brain and a tool its operator allows, the result of `namedLinks` and nothing else, with no connection, which an interaction function's run checks at its start. - `startOf({ reference, input })`: the fields `callStarted` builds for one call, the server, the tool, the size and digest of the arguments and, where the entry records content, the arguments scrubbed and cut, which a delivery, a read or a telling records before it calls. - `callOnce(call)`: one call of one tool for a delivery, a read or a telling (see [One call for a delivery](#one-call-for-a-delivery)). @@ -51,7 +51,7 @@ Nothing is learned from a server when the settings are read: the server starts w - `brainsServedBy(server)`: the brains of its org a server serves, as its entry's `brains`, or `['*']` when the entry leaves them out, read from the settings without loading anything. - `close()`: ends every session and stops every process, when the server stops. -`open` fails with `ToolNotOffered` when a reference names a server not configured for the execution's org and brain (`mcp_server_not_configured`), a tool the operator does not allow (`tool_not_allowed`), or a tool its server does not list (`tool_not_listed`), and with `McpServerFailed` when a server cannot be used (`unreachable`, `failing`, `rate_limited`, or `key_refused` when it answers HTTP 401 to the key the runtime gives it). Otherwise it connects to each named server, lists its tools once, and keeps that listing for the run: a `list_changed` notification changes nothing, and a call to a tool the server no longer has is a tool error. `server/*` offers every listed tool the operator allows. +`open` fails with `ToolNotOffered` when a reference names a server not configured for the run's org and brain (`mcp_server_not_configured`), a tool the operator does not allow (`tool_not_allowed`), or a tool its server does not list (`tool_not_listed`), and with `McpServerFailed` when a server cannot be used (`unreachable`, `failing`, `rate_limited`, or `key_refused` when it answers HTTP 401 to the key the runtime gives it). Otherwise it connects to each named server, lists its tools once, and keeps that listing for the run: a `list_changed` notification changes nothing, and a call to a tool the server no longer has is a tool error. `server/*` offers every listed tool the operator allows. `RunTools` offers each tool under its model-facing name, `mcp__server__tool`, with characters outside letters, digits and underscore mapped to underscores, cut to 64 characters with an 8-character hash when longer or when two names would collide. A numeric suffix resolves any remaining collision, so every tool offered in a run has a distinct name. Its description is cut to 4 KiB and the server's input schema is unchanged. It also says whether the calls have ended (`callsEnded`, `ended`, `ending`), whether the run called any tool (`calledAny`), and the tools it used in words (`usedInWords`), and lets the run's servers go (`close`). @@ -59,7 +59,7 @@ A call of an offered tool: 1. is refused as a tool error, never sent and never recorded, when the run's calls or results are spent, its arguments are too large, or it repeats a call made twice already; 2. records `tool_call_started` through the run's journal, which answers the number the run's record gave the call, and is not sent if that fails; -3. is forwarded with the metadata the opener gave, a run's id under `com.beonauto/execution_id`, within its deadline, waiting out a 429 whose `Retry-After` fits the longest wait, opening a session the server forgot once, and restarting a `stdio` process that exited once per run; +3. is forwarded with the metadata the opener gave, a run's id under `com.beonauto/run_id`, within its deadline, waiting out a 429 whose `Retry-After` fits the longest wait, opening a session the server forgot once, and restarting a `stdio` process that exited once per run; 4. records `tool_call_answered`, unless the run was cancelled meanwhile; 5. answers the model: text content as text, structured content only when there is no text, other content as a one-line placeholder, an `isError` result as a tool error, and a failure as a tool error naming the server, all scrubbed of the entry's secrets and minted tokens. A server's `instructions` never reach the model. @@ -67,11 +67,11 @@ The call answers a `CallReply`: the model reads its `text` and `isError`, and be Where a server's tools are first listed, by a listing, a run's opening or a test (`connectedTo`), a server none of whose allowed tools carries `readOnlyHint` and whose entry has no `testable` is reported once to `reportUntestable`, by name, for the life of the access, which the server logs as one line at INFO saying that `test_tool_call` can test none of its tools and that the tools safe to test go under `testable` (`untestableNoting` in `src/tool-tests/testing-guard.ts`). Nothing is noted at start, since no server is asked before a run or a listing needs it. -A server failure is also reported to the operator through `reportServerMessage`, bounded and scrubbed, naming the run by `execution_id` or a test by `tool_test_id`, whichever the opener's metadata gives, and the fifth ends the run's calls, with `ending()` saying `failing` or `rate_limited`. +A server failure is also reported to the operator through `reportServerMessage`, bounded and scrubbed, naming the run by `run_id` or a test by `tool_test_id`, whichever the opener's metadata gives, and the fifth ends the run's calls, with `ending()` saying `failing` or `rate_limited`. ## One call for a delivery -`callOnce({ org, brain, reference, input, meta })` makes one call of one tool for an interaction function, a delivery of its request, a read of the replies to it or a telling, with bounds of its own, `deliveryBounds`, and records nothing: its caller records the start before calling, with the fields `startOf` gives, and the end from what the call answers. It shares the server's link with the runs, so it waits for a session to open no longer than the connection bound of a delivery, 10 s, or the shared opening bound when that is shorter (`connectionBoundOf`), letting go of a session that opens later; it waits no longer either to open a forgotten session again or to start an exited process again (`boundedSlot`); it lists no tools: a tool the server no longer has is a tool error. The call carries the metadata its caller gives, as a run's calls do: a delivery gives the run's id under `com.beonauto/execution_id` and the delivery's id under `com.beonauto/delivery_id`, the same on every attempt of one request, and a read or a telling its own id under `com.beonauto/conversation_call_id` (`executionIdKey`, `deliveryIdKey` and `conversationCallIdKey`, which `@beonauto/mcp/policy` exports). It answers the call whole, `CalledOnce`: +`callOnce({ org, brain, reference, input, meta })` makes one call of one tool for an interaction function, a delivery of its request, a read of the replies to it or a telling, with bounds of its own, `deliveryBounds`, and records nothing: its caller records the start before calling, with the fields `startOf` gives, and the end from what the call answers. It shares the server's link with the runs, so it waits for a session to open no longer than the connection bound of a delivery, 10 s, or the shared opening bound when that is shorter (`connectionBoundOf`), letting go of a session that opens later; it waits no longer either to open a forgotten session again or to start an exited process again (`boundedSlot`); it lists no tools: a tool the server no longer has is a tool error. The call carries the metadata its caller gives, as a run's calls do: a delivery gives the run's id under `com.beonauto/run_id` and the delivery's id under `com.beonauto/delivery_id`, the same on every attempt of one request, and a read or a telling its own id under `com.beonauto/conversation_call_id` (`runIdKey`, `deliveryIdKey` and `conversationCallIdKey`, which `@beonauto/mcp/policy` exports). It answers the call whole, `CalledOnce`: - `not_offered`, sent nowhere, for a server not configured for the brain's org and brain or a tool the operator does not allow, with the `because` and words of `namedLinks`; - `unopened`, sent nowhere, when the server could not be reached or no connection opened within its bound, with the words cut at 1 KiB and scrubbed; @@ -128,7 +128,7 @@ The MCP client has no logging option. The errors it meets, such as a line from a ## What is recorded -`tool_call_started` carries the call's number in the run, the id the model gave it, the server and tool, and the size and SHA-256 digest of the arguments as `JSON.stringify` writes them. `tool_call_answered` carries the number, the outcome (`result`, `tool_error`, `server_failure`, `timed_out` or `cancelled`), the size and digest of the result's content, its `content` and `structuredContent` as the client hands them back, re-serialised, never its `_meta` or `isError`, the duration and the JSON-RPC id. An entry with `request_id` adds the server's own id of the request; one with `record_content: true` adds the arguments and the result's content, scrubbed and cut to 4 KiB as they are stored, a JSON string whose escapes count. The fields of the two facts are written once, as the effect schemas `CallStartedSchema` and `CallAnsweredSchema`, which the package exports, and `@beonauto/specs` builds the run's two events from them, adding the call's `number`, `by` and `at`. +`tool_call_started` carries the call's number in the run, the id the model gave it, the server and tool, and the size and SHA-256 digest of the arguments as `JSON.stringify` writes them. `tool_call_answered` carries the number, the outcome (`result`, `tool_error`, `server_failure`, `timed_out` or `cancelled`), the size and digest of the result's content, its `content` and `structuredContent` as the client hands them back, re-serialised, never its `_meta` or `isError`, the duration and the JSON-RPC id. An entry with `request_id` adds the server's own id of the request; one with `record_content: true` adds the arguments and the result's content, scrubbed and cut to 4 KiB as they are stored, a JSON string whose escapes count. The fields of the two facts are written once, as the effect schemas `CallStartedSchema` and `CallAnsweredSchema`, which the package exports, and `@beonauto/definitions` builds the run's two events from them, adding the call's `number`, `by` and `at`. ## Testing @@ -160,6 +160,6 @@ The tests never call a real gateway. Once, before relying on one, run a reasonin ``` 4. Start the server and create a reasoning function in that brain with `tools: [graph/*]` whose prompt asks a read-only question the policy allows, including the denied field. -5. Run it with `execute_spec`, then read the run with `get_execution_history`. +5. Run it with `run_definition`, then read the run with `get_run_history`. -Check that the run succeeded; that its history shows a `tool_call_started` and a `tool_call_answered` for each call, the denial answered as `tool_error` and the model's answer saying the field was withheld; that the gateway's audit shows the calls made by the application, each carrying the run's id under `com.beonauto/execution_id`; that `execute_spec` for the same `execution_id` answers the same run again; and that the server's log holds no key or token. +Check that the run succeeded; that its history shows a `tool_call_started` and a `tool_call_answered` for each call, the denial answered as `tool_error` and the model's answer saying the field was withheld; that the gateway's audit shows the calls made by the application, each carrying the run's id under `com.beonauto/run_id`; that `run_definition` for the same `run_id` answers the same run again; and that the server's log holds no key or token. diff --git a/packages/mcp/src/access/caller-context.ts b/packages/mcp/src/access/caller-context.ts index dc181fdca..926f60a9f 100644 --- a/packages/mcp/src/access/caller-context.ts +++ b/packages/mcp/src/access/caller-context.ts @@ -13,7 +13,7 @@ export interface CallerContext { export interface ServerMessage { readonly server: string; readonly message: string; - readonly execution_id: string | null; + readonly run_id: string | null; readonly tool_test_id: string | null; } diff --git a/packages/mcp/src/access/linked-access.ts b/packages/mcp/src/access/linked-access.ts index 91ecc1932..714fd2816 100644 --- a/packages/mcp/src/access/linked-access.ts +++ b/packages/mcp/src/access/linked-access.ts @@ -20,7 +20,7 @@ export function linkedAccess(settings: McpSettings, options: ToolAccessOptions): now: options.now ?? Date.now, timing, reportOutput: (server, message) => { - report({ server, message, execution_id: null, tool_test_id: null }); + report({ server, message, run_id: null, tool_test_id: null }); }, }; const links = new Map(settings.servers.map((server) => [server.name, serverLink(server, linkOptions)])); diff --git a/packages/mcp/src/access/tool-access.test.ts b/packages/mcp/src/access/tool-access.test.ts index 7587ce2ff..7569631ab 100644 --- a/packages/mcp/src/access/tool-access.test.ts +++ b/packages/mcp/src/access/tool-access.test.ts @@ -120,8 +120,8 @@ describe('the tools a run is offered', () => { { text: 'Answered from far away.', isError: false }, ]); expect(fake.received()).toEqual([ - { tool: 'graph.query.v2', arguments: { query: 'acme' }, meta: { 'com.beonauto/execution_id': toolRunId } }, - { tool: longToolName, arguments: {}, meta: { 'com.beonauto/execution_id': toolRunId } }, + { tool: 'graph.query.v2', arguments: { query: 'acme' }, meta: { 'com.beonauto/run_id': toolRunId } }, + { tool: longToolName, arguments: {}, meta: { 'com.beonauto/run_id': toolRunId } }, ]); }); }); @@ -234,7 +234,7 @@ describe('a stdio server and the MCP client', { timeout: stdioTestTimeoutMs }, ( expect(messages()).toContainEqual({ server: 'limitless', message: 'The fake MCP server says line 1 on stderr', - execution_id: null, + run_id: null, tool_test_id: null, }); }); @@ -250,7 +250,7 @@ describe('a stdio server and the MCP client', { timeout: stdioTestTimeoutMs }, ( expect(messages()).toContainEqual({ server: 'limitless', message: 'The MCP server wrote a message that is not JSON-RPC', - execution_id: null, + run_id: null, tool_test_id: null, }); }); diff --git a/packages/mcp/src/calls/call-meta.ts b/packages/mcp/src/calls/call-meta.ts index 8a6bcd504..1bc991b65 100644 --- a/packages/mcp/src/calls/call-meta.ts +++ b/packages/mcp/src/calls/call-meta.ts @@ -1,4 +1,4 @@ -export const executionIdKey = 'com.beonauto/execution_id'; +export const runIdKey = 'com.beonauto/run_id'; export const deliveryIdKey = 'com.beonauto/delivery_id'; diff --git a/packages/mcp/src/calls/call-replies.test.ts b/packages/mcp/src/calls/call-replies.test.ts index ed9e7c626..7431c0561 100644 --- a/packages/mcp/src/calls/call-replies.test.ts +++ b/packages/mcp/src/calls/call-replies.test.ts @@ -49,7 +49,7 @@ async function opened(meta: Readonly>, ...tools: readonly describe('what a call answers beside what the model sees', () => { it('is the outcome, the size of the whole result, how long it took and the id the server gave it, as recorded', async () => { - const { call, journal } = await opened({ 'com.beonauto/execution_id': testId }, 'search', 'denied'); + const { call, journal } = await opened({ 'com.beonauto/run_id': testId }, 'search', 'denied'); const replies = [await call(0, { query: 'acme' }), await call(1, {})]; @@ -68,7 +68,7 @@ describe('what a call answers beside what the model sees', () => { }); it('is a server failure with no result when the server fails the call', async () => { - const { call } = await opened({ 'com.beonauto/execution_id': testId }, 'broken'); + const { call } = await opened({ 'com.beonauto/run_id': testId }, 'broken'); expect(await call(0, {})).toMatchObject({ isError: true, @@ -81,15 +81,15 @@ describe('what a call answers beside what the model sees', () => { describe('a failed call reported to the operator', () => { it('names the run whose call it was, or the test, by the id its opener gave', async () => { - const ofARun = await opened({ 'com.beonauto/execution_id': testId }, 'broken'); + const ofARun = await opened({ 'com.beonauto/run_id': testId }, 'broken'); const ofATest = await opened({ 'com.beonauto/tool_test_id': testId }, 'broken'); await ofARun.call(0, {}); await ofATest.call(0, {}); expect([...ofARun.messages(), ...ofATest.messages()]).toMatchObject([ - { server: 'graph', execution_id: testId, tool_test_id: null }, - { server: 'graph', execution_id: null, tool_test_id: testId }, + { server: 'graph', run_id: testId, tool_test_id: null }, + { server: 'graph', run_id: null, tool_test_id: testId }, ]); }); }); diff --git a/packages/mcp/src/calls/call-replies.ts b/packages/mcp/src/calls/call-replies.ts index 4ab15ab33..345cfd340 100644 --- a/packages/mcp/src/calls/call-replies.ts +++ b/packages/mcp/src/calls/call-replies.ts @@ -4,7 +4,7 @@ import { failedOnce, failuresEnded, shownResult, type CallTally } from '../bound import { errorTextForModel, errorTextForOperator, resultText } from '../bounds/result-text.ts'; import { bytesOf } from '../bounds/text-bytes.ts'; import type { CallOutcome } from './call-facts.ts'; -import { executionIdKey, toolTestIdKey } from './call-meta.ts'; +import { runIdKey, toolTestIdKey } from './call-meta.ts'; import type { Forwarded } from './tool-calls.ts'; interface ModelWords { @@ -46,7 +46,7 @@ export function failureCounted( replying.report({ server: replying.server, message: errorTextForOperator(done.message, replying.scrub), - execution_id: replying.meta[executionIdKey] ?? null, + run_id: replying.meta[runIdKey] ?? null, tool_test_id: replying.meta[toolTestIdKey] ?? null, }); const counted = failedOnce(tally); diff --git a/packages/mcp/src/calls/tool-caller.test.ts b/packages/mcp/src/calls/tool-caller.test.ts index 655357a62..1b327e307 100644 --- a/packages/mcp/src/calls/tool-caller.test.ts +++ b/packages/mcp/src/calls/tool-caller.test.ts @@ -3,7 +3,6 @@ import { setTimeout } from 'node:timers/promises'; import { Effect } from 'effect'; import { afterEach, describe, expect, it } from 'vitest'; -import type { CallJournal, RecordedCall } from '../calls/recorded-calls.ts'; import { controlledSignals, fakeApiKey, @@ -12,6 +11,7 @@ import { serveFakeMcp, toolRun, } from '../testing/index.ts'; +import type { CallJournal, RecordedCall } from './recorded-calls.ts'; const closing: (() => Promise)[] = []; diff --git a/packages/mcp/src/calls/tool-calls.test.ts b/packages/mcp/src/calls/tool-calls.test.ts index 33fe2ddc8..adad5b012 100644 --- a/packages/mcp/src/calls/tool-calls.test.ts +++ b/packages/mcp/src/calls/tool-calls.test.ts @@ -132,9 +132,7 @@ describe('a server that fails a call', () => { const { call, messages } = await runWith(['broken']); expect(await call('broken', { key: fakeApiKey, attempt: 1 })).toMatchObject({ text: brokenWithKey, isError: true }); - expect(messages()).toEqual([ - { server: 'graph', message: reportedWithKey, execution_id: toolRunId, tool_test_id: null }, - ]); + expect(messages()).toEqual([{ server: 'graph', message: reportedWithKey, run_id: toolRunId, tool_test_id: null }]); }); it('ends the calls after five failures', async () => { diff --git a/packages/mcp/src/delivery/delivery-call.test.ts b/packages/mcp/src/delivery/delivery-call.test.ts index ee90199a4..4e4c36efc 100644 --- a/packages/mcp/src/delivery/delivery-call.test.ts +++ b/packages/mcp/src/delivery/delivery-call.test.ts @@ -48,7 +48,7 @@ describe('one call of a tool for a delivery', () => { { tool: 'echo', arguments: delivery.input, - meta: { 'com.beonauto/execution_id': deliveredRunId, 'com.beonauto/delivery_id': deliveryId }, + meta: { 'com.beonauto/run_id': deliveredRunId, 'com.beonauto/delivery_id': deliveryId }, }, ]); expect(fake.seen().map(({ rpc }) => rpc)).not.toContain('tools/list'); diff --git a/packages/mcp/src/own-calls/conversation-call-events.ts b/packages/mcp/src/own-calls/conversation-call-events.ts index 67140c606..8cd090fce 100644 --- a/packages/mcp/src/own-calls/conversation-call-events.ts +++ b/packages/mcp/src/own-calls/conversation-call-events.ts @@ -32,7 +32,7 @@ const answeredFields = { const TellingStartedSchema = Schema.Struct({ type: Schema.Literal('telling_started'), call_id: Schema.String, - execution_id: Schema.String, + run_id: Schema.String, ...theTool, ...startedFields, ...ofTheBrain, diff --git a/packages/mcp/src/own-calls/conversation-call-presenter.test.ts b/packages/mcp/src/own-calls/conversation-call-presenter.test.ts index 713c480f7..565f605ab 100644 --- a/packages/mcp/src/own-calls/conversation-call-presenter.test.ts +++ b/packages/mcp/src/own-calls/conversation-call-presenter.test.ts @@ -128,7 +128,7 @@ describe('a telling the brain made in a conversation, as the brain events show i const started = shown({ type: 'telling_started', call_id: 'call-2', - execution_id: 'run-1', + run_id: 'run-1', server: 'chat', tool: 'post_message', arguments_bytes: 80, @@ -144,7 +144,7 @@ describe('a telling the brain made in a conversation, as the brain events show i 'The tool that told the party was no longer offered by its server.', ]); expect([started?.data, ended?.data]).toMatchObject([ - { call_id: 'call-2', execution_id: 'run-1', arguments_bytes: 80 }, + { call_id: 'call-2', run_id: 'run-1', arguments_bytes: 80 }, { outcome: 'result', ...answered }, ]); }); diff --git a/packages/mcp/src/own-calls/conversation-call-presenter.ts b/packages/mcp/src/own-calls/conversation-call-presenter.ts index 28b389558..21e780d23 100644 --- a/packages/mcp/src/own-calls/conversation-call-presenter.ts +++ b/packages/mcp/src/own-calls/conversation-call-presenter.ts @@ -121,7 +121,7 @@ function accountOf(event: ConversationCallEvent) { if (event.type === 'telling_started') { return { summary: `The brain told the party how to answer ${throughTheTool(event)}.`, - data: { ...startShown(event, toolBounds.shownContentBytes), execution_id: named(event.execution_id) }, + data: { ...startShown(event, toolBounds.shownContentBytes), run_id: named(event.run_id) }, }; } return { diff --git a/packages/mcp/src/own-calls/conversation-calls.test.ts b/packages/mcp/src/own-calls/conversation-calls.test.ts index 7689493f8..3e3d655c8 100644 --- a/packages/mcp/src/own-calls/conversation-calls.test.ts +++ b/packages/mcp/src/own-calls/conversation-calls.test.ts @@ -46,10 +46,10 @@ function decided(event: ConversationCallEvent, ...history: readonly Conversation describe('a telling the brain makes in a conversation', () => { it('records its start with the call it sends and its end with what the tool answered', () => { - expect(tellingStartedOf({ callId: 'call-2', executionId: 'run-1' }, started, recorded)).toEqual({ + expect(tellingStartedOf({ callId: 'call-2', runId: 'run-1' }, started, recorded)).toEqual({ type: 'telling_started', call_id: 'call-2', - execution_id: 'run-1', + run_id: 'run-1', ...started, ...recorded, }); @@ -147,7 +147,7 @@ describe('a read the brain could not send', () => { }); describe('the turns of a call in a conversation', () => { - const telling = tellingStartedOf({ callId: 'call-2', executionId: 'run-1' }, started, recorded); + const telling = tellingStartedOf({ callId: 'call-2', runId: 'run-1' }, started, recorded); const ending = tellingEndedOf('call-2', answered, recorded); const read = repliesReadOf(reading, recorded); diff --git a/packages/mcp/src/own-calls/conversation-calls.ts b/packages/mcp/src/own-calls/conversation-calls.ts index 3f36aaa1b..3c2c24b18 100644 --- a/packages/mcp/src/own-calls/conversation-calls.ts +++ b/packages/mcp/src/own-calls/conversation-calls.ts @@ -38,15 +38,11 @@ function tellingOutcomeOf(end: CalledOnce): TellingOutcome { export interface Telling { readonly callId: string; - readonly executionId: string; + readonly runId: string; } -export function tellingStartedOf( - { callId, executionId }: Telling, - start: StartedFields, - recorded: Recorded, -): TellingStarted { - return { type: 'telling_started', call_id: callId, execution_id: executionId, ...start, ...recorded }; +export function tellingStartedOf({ callId, runId }: Telling, start: StartedFields, recorded: Recorded): TellingStarted { + return { type: 'telling_started', call_id: callId, run_id: runId, ...start, ...recorded }; } export function tellingEndedOf(callId: string, end: CalledOnce, recorded: Recorded): TellingEnded { diff --git a/packages/mcp/src/policy.ts b/packages/mcp/src/policy.ts index 8f8c534eb..1f87f7505 100644 --- a/packages/mcp/src/policy.ts +++ b/packages/mcp/src/policy.ts @@ -1,5 +1,5 @@ export { runBoundMs } from './bounds/call-bounds.ts'; -export { conversationCallIdKey, deliveryIdKey, executionIdKey } from './calls/call-meta.ts'; +export { conversationCallIdKey, deliveryIdKey, runIdKey } from './calls/call-meta.ts'; export { isServerName, isToolName, diff --git a/packages/mcp/src/testing/delivery-calls.ts b/packages/mcp/src/testing/delivery-calls.ts index b9075df5d..7195deab9 100644 --- a/packages/mcp/src/testing/delivery-calls.ts +++ b/packages/mcp/src/testing/delivery-calls.ts @@ -57,7 +57,7 @@ export const delivery: DeliveryCall = { brain: 'alpha', reference: { server: 'graph', tool: 'echo' }, input: { channel: '#approvals', text: 'Please approve' }, - meta: { 'com.beonauto/execution_id': deliveredRunId, 'com.beonauto/delivery_id': deliveryId }, + meta: { 'com.beonauto/run_id': deliveredRunId, 'com.beonauto/delivery_id': deliveryId }, }; export function calledOnce( diff --git a/packages/mcp/src/testing/tool-runs.ts b/packages/mcp/src/testing/tool-runs.ts index 90d6f19bb..a7006f03c 100644 --- a/packages/mcp/src/testing/tool-runs.ts +++ b/packages/mcp/src/testing/tool-runs.ts @@ -2,7 +2,7 @@ import { Effect } from 'effect'; import type { CallerContext } from '../access/caller-context.ts'; import type { CallStarted } from '../calls/call-facts.ts'; -import { executionIdKey } from '../calls/call-meta.ts'; +import { runIdKey } from '../calls/call-meta.ts'; import type { CallJournal, RecordedCall } from '../calls/recorded-calls.ts'; import type { CallSignals } from '../calls/run-parts.ts'; @@ -48,7 +48,7 @@ export function recordingCallJournal(): RecordingCallJournal { } export function toolRun(journal: CallJournal, changes: Partial> = {}): CallerContext { - return { id: toolRunId, org: 'acme', brain: 'alpha', meta: { [executionIdKey]: toolRunId }, ...changes, journal }; + return { id: toolRunId, org: 'acme', brain: 'alpha', meta: { [runIdKey]: toolRunId }, ...changes, journal }; } export function controlledSignals(): ControlledSignals { diff --git a/packages/mcp/src/tool-tests/tool-test-outcomes.test.ts b/packages/mcp/src/tool-tests/tool-test-outcomes.test.ts index bc137aec9..421c61a89 100644 --- a/packages/mcp/src/tool-tests/tool-test-outcomes.test.ts +++ b/packages/mcp/src/tool-tests/tool-test-outcomes.test.ts @@ -55,7 +55,7 @@ describe('a test whose tool does not answer as asked', () => { result_bytes: null, }, }); - expect(messages()).toMatchObject([{ server: 'graph', execution_id: null, tool_test_id: aTestId }]); + expect(messages()).toMatchObject([{ server: 'graph', run_id: null, tool_test_id: aTestId }]); }); it('succeeds as timed out when the tool takes longer than a call may', async () => { diff --git a/packages/operations/README.md b/packages/operations/README.md index f082653ac..a8d72e2e2 100644 --- a/packages/operations/README.md +++ b/packages/operations/README.md @@ -88,7 +88,7 @@ Plain words have bounds: an outcome takes at most `mostOutcomeCharacters`, 400, `InvalidInput` is for input that matches the input schema but that the handler finds wrong, such as a document it parses. It carries a detail and its issues, each a `detail` and a JSON Pointer `pointer` into the input. A handler that declares `invalid_input` and fails with it is rejected with reason `invalid_input` and those issues, the same rejection the dispatcher gives input that breaks the schema. Every rejection carries at most 100 issues, and each issue only its `detail` and `pointer`. -`InvalidInput`, `Unavailable` and `Conflict` may also carry a `record`, a JSON object of what was done before the rejection, such as the tokens a model call spent. No outcome or problem document shows it; `@beonauto/specs` keeps it on a run its runtime adapter rejected. +`InvalidInput`, `Unavailable` and `Conflict` may also carry a `record`, a JSON object of what was done before the rejection, such as the tokens a model call spent. No outcome or problem document shows it; `@beonauto/definitions` keeps it on a run its runtime adapter rejected. `getLabel.registration` is what a catalog stores: the route, the kind, the success status, the reasons, the permissions that let a caller call it, whether the operation targets a brain, whether it reaches outside the server, and the JSON Schema of the input with its definitions kept apart. Its `run` decodes an input, runs the handler and encodes the output; only the dispatcher calls it, because it checks nothing about the caller. @@ -117,7 +117,7 @@ An org operation targets at most one brain, and names it `brain`. When its input A caller of one org gets the same `forbidden` rejection for an existing and a missing brain of another org. A caller's identity can be decoded with `CallerIdentitySchema`. -A brain request may carry a `lineage`, `{ causationId, correlationId }`: the message that caused the call and the run the call belongs to, a `depth`, the reaction depth of the run it starts (see the workflow host's reactions), a `callDepth`, the number of calls above the run it starts, a `calledBy`, the call that run answers (`CallLink`: the execution id of the workflow, the call's reference and its run), and a `trigger`, the trigger of a workflow that started the run (`TriggerLink`: its kind, `event`, `cron` or `every`, and its reference in the document). Only callers in the same process set them, as the workflow host does when a workflow calls a function or a trigger starts a workflow; a transport never does, so no input reaches them. The brain binding gives them to a brain command as the `CallLineage` service, `{ lineage, depth, callDepth, calledBy, trigger }`, `lineage`, `calledBy` and `trigger` `null` and the depths 0 when the request carried none, and the command passes the lineage to `BrainWriter.execute` with the events it appends. +A brain request may carry a `lineage`, `{ causationId, correlationId }`: the message that caused the call and the run the call belongs to, a `depth`, the reaction depth of the run it starts (see the workflow host's reactions), a `callDepth`, the number of calls above the run it starts, a `calledBy`, the call that run answers (`CallLink`: the run id of the workflow, the call's reference and its run), and a `trigger`, the trigger of a workflow that started the run (`TriggerLink`: its kind, `event`, `cron` or `every`, and its reference in the document). Only callers in the same process set them, as the workflow host does when a workflow calls a function or a trigger starts a workflow; a transport never does, so no input reaches them. The brain binding gives them to a brain command as the `CallLineage` service, `{ lineage, depth, callDepth, calledBy, trigger }`, `lineage`, `calledBy` and `trigger` `null` and the depths 0 when the request carried none, and the command passes the lineage to `BrainWriter.execute` with the events it appends. A definition sets `permittedBy` when a permission other than the one of its kind and scope lets a caller call it: `list_brains` of `@beonauto/brains`, an org query that answers only the brains its caller may access, is permitted by `org:read` or `brain:read`, so a key that may only read inside some brains can find them. The registration carries the list as `permissions`, the permission of the kind and scope when left out; a definition permitted by no permission is refused when it is defined. A transport that lists what a caller may call, as the MCP tools do, checks the same list. @@ -145,14 +145,14 @@ For each call the dispatcher binds the ledger to the call's address. `OrgReader` `Ledger.readRecorded(brain, selection, page)` reads one page of what a brain recorded, in the order the ledger recorded it, and answers `{ records, hasMore, nextCursor }`. Each record is `{ id, cursor, causationId, correlationId, stream, version, type, data, recordedAt }`: `id` is the message id, `causationId` and `correlationId` its lineage, `cursor` the record's own cursor, `version` its position within its stream, from 1, `data` the stored event as JSON, and `recordedAt` the time the store recorded it in ISO 8601. The brain is matched exactly: a read of `acme/sales` never answers a record of `acme/Sales`, `acme/sales2` or `acme/sales_x`. [Decision 0002](../../docs/decisions/0002-reading-runs-and-brain-events.md) records the design of this read. -- The selection is `{ kind: 'everything' }`, the whole partition of the brain; `{ kind: 'run', execution }`, the two streams of one run, `executions/` and `runs/`; `{ kind: 'correlated', correlation }`, every message of the brain whose correlation is that run, in the brain's order; or `{ kind: 'executions', notBeginningWith?, primitive?, name? }`, the first and the latest message of every execution stream, ordered by the position of the first, leaving out the streams whose first message is of a type `notBeginningWith` names, which the store passes over before it counts the page, so a page of runs is full and `hasMore` true only while runs remain. `primitive` and `name` keep the runs whose first message holds that `primitive` and that `name` at the top of its data, as a run's start records them; the store reads them from the first message as it examines each run, before it counts the page, so a page of the runs of one definition is full while it finds them and `hasMore` true only while runs remain. A `primitive` or `name` must hold neither U+0000 nor an unpaired surrogate, as no definition's does: PostgreSQL fails the read for one, `22P05` and `22P02`, where SQLite and the in-memory ledger find no run. -- The page is `{ cursor?, order, limit, since?, types?, dataOf? }`. `order` is `asc`, oldest first, or `desc`, newest first. `since` is the lower bound of the page in either order, by the time the store recorded. `types` keeps the records of those stored types; with `executions` it keeps the runs whose latest message is of those types, which is how a status filter is answered. `dataOf` reads the heads of the records: only the records of those stored types carry their `data`, and the others come with their id, cursor, lineage, stream, version, type and time, `data` left out, and count nothing toward the 4 MiB a page loads, so a reader that follows a brain passes a run log's records of up to 1.5 MiB at the cost of their heads; `dataOf: []` loads no data at all. -- A page is bounded by what the store examines and loads: it answers at most `limit` records, 1 to 100, examining that many without a filter, and with a `types` filter, or a `primitive` or `name` of `executions`, up to `mostExaminedInAPage`, 1,000 records, or 1,000 runs for `executions`, of which it answers those that match, possibly none; and it loads at most 4 MiB of stored data, though the first record a page wants is always delivered. A bound ends the page with `nextCursor`, possibly with fewer records than asked or none. `boundedPage` applies these bounds over what a store examined, so every ledger bounds a page the same way. +- The selection is `{ kind: 'everything' }`, the whole partition of the brain; `{ kind: 'run', run }`, the two streams of one run, `runs/` and its run log, `run-logs/`; `{ kind: 'correlated', correlation }`, every message of the brain whose correlation is that run, in the brain's order; or `{ kind: 'runs', notBeginningWith?, definitionType?, name? }`, the first and the latest message of every run stream, ordered by the position of the first, leaving out the streams whose first message is of a type `notBeginningWith` names, which the store passes over before it counts the page, so a page of runs is full and `hasMore` true only while runs remain. `definitionType` and `name` keep the runs whose first message holds that `definition_type` and that `name` at the top of its data, as a run's start records them; the store reads them from the first message as it examines each run, before it counts the page, so a page of the runs of one definition is full while it finds them and `hasMore` true only while runs remain. A `definitionType` or `name` must hold neither U+0000 nor an unpaired surrogate, as no definition's does: PostgreSQL fails the read for one, `22P05` and `22P02`, where SQLite and the in-memory ledger find no run. +- The page is `{ cursor?, order, limit, since?, types?, dataOf? }`. `order` is `asc`, oldest first, or `desc`, newest first. `since` is the lower bound of the page in either order, by the time the store recorded. `types` keeps the records of those stored types; with `runs` it keeps the runs whose latest message is of those types, which is how a status filter is answered. `dataOf` reads the heads of the records: only the records of those stored types carry their `data`, and the others come with their id, cursor, lineage, stream, version, type and time, `data` left out, and count nothing toward the 4 MiB a page loads, so a reader that follows a brain passes a run log's records of up to 1.5 MiB at the cost of their heads; `dataOf: []` loads no data at all. +- A page is bounded by what the store examines and loads: it answers at most `limit` records, 1 to 100, examining that many without a filter, and with a `types` filter, or a `definitionType` or `name` of `runs`, up to `mostExaminedInAPage`, 1,000 records, or 1,000 runs for `runs`, of which it answers those that match, possibly none; and it loads at most 4 MiB of stored data, though the first record a page wants is always delivered. A bound ends the page with `nextCursor`, possibly with fewer records than asked or none. `boundedPage` applies these bounds over what a store examined, so every ledger bounds a page the same way. - `nextCursor` is null when nothing remains. A reader at the end of the brain keeps the cursor of the last record it read and reads on from it later. - `lastExamined` is the place of the last record the page examined, `{ cursor, recordedAt }`, or null when it examined none: the record `nextCursor` reads on after when a bound ends the page, and at the end of the brain the last record there, even one of a type the page did not want, so a reader that keeps a checkpoint of what it has examined, as the workflow host's projector does, moves past records it filters out. `boundedPage` names it as `lastExamined`. - A cursor is opaque to callers: the base64url encoding, without padding, of a JSON array, the brain key first (`cursorOfParts`, `partsOfCursor`). A record's cursor reads on after the record. `cursorWithin(cursor, index)` adds one more part, a number, the index of the last event of the record a page answered; a read from such a cursor begins with that record, and the events of the record up to that index are left out by whoever presents them. A cursor that does not decode as one fails the read with `InvalidCursor` of kind `malformed`, and one that another brain gave with kind `of_another_brain`. -`BrainReader.readRecorded(selection, page)` is the same read bound to the brain of the call, so a handler never reads another brain. It names streams relative to the brain, as `load` and `execute` take them, and turns `InvalidCursor` into `InvalidInput` at `/cursor`, which a handler that declares `invalid_input` passes on: `The cursor is malformed` for a malformed cursor, and `The cursor was not given by a read of this brain` for another brain's. A run's execution id must be a well-formed stream segment and a page must hold 1 to 100 records from a valid time; anything else fails the call. +`BrainReader.readRecorded(selection, page)` is the same read bound to the brain of the call, so a handler never reads another brain. It names streams relative to the brain, as `load` and `execute` take them, and turns `InvalidCursor` into `InvalidInput` at `/cursor`, which a handler that declares `invalid_input` passes on: `The cursor is malformed` for a malformed cursor, and `The cursor was not given by a read of this brain` for another brain's. A run's id must be a well-formed stream segment and a page must hold 1 to 100 records from a valid time; anything else fails the call. The pieces of the operations that read the ledger: @@ -164,15 +164,15 @@ The pieces of the operations that read the ledger: ## The outcomes of runs -`Ledger.readRunOutcomes(brain, window, selection)` reads the outcomes of a brain's runs, one row per run, that the ledger keeps as their events are appended, and answers them in groups: one for each day a run first started, its `primitive`, its `name` and its `status`, `started`, `succeeded`, `failed` or `rejected`, with how many `runs` the group holds, the sums of their `inputTokens`, `outputTokens` and `cachedTokens`, 0 where none recorded a number, and the `durations` of the runs that have one, in no order. The `window` is `{ from, to }`, two days as `YYYY-MM-DD`, both included; the `selection` keeps one `primitive`, one `name`, or both. The groups come in no order. The brain is matched exactly, as for the read of what a brain recorded. +`Ledger.readRunOutcomes(brain, window, selection)` reads the outcomes of a brain's runs, one row per run, that the ledger keeps as their events are appended, and answers them in groups: one for each day a run first started, its `definitionType`, its `name` and its `status`, `started`, `succeeded`, `failed` or `rejected`, with how many `runs` the group holds, the sums of their `inputTokens`, `outputTokens` and `cachedTokens`, 0 where none recorded a number, and the `durations` of the runs that have one, in no order. The `window` is `{ from, to }`, two days as `YYYY-MM-DD`, both included; the `selection` keeps one `definitionType`, one `name`, or both. The groups come in no order. The brain is matched exactly, as for the read of what a brain recorded. -What a row holds is not the ledger's to know: a `RunOutcomeMapping`, `{ types, rowAfter }`, which the package that owns the run events supplies and the composition root gives the ledger, names the stored types that change a row and turns the row of a run, or none, and one of its events, as the ledger's reads decode it, into the row to keep, or `undefined` to keep the row as it is. A `RunOutcome` holds `startedDay`, `startedAt`, `lastStartedAt`, `primitive`, `name`, `status`, `durationMs` and the three token counts, each `null` when unknown. The mapping must never throw: the ledger calls it inside the append. `runStreamOf(stream)` tells whether a stream is a run's, named `executions/` with nothing nested under it, and answers its brain key and run id; `brainStreamOf(stream)` answers the brain key, the kind and the id of any stream of a brain with nothing nested under it. +What a row holds is not the ledger's to know: a `RunOutcomeMapping`, `{ types, rowAfter }`, which the package that owns the run events supplies and the composition root gives the ledger, names the stored types that change a row and turns the row of a run, or none, and one of its events, as the ledger's reads decode it, into the row to keep, or `undefined` to keep the row as it is. A `RunOutcome` holds `startedDay`, `startedAt`, `lastStartedAt`, `definitionType`, `name`, `status`, `durationMs` and the three token counts, each `null` when unknown. The mapping must never throw: the ledger calls it inside the append. `runStreamOf(stream)` tells whether a stream is a run's, named `runs/` with nothing nested under it, and answers its brain key and run id; `brainStreamOf(stream)` answers the brain key, the kind and the id of any stream of a brain with nothing nested under it. `BrainReader.readRunOutcomes(window, selection)` is the same read bound to the brain of the call. ## Keyed projections -A `KeyedProjection` is a table the ledger keeps, one row a key, inside the append of each event of the types it names on a stream of the kinds it names, as it keeps the outcomes of runs; the package that owns what a row means declares it and the composition root registers it with the ledger. It names its `name` and `version`, whose table is `projectedTableOf`, `_`, so a change to what it keeps is a new version and a new table; the stream `kinds` it folds, `executions` for a projection of runs; the stored `types` that change a row; its `columns`, each `text`, `integer` or `boolean`, beside the `brain_key` and `row_key` that key every row; its `indexes`, each on the brain and its columns, or on its columns alone across brains with `acrossBrains`, and partial with `whereSet` on a column that is set; `keyOf(event, stream)`, the key of the row an event changes, the stream's id when left out and none when it answers `undefined`; `advanced`, the `columns` its reader advances and the types whose fold sets them, `setBy`; and `rowAfter(row, event, message)`, which turns the keyed row, or none, the event as a read decodes it, and the message's `id` and `position` into the row to keep, or `undefined` to keep the row as it is. It must never throw. `checkedProjection` refuses a name, a version, a kind, a column, an index or an advanced column a table cannot hold, and `rowKeyOf` and `setsAdvancedColumns` say what the ledger's fold does with a fact. +A `KeyedProjection` is a table the ledger keeps, one row a key, inside the append of each event of the types it names on a stream of the kinds it names, as it keeps the outcomes of runs; the package that owns what a row means declares it and the composition root registers it with the ledger. It names its `name` and `version`, whose table is `projectedTableOf`, `_`, so a change to what it keeps is a new version and a new table; the stream `kinds` it folds, `runs` for a projection of runs; the stored `types` that change a row; its `columns`, each `text`, `integer` or `boolean`, beside the `brain_key` and `row_key` that key every row; its `indexes`, each on the brain and its columns, or on its columns alone across brains with `acrossBrains`, and partial with `whereSet` on a column that is set; `keyOf(event, stream)`, the key of the row an event changes, the stream's id when left out and none when it answers `undefined`; `advanced`, the `columns` its reader advances and the types whose fold sets them, `setBy`; and `rowAfter(row, event, message)`, which turns the keyed row, or none, the event as a read decodes it, and the message's `id` and `position` into the row to keep, or `undefined` to keep the row as it is. It must never throw. `checkedProjection` refuses a name, a version, a kind, a column, an index or an advanced column a table cannot hold, and `rowKeyOf` and `setsAdvancedColumns` say what the ledger's fold does with a fact. `Ledger.readProjectedRows(projection, brain, query)` reads the rows of one brain whose columns equal the values of `query.where`, `row_key` among them for one row, ordered by `orderBy` and then the row's key, `asc` or `desc`, after the values `after` names when it is given, at most `limit`; `countProjectedRows(projection, brain, where)` counts them; `readDueRows(projection, { column, through, limit })` reads the rows of every brain whose integer `column` is set and at most `through`, the soonest first; and `nextDueOf(projection, column, after)` answers the smallest value of the column after `after`, or `null`, so a reader that holds back rows already due still learns when the next one comes; and `advanceRow(projection, brain, key, { set, when })` writes the columns the projection's reader advances of one row, and no other, while the columns of `when` still hold the values given, which the fold leaves as they stand unless it sets them. A projection the ledger does not keep answers no rows and advances nothing. `BrainReader` holds the first two, bound to the brain of the call. `lineageAttributeNames` are the extension attributes of an event that carry the lineage of a record, `causationid` and `correlationid`. A window whose days are not days of the calendar, such as `2026-02-30`, or whose `to` comes before its `from`, fails the call: the operation that reads it checks its input first. `isCalendarDay(text)` is that check, a `YYYY-MM-DD` that names the same day when read back; `PagingInputFields.since` holds its date to it as well, and its hours, minutes and offset to their ranges, so `2026-02-30T00:00:00Z` is refused rather than read as 2 March and `2026-10-05T24:00:00Z` rather than read as the next midnight. diff --git a/packages/operations/src/caller/call-lineage.ts b/packages/operations/src/caller/call-lineage.ts index 35ebad700..cf35de6f4 100644 --- a/packages/operations/src/caller/call-lineage.ts +++ b/packages/operations/src/caller/call-lineage.ts @@ -3,7 +3,7 @@ import { Context } from 'effect'; import type { Lineage } from '../ledger/message-lineage.ts'; export interface CallLink { - readonly execution_id: string; + readonly run_id: string; readonly reference: string; readonly run: number; } diff --git a/packages/operations/src/dispatch/call-lineage.test.ts b/packages/operations/src/dispatch/call-lineage.test.ts index 67f746b7a..80bc525f2 100644 --- a/packages/operations/src/dispatch/call-lineage.test.ts +++ b/packages/operations/src/dispatch/call-lineage.test.ts @@ -24,7 +24,7 @@ const note = defineCommand('brain', { version: Schema.Int, depth: Schema.Int, callDepth: Schema.Int, - calledBy: Schema.NullOr(Schema.Struct({ execution_id: Schema.String, reference: Schema.String, run: Schema.Int })), + calledBy: Schema.NullOr(Schema.Struct({ run_id: Schema.String, reference: Schema.String, run: Schema.Int })), trigger: Schema.NullOr( Schema.Struct({ kind: Schema.Literals(['event', 'cron', 'every']), reference: Schema.String }), ), @@ -72,7 +72,7 @@ describe('the lineage of a call', () => { it('carries how many calls are above the run and the call it answers, and none for a request that gave none', async () => { const { dispatcher, run } = harness(); - const calledBy = { execution_id: '0199a3c4-7d2e-7c1a-9b3f-2f1e0d9c8b7a', reference: '/do/0/ask', run: 2 }; + const calledBy = { run_id: '0199a3c4-7d2e-7c1a-9b3f-2f1e0d9c8b7a', reference: '/do/0/ask', run: 2 }; const outcomes = [ await run(dispatcher.dispatchToBrain(note.registration, { ...toAlpha(acmeAdmin), callDepth: 2, calledBy })), diff --git a/packages/operations/src/ledger/bound-ports.test.ts b/packages/operations/src/ledger/bound-ports.test.ts index 75018967d..dc801d865 100644 --- a/packages/operations/src/ledger/bound-ports.test.ts +++ b/packages/operations/src/ledger/bound-ports.test.ts @@ -76,7 +76,7 @@ describe('the prefix of a brain', () => { }); const malformedStreams = [ - '../../beta/specs', + '../../beta/definitions', '', 'red/', 'red//blue', @@ -151,7 +151,7 @@ describe('the read of what a brain recorded, bound to a call', () => { expect( await run( - dispatcher.dispatchToBrain(readNoteHistory.registration, toAlpha(acmeAdmin, { limit: 10, execution: 'run-1' })), + dispatcher.dispatchToBrain(readNoteHistory.registration, toAlpha(acmeAdmin, { limit: 10, run: 'run-1' })), ), ).toEqual({ status: 'succeeded', output: { streams: [], ids: [], next_cursor: null } }); }); @@ -218,8 +218,8 @@ describe('a page the read bound to a call cannot hold', () => { [{ limit: 0 }, 'RangeError: A page holds 1 to 100 records, not 0'], [{ limit: 101 }, 'RangeError: A page holds 1 to 100 records, not 101'], [{ limit: 10, since: 'yesterday' }, 'RangeError: The time "yesterday" a page starts from is not a time'], - [{ limit: 10, execution: 'a/../b' }, 'Error: The stream name "executions/a/../b" is malformed'], - [{ limit: 10, correlation: 'a/../b' }, 'Error: The stream name "executions/a/../b" is malformed'], + [{ limit: 10, run: 'a/../b' }, 'Error: The stream name "runs/a/../b" is malformed'], + [{ limit: 10, correlation: 'a/../b' }, 'Error: The stream name "runs/a/../b" is malformed'], ] as const)('fails the call, %j', async (input, defect) => { const { dispatcher, reported, run } = harness(); @@ -240,8 +240,8 @@ describe('the read of the outcomes of runs, bound to a call', () => { it('reads only the brain of the call', async () => { const { dispatcher, ledger, run } = harness({ runOutcomes: runTallies }); const began: RunFact = { type: 'run_began', at: '2026-10-01T09:00:00.000Z', fn: 'triage' }; - await Effect.runPromise(ledger.service.execute('brain/acme/alpha/executions/r1', runFacts, [began])); - await Effect.runPromise(ledger.service.execute('brain/globex/gamma/executions/r2', runFacts, [began])); + await Effect.runPromise(ledger.service.execute('brain/acme/alpha/runs/r1', runFacts, [began])); + await Effect.runPromise(ledger.service.execute('brain/globex/gamma/runs/r2', runFacts, [began])); const window = { from: '2026-10-01', to: '2026-10-01' }; const read = await run(dispatcher.dispatchToBrain(readRunTallies.registration, toAlpha(acmeAdmin, window))); @@ -249,7 +249,10 @@ describe('the read of the outcomes of runs, bound to a call', () => { dispatcher.dispatchToBrain(readRunTallies.registration, toAlpha(acmeAdmin, { ...window, name: 'draft' })), ); - expect(read).toMatchObject({ status: 'succeeded', output: { groups: [{ name: 'triage', runs: 1 }] } }); + expect(read).toMatchObject({ + status: 'succeeded', + output: { groups: [{ type: 'tally', name: 'triage', runs: 1 }] }, + }); expect(named).toEqual({ status: 'succeeded', output: { groups: [] } }); }); @@ -267,8 +270,8 @@ describe('the read of projected rows, bound to a call', () => { it('reads only the brain of the call, and counts its rows', async () => { const { dispatcher, ledger, run } = harness({ projections: [runTallyRows] }); const began: RunFact = { type: 'run_began', at: '2026-10-01T09:00:00.000Z', fn: 'triage' }; - await Effect.runPromise(ledger.service.execute('brain/acme/alpha/executions/r1', runFacts, [began])); - await Effect.runPromise(ledger.service.execute('brain/acme/beta/executions/r2', runFacts, [began])); + await Effect.runPromise(ledger.service.execute('brain/acme/alpha/runs/r1', runFacts, [began])); + await Effect.runPromise(ledger.service.execute('brain/acme/beta/runs/r2', runFacts, [began])); expect(await run(dispatcher.dispatchToBrain(readTallyRows.registration, toAlpha(acmeAdmin, { limit: 5 })))).toEqual( { diff --git a/packages/operations/src/ledger/bound-ports.ts b/packages/operations/src/ledger/bound-ports.ts index ad0758165..11ade8b82 100644 --- a/packages/operations/src/ledger/bound-ports.ts +++ b/packages/operations/src/ledger/bound-ports.ts @@ -58,10 +58,10 @@ export function prefixedWriter(ledger: StreamWriter, prefix: string): StreamWrit function wellFormedSelection(selection: RecordedSelection): Effect.Effect { if (selection.kind === 'run') { - return wellFormed(`executions/${selection.execution}`).pipe(Effect.as(selection)); + return wellFormed(`runs/${selection.run}`).pipe(Effect.as(selection)); } return selection.kind === 'correlated' - ? wellFormed(`executions/${selection.correlation}`).pipe(Effect.as(selection)) + ? wellFormed(`runs/${selection.correlation}`).pipe(Effect.as(selection)) : Effect.succeed(selection); } diff --git a/packages/operations/src/projections/keyed-projection.test.ts b/packages/operations/src/projections/keyed-projection.test.ts index 163108b45..586143f15 100644 --- a/packages/operations/src/projections/keyed-projection.test.ts +++ b/packages/operations/src/projections/keyed-projection.test.ts @@ -13,10 +13,10 @@ import { topicRows } from './topic-rows.ts'; describe('the stream of a brain a fact was appended to', () => { it('is read as the brain key, the kind and the id, and nothing that names no kind and id', () => { expect([ - brainStreamOf('brain/acme/alpha/executions/r1'), - brainStreamOf('brain/acme/alpha/executions'), - brainStreamOf('brain/acme/alpha/executions/r1/nested'), - ]).toEqual([{ brainKey: 'brain/acme/alpha/', kind: 'executions', id: 'r1' }, undefined, undefined]); + brainStreamOf('brain/acme/alpha/runs/r1'), + brainStreamOf('brain/acme/alpha/runs'), + brainStreamOf('brain/acme/alpha/runs/r1/nested'), + ]).toEqual([{ brainKey: 'brain/acme/alpha/', kind: 'runs', id: 'r1' }, undefined, undefined]); }); }); @@ -24,7 +24,7 @@ describe('the key of the row a fact changes', () => { const opened = { type: 'topic_opened', topic: 'spring', at: 0 }; it('is the stream’s id by default, what the mapping says otherwise, and none for a kind it does not fold', () => { - const run = { brainKey: 'brain/acme/alpha/', kind: 'executions', id: 'r1' }; + const run = { brainKey: 'brain/acme/alpha/', kind: 'runs', id: 'r1' }; expect([ rowKeyOf(runTallyRows, opened, run), diff --git a/packages/operations/src/projections/memory-projections.test.ts b/packages/operations/src/projections/memory-projections.test.ts index ddd7bcfc1..fbfa8160e 100644 --- a/packages/operations/src/projections/memory-projections.test.ts +++ b/packages/operations/src/projections/memory-projections.test.ts @@ -30,9 +30,9 @@ function read(ledger: MemoryLedger, query: ProjectedRowsQuery = newestFirst) { describe('a projection of runs in the in-memory ledger', () => { it('keeps one row a run from the facts of its types, with the id of the message it last took', async () => { const ledger = memoryLedger(undefined, [runTallyRows]); - await noting(ledger, 'brain/acme/alpha/executions/r1', began('triage'), { type: 'run_noted' }); - await noting(ledger, 'brain/acme/alpha/executions/r1', ended); - await noting(ledger, 'brain/acme/alpha/runs/r1', began('ignored')); + await noting(ledger, 'brain/acme/alpha/runs/r1', began('triage'), { type: 'run_noted' }); + await noting(ledger, 'brain/acme/alpha/runs/r1', ended); + await noting(ledger, 'brain/acme/alpha/run-logs/r1', began('ignored')); expect(await read(ledger)).toEqual([ { @@ -46,7 +46,7 @@ describe('a projection of runs in the in-memory ledger', () => { facts: 3, open: false, due_at: null, - last_message: messageIdOf('brain/acme/alpha/executions/r1', 3), + last_message: messageIdOf('brain/acme/alpha/runs/r1', 3), }, }, ]); @@ -56,10 +56,10 @@ describe('a projection of runs in the in-memory ledger', () => { describe('a read of the rows of a projection in the in-memory ledger', () => { it('reads the rows of one brain by their columns, in order, from where a page ended', async () => { const ledger = memoryLedger(undefined, [runTallyRows]); - await noting(ledger, 'brain/acme/alpha/executions/r1', began('triage', 0)); - await noting(ledger, 'brain/acme/alpha/executions/r2', began('triage', 1), ended); - await noting(ledger, 'brain/acme/alpha/executions/r3', began('review', 2)); - await noting(ledger, 'brain/acme/beta/executions/r4', began('triage', 3)); + await noting(ledger, 'brain/acme/alpha/runs/r1', began('triage', 0)); + await noting(ledger, 'brain/acme/alpha/runs/r2', began('triage', 1), ended); + await noting(ledger, 'brain/acme/alpha/runs/r3', began('review', 2)); + await noting(ledger, 'brain/acme/beta/runs/r4', began('triage', 3)); const runsOf = async (query: ProjectedRowsQuery) => (await read(ledger, query)).map(({ key }) => key); const open = { column: 'open', equals: true }; @@ -77,9 +77,9 @@ describe('a read of the rows of a projection in the in-memory ledger', () => { it('reads the rows due by a time across brains, the soonest first, and the next due time', async () => { const ledger = memoryLedger(undefined, [runTallyRows]); - await noting(ledger, 'brain/acme/alpha/executions/r1', began('triage', 2)); - await noting(ledger, 'brain/globex/gamma/executions/r2', began('triage', 0)); - await noting(ledger, 'brain/acme/beta/executions/r3', began('triage', 1), ended); + await noting(ledger, 'brain/acme/alpha/runs/r1', began('triage', 2)); + await noting(ledger, 'brain/globex/gamma/runs/r2', began('triage', 0)); + await noting(ledger, 'brain/acme/beta/runs/r3', began('triage', 1), ended); const due = (through: number, limit = 10) => Effect.runPromise(ledger.service.readDueRows('run_tallies', { column: 'due_at', through, limit })); @@ -100,37 +100,35 @@ describe('a read of the rows of a projection in the in-memory ledger', () => { describe('an append to a run of which a projection keeps a row, in the in-memory ledger', () => { it('keeps nothing of an append whose projection breaks down, and records nothing of it', async () => { - const breaking: KeyedProjection = tallyRowsOf(1, (row) => row['status'] !== 'started'); + const breaking: KeyedProjection = tallyRowsOf(2, (row) => row['status'] !== 'started'); const ledger = memoryLedger(undefined, [breaking]); - await noting(ledger, 'brain/acme/alpha/executions/r1', began('triage')); + await noting(ledger, 'brain/acme/alpha/runs/r1', began('triage')); - const failed = await Effect.runPromiseExit( - ledger.service.execute('brain/acme/alpha/executions/r1', runFacts, [ended]), - ); + const failed = await Effect.runPromiseExit(ledger.service.execute('brain/acme/alpha/runs/r1', runFacts, [ended])); expect(Exit.isFailure(failed)).toBe(true); expect((await read(ledger)).map(({ row }) => row['status'])).toEqual(['started']); - expect((await Effect.runPromise(ledger.service.load('brain/acme/alpha/executions/r1', runFacts))).version).toBe(1); + expect((await Effect.runPromise(ledger.service.load('brain/acme/alpha/runs/r1', runFacts))).version).toBe(1); }); it('keeps nothing for a run whose facts its mapping does not take', async () => { const ledger = memoryLedger(undefined, [runTallyRows]); - await noting(ledger, 'brain/acme/alpha/executions/r1', ended, { type: 'run_noted' }); + await noting(ledger, 'brain/acme/alpha/runs/r1', ended, { type: 'run_noted' }); expect(await read(ledger)).toEqual([]); }); it('hands its mapping only the facts of the types it names', async () => { const ledger = memoryLedger(undefined, [{ ...runTallyRows, types: ['run_began', 'run_ended'] }]); - await noting(ledger, 'brain/acme/alpha/executions/r1', began('triage'), { type: 'run_noted' }); + await noting(ledger, 'brain/acme/alpha/runs/r1', began('triage'), { type: 'run_noted' }); expect((await read(ledger)).map(({ row }) => row['facts'])).toEqual([1]); }); it('orders a column that is not set before every value that is', async () => { const ledger = memoryLedger(undefined, [runTallyRows]); - await noting(ledger, 'brain/acme/alpha/executions/r1', began('triage', 0)); - await noting(ledger, 'brain/acme/alpha/executions/r2', began('triage', 1), ended); + await noting(ledger, 'brain/acme/alpha/runs/r1', began('triage', 0)); + await noting(ledger, 'brain/acme/alpha/runs/r2', began('triage', 1), ended); const byDue = { where: [], orderBy: ['due_at'], limit: 10 }; expect((await read(ledger, { ...byDue, order: 'asc' })).map(({ key }) => key)).toEqual(['r2', 'r1']); @@ -140,9 +138,9 @@ describe('an append to a run of which a projection keeps a row, in the in-memory describe('the table of a projection', () => { it('is named for the projection and its version, so a new version is a new table', () => { - expect([projectedTableOf(runTallyRows), projectedTableOf(tallyRowsOf(2))]).toEqual([ - 'run_tallies_1', + expect([projectedTableOf(runTallyRows), projectedTableOf(tallyRowsOf(3))]).toEqual([ 'run_tallies_2', + 'run_tallies_3', ]); }); }); @@ -169,7 +167,7 @@ describe('a projection declared with names a table cannot hold', () => { { ...runTallyRows, indexes: [{ name: 'due', columns: ['due_at'], whereSet: 'due' }] }, ], ['The projection run_tallies names no stream kind it folds', { ...runTallyRows, kinds: [] }], - ['The stream kind name Executions is malformed', { ...runTallyRows, kinds: ['Executions'] }], + ['The stream kind name Runs is malformed', { ...runTallyRows, kinds: ['Runs'] }], [ 'The projection topics has no column closed', { ...topicRows, advanced: { columns: ['closed'], setBy: ['topic_opened'] } }, @@ -198,7 +196,7 @@ function topicsOf(ledger: MemoryLedger) { describe('a projection keyed by what its mapping says, over the stream kinds it names', () => { it('keeps one row a key from the facts of every stream of its kinds, and none of another kind', async () => { const ledger = memoryLedger(undefined, [topicRows]); - await topics(ledger, 'brain/acme/alpha/executions/r1', { type: 'topic_opened', topic: 'spring', at: nine }); + await topics(ledger, 'brain/acme/alpha/runs/r1', { type: 'topic_opened', topic: 'spring', at: nine }); await topics(ledger, 'brain/acme/alpha/notes/n1', { type: 'topic_noted', topic: 'spring', note: 'first' }); await topics(ledger, 'brain/acme/alpha/notes/n2', { type: 'topic_noted', topic: 'autumn', note: 'none' }); await topics(ledger, 'brain/acme/alpha/others/o1', { type: 'topic_noted', topic: 'spring', note: 'ignored' }); @@ -223,7 +221,7 @@ describe('a projection keyed by what its mapping says, over the stream kinds it it('lets its reader advance the columns it declares on one row, and no other column', async () => { const ledger = memoryLedger(undefined, [topicRows]); - await topics(ledger, 'brain/acme/alpha/executions/r1', { type: 'topic_opened', topic: 'spring', at: nine }); + await topics(ledger, 'brain/acme/alpha/runs/r1', { type: 'topic_opened', topic: 'spring', at: nine }); await Effect.runPromise( ledger.service.advanceRow('topics', alpha, 'spring', { @@ -248,8 +246,8 @@ describe('a projection keyed by what its mapping says, over the stream kinds it describe('the advance of a row of the in-memory ledger that its fold changed since its reader read it', () => { it('advances a row only while the columns it is told to compare still hold what its reader read', async () => { const ledger = memoryLedger(undefined, [topicRows]); - await topics(ledger, 'brain/acme/alpha/executions/r1', { type: 'topic_opened', topic: 'spring', at: nine }); - const readAt = messageIdOf('brain/acme/alpha/executions/r1', 1); + await topics(ledger, 'brain/acme/alpha/runs/r1', { type: 'topic_opened', topic: 'spring', at: nine }); + const readAt = messageIdOf('brain/acme/alpha/runs/r1', 1); await topics(ledger, 'brain/acme/alpha/notes/n1', { type: 'topic_noted', topic: 'spring', note: 'meanwhile' }); await Effect.runPromise( diff --git a/packages/operations/src/projections/tally-rows.ts b/packages/operations/src/projections/tally-rows.ts index 20275bfc4..e9dd09d02 100644 --- a/packages/operations/src/projections/tally-rows.ts +++ b/packages/operations/src/projections/tally-rows.ts @@ -46,7 +46,7 @@ function rowAfterFact( export function tallyRowsOf(version: number, fail?: (row: ProjectedRow) => boolean): KeyedProjection { return { name: 'run_tallies', - kinds: ['executions'], + kinds: ['runs'], version, types: ['run_began', 'run_ended', 'run_noted'], columns: [ @@ -75,7 +75,7 @@ export function tallyRowsOf(version: number, fail?: (row: ProjectedRow) => boole }; } -export const runTallyRows = tallyRowsOf(1); +export const runTallyRows = tallyRowsOf(2); export const readTallyRows = defineQuery('brain', { name: 'read_tally_rows', diff --git a/packages/operations/src/projections/topic-rows.ts b/packages/operations/src/projections/topic-rows.ts index 64bf7e866..b351c4d7a 100644 --- a/packages/operations/src/projections/topic-rows.ts +++ b/packages/operations/src/projections/topic-rows.ts @@ -43,8 +43,8 @@ function rowAfterFact( export const topicRows: KeyedProjection = { name: 'topics', - version: 1, - kinds: ['executions', 'notes'], + version: 2, + kinds: ['runs', 'notes'], types: ['topic_opened', 'topic_noted'], columns: [ { name: 'topic', kind: 'text' }, diff --git a/packages/operations/src/reading/event-paging.test.ts b/packages/operations/src/reading/event-paging.test.ts index 748ad6b91..4eb2871ab 100644 --- a/packages/operations/src/reading/event-paging.test.ts +++ b/packages/operations/src/reading/event-paging.test.ts @@ -13,7 +13,7 @@ function recordOf(position: number, steps: number): RecordedEvent { cursor: cursorOfParts(['brain/acme/alpha/', String(position)]), causationId: null, correlationId: null, - stream: 'runs/r1', + stream: 'run-logs/r1', version: position, type: 'moved', data: { position, steps }, @@ -37,7 +37,7 @@ function eventsOf({ id, cursor, data }: RecordedEvent): readonly PublicEvent[] { } const presentation = presentationOf([ - { streamKind: 'runs', publicNames: { moved: ['moved', 'stepped'] }, present: eventsOf }, + { streamKind: 'run-logs', publicNames: { moved: ['moved', 'stepped'] }, present: eventsOf }, ]); function pageOf(records: readonly RecordedEvent[], nextCursor: string | null = null): RecordedPage { @@ -61,7 +61,7 @@ describe('a page of events', () => { true, 'later', ]); - expect(page.events.map(({ stream }) => stream)).toEqual(Array.from({ length: 5 }, () => 'runs/r1')); + expect(page.events.map(({ stream }) => stream)).toEqual(Array.from({ length: 5 }, () => 'run-logs/r1')); }); it('ends inside a record when its limit falls there, with a cursor that reads on from the next event', () => { diff --git a/packages/operations/src/reading/presentation.test.ts b/packages/operations/src/reading/presentation.test.ts index a056f4ef4..2881a53b9 100644 --- a/packages/operations/src/reading/presentation.test.ts +++ b/packages/operations/src/reading/presentation.test.ts @@ -44,8 +44,8 @@ const presentation = presentationOf([notes, shelves]); describe('the kind of a stream', () => { it.each([ - ['executions/0199a3c4-7d2e-7c1a-9b3f-2f1e0d9c8b7a', 'executions'], - ['specs/inference', 'specs'], + ['runs/0199a3c4-7d2e-7c1a-9b3f-2f1e0d9c8b7a', 'runs'], + ['definitions/reasoning', 'definitions'], ['notes', 'notes'], ])('of %s is %s', (stream, kind) => { expect(streamKindOf(stream)).toBe(kind); @@ -63,7 +63,7 @@ describe('the presentation of what a brain recorded', () => { it('hides a record of a kind without a presenter, of a type its presenter hides or does not know, or one its presenter hides', () => { expect( [ - recorded('runs/0199a3c4-7d2e-7c1a-9b3f-2f1e0d9c8b7a', 'input_applied'), + recorded('run-logs/0199a3c4-7d2e-7c1a-9b3f-2f1e0d9c8b7a', 'input_applied'), recorded('notes', 'note_dropped'), recorded('notes', 'note_burnt'), recorded('notes', 'constructor'), diff --git a/packages/operations/src/reading/recorded-read.ts b/packages/operations/src/reading/recorded-read.ts index fda6ff23b..04fe0d496 100644 --- a/packages/operations/src/reading/recorded-read.ts +++ b/packages/operations/src/reading/recorded-read.ts @@ -5,12 +5,12 @@ export type RecordedOrder = 'asc' | 'desc'; export type RecordedSelection = | { readonly kind: 'everything' } | { - readonly kind: 'executions'; + readonly kind: 'runs'; readonly notBeginningWith?: readonly string[]; - readonly primitive?: string; + readonly definitionType?: string; readonly name?: string; } - | { readonly kind: 'run'; readonly execution: string } + | { readonly kind: 'run'; readonly run: string } | { readonly kind: 'correlated'; readonly correlation: string }; export interface RecordedPageRequest { diff --git a/packages/operations/src/run-outcomes/memory-run-outcomes.test.ts b/packages/operations/src/run-outcomes/memory-run-outcomes.test.ts index 9690fe1e1..6db18b4ce 100644 --- a/packages/operations/src/run-outcomes/memory-run-outcomes.test.ts +++ b/packages/operations/src/run-outcomes/memory-run-outcomes.test.ts @@ -51,14 +51,14 @@ const failingOnAFailure: RunOutcomeMapping = { }; describe('a run stream', () => { - it('is a stream named executions/ in a brain, and nothing nested under it', () => { + it('is a stream named runs/ in a brain, and nothing nested under it', () => { expect( [ - 'brain/acme/alpha/executions/r1', - 'brain/acme/alpha/executions/r1/more', 'brain/acme/alpha/runs/r1', - 'brain/acme/executions/r1', - 'brain/acme/alpha/executions/', + 'brain/acme/alpha/runs/r1/more', + 'brain/acme/alpha/run-logs/r1', + 'brain/acme/runs/r1', + 'brain/acme/alpha/runs/', ].map((stream) => runStreamOf(stream)), ).toEqual([{ brainKey: 'brain/acme/alpha/', runId: 'r1' }, undefined, undefined, undefined, undefined]); }); @@ -67,11 +67,11 @@ describe('a run stream', () => { describe('the outcomes of runs in the in-memory ledger', () => { it('are one row per run, from the mapping it is given, grouped by day, function and status', async () => { const ledger = memoryLedger(runTallies); - await noting(ledger, 'brain/acme/alpha/executions/r1', began('triage'), ended('succeeded', 120, 40)); - await noting(ledger, 'brain/acme/alpha/executions/r2', began('triage')); - await noting(ledger, 'brain/acme/alpha/executions/r2', ended('succeeded', 80, 2)); - await noting(ledger, 'brain/acme/alpha/executions/r3', began('triage'), ended('rejected', null)); - await noting(ledger, 'brain/acme/alpha/executions/r4', began('draft', '2026-10-02T23:59:59.999Z')); + await noting(ledger, 'brain/acme/alpha/runs/r1', began('triage'), ended('succeeded', 120, 40)); + await noting(ledger, 'brain/acme/alpha/runs/r2', began('triage')); + await noting(ledger, 'brain/acme/alpha/runs/r2', ended('succeeded', 80, 2)); + await noting(ledger, 'brain/acme/alpha/runs/r3', began('triage'), ended('rejected', null)); + await noting(ledger, 'brain/acme/alpha/runs/r4', began('draft', '2026-10-02T23:59:59.999Z')); const groups = await reading(ledger); @@ -86,16 +86,16 @@ describe('the outcomes of runs in the in-memory ledger', () => { it('keep to the days of the window, the selection and the brain asked for', async () => { const ledger = memoryLedger(runTallies); - await noting(ledger, 'brain/acme/alpha/executions/r1', began('triage', '2026-09-30T23:59:59.999Z')); - await noting(ledger, 'brain/acme/alpha/executions/r2', began('triage', '2026-10-01T00:00:00.000Z')); - await noting(ledger, 'brain/acme/alpha/executions/r3', began('draft', '2026-10-01T00:00:00.000Z')); - await noting(ledger, 'brain/acme/alpha2/executions/r4', began('triage', '2026-10-01T00:00:00.000Z')); + await noting(ledger, 'brain/acme/alpha/runs/r1', began('triage', '2026-09-30T23:59:59.999Z')); + await noting(ledger, 'brain/acme/alpha/runs/r2', began('triage', '2026-10-01T00:00:00.000Z')); + await noting(ledger, 'brain/acme/alpha/runs/r3', began('draft', '2026-10-01T00:00:00.000Z')); + await noting(ledger, 'brain/acme/alpha2/runs/r4', began('triage', '2026-10-01T00:00:00.000Z')); const pages = await Promise.all([ reading(ledger, { from: '2026-10-01', to: '2026-10-01' }), reading(ledger, october, { name: 'draft' }), - reading(ledger, october, { primitive: 'other' }), - reading(ledger, october, { primitive: 'tally', name: 'triage' }), + reading(ledger, october, { definitionType: 'other' }), + reading(ledger, october, { definitionType: 'tally', name: 'triage' }), ]); expect(pages.map((groups) => runsOf(groups))).toEqual([ @@ -110,15 +110,15 @@ describe('the outcomes of runs in the in-memory ledger', () => { describe('what the in-memory ledger keeps no outcome of', () => { it('is another stream, another type, and an event the mapping keeps nothing of', async () => { const ledger = memoryLedger(runTallies); - await noting(ledger, 'brain/acme/alpha/runs/r1', began('triage')); - await noting(ledger, 'brain/acme/alpha/executions/r2', ended('failed', 5), { type: 'run_noted' }); + await noting(ledger, 'brain/acme/alpha/run-logs/r1', began('triage')); + await noting(ledger, 'brain/acme/alpha/runs/r2', ended('failed', 5), { type: 'run_noted' }); expect(await reading(ledger)).toEqual([]); }); it('is anything, without a mapping', async () => { const ledger = memoryLedger(); - await noting(ledger, 'brain/acme/alpha/executions/r1', began('triage')); + await noting(ledger, 'brain/acme/alpha/runs/r1', began('triage')); expect(await reading(ledger)).toEqual([]); }); @@ -127,9 +127,7 @@ describe('what the in-memory ledger keeps no outcome of', () => { const ledger = memoryLedger(failingOnAFailure); const appended = await Effect.runPromise( - Effect.exit( - ledger.service.execute('brain/acme/alpha/executions/r1', runFacts, [began('triage'), ended('failed', 5)]), - ), + Effect.exit(ledger.service.execute('brain/acme/alpha/runs/r1', runFacts, [began('triage'), ended('failed', 5)])), ); expect(Exit.hasDies(appended)).toBe(true); diff --git a/packages/operations/src/run-outcomes/memory-run-outcomes.ts b/packages/operations/src/run-outcomes/memory-run-outcomes.ts index 496ecb1e7..b18b4cfd9 100644 --- a/packages/operations/src/run-outcomes/memory-run-outcomes.ts +++ b/packages/operations/src/run-outcomes/memory-run-outcomes.ts @@ -22,17 +22,27 @@ export interface MemoryRunOutcomes { readonly readRunOutcomes: RunOutcomesReader['readRunOutcomes']; } -function isSelected(row: RunOutcome, window: RunOutcomeWindow, { primitive, name }: RunOutcomeSelection): boolean { +function isSelected(row: RunOutcome, window: RunOutcomeWindow, { definitionType, name }: RunOutcomeSelection): boolean { return ( row.startedDay >= window.from && row.startedDay <= window.to && - (primitive === undefined || row.primitive === primitive) && + (definitionType === undefined || row.definitionType === definitionType) && (name === undefined || row.name === name) ); } -function emptyGroupOf({ startedDay: day, primitive, name, status }: RunOutcome): RunOutcomeGroup { - return { day, primitive, name, status, runs: 0, inputTokens: 0, outputTokens: 0, cachedTokens: 0, durations: [] }; +function emptyGroupOf({ startedDay: day, definitionType, name, status }: RunOutcome): RunOutcomeGroup { + return { + day, + definitionType, + name, + status, + runs: 0, + inputTokens: 0, + outputTokens: 0, + cachedTokens: 0, + durations: [], + }; } function added(group: RunOutcomeGroup, row: RunOutcome): RunOutcomeGroup { @@ -49,7 +59,7 @@ function added(group: RunOutcomeGroup, row: RunOutcome): RunOutcomeGroup { function grouped(rows: readonly RunOutcome[]): readonly RunOutcomeGroup[] { const groups = new Map(); for (const row of rows) { - const key = JSON.stringify([row.startedDay, row.primitive, row.name, row.status]); + const key = JSON.stringify([row.startedDay, row.definitionType, row.name, row.status]); groups.set(key, added(groups.get(key) ?? emptyGroupOf(row), row)); } return [...groups.values()]; diff --git a/packages/operations/src/run-outcomes/run-outcomes.ts b/packages/operations/src/run-outcomes/run-outcomes.ts index a6f6c665d..75fd84382 100644 --- a/packages/operations/src/run-outcomes/run-outcomes.ts +++ b/packages/operations/src/run-outcomes/run-outcomes.ts @@ -8,7 +8,7 @@ export interface RunOutcome { readonly startedDay: string; readonly startedAt: string; readonly lastStartedAt: string; - readonly primitive: string; + readonly definitionType: string; readonly name: string; readonly status: RunOutcomeStatus; readonly durationMs: number | null; @@ -28,13 +28,13 @@ export interface RunOutcomeWindow { } export interface RunOutcomeSelection { - readonly primitive?: string; + readonly definitionType?: string; readonly name?: string; } export interface RunOutcomeGroup { readonly day: string; - readonly primitive: string; + readonly definitionType: string; readonly name: string; readonly status: RunOutcomeStatus; readonly runs: number; @@ -49,7 +49,7 @@ export interface RunStream { readonly runId: string; } -const runStream = /^(?[^/]+\/[^/]+\/[^/]+\/)executions\/(?[^/]+)$/u; +const runStream = /^(?[^/]+\/[^/]+\/[^/]+\/)runs\/(?[^/]+)$/u; export function runStreamOf(stream: string): RunStream | undefined { const groups = runStream.exec(stream)?.groups; diff --git a/packages/operations/src/run-outcomes/run-tallies.ts b/packages/operations/src/run-outcomes/run-tallies.ts index c7b96f481..6e32244d7 100644 --- a/packages/operations/src/run-outcomes/run-tallies.ts +++ b/packages/operations/src/run-outcomes/run-tallies.ts @@ -1,6 +1,13 @@ import { Effect, Option, Result, Schema } from 'effect'; -import { BrainReader, defineQuery, type Decider, type RunOutcome, type RunOutcomeMapping } from '../index.ts'; +import { + BrainReader, + defineQuery, + type Decider, + type RunOutcome, + type RunOutcomeGroup, + type RunOutcomeMapping, +} from '../index.ts'; const BeganSchema = Schema.Struct({ type: Schema.Literal('run_began'), at: Schema.String, fn: Schema.String }); @@ -30,7 +37,13 @@ const decodeFact = Schema.decodeUnknownOption(TalliedSchema); function tallied(row: RunOutcome | undefined, fact: typeof TalliedSchema.Type): RunOutcome | undefined { if (fact.type === 'run_began') { const { at, fn } = fact; - const started = { startedDay: at.slice(0, 10), startedAt: at, lastStartedAt: at, primitive: 'tally', name: fn }; + const started = { + startedDay: at.slice(0, 10), + startedAt: at, + lastStartedAt: at, + definitionType: 'tally', + name: fn, + }; return { ...started, status: 'started', @@ -55,7 +68,7 @@ export const runTallies: RunOutcomeMapping = { const GroupSchema = Schema.Struct({ day: Schema.String, - primitive: Schema.String, + type: Schema.String, name: Schema.String, status: Schema.String, runs: Schema.Int, @@ -65,6 +78,20 @@ const GroupSchema = Schema.Struct({ durations: Schema.Array(Schema.Int), }); +function talliedGroupOf(group: RunOutcomeGroup): typeof GroupSchema.Type { + return { + day: group.day, + type: group.definitionType, + name: group.name, + status: group.status, + runs: group.runs, + inputTokens: group.inputTokens, + outputTokens: group.outputTokens, + cachedTokens: group.cachedTokens, + durations: group.durations, + }; +} + export const readRunTallies = defineQuery('brain', { name: 'read_run_tallies', title: 'Read run tallies', @@ -75,6 +102,7 @@ export const readRunTallies = defineQuery('brain', { reasons: [], handle: Effect.fnUntraced(function* ({ from, to, name }) { const selection = name === undefined ? {} : { name }; - return { groups: yield* (yield* BrainReader).readRunOutcomes({ from, to }, selection) }; + const groups = yield* (yield* BrainReader).readRunOutcomes({ from, to }, selection); + return { groups: groups.map((group) => talliedGroupOf(group)) }; }), }); diff --git a/packages/operations/src/testing/memory-ledger.test.ts b/packages/operations/src/testing/memory-ledger.test.ts index f3aef9501..6eeecf68c 100644 --- a/packages/operations/src/testing/memory-ledger.test.ts +++ b/packages/operations/src/testing/memory-ledger.test.ts @@ -50,12 +50,12 @@ const everything: RecordedSelection = { kind: 'everything' }; function aBrainWith(ledger: MemoryLedger) { return Effect.all([ - recording(ledger, 1000, 'brain/acme/alpha/executions/r1', 'execution_started'), - recording(ledger, 1000, 'brain/acme/alpha/runs/r1', 'input_applied'), + recording(ledger, 1000, 'brain/acme/alpha/runs/r1', 'run_started'), + recording(ledger, 1000, 'brain/acme/alpha/run-logs/r1', 'input_applied'), recording(ledger, 2000, 'brain/acme/alpha2/notes', 'noted'), recording(ledger, 2000, 'org/acme/brains', 'brain_created'), - recording(ledger, 3000, 'brain/acme/alpha/executions/r1', 'execution_succeeded'), - recording(ledger, 4000, 'brain/acme/alpha/executions/r2', 'execution_started'), + recording(ledger, 3000, 'brain/acme/alpha/runs/r1', 'run_succeeded'), + recording(ledger, 4000, 'brain/acme/alpha/runs/r2', 'run_started'), ]); } @@ -83,10 +83,10 @@ describe('the in-memory read of what a brain recorded', () => { ); expect(pages).toEqual([ - { types: ['execution_started', 'input_applied', 'execution_succeeded'], hasMore: true }, - { types: ['execution_started'], hasMore: false }, - { types: ['execution_started', 'execution_succeeded', 'input_applied'], hasMore: true }, - { types: ['execution_started'], hasMore: false }, + { types: ['run_started', 'input_applied', 'run_succeeded'], hasMore: true }, + { types: ['run_started'], hasMore: false }, + { types: ['run_started', 'run_succeeded', 'input_applied'], hasMore: true }, + { types: ['run_started'], hasMore: false }, ]); }); }); @@ -99,27 +99,27 @@ describe('the in-memory read of runs', () => { Effect.gen(function* () { yield* aBrainWith(ledger); return yield* Effect.all([ - reading(ledger, { kind: 'run', execution: 'r1' }, { order: 'asc', limit: 10 }), - reading(ledger, { kind: 'executions' }, { order: 'desc', limit: 10 }), - reading(ledger, { kind: 'executions' }, { order: 'desc', limit: 10, types: ['execution_succeeded'] }), + reading(ledger, { kind: 'run', run: 'r1' }, { order: 'asc', limit: 10 }), + reading(ledger, { kind: 'runs' }, { order: 'desc', limit: 10 }), + reading(ledger, { kind: 'runs' }, { order: 'desc', limit: 10, types: ['run_succeeded'] }), ]); }), ); expect(ofRun.records.map(({ stream, type }) => `${stream} ${type}`)).toEqual([ - 'brain/acme/alpha/executions/r1 execution_started', - 'brain/acme/alpha/runs/r1 input_applied', - 'brain/acme/alpha/executions/r1 execution_succeeded', + 'brain/acme/alpha/runs/r1 run_started', + 'brain/acme/alpha/run-logs/r1 input_applied', + 'brain/acme/alpha/runs/r1 run_succeeded', ]); - expect(typesOf(runs)).toEqual(['execution_started', 'execution_started', 'execution_succeeded']); + expect(typesOf(runs)).toEqual(['run_started', 'run_started', 'run_succeeded']); expect(runs.records.map(({ stream }) => stream)).toEqual([ - 'brain/acme/alpha/executions/r2', - 'brain/acme/alpha/executions/r1', - 'brain/acme/alpha/executions/r1', + 'brain/acme/alpha/runs/r2', + 'brain/acme/alpha/runs/r1', + 'brain/acme/alpha/runs/r1', ]); expect(succeeded.records.map(({ stream }) => stream)).toEqual([ - 'brain/acme/alpha/executions/r1', - 'brain/acme/alpha/executions/r1', + 'brain/acme/alpha/runs/r1', + 'brain/acme/alpha/runs/r1', ]); }); }); @@ -135,16 +135,16 @@ describe('the in-memory read from a time or of some types', () => { reading(ledger, everything, { order: 'asc', limit: 10, since: new Date(1000).toISOString() }), reading(ledger, everything, { order: 'desc', limit: 10, since: new Date(2500).toISOString() }), reading(ledger, everything, { order: 'asc', limit: 10, since: new Date(5000).toISOString() }), - reading(ledger, everything, { order: 'asc', limit: 10, types: ['execution_succeeded', 'input_applied'] }), + reading(ledger, everything, { order: 'asc', limit: 10, types: ['run_succeeded', 'input_applied'] }), ]); }), ); expect(pages.map((page) => ({ types: typesOf(page), next: page.nextCursor }))).toEqual([ - { types: ['execution_started', 'input_applied', 'execution_succeeded', 'execution_started'], next: null }, - { types: ['execution_started', 'execution_succeeded'], next: null }, + { types: ['run_started', 'input_applied', 'run_succeeded', 'run_started'], next: null }, + { types: ['run_started', 'run_succeeded'], next: null }, { types: [], next: null }, - { types: ['input_applied', 'execution_succeeded'], next: null }, + { types: ['input_applied', 'run_succeeded'], next: null }, ]); expect(pages[0]?.records.map(({ recordedAt }) => recordedAt).at(-1)).toBe('1970-01-01T00:00:04.000Z'); }); @@ -158,8 +158,8 @@ describe('the in-memory read that loads the data of some types alone', () => { Effect.gen(function* () { yield* aBrainWith(ledger); return yield* Effect.all([ - reading(ledger, everything, { order: 'asc', limit: 10, dataOf: ['execution_succeeded'] }), - reading(ledger, { kind: 'executions' }, { order: 'asc', limit: 10, dataOf: [] }), + reading(ledger, everything, { order: 'asc', limit: 10, dataOf: ['run_succeeded'] }), + reading(ledger, { kind: 'runs' }, { order: 'asc', limit: 10, dataOf: [] }), ]); }), ); @@ -168,15 +168,15 @@ describe('the in-memory read that loads the data of some types alone', () => { pages.map(({ records }) => records.map(({ type, version, data }) => [type, version, data !== undefined])), ).toEqual([ [ - ['execution_started', 1, false], + ['run_started', 1, false], ['input_applied', 1, false], - ['execution_succeeded', 2, true], - ['execution_started', 1, false], + ['run_succeeded', 2, true], + ['run_started', 1, false], ], [ - ['execution_started', 1, false], - ['execution_succeeded', 2, false], - ['execution_started', 1, false], + ['run_started', 1, false], + ['run_succeeded', 2, false], + ['run_started', 1, false], ], ]); }); @@ -231,8 +231,8 @@ describe('the in-memory read from inside a record', () => { ); expect(pages.map((page) => typesOf(page))).toEqual([ - ['input_applied', 'execution_succeeded'], - ['input_applied', 'execution_started'], + ['input_applied', 'run_succeeded'], + ['input_applied', 'run_started'], ]); }); }); @@ -244,7 +244,12 @@ describe('the in-memory lineage of what a brain recorded', () => { const [correlated, page] = await run( Effect.gen(function* () { yield* aBrainWith(ledger); - yield* ledger.service.execute('brain/acme/alpha/runs/r1', happenings, [{ type: 'noted', note: '' }], lineage); + yield* ledger.service.execute( + 'brain/acme/alpha/run-logs/r1', + happenings, + [{ type: 'noted', note: '' }], + lineage, + ); return yield* Effect.all([ reading(ledger, { kind: 'correlated', correlation: 'r1' }, { order: 'asc', limit: 10 }), reading(ledger, everything, { order: 'asc', limit: 1 }), @@ -253,10 +258,14 @@ describe('the in-memory lineage of what a brain recorded', () => { ); expect(correlated.records.map(({ id, stream, causationId }) => ({ id, stream, causationId }))).toEqual([ - { id: messageIdOf('brain/acme/alpha/runs/r1', 2), stream: 'brain/acme/alpha/runs/r1', causationId: 'cause' }, + { + id: messageIdOf('brain/acme/alpha/run-logs/r1', 2), + stream: 'brain/acme/alpha/run-logs/r1', + causationId: 'cause', + }, ]); expect(page.records.map(({ id, causationId, correlationId }) => ({ id, causationId, correlationId }))).toEqual([ - { id: messageIdOf('brain/acme/alpha/executions/r1', 1), causationId: null, correlationId: null }, + { id: messageIdOf('brain/acme/alpha/runs/r1', 1), causationId: null, correlationId: null }, ]); }); }); diff --git a/packages/operations/src/testing/memory-recorded.ts b/packages/operations/src/testing/memory-recorded.ts index 8b7fab5e4..4dc047533 100644 --- a/packages/operations/src/testing/memory-recorded.ts +++ b/packages/operations/src/testing/memory-recorded.ts @@ -40,10 +40,10 @@ interface Resumed { readonly inclusive: boolean; } -type RunsSelection = Extract; +type RunsSelection = Extract; const DefinitionHeldSchema = Schema.Struct({ - primitive: Schema.optionalKey(Schema.Unknown), + definition_type: Schema.optionalKey(Schema.Unknown), name: Schema.optionalKey(Schema.Unknown), }); @@ -97,13 +97,13 @@ function loadedSizeOf(page: RecordedPageRequest, record: MemoryRecord): number { function inSelection(key: string, selection: RecordedSelection): (record: MemoryRecord) => boolean { if (selection.kind === 'run') { - const streams = new Set([`${key}executions/${selection.execution}`, `${key}runs/${selection.execution}`]); + const streams = new Set([`${key}runs/${selection.run}`, `${key}run-logs/${selection.run}`]); return ({ stream }) => streams.has(stream); } - if (selection.kind === 'executions') { + if (selection.kind === 'runs') { const leftOut = new Set(selection.notBeginningWith); return ({ stream, streamPosition, type }) => - streamPosition === 1 && stream.startsWith(`${key}executions/`) && !leftOut.has(type); + streamPosition === 1 && stream.startsWith(`${key}runs/`) && !leftOut.has(type); } return selection.kind === 'correlated' ? ({ correlationId }) => correlationId === selection.correlation : () => true; } @@ -145,11 +145,13 @@ function holdsWhatWasAsked(asked: string | undefined, held: unknown): boolean { return asked === undefined || held === asked; } -function isOfTheDefinitionAsked({ primitive, name }: RunsSelection, { data }: MemoryRecord): boolean { - const asksForNone = primitive === undefined && name === undefined; +function isOfTheDefinitionAsked({ definitionType, name }: RunsSelection, { data }: MemoryRecord): boolean { + const asksForNone = definitionType === undefined && name === undefined; return ( asksForNone || - (holdsADefinition(data) && holdsWhatWasAsked(primitive, data.primitive) && holdsWhatWasAsked(name, data.name)) + (holdsADefinition(data) && + holdsWhatWasAsked(definitionType, data.definition_type) && + holdsWhatWasAsked(name, data.name)) ); } @@ -213,9 +215,9 @@ function pageOf( const candidates = inOrder.filter( (record) => inSelection(key, selection)(record) && record.position >= from && isBeyond(record.position), ); - const cap = page.types === undefined && selection.kind !== 'executions' ? page.limit : mostExaminedInAPage; + const cap = page.types === undefined && selection.kind !== 'runs' ? page.limit : mostExaminedInAPage; const examined = - selection.kind === 'executions' + selection.kind === 'runs' ? examinedRuns(log, candidates, page, selection) : examinedRecords(candidates, page, cap); const { delivered, resumeAfter, lastExamined } = boundedPage(examined, page.limit, cap); diff --git a/packages/operations/src/testing/memory-runs.test.ts b/packages/operations/src/testing/memory-runs.test.ts index cf2192538..15ff5ae4f 100644 --- a/packages/operations/src/testing/memory-runs.test.ts +++ b/packages/operations/src/testing/memory-runs.test.ts @@ -17,22 +17,20 @@ const happenings: Decider = { eventSchema: HappenedSchema, }; -const runsOnly: RecordedSelection = { kind: 'executions', notBeginningWith: ['execution_cancel_requested'] }; +const runsOnly: RecordedSelection = { kind: 'runs', notBeginningWith: ['run_cancel_requested'] }; const newestHundred: RecordedPageRequest = { order: 'desc', limit: 100 }; function streamsBeginningWith(ledger: MemoryLedger, firstTypes: readonly string[]) { return Effect.forEach(firstTypes, (type, index) => TestClock.setTime(1000 + index).pipe( - Effect.andThen(ledger.service.execute(`brain/acme/alpha/executions/s${index}`, happenings, { type })), + Effect.andThen(ledger.service.execute(`brain/acme/alpha/runs/s${index}`, happenings, { type })), ), ); } function runsWithOneLeftOutEvery(every: number, count: number): readonly string[] { - return Array.from({ length: count }, (_, index) => - index % every === 0 ? 'execution_cancel_requested' : 'execution_started', - ); + return Array.from({ length: count }, (_, index) => (index % every === 0 ? 'run_cancel_requested' : 'run_started')); } function read(ledger: MemoryLedger, selection: RecordedSelection) { @@ -48,7 +46,7 @@ describe('the in-memory read of runs that leaves out the streams beginning with Effect.gen(function* () { yield* streamsBeginningWith(among, runsWithOneLeftOutEvery(21, 105)); yield* streamsBeginningWith(oldest, runsWithOneLeftOutEvery(101, 101)); - return yield* Effect.all([read(among, runsOnly), read(oldest, runsOnly), read(oldest, { kind: 'executions' })]); + return yield* Effect.all([read(among, runsOnly), read(oldest, runsOnly), read(oldest, { kind: 'runs' })]); }).pipe(Effect.provide(TestClock.layer())), ); @@ -57,11 +55,11 @@ describe('the in-memory read of runs that leaves out the streams beginning with [100, false], [100, true], ]); - expect(pages[0]?.records.map(({ type }) => type)).not.toContain('execution_cancel_requested'); + expect(pages[0]?.records.map(({ type }) => type)).not.toContain('run_cancel_requested'); }); }); -const StartSchema = Schema.Struct({ type: Schema.String, primitive: Schema.String, name: Schema.String }); +const StartSchema = Schema.Struct({ type: Schema.String, definition_type: Schema.String, name: Schema.String }); type Start = typeof StartSchema.Type; @@ -76,9 +74,9 @@ function runsWithOneInFourOfEachDefinition(ledger: MemoryLedger) { return Effect.forEach( Array.from({ length: 28 }, (_, index) => index), (index) => - ledger.service.execute(`brain/acme/alpha/executions/r${index}`, starts, { - type: 'execution_started', - primitive: index % 4 === 0 ? 'orchestration' : 'inference', + ledger.service.execute(`brain/acme/alpha/runs/r${index}`, starts, { + type: 'run_started', + definition_type: index % 4 === 0 ? 'workflow' : 'reasoning', name: index % 4 === 1 ? 'qualify-enquiry' : 'summary', }), { discard: true }, @@ -106,22 +104,18 @@ function everyPageOf( } describe('the in-memory read of the runs of one definition', () => { - it('fills every page from the runs of the primitive or the name asked for, and has no more after the last', async () => { + it('fills every page from the runs of the definition type or the name asked for, and has no more after the last', async () => { const ledger = memoryLedger(); const pages = await Effect.runPromise( runsWithOneInFourOfEachDefinition(ledger).pipe( Effect.andThen( Effect.all([ - everyPageOf(ledger, { kind: 'executions', primitive: 'orchestration' }, { order: 'desc', limit: 5 }), - everyPageOf(ledger, { kind: 'executions', name: 'qualify-enquiry' }, { order: 'desc', limit: 2 }), - everyPageOf(ledger, { kind: 'executions', primitive: 'inference', name: 'qualify-enquiry' }, newestHundred), - everyPageOf(ledger, { kind: 'executions', primitive: 'orchestration', name: 'summary' }, newestHundred), - everyPageOf( - ledger, - { kind: 'executions', primitive: 'orchestration', name: 'qualify-enquiry' }, - newestHundred, - ), + everyPageOf(ledger, { kind: 'runs', definitionType: 'workflow' }, { order: 'desc', limit: 5 }), + everyPageOf(ledger, { kind: 'runs', name: 'qualify-enquiry' }, { order: 'desc', limit: 2 }), + everyPageOf(ledger, { kind: 'runs', definitionType: 'reasoning', name: 'qualify-enquiry' }, newestHundred), + everyPageOf(ledger, { kind: 'runs', definitionType: 'workflow', name: 'summary' }, newestHundred), + everyPageOf(ledger, { kind: 'runs', definitionType: 'workflow', name: 'qualify-enquiry' }, newestHundred), ]), ), ), @@ -153,10 +147,10 @@ describe('the in-memory read of the runs of one definition, over a first record id: 'message-1', causationId: null, correlationId: null, - stream: 'brain/acme/alpha/executions/r1', + stream: 'brain/acme/alpha/runs/r1', streamPosition: 1, - type: 'execution_started', - data: 'orchestration', + type: 'run_started', + data: 'workflow', recordedAt: '2026-10-07T09:00:00.000Z', }, ]); @@ -167,12 +161,12 @@ describe('the in-memory read of the runs of one definition, over a first record const pages = await Effect.runPromise( Effect.all([ - reading({ kind: 'executions' }), - reading({ kind: 'executions', primitive: 'orchestration' }), - reading({ kind: 'executions', name: 'orchestration' }), + reading({ kind: 'runs' }), + reading({ kind: 'runs', definitionType: 'workflow' }), + reading({ kind: 'runs', name: 'workflow' }), ]), ); - expect(pages).toEqual([['orchestration'], [], []]); + expect(pages).toEqual([['workflow'], [], []]); }); }); diff --git a/packages/operations/src/testing/notes.ts b/packages/operations/src/testing/notes.ts index b7fc081fc..acfbc8319 100644 --- a/packages/operations/src/testing/notes.ts +++ b/packages/operations/src/testing/notes.ts @@ -99,9 +99,9 @@ export const copyNote = defineCommand('brain', { }), }); -function selectionOf(execution: string | undefined, correlation: string | undefined): RecordedSelection { - if (execution !== undefined) { - return { kind: 'run', execution }; +function selectionOf(run: string | undefined, correlation: string | undefined): RecordedSelection { + if (run !== undefined) { + return { kind: 'run', run }; } return correlation === undefined ? { kind: 'everything' } : { kind: 'correlated', correlation }; } @@ -115,7 +115,7 @@ export const readNoteHistory = defineQuery('brain', { limit: Schema.Int, cursor: Schema.optionalKey(Schema.String), since: Schema.optionalKey(Schema.String), - execution: Schema.optionalKey(Schema.String), + run: Schema.optionalKey(Schema.String), correlation: Schema.optionalKey(Schema.String), }), outputSchema: Schema.Struct({ @@ -124,8 +124,8 @@ export const readNoteHistory = defineQuery('brain', { next_cursor: Schema.NullOr(Schema.String), }), reasons: ['invalid_input'], - handle: Effect.fnUntraced(function* ({ limit, cursor, since, execution, correlation }) { - const { records, nextCursor } = yield* (yield* BrainReader).readRecorded(selectionOf(execution, correlation), { + handle: Effect.fnUntraced(function* ({ limit, cursor, since, run, correlation }) { + const { records, nextCursor } = yield* (yield* BrainReader).readRecorded(selectionOf(run, correlation), { order: 'asc', limit, ...(cursor === undefined ? {} : { cursor }), diff --git a/packages/server/Dockerfile b/packages/server/Dockerfile index a2a9ffacd..910302975 100644 --- a/packages/server/Dockerfile +++ b/packages/server/Dockerfile @@ -11,19 +11,19 @@ COPY packages/server/package.json packages/server/ COPY packages/api/package.json packages/api/ COPY packages/brains/package.json packages/brains/ COPY packages/config/package.json packages/config/ +COPY packages/definitions/package.json packages/definitions/ COPY packages/identity/package.json packages/identity/ COPY packages/ledger/package.json packages/ledger/ COPY packages/mcp/package.json packages/mcp/ COPY packages/operations/package.json packages/operations/ COPY packages/outbound/package.json packages/outbound/ -COPY packages/specs/package.json packages/specs/ COPY packages/workflow-engine/package.json packages/workflow-engine/ COPY packages/workflow-host/package.json packages/workflow-host/ -COPY primitives/computation/package.json primitives/computation/ -COPY primitives/inference/package.json primitives/inference/ -COPY primitives/interaction/package.json primitives/interaction/ -COPY primitives/orchestration/package.json primitives/orchestration/ -COPY primitives/recollection/package.json primitives/recollection/ +COPY capabilities/computation/package.json capabilities/computation/ +COPY capabilities/coordination/package.json capabilities/coordination/ +COPY capabilities/interaction/package.json capabilities/interaction/ +COPY capabilities/reasoning/package.json capabilities/reasoning/ +COPY capabilities/recall/package.json capabilities/recall/ RUN pnpm install --frozen-lockfile --prod --no-optional --ignore-scripts --filter @beonauto/server... RUN --network=none npm_config_build_from_source=sqlite3 npm_config_nodedir=/usr/local \ pnpm rebuild --filter @beonauto/server... sqlite3 \ @@ -31,7 +31,7 @@ RUN --network=none npm_config_build_from_source=sqlite3 npm_config_nodedir=/usr/ -exec rm -rf {} + ARG AZURE_IDENTITY=false RUN case "$AZURE_IDENTITY" in \ - true) pnpm install --frozen-lockfile --prod --ignore-scripts --filter @beonauto/inference ;; \ + true) pnpm install --frozen-lockfile --prod --ignore-scripts --filter @beonauto/reasoning ;; \ false) ;; \ *) echo "AZURE_IDENTITY is true or false, not $AZURE_IDENTITY" >&2 && exit 1 ;; \ esac @@ -47,20 +47,20 @@ COPY docs/reference/*-format.md docs/reference/ COPY packages/api/src packages/api/src COPY packages/brains/src packages/brains/src COPY packages/config/src packages/config/src +COPY packages/definitions/src packages/definitions/src COPY packages/identity/src packages/identity/src COPY packages/ledger/src packages/ledger/src COPY packages/mcp/src packages/mcp/src COPY packages/operations/src packages/operations/src COPY packages/outbound/src packages/outbound/src COPY packages/server/src packages/server/src -COPY packages/specs/src packages/specs/src COPY packages/workflow-engine/src packages/workflow-engine/src COPY packages/workflow-host/src packages/workflow-host/src -COPY primitives/computation/src primitives/computation/src -COPY primitives/inference/src primitives/inference/src -COPY primitives/interaction/src primitives/interaction/src -COPY primitives/orchestration/src primitives/orchestration/src -COPY primitives/recollection/src primitives/recollection/src +COPY capabilities/computation/src capabilities/computation/src +COPY capabilities/coordination/src capabilities/coordination/src +COPY capabilities/interaction/src capabilities/interaction/src +COPY capabilities/reasoning/src capabilities/reasoning/src +COPY capabilities/recall/src capabilities/recall/src RUN apt-get update \ && apt-get install --yes --no-install-recommends tini \ && rm -rf /var/lib/apt/lists/* \ diff --git a/packages/server/Dockerfile.dockerignore b/packages/server/Dockerfile.dockerignore index fed1a2469..5b5496461 100644 --- a/packages/server/Dockerfile.dockerignore +++ b/packages/server/Dockerfile.dockerignore @@ -11,7 +11,7 @@ packages/server/data packages/*/src/testing packages/*/src/*-testing packages/server/src/development -primitives/*/src/testing +capabilities/*/src/testing .git .github .vscode diff --git a/packages/server/measure/conversations.ts b/packages/server/measure/conversations.ts index 326ab68b0..bbac2da93 100644 --- a/packages/server/measure/conversations.ts +++ b/packages/server/measure/conversations.ts @@ -4,7 +4,7 @@ import { serveFakeMcp, type FakeMcpServer } from '@beonauto/mcp/testing'; import type { MeasuredLedger } from './measured-ledgers.ts'; import { brain, inTurns, measuredServer, percentile, type MeasuredServer } from './measured-server.ts'; -import { executionIdOf, pauseSource, spread, timersMeasured, type TimerPlan } from './timer-runs.ts'; +import { runIdOf, pauseSource, spread, timersMeasured, type TimerPlan } from './timer-runs.ts'; const chatKey = 'measure-chat-key-41c7a9e2'; @@ -80,12 +80,14 @@ function readsCounted(chat: FakeMcpServer, count: number): ReadCount { async function askedOn(server: MeasuredServer, chat: FakeMcpServer, count: number): Promise { await server.call('POST', '/v1/orgs/local/brains', { brain: 'measure', name: 'Measure' }); - await server.call('POST', `${brain}/specs/interaction`, { name: 'approval', source: approval }); - await server.call('POST', `${brain}/specs/orchestration`, { name: 'pause', source: pauseSource(30) }); + await server.call('POST', `${brain}/definitions/interaction`, { name: 'approval', source: approval }); + await server.call('POST', `${brain}/definitions/workflow`, { name: 'pause', source: pauseSource(30) }); const from = Date.now(); await inTurns(count, 32, async (index) => { - executionIdOf( - await server.call('POST', `${brain}/specs/interaction/approval/execute`, { input: { owner: `owner-${index}` } }), + runIdOf( + await server.call('POST', `${brain}/definitions/interaction/approval/run`, { + input: { owner: `owner-${index}` }, + }), ); }); await deliveredTo(chat, count); diff --git a/packages/server/measure/expiries.ts b/packages/server/measure/expiries.ts index a153a1255..2c16d10e4 100644 --- a/packages/server/measure/expiries.ts +++ b/packages/server/measure/expiries.ts @@ -2,7 +2,7 @@ import { Schema } from 'effect'; import type { MeasuredLedger } from './measured-ledgers.ts'; import { brain, inTurns, measuredServer, percentile, type MeasuredServer } from './measured-server.ts'; -import { executionIdOf, pauseSource, spread, timersMeasured, type TimerPlan } from './timer-runs.ts'; +import { runIdOf, pauseSource, spread, timersMeasured, type TimerPlan } from './timer-runs.ts'; const timers = { count: 20, seconds: 30, spacingMs: 3000 }; @@ -20,7 +20,7 @@ const approval = [ const decodePage = Schema.decodeUnknownSync( Schema.Struct({ - executions: Schema.Array(Schema.Struct({ status: Schema.String, finished_at: Schema.optionalKey(Schema.String) })), + runs: Schema.Array(Schema.Struct({ status: Schema.String, finished_at: Schema.optionalKey(Schema.String) })), next_cursor: Schema.NullOr(Schema.String), }), ); @@ -29,12 +29,12 @@ type Write = (line: string) => void; async function askedOn(server: MeasuredServer, requests: number): Promise { await server.call('POST', '/v1/orgs/local/brains', { brain: 'measure', name: 'Measure' }); - await server.call('POST', `${brain}/specs/interaction`, { name: 'approval', source: approval }); - await server.call('POST', `${brain}/specs/orchestration`, { name: 'pause', source: pauseSource(timers.seconds) }); + await server.call('POST', `${brain}/definitions/interaction`, { name: 'approval', source: approval }); + await server.call('POST', `${brain}/definitions/workflow`, { name: 'pause', source: pauseSource(timers.seconds) }); const from = Date.now(); await inTurns(requests, 32, async (index) => { - executionIdOf( - await server.call('POST', `${brain}/specs/interaction/approval/execute`, { + runIdOf( + await server.call('POST', `${brain}/definitions/interaction/approval/run`, { input: { owner: `owner-${index % 100}` }, }), ); @@ -48,8 +48,8 @@ async function expiryLagsOf( cursor: string | null = null, ): Promise { const after = cursor === null ? '' : `&cursor=${encodeURIComponent(cursor)}`; - const page = decodePage(await server.call('GET', `${brain}/executions?primitive=interaction&limit=100${after}`)); - const lags = page.executions.flatMap(({ status, finished_at: finishedAt }) => + const page = decodePage(await server.call('GET', `${brain}/runs?type=interaction&limit=100${after}`)); + const lags = page.runs.flatMap(({ status, finished_at: finishedAt }) => status === 'rejected' && finishedAt !== undefined ? [Date.parse(finishedAt) - dueAt] : [], ); return page.next_cursor === null ? lags : [...lags, ...(await expiryLagsOf(server, dueAt, page.next_cursor))]; diff --git a/packages/server/measure/hanging.ts b/packages/server/measure/hanging.ts index 155acf246..527d35758 100644 --- a/packages/server/measure/hanging.ts +++ b/packages/server/measure/hanging.ts @@ -4,7 +4,7 @@ import { Schema } from 'effect'; import type { MeasuredLedger } from './measured-ledgers.ts'; import { brain, inTurns, measuredServer, percentile, type MeasuredServer } from './measured-server.ts'; -import { executionIdOf, pauseSource, spread, timersMeasured, type TimerPlan } from './timer-runs.ts'; +import { runIdOf, pauseSource, spread, timersMeasured, type TimerPlan } from './timer-runs.ts'; const toolKey = 'measure-tool-key-6d02b8f1'; @@ -59,12 +59,14 @@ function hangingServerOf(url: string): Readonly> { async function askedOn(server: MeasuredServer, requests: number, plan: TimerPlan): Promise { await server.call('POST', '/v1/orgs/local/brains', { brain: 'measure', name: 'Measure' }); - await server.call('POST', `${brain}/specs/interaction`, { name: 'approval', source: approval }); - await server.call('POST', `${brain}/specs/orchestration`, { name: 'pause', source: pauseSource(plan.seconds) }); + await server.call('POST', `${brain}/definitions/interaction`, { name: 'approval', source: approval }); + await server.call('POST', `${brain}/definitions/workflow`, { name: 'pause', source: pauseSource(plan.seconds) }); const from = Date.now(); await inTurns(requests, 32, async (index) => { - executionIdOf( - await server.call('POST', `${brain}/specs/interaction/approval/execute`, { input: { owner: `owner-${index}` } }), + runIdOf( + await server.call('POST', `${brain}/definitions/interaction/approval/run`, { + input: { owner: `owner-${index}` }, + }), ); }); return Date.now() - from; diff --git a/packages/server/measure/timer-runs.ts b/packages/server/measure/timer-runs.ts index 0485ddca5..0b77decc0 100644 --- a/packages/server/measure/timer-runs.ts +++ b/packages/server/measure/timer-runs.ts @@ -17,7 +17,7 @@ export interface Sampling { readonly stop: () => void; } -const decodeStarted = Schema.decodeUnknownSync(Schema.Struct({ execution_id: Schema.String })); +const decodeStarted = Schema.decodeUnknownSync(Schema.Struct({ run_id: Schema.String })); const decodeEnded = Schema.decodeUnknownSync(Schema.Struct({ started_at: Schema.String, finished_at: Schema.String })); @@ -29,8 +29,8 @@ do: `; } -export function executionIdOf(answer: unknown): string { - return decodeStarted(answer).execution_id; +export function runIdOf(answer: unknown): string { + return decodeStarted(answer).run_id; } function loadNow(): string { @@ -61,8 +61,8 @@ async function timersStarted(server: MeasuredServer, plan: TimerPlan, index = 0) return []; } await sleep(Math.max(0, plan.firstDueAt - plan.seconds * 1000 + index * plan.spacingMs - Date.now())); - const started = await server.call('POST', `${brain}/specs/orchestration/pause/execute`, { input: {} }); - return [executionIdOf(started), ...(await timersStarted(server, plan, index + 1))]; + const started = await server.call('POST', `${brain}/definitions/workflow/pause/run`, { input: {} }); + return [runIdOf(started), ...(await timersStarted(server, plan, index + 1))]; } async function latenessOf( @@ -71,7 +71,7 @@ async function latenessOf( { seconds }: TimerPlan, ): Promise { const ended = await Promise.all( - runs.map(async (run) => decodeEnded(await server.call('GET', `${brain}/executions/${run}`))), + runs.map(async (run) => decodeEnded(await server.call('GET', `${brain}/runs/${run}`))), ); return ended.map( ({ started_at: startedAt, finished_at: finishedAt }) => diff --git a/packages/server/package.json b/packages/server/package.json index b7a0ddd72..ff10c2e69 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -20,15 +20,15 @@ "@beonauto/brains": "workspace:*", "@beonauto/computation": "workspace:*", "@beonauto/config": "workspace:*", + "@beonauto/coordination": "workspace:*", + "@beonauto/definitions": "workspace:*", "@beonauto/identity": "workspace:*", - "@beonauto/inference": "workspace:*", "@beonauto/interaction": "workspace:*", "@beonauto/ledger": "workspace:*", "@beonauto/mcp": "workspace:*", "@beonauto/operations": "workspace:*", - "@beonauto/orchestration": "workspace:*", - "@beonauto/recollection": "workspace:*", - "@beonauto/specs": "workspace:*", + "@beonauto/reasoning": "workspace:*", + "@beonauto/recall": "workspace:*", "@beonauto/workflow-engine": "workspace:*", "@beonauto/workflow-host": "workspace:*", "effect": "catalog:" diff --git a/packages/server/src/analytics/analytics-over-http.test.ts b/packages/server/src/analytics/analytics-over-http.test.ts index f7d9dd2dc..15dff3a60 100644 --- a/packages/server/src/analytics/analytics-over-http.test.ts +++ b/packages/server/src/analytics/analytics-over-http.test.ts @@ -1,8 +1,8 @@ import { randomUUID } from 'node:crypto'; import { withMcpSession } from '@beonauto/api/testing'; -import { OutputInvalid } from '@beonauto/inference'; -import { answers, textResult } from '@beonauto/inference/testing'; +import { OutputInvalid } from '@beonauto/reasoning'; +import { answers, textResult } from '@beonauto/reasoning/testing'; import { Effect, Schema } from 'effect'; import { Client } from 'pg'; import { afterEach, describe, expect, it, onTestFinished } from 'vitest'; @@ -84,9 +84,9 @@ async function brainWithThreeRuns(environment: Readonly>) environment, ); await server.call('POST', '/v1/orgs/acme/brains', { body: { brain: 'alpha', name: 'Alpha' } }); - await server.call('POST', `${alpha}/specs/inference`, { body: { name: 'summary', source: summary } }); + await server.call('POST', `${alpha}/definitions/reasoning`, { body: { name: 'summary', source: summary } }); const running = (input: Readonly>) => - server.call('POST', `${alpha}/specs/inference/summary/execute`, { body: { input } }); + server.call('POST', `${alpha}/definitions/reasoning/summary/run`, { body: { input } }); await running({ text: 'the quarter' }); await running({ text: 'the year' }); await running({ text: 7 }); @@ -109,7 +109,7 @@ describe.each(stores)('the analytics of a brain over HTTP and MCP, on $store', ( await brainWithThreeRuns(await environment()); const read = await server.call('GET', `${alpha}/analytics`); - const filtered = await server.call('GET', `${alpha}/analytics?days=14&primitive=inference&name=summary`); + const filtered = await server.call('GET', `${alpha}/analytics?days=14&type=reasoning&name=summary`); const onAlpha = { url: `${server.origin}/orgs/acme/brains/alpha/mcp`, headers: {} }; const overMcp = await withMcpSession('current revision', onAlpha, (session) => session.callTool('get_brain_analytics', {}), @@ -121,7 +121,7 @@ describe.each(stores)('the analytics of a brain over HTTP and MCP, on $store', ( days: 7, runs: { total: 3, succeeded: 1, failed: 0, rejected: 2 }, tokens: { input: 1500, output: 100, cached: 1000 }, - by_function: [{ primitive: 'inference', name: 'summary', runs: 3 }], + by_function: [{ type: 'reasoning', name: 'summary', runs: 3 }], }, }); expect(read.body).toMatchObject({ by_day: { 6: { day: today, runs: { total: 3 } } } }); diff --git a/packages/server/src/analytics/run-outcomes-on-stores.test.ts b/packages/server/src/analytics/run-outcomes-on-stores.test.ts index bdabb02bb..2367af721 100644 --- a/packages/server/src/analytics/run-outcomes-on-stores.test.ts +++ b/packages/server/src/analytics/run-outcomes-on-stores.test.ts @@ -64,8 +64,8 @@ const fact = { by: 'acme-admin' }; const usage = { input: { total: 1200, cache_read: 1000 }, output: { total: 300 } }; -function started(name: string, at: string, primitive = 'inference'): Fact { - return { type: 'execution_started', primitive, name, spec_version: 1, input: {}, ...fact, at }; +function started(name: string, at: string, type = 'reasoning'): Fact { + return { type: 'run_started', definition_type: type, name, definition_version: 1, input: {}, ...fact, at }; } function finished(type: string, at: string, more: Readonly> = {}): Fact { @@ -77,49 +77,49 @@ const runs: readonly (readonly [string, readonly Fact[]])[] = [ 'started-twice', [ started('triage', '2026-10-01T09:00:00.000Z'), - finished('execution_failed', '2026-10-01T09:00:01.000Z'), + finished('run_failed', '2026-10-01T09:00:01.000Z'), started('triage', '2026-10-01T10:00:00.000Z'), finished('tool_call_started', '2026-10-01T10:00:00.100Z', { number: 1 }), - finished('execution_succeeded', '2026-10-01T10:00:00.250Z', { output: 'ok', record: { usage } }), + finished('run_succeeded', '2026-10-01T10:00:00.250Z', { output: 'ok', record: { usage } }), ], ], [ 'without-usage', [ started('triage', '2026-10-01T11:00:00.000Z'), - finished('execution_succeeded', '2026-10-01T11:00:00.500Z', { output: 'ok', record: {} }), + finished('run_succeeded', '2026-10-01T11:00:00.500Z', { output: 'ok', record: {} }), ], ], [ 'not-an-object', [ started('triage', '2026-10-01T12:00:00.000Z'), - finished('execution_succeeded', '2026-10-01T12:00:00.100Z', { output: 'ok', record: 'text' }), + finished('run_succeeded', '2026-10-01T12:00:00.100Z', { output: 'ok', record: 'text' }), ], ], [ 'rejected-with-usage', [ started('triage', '2026-10-01T13:00:00.000Z'), - finished('execution_rejected', '2026-10-01T13:00:01.000Z', { rejection: {}, record: { usage } }), + finished('run_rejected', '2026-10-01T13:00:01.000Z', { rejection: {}, record: { usage } }), ], ], [ 'rejected', [ started('triage', '2026-10-01T14:00:00.000Z'), - finished('execution_rejected', '2026-10-01T14:00:01.000Z', { rejection: {} }), + finished('run_rejected', '2026-10-01T14:00:01.000Z', { rejection: {} }), ], ], [ 'workflow', [ - started('approval', '2026-10-01T15:00:00.000Z', 'orchestration'), - finished('execution_deferred', '2026-10-01T15:00:00.010Z', { record: {} }), - finished('execution_succeeded', '2026-10-02T15:00:00.000Z', { output: {}, record: {} }), + started('approval', '2026-10-01T15:00:00.000Z', 'workflow'), + finished('run_deferred', '2026-10-01T15:00:00.010Z', { record: {} }), + finished('run_succeeded', '2026-10-02T15:00:00.000Z', { output: {}, record: {} }), ], ], - ['finish-alone', [finished('execution_failed', '2026-10-02T09:00:00.000Z')]], + ['finish-alone', [finished('run_failed', '2026-10-02T09:00:00.000Z')]], ['still-going', [started('draft', '2026-10-02T10:00:00.000Z')]], ]; @@ -128,7 +128,7 @@ function written(ledger: Ledger['Service']): Promise { Effect.forEach( runs, ([run, given]) => - Effect.forEach(given, (each) => ledger.execute(`brain/acme/alpha/executions/${run}`, facts, [each]), { + Effect.forEach(given, (each) => ledger.execute(`brain/acme/alpha/runs/${run}`, facts, [each]), { discard: true, }), { discard: true }, @@ -143,10 +143,10 @@ function withoutTheProjection(settings: LedgerSettings): Layer.Layer { } function lineOf(group: RunOutcomeGroup): string { - const { day, primitive, name, status, inputTokens, outputTokens, cachedTokens } = group; + const { day, definitionType: type, name, status, inputTokens, outputTokens, cachedTokens } = group; const durations = group.durations.toSorted((left, right) => left - right).join(' '); const tokens = `${inputTokens} ${outputTokens} ${cachedTokens}`; - return `${day} ${primitive}/${name} ${status} ${group.runs} [${durations}] ${tokens}`; + return `${day} ${type}/${name} ${status} ${group.runs} [${durations}] ${tokens}`; } async function outcomesIn(ledger: Ledger['Service']): Promise { @@ -157,11 +157,11 @@ async function outcomesIn(ledger: Ledger['Service']): Promise } const kept = [ - '2026-10-01 inference/triage rejected 2 [] 1200 300 1000', - '2026-10-01 inference/triage succeeded 3 [100 250 500] 1200 300 1000', - '2026-10-01 orchestration/approval succeeded 1 [86400000] 0 0 0', + '2026-10-01 reasoning/triage rejected 2 [] 1200 300 1000', + '2026-10-01 reasoning/triage succeeded 3 [100 250 500] 1200 300 1000', + '2026-10-01 workflow/approval succeeded 1 [86400000] 0 0 0', '2026-10-02 / failed 1 [] 0 0 0', - '2026-10-02 inference/draft started 1 [] 0 0 0', + '2026-10-02 reasoning/draft started 1 [] 0 0 0', ]; interface Store { diff --git a/packages/server/src/brains/ledger-on-postgresql.test.ts b/packages/server/src/brains/ledger-on-postgresql.test.ts index 93c3850c7..bd2a074fb 100644 --- a/packages/server/src/brains/ledger-on-postgresql.test.ts +++ b/packages/server/src/brains/ledger-on-postgresql.test.ts @@ -2,14 +2,14 @@ import { randomUUID } from 'node:crypto'; import { fileURLToPath } from 'node:url'; import { campaignPace, campaignRows } from '@beonauto/computation/testing'; -import { answers, textResult } from '@beonauto/inference/testing'; +import { answers, textResult } from '@beonauto/reasoning/testing'; import { Schema } from 'effect'; import { Client } from 'pg'; import { describe, expect, it, onTestFinished } from 'vitest'; import { spawnServer, spawnedServerTestTimeoutMs } from '../testing/processes/spawned-server.ts'; import { servingReasoning } from '../testing/servers/reasoning-server.ts'; -import { executionIdIn, settledExecution, workflowSource } from '../testing/servers/workflow-server.ts'; +import { runIdIn, settledRun, workflowSource } from '../testing/servers/workflow-server.ts'; const mainModule = fileURLToPath(new URL('../main.ts', import.meta.url)); @@ -49,24 +49,21 @@ const computedRun = Schema.decodeUnknownSync( async function computedOn(environment: Readonly>) { const computing = await servingReasoning([], environment); await computing.call('POST', '/v1/orgs/acme/brains', { body: { brain: 'alpha', name: 'Alpha' } }); - await computing.call('POST', `${brain}/specs/computation`, { body: { name: 'pace', source: campaignPace } }); - const runs = await executionIds.reduce[]>>( - async (before, executionId) => { - await computing.call('POST', `${brain}/specs/computation/pace/execute`, { - body: { input: campaignRows(500), execution_id: executionId }, - }); - const run = computedRun((await computing.call('GET', `${brain}/executions/${executionId}`)).body); - return [...(await before), run]; - }, - Promise.resolve([]), - ); + await computing.call('POST', `${brain}/definitions/computation`, { body: { name: 'pace', source: campaignPace } }); + const runs = await runIds.reduce[]>>(async (before, runId) => { + await computing.call('POST', `${brain}/definitions/computation/pace/run`, { + body: { input: campaignRows(500), run_id: runId }, + }); + const run = computedRun((await computing.call('GET', `${brain}/runs/${runId}`)).body); + return [...(await before), run]; + }, Promise.resolve([])); await computing.stop(); return runs; } -const executionId = '0199a3c4-7d2e-7c1a-9b3f-2f1e0d9c8b7a'; +const runId = '0199a3c4-7d2e-7c1a-9b3f-2f1e0d9c8b7a'; -const executionIds = [executionId, '0199a3c4-7d2e-7c1a-9b3f-2f1e0d9c8b7b']; +const runIds = [runId, '0199a3c4-7d2e-7c1a-9b3f-2f1e0d9c8b7b']; const summary = [ '---', @@ -86,24 +83,26 @@ describe.skipIf(skipped)( `A server that keeps its ledger in PostgreSQL${notice}`, { timeout: spawnedServerTestTimeoutMs }, () => { - it('executes a reasoning function definition of a brain it created, and reads the execution again after a restart', async () => { + it('executes a reasoning function definition of a brain it created, and reads the run again after a restart', async () => { const environment = await onADatabaseOfItsOwn(); const first = await servingReasoning([answers(textResult('Profits rose.'))], environment); const created = await first.call('POST', '/v1/orgs/acme/brains', { body: { brain: 'alpha', name: 'Alpha' } }); - const spec = await first.call('POST', `${brain}/specs/inference`, { body: { name: 'summary', source: summary } }); - const executed = await first.call('POST', `${brain}/specs/inference/summary/execute`, { - body: { input: { text: 'the quarter' }, execution_id: executionId }, + const definition = await first.call('POST', `${brain}/definitions/reasoning`, { + body: { name: 'summary', source: summary }, + }); + const ran = await first.call('POST', `${brain}/definitions/reasoning/summary/run`, { + body: { input: { text: 'the quarter' }, run_id: runId }, }); - const execution = `${brain}/executions/${executionId}`; - const before = await first.call('GET', execution); + const run = `${brain}/runs/${runId}`; + const before = await first.call('GET', run); await first.stop(); const second = await servingReasoning([], environment); - const after = await second.call('GET', execution); + const after = await second.call('GET', run); const brainAfter = await second.call('GET', brain); await second.stop(); - expect([created.status, spec.status, executed.status, before.status]).toEqual([201, 201, 200, 200]); + expect([created.status, definition.status, ran.status, before.status]).toEqual([201, 201, 200, 200]); expect(before.body).toMatchObject({ status: 'succeeded', output: 'Profits rose.', name: 'summary' }); expect(after).toMatchObject({ status: 200, body: before.body }); expect(brainAfter).toMatchObject({ status: 200, body: { id: 'alpha', name: 'Alpha', status: 'active' } }); @@ -151,18 +150,18 @@ describe.skipIf(skipped)( const environment = await onADatabaseOfItsOwn(); const first = await servingReasoning([], environment); await first.call('POST', '/v1/orgs/acme/brains', { body: { brain: 'alpha', name: 'Alpha' } }); - await first.call('POST', `${brain}/specs/orchestration`, { body: { name: 'approval', source: approval } }); - const started = await first.call('POST', `${brain}/specs/orchestration/approval/execute`, { + await first.call('POST', `${brain}/definitions/workflow`, { body: { name: 'approval', source: approval } }); + const started = await first.call('POST', `${brain}/definitions/workflow/approval/run`, { body: { input: {} }, }); await first.stop(); const second = await servingReasoning([], environment); - const execution = `${brain}/executions/${executionIdIn(started.body)}`; - const sent = await second.call('POST', `${execution}/events`, { + const run = `${brain}/runs/${runIdIn(started.body)}`; + const sent = await second.call('POST', `${run}/events`, { body: { event: { type: 'com.acme.approved', data: { by: 'Ada' } } }, }); - const settled = await settledExecution(second, execution); + const settled = await settledRun(second, run); await second.stop(); expect(started).toMatchObject({ status: 200, body: { status: 'started' } }); diff --git a/packages/server/src/composition/brain-operations.ts b/packages/server/src/composition/brain-operations.ts index dd6f68647..454c54b0b 100644 --- a/packages/server/src/composition/brain-operations.ts +++ b/packages/server/src/composition/brain-operations.ts @@ -1,23 +1,23 @@ import { defineListBrainEvents } from '@beonauto/brains'; -import { conversationCallPresenter, toolTestPresenter } from '@beonauto/mcp'; -import type { Presenter } from '@beonauto/operations'; import { - makeSpecOperations, - makeSpecPresenters, + makeDefinitionOperations, + makeDefinitionPresenters, publishEvent, type BrainOperation, - type Primitive, -} from '@beonauto/specs'; + type Capability, +} from '@beonauto/definitions'; +import { conversationCallPresenter, toolTestPresenter } from '@beonauto/mcp'; +import type { Presenter } from '@beonauto/operations'; export function brainOperationsServing( - primitives: readonly Primitive[], + capabilities: readonly Capability[], logPresenters: readonly Presenter[], ): readonly BrainOperation[] { const presenters = [ - ...makeSpecPresenters(primitives), + ...makeDefinitionPresenters(capabilities), toolTestPresenter, conversationCallPresenter, ...logPresenters, ]; - return [...makeSpecOperations(primitives, presenters), defineListBrainEvents(presenters), publishEvent]; + return [...makeDefinitionOperations(capabilities, presenters), defineListBrainEvents(presenters), publishEvent]; } diff --git a/packages/server/src/composition/composition-root.ts b/packages/server/src/composition/composition-root.ts index 42f8ec4c7..d4433da46 100644 --- a/packages/server/src/composition/composition-root.ts +++ b/packages/server/src/composition/composition-root.ts @@ -8,7 +8,7 @@ import { logIncident, logLedger } from '../logging/logging.ts'; import { serveWorkflows } from '../workflows/workflows.ts'; import { ledgerLayerOf } from './ledger-store.ts'; import { functionWiringOf, functionsServedBy, type FunctionWiring } from './served-functions.ts'; -import { loggedModelAccess } from './served-inference.ts'; +import { loggedModelAccess } from './served-reasoning.ts'; const loggingIncidentReporter = Layer.succeed(IncidentReporter, IncidentReporter.of({ report: logIncident })); diff --git a/packages/server/src/composition/ledger-store.ts b/packages/server/src/composition/ledger-store.ts index e665aebc6..ae0edfb67 100644 --- a/packages/server/src/composition/ledger-store.ts +++ b/packages/server/src/composition/ledger-store.ts @@ -1,9 +1,9 @@ +import { runOutcomeMapping } from '@beonauto/definitions'; import { conversations, openRequests } from '@beonauto/interaction'; import type { AppendSignal } from '@beonauto/ledger'; import { postgresqlLedgerLayer } from '@beonauto/ledger/postgresql'; import { ledgerLayer } from '@beonauto/ledger/sqlite3'; import type { Ledger } from '@beonauto/operations'; -import { runOutcomeMapping } from '@beonauto/specs'; import { Redacted, type Layer } from 'effect'; import type { LedgerSettings } from '../settings/ledger-settings.ts'; diff --git a/packages/server/src/composition/served-computation.ts b/packages/server/src/composition/served-computation.ts index d45759ac1..973a58c07 100644 --- a/packages/server/src/composition/served-computation.ts +++ b/packages/server/src/composition/served-computation.ts @@ -1,5 +1,5 @@ import { computationBounds, makeComputationFunctionAdapter } from '@beonauto/computation'; -import type { Primitive } from '@beonauto/specs'; +import type { Capability } from '@beonauto/definitions'; import { programPool, type ProgramPool } from '@beonauto/workflow-engine/dsl'; import type { ComputationSettings } from '../function-settings/computation-settings.ts'; @@ -8,7 +8,7 @@ import type { Served } from '../lifecycle/lifecycle.ts'; export type ProgramPoolOf = (settings: ComputationSettings) => ProgramPool; export interface ServedComputation { - readonly primitive: Primitive; + readonly capability: Capability; readonly pool: ProgramPool; readonly withPoolClosed: (served: Served) => Served; } @@ -19,7 +19,7 @@ export const workerPool: ProgramPoolOf = ({ workers }) => export function computationServedBy(settings: ComputationSettings, poolOf: ProgramPoolOf): ServedComputation { const pool = poolOf(settings); return { - primitive: makeComputationFunctionAdapter({ pool }), + capability: makeComputationFunctionAdapter({ pool }), pool, withPoolClosed: ({ routes, stopWork }) => ({ routes, diff --git a/packages/server/src/composition/served-functions.ts b/packages/server/src/composition/served-functions.ts index b1543a396..13c264d96 100644 --- a/packages/server/src/composition/served-functions.ts +++ b/packages/server/src/composition/served-functions.ts @@ -6,7 +6,7 @@ import type { Served } from '../lifecycle/lifecycle.ts'; import type { Settings } from '../settings/settings.ts'; import type { WorkflowParts } from '../workflows/workflows.ts'; import { computationServedBy, workerPool, type ProgramPoolOf } from './served-computation.ts'; -import type { ModelAccessOf } from './served-inference.ts'; +import type { ModelAccessOf } from './served-reasoning.ts'; import { recallWiring, type RecallWiring } from './served-recall.ts'; import { toolUsersServedBy } from './served-tools.ts'; @@ -38,7 +38,7 @@ export async function functionsServedBy( const recall = await wiring.served(runtime, settings, computation.pool); return { parts: { - primitives: [reasoning.primitive, interaction.primitive, computation.primitive, recall.primitive], + capabilities: [reasoning.capability, interaction.capability, computation.capability, recall.capability], orgOperations: [...brainOperations, reasoning.listModels, reasoning.listToolServersInOrg], brainOperations: [reasoning.listToolServers, reasoning.testToolCall, ...interaction.operations], dueWork: interaction.dueWork, diff --git a/packages/server/src/composition/served-interaction.ts b/packages/server/src/composition/served-interaction.ts index 8681445aa..712ff7a03 100644 --- a/packages/server/src/composition/served-interaction.ts +++ b/packages/server/src/composition/served-interaction.ts @@ -1,4 +1,5 @@ import type { AppRuntime } from '@beonauto/api'; +import type { BrainOperation, Capability } from '@beonauto/definitions'; import { answerInteraction, conversationsDue, @@ -10,13 +11,12 @@ import { } from '@beonauto/interaction'; import type { ToolAccess } from '@beonauto/mcp'; import type { DispatcherServices } from '@beonauto/operations'; -import type { BrainOperation, Primitive } from '@beonauto/specs'; import type { Settings } from '../settings/settings.ts'; import { runtimeLedger } from './runtime-ledger.ts'; export interface ServedInteraction { - readonly primitive: Primitive; + readonly capability: Capability; readonly operations: readonly BrainOperation[]; readonly dueWork: readonly RequestsDue[]; } @@ -28,7 +28,7 @@ export function interactionServedBy( ): ServedInteraction { const ledger = runtimeLedger(runtime); return { - primitive: makeInteractionFunctionAdapter({ + capability: makeInteractionFunctionAdapter({ tools, openRequests: (brain) => ledger.countProjectedRows(openRequestsName, brain, [{ column: 'open', equals: true }]), mostOpenRequests: interaction.mostOpenRequests, diff --git a/packages/server/src/composition/served-inference.ts b/packages/server/src/composition/served-reasoning.ts similarity index 90% rename from packages/server/src/composition/served-inference.ts rename to packages/server/src/composition/served-reasoning.ts index 64ae5911a..bbbdd7737 100644 --- a/packages/server/src/composition/served-inference.ts +++ b/packages/server/src/composition/served-reasoning.ts @@ -1,14 +1,14 @@ import type { AppRuntime } from '@beonauto/api'; import { foundBrain } from '@beonauto/brains'; +import { defineListToolServers, defineListToolServersInOrg, defineTestToolCall, type ToolAccess } from '@beonauto/mcp'; +import type { DispatcherServices } from '@beonauto/operations'; import { defineListModels, makeReasoningFunctionAdapter, makeModelAccess, type ModelAccess, type ModelSettings, -} from '@beonauto/inference'; -import { defineListToolServers, defineListToolServersInOrg, defineTestToolCall, type ToolAccess } from '@beonauto/mcp'; -import type { DispatcherServices } from '@beonauto/operations'; +} from '@beonauto/reasoning'; import { Effect } from 'effect'; import { logModelProviders, logOperatorHint, logProviderMessage } from '../logging/logging.ts'; @@ -17,7 +17,7 @@ import type { Settings } from '../settings/settings.ts'; export type ModelAccessOf = (settings: ModelSettings) => Effect.Effect; export interface ServedReasoning { - readonly primitive: ReturnType; + readonly capability: ReturnType; readonly listModels: ReturnType; readonly listToolServers: ReturnType; readonly listToolServersInOrg: ReturnType; @@ -36,7 +36,7 @@ export async function reasoningServedBy( const { languageModel, status, offered, catalog } = await Effect.runPromise(modelAccessOf(settings.models)); await runtime.run(logModelProviders(status)); return { - primitive: makeReasoningFunctionAdapter({ languageModel, offered, tools }), + capability: makeReasoningFunctionAdapter({ languageModel, offered, tools }), listModels: defineListModels(catalog), listToolServers: defineListToolServers(tools), listToolServersInOrg: defineListToolServersInOrg(tools, foundBrain), diff --git a/packages/server/src/composition/served-recall.ts b/packages/server/src/composition/served-recall.ts index 4116ba64e..c56299905 100644 --- a/packages/server/src/composition/served-recall.ts +++ b/packages/server/src/composition/served-recall.ts @@ -1,8 +1,8 @@ import type { AppRuntime } from '@beonauto/api'; +import type { Capability } from '@beonauto/definitions'; import { appendSignal, type AppendSignal } from '@beonauto/ledger'; import type { DispatcherServices } from '@beonauto/operations'; -import { makeRecallFunctionAdapter, recallBounds, recallDefinitionType, recallFolding } from '@beonauto/recollection'; -import type { Primitive } from '@beonauto/specs'; +import { makeRecallFunctionAdapter, recallBounds, recallDefinitionType, recallFolding } from '@beonauto/recall'; import type { ProgramPool } from '@beonauto/workflow-engine/dsl'; import { openWorkflowStore, type ProjectorSettings, type WorkflowStore } from '@beonauto/workflow-host'; @@ -11,7 +11,7 @@ import { hostDatabaseOf } from '../workflows/host-dependencies.ts'; import { hostReports } from '../workflows/host-reports.ts'; interface ServedRecall { - readonly primitive: Primitive; + readonly capability: Capability; readonly store: WorkflowStore; readonly views: ProjectorSettings; } @@ -32,7 +32,7 @@ export function recallWiring(): RecallWiring { served: async (runtime, { ledger, recall }, pool) => { const store = await openWorkflowStore(hostDatabaseOf(ledger), hostReports(runtime).lostConnection); return { - primitive: makeRecallFunctionAdapter({ pool, views: store.views, mostFunctions: recall.mostFunctions }), + capability: makeRecallFunctionAdapter({ pool, views: store.views, mostFunctions: recall.mostFunctions }), store, views: { definitionType: recallDefinitionType, diff --git a/packages/server/src/composition/served-routes.ts b/packages/server/src/composition/served-routes.ts index 8cf2a15b7..c750e4163 100644 --- a/packages/server/src/composition/served-routes.ts +++ b/packages/server/src/composition/served-routes.ts @@ -1,4 +1,5 @@ import { mcpRoutes, operationRoutes, type AppRuntime, type RegisterRoutes } from '@beonauto/api'; +import type { Capability } from '@beonauto/definitions'; import { makeCatalog, makeDispatcher, @@ -6,7 +7,6 @@ import { type Dispatcher, type DispatcherServices, } from '@beonauto/operations'; -import type { Primitive } from '@beonauto/specs'; import { servedGuidesOf } from '../guides/served-guides.ts'; import { logMcpError } from '../logging/logging.ts'; @@ -18,7 +18,7 @@ export function routesFor( runtime: AppRuntime, catalog: Catalog, dispatcher: Dispatcher, - primitives: readonly Primitive[], + capabilities: readonly Capability[], ): readonly RegisterRoutes[] { return [ operationRoutes({ catalog, dispatcher, runCall: runtime.run }), @@ -27,7 +27,7 @@ export function routesFor( dispatcher, runCall: runtime.run, serverInfo: release, - ...servedGuidesOf(primitives), + ...servedGuidesOf(capabilities), reportError: (error) => { void runtime.run(logMcpError(error)); }, diff --git a/packages/server/src/composition/served-tools.ts b/packages/server/src/composition/served-tools.ts index 0976e7845..191b454ce 100644 --- a/packages/server/src/composition/served-tools.ts +++ b/packages/server/src/composition/served-tools.ts @@ -5,8 +5,8 @@ import type { DispatcherServices } from '@beonauto/operations'; import type { Served } from '../lifecycle/lifecycle.ts'; import { logServerMessage, logUntestableServer } from '../logging/logging.ts'; import type { Settings } from '../settings/settings.ts'; -import { reasoningServedBy, type ModelAccessOf, type ServedReasoning } from './served-inference.ts'; import { interactionServedBy, type ServedInteraction } from './served-interaction.ts'; +import { reasoningServedBy, type ModelAccessOf, type ServedReasoning } from './served-reasoning.ts'; export interface ServedToolUsers { readonly reasoning: ServedReasoning; diff --git a/packages/server/src/computation/computation-deadline.test.ts b/packages/server/src/computation/computation-deadline.test.ts index 3079107f5..4813f0a6d 100644 --- a/packages/server/src/computation/computation-deadline.test.ts +++ b/packages/server/src/computation/computation-deadline.test.ts @@ -33,12 +33,12 @@ afterEach(async () => { await server.stop(); }); -function executed(rows: number) { - return server.call('POST', `${alpha}/specs/computation/pace/execute`, { body: { input: campaignRows(rows) } }); +function ran(rows: number) { + return server.call('POST', `${alpha}/definitions/computation/pace/run`, { body: { input: campaignRows(rows) } }); } async function ranOn(rows: number) { - return decodeRan((await executed(rows)).body).output; + return decodeRan((await ran(rows)).body).output; } describe('a run of a computation function that does not end by itself, over HTTP', { timeout: 60_000 }, () => { @@ -48,16 +48,16 @@ describe('a run of a computation function that does not end by itself, over HTTP programPool({ workers, heapMegabytes: computationBounds.heapMegabytes, worker: blockingOnTwoRows }), }); await server.call('POST', '/v1/orgs/acme/brains', { body: { brain: 'alpha', name: 'Alpha' } }); - await server.call('POST', `${alpha}/specs/computation`, { body: { name: 'pace', source: campaignPace } }); + await server.call('POST', `${alpha}/definitions/computation`, { body: { name: 'pace', source: campaignPace } }); const warm = await ranOn(3); const started = performance.now(); const settled = { run: false }; - const running = executed(2).then((response) => { + const running = ran(2).then((response) => { settled.run = true; return { response, milliseconds: performance.now() - started }; }); - const meanwhile = await server.call('GET', `${alpha}/specs/computation/pace`); + const meanwhile = await server.call('GET', `${alpha}/definitions/computation/pace`); const elsewhere = await ranOn(3); const answeredFirst = !settled.run; const { response, milliseconds } = await running; diff --git a/packages/server/src/computation/computation-determinism.test.ts b/packages/server/src/computation/computation-determinism.test.ts index d542ac67b..ff54ac87a 100644 --- a/packages/server/src/computation/computation-determinism.test.ts +++ b/packages/server/src/computation/computation-determinism.test.ts @@ -4,7 +4,7 @@ import { describe, expect, it } from 'vitest'; import { alpha, servingReasoning, type ReasoningServer } from '../testing/servers/reasoning-server.ts'; -const executionIds = { cold: '0199a3c4-7d2e-7c1a-9b3f-2f1e0d9c8b7a', warm: '0199a3c4-7d2e-7c1a-9b3f-2f1e0d9c8b7b' }; +const runIds = { cold: '0199a3c4-7d2e-7c1a-9b3f-2f1e0d9c8b7a', warm: '0199a3c4-7d2e-7c1a-9b3f-2f1e0d9c8b7b' }; const decodeRun = Schema.decodeUnknownSync( Schema.Struct({ @@ -14,17 +14,17 @@ const decodeRun = Schema.decodeUnknownSync( }), ); -async function ranIn(server: ReasoningServer, input: Schema.Json, executionId: string) { - await server.call('POST', `${alpha}/specs/computation/pace/execute`, { body: { input, execution_id: executionId } }); - const { output, record } = decodeRun((await server.call('GET', `${alpha}/executions/${executionId}`)).body); +async function ranIn(server: ReasoningServer, input: Schema.Json, runId: string) { + await server.call('POST', `${alpha}/definitions/computation/pace/run`, { body: { input, run_id: runId } }); + const { output, record } = decodeRun((await server.call('GET', `${alpha}/runs/${runId}`)).body); return { output, work: record.work, input_bytes: record.input_bytes, output_bytes: record.output_bytes }; } async function ranOn(server: ReasoningServer, input: Schema.Json) { await server.call('POST', '/v1/orgs/acme/brains', { body: { brain: 'alpha', name: 'Alpha' } }); - await server.call('POST', `${alpha}/specs/computation`, { body: { name: 'pace', source: campaignPace } }); - const cold = await ranIn(server, input, executionIds.cold); - const warm = await ranIn(server, input, executionIds.warm); + await server.call('POST', `${alpha}/definitions/computation`, { body: { name: 'pace', source: campaignPace } }); + const cold = await ranIn(server, input, runIds.cold); + const warm = await ranIn(server, input, runIds.warm); await server.stop(); return { cold, warm }; } diff --git a/packages/server/src/computation/computation-over-http.test.ts b/packages/server/src/computation/computation-over-http.test.ts index 44c9eb0b3..d2e992936 100644 --- a/packages/server/src/computation/computation-over-http.test.ts +++ b/packages/server/src/computation/computation-over-http.test.ts @@ -18,7 +18,7 @@ const raising = [ '| error("no budget for \\(length) rows")', ].join('\n'); -const executionId = '0199a3c4-7d2e-7c1a-9b3f-2f1e0d9c8b7a'; +const runId = '0199a3c4-7d2e-7c1a-9b3f-2f1e0d9c8b7a'; const decodeHistory = Schema.decodeUnknownSync( Schema.Struct({ @@ -39,26 +39,26 @@ function scripted(...outcomes: readonly PoolOutcome[]): ProgramPoolOf { async function serving(programPoolOf: ProgramPoolOf = workerPool): Promise { server = await servingReasoning([], { LOCAL_MODE: 'true' }, undefined, { programPoolOf }); await server.call('POST', '/v1/orgs/acme/brains', { body: { brain: 'alpha', name: 'Alpha' } }); - await server.call('POST', `${alpha}/specs/computation`, { body: { name: 'pace', source: campaignPace } }); - await server.call('POST', `${alpha}/specs/computation`, { body: { name: 'raising', source: raising } }); + await server.call('POST', `${alpha}/definitions/computation`, { body: { name: 'pace', source: campaignPace } }); + await server.call('POST', `${alpha}/definitions/computation`, { body: { name: 'raising', source: raising } }); return server; } -function executing(name: string, body: object) { - return server.call('POST', `${alpha}/specs/computation/${name}/execute`, { body }); +function running(name: string, body: object) { + return server.call('POST', `${alpha}/definitions/computation/${name}/run`, { body }); } describe('a computation function over HTTP', { timeout: computationTestTimeoutMs }, () => { it('runs its program on the input and answers the output, recorded with the work, the time and the sizes', async () => { await serving(); - const executed = await executing('pace', { input: campaignRows(100), execution_id: executionId }); - const read = await server.call('GET', `${alpha}/executions/${executionId}`); + const ran = await running('pace', { input: campaignRows(100), run_id: runId }); + const read = await server.call('GET', `${alpha}/runs/${runId}`); - expect(executed).toMatchObject({ + expect(ran).toMatchObject({ status: 200, body: { - primitive: 'computation', + type: 'computation', name: 'pace', status: 'succeeded', output: { campaigns: [{ campaign: 'campaign-0' }, {}, {}, {}], total_spend_cents: 283_150 }, @@ -73,13 +73,13 @@ describe('a computation function over HTTP', { timeout: computationTestTimeoutMs expect(read.body).toHaveProperty('record.output_bytes'); }); - it('is listed and read with the spec operations, its schemas and description shown', async () => { + it('is listed and read with the definition operations, its schemas and description shown', async () => { await serving(); - expect(await server.call('GET', `${alpha}/specs/computation/pace`)).toMatchObject({ + expect(await server.call('GET', `${alpha}/definitions/computation/pace`)).toMatchObject({ status: 200, body: { - primitive: 'computation', + type: 'computation', media_type: 'text/markdown', description: 'Spend, pace and projection per campaign, in cents, for a reporting period', input_schema: { type: 'object', required: ['rows', 'period'] }, @@ -97,20 +97,20 @@ describe( await serving(); const detail = 'The program raised an error on line 6: no budget for 2 rows'; - const executed = await executing('raising', { input: campaignRows(2), execution_id: executionId }); - const read = await server.call('GET', `${alpha}/executions/${executionId}`); - const listed = await server.call('GET', `${alpha}/executions?primitive=computation`); - const { events } = decodeHistory((await server.call('GET', `${alpha}/executions/${executionId}/history`)).body); + const ran = await running('raising', { input: campaignRows(2), run_id: runId }); + const read = await server.call('GET', `${alpha}/runs/${runId}`); + const listed = await server.call('GET', `${alpha}/runs?type=computation`); + const { events } = decodeHistory((await server.call('GET', `${alpha}/runs/${runId}/history`)).body); - expect(executed).toMatchObject({ status: 409, body: { reason: 'conflict', kind: 'unworkable', detail } }); + expect(ran).toMatchObject({ status: 409, body: { reason: 'conflict', kind: 'unworkable', detail } }); expect(read.body).toMatchObject({ status: 'rejected', rejection: { reason: 'conflict', kind: 'unworkable', detail }, }); expect(listed.body).toMatchObject({ - executions: [{ execution_id: executionId, rejection: { reason: 'conflict', kind: 'unworkable' } }], + runs: [{ run_id: runId, rejection: { reason: 'conflict', kind: 'unworkable' } }], }); - expect(events.map(({ type }) => type)).toEqual(['execution_started', 'execution_rejected']); + expect(events.map(({ type }) => type)).toEqual(['run_started', 'run_rejected']); expect(events[1]).toMatchObject({ summary: 'A run did not go through: it cannot work as it is written.', data: { reason: 'conflict', kind: 'unworkable', detail }, @@ -121,8 +121,8 @@ describe( it('runs again under its id, ending the same way, since the same input gives the same result', async () => { await serving(); - const first = await executing('raising', { input: campaignRows(2), execution_id: executionId }); - const again = await executing('raising', { input: campaignRows(2), execution_id: executionId }); + const first = await running('raising', { input: campaignRows(2), run_id: runId }); + const again = await running('raising', { input: campaignRows(2), run_id: runId }); expect(again.body).toEqual(first.body); }); @@ -133,9 +133,9 @@ describe('an output too large to record, over HTTP', { timeout: computationTestT it('ends in conflict for an output that would take 240 MB as JSON, measured before it is written, and the server answers on', async () => { await serving(); const doubled = ['---', 'language: jq', '---', '("\\u0001Ā" * 15000000) | [., .]'].join('\n'); - await server.call('POST', `${alpha}/specs/computation`, { body: { name: 'doubled', source: doubled } }); + await server.call('POST', `${alpha}/definitions/computation`, { body: { name: 'doubled', source: doubled } }); - expect(await executing('doubled', { input: null })).toMatchObject({ + expect(await running('doubled', { input: null })).toMatchObject({ status: 409, body: { reason: 'conflict', @@ -143,7 +143,7 @@ describe('an output too large to record, over HTTP', { timeout: computationTestT detail: "The program's output takes more than the 1048320 bytes as JSON a run can record", }, }); - expect(await executing('pace', { input: campaignRows(2) })).toMatchObject({ status: 200 }); + expect(await running('pace', { input: campaignRows(2) })).toMatchObject({ status: 200 }); }); }); @@ -151,13 +151,13 @@ describe('a long error, over HTTP', { timeout: computationTestTimeoutMs }, () => it('answers, records and lists the text of the error cut at 1,024 bytes', async () => { await serving(); const shouting = ['---', 'language: jq', '---', 'error("x" * 30000000)'].join('\n'); - await server.call('POST', `${alpha}/specs/computation`, { body: { name: 'shouting', source: shouting } }); + await server.call('POST', `${alpha}/definitions/computation`, { body: { name: 'shouting', source: shouting } }); const detail = `The program raised an error on line 4: ${'x'.repeat(1024)}…`; - const executed = await executing('shouting', { input: null, execution_id: executionId }); - const read = await server.call('GET', `${alpha}/executions/${executionId}`); + const ran = await running('shouting', { input: null, run_id: runId }); + const read = await server.call('GET', `${alpha}/runs/${runId}`); - expect(executed).toMatchObject({ status: 409, body: { reason: 'conflict', kind: 'unworkable', detail } }); + expect(ran).toMatchObject({ status: 409, body: { reason: 'conflict', kind: 'unworkable', detail } }); expect(read.body).toMatchObject({ rejection: { reason: 'conflict', kind: 'unworkable', detail } }); expect(JSON.stringify(read.body).length).toBeLessThan(2048); }); @@ -170,12 +170,12 @@ describe( it('rejects an input the input schema refuses, under /input', async () => { await serving(); - expect( - await executing('pace', { input: { rows: [], period: { days_elapsed: 0, days_total: 30 } } }), - ).toMatchObject({ - status: 422, - body: { reason: 'invalid_input', errors: [{ pointer: '/input/period/days_elapsed' }] }, - }); + expect(await running('pace', { input: { rows: [], period: { days_elapsed: 0, days_total: 30 } } })).toMatchObject( + { + status: 422, + body: { reason: 'invalid_input', errors: [{ pointer: '/input/period/days_elapsed' }] }, + }, + ); }); it('refuses a definition with each problem and its line, under /source, and a document over 64 KiB', async () => { @@ -183,7 +183,7 @@ describe( const refused = ['---', 'language: jq', 'model: anthropic/claude-sonnet-4-5', '---', 'now'].join('\n'); expect( - await server.call('POST', `${alpha}/specs/computation`, { body: { name: 'clock', source: refused } }), + await server.call('POST', `${alpha}/definitions/computation`, { body: { name: 'clock', source: refused } }), ).toMatchObject({ status: 422, body: { @@ -203,7 +203,7 @@ describe( }, }); expect( - await server.call('POST', `${alpha}/specs/computation`, { + await server.call('POST', `${alpha}/definitions/computation`, { body: { name: 'large', source: `---\nlanguage: jq\n---\n${'.'.repeat(65_536)}` }, }), ).toMatchObject({ status: 422, body: { reason: 'invalid_input', errors: [{ pointer: '/source' }] } }); @@ -218,7 +218,7 @@ describe( it('is unavailable when no worker is free within its deadline', async () => { await serving(scripted({ ran: 'stopped', because: 'busy', milliseconds: 10_000 })); - expect(await executing('pace', { input: campaignRows(2) })).toMatchObject({ + expect(await running('pace', { input: campaignRows(2) })).toMatchObject({ status: 503, body: { reason: 'unavailable', @@ -232,10 +232,10 @@ describe( scripted({ ran: 'crashed', detail: 'The worker ended with code 1 before it answered', milliseconds: 5 }), ); - const executed = await executing('pace', { input: campaignRows(2), execution_id: executionId }); + const ran = await running('pace', { input: campaignRows(2), run_id: runId }); - expect(executed).toMatchObject({ status: 500 }); - expect(await server.call('GET', `${alpha}/executions/${executionId}`)).toMatchObject({ + expect(ran).toMatchObject({ status: 500 }); + expect(await server.call('GET', `${alpha}/runs/${runId}`)).toMatchObject({ body: { status: 'failed' }, }); }); diff --git a/packages/server/src/computation/computation-over-mcp.test.ts b/packages/server/src/computation/computation-over-mcp.test.ts index ebcdfde31..f17fcd9c6 100644 --- a/packages/server/src/computation/computation-over-mcp.test.ts +++ b/packages/server/src/computation/computation-over-mcp.test.ts @@ -34,47 +34,47 @@ async function onAlpha(use: (session: McpSession) => Promise): Promise describe('a computation function over MCP, on the endpoint of its brain', { timeout: computationTestTimeoutMs }, () => { it('is created, run and read back with its run and the words of its result', async () => { - const { created, executed, execution, summed } = await onAlpha(async (session) => { - const creating = await session.callTool('create_spec', { - primitive: 'computation', + const { created, ran, run, summed } = await onAlpha(async (session) => { + const creating = await session.callTool('create_definition', { + type: 'computation', name: 'pace', source: campaignPace, }); - const executing = await session.callTool('execute_spec', { - primitive: 'computation', + const running = await session.callTool('run_definition', { + type: 'computation', name: 'pace', input: campaignRows(4), }); - const reading = await session.callTool('get_execution', { - execution_id: String(executing.structuredContent?.['execution_id']), + const reading = await session.callTool('get_run', { + run_id: String(running.structuredContent?.['run_id']), }); - await session.callTool('create_spec', { primitive: 'computation', name: 'total', source: total }); - const summing = await session.callTool('execute_spec', { - primitive: 'computation', + await session.callTool('create_definition', { type: 'computation', name: 'total', source: total }); + const summing = await session.callTool('run_definition', { + type: 'computation', name: 'total', input: campaignRows(4), }); - return { created: creating, executed: executing, execution: reading, summed: summing }; + return { created: creating, ran: running, run: reading, summed: summing }; }); - expect(created.structuredContent).toMatchObject({ primitive: 'computation', name: 'pace', version: 1 }); - expect(executed.structuredContent).toMatchObject({ status: 'succeeded', output: { total_spend_cents: 4222 } }); - expect(plainTextIn(executed)).toBe( + expect(created.structuredContent).toMatchObject({ type: 'computation', name: 'pace', version: 1 }); + expect(ran.structuredContent).toMatchObject({ status: 'succeeded', output: { total_spend_cents: 4222 } }); + expect(plainTextIn(ran)).toBe( 'Ran the computation function “pace”. Its result is too long to repeat here; the whole of it is in the details below.', ); expect(plainTextIn(summed)).toBe('Ran the computation function “total”. Its result: total spend cents: 4222.'); - expect(execution.structuredContent).toMatchObject({ record: { language: 'jq' } }); + expect(run.structuredContent).toMatchObject({ record: { language: 'jq' } }); }); }); describe('a computation function that cannot work as written, over MCP', { timeout: computationTestTimeoutMs }, () => { it('answers a program that gives two outputs with isError, the conflict and its kind, in plain words', async () => { - const executed = await onAlpha(async (session) => { - await session.callTool('create_spec', { primitive: 'computation', name: 'twice', source: twice }); - return session.callTool('execute_spec', { primitive: 'computation', name: 'twice', input: [1, 2] }); + const ran = await onAlpha(async (session) => { + await session.callTool('create_definition', { type: 'computation', name: 'twice', source: twice }); + return session.callTool('run_definition', { type: 'computation', name: 'twice', input: [1, 2] }); }); - expect({ isError: executed.isError, problem: problemIn(executed) }).toMatchObject({ + expect({ isError: ran.isError, problem: problemIn(ran) }).toMatchObject({ isError: true, problem: { status: 409, @@ -83,18 +83,18 @@ describe('a computation function that cannot work as written, over MCP', { timeo detail: 'The program gave more than one output; a computation function gives exactly one', }, }); - expect(plainTextIn(executed)).toContain('it cannot work as it is written'); - expect(internalTermsIn(plainTextIn(executed))).toEqual([]); + expect(plainTextIn(ran)).toContain('it cannot work as it is written'); + expect(internalTermsIn(plainTextIn(ran))).toEqual([]); }); }); -describe('the spec tools an agent sees', { timeout: computationTestTimeoutMs }, () => { +describe('the definition tools an agent sees', { timeout: computationTestTimeoutMs }, () => { it('name the guide to a computation function, which says the language it is written in', async () => { const tools = listedTools(await onAlpha((session) => session.listTools())); - const createSpec = tools.find(({ name }) => name === 'create_spec'); + const createDefinition = tools.find(({ name }) => name === 'create_definition'); const guide = await onAlpha((session) => session.callTool('get_guide', { guide: 'computation-function' })); - expect(createSpec?.description).toContain('computation, a computation function, guide computation-function'); + expect(createDefinition?.description).toContain('computation, a computation function, guide computation-function'); expect(textOf(guide)).toContain('jq'); }); }); diff --git a/packages/server/src/computation/computation-workflows.test.ts b/packages/server/src/computation/computation-workflows.test.ts index 029202733..175b63830 100644 --- a/packages/server/src/computation/computation-workflows.test.ts +++ b/packages/server/src/computation/computation-workflows.test.ts @@ -1,7 +1,7 @@ import { withMcpSession } from '@beonauto/api/testing'; import { campaignPace, campaignRows } from '@beonauto/computation/testing'; -import { jsonResult, textResult, type ScriptedReply } from '@beonauto/inference/testing'; import { serveFakeMcp, type FakeMcpServer } from '@beonauto/mcp/testing'; +import { jsonResult, textResult, type ScriptedReply } from '@beonauto/reasoning/testing'; import { scriptedPool, type PoolOutcome } from '@beonauto/workflow-engine/testing'; import { Effect, Schema } from 'effect'; import { afterEach, describe, expect, it } from 'vitest'; @@ -9,9 +9,9 @@ import { afterEach, describe, expect, it } from 'vitest'; import { workerPool, type ProgramPoolOf } from '../composition/served-computation.ts'; import { alpha, type ReasoningServer } from '../testing/servers/reasoning-server.ts'; import { - executionIdIn, + runIdIn, servingWorkflows, - settledExecution, + settledRun, settledOverMcp, workflowSource, workflowTestTimeoutMs, @@ -49,8 +49,8 @@ const catching = workflowSource( - compute: try: - shout: - call: execute_spec - with: { primitive: computation, name: shouting, input: '\${ . }' } + call: run_definition + with: { type: computation, name: shouting, input: '\${ . }' } catch: errors: with: { status: 409 } @@ -69,15 +69,15 @@ function report(name: string, computation: string): string { name, `do: - read: - call: execute_spec - with: { primitive: inference, name: read-rows, input: { campaigns: '\${ .campaigns }' } } + call: run_definition + with: { type: reasoning, name: read-rows, input: { campaigns: '\${ .campaigns }' } } output: as: '\${ { rows: .rows, period: $input.period } }' - compute: try: - pace: - call: execute_spec - with: { primitive: computation, name: ${computation}, input: '\${ . }' } + call: run_definition + with: { type: computation, name: ${computation}, input: '\${ . }' } catch: errors: with: { status: 503 } @@ -86,8 +86,8 @@ function report(name: string, computation: string): string { limit: attempt: { count: 2 } - write: - call: execute_spec - with: { primitive: inference, name: summary, input: { total_spend_cents: '\${ .total_spend_cents }' } } + call: run_definition + with: { type: reasoning, name: summary, input: { total_spend_cents: '\${ .total_spend_cents }' } } `, ); } @@ -136,41 +136,41 @@ async function serving(programPoolOf: ProgramPoolOf, ...replies: readonly Script closing.push(server.stop); await server.call('POST', '/v1/orgs/acme/brains', { body: { brain: 'alpha', name: 'Alpha' } }); const definitions = [ - ['inference', 'read-rows', readRows], - ['inference', 'summary', summary], + ['reasoning', 'read-rows', readRows], + ['reasoning', 'summary', summary], ['computation', 'pace', campaignPace], ['computation', 'raising', raising], - ['orchestration', 'report', report('report', 'pace')], - ['orchestration', 'stuck', report('stuck', 'raising')], + ['workflow', 'report', report('report', 'pace')], + ['workflow', 'stuck', report('stuck', 'raising')], ['computation', 'shouting', shouting], - ['orchestration', 'catching', catching], + ['workflow', 'catching', catching], ] as const; await definitions.reduce( - (created: Promise, [primitive, name, source]) => - created.then(() => server.call('POST', `${alpha}/specs/${primitive}`, { body: { name, source } })), + (created: Promise, [type, name, source]) => + created.then(() => server.call('POST', `${alpha}/definitions/${type}`, { body: { name, source } })), Promise.resolve(), ); return server; } -async function settledRun(server: ReasoningServer, workflow: string) { - const started = await server.call('POST', `${alpha}/specs/orchestration/${workflow}/execute`, { +async function settledWorkflowRun(server: ReasoningServer, workflow: string) { + const started = await server.call('POST', `${alpha}/definitions/workflow/${workflow}/run`, { body: { input: workflowInput }, }); - return settledExecution(server, `${alpha}/executions/${executionIdIn(started.body)}`); + return settledRun(server, `${alpha}/runs/${runIdIn(started.body)}`); } async function computationRuns(server: ReasoningServer) { - return (await server.call('GET', `${alpha}/executions?primitive=computation`)).body; + return (await server.call('GET', `${alpha}/runs?type=computation`)).body; } const decodeListing = Schema.decodeUnknownSync( - Schema.Struct({ executions: Schema.Array(Schema.Struct({ execution_id: Schema.String })) }), + Schema.Struct({ runs: Schema.Array(Schema.Struct({ run_id: Schema.String })) }), ); async function computedOutput(server: ReasoningServer): Promise { - const [run] = decodeListing(await computationRuns(server)).executions; - const read = await server.call('GET', `${alpha}/executions/${run?.execution_id ?? ''}`); + const [run] = decodeListing(await computationRuns(server)).runs; + const read = await server.call('GET', `${alpha}/runs/${run?.run_id ?? ''}`); return Schema.decodeUnknownSync(Schema.Struct({ output: Schema.Unknown }))(read.body).output; } @@ -187,12 +187,12 @@ describe( Effect.succeed(textResult('The campaigns spent 2,831.50 in all.')), ); - const settled = await settledRun(server, 'report'); + const settled = await settledWorkflowRun(server, 'report'); expect(settled).toMatchObject({ body: { status: 'succeeded', output: 'The campaigns spent 2,831.50 in all.' } }); expect(graphs[0]?.received()).toMatchObject([{ tool: 'echo', arguments: { rows: rows['rows'] } }]); expect(await computationRuns(server)).toMatchObject({ - executions: [{ primitive: 'computation', name: 'pace', status: 'succeeded' }], + runs: [{ type: 'computation', name: 'pace', status: 'succeeded' }], }); expect(await computedOutput(server)).toMatchObject({ total_spend_cents: 283_150 }); expect(server.modelCalls()).toBe(2); @@ -205,11 +205,11 @@ describe( () => Effect.succeed(textResult('Spent.')), ); - const settled = await settledRun(server, 'report'); + const settled = await settledWorkflowRun(server, 'report'); expect(settled).toMatchObject({ body: { status: 'succeeded', output: 'Spent.' } }); expect(await computationRuns(server)).toMatchObject({ - executions: [ + runs: [ { name: 'pace', status: 'succeeded' }, { name: 'pace', status: 'rejected', rejection: { reason: 'unavailable' } }, ], @@ -219,11 +219,11 @@ describe( it('does not retry a conflict, since the same input gives the same result, and the workflow ends', async () => { const server = await serving(workerPool, readingRowsThroughTheGraph()); - const settled = await settledRun(server, 'stuck'); + const settled = await settledWorkflowRun(server, 'stuck'); expect(settled).toMatchObject({ body: { status: 'rejected' } }); expect(await computationRuns(server)).toMatchObject({ - executions: [{ name: 'raising', status: 'rejected', rejection: { reason: 'conflict', kind: 'unworkable' } }], + runs: [{ name: 'raising', status: 'rejected', rejection: { reason: 'conflict', kind: 'unworkable' } }], }); expect(server.modelCalls()).toBe(1); }); @@ -234,7 +234,7 @@ describe('a workflow whose computation function raises a long error', { timeout: it('catches it as the runtime error of status 409 the format documents, its text cut at 1,024 bytes', async () => { const server = await serving(workerPool); - const settled = await settledRun(server, 'catching'); + const settled = await settledWorkflowRun(server, 'catching'); const caught = Schema.decodeUnknownSync( Schema.Struct({ body: Schema.Struct({ @@ -262,12 +262,12 @@ describe('the same workflow over MCP', { timeout: workflowTestTimeoutMs }, () => 'current revision', { url: `${server.origin}/orgs/acme/brains/alpha/mcp`, headers: {} }, async (session) => { - const started = await session.callTool('execute_spec', { - primitive: 'orchestration', + const started = await session.callTool('run_definition', { + type: 'workflow', name: 'report', input: workflowInput, }); - return settledOverMcp(session, String(started.structuredContent?.['execution_id'])); + return settledOverMcp(session, String(started.structuredContent?.['run_id'])); }, ); diff --git a/packages/server/src/config-file/config-file.test.ts b/packages/server/src/config-file/config-file.test.ts index be95e6e34..d3256a2a0 100644 --- a/packages/server/src/config-file/config-file.test.ts +++ b/packages/server/src/config-file/config-file.test.ts @@ -1,12 +1,7 @@ import { createApiKey } from '@beonauto/identity'; import { describe, expect, it } from 'vitest'; -import { - configuredServer, - rejectedExecution, - settingLines, - stoppedOutput, -} from '../testing/processes/configured-server.ts'; +import { configuredServer, rejectedRun, settingLines, stoppedOutput } from '../testing/processes/configured-server.ts'; import { spawnedServerTestTimeoutMs } from '../testing/processes/spawned-server.ts'; import { request } from '../testing/servers/http-client.ts'; @@ -45,8 +40,8 @@ describe( const { child, configFile } = configuredServer(fileText, { GATEWAY_API_KEY: gatewayKey }); const port = await child.port; - const unauthenticated = await rejectedExecution(port); - const rejected = await rejectedExecution(port, created.key); + const unauthenticated = await rejectedRun(port); + const rejected = await rejectedRun(port, created.key); const origins = { listed: await fromOrigin(port, 'https://app.example.com'), other: await fromOrigin(port, 'https://other.example'), @@ -77,12 +72,12 @@ describe( 'a server whose configuration file declares and allows models', { timeout: spawnedServerTestTimeoutMs }, () => { - it('lists the declared models, and refuses a spec that names a model it does not allow', async () => { + it('lists the declared models, and refuses a definition that names a model it does not allow', async () => { const { child, configFile } = configuredServer(modelsText, { LOCAL_MODE: 'true', AWS_REGION: 'eu-central-1' }); const port = await child.port; const listed = await request(port, 'GET', '/v1/orgs/acme/models?provider=bedrock'); - const rejected = await rejectedExecution(port); + const rejected = await rejectedRun(port); const stderr = await stoppedOutput(child); expect(listed.body).toMatchObject({ @@ -119,7 +114,7 @@ describe('a server with settings in the environment', { timeout: spawnedServerTe MODEL_ALIASES: JSON.stringify({ 'house/fast': 'relay/llama-3.3-70b' }), }); - const rejected = await rejectedExecution(await child.port); + const rejected = await rejectedRun(await child.port); const stderr = await stoppedOutput(child); expect(rejected.body).toMatchObject({ @@ -135,7 +130,7 @@ describe('a server with settings in the environment', { timeout: spawnedServerTe MODEL_ALIASES: JSON.stringify({ 'house/fast': 'gateway/llama-3.3-70b' }), }); - const rejected = await rejectedExecution(await child.port, created.key); + const rejected = await rejectedRun(await child.port, created.key); const stderr = await stoppedOutput(child); expect(rejected.body).toMatchObject({ diff --git a/packages/server/src/config-file/file-settings.ts b/packages/server/src/config-file/file-settings.ts index d76c2b44f..45aa421ac 100644 --- a/packages/server/src/config-file/file-settings.ts +++ b/packages/server/src/config-file/file-settings.ts @@ -1,12 +1,12 @@ import { fileSetting, type FileSetting, type RefusedKeys } from '@beonauto/config'; import { ApiKeysSchema } from '@beonauto/identity'; +import { McpServersSchema } from '@beonauto/mcp'; import { AllowedModelsSchema, DeclaredModelsSchema, ModelAliasesSchema, ModelGatewaysSchema, -} from '@beonauto/inference'; -import { McpServersSchema } from '@beonauto/mcp'; +} from '@beonauto/reasoning'; import { Schema } from 'effect'; import { Origin } from '../settings/origin.ts'; diff --git a/packages/server/src/development/development-reload.test.ts b/packages/server/src/development/development-reload.test.ts index 0265667f3..744a94db4 100644 --- a/packages/server/src/development/development-reload.test.ts +++ b/packages/server/src/development/development-reload.test.ts @@ -62,13 +62,13 @@ describe('a server crash under pnpm dev', { timeout: developmentTestTimeoutMs }, it('keeps running when the server dies while a workflow waits on a function, and on the next save the run goes on', async () => { const gateway = await gatewayThatHangsFirst(); const development = startDevelopment(developmentFiles(), { environment: { MODEL_GATEWAYS: gateway.gateways } }); - const executionId = await welcomingStarted(await untilListening(development), 'delta'); + const runId = await welcomingStarted(await untilListening(development), 'delta'); await gateway.firstHeard; development.signal('SIGUSR2'); await untilWritten(development.stderr, /The server stopped \(signal SIGKILL\)/u); appendFileSync(development.files.envFile, '# saved\n'); - const settled = await settledOver(await untilListening(development, 2), `/delta/executions/${executionId}`); + const settled = await settledOver(await untilListening(development, 2), `/delta/runs/${runId}`); development.signal('SIGTERM'); expect(settled).toMatchObject({ status: 'succeeded', output: 'Welcome, Ada.' }); diff --git a/packages/server/src/development/development-workers.test.ts b/packages/server/src/development/development-workers.test.ts index 0afe5a5e6..95e317972 100644 --- a/packages/server/src/development/development-workers.test.ts +++ b/packages/server/src/development/development-workers.test.ts @@ -17,7 +17,7 @@ const saidSoFar = [ 'language: jq', 'source:', ' events:', - ' - type: execution_succeeded', + ' - type: run_succeeded', ' subject: computation/echo', 'view:', ' initial: []', @@ -34,13 +34,13 @@ describe('the functions pnpm dev serves in worker threads', { timeout: developme request(port, method, path, { body }); await call('POST', '/v1/orgs/local/brains', { brain: 'meetings', name: 'Meetings' }); - await call('POST', `${meetings}/specs/computation`, { name: 'echo', source: echo }); - const echoed = await call('POST', `${meetings}/specs/computation/echo/execute`, { input: { said: 'hello' } }); + await call('POST', `${meetings}/definitions/computation`, { name: 'echo', source: echo }); + const echoed = await call('POST', `${meetings}/definitions/computation/echo/run`, { input: { said: 'hello' } }); expect(echoed.body).toMatchObject({ status: 'succeeded', output: { said: 'hello' } }); - await call('POST', `${meetings}/specs/recollection`, { name: 'said', source: saidSoFar }); + await call('POST', `${meetings}/definitions/recall`, { name: 'said', source: saidSoFar }); const recalled = await vi.waitFor( async () => { - const answered = await call('POST', `${meetings}/specs/recollection/said/execute`, { input: {} }); + const answered = await call('POST', `${meetings}/definitions/recall/said/run`, { input: {} }); expect(answered.status).toBe(200); return answered; }, diff --git a/packages/server/src/development/env-files.test.ts b/packages/server/src/development/env-files.test.ts index 6b1397004..d35c45391 100644 --- a/packages/server/src/development/env-files.test.ts +++ b/packages/server/src/development/env-files.test.ts @@ -3,7 +3,7 @@ import { join } from 'node:path'; import { fileURLToPath } from 'node:url'; import { parseEnv } from 'node:util'; -import { providerStatus } from '@beonauto/inference'; +import { providerStatus } from '@beonauto/reasoning'; import { describe, expect, it } from 'vitest'; import { readSettings } from '../settings/settings.ts'; diff --git a/packages/server/src/development/local-development.test.ts b/packages/server/src/development/local-development.test.ts index 3ca00aebd..7b936dfb9 100644 --- a/packages/server/src/development/local-development.test.ts +++ b/packages/server/src/development/local-development.test.ts @@ -23,8 +23,8 @@ describe('the local development setup', () => { 'packages/server', 'packages/api', 'packages/workflow-host', - 'primitives/orchestration', - 'primitives/inference', + 'capabilities/coordination', + 'capabilities/reasoning', ] .map((workspace) => join(repository, workspace, 'src')) .filter((directory) => !watched.includes(directory)); diff --git a/packages/server/src/development/local-development.ts b/packages/server/src/development/local-development.ts index a59fa5dd5..3fdc74d4f 100644 --- a/packages/server/src/development/local-development.ts +++ b/packages/server/src/development/local-development.ts @@ -8,7 +8,7 @@ import type { DevelopmentSetup } from './development-run.ts'; const repository = fileURLToPath(new URL('../../../../', import.meta.url)); function workspaceSources(root: string): readonly string[] { - return ['packages', 'primitives'] + return ['packages', 'capabilities'] .flatMap((group) => readdirSync(join(root, group)).map((name) => join(root, group, name, 'src'))) .filter((directory) => existsSync(directory)); } diff --git a/packages/server/src/development/quick-start.test.ts b/packages/server/src/development/quick-start.test.ts index 5b314e208..986b952e0 100644 --- a/packages/server/src/development/quick-start.test.ts +++ b/packages/server/src/development/quick-start.test.ts @@ -55,14 +55,14 @@ const triage = [ "document: {dsl: '1.0.3', namespace: support, name: triage-ticket, version: '1.0.0'}", 'do:', ' - classify:', - ' call: execute_spec', - " with: {primitive: inference, name: classify-ticket, input: {ticket: '${ .ticket }'}}", + ' call: run_definition', + " with: {type: reasoning, name: classify-ticket, input: {ticket: '${ .ticket }'}}", " output: {as: '${ $input + {triage: .} }'}", ' - escalate:', ' if: .triage.urgency == "high"', - ' call: execute_spec', + ' call: run_definition', ' with:', - ' primitive: inference', + ' type: reasoning', ' name: escalation-note', " input: {ticket: '${ .ticket }', category: '${ .triage.category }'}", " output: {as: '${ $input + {note: .} }'}", @@ -123,22 +123,22 @@ function structured(result: ToolResult): Readonly> { return { isError: result.isError ?? false, ...result.structuredContent }; } -async function settled(session: McpSession, executionId: unknown): Promise>> { - const reading = structured(await session.callTool('get_execution', { brain, execution_id: executionId })); +async function settled(session: McpSession, runId: unknown): Promise>> { + const reading = structured(await session.callTool('get_run', { brain, run_id: runId })); if (reading['status'] !== 'started') { return reading; } await setTimeout(100); - return settled(session, executionId); + return settled(session, runId); } -async function stored(session: McpSession, primitive: string, name: string, source: string): Promise { - return structured(await session.callTool('create_spec', { brain, primitive, name, source })); +async function stored(session: McpSession, type: string, name: string, source: string): Promise { + return structured(await session.callTool('create_definition', { brain, type, name, source })); } -async function executed(session: McpSession, name: string, input: object, primitive = 'inference'): Promise { - const started = structured(await session.callTool('execute_spec', { brain, primitive, name, input })); - return settled(session, started['execution_id']); +async function ran(session: McpSession, name: string, input: object, type = 'reasoning'): Promise { + const started = structured(await session.callTool('run_definition', { brain, type, name, input })); + return settled(session, started['run_id']); } describe( @@ -150,17 +150,17 @@ describe( it('creates a brain, stores a reasoning function that classifies tickets, runs it, reads its record and runs a new version', async () => { const steps = await onPnpmDev(async (session) => ({ brain: structured(await session.callTool('create_brain', { brain, name: 'Support' })), - stored: await stored(session, 'inference', 'classify-ticket', classifyingPrompt('Answer as JSON.')), - first: await executed(session, 'classify-ticket', { ticket: charged }), + stored: await stored(session, 'reasoning', 'classify-ticket', classifyingPrompt('Answer as JSON.')), + first: await ran(session, 'classify-ticket', { ticket: charged }), updated: structured( - await session.callTool('update_spec', { + await session.callTool('update_definition', { brain, - primitive: 'inference', + type: 'reasoning', name: 'classify-ticket', source: classifyingPrompt('Anything about money is billing and at least normal urgency.'), }), ), - second: await executed(session, 'classify-ticket', { ticket: charged }), + second: await ran(session, 'classify-ticket', { ticket: charged }), })); expect(steps).toMatchObject({ @@ -172,7 +172,7 @@ describe( record: { usage: { total: 80 }, prompt: { message: `\nTicket: ${charged}` } }, }, updated: { isError: false, version: 2 }, - second: { status: 'succeeded', name: 'classify-ticket', spec_version: 2 }, + second: { status: 'succeeded', name: 'classify-ticket', definition_version: 2 }, }); }); }, @@ -182,12 +182,12 @@ describe('the workflow requests of the quick start, over /mcp', { timeout: devel it('builds a workflow that drafts an escalation note only for an urgent ticket, and runs it on two', async () => { const outputs = await onPnpmDev(async (session) => { await session.callTool('create_brain', { brain, name: 'Support' }); - await stored(session, 'inference', 'classify-ticket', classifyingPrompt('Answer as JSON.')); - await stored(session, 'inference', 'escalation-note', escalationNote); - await stored(session, 'orchestration', 'triage-ticket', triage); + await stored(session, 'reasoning', 'classify-ticket', classifyingPrompt('Answer as JSON.')); + await stored(session, 'reasoning', 'escalation-note', escalationNote); + await stored(session, 'workflow', 'triage-ticket', triage); return [ - await executed(session, 'triage-ticket', { ticket: charged }, 'orchestration'), - await executed(session, 'triage-ticket', { ticket: question }, 'orchestration'), + await ran(session, 'triage-ticket', { ticket: charged }, 'workflow'), + await ran(session, 'triage-ticket', { ticket: question }, 'workflow'), ]; }); @@ -200,23 +200,23 @@ describe('the workflow requests of the quick start, over /mcp', { timeout: devel it('starts a workflow that waits for an approval, sends the approval, and sees it finish', async () => { const ending = await onPnpmDev(async (session) => { await session.callTool('create_brain', { brain, name: 'Support' }); - await stored(session, 'orchestration', 'refund-approval', approval); + await stored(session, 'workflow', 'refund-approval', approval); const started = structured( - await session.callTool('execute_spec', { + await session.callTool('run_definition', { brain, - primitive: 'orchestration', + type: 'workflow', name: 'refund-approval', input: { refund: 'ticket-4711' }, }), ); const sent = structured( - await session.callTool('send_execution_event', { + await session.callTool('send_run_event', { brain, - execution_id: started['execution_id'], + run_id: started['run_id'], event: { type: 'com.acme.refund.approved', data: { by: 'dana' } }, }), ); - return { started, sent, settled: await settled(session, started['execution_id']) }; + return { started, sent, settled: await settled(session, started['run_id']) }; }); expect(ending).toMatchObject({ @@ -252,13 +252,15 @@ describe('the first-brain prompt of the quick start, over /mcp', { timeout: deve brain: structured( await session.callTool('create_brain', { brain, name: 'Support', description: 'Classifies support tickets' }), ), - stored: await stored(session, 'inference', 'classify-ticket', classifyingPrompt('Answer as JSON.')), - run: await executed(session, 'classify-ticket', { ticket: charged }), + stored: await stored(session, 'reasoning', 'classify-ticket', classifyingPrompt('Answer as JSON.')), + run: await ran(session, 'classify-ticket', { ticket: charged }), }; }); expect(outcome.recipe).toMatch(/^Create my first brain\.\n\n# Create your first brain\n/u); - expect(inTheirOrder(outcome.recipe, ['list_brains', 'create_brain', 'create_spec', 'execute_spec'])).toBe(true); + expect(inTheirOrder(outcome.recipe, ['list_brains', 'create_brain', 'create_definition', 'run_definition'])).toBe( + true, + ); expect(outcome.guide.uri).toBe('guide://reasoning-function'); expect(outcome.guide.text).toContain('# Reasoning function format'); expect(outcome).toMatchObject({ diff --git a/packages/server/src/development/ready-notice.ts b/packages/server/src/development/ready-notice.ts index ac1174ce9..da82d4d32 100644 --- a/packages/server/src/development/ready-notice.ts +++ b/packages/server/src/development/ready-notice.ts @@ -1,5 +1,5 @@ import { configurationOf, type Environment } from '@beonauto/config'; -import { providerStatus, readModelSettings } from '@beonauto/inference'; +import { providerStatus, readModelSettings } from '@beonauto/reasoning'; import { Effect, Function, Result } from 'effect'; import { fileSettings, refusedKeys } from '../config-file/file-settings.ts'; diff --git a/packages/server/src/function-settings/reasoning-settings.ts b/packages/server/src/function-settings/reasoning-settings.ts index d7ff57699..47fa1cb2b 100644 --- a/packages/server/src/function-settings/reasoning-settings.ts +++ b/packages/server/src/function-settings/reasoning-settings.ts @@ -1,12 +1,12 @@ import type { Environment, FileUse } from '@beonauto/config'; +import { McpSettingsInvalid, readMcpSettings, type McpSettings } from '@beonauto/mcp'; import { ModelSettingsInvalid, providerStatus, readModelSettings, type ModelSettings, type SettingProblem, -} from '@beonauto/inference'; -import { McpSettingsInvalid, readMcpSettings, type McpSettings } from '@beonauto/mcp'; +} from '@beonauto/reasoning'; import { Effect } from 'effect'; export interface ReasoningSettings { diff --git a/packages/server/src/function-settings/recall-settings.test.ts b/packages/server/src/function-settings/recall-settings.test.ts index 6f4920d0e..c0d2eea26 100644 --- a/packages/server/src/function-settings/recall-settings.test.ts +++ b/packages/server/src/function-settings/recall-settings.test.ts @@ -20,9 +20,9 @@ describe('the recall function settings', () => { it('read each of the three', () => { expect( readSettings({ - RECOLLECTION_MAX_FUNCTIONS: ' 100 ', - RECOLLECTION_MAX_REBUILDS: '2', - RECOLLECTION_BRAINS_AT_ONCE: '8', + RECALL_MAX_FUNCTIONS: ' 100 ', + RECALL_MAX_REBUILDS: '2', + RECALL_BRAINS_AT_ONCE: '8', }).recall, ).toEqual({ mostFunctions: 100, rebuildsAtOnce: 2, brainsAtOnce: 8 }); }); @@ -31,13 +31,13 @@ describe('the recall function settings', () => { expect( String( errorFrom({ - RECOLLECTION_MAX_FUNCTIONS: '0', - RECOLLECTION_MAX_REBUILDS: '65', - RECOLLECTION_BRAINS_AT_ONCE: 'many', + RECALL_MAX_FUNCTIONS: '0', + RECALL_MAX_REBUILDS: '65', + RECALL_BRAINS_AT_ONCE: 'many', }), ), ).toBe( - 'InvalidSettingsError: The recall function settings are invalid. RECOLLECTION_MAX_FUNCTIONS: Expected a whole number from 1 to 1000, such as 32; RECOLLECTION_MAX_REBUILDS: Expected a whole number from 1 to 64, such as 4; RECOLLECTION_BRAINS_AT_ONCE: Expected a whole number from 1 to 64, such as 4', + 'InvalidSettingsError: The recall function settings are invalid. RECALL_MAX_FUNCTIONS: Expected a whole number from 1 to 1000, such as 32; RECALL_MAX_REBUILDS: Expected a whole number from 1 to 64, such as 4; RECALL_BRAINS_AT_ONCE: Expected a whole number from 1 to 64, such as 4', ); }); }); diff --git a/packages/server/src/function-settings/recall-settings.ts b/packages/server/src/function-settings/recall-settings.ts index 00569d54f..230135ed4 100644 --- a/packages/server/src/function-settings/recall-settings.ts +++ b/packages/server/src/function-settings/recall-settings.ts @@ -17,11 +17,11 @@ interface Bounds { readonly example: number; } -const mostFunctions: Bounds = { setting: 'RECOLLECTION_MAX_FUNCTIONS', least: 1, most: 1000, example: 32 }; +const mostFunctions: Bounds = { setting: 'RECALL_MAX_FUNCTIONS', least: 1, most: 1000, example: 32 }; -const rebuildsAtOnce: Bounds = { setting: 'RECOLLECTION_MAX_REBUILDS', least: 1, most: 64, example: 4 }; +const rebuildsAtOnce: Bounds = { setting: 'RECALL_MAX_REBUILDS', least: 1, most: 64, example: 4 }; -const brainsAtOnce: Bounds = { setting: 'RECOLLECTION_BRAINS_AT_ONCE', least: 1, most: 64, example: 4 }; +const brainsAtOnce: Bounds = { setting: 'RECALL_BRAINS_AT_ONCE', least: 1, most: 64, example: 4 }; function sourceOf({ setting, example }: Bounds): Config.Config { return Config.String(setting).pipe(Config.withDefault(String(example))); diff --git a/packages/server/src/guides/recipes.ts b/packages/server/src/guides/recipes.ts index 735d3dd3d..babb00bdb 100644 --- a/packages/server/src/guides/recipes.ts +++ b/packages/server/src/guides/recipes.ts @@ -11,7 +11,7 @@ const outlines: readonly RecipeOutline[] = [ description: 'Creates a brain with a first reasoning function, and runs it once the person has agreed to it.', arguments: [], formatGuide: 'reasoning-function', - calls: ['list_brains', 'create_brain', 'create_spec', 'test_tool_call', 'execute_spec'], + calls: ['list_brains', 'create_brain', 'create_definition', 'test_tool_call', 'run_definition'], request: () => 'Create my first brain.', }, { @@ -26,7 +26,7 @@ const outlines: readonly RecipeOutline[] = [ }, ], formatGuide: 'recall-function', - calls: ['list_specs', 'create_spec', 'update_spec', 'execute_spec'], + calls: ['list_definitions', 'create_definition', 'update_definition', 'run_definition'], request: ({ what }) => `Make the brain remember ${String(what)}.`, }, { @@ -41,7 +41,7 @@ const outlines: readonly RecipeOutline[] = [ }, ], formatGuide: 'reasoning-function', - calls: ['list_tool_servers', 'test_tool_call', 'create_spec', 'execute_spec', 'get_execution_history'], + calls: ['list_tool_servers', 'test_tool_call', 'create_definition', 'run_definition', 'get_run_history'], request: ({ server }) => server === undefined ? 'Give the brain tools.' : `Give the brain the tools of the tool server ${server}.`, }, @@ -58,7 +58,7 @@ const outlines: readonly RecipeOutline[] = [ }, ], formatGuide: 'workflow', - calls: ['list_specs', 'create_spec', 'update_spec', 'list_executions', 'get_execution'], + calls: ['list_definitions', 'create_definition', 'update_definition', 'list_runs', 'get_run'], request: ({ workflow, when }) => `Run the workflow ${String(workflow)} ${String(when)}.`, }, ]; diff --git a/packages/server/src/guides/recipes/first-brain.md b/packages/server/src/guides/recipes/first-brain.md index 9238f761f..e63ce0f4c 100644 --- a/packages/server/src/guides/recipes/first-brain.md +++ b/packages/server/src/guides/recipes/first-brain.md @@ -6,6 +6,6 @@ A first brain holds one reasoning function that the person can run and see the a 2. Ask the person what the brain is for, in their own words, and what its first function should do, such as checking a campaign brief for an audience, a budget and a measurable goal. When more than one model is offered, ask which to use; a model whose id ends in `*` stands for many and is not a model to run. 3. Create the brain with create_brain, with an id of lowercase letters, digits and hyphens and the person's words on what it is for, or use the brain they name when list_brains shows it. 4. Write the reasoning function in the format of the reasoning-function guide: its model, a description, an input schema with the fields the person will give, and a prompt. When it should use a tool server's tools, follow the give-tools recipe: to learn what a tool answers, test it with test_tool_call, and never make a function to look. Show the person the whole document and ask whether to save it. -5. Once they agree, save it with create_spec, `primitive` inference. A name that is taken is refused; choose another with the person rather than changing the function that has it. -6. Ask for an input, run the function with execute_spec, and tell the person what it answered, in their words. +5. Once they agree, save it with create_definition, `type` reasoning. A name that is taken is refused; choose another with the person rather than changing the function that has it. +6. Ask for an input, run the function with run_definition, and tell the person what it answered, in their words. 7. Tell the person what they can do next: run it again on another input, change it, give it tools with the give-tools recipe, make the brain remember its answers with the remember recipe, or run it on a schedule with the schedule recipe. diff --git a/packages/server/src/guides/recipes/give-tools.md b/packages/server/src/guides/recipes/give-tools.md index ca008e3ea..e7c3f0570 100644 --- a/packages/server/src/guides/recipes/give-tools.md +++ b/packages/server/src/guides/recipes/give-tools.md @@ -7,6 +7,6 @@ A reasoning function can call the tools of the tool servers that whoever runs th 3. When a server says it cannot be asked just now, tell the person why, in its words; whoever runs the server can look into it. 4. To learn what a tool answers, test it with test_tool_call, with the arguments its input_schema takes, and read the answer: that is what the function's model will see. Never make a function to look. When a prompt needs an id, such as a channel's, test the tool that lists them and take the id from its answer, confirming the choice with the person. A tool that cannot be tested may change something; list_tool_servers says which can. 5. Ask the person what the function should do with the tools, and which of them it needs. -6. Write a reasoning function whose `tools` name those tools in the format of the reasoning-function guide, show it to the person, and save it with create_spec, `primitive` inference, once they agree. -7. Run it with execute_spec. A run that called tools and did not succeed is not run again under its id, since a tool may have changed something: read what it called with get_execution_history, and start a new run only if the person still wants one. +6. Write a reasoning function whose `tools` name those tools in the format of the reasoning-function guide, show it to the person, and save it with create_definition, `type` reasoning, once they agree. +7. Run it with run_definition. A run that called tools and did not succeed is not run again under its id, since a tool may have changed something: read what it called with get_run_history, and start a new run only if the person still wants one. 8. Tell the person what the run did with the tools, and that the function can call only the tools it names. diff --git a/packages/server/src/guides/recipes/remember.md b/packages/server/src/guides/recipes/remember.md index e1f8e0f1a..b0e159c63 100644 --- a/packages/server/src/guides/recipes/remember.md +++ b/packages/server/src/guides/recipes/remember.md @@ -2,10 +2,10 @@ A recall function answers from what it keeps of the brain's own history, its view: every run's start and end, with its result when it succeeded, the definitions saved and every event published to the brain. A view holds what a run answered, never what it was given or the tools it called. Read the recall-function guide with get_guide before you write one. -1. Ask the person what the brain should remember, and which function's runs hold it, such as what one function posted today. Find that function with list_specs. -2. Tell the person that a view holds what a run answered, never what it was given or the tools it called, so a function whose job is to post must answer what it posted. When the function does not answer it, offer to change the function so that it does, and show the change before you save it with update_spec. -3. When no function holds it yet, write that function first and save it with create_spec once the person agrees. -4. Write a recall function whose view folds that function's succeeded runs, with a filter of the type `execution_succeeded` and the subject `/` of the function, and whose `answer` gives what the person asked for. The second example of the recall-function guide keeps what one function answered. -5. Show the person the document, and save it with create_spec, `primitive` recollection, once they agree. -6. Run it with execute_spec once it answers. While its view is still being built, the run says so: try again in a moment, since a new version builds its view from the brain's whole history. +1. Ask the person what the brain should remember, and which function's runs hold it, such as what one function posted today. Find that function with list_definitions. +2. Tell the person that a view holds what a run answered, never what it was given or the tools it called, so a function whose job is to post must answer what it posted. When the function does not answer it, offer to change the function so that it does, and show the change before you save it with update_definition. +3. When no function holds it yet, write that function first and save it with create_definition once the person agrees. +4. Write a recall function whose view folds that function's succeeded runs, with a filter of the type `run_succeeded` and the subject `/` of the function, and whose `answer` gives what the person asked for. The second example of the recall-function guide keeps what one function answered. +5. Show the person the document, and save it with create_definition, `type` recall, once they agree. +6. Run it with run_definition once it answers. While its view is still being built, the run says so: try again in a moment, since a new version builds its view from the brain's whole history. 7. Tell the person that the brain now remembers from its whole history on, every run of that function whoever started it, and that it sees nothing done outside the brain. diff --git a/packages/server/src/guides/recipes/schedule.md b/packages/server/src/guides/recipes/schedule.md index cde5ec617..f9ef9a6eb 100644 --- a/packages/server/src/guides/recipes/schedule.md +++ b/packages/server/src/guides/recipes/schedule.md @@ -2,8 +2,8 @@ A workflow can start on its own: at set times or every so often, in UTC, or whenever an event of a given type is published to the brain. Read the workflow guide with get_guide before you write one. -1. Ask the person which workflow to run and when, such as every weekday at 9:00 their time, or whenever the month's books are closed. Find the workflow with list_specs, `primitive` orchestration, or write one in the format of the workflow guide. +1. Ask the person which workflow to run and when, such as every weekday at 9:00 their time, or whenever the month's books are closed. Find the workflow with list_definitions, `type` workflow, or write one in the format of the workflow guide. 2. Give the workflow a `schedule` as the workflow guide says, with one trigger or up to three, each kept on its own: `cron` with five fields, minute, hour, day of month, month and day of week, in UTC; `every` with a duration of at least a minute; and `on` with the type of the events that start it. A run its schedule starts runs as the brain itself, with its due time, or a list of the event, as its input, and its history says which trigger started it. -3. Show the person the document, and save it with update_spec, or with create_spec for a new workflow, once they agree. +3. Show the person the document, and save it with update_definition, or with create_definition for a new workflow, once they agree. 4. Tell the person when it runs next, in their own time, and that every run is kept in the brain's history. -5. To see how its runs went, call list_executions with `primitive` orchestration and the workflow's `name`, and get_execution for one run. +5. To see how its runs went, call list_runs with `type` workflow and the workflow's `name`, and get_run for one run. diff --git a/packages/server/src/guides/served-guides.test.ts b/packages/server/src/guides/served-guides.test.ts index 92bf19284..7cc9912ad 100644 --- a/packages/server/src/guides/served-guides.test.ts +++ b/packages/server/src/guides/served-guides.test.ts @@ -2,7 +2,7 @@ import { Buffer } from 'node:buffer'; import { readFileSync } from 'node:fs'; import { mostGuideBytes, mostRecipeBytes } from '@beonauto/api'; -import { definePrimitive, type Primitive, type PrimitiveGuide } from '@beonauto/specs'; +import { defineCapability, type Capability, type CapabilityGuide } from '@beonauto/definitions'; import { Effect } from 'effect'; import { describe, expect, it } from 'vitest'; @@ -10,9 +10,9 @@ import { withLinksResolved } from './page-links.ts'; import { servedGuidesOf } from './served-guides.ts'; import { withoutSiteMarkup } from './site-markup.ts'; -function primitiveOf(name: string, noun: string, guide: PrimitiveGuide): Primitive { - return definePrimitive({ - name, +function capabilityOf(name: string, noun: string, guide: CapabilityGuide): Capability { + return defineCapability({ + type: name, title: noun, guide, noun: { one: noun, other: `${noun}s` }, @@ -20,26 +20,26 @@ function primitiveOf(name: string, noun: string, guide: PrimitiveGuide): Primiti mediaType: 'text/markdown', parse: () => Effect.succeed({}), summarize: () => ({}), - execute: () => Effect.succeed({ output: null, record: {} }), + run: () => Effect.succeed({ output: null, record: {} }), }); } const onThisServer = 'On this server, a reasoning function names its model through anthropic.'; -const reasoning = primitiveOf('inference', 'reasoning function', { name: 'reasoning-function', onThisServer }); +const reasoning = capabilityOf('reasoning', 'reasoning function', { name: 'reasoning-function', onThisServer }); -const computation = primitiveOf('computation', 'computation function', { name: 'computation-function' }); +const computation = capabilityOf('computation', 'computation function', { name: 'computation-function' }); -const recall = primitiveOf('recollection', 'recall function', { name: 'recall-function' }); +const recall = capabilityOf('recall', 'recall function', { name: 'recall-function' }); -const workflow = primitiveOf('orchestration', 'workflow', { name: 'workflow' }); +const workflow = capabilityOf('workflow', 'workflow', { name: 'workflow' }); const everyType = [reasoning, computation, recall, workflow]; -const interaction = primitiveOf('interaction', 'interaction function', { name: 'interaction-function' }); +const interaction = capabilityOf('interaction', 'interaction function', { name: 'interaction-function' }); -function recipeTextsOf(primitives: readonly Primitive[]): Readonly> { - return Object.fromEntries(servedGuidesOf(primitives).recipes.map(({ name, text }) => [name, text])); +function recipeTextsOf(capabilities: readonly Capability[]): Readonly> { + return Object.fromEntries(servedGuidesOf(capabilities).recipes.map(({ name, text }) => [name, text])); } function bytesOf(texts: Readonly>, ...names: readonly string[]): readonly number[] { @@ -80,10 +80,10 @@ describe('the guides of a server that runs every type of definition', () => { 'schedule', ]); expect(definitionTypes).toEqual([ - { primitive: 'inference', noun: 'reasoning function', guide: 'reasoning-function' }, - { primitive: 'computation', noun: 'computation function', guide: 'computation-function' }, - { primitive: 'recollection', noun: 'recall function', guide: 'recall-function' }, - { primitive: 'orchestration', noun: 'workflow', guide: 'workflow' }, + { type: 'reasoning', noun: 'reasoning function', guide: 'reasoning-function' }, + { type: 'computation', noun: 'computation function', guide: 'computation-function' }, + { type: 'recall', noun: 'recall function', guide: 'recall-function' }, + { type: 'workflow', noun: 'workflow', guide: 'workflow' }, ]); }); @@ -184,7 +184,7 @@ describe('the recipes that give a brain tools', () => { "When it should use a tool server's tools, follow the give-tools recipe: to learn what a tool answers, test it with test_tool_call, and never make a function to look.", ); expect([Buffer.byteLength(String(texts['give-tools'])), Buffer.byteLength(String(texts['first-brain']))]).toEqual([ - 2052, 1761, + 2049, 1764, ]); }); }); @@ -200,11 +200,11 @@ describe('the recipes of a server that serves interaction functions', () => { 'give it tools with the give-tools recipe, send someone a message through a tool and take their answer with an interaction function, make the brain remember its answers', ); expect([ - bytesOf(texts, 'give-tools', 'first-brain'), - bytesOf(recipeTextsOf(everyType), 'give-tools', 'first-brain'), + bytesOf(texts, 'first-brain', 'remember', 'give-tools', 'schedule'), + bytesOf(recipeTextsOf(everyType), 'first-brain', 'remember', 'give-tools', 'schedule'), ]).toEqual([ - [2312, 1851], - [2052, 1761], + [1854, 1762, 2309, 1285], + [1764, 1762, 2049, 1285], ]); }); @@ -254,7 +254,7 @@ describe('the terminology guide of a server', () => { describe('a server without the page of a type it runs', () => { it('fails to start', () => { - expect(() => servedGuidesOf([primitiveOf('drafting', 'draft', { name: 'drafting-function' })])).toThrow( + expect(() => servedGuidesOf([capabilityOf('drafting', 'draft', { name: 'drafting-function' })])).toThrow( /no such file or directory.*drafting-format\.md/u, ); }); diff --git a/packages/server/src/guides/served-guides.ts b/packages/server/src/guides/served-guides.ts index d5d82c78b..2f971ac01 100644 --- a/packages/server/src/guides/served-guides.ts +++ b/packages/server/src/guides/served-guides.ts @@ -1,8 +1,8 @@ import { readFileSync } from 'node:fs'; import type { DefinitionType, Guide, Recipe } from '@beonauto/api'; +import type { Capability } from '@beonauto/definitions'; import { capitalized } from '@beonauto/operations'; -import type { Primitive } from '@beonauto/specs'; import { withLinksResolved } from './page-links.ts'; import { recipesFor } from './recipes.ts'; @@ -27,7 +27,7 @@ function pageText(page: string): string { return readFileSync(new URL(page, documentation), 'utf8'); } -function typeGuideOf({ noun, guide }: Pick): Guide { +function typeGuideOf({ noun, guide }: Pick): Guide { const page = pageOfGuide(guide.name); const text = withLinksResolved(withoutSiteMarkup(pageText(page)), page); return { @@ -38,9 +38,9 @@ function typeGuideOf({ noun, guide }: Pick): Guide }; } -export function servedGuidesOf(primitives: readonly Primitive[]): ServedGuides { - const definitionTypes: readonly DefinitionType[] = primitives.map(({ name, noun, guide }) => ({ - primitive: name, +export function servedGuidesOf(capabilities: readonly Capability[]): ServedGuides { + const definitionTypes: readonly DefinitionType[] = capabilities.map(({ type, noun, guide }) => ({ + type, noun: noun.one, guide: guide.name, })); @@ -49,7 +49,7 @@ export function servedGuidesOf(primitives: readonly Primitive[]): ServedGuides { pageText(terminologyPage), definitionTypes.map(({ noun }) => noun), ), - ...primitives.map(({ noun, guide }) => typeGuideOf({ noun, guide })), + ...capabilities.map(({ noun, guide }) => typeGuideOf({ noun, guide })), ]; return { definitionTypes, guides, recipes: recipesFor(guides) }; } diff --git a/packages/server/src/guides/terminology-guide.test.ts b/packages/server/src/guides/terminology-guide.test.ts index 8a992f8a9..e390e044b 100644 --- a/packages/server/src/guides/terminology-guide.test.ts +++ b/packages/server/src/guides/terminology-guide.test.ts @@ -46,7 +46,7 @@ describe('the terminology guide', () => { expect(text).not.toContain('Language model'); expect(text).not.toContain('Predictive model'); - expect(text).toContain('- Run: One execution of a workflow or function against particular inputs.'); + expect(text).toContain('- Run: A workflow or function carried out once against particular inputs.'); }); it('cannot be made from a page without the tables it reads', () => { diff --git a/packages/server/src/interaction/documented-examples.test.ts b/packages/server/src/interaction/documented-examples.test.ts index 4c3c3a331..ea636516a 100644 --- a/packages/server/src/interaction/documented-examples.test.ts +++ b/packages/server/src/interaction/documented-examples.test.ts @@ -1,12 +1,12 @@ import { withMcpSession } from '@beonauto/api/testing'; -import { answers, textResult } from '@beonauto/inference/testing'; +import { answers, textResult } from '@beonauto/reasoning/testing'; import { Schema } from 'effect'; import { describe, expect, it, onTestFinished } from 'vitest'; import { interactionServerOn } from '../testing/servers/interaction-server.ts'; import { alpha } from '../testing/servers/reasoning-server.ts'; import { fencedBlocksOf, pageOf, tutorialRunOn } from '../testing/servers/tutorial-calls.ts'; -import { executionIdIn, servingWorkflows, workflowTestTimeoutMs } from '../testing/servers/workflow-server.ts'; +import { runIdIn, servingWorkflows, workflowTestTimeoutMs } from '../testing/servers/workflow-server.ts'; const reference = fencedBlocksOf('reference/interaction-format.md'); @@ -16,20 +16,20 @@ describe('the example of the interaction function format', { timeout: workflowTe it('asks through the inbox in a workflow that takes the answer the page shows as its output', async () => { const server = await interactionServerOn({}); await server.call('POST', '/v1/orgs/acme/brains', { body: { brain: 'alpha', name: 'Alpha' } }); - await server.call('POST', `${alpha}/specs/interaction`, { + await server.call('POST', `${alpha}/definitions/interaction`, { body: { name: 'approve-brief', source: reference.get('markdown') }, }); - await server.call('POST', `${alpha}/specs/orchestration`, { + await server.call('POST', `${alpha}/definitions/workflow`, { body: { name: 'brief-approval', source: reference.get('yaml') }, }); - const started = await server.call('POST', `${alpha}/specs/orchestration/brief-approval/execute`, { + const started = await server.call('POST', `${alpha}/definitions/workflow/brief-approval/run`, { body: { input: { campaign: 'Spring', owner: 'ada', summary: 'A brief for the spring sale.' } }, }); const [request] = await server.openRequests(1); const answered = await server.answer(String(request), decodeAnswer(reference.get('json'))); expect(answered.status).toBe(200); - expect(await server.settled(executionIdIn(started.body))).toMatchObject({ + expect(await server.settled(runIdIn(started.body))).toMatchObject({ status: 'succeeded', output: { choice: 'approve', note: 'Ready to launch.' }, }); diff --git a/packages/server/src/interaction/interaction-expiry.test.ts b/packages/server/src/interaction/interaction-expiry.test.ts index 19b6ec32f..8bcb08352 100644 --- a/packages/server/src/interaction/interaction-expiry.test.ts +++ b/packages/server/src/interaction/interaction-expiry.test.ts @@ -48,8 +48,8 @@ describe('requests past their expiry while the server was stopped', { timeout: w expect(await second.settled(uncaught)).toMatchObject(unansweredAsExpired); expect(await second.openRequests(0)).toEqual([]); expect( - await second.call('POST', `${alpha}/specs/interaction/approve-brief/execute`, { - body: { input: brief, execution_id: asked }, + await second.call('POST', `${alpha}/definitions/interaction/approve-brief/run`, { + body: { input: brief, run_id: asked }, }), ).toMatchObject({ status: 410, body: { type: 'https://on.auto/problems/unanswered', kind: 'expired' } }); }); diff --git a/packages/server/src/interaction/interaction-over-http.test.ts b/packages/server/src/interaction/interaction-over-http.test.ts index 8ec161bc8..ed6d57db0 100644 --- a/packages/server/src/interaction/interaction-over-http.test.ts +++ b/packages/server/src/interaction/interaction-over-http.test.ts @@ -2,33 +2,33 @@ import { describe, expect, it } from 'vitest'; import { asking, brief, servingInteractions } from '../testing/servers/interaction-server.ts'; import { alpha } from '../testing/servers/reasoning-server.ts'; -import { executionIdIn, workflowTestTimeoutMs } from '../testing/servers/workflow-server.ts'; +import { runIdIn, workflowTestTimeoutMs } from '../testing/servers/workflow-server.ts'; describe('an interaction function through the inbox, over HTTP', { timeout: workflowTestTimeoutMs }, () => { it('leaves its request in the inbox and takes a checked answer as the output, once', async () => { const server = await servingInteractions(); - const started = await server.call('POST', `${alpha}/specs/interaction/approve-brief/execute`, { + const started = await server.call('POST', `${alpha}/definitions/interaction/approve-brief/run`, { body: { input: brief }, }); - const runId = executionIdIn(started.body); + const runId = runIdIn(started.body); const listed = await server.call('GET', `${alpha}/interactions?to=ada&function=approve-brief`); const invalid = await server.answer(runId, { answer: { choice: 'maybe' } }); const answered = await server.answer(runId, { answer: { choice: 'approve' }, claimed_for: 'the campaign team' }); const again = await server.answer(runId, { answer: { choice: 'approve' } }); const another = await server.answer(runId, { answer: { choice: 'reject' } }); - const history = await server.call('GET', `${alpha}/executions/${runId}/history`); - const analytics = await server.call('GET', `${alpha}/analytics?primitive=interaction`); + const history = await server.call('GET', `${alpha}/runs/${runId}/history`); + const analytics = await server.call('GET', `${alpha}/analytics?type=interaction`); expect(started).toMatchObject({ status: 200, body: { status: 'started' } }); - expect(listed).toMatchObject({ status: 200, body: { interactions: [{ execution_id: runId }] } }); + expect(listed).toMatchObject({ status: 200, body: { interactions: [{ run_id: runId }] } }); expect([invalid.status, answered.status, again.status, another.status]).toEqual([422, 200, 200, 409]); expect(invalid.body).toMatchObject({ errors: [{ pointer: '/answer/choice' }] }); expect(await server.settled(runId)).toMatchObject({ status: 'succeeded', output: { choice: 'approve' } }); expect(history.text).not.toContain('approve"'); expect(analytics.body).toMatchObject({ runs: { succeeded: 1 }, - by_function: [{ primitive: 'interaction', name: 'approve-brief', runs: 1 }], + by_function: [{ type: 'interaction', name: 'approve-brief', runs: 1 }], }); }); }); @@ -41,7 +41,7 @@ describe( const server = await servingInteractions(); const runId = await server.ask('approve-brief'); - await server.call('POST', `${alpha}/executions/${runId}/cancel`, { body: { reason: 'The brief was withdrawn' } }); + await server.call('POST', `${alpha}/runs/${runId}/cancel`, { body: { reason: 'The brief was withdrawn' } }); const settled = await server.settled(runId); const late = await server.answer(runId, { answer: { choice: 'approve' } }); @@ -65,7 +65,7 @@ describe( describe('an Authorization header that holds no Bearer key, over HTTP', { timeout: workflowTestTimeoutMs }, () => { it('is refused as malformed, in the one sentence that names Bearer, before any brain is read', async () => { const server = await servingInteractions(); - const answered = await server.call('POST', `${alpha}/executions/0199a3c4-7d2e-7c1a-9b3f-2f1e0d9c8b7a/answer`, { + const answered = await server.call('POST', `${alpha}/runs/0199a3c4-7d2e-7c1a-9b3f-2f1e0d9c8b7a/answer`, { body: { answer: { choice: 'approve' } }, authorization: 'Request not-a-token-of-any-request', }); diff --git a/packages/server/src/interaction/interaction-reactions.test.ts b/packages/server/src/interaction/interaction-reactions.test.ts index 6518e7e12..9f86571c9 100644 --- a/packages/server/src/interaction/interaction-reactions.test.ts +++ b/packages/server/src/interaction/interaction-reactions.test.ts @@ -8,14 +8,14 @@ import { workflowSource, workflowTestTimeoutMs } from '../testing/servers/workfl const decodeRuns = Schema.decodeUnknownSync( Schema.Struct({ - executions: Schema.Array(Schema.Struct({ execution_id: Schema.String, status: Schema.String })), + runs: Schema.Array(Schema.Struct({ run_id: Schema.String, status: Schema.String })), }), ); const onAnApproval = workflowSource( 'on-approval', `schedule: - on: { one: { with: { type: execution_succeeded, subject: interaction/approve-brief } } } + on: { one: { with: { type: run_succeeded, subject: interaction/approve-brief } } } do: - noted: { set: { choice: '\${ .[0].data.output.choice }', answered_by: '\${ .[0].data.caller }' } } `, @@ -23,26 +23,24 @@ do: const hurried = `do: - ask: - call: execute_spec - with: { primitive: interaction, name: approve-brief, input: { campaign: Spring, owner: ada } } + call: run_definition + with: { type: interaction, name: approve-brief, input: { campaign: Spring, owner: ada } } timeout: { after: { milliseconds: 500 } } `; describe('another workflow, triggered by the answer to a request', { timeout: workflowTestTimeoutMs }, () => { it('starts on the ending of the interaction function and reads the answer and who gave it', async () => { const server = await servingInteractions(); - await server.call('POST', `${alpha}/specs/orchestration`, { body: { name: 'on-approval', source: onAnApproval } }); + await server.call('POST', `${alpha}/definitions/workflow`, { body: { name: 'on-approval', source: onAnApproval } }); const runId = await server.ask('approve-brief'); await server.answer(runId, { answer: { choice: 'approve' } }); const [reaction] = await until( - async () => - decodeRuns((await server.call('GET', `${alpha}/executions?primitive=orchestration&name=on-approval`)).body) - .executions, + async () => decodeRuns((await server.call('GET', `${alpha}/runs?type=workflow&name=on-approval`)).body).runs, (runs) => runs.some(({ status }) => status !== 'started'), ); - expect(await server.settled(String(reaction?.execution_id))).toMatchObject({ + expect(await server.settled(String(reaction?.run_id))).toMatchObject({ status: 'succeeded', output: { choice: 'approve', answered_by: 'local' }, }); diff --git a/packages/server/src/interaction/interaction-through-tools.test.ts b/packages/server/src/interaction/interaction-through-tools.test.ts index b828c443c..db600347b 100644 --- a/packages/server/src/interaction/interaction-through-tools.test.ts +++ b/packages/server/src/interaction/interaction-through-tools.test.ts @@ -5,7 +5,7 @@ import { chatEnvironment, chatKey, chatServer, deliveryHistoryOf } from '../test import { servingInteractions } from '../testing/servers/interaction-server.ts'; import { alpha } from '../testing/servers/reasoning-server.ts'; import { until } from '../testing/servers/workflow-calls.ts'; -import { executionIdIn, workflowTestTimeoutMs } from '../testing/servers/workflow-server.ts'; +import { runIdIn, workflowTestTimeoutMs } from '../testing/servers/workflow-server.ts'; const aDigest: unknown = expect.stringMatching(/^[0-9a-f]{64}$/u); @@ -33,7 +33,7 @@ describe('an interaction function that delivers through a tool, over HTTP', { ti expect(facts).toEqual([ { type: 'delivery_started', - execution_id: runId, + run_id: runId, by: 'brain:alpha', number: 1, delivery: { server: 'chat', tool: 'post_message' }, @@ -43,7 +43,7 @@ describe('an interaction function that delivers through a tool, over HTTP', { ti }, { type: 'delivery_ended', - execution_id: runId, + run_id: runId, by: 'brain:alpha', number: 1, outcome: 'delivered', @@ -55,9 +55,7 @@ describe('an interaction function that delivers through a tool, over HTTP', { ti }, ]); expect(listed.body).toMatchObject({ - interactions: [ - { execution_id: runId, delivery: { server: 'chat', tool: 'post_message' }, standing: 'delivered' }, - ], + interactions: [{ run_id: runId, delivery: { server: 'chat', tool: 'post_message' }, standing: 'delivered' }], }); }); }); @@ -69,11 +67,11 @@ describe( it('records the arguments and the answer, scrubbed, where the server records its content, shown at 2 KiB', async () => { const chat = await chatServer(); const server = await servingInteractions(chatDelivery, chatEnvironment(chat.url, { record_content: true })); - const started = await server.call('POST', `${alpha}/specs/interaction/approve-brief/execute`, { + const started = await server.call('POST', `${alpha}/definitions/interaction/approve-brief/run`, { body: { input: { owner: 'ada', campaign: `${chatKey} ${'x'.repeat(5000)}` } }, }); - const [start, end] = await endedFacts(server, executionIdIn(started.body)); + const [start, end] = await endedFacts(server, runIdIn(started.body)); expect(String(start?.['arguments_json'])).toMatch( /^\{"channel":"#approvals-ada","text":"Please review the brief for \[redacted\] x+$/u, @@ -89,11 +87,11 @@ describe( expect(await server.settled(runId)).toMatchObject({ status: 'succeeded', output: {} }); expect(await server.causes(runId)).toEqual([ - ['execution_started', null], - ['interaction_requested', 'execution_started'], + ['run_started', null], + ['interaction_requested', 'run_started'], ['delivery_started', 'interaction_requested'], ['delivery_ended', 'delivery_started'], - ['execution_succeeded', 'delivery_ended'], + ['run_succeeded', 'delivery_ended'], ]); }); }, diff --git a/packages/server/src/interaction/replies-over-http.test.ts b/packages/server/src/interaction/replies-over-http.test.ts index fa1047087..02ad3ce83 100644 --- a/packages/server/src/interaction/replies-over-http.test.ts +++ b/packages/server/src/interaction/replies-over-http.test.ts @@ -64,7 +64,7 @@ describe( 'tool: thread_replies', 'tool: echo', ); - const updated = await server.call('PUT', `${alpha}/specs/interaction/approve-brief`, { + const updated = await server.call('PUT', `${alpha}/definitions/interaction/approve-brief`, { body: { source: changed }, }); chat.chat.reply({ channel: '#approvals-ada', thread: chat.chat.posted()[0]?.ts, user: 'ada', text: 'reject' }); @@ -83,7 +83,7 @@ describe( it('share one row and one read for their requests', async () => { const chat = await chatServer(); const server = await servingInteractions([...chatDelivery, ...flatReading], chatEnvironment(chat.url)); - await server.call('POST', `${alpha}/specs/interaction`, { + await server.call('POST', `${alpha}/definitions/interaction`, { body: { name: 'approve-again', source: approvalDocument([...chatDelivery, ...flatReading]) }, }); await server.ask('approve-brief'); @@ -120,7 +120,7 @@ describe( const events = await until(() => brainEventsOf(server), readAndTold); const listing = JSON.stringify((await server.call('GET', `${alpha}/interactions`)).body); - const history = JSON.stringify((await server.call('GET', `${alpha}/executions/${runId}/history`)).body); + const history = JSON.stringify((await server.call('GET', `${alpha}/runs/${runId}/history`)).body); expect(events).toContain('[redacted]'); expect(events).toContain('telling_started'); diff --git a/packages/server/src/interaction/request-crashes-on-stores.test.ts b/packages/server/src/interaction/request-crashes-on-stores.test.ts index c140d7353..32ea23be1 100644 --- a/packages/server/src/interaction/request-crashes-on-stores.test.ts +++ b/packages/server/src/interaction/request-crashes-on-stores.test.ts @@ -121,7 +121,7 @@ describe.each(stores)('a reply taken while another settlement is under way, on $ await racing.started(); const meanwhile = await asked.brain.call(answerInteraction, { - execution_id: askedRunId, + run_id: askedRunId, answer: { choice: 'reject' }, }); await asked.brain.performDue(Date.now()); @@ -150,7 +150,7 @@ describe.each(stores)( await racing.started(); await asked.brain.cancel(askedRunId); - await racing.cancelSettled(asked.brain.primitive); + await racing.cancelSettled(asked.brain.capability); expect(await asked.brain.runOf(askedRunId)).toMatchObject({ output: { status: 'succeeded', output: {}, record: { delivered_at: anyTime } }, diff --git a/packages/server/src/interaction/tool-disallowed.test.ts b/packages/server/src/interaction/tool-disallowed.test.ts index f45ce067f..d43778251 100644 --- a/packages/server/src/interaction/tool-disallowed.test.ts +++ b/packages/server/src/interaction/tool-disallowed.test.ts @@ -50,7 +50,7 @@ describe('a tool its operator disallowed after a request asked through it', { ti expect(unreached[1]).toEqual({ type: 'delivery_ended', - execution_id: runId, + run_id: runId, by: 'brain:alpha', number: 1, outcome: 'failed', diff --git a/packages/server/src/interaction/tool-refusals.test.ts b/packages/server/src/interaction/tool-refusals.test.ts index 31b6dac7e..ccc653fb5 100644 --- a/packages/server/src/interaction/tool-refusals.test.ts +++ b/packages/server/src/interaction/tool-refusals.test.ts @@ -15,15 +15,15 @@ const decodeHistory = Schema.decodeUnknownSync( const namingTheTool: unknown = expect.stringContaining('post_message'); function asked(server: Awaited>) { - return server.call('POST', `${alpha}/specs/interaction/approve-brief/execute`, { - body: { input: brief, execution_id: '0199a3c4-7d2e-7c1a-9b3f-2f1e0d9c8b7a' }, + return server.call('POST', `${alpha}/definitions/interaction/approve-brief/run`, { + body: { input: brief, run_id: '0199a3c4-7d2e-7c1a-9b3f-2f1e0d9c8b7a' }, }); } async function refusedThrough(environment: Readonly>) { const server = await servingInteractions(chatDelivery, environment); const refusal = await asked(server); - const history = await server.call('GET', `${alpha}/executions/0199a3c4-7d2e-7c1a-9b3f-2f1e0d9c8b7a/history`); + const history = await server.call('GET', `${alpha}/runs/0199a3c4-7d2e-7c1a-9b3f-2f1e0d9c8b7a/history`); return { refusal: refusal.body, types: decodeHistory(history.body).events.map(({ type }) => type) }; } @@ -40,7 +40,7 @@ describe('a delivery through a tool this brain may not use, over HTTP', { timeou expect(refusals).toMatchObject([ { refusal: { reason: 'unavailable', kind: 'tool_not_offered', because: 'mcp_server_not_configured' }, - types: ['execution_started', 'execution_rejected'], + types: ['run_started', 'run_rejected'], }, { refusal: { detail: 'No MCP server named chat is configured for this brain' } }, { @@ -48,7 +48,7 @@ describe('a delivery through a tool this brain may not use, over HTTP', { timeou because: 'tool_not_allowed', detail: 'The operator of this server does not allow chat/post_message', }, - types: ['execution_started', 'execution_rejected'], + types: ['run_started', 'run_rejected'], }, ]); expect(chat.received()).toEqual([]); diff --git a/packages/server/src/interaction/typed-arguments.test.ts b/packages/server/src/interaction/typed-arguments.test.ts index 758178470..94c57d652 100644 --- a/packages/server/src/interaction/typed-arguments.test.ts +++ b/packages/server/src/interaction/typed-arguments.test.ts @@ -47,9 +47,11 @@ describe('the arguments of a delivery, typed as written, over HTTP', { timeout: it('sends a number, a boolean and an object as written, an expression alone as its value, and other strings as text', async () => { const chat = await chatServer(); const server = await servingInteractions([], chatEnvironment(chat.url)); - await server.call('POST', `${alpha}/specs/interaction`, { body: { name: 'approve-limit', source: limitApproval } }); + await server.call('POST', `${alpha}/definitions/interaction`, { + body: { name: 'approve-limit', source: limitApproval }, + }); - await server.call('POST', `${alpha}/specs/interaction/approve-limit/execute`, { + await server.call('POST', `${alpha}/definitions/interaction/approve-limit/run`, { body: { input: { owner: 'ada', limit: 20 } }, }); const [received] = await until( diff --git a/packages/server/src/logging/host-notes.test.ts b/packages/server/src/logging/host-notes.test.ts index ed44e3e26..62693b387 100644 --- a/packages/server/src/logging/host-notes.test.ts +++ b/packages/server/src/logging/host-notes.test.ts @@ -3,7 +3,7 @@ import { describe, expect, it } from 'vitest'; import { linesLoggedBy } from '../testing/records/logged-lines.ts'; import { logHostNote } from './host-notes.ts'; -const run = { org: 'acme', brain: 'alpha', executionId: '0199a3c4-7d2e-7c1a-9b3f-2f1e0d9c8b7a' }; +const run = { org: 'acme', brain: 'alpha', runId: '0199a3c4-7d2e-7c1a-9b3f-2f1e0d9c8b7a' }; describe('the notes of the workflow host', () => { it('warn that another server runs the workflows of the database, and until when it holds them', async () => { @@ -25,7 +25,7 @@ describe('the notes of the workflow host', () => { ); }); - it('warn once when a settlement backs off, and once when it is settled at last, naming only the execution', async () => { + it('warn once when a settlement backs off, and once when it is settled at last, naming only the run', async () => { const lines = [ ...(await linesLoggedBy( logHostNote({ kind: 'settle_backing_off', run, attempts: 20, detail: 'The ledger cannot be reached' }), @@ -35,14 +35,12 @@ describe('the notes of the workflow host', () => { expect(lines).toEqual([ expect.stringContaining( - '"message":"An execution could not be settled in 20 attempts; it is tried again once a minute until it is","level":"WARN"', - ), - expect.stringContaining( - '"message":"An execution that could not be settled was settled at attempt 22","level":"WARN"', + '"message":"A run could not be settled in 20 attempts; it is tried again once a minute until it is","level":"WARN"', ), + expect.stringContaining('"message":"A run that could not be settled was settled at attempt 22","level":"WARN"'), ]); expect(lines[0]).toContain( - '"annotations":{"org":"acme","brain":"alpha","execution_id":"0199a3c4-7d2e-7c1a-9b3f-2f1e0d9c8b7a","error":"The ledger cannot be reached"}', + '"annotations":{"org":"acme","brain":"alpha","run_id":"0199a3c4-7d2e-7c1a-9b3f-2f1e0d9c8b7a","error":"The ledger cannot be reached"}', ); }); }); @@ -78,7 +76,7 @@ describe('the note of a record of a run passed before its outputs were dispatche '"message":"A record of a run\'s log was passed before the run\'s outputs were dispatched, after 20 sweeps held it; a listener it armed takes events once it is kept, and none recorded before","level":"WARN"', ); expect(line).toContain( - '"annotations":{"org":"acme","brain":"alpha","execution_id":"0199a3c4-7d2e-7c1a-9b3f-2f1e0d9c8b7a","version":4}', + '"annotations":{"org":"acme","brain":"alpha","run_id":"0199a3c4-7d2e-7c1a-9b3f-2f1e0d9c8b7a","version":4}', ); }); }); diff --git a/packages/server/src/logging/host-notes.ts b/packages/server/src/logging/host-notes.ts index 25858cd35..f587fda9b 100644 --- a/packages/server/src/logging/host-notes.ts +++ b/packages/server/src/logging/host-notes.ts @@ -17,13 +17,13 @@ function tookOver({ holder }: NoteOf<'took_over'>): Effect.Effect { function backingOff({ run, attempts, detail }: NoteOf<'settle_backing_off'>): Effect.Effect { return Effect.logWarning( - `An execution could not be settled in ${attempts} attempts; it is tried again once a minute until it is`, - ).pipe(Effect.annotateLogs({ org: run.org, brain: run.brain, execution_id: run.executionId, error: detail })); + `A run could not be settled in ${attempts} attempts; it is tried again once a minute until it is`, + ).pipe(Effect.annotateLogs({ org: run.org, brain: run.brain, run_id: run.runId, error: detail })); } function settledAfterBackingOff({ run, attempts }: NoteOf<'settled_after_back_off'>): Effect.Effect { - return Effect.logWarning(`An execution that could not be settled was settled at attempt ${attempts}`).pipe( - Effect.annotateLogs({ org: run.org, brain: run.brain, execution_id: run.executionId }), + return Effect.logWarning(`A run that could not be settled was settled at attempt ${attempts}`).pipe( + Effect.annotateLogs({ org: run.org, brain: run.brain, run_id: run.runId }), ); } @@ -42,7 +42,7 @@ function viewStalled({ brain, name, version }: NoteOf<'view_stalled'>): Effect.E function offerDeclined({ run, detail }: NoteOf<'offer_declined'>): Effect.Effect { return Effect.logWarning( 'A run waiting for an event of its brain did not take one, since its filter failed on the event', - ).pipe(Effect.annotateLogs({ org: run.org, brain: run.brain, execution_id: run.executionId, error: detail })); + ).pipe(Effect.annotateLogs({ org: run.org, brain: run.brain, run_id: run.runId, error: detail })); } function recordUnreadable({ org, brain, recordId, type }: NoteOf<'record_unreadable'>): Effect.Effect { @@ -54,7 +54,7 @@ function recordUnreadable({ org, brain, recordId, type }: NoteOf<'record_unreada function runRecordPassed({ run, version, sweeps }: NoteOf<'run_record_passed'>): Effect.Effect { return Effect.logWarning( `A record of a run's log was passed before the run's outputs were dispatched, after ${sweeps} sweeps held it; a listener it armed takes events once it is kept, and none recorded before`, - ).pipe(Effect.annotateLogs({ org: run.org, brain: run.brain, execution_id: run.executionId, version })); + ).pipe(Effect.annotateLogs({ org: run.org, brain: run.brain, run_id: run.runId, version })); } const loggers: { readonly [Kind in HostNote['kind']]: (note: NoteOf) => Effect.Effect } = { diff --git a/packages/server/src/logging/logging.test.ts b/packages/server/src/logging/logging.test.ts index 82c3a1142..9710df100 100644 --- a/packages/server/src/logging/logging.test.ts +++ b/packages/server/src/logging/logging.test.ts @@ -121,14 +121,14 @@ describe('the workflow logs', () => { expect(line).toContain(words); }); - it('report an execution settling left started as an error with its org, brain, id and reason only', async () => { + it('report a run settling left started as an error with its org, brain, id and reason only', async () => { const [line] = await linesLoggedBy( - logUnsettled({ org: 'acme', brain: 'alpha', executionId: 'e-1', reason: 'The ledger has no such execution' }), + logUnsettled({ org: 'acme', brain: 'alpha', runId: 'e-1', reason: 'The ledger has no such run' }), ); - expect(line).toContain('"message":"An execution stays started because settling it failed","level":"ERROR"'); + expect(line).toContain('"message":"A run stays started because settling it failed","level":"ERROR"'); expect(line).toContain( - '"annotations":{"org":"acme","brain":"alpha","execution_id":"e-1","reason":"The ledger has no such execution"}', + '"annotations":{"org":"acme","brain":"alpha","run_id":"e-1","reason":"The ledger has no such run"}', ); }); }); @@ -230,11 +230,11 @@ describe('the pretty log format', () => { it('writes an annotation that is not one plain word as JSON', async () => { const lines = await prettyLinesLoggedBy( - logUnsettled({ org: 'acme', brain: 'alpha', executionId: 'e-1', reason: 'The ledger has no such execution' }), + logUnsettled({ org: 'acme', brain: 'alpha', runId: 'e-1', reason: 'The ledger has no such run' }), ); expect(lines).toEqual([ - '(attempt: () => Promise, done: (value: A) => boolean): Promise { diff --git a/packages/server/src/testing/servers/workflow-server.ts b/packages/server/src/testing/servers/workflow-server.ts index da37cd1de..a6880ec1b 100644 --- a/packages/server/src/testing/servers/workflow-server.ts +++ b/packages/server/src/testing/servers/workflow-server.ts @@ -1,7 +1,7 @@ import { setTimeout } from 'node:timers/promises'; import type { McpSession, ToolResult } from '@beonauto/api/testing'; -import type { ScriptedReply } from '@beonauto/inference/testing'; +import type { ScriptedReply } from '@beonauto/reasoning/testing'; import type { HostClock } from '@beonauto/workflow-host'; import { Option, Schema } from 'effect'; @@ -15,7 +15,7 @@ const localMode: Readonly> = { LOCAL_MODE: 'true' }; const statusOf = Schema.decodeUnknownOption(Schema.Struct({ status: Schema.String })); -const executionOf = Schema.decodeUnknownSync(Schema.Struct({ execution_id: Schema.String })); +const runOf = Schema.decodeUnknownSync(Schema.Struct({ run_id: Schema.String })); export function servingWorkflows( replies: readonly ScriptedReply[], @@ -33,15 +33,15 @@ export function workflowSource(name: string, steps: string): string { return `document:\n dsl: '1.0.3'\n namespace: acme\n name: ${name}\n version: '1.0.0'\n${steps}`; } -export function executionIdIn(body: unknown): string { - return executionOf(body).execution_id; +export function runIdIn(body: unknown): string { + return runOf(body).run_id; } export function isStarted(body: unknown): boolean { return Option.getOrUndefined(statusOf(body))?.status === 'started'; } -export async function settledExecution( +export async function settledRun( server: ReasoningServer, path: string, options: RequestOptions = {}, @@ -51,14 +51,14 @@ export async function settledExecution( return response; } await setTimeout(100); - return settledExecution(server, path, options); + return settledRun(server, path, options); } -export async function settledOverMcp(session: McpSession, executionId: string): Promise { - const reading = await session.callTool('get_execution', { execution_id: executionId }); +export async function settledOverMcp(session: McpSession, runId: string): Promise { + const reading = await session.callTool('get_run', { run_id: runId }); if (!isStarted(reading.structuredContent)) { return reading; } await setTimeout(100); - return settledOverMcp(session, executionId); + return settledOverMcp(session, runId); } diff --git a/packages/server/src/workflow-calls/runless-streams.test.ts b/packages/server/src/workflow-calls/runless-streams.test.ts index 1721365a4..35461f70e 100644 --- a/packages/server/src/workflow-calls/runless-streams.test.ts +++ b/packages/server/src/workflow-calls/runless-streams.test.ts @@ -6,7 +6,7 @@ import { afterEach, describe, expect, it, onTestFinished } from 'vitest'; import { alpha, type ReasoningServer } from '../testing/servers/reasoning-server.ts'; import { servingCalls, startedRunOf, until } from '../testing/servers/workflow-calls.ts'; -import { executionIdIn, settledExecution, workflowTestTimeoutMs } from '../testing/servers/workflow-server.ts'; +import { runIdIn, settledRun, workflowTestTimeoutMs } from '../testing/servers/workflow-server.ts'; const postgresql = process.env['LEDGER_TEST_POSTGRESQL_URL'] ?? ''; @@ -51,14 +51,14 @@ const History = Schema.Struct({ type: Schema.String, data: Schema.Struct({ reference: Schema.optionalKey(Schema.String), - execution_id: Schema.optionalKey(Schema.String), + run_id: Schema.optionalKey(Schema.String), }), }), ), }); const Listed = Schema.Struct({ - executions: Schema.Array(Schema.Struct({ execution_id: Schema.String, primitive: Schema.String })), + runs: Schema.Array(Schema.Struct({ run_id: Schema.String, type: Schema.String })), }); const queued = '/do/0/wait'; @@ -71,12 +71,12 @@ afterEach(async () => { function workflowsAndFunctionsIn(body: unknown): readonly string[] { return Schema.decodeUnknownSync(Listed)(body) - .executions.map(({ execution_id: id, primitive }) => (primitive === 'orchestration' ? id : primitive)) + .runs.map(({ run_id: id, type }) => (type === 'workflow' ? id : type)) .toSorted(); } async function holdingTheOnlyPermit(): Promise { - const holder = executionIdIn((await startedRunOf(server, 'asking')).body); + const holder = runIdIn((await startedRunOf(server, 'asking')).body); await until( () => Promise.resolve(server.modelCalls()), (calls) => calls > 0, @@ -86,17 +86,17 @@ async function holdingTheOnlyPermit(): Promise { async function childOfTheQueuedCall(parent: string): Promise { const history = await until( - () => server.call('GET', `${alpha}/executions/${parent}/history?limit=100`), + () => server.call('GET', `${alpha}/runs/${parent}/history?limit=100`), ({ body }) => Schema.decodeUnknownSync(History)(body).events.some(({ data }) => data.reference === queued), ); const waiting = Schema.decodeUnknownSync(History)(history.body).events.find(({ data }) => data.reference === queued); - return String(waiting?.data.execution_id); + return String(waiting?.data.run_id); } async function cancelledFirst(child: string): Promise { await until( - () => server.call('GET', `${alpha}/events?type=execution_cancel_requested&limit=100`), - ({ body }) => Schema.decodeUnknownSync(History)(body).events.some(({ data }) => data.execution_id === child), + () => server.call('GET', `${alpha}/events?type=run_cancel_requested&limit=100`), + ({ body }) => Schema.decodeUnknownSync(History)(body).events.some(({ data }) => data.run_id === child), ); } @@ -109,23 +109,23 @@ describe.each(stores)( async () => { server = await servingCalls([() => Effect.never], { ...(await environment()), - ORCHESTRATION_NESTED_EXECUTIONS: '1', + WORKFLOW_NESTED_RUNS: '1', }); const holder = await holdingTheOnlyPermit(); - const parent = executionIdIn((await startedRunOf(server, 'waiting')).body); + const parent = runIdIn((await startedRunOf(server, 'waiting')).body); const child = await childOfTheQueuedCall(parent); - await server.call('POST', `${alpha}/executions/${parent}/cancel`, { body: { reason: 'No longer needed' } }); - await settledExecution(server, `${alpha}/executions/${parent}`); + await server.call('POST', `${alpha}/runs/${parent}/cancel`, { body: { reason: 'No longer needed' } }); + await settledRun(server, `${alpha}/runs/${parent}`); await cancelledFirst(child); - const listed = await server.call('GET', `${alpha}/executions?limit=100`); - const startedOnes = await server.call('GET', `${alpha}/executions?status=started&limit=100`); - const run = await server.call('GET', `${alpha}/executions/${child}`); - const history = await server.call('GET', `${alpha}/executions/${child}/history`); + const listed = await server.call('GET', `${alpha}/runs?limit=100`); + const startedOnes = await server.call('GET', `${alpha}/runs?status=started&limit=100`); + const run = await server.call('GET', `${alpha}/runs/${child}`); + const history = await server.call('GET', `${alpha}/runs/${child}/history`); expect([listed.status, startedOnes.status, run.status, history.status]).toEqual([200, 200, 404, 404]); - expect(workflowsAndFunctionsIn(listed.body)).toEqual([holder, parent, 'inference'].toSorted()); - expect(workflowsAndFunctionsIn(startedOnes.body)).toEqual([holder, 'inference'].toSorted()); + expect(workflowsAndFunctionsIn(listed.body)).toEqual([holder, parent, 'reasoning'].toSorted()); + expect(workflowsAndFunctionsIn(startedOnes.body)).toEqual([holder, 'reasoning'].toSorted()); }, ); }, diff --git a/packages/server/src/workflow-calls/waiting-servers.test.ts b/packages/server/src/workflow-calls/waiting-servers.test.ts index 1dc64d5b5..ab61660d7 100644 --- a/packages/server/src/workflow-calls/waiting-servers.test.ts +++ b/packages/server/src/workflow-calls/waiting-servers.test.ts @@ -7,9 +7,9 @@ import type { SpawnedServer } from '../testing/processes/spawned-server.ts'; import { requestTo, settledOver, workflowProcess, type Answer } from '../testing/processes/workflow-process.ts'; import { temporaryLedger } from '../testing/records/temporary-ledger.ts'; import { until, workflows } from '../testing/servers/workflow-calls.ts'; -import { executionIdIn, workflowSource, workflowTestTimeoutMs } from '../testing/servers/workflow-server.ts'; +import { runIdIn, workflowSource, workflowTestTimeoutMs } from '../testing/servers/workflow-server.ts'; -const Listed = Schema.Struct({ executions: Schema.Array(Schema.Struct({ execution_id: Schema.String })) }); +const Listed = Schema.Struct({ runs: Schema.Array(Schema.Struct({ run_id: Schema.String })) }); const decodeListed = Schema.decodeUnknownSync(Listed); @@ -27,8 +27,8 @@ function sourceOf(name: string): string { async function definedOn(port: number): Promise { await requestTo(port, 'POST', '', { brain: 'beta', name: 'Beta' }); - await requestTo(port, 'POST', '/beta/specs/orchestration', { name: 'pending', source: sourceOf('pending') }); - await requestTo(port, 'POST', '/beta/specs/orchestration', { name: 'waiting', source: sourceOf('waiting') }); + await requestTo(port, 'POST', '/beta/definitions/workflow', { name: 'pending', source: sourceOf('pending') }); + await requestTo(port, 'POST', '/beta/definitions/workflow', { name: 'waiting', source: sourceOf('waiting') }); } function callStatesIn(file: string): () => Promise { @@ -46,10 +46,10 @@ function waitingOnce(states: readonly unknown[]): boolean { async function waitingOn(port: number, file: string): Promise<{ readonly parent: string; readonly child: string }> { await definedOn(port); - const started = await requestTo(port, 'POST', '/beta/specs/orchestration/waiting/execute', { input: {} }); + const started = await requestTo(port, 'POST', '/beta/definitions/workflow/waiting/run', { input: {} }); await until(callStatesIn(file), waitingOnce); - const listed = await requestTo(port, 'GET', '/beta/executions?name=pending'); - return { parent: executionIdIn(started.body), child: String(decodeListed(listed.body).executions[0]?.execution_id) }; + const listed = await requestTo(port, 'GET', '/beta/runs?name=pending'); + return { parent: runIdIn(started.body), child: String(decodeListed(listed.body).runs[0]?.run_id) }; } function accepted(answer: Answer): boolean { @@ -75,10 +75,10 @@ describe( const port = await second.port; const afterRestart = await callStatesIn(file)(); await until( - () => requestTo(port, 'POST', `/beta/executions/${child}/events`, { event: { type: 'com.acme.go', data: 1 } }), + () => requestTo(port, 'POST', `/beta/runs/${child}/events`, { event: { type: 'com.acme.go', data: 1 } }), accepted, ); - const settled = await settledOver(port, `/beta/executions/${parent}`); + const settled = await settledOver(port, `/beta/runs/${parent}`); expect(afterRestart).toEqual([{ state: 'waiting' }]); expect(settled).toMatchObject({ status: 'succeeded', output: [1] }); @@ -94,11 +94,11 @@ describe('a cancel that lands on a server that does not hold the lease', { timeo const { parent, child } = await waitingOn(await holder.port, file); const standby = workflowProcess(file); - const cancelled = await requestTo(await standby.port, 'POST', `/beta/executions/${child}/cancel`, { + const cancelled = await requestTo(await standby.port, 'POST', `/beta/runs/${child}/cancel`, { reason: 'Cancelled elsewhere', }); - const settled = await settledOver(await holder.port, `/beta/executions/${parent}`); - const ofTheChild = await settledOver(await holder.port, `/beta/executions/${child}`); + const settled = await settledOver(await holder.port, `/beta/runs/${parent}`); + const ofTheChild = await settledOver(await holder.port, `/beta/runs/${child}`); expect(cancelled.status).toBe(200); expect(ofTheChild).toMatchObject({ @@ -117,8 +117,8 @@ describe('a cancel that lands on a server that does not hold the lease', { timeo const second = workflowProcess(file); const port = await second.port; - const cancelled = await requestTo(port, 'POST', `/beta/executions/${parent}/cancel`, {}); - const settled = await settledOver(port, `/beta/executions/${parent}`); + const cancelled = await requestTo(port, 'POST', `/beta/runs/${parent}/cancel`, {}); + const settled = await settledOver(port, `/beta/runs/${parent}`); expect(cancelled.status).toBe(200); expect(settled).toMatchObject({ status: 'rejected', rejection: { reason: 'cancelled', kind: 'requested' } }); diff --git a/packages/server/src/workflow-calls/workflow-calls-over-http.test.ts b/packages/server/src/workflow-calls/workflow-calls-over-http.test.ts index b04c77bd8..a5efce084 100644 --- a/packages/server/src/workflow-calls/workflow-calls-over-http.test.ts +++ b/packages/server/src/workflow-calls/workflow-calls-over-http.test.ts @@ -1,9 +1,9 @@ -import { answers, textResult } from '@beonauto/inference/testing'; +import { answers, textResult } from '@beonauto/reasoning/testing'; import { afterEach, describe, expect, it } from 'vitest'; import { alpha, type ReasoningServer } from '../testing/servers/reasoning-server.ts'; import { ended, runsOf, servingCalls, startedRunOf, until } from '../testing/servers/workflow-calls.ts'; -import { executionIdIn, settledExecution, workflowTestTimeoutMs } from '../testing/servers/workflow-server.ts'; +import { runIdIn, settledRun, workflowTestTimeoutMs } from '../testing/servers/workflow-server.ts'; let server: ReasoningServer; @@ -13,7 +13,7 @@ afterEach(async () => { async function settledRunOf(name: string) { const started = await startedRunOf(server, name); - return settledExecution(server, `${alpha}/executions/${executionIdIn(started.body)}`); + return settledRun(server, `${alpha}/runs/${runIdIn(started.body)}`); } describe('a workflow that calls a workflow, over HTTP', { timeout: workflowTestTimeoutMs }, () => { @@ -35,10 +35,10 @@ describe('a workflow that calls a workflow, over HTTP', { timeout: workflowTestT () => runsOf(server, 'pending'), (runs) => runs.length === 1, ); - const cancelled = await server.call('POST', `${alpha}/executions/${String(pending?.execution_id)}/cancel`, { + const cancelled = await server.call('POST', `${alpha}/runs/${String(pending?.run_id)}/cancel`, { body: { reason: 'No longer needed' }, }); - const settled = await settledExecution(server, `${alpha}/executions/${executionIdIn(started.body)}`); + const settled = await settledRun(server, `${alpha}/runs/${runIdIn(started.body)}`); expect(cancelled).toMatchObject({ status: 200, body: { status: 'started' } }); expect(settled).toMatchObject({ body: { status: 'succeeded', output: { caught: 'requested' } } }); @@ -66,7 +66,7 @@ describe('workflows that call workflows, over HTTP', { timeout: workflowTestTime }); it('keep no more calls open under one run than the server allows, refusing the next', async () => { - server = await servingCalls([], { ORCHESTRATION_MAX_OPEN_CALLS: '2' }); + server = await servingCalls([], { WORKFLOW_MAX_OPEN_CALLS: '2' }); const settled = await settledRunOf('wide'); const pending = await until(() => runsOf(server, 'pending'), ended); @@ -79,7 +79,7 @@ describe('workflows that call workflows, over HTTP', { timeout: workflowTestTime }); it('release the call while the run it waits for runs, so one call at once still lets many runs wait', async () => { - server = await servingCalls([], { ORCHESTRATION_NESTED_EXECUTIONS: '1' }); + server = await servingCalls([], { WORKFLOW_NESTED_RUNS: '1' }); await startedRunOf(server, 'waiting'); await startedRunOf(server, 'waiting'); diff --git a/packages/server/src/workflow-calls/workflow-cancels.test.ts b/packages/server/src/workflow-calls/workflow-cancels.test.ts index 9a37e84be..e2ef1e25e 100644 --- a/packages/server/src/workflow-calls/workflow-cancels.test.ts +++ b/packages/server/src/workflow-calls/workflow-cancels.test.ts @@ -1,15 +1,10 @@ import { withMcpSession } from '@beonauto/api/testing'; -import { answers, textResult } from '@beonauto/inference/testing'; +import { answers, textResult } from '@beonauto/reasoning/testing'; import { afterEach, describe, expect, it } from 'vitest'; import { alpha, type ReasoningServer } from '../testing/servers/reasoning-server.ts'; import { ended, runsOf, servingCalls, startedRunOf, until } from '../testing/servers/workflow-calls.ts'; -import { - executionIdIn, - settledExecution, - settledOverMcp, - workflowTestTimeoutMs, -} from '../testing/servers/workflow-server.ts'; +import { runIdIn, settledRun, settledOverMcp, workflowTestTimeoutMs } from '../testing/servers/workflow-server.ts'; let server: ReasoningServer; @@ -17,8 +12,8 @@ afterEach(async () => { await server.stop(); }); -function cancelOf(executionId: string, body: object) { - return server.call('POST', `${alpha}/executions/${executionId}/cancel`, { body }); +function cancelOf(runId: string, body: object) { + return server.call('POST', `${alpha}/runs/${runId}/cancel`, { body }); } async function waitingRunOf(name: string): Promise { @@ -27,20 +22,20 @@ async function waitingRunOf(name: string): Promise { () => runsOf(server, 'pending'), (runs) => runs.length === 1, ); - return executionIdIn(started.body); + return runIdIn(started.body); } describe('a cancel of a workflow run, over HTTP', { timeout: workflowTestTimeoutMs }, () => { it('ends it as cancelled, with who asked and why, and cancels the run it waits for as its parent ended', async () => { server = await servingCalls([]); - const executionId = await waitingRunOf('waiting'); + const runId = await waitingRunOf('waiting'); - const cancelled = await cancelOf(executionId, { reason: 'No longer needed' }); - const settled = await settledExecution(server, `${alpha}/executions/${executionId}`); + const cancelled = await cancelOf(runId, { reason: 'No longer needed' }); + const settled = await settledRun(server, `${alpha}/runs/${runId}`); const pending = await until(() => runsOf(server, 'pending'), ended); - const again = await cancelOf(executionId, { reason: 'No longer needed' }); + const again = await cancelOf(runId, { reason: 'No longer needed' }); - expect(cancelled).toMatchObject({ status: 200, body: { execution_id: executionId, status: 'started' } }); + expect(cancelled).toMatchObject({ status: 200, body: { run_id: runId, status: 'started' } }); expect(settled).toMatchObject({ body: { status: 'rejected', rejection: { reason: 'cancelled', kind: 'requested', detail: 'No longer needed' } }, }); @@ -50,10 +45,10 @@ describe('a cancel of a workflow run, over HTTP', { timeout: workflowTestTimeout it('reaches every run of a tree three levels deep', async () => { server = await servingCalls([]); - const executionId = await waitingRunOf('top'); + const runId = await waitingRunOf('top'); - await cancelOf(executionId, {}); - const settled = await settledExecution(server, `${alpha}/executions/${executionId}`); + await cancelOf(runId, {}); + const settled = await settledRun(server, `${alpha}/runs/${runId}`); const middle = await until(() => runsOf(server, 'middle'), ended); const pending = await until(() => runsOf(server, 'pending'), ended); @@ -67,7 +62,7 @@ describe('a cancel of a workflow run, over HTTP', { timeout: workflowTestTimeout server = await servingCalls([]); const started = await startedRunOf(server, 'impatient'); - const settled = await settledExecution(server, `${alpha}/executions/${executionIdIn(started.body)}`); + const settled = await settledRun(server, `${alpha}/runs/${runIdIn(started.body)}`); const pending = await until(() => runsOf(server, 'pending'), ended); expect(settled).toMatchObject({ body: { status: 'rejected', rejection: { reason: 'unavailable' } } }); @@ -78,11 +73,11 @@ describe('a cancel of a workflow run, over HTTP', { timeout: workflowTestTimeout describe('a cancel of a run that cannot be cancelled, over HTTP', { timeout: workflowTestTimeoutMs }, () => { it('is a conflict for a run that ran within its call, and not found for a run the brain does not have', async () => { server = await servingCalls([answers(textResult('Short.'))]); - const ran = await server.call('POST', `${alpha}/specs/inference/summary/execute`, { + const ran = await server.call('POST', `${alpha}/definitions/reasoning/summary/run`, { body: { input: { text: 'long' } }, }); - const ranAlready = await cancelOf(executionIdIn(ran.body), {}); + const ranAlready = await cancelOf(runIdIn(ran.body), {}); const unknown = await cancelOf('0199a3c4-7d2e-7c1a-9b3f-2f1e0d9c8b7f', {}); expect([ranAlready.status, unknown.status]).toEqual([409, 404]); @@ -90,16 +85,16 @@ describe('a cancel of a run that cannot be cancelled, over HTTP', { timeout: wor }); describe('a cancel of a workflow run, over MCP', { timeout: workflowTestTimeoutMs }, () => { - it('is the tool cancel_execution, whose run then ends as cancelled', async () => { + it('is the tool cancel_run, whose run then ends as cancelled', async () => { server = await servingCalls([]); - const executionId = await waitingRunOf('waiting'); + const runId = await waitingRunOf('waiting'); const { cancelled, settled } = await withMcpSession( 'current revision', { url: `${server.origin}/orgs/acme/brains/alpha/mcp`, headers: {} }, async (session) => ({ - cancelled: await session.callTool('cancel_execution', { execution_id: executionId, reason: 'Done' }), - settled: await settledOverMcp(session, executionId), + cancelled: await session.callTool('cancel_run', { run_id: runId, reason: 'Done' }), + settled: await settledOverMcp(session, runId), }), ); diff --git a/packages/server/src/workflow-executions/nested-executions.test.ts b/packages/server/src/workflow-executions/nested-executions.test.ts deleted file mode 100644 index 07da3aeb5..000000000 --- a/packages/server/src/workflow-executions/nested-executions.test.ts +++ /dev/null @@ -1,71 +0,0 @@ -import { answers, textResult } from '@beonauto/inference/testing'; -import { afterEach, describe, expect, it } from 'vitest'; - -import type { TestResponse } from '../testing/servers/http-client.ts'; -import { alpha, type ReasoningServer } from '../testing/servers/reasoning-server.ts'; -import { - executionIdIn, - servingWorkflows, - settledExecution, - workflowSource, - workflowTestTimeoutMs, -} from '../testing/servers/workflow-server.ts'; - -const summary = ['---', 'model: anthropic/claude-sonnet-4-5', '---', 'Summarize: {{ input.text }}'].join('\n'); - -const asking = workflowSource( - 'asking', - "do:\n - ask: { call: execute_spec, with: { primitive: inference, name: summary, input: { text: 'long' } } }\n", -); - -const nesting = workflowSource( - 'nesting', - 'do:\n - nest: { call: execute_spec, with: { primitive: orchestration, name: asking } }\n', -); - -let server: ReasoningServer; - -afterEach(async () => { - await server.stop(); -}); - -async function servingAsking(): Promise { - server = await servingWorkflows([answers(textResult('Short.')), answers(textResult('Again.'))]); - await server.call('POST', '/v1/orgs/acme/brains', { body: { brain: 'alpha', name: 'Alpha' } }); - await server.call('POST', `${alpha}/specs/inference`, { body: { name: 'summary', source: summary } }); - await server.call('POST', `${alpha}/specs/orchestration`, { body: { name: 'asking', source: asking } }); -} - -async function settledRunOf(name: string): Promise { - const started = await server.call('POST', `${alpha}/specs/orchestration/${name}/execute`, { body: { input: {} } }); - return settledExecution(server, `${alpha}/executions/${executionIdIn(started.body)}`); -} - -describe('a nested execution of a workflow', { timeout: workflowTestTimeoutMs }, () => { - it('runs under the id its workflow gives it, so a call again with that id runs the spec once', async () => { - await servingAsking(); - - const settled = await settledRunOf('asking'); - const nested = String(server.modelExecutions()[0]); - const again = await server.call('POST', `${alpha}/specs/inference/summary/execute`, { - body: { input: { text: 'long' }, execution_id: nested }, - }); - - expect(settled).toMatchObject({ body: { status: 'succeeded', output: 'Short.' } }); - expect(again).toMatchObject({ status: 200, body: { execution_id: nested, status: 'succeeded', output: 'Short.' } }); - expect(server.modelCalls()).toBe(1); - }); - - it('may be of another workflow, whose run it waits for under the id it gives that run', async () => { - await servingAsking(); - await server.call('POST', `${alpha}/specs/orchestration`, { body: { name: 'nesting', source: nesting } }); - - const settled = await settledRunOf('nesting'); - const nested = String(server.modelExecutions()[0]); - const askingRuns = await server.call('GET', `${alpha}/executions?name=asking`); - - expect(settled).toMatchObject({ body: { status: 'succeeded', output: 'Short.' } }); - expect(askingRuns).toMatchObject({ body: { executions: [{ status: 'succeeded' }] } }); - expect(nested).not.toBe(executionIdIn(settled.body)); - }); -}); diff --git a/packages/server/src/workflow-runs/nested-runs.test.ts b/packages/server/src/workflow-runs/nested-runs.test.ts new file mode 100644 index 000000000..75eb59d77 --- /dev/null +++ b/packages/server/src/workflow-runs/nested-runs.test.ts @@ -0,0 +1,71 @@ +import { answers, textResult } from '@beonauto/reasoning/testing'; +import { afterEach, describe, expect, it } from 'vitest'; + +import type { TestResponse } from '../testing/servers/http-client.ts'; +import { alpha, type ReasoningServer } from '../testing/servers/reasoning-server.ts'; +import { + runIdIn, + servingWorkflows, + settledRun, + workflowSource, + workflowTestTimeoutMs, +} from '../testing/servers/workflow-server.ts'; + +const summary = ['---', 'model: anthropic/claude-sonnet-4-5', '---', 'Summarize: {{ input.text }}'].join('\n'); + +const asking = workflowSource( + 'asking', + "do:\n - ask: { call: run_definition, with: { type: reasoning, name: summary, input: { text: 'long' } } }\n", +); + +const nesting = workflowSource( + 'nesting', + 'do:\n - nest: { call: run_definition, with: { type: workflow, name: asking } }\n', +); + +let server: ReasoningServer; + +afterEach(async () => { + await server.stop(); +}); + +async function servingAsking(): Promise { + server = await servingWorkflows([answers(textResult('Short.')), answers(textResult('Again.'))]); + await server.call('POST', '/v1/orgs/acme/brains', { body: { brain: 'alpha', name: 'Alpha' } }); + await server.call('POST', `${alpha}/definitions/reasoning`, { body: { name: 'summary', source: summary } }); + await server.call('POST', `${alpha}/definitions/workflow`, { body: { name: 'asking', source: asking } }); +} + +async function settledRunOf(name: string): Promise { + const started = await server.call('POST', `${alpha}/definitions/workflow/${name}/run`, { body: { input: {} } }); + return settledRun(server, `${alpha}/runs/${runIdIn(started.body)}`); +} + +describe('a nested run of a workflow', { timeout: workflowTestTimeoutMs }, () => { + it('runs under the id its workflow gives it, so a call again with that id runs the definition once', async () => { + await servingAsking(); + + const settled = await settledRunOf('asking'); + const nested = String(server.modelRunIds()[0]); + const again = await server.call('POST', `${alpha}/definitions/reasoning/summary/run`, { + body: { input: { text: 'long' }, run_id: nested }, + }); + + expect(settled).toMatchObject({ body: { status: 'succeeded', output: 'Short.' } }); + expect(again).toMatchObject({ status: 200, body: { run_id: nested, status: 'succeeded', output: 'Short.' } }); + expect(server.modelCalls()).toBe(1); + }); + + it('may be of another workflow, whose run it waits for under the id it gives that run', async () => { + await servingAsking(); + await server.call('POST', `${alpha}/definitions/workflow`, { body: { name: 'nesting', source: nesting } }); + + const settled = await settledRunOf('nesting'); + const nested = String(server.modelRunIds()[0]); + const askingRuns = await server.call('GET', `${alpha}/runs?name=asking`); + + expect(settled).toMatchObject({ body: { status: 'succeeded', output: 'Short.' } }); + expect(askingRuns).toMatchObject({ body: { runs: [{ status: 'succeeded' }] } }); + expect(nested).not.toBe(runIdIn(settled.body)); + }); +}); diff --git a/packages/server/src/workflow-executions/nested-values-over-http.test.ts b/packages/server/src/workflow-runs/nested-values-over-http.test.ts similarity index 78% rename from packages/server/src/workflow-executions/nested-values-over-http.test.ts rename to packages/server/src/workflow-runs/nested-values-over-http.test.ts index 5f9fe1cfb..43ea2aa10 100644 --- a/packages/server/src/workflow-executions/nested-values-over-http.test.ts +++ b/packages/server/src/workflow-runs/nested-values-over-http.test.ts @@ -4,7 +4,7 @@ import { Client } from 'pg'; import { afterEach, describe, expect, it, onTestFinished } from 'vitest'; import { alpha, servingReasoning, type ReasoningServer } from '../testing/servers/reasoning-server.ts'; -import { settledExecution, workflowSource, workflowTestTimeoutMs } from '../testing/servers/workflow-server.ts'; +import { settledRun, workflowSource, workflowTestTimeoutMs } from '../testing/servers/workflow-server.ts'; const postgresql = process.env['LEDGER_TEST_POSTGRESQL_URL'] ?? ''; @@ -55,7 +55,7 @@ const waiting = workflowSource( `, ); -const executionId = '0199a3c4-7d2e-7c1a-9b3f-2f1e0d9c8b7a'; +const runId = '0199a3c4-7d2e-7c1a-9b3f-2f1e0d9c8b7a'; function nested(levels: number): unknown { return levels === 0 ? 'yes' : [nested(levels - 1)]; @@ -70,7 +70,7 @@ afterEach(async () => { async function brainWithAWaitingWorkflow(environment: Readonly>): Promise { server = await servingReasoning([], environment); await server.call('POST', '/v1/orgs/acme/brains', { body: { brain: 'alpha', name: 'Alpha' } }); - await server.call('POST', `${alpha}/specs/orchestration`, { body: { name: 'waiting', source: waiting } }); + await server.call('POST', `${alpha}/definitions/workflow`, { body: { name: 'waiting', source: waiting } }); } describe.each(stores)( @@ -80,10 +80,10 @@ describe.each(stores)( it.skipIf(skipped)('are taken as a run input, as the data of a sent event and of a published one', async () => { await brainWithAWaitingWorkflow(await environment()); - const started = await server.call('POST', `${alpha}/specs/orchestration/waiting/execute`, { - body: { input: nested(512), execution_id: executionId }, + const started = await server.call('POST', `${alpha}/definitions/workflow/waiting/run`, { + body: { input: nested(512), run_id: runId }, }); - const sent = await server.call('POST', `${alpha}/executions/${executionId}/events`, { + const sent = await server.call('POST', `${alpha}/runs/${runId}/events`, { body: { event: { type: 'com.acme.approval.decided', data: nested(510) } }, }); const published = await server.call('POST', `${alpha}/events`, { @@ -91,7 +91,7 @@ describe.each(stores)( }); expect([started.status, sent.status, published.status]).toEqual([200, 200, 200]); - expect(await settledExecution(server, `${alpha}/executions/${executionId}`)).toMatchObject({ + expect(await settledRun(server, `${alpha}/runs/${runId}`)).toMatchObject({ body: { status: 'succeeded' }, }); }); @@ -99,22 +99,22 @@ describe.each(stores)( it.skipIf(skipped)('are refused deeper than that, as invalid input, before anything is recorded', async () => { await brainWithAWaitingWorkflow(await environment()); - const executed = await server.call('POST', `${alpha}/specs/orchestration/waiting/execute`, { + const ran = await server.call('POST', `${alpha}/definitions/workflow/waiting/run`, { body: { input: nested(3000) }, }); - const sent = await server.call('POST', `${alpha}/executions/${executionId}/events`, { + const sent = await server.call('POST', `${alpha}/runs/${runId}/events`, { body: { event: { type: 'com.acme.approval.decided', data: nested(3000) } }, }); const published = await server.call('POST', `${alpha}/events`, { body: { event: { source: '/ledger/eu', type: 'com.acme.ledger.month-closed', data: nested(3000) } }, }); - expect([executed, sent, published].map(({ status, body }) => [status, body])).toMatchObject([ + expect([ran, sent, published].map(({ status, body }) => [status, body])).toMatchObject([ [422, { reason: 'invalid_input', errors: [{ pointer: '/input' }] }], [422, { reason: 'invalid_input', errors: [{ pointer: '/event/data' }] }], [422, { reason: 'invalid_input', errors: [{ pointer: '/event/data' }] }], ]); - expect(await server.call('GET', `${alpha}/executions`)).toMatchObject({ body: { executions: [] } }); + expect(await server.call('GET', `${alpha}/runs`)).toMatchObject({ body: { runs: [] } }); }); }, ); diff --git a/packages/server/src/workflow-executions/reactions-over-http.test.ts b/packages/server/src/workflow-runs/reactions-over-http.test.ts similarity index 72% rename from packages/server/src/workflow-executions/reactions-over-http.test.ts rename to packages/server/src/workflow-runs/reactions-over-http.test.ts index 6148d2023..bd08843d1 100644 --- a/packages/server/src/workflow-executions/reactions-over-http.test.ts +++ b/packages/server/src/workflow-runs/reactions-over-http.test.ts @@ -5,17 +5,17 @@ import { afterEach, describe, expect, it } from 'vitest'; import { alpha, type ReasoningServer } from '../testing/servers/reasoning-server.ts'; import { - executionIdIn, + runIdIn, servingWorkflows, - settledExecution, + settledRun, workflowSource, workflowTestTimeoutMs, } from '../testing/servers/workflow-server.ts'; const ListedRuns = Schema.Struct({ - executions: Schema.Array( + runs: Schema.Array( Schema.Struct({ - execution_id: Schema.String, + run_id: Schema.String, name: Schema.String, status: Schema.String, started_by: Schema.String, @@ -25,7 +25,7 @@ const ListedRuns = Schema.Struct({ const runsIn = Schema.decodeUnknownSync(ListedRuns); -type ListedRun = (typeof ListedRuns.Type)['executions'][number]; +type ListedRun = (typeof ListedRuns.Type)['runs'][number]; const closing = workflowSource( 'close-the-month', @@ -49,20 +49,23 @@ afterEach(async () => { }); async function created([name, source]: readonly [string, string]): Promise { - await server.call('POST', `${alpha}/specs/orchestration`, { body: { name, source } }); + await server.call('POST', `${alpha}/definitions/workflow`, { body: { name, source } }); } -async function servingWith(...specs: readonly (readonly [string, string])[]): Promise { +async function servingWith(...definitions: readonly (readonly [string, string])[]): Promise { server = await servingWorkflows([]); await server.call('POST', '/v1/orgs/acme/brains', { body: { brain: 'alpha', name: 'Alpha' } }); - await specs.reduce>((before, spec) => before.then(() => created(spec)), Promise.resolve()); + await definitions.reduce>( + (before, definition) => before.then(() => created(definition)), + Promise.resolve(), + ); } async function runsOf(name: string, attempts = 300): Promise { - const response = await server.call('GET', `${alpha}/executions?primitive=orchestration&name=${name}`); - const { executions } = runsIn(response.body); - if (executions.some(({ status }) => status !== 'started') || attempts <= 1) { - return executions; + const response = await server.call('GET', `${alpha}/runs?type=workflow&name=${name}`); + const { runs } = runsIn(response.body); + if (runs.some(({ status }) => status !== 'started') || attempts <= 1) { + return runs; } await setTimeout(100); return runsOf(name, attempts - 1); @@ -70,7 +73,7 @@ async function runsOf(name: string, attempts = 300): Promise { await servingWith(['close-the-month', closing], ['announce', announcing]); - const announced = await server.call('POST', `${alpha}/specs/orchestration/announce/execute`, { + const announced = await server.call('POST', `${alpha}/definitions/workflow/announce/run`, { body: { input: {} }, }); - await settledExecution(server, `${alpha}/executions/${executionIdIn(announced.body)}`); + await settledRun(server, `${alpha}/runs/${runIdIn(announced.body)}`); const { settled } = await triggeredRunOf('close-the-month'); expect(settled.body).toMatchObject({ status: 'succeeded', output: { month: 'october' } }); @@ -103,14 +106,14 @@ describe('a workflow whose trigger is an event', { timeout: workflowTestTimeoutM describe('a run waiting for an event whose type its filter names', { timeout: workflowTestTimeoutMs }, () => { it('takes one published to its brain', async () => { await servingWith(['approval', approving]); - const started = await server.call('POST', `${alpha}/specs/orchestration/approval/execute`, { body: { input: {} } }); - const path = `${alpha}/executions/${executionIdIn(started.body)}`; + const started = await server.call('POST', `${alpha}/definitions/workflow/approval/run`, { body: { input: {} } }); + const path = `${alpha}/runs/${runIdIn(started.body)}`; await setTimeout(200); await server.call('POST', `${alpha}/events`, { body: { event: { source: '/desk', type: 'com.acme.approved', data: { by: 'Ada' } } }, }); - const settled = await settledExecution(server, path); + const settled = await settledRun(server, path); expect(settled.body).toMatchObject({ status: 'succeeded', output: { by: 'Ada' } }); }); diff --git a/packages/server/src/workflow-executions/reactions-over-mcp.test.ts b/packages/server/src/workflow-runs/reactions-over-mcp.test.ts similarity index 79% rename from packages/server/src/workflow-executions/reactions-over-mcp.test.ts rename to packages/server/src/workflow-runs/reactions-over-mcp.test.ts index 49580a7c8..8fc51ef35 100644 --- a/packages/server/src/workflow-executions/reactions-over-mcp.test.ts +++ b/packages/server/src/workflow-runs/reactions-over-mcp.test.ts @@ -13,7 +13,7 @@ const closing = workflowSource( ); const ListedRuns = Schema.Struct({ - executions: Schema.Array(Schema.Struct({ status: Schema.String, started_by: Schema.String })), + runs: Schema.Array(Schema.Struct({ status: Schema.String, started_by: Schema.String })), }); const BrainEvents = Schema.Struct({ events: Schema.Array(Schema.Struct({ type: Schema.String })) }); @@ -38,10 +38,9 @@ async function onAlpha(use: (session: McpSession) => Promise): Promise async function endedRuns(session: McpSession, attempts = 300): Promise { const listed = runsIn( - (await session.callTool('list_executions', { primitive: 'orchestration', name: 'close-the-month' })) - .structuredContent, + (await session.callTool('list_runs', { type: 'workflow', name: 'close-the-month' })).structuredContent, ); - if (listed.executions.some(({ status }) => status !== 'started') || attempts <= 1) { + if (listed.runs.some(({ status }) => status !== 'started') || attempts <= 1) { return listed; } await setTimeout(100); @@ -51,7 +50,7 @@ async function endedRuns(session: McpSession, attempts = 300): Promise { it('runs as the brain for an event published to the brain, and the feed shows the event and the run', async () => { const seen = await onAlpha(async (session) => { - await session.callTool('create_spec', { primitive: 'orchestration', name: 'close-the-month', source: closing }); + await session.callTool('create_definition', { type: 'workflow', name: 'close-the-month', source: closing }); await session.callTool('publish_event', { event: { source: '/ledger', type: 'com.acme.ledger.closed', data: { month: 'september' } }, }); @@ -60,7 +59,7 @@ describe('a workflow whose trigger is an event, over MCP', { timeout: workflowTe return { runs, feed: eventsIn(feed.structuredContent).events.map(({ type }) => type) }; }); - expect(seen.runs.executions).toEqual([{ status: 'succeeded', started_by: 'brain:alpha' }]); - expect(seen.feed).toEqual(expect.arrayContaining(['event_published', 'execution_started', 'execution_succeeded'])); + expect(seen.runs.runs).toEqual([{ status: 'succeeded', started_by: 'brain:alpha' }]); + expect(seen.feed).toEqual(expect.arrayContaining(['event_published', 'run_started', 'run_succeeded'])); }); }); diff --git a/packages/server/src/workflow-executions/run-graph-over-http.test.ts b/packages/server/src/workflow-runs/run-graph-over-http.test.ts similarity index 66% rename from packages/server/src/workflow-executions/run-graph-over-http.test.ts rename to packages/server/src/workflow-runs/run-graph-over-http.test.ts index e3130c571..a01b7034c 100644 --- a/packages/server/src/workflow-executions/run-graph-over-http.test.ts +++ b/packages/server/src/workflow-runs/run-graph-over-http.test.ts @@ -1,13 +1,13 @@ import { internalTermsIn } from '@beonauto/api/testing'; -import { answers, jsonResult } from '@beonauto/inference/testing'; +import { answers, jsonResult } from '@beonauto/reasoning/testing'; import { Schema } from 'effect'; import { afterEach, describe, expect, it } from 'vitest'; import { alpha, type ReasoningServer } from '../testing/servers/reasoning-server.ts'; import { - executionIdIn, + runIdIn, servingWorkflows, - settledExecution, + settledRun, workflowSource, workflowTestTimeoutMs, } from '../testing/servers/workflow-server.ts'; @@ -26,8 +26,8 @@ const review = workflowSource( 'review', `do: - judge: - call: execute_spec - with: { primitive: inference, name: verdict, input: { expense: '\${ .expense }' } } + call: run_definition + with: { type: reasoning, name: verdict, input: { expense: '\${ .expense }' } } - route: switch: - approved: { when: .approve == true, then: accept } @@ -45,7 +45,7 @@ const EventSchema = Schema.Struct({ summary: Schema.String, data: Schema.Struct({ name: Schema.optionalKey(Schema.String), - execution_id: Schema.optionalKey(Schema.String), + run_id: Schema.optionalKey(Schema.String), }), }); @@ -68,14 +68,14 @@ afterEach(async () => { async function reviewed(): Promise { server = await servingWorkflows([answers(jsonResult({ approve: false }))]); await server.call('POST', '/v1/orgs/acme/brains', { body: { brain: 'alpha', name: 'Alpha' } }); - await server.call('POST', `${alpha}/specs/inference`, { body: { name: 'verdict', source: verdict } }); - await server.call('POST', `${alpha}/specs/orchestration`, { body: { name: 'review', source: review } }); - const started = await server.call('POST', `${alpha}/specs/orchestration/review/execute`, { + await server.call('POST', `${alpha}/definitions/reasoning`, { body: { name: 'verdict', source: verdict } }); + await server.call('POST', `${alpha}/definitions/workflow`, { body: { name: 'review', source: review } }); + const started = await server.call('POST', `${alpha}/definitions/workflow/review/run`, { body: { input: { expense: 'a yacht' } }, }); - const executionId = executionIdIn(started.body); - await settledExecution(server, `${alpha}/executions/${executionId}`); - return executionId; + const runId = runIdIn(started.body); + await settledRun(server, `${alpha}/runs/${runId}`); + return runId; } async function everyEvent(path: string, cursor?: string): Promise { @@ -84,8 +84,8 @@ async function everyEvent(path: string, cursor?: string): Promise type === 'execution_started' && data.execution_id === executionId); +function startOf(events: readonly Event[], runId: string): Event | undefined { + return events.find(({ type, data }) => type === 'run_started' && data.run_id === runId); } function named(events: readonly Event[], type: string, name?: string): Event { @@ -98,9 +98,9 @@ function named(events: readonly Event[], type: string, name?: string): Event { describe('the graph of a workflow run, over HTTP', { timeout: workflowTestTimeoutMs }, () => { it('names every event and its cause, from the start through each step to the finish', async () => { - const executionId = await reviewed(); - const events = await everyEvent(`${alpha}/executions/${executionId}/history?limit=100`); - const started = named(events, 'execution_started'); + const runId = await reviewed(); + const events = await everyEvent(`${alpha}/runs/${runId}/history?limit=100`); + const started = named(events, 'run_started'); const [first, second] = events.filter(({ type }) => type === 'workflow_input_applied'); expect([ @@ -111,7 +111,7 @@ describe('the graph of a workflow run, over HTTP', { timeout: workflowTestTimeou named(events, 'step_finished', 'judge').causation_id, named(events, 'step_finished', 'route').causation_id, named(events, 'step_finished', 'decline').causation_id, - named(events, 'execution_succeeded').causation_id, + named(events, 'run_succeeded').causation_id, ]).toEqual([ null, started.id, @@ -123,38 +123,38 @@ describe('the graph of a workflow run, over HTTP', { timeout: workflowTestTimeou named(events, 'step_finished', 'decline').id, ]); expect(events.slice(0, 3).map(({ type }) => type)).toEqual([ - 'execution_started', + 'run_started', 'workflow_input_applied', 'step_waiting', ]); expect(events.filter(({ type }) => type === 'step_started')).toEqual([]); - expect(events.map(({ type }) => type)).not.toContain('execution_deferred'); + expect(events.map(({ type }) => type)).not.toContain('run_deferred'); expect(events.flatMap(({ summary }) => internalTermsIn(summary))).toEqual([]); }); }); describe('the tree of a workflow run, over HTTP', { timeout: workflowTestTimeoutMs }, () => { it('reads the whole tree of a run in the feed, its child started by the waiting step, and nothing for the child', async () => { - const executionId = await reviewed(); - const tree = await everyEvent(`${alpha}/events?execution_id=${executionId}&order=asc&limit=100`); + const runId = await reviewed(); + const tree = await everyEvent(`${alpha}/events?run_id=${runId}&order=asc&limit=100`); const waiting = named(tree, 'step_waiting', 'judge'); - const child = String(waiting.data.execution_id); - const ofChild = await everyEvent(`${alpha}/events?execution_id=${child}&limit=100`); + const child = String(waiting.data.run_id); + const ofChild = await everyEvent(`${alpha}/events?run_id=${child}&limit=100`); const childStarted = startOf(tree, child); expect(childStarted?.causation_id).toBe(waiting.id); - expect(tree.filter(({ data }) => data.execution_id === child).map(({ type }) => type)).toEqual([ + expect(tree.filter(({ data }) => data.run_id === child).map(({ type }) => type)).toEqual([ 'step_waiting', - 'execution_started', - 'execution_succeeded', + 'run_started', + 'run_succeeded', ]); expect(ofChild).toEqual([]); }); - it('is never given by the input of execute_spec, which refuses a lineage', async () => { + it('is never given by the input of run_definition, which refuses a lineage', async () => { await reviewed(); - const refused = await server.call('POST', `${alpha}/specs/orchestration/review/execute`, { + const refused = await server.call('POST', `${alpha}/definitions/workflow/review/run`, { body: { input: {}, lineage: { causationId: null, correlationId: 'mine' } }, }); @@ -167,22 +167,22 @@ describe('the tree of a workflow run, over HTTP', { timeout: workflowTestTimeout describe('the pages of a workflow run, over HTTP', { timeout: workflowTestTimeoutMs }, () => { it('reads on from the cursor of an input in either order, the steps of that input first oldest first', async () => { - const executionId = await reviewed(); - const tree = await everyEvent(`${alpha}/events?execution_id=${executionId}&order=asc&limit=100`); + const runId = await reviewed(); + const tree = await everyEvent(`${alpha}/events?run_id=${runId}&order=asc&limit=100`); const at = tree.findLastIndex(({ type }) => type === 'workflow_input_applied'); const input = tree[at]; - const after = await everyEvent(`${alpha}/events?execution_id=${executionId}&order=asc&limit=100`, input?.cursor); - const before = await everyEvent(`${alpha}/events?execution_id=${executionId}&order=desc&limit=100`, input?.cursor); + const after = await everyEvent(`${alpha}/events?run_id=${runId}&order=asc&limit=100`, input?.cursor); + const before = await everyEvent(`${alpha}/events?run_id=${runId}&order=desc&limit=100`, input?.cursor); expect([after, before]).toEqual([tree.slice(at + 1), tree.slice(0, at).toReversed()]); expect(after[0]?.type).toBe('step_finished'); }); it('pages the history by events in the order recorded, ends inside an input, and reads on with nothing lost or repeated', async () => { - const executionId = await reviewed(); - const whole = await everyEvent(`${alpha}/executions/${executionId}/history?limit=100`); - const byTwo = await everyEvent(`${alpha}/executions/${executionId}/history?limit=2`); - const newestFirst = await everyEvent(`${alpha}/executions/${executionId}/history?order=desc&limit=3`); + const runId = await reviewed(); + const whole = await everyEvent(`${alpha}/runs/${runId}/history?limit=100`); + const byTwo = await everyEvent(`${alpha}/runs/${runId}/history?limit=2`); + const newestFirst = await everyEvent(`${alpha}/runs/${runId}/history?order=desc&limit=3`); expect(byTwo.map(({ id }) => id)).toEqual(whole.map(({ id }) => id)); expect(newestFirst.map(({ id }) => id)).toEqual(whole.map(({ id }) => id).toReversed()); diff --git a/packages/server/src/workflow-runs/run-log-streams.test.ts b/packages/server/src/workflow-runs/run-log-streams.test.ts new file mode 100644 index 000000000..6aee7e003 --- /dev/null +++ b/packages/server/src/workflow-runs/run-log-streams.test.ts @@ -0,0 +1,125 @@ +import { DatabaseSync } from 'node:sqlite'; + +import { answers, textResult } from '@beonauto/reasoning/testing'; +import { Schema } from 'effect'; +import { afterEach, describe, expect, it } from 'vitest'; + +import { temporaryLedger, type TemporaryLedger } from '../testing/records/temporary-ledger.ts'; +import { alpha, type ReasoningServer } from '../testing/servers/reasoning-server.ts'; +import { until } from '../testing/servers/workflow-calls.ts'; +import { + runIdIn, + servingWorkflows, + workflowSource, + workflowTestTimeoutMs, +} from '../testing/servers/workflow-server.ts'; + +const brainKey = 'brain/acme/alpha/'; + +const summary = ['---', 'model: anthropic/claude-sonnet-4-5', '---', 'Summarize: {{ input.text }}'].join('\n'); + +const workflows: Readonly> = { + setting: 'do:\n - done: { set: { done: true } }\n', + asking: "do:\n - ask: { call: run_definition, with: { type: reasoning, name: summary, input: { text: 'long' } } }\n", + nesting: 'do:\n - nest: { call: run_definition, with: { type: workflow, name: setting } }\n', + pausing: 'do:\n - pause: { wait: { milliseconds: 50 } }\n', + listening: 'do:\n - hold: { listen: { to: { one: { with: { type: com.acme.go } } } } }\n', + telling: + 'do:\n - tell: { emit: { event: { with: { type: com.acme.told, source: https://acme.example/told, data: { to: ada } } } } }\n', + forking: + 'do:\n - both:\n fork:\n branches:\n - a: { set: { a: 1 } }\n - b: { set: { b: 2 } }\n', + guarding: `do: + - guarded: + try: + - fail: { raise: { error: { type: https://example.com/errors/busy, status: 503 } } } + catch: + errors: { with: { status: 503 } } + do: + - recover: { set: { recovered: true } } +`, +}; + +const FirstMessages = Schema.Array(Schema.Struct({ stream: Schema.String, type: Schema.String })); + +type FirstMessage = (typeof FirstMessages.Type)[number]; + +const decodeFirstMessages = Schema.decodeUnknownSync(FirstMessages); + +let server: ReasoningServer; + +let ledger: TemporaryLedger; + +afterEach(async () => { + await server.stop(); + ledger.remove(); +}); + +function firstMessagesOfTheBrain(): readonly FirstMessage[] { + const database = new DatabaseSync(ledger.fileName, { readOnly: true }); + try { + return decodeFirstMessages( + database + .prepare( + 'SELECT stream_id AS stream, message_type AS type FROM emt_messages WHERE stream_position = 1 AND substr(stream_id, 1, ?) = ?', + ) + .all(brainKey.length, brainKey), + ); + } finally { + database.close(); + } +} + +function kindKeyOf(stream: string): string { + return `${stream.split('/').slice(0, 4).join('/')}/`; +} + +function streamsOfKind(messages: readonly FirstMessage[], kind: string): readonly FirstMessage[] { + return messages.filter(({ stream }) => kindKeyOf(stream) === `${brainKey}${kind}/`); +} + +function holdsTheLogOfEvery(runIds: readonly string[]) { + return (messages: readonly FirstMessage[]): boolean => { + const logged = new Set(streamsOfKind(messages, 'run-logs').map(({ stream }) => stream)); + return runIds.every((runId) => logged.has(`${brainKey}run-logs/${runId}`)); + }; +} + +async function definedInTurn(entries: readonly (readonly [string, string])[]): Promise { + const [entry, ...rest] = entries; + if (entry !== undefined) { + const [name, steps] = entry; + await server.call('POST', `${alpha}/definitions/workflow`, { body: { name, source: workflowSource(name, steps) } }); + await definedInTurn(rest); + } +} + +async function startedInTurn(names: readonly string[], runIds: readonly string[] = []): Promise { + const [name, ...rest] = names; + if (name === undefined) { + return runIds; + } + const response = await server.call('POST', `${alpha}/definitions/workflow/${name}/run`, { body: { input: {} } }); + return startedInTurn(rest, [...runIds, runIdIn(response.body)]); +} + +describe('the run log of a workflow', { timeout: workflowTestTimeoutMs }, () => { + it('is kept under its own kind, so no stream of the kind of a run begins with an input the engine applied', async () => { + ledger = temporaryLedger(); + server = await servingWorkflows([answers(textResult('Short.'))], { + LOCAL_MODE: 'true', + LEDGER_FILE: ledger.fileName, + }); + await server.call('POST', '/v1/orgs/acme/brains', { body: { brain: 'alpha', name: 'Alpha' } }); + await server.call('POST', `${alpha}/definitions/reasoning`, { body: { name: 'summary', source: summary } }); + await definedInTurn(Object.entries(workflows)); + const runIds = await startedInTurn(Object.keys(workflows)); + + const messages = await until(() => Promise.resolve(firstMessagesOfTheBrain()), holdsTheLogOfEvery(runIds)); + + expect(streamsOfKind(messages, 'runs').filter(({ type }) => type === 'input_applied')).toEqual([]); + expect(streamsOfKind(messages, 'runs').map(({ stream }) => stream)).toEqual( + expect.arrayContaining(runIds.map((runId) => `${brainKey}runs/${runId}`)), + ); + expect(new Set(streamsOfKind(messages, 'run-logs').map(({ type }) => type))).toEqual(new Set(['input_applied'])); + }); +}); diff --git a/packages/server/src/workflow-executions/several-triggers.test.ts b/packages/server/src/workflow-runs/several-triggers.test.ts similarity index 73% rename from packages/server/src/workflow-executions/several-triggers.test.ts rename to packages/server/src/workflow-runs/several-triggers.test.ts index 60e1240e2..9ffe8b045 100644 --- a/packages/server/src/workflow-executions/several-triggers.test.ts +++ b/packages/server/src/workflow-runs/several-triggers.test.ts @@ -26,9 +26,7 @@ const closing = workflowSource( const TriggerSchema = Schema.Struct({ kind: Schema.String, reference: Schema.String }); const ListedRuns = Schema.Struct({ - executions: Schema.Array( - Schema.Struct({ execution_id: Schema.String, status: Schema.String, started_by: Schema.String }), - ), + runs: Schema.Array(Schema.Struct({ run_id: Schema.String, status: Schema.String, started_by: Schema.String })), }); const StartSchema = Schema.Struct({ @@ -42,7 +40,7 @@ const HistoryPage = Schema.Struct({ events: Schema.NonEmptyArray(Schema.Unknown) const Triggers = Schema.Struct({ triggers: Schema.Array(TriggerSchema) }); -const SavedSpec = Schema.Struct({ ...Triggers.fields, triggers_since: Schema.String }); +const SavedDefinition = Schema.Struct({ ...Triggers.fields, triggers_since: Schema.String }); const BrainEvents = Schema.Struct({ events: Schema.NonEmptyArray(Schema.Struct({ id: Schema.String })) }); @@ -52,11 +50,11 @@ const historyIn = Schema.decodeUnknownSync(HistoryPage); const startIn = Schema.decodeUnknownSync(StartSchema); -const specIn = Schema.decodeUnknownSync(SavedSpec); +const definitionIn = Schema.decodeUnknownSync(SavedDefinition); -const ListedSpecs = Schema.Struct({ specs: Schema.Array(Triggers) }); +const ListedDefinitions = Schema.Struct({ definitions: Schema.Array(Triggers) }); -const listedIn = Schema.decodeUnknownSync(ListedSpecs); +const listedIn = Schema.decodeUnknownSync(ListedDefinitions); const eventsIn = Schema.decodeUnknownSync(BrainEvents); @@ -65,9 +63,9 @@ type Start = typeof StartSchema.Type; interface Seen { readonly runs: typeof ListedRuns.Type; readonly starts: readonly Start[]; - readonly spec: typeof SavedSpec.Type; + readonly definition: typeof SavedDefinition.Type; readonly eventRecord: string; - readonly listedOverMcp: typeof ListedSpecs.Type; + readonly listedOverMcp: typeof ListedDefinitions.Type; readonly startOverMcp: Start; } @@ -76,18 +74,16 @@ let server: ReasoningServer; let seen: Seen; async function endedRuns(count: number, attempts = 300): Promise { - const listed = runsIn( - (await server.call('GET', `${alpha}/executions?primitive=orchestration&name=close-the-month`)).body, - ); - if (listed.executions.filter(({ status }) => status !== 'started').length >= count || attempts <= 1) { + const listed = runsIn((await server.call('GET', `${alpha}/runs?type=workflow&name=close-the-month`)).body); + if (listed.runs.filter(({ status }) => status !== 'started').length >= count || attempts <= 1) { return listed; } await setTimeout(100); return endedRuns(count, attempts - 1); } -async function startOf(executionId: string): Promise { - return startIn(historyIn((await server.call('GET', `${alpha}/executions/${executionId}/history`)).body).events[0]); +async function startOf(runId: string): Promise { + return startIn(historyIn((await server.call('GET', `${alpha}/runs/${runId}/history`)).body).events[0]); } function startByKind(kind: string): Start | undefined { @@ -100,7 +96,7 @@ async function triggeredThreeTimes(): Promise { const mcp = { url: `${server.origin}/orgs/acme/brains/alpha/mcp`, headers: {} }; await server.call('POST', '/v1/orgs/acme/brains', { body: { brain: 'alpha', name: 'Alpha' } }); await withMcpSession('current revision', mcp, (session) => - session.callTool('create_spec', { primitive: 'orchestration', name: 'close-the-month', source: closing }), + session.callTool('create_definition', { type: 'workflow', name: 'close-the-month', source: closing }), ); const savedBy = Date.now(); await server.call('POST', `${alpha}/events`, { @@ -109,16 +105,16 @@ async function triggeredThreeTimes(): Promise { await endedRuns(1); clock.moveTo(savedBy + aMinuteAndASecond); const runs = await endedRuns(3); - const starts = await Promise.all(runs.executions.map(({ execution_id: executionId }) => startOf(executionId))); - const newest = runs.executions[0]?.execution_id ?? ''; + const starts = await Promise.all(runs.runs.map(({ run_id: runId }) => startOf(runId))); + const newest = runs.runs[0]?.run_id ?? ''; const overMcp = await withMcpSession('current revision', mcp, async (session) => ({ - listed: listedIn((await session.callTool('list_specs', { primitive: 'orchestration' })).structuredContent), - history: historyIn((await session.callTool('get_execution_history', { execution_id: newest })).structuredContent), + listed: listedIn((await session.callTool('list_definitions', { type: 'workflow' })).structuredContent), + history: historyIn((await session.callTool('get_run_history', { run_id: newest })).structuredContent), })); return { runs, starts, - spec: specIn((await server.call('GET', `${alpha}/specs/orchestration/close-the-month`)).body), + definition: definitionIn((await server.call('GET', `${alpha}/definitions/workflow/close-the-month`)).body), eventRecord: eventsIn((await server.call('GET', `${alpha}/events?type=event_published`)).body).events[0].id, listedOverMcp: overMcp.listed, startOverMcp: startIn(overMcp.history.events[0]), @@ -135,16 +131,16 @@ afterAll(async () => { describe('a workflow with an event trigger, a cron schedule and an every schedule', () => { it('shows its triggers in the order its document names them, over HTTP and MCP', () => { - expect(seen.spec.triggers).toEqual([ + expect(seen.definition.triggers).toEqual([ { kind: 'event', reference: '/schedule/on' }, { kind: 'cron', reference: '/schedule/cron' }, { kind: 'every', reference: '/schedule/every' }, ]); - expect(seen.listedOverMcp.specs).toEqual([{ triggers: seen.spec.triggers }]); + expect(seen.listedOverMcp.definitions).toEqual([{ triggers: seen.definition.triggers }]); }); it('runs as the brain once for an event and once at a due time of each schedule', () => { - expect(seen.runs.executions.map(({ status, started_by: by }) => [status, by])).toEqual([ + expect(seen.runs.runs.map(({ status, started_by: by }) => [status, by])).toEqual([ ['succeeded', 'brain:alpha'], ['succeeded', 'brain:alpha'], ['succeeded', 'brain:alpha'], @@ -157,21 +153,21 @@ describe('a workflow with an event trigger, a cron schedule and an every schedul it('names in the history of each run the trigger that started it, and what caused it', () => { expect([startByKind('event'), startByKind('cron'), startByKind('every')]).toEqual([ { - type: 'execution_started', + type: 'run_started', summary: 'A run of the workflow “close-the-month” was started by its event trigger.', causation_id: seen.eventRecord, data: { trigger: { kind: 'event', reference: '/schedule/on' } }, }, { - type: 'execution_started', + type: 'run_started', summary: 'A run of the workflow “close-the-month” was started by its cron schedule.', - causation_id: seen.spec.triggers_since, + causation_id: seen.definition.triggers_since, data: { trigger: { kind: 'cron', reference: '/schedule/cron' } }, }, { - type: 'execution_started', + type: 'run_started', summary: 'A run of the workflow “close-the-month” was started by its every schedule.', - causation_id: seen.spec.triggers_since, + causation_id: seen.definition.triggers_since, data: { trigger: { kind: 'every', reference: '/schedule/every' } }, }, ]); diff --git a/packages/server/src/workflow-executions/workflow-access-over-http.test.ts b/packages/server/src/workflow-runs/workflow-access-over-http.test.ts similarity index 57% rename from packages/server/src/workflow-executions/workflow-access-over-http.test.ts rename to packages/server/src/workflow-runs/workflow-access-over-http.test.ts index cbf38603c..af1d4a80b 100644 --- a/packages/server/src/workflow-executions/workflow-access-over-http.test.ts +++ b/packages/server/src/workflow-runs/workflow-access-over-http.test.ts @@ -1,14 +1,14 @@ import { createApiKey } from '@beonauto/identity'; -import { answers, jsonResult } from '@beonauto/inference/testing'; import { allPermissions } from '@beonauto/operations'; +import { answers, jsonResult } from '@beonauto/reasoning/testing'; import { Schema } from 'effect'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { alpha, type ReasoningServer } from '../testing/servers/reasoning-server.ts'; import { - executionIdIn, + runIdIn, servingWorkflows, - settledExecution, + settledRun, workflowSource, workflowTestTimeoutMs, } from '../testing/servers/workflow-server.ts'; @@ -40,8 +40,8 @@ const approval = workflowSource( 'approval', `do: - judge: - call: execute_spec - with: { primitive: inference, name: verdict, input: { expense: '\${ .expense }' } } + call: run_definition + with: { type: reasoning, name: verdict, input: { expense: '\${ .expense }' } } - decide: listen: to: @@ -62,8 +62,11 @@ beforeEach(async () => { }); const asAdmin = { key: admin.key }; await server.call('POST', '/v1/orgs/acme/brains', { ...asAdmin, body: { brain: 'alpha', name: 'Alpha' } }); - await server.call('POST', `${alpha}/specs/inference`, { ...asAdmin, body: { name: 'verdict', source: verdict } }); - await server.call('POST', `${alpha}/specs/orchestration`, { + await server.call('POST', `${alpha}/definitions/reasoning`, { + ...asAdmin, + body: { name: 'verdict', source: verdict }, + }); + await server.call('POST', `${alpha}/definitions/workflow`, { ...asAdmin, body: { name: 'approval', source: approval }, }); @@ -74,79 +77,79 @@ afterEach(async () => { }); async function startedBy(key: string): Promise { - const response = await server.call('POST', `${alpha}/specs/orchestration/approval/execute`, { + const response = await server.call('POST', `${alpha}/definitions/workflow/approval/run`, { key, body: { input: { expense: 'a taxi' } }, }); expect(response).toMatchObject({ status: 200, body: { status: 'started' } }); - return executionIdIn(response.body); + return runIdIn(response.body); } describe('a workflow that listens for an event, over HTTP', { timeout: workflowTestTimeoutMs }, () => { - it('takes the event sent to its execution and settles with it, as the caller who started it', async () => { - const executionId = await startedBy(runner.key); + it('takes the event sent to its run and settles with it, as the caller who started it', async () => { + const runId = await startedBy(runner.key); - const sent = await server.call('POST', `${alpha}/executions/${executionId}/events`, { + const sent = await server.call('POST', `${alpha}/runs/${runId}/events`, { key: runner.key, body: { event: decided }, }); - const settled = await settledExecution(server, `${alpha}/executions/${executionId}`, { key: runner.key }); - const nested = server.modelExecutions()[0]; + const settled = await settledRun(server, `${alpha}/runs/${runId}`, { key: runner.key }); + const nested = server.modelRunIds()[0]; - expect(sent).toMatchObject({ status: 200, body: { execution_id: executionId, event: decided } }); + expect(sent).toMatchObject({ status: 200, body: { run_id: runId, event: decided } }); expect(Object.keys(sentEventOf(sent.body).event)).toEqual(['type', 'source', 'data', 'id', 'time']); expect(settled).toMatchObject({ body: { status: 'succeeded', output: [{ approved: true }], started_by: 'acme-runner' }, }); - expect(await server.call('GET', `${alpha}/executions/${String(nested)}`, { key: admin.key })).toMatchObject({ - body: { primitive: 'inference', name: 'verdict', status: 'succeeded', started_by: 'acme-runner' }, + expect(await server.call('GET', `${alpha}/runs/${String(nested)}`, { key: admin.key })).toMatchObject({ + body: { type: 'reasoning', name: 'verdict', status: 'succeeded', started_by: 'acme-runner' }, }); }); }); describe('a key without access to the brain of a workflow', { timeout: workflowTestTimeoutMs }, () => { - it('cannot execute it, read its execution, or send it an event', async () => { - const executionId = await startedBy(admin.key); + it('cannot execute it, read its run, or send it an event', async () => { + const runId = await startedBy(admin.key); const asOutsider = { key: outsider.key }; - const executing = await server.call('POST', `${alpha}/specs/orchestration/approval/execute`, { + const running = await server.call('POST', `${alpha}/definitions/workflow/approval/run`, { ...asOutsider, body: { input: { expense: 'a yacht' } }, }); - const reading = await server.call('GET', `${alpha}/executions/${executionId}`, asOutsider); - const sending = await server.call('POST', `${alpha}/executions/${executionId}/events`, { + const reading = await server.call('GET', `${alpha}/runs/${runId}`, asOutsider); + const sending = await server.call('POST', `${alpha}/runs/${runId}/events`, { ...asOutsider, body: { event: decided }, }); - await server.call('POST', `${alpha}/executions/${executionId}/events`, { + await server.call('POST', `${alpha}/runs/${runId}/events`, { key: admin.key, body: { event: decided }, }); - expect([executing.status, reading.status, sending.status]).toEqual([403, 403, 403]); - expect(await settledExecution(server, `${alpha}/executions/${executionId}`, { key: admin.key })).toMatchObject({ + expect([running.status, reading.status, sending.status]).toEqual([403, 403, 403]); + expect(await settledRun(server, `${alpha}/runs/${runId}`, { key: admin.key })).toMatchObject({ body: { status: 'succeeded', output: [{ approved: true }] }, }); }); - it('that may only read cannot execute it or send it an event, but can read its execution', async () => { - const executionId = await startedBy(admin.key); + it('that may only read cannot execute it or send it an event, but can read its run', async () => { + const runId = await startedBy(admin.key); const asReader = { key: reader.key }; - const executing = await server.call('POST', `${alpha}/specs/orchestration/approval/execute`, { + const running = await server.call('POST', `${alpha}/definitions/workflow/approval/run`, { ...asReader, body: { input: { expense: 'a yacht' } }, }); - const sending = await server.call('POST', `${alpha}/executions/${executionId}/events`, { + const sending = await server.call('POST', `${alpha}/runs/${runId}/events`, { ...asReader, body: { event: decided }, }); - const reading = await server.call('GET', `${alpha}/executions/${executionId}`, asReader); - await server.call('POST', `${alpha}/executions/${executionId}/events`, { + const reading = await server.call('GET', `${alpha}/runs/${runId}`, asReader); + await server.call('POST', `${alpha}/runs/${runId}/events`, { key: admin.key, body: { event: decided }, }); - expect([executing.status, sending.status, reading.status]).toEqual([403, 403, 200]); + expect([running.status, sending.status, reading.status]).toEqual([403, 403, 200]); }); }); diff --git a/packages/server/src/workflow-executions/workflow-flood.test.ts b/packages/server/src/workflow-runs/workflow-flood.test.ts similarity index 62% rename from packages/server/src/workflow-executions/workflow-flood.test.ts rename to packages/server/src/workflow-runs/workflow-flood.test.ts index dbf67afb6..e05542ee7 100644 --- a/packages/server/src/workflow-executions/workflow-flood.test.ts +++ b/packages/server/src/workflow-runs/workflow-flood.test.ts @@ -2,9 +2,9 @@ import { afterEach, describe, expect, it } from 'vitest'; import { alpha, type ReasoningServer } from '../testing/servers/reasoning-server.ts'; import { - executionIdIn, + runIdIn, servingWorkflows, - settledExecution, + settledRun, workflowSource, workflowTestTimeoutMs, } from '../testing/servers/workflow-server.ts'; @@ -20,18 +20,18 @@ afterEach(async () => { await server.stop(); }); -async function waitingExecution(): Promise { +async function waitingRun(): Promise { server = await servingWorkflows([]); await server.call('POST', '/v1/orgs/acme/brains', { body: { brain: 'alpha', name: 'Alpha' } }); - await server.call('POST', `${alpha}/specs/orchestration`, { body: { name: 'waiting', source: waiting } }); - const started = await server.call('POST', `${alpha}/specs/orchestration/waiting/execute`, { body: { input: {} } }); - return executionIdIn(started.body); + await server.call('POST', `${alpha}/definitions/workflow`, { body: { name: 'waiting', source: waiting } }); + const started = await server.call('POST', `${alpha}/definitions/workflow/waiting/run`, { body: { input: {} } }); + return runIdIn(started.body); } -async function flood(executionId: string, count: number, data: string): Promise { +async function flood(runId: string, count: number, data: string): Promise { const statuses = await Promise.all( Array.from({ length: count }, async (_, index) => { - const sent = await server.call('POST', `${alpha}/executions/${executionId}/events`, { + const sent = await server.call('POST', `${alpha}/runs/${runId}/events`, { body: { event: { id: `e${index}`, type: 'com.acme.flood', data } }, }); return sent.status; @@ -42,11 +42,11 @@ async function flood(executionId: string, count: number, data: string): Promise< describe('a workflow flooded with events it does not consume', { timeout: workflowTestTimeoutMs }, () => { it('fails once more than 64 wait, settles rejected, and answers later events not found', async () => { - const executionId = await waitingExecution(); + const runId = await waitingRun(); - const answered = await flood(executionId, 80, 'small'); - const settled = await settledExecution(server, `${alpha}/executions/${executionId}`); - const later = await server.call('POST', `${alpha}/executions/${executionId}/events`, { + const answered = await flood(runId, 80, 'small'); + const settled = await settledRun(server, `${alpha}/runs/${runId}`); + const later = await server.call('POST', `${alpha}/runs/${runId}/events`, { body: { event: { type: 'com.acme.flood' } }, }); @@ -57,10 +57,10 @@ describe('a workflow flooded with events it does not consume', { timeout: workfl }); it('fails once more than 1 MiB of events waits, however few they are', async () => { - const executionId = await waitingExecution(); + const runId = await waitingRun(); - await flood(executionId, 8, 'x'.repeat(200_000)); - const settled = await settledExecution(server, `${alpha}/executions/${executionId}`); + await flood(runId, 8, 'x'.repeat(200_000)); + const settled = await settledRun(server, `${alpha}/runs/${runId}`); expect(settled).toMatchObject({ body: { status: 'rejected', rejection: { reason: 'unavailable' } } }); }); diff --git a/packages/server/src/workflow-executions/workflow-tenant-data.test.ts b/packages/server/src/workflow-runs/workflow-tenant-data.test.ts similarity index 81% rename from packages/server/src/workflow-executions/workflow-tenant-data.test.ts rename to packages/server/src/workflow-runs/workflow-tenant-data.test.ts index 17568ff30..a29d0fc0a 100644 --- a/packages/server/src/workflow-executions/workflow-tenant-data.test.ts +++ b/packages/server/src/workflow-runs/workflow-tenant-data.test.ts @@ -17,12 +17,12 @@ afterAll(() => { ledger.remove(); }); -interface Spec { +interface Definition { readonly name: string; readonly steps: string; } -const specs: readonly Spec[] = [ +const definitions: readonly Definition[] = [ { name: 'document', steps: `do:\n - fail: { raise: { error: { type: https://example.com/${marker}, status: 422, title: ${marker} } } }\n`, @@ -38,31 +38,31 @@ const specs: readonly Spec[] = [ }, { name: 'nested', - steps: `do:\n - ask: { call: execute_spec, with: { primitive: inference, name: ${marker}, input: { secret: ${marker} } } }\n`, + steps: `do:\n - ask: { call: run_definition, with: { type: reasoning, name: ${marker}, input: { secret: ${marker} } } }\n`, }, { name: 'limit', steps: `timeout: { after: { milliseconds: 300 } }\ndo:\n - ${marker}: { wait: PT1H }\n` }, { name: 'flood', steps: 'do:\n - wait: { listen: { to: { one: { with: { type: com.acme.never } } } } }\n' }, ]; async function started(port: number, name: string): Promise { - const executionId = randomUUID(); - await requestTo(port, 'POST', `/alpha/specs/orchestration/${name}/execute`, { + const runId = randomUUID(); + await requestTo(port, 'POST', `/alpha/definitions/workflow/${name}/run`, { input: { secret: marker }, - execution_id: executionId, + run_id: runId, }); - return [name, executionId]; + return [name, runId]; } function idOf(ids: ReadonlyMap, name: string): string { const id = ids.get(name); if (id === undefined) { - throw new Error(`No execution of ${name} started`); + throw new Error(`No run of ${name} started`); } return id; } -async function sent(port: number, executionId: string, event: unknown): Promise { - const answer = await requestTo(port, 'POST', `/alpha/executions/${executionId}/events`, { event }); +async function sent(port: number, runId: string, event: unknown): Promise { + const answer = await requestTo(port, 'POST', `/alpha/runs/${runId}/events`, { event }); return answer.status; } @@ -72,7 +72,7 @@ async function settledEach(port: number, ids: ReadonlyMap): Prom const settled = await Promise.all( [...ids.keys()].map(async (name): Promise => [ name, - await settledOver(port, `/alpha/executions/${idOf(ids, name)}`), + await settledOver(port, `/alpha/runs/${idOf(ids, name)}`), ]), ); return new Map(settled); @@ -82,10 +82,10 @@ function statusesOf(settled: ReadonlyMap): Readonly [name, statusOf(settled.get(name)).status])); } -async function createdInTurn(port: number, remaining: readonly Spec[]): Promise { +async function createdInTurn(port: number, remaining: readonly Definition[]): Promise { const [first, ...rest] = remaining; if (first !== undefined) { - await requestTo(port, 'POST', '/alpha/specs/orchestration', { + await requestTo(port, 'POST', '/alpha/definitions/workflow', { name: first.name, source: workflowSource(first.name, first.steps), }); @@ -95,8 +95,8 @@ async function createdInTurn(port: number, remaining: readonly Spec[]): Promise< async function startedEach(port: number): Promise> { await requestTo(port, 'POST', '', { brain: 'alpha', name: 'Alpha' }); - await createdInTurn(port, specs); - return new Map(await Promise.all(specs.map(({ name }) => started(port, name)))); + await createdInTurn(port, definitions); + return new Map(await Promise.all(definitions.map(({ name }) => started(port, name)))); } async function stopped(child: SpawnedServer): Promise { diff --git a/packages/server/src/workflow-executions/workflow-tool-cancellation.test.ts b/packages/server/src/workflow-runs/workflow-tool-cancellation.test.ts similarity index 74% rename from packages/server/src/workflow-executions/workflow-tool-cancellation.test.ts rename to packages/server/src/workflow-runs/workflow-tool-cancellation.test.ts index 57fd8ac72..ff181c8d9 100644 --- a/packages/server/src/workflow-executions/workflow-tool-cancellation.test.ts +++ b/packages/server/src/workflow-runs/workflow-tool-cancellation.test.ts @@ -1,15 +1,15 @@ import { setTimeout } from 'node:timers/promises'; -import { answers, callingTools, textResult } from '@beonauto/inference/testing'; import { serveFakeMcp, type FakeMcpServer } from '@beonauto/mcp/testing'; +import { answers, callingTools, textResult } from '@beonauto/reasoning/testing'; import { Schema } from 'effect'; import { afterEach, describe, expect, it } from 'vitest'; import { alpha, type ReasoningServer } from '../testing/servers/reasoning-server.ts'; import { - executionIdIn, + runIdIn, servingWorkflows, - settledExecution, + settledRun, workflowSource, workflowTestTimeoutMs, } from '../testing/servers/workflow-server.ts'; @@ -24,8 +24,8 @@ const impatient = workflowSource( 'impatient', `do: - wait: - call: execute_spec - with: { primitive: inference, name: sleeper } + call: run_definition + with: { type: reasoning, name: sleeper } timeout: { after: PT1S } `, ); @@ -53,8 +53,8 @@ async function serving(fake: FakeMcpServer): Promise { ); closing.push(server.stop); await server.call('POST', '/v1/orgs/acme/brains', { body: { brain: 'alpha', name: 'Alpha' } }); - await server.call('POST', `${alpha}/specs/inference`, { body: { name: 'sleeper', source: sleeper } }); - await server.call('POST', `${alpha}/specs/orchestration`, { body: { name: 'impatient', source: impatient } }); + await server.call('POST', `${alpha}/definitions/reasoning`, { body: { name: 'sleeper', source: sleeper } }); + await server.call('POST', `${alpha}/definitions/workflow`, { body: { name: 'impatient', source: impatient } }); return server; } @@ -78,16 +78,16 @@ describe( closing.push(fake.close); const server = await serving(fake); - const started = await server.call('POST', `${alpha}/specs/orchestration/impatient/execute`, { + const started = await server.call('POST', `${alpha}/definitions/workflow/impatient/run`, { body: { input: {} }, }); - const workflow = await settledExecution(server, `${alpha}/executions/${executionIdIn(started.body)}`); + const workflow = await settledRun(server, `${alpha}/runs/${runIdIn(started.body)}`); const calls = await untilCancelled(fake); - const nested = String(server.modelExecutions()[0]); - const run = await settledExecution(server, `${alpha}/executions/${nested}`); - const history = await server.call('GET', `${alpha}/executions/${nested}/history`); - const again = await server.call('POST', `${alpha}/specs/inference/sleeper/execute`, { - body: { input: {}, execution_id: nested }, + const nested = String(server.modelRunIds()[0]); + const run = await settledRun(server, `${alpha}/runs/${nested}`); + const history = await server.call('GET', `${alpha}/runs/${nested}/history`); + const again = await server.call('POST', `${alpha}/definitions/reasoning/sleeper/run`, { + body: { input: {}, run_id: nested }, }); expect(workflow).toMatchObject({ body: { status: 'rejected', rejection: { reason: 'unavailable' } } }); @@ -99,10 +99,10 @@ describe( body: { status: 'rejected', rejection: { reason: 'cancelled', kind: 'deadline' } }, }); expect(decodeHistory(history.body).events.map(({ type }) => type)).toEqual([ - 'execution_started', + 'run_started', 'tool_call_started', - 'execution_cancel_requested', - 'execution_rejected', + 'run_cancel_requested', + 'run_rejected', ]); expect(again).toMatchObject({ status: 409, body: { reason: 'cancelled', kind: 'deadline' } }); }); diff --git a/packages/server/src/workflow-executions/workflow-tools.test.ts b/packages/server/src/workflow-runs/workflow-tools.test.ts similarity index 79% rename from packages/server/src/workflow-executions/workflow-tools.test.ts rename to packages/server/src/workflow-runs/workflow-tools.test.ts index 3ab044e7d..cc0b73cbc 100644 --- a/packages/server/src/workflow-executions/workflow-tools.test.ts +++ b/packages/server/src/workflow-runs/workflow-tools.test.ts @@ -1,14 +1,14 @@ -import { TimedOut } from '@beonauto/inference'; -import { callingTools, type ScriptedReply } from '@beonauto/inference/testing'; import { serveFakeMcp, type FakeMcpServer } from '@beonauto/mcp/testing'; +import { TimedOut } from '@beonauto/reasoning'; +import { callingTools, type ScriptedReply } from '@beonauto/reasoning/testing'; import { Effect, Schema } from 'effect'; import { afterEach, describe, expect, it } from 'vitest'; import { alpha, type ReasoningServer } from '../testing/servers/reasoning-server.ts'; import { - executionIdIn, + runIdIn, servingWorkflows, - settledExecution, + settledRun, workflowSource, workflowTestTimeoutMs, } from '../testing/servers/workflow-server.ts'; @@ -19,8 +19,8 @@ function reasoningFunction(tool: string): string { return ['---', 'model: anthropic/claude-sonnet-4-5', `tools: [${tool}]`, '---', 'Find acme.'].join('\n'); } -function workflowCalling(name: string, spec: string, caught: boolean): string { - const call = `{ call: execute_spec, with: { primitive: inference, name: ${spec} } }`; +function workflowCalling(name: string, definition: string, caught: boolean): string { + const call = `{ call: run_definition, with: { type: reasoning, name: ${definition} } }`; return workflowSource( name, caught @@ -76,35 +76,35 @@ async function serving(...replies: readonly ScriptedReply[]): Promise summary.startsWith('A function')); - const ending = events.find(({ type }) => type === 'execution_rejected')?.summary; + const ending = events.find(({ type }) => type === 'run_rejected')?.summary; return { settled, answers, ending }; } @@ -115,7 +115,7 @@ describe( it('catches the kind and because of a tool the server does not offer, and its history says why', async () => { const server = await serving(); - const { settled, answers } = await settledRun(server, 'reporting'); + const { settled, answers } = await settledWorkflowRun(server, 'reporting'); expect(settled).toMatchObject({ body: { status: 'succeeded', output: { kind: 'tool_not_offered', because: 'mcp_server_not_configured' } }, @@ -138,7 +138,7 @@ describe( it('ends with the kind and because of tools that could not finish when nothing catches them', async () => { const server = await serving(callingTools([['mcp__graph__search', { query: 'acme' }]], timedOut)); - const { settled, answers } = await settledRun(server, 'careless'); + const { settled, answers } = await settledWorkflowRun(server, 'careless'); expect(settled).toMatchObject({ body: { @@ -163,7 +163,7 @@ describe( it('ends as plain unavailable when a step met a tool server that could not be used, saying nothing of what was called', async () => { const server = await serving(); - const { settled, ending } = await settledRun(server, 'stranded'); + const { settled, ending } = await settledWorkflowRun(server, 'stranded'); expect(settled).toMatchObject({ body: { status: 'rejected', rejection: { reason: 'unavailable' } } }); expect(settled.body).not.toHaveProperty('rejection.kind'); diff --git a/packages/server/src/workflow-executions/workflows-over-http.test.ts b/packages/server/src/workflow-runs/workflows-over-http.test.ts similarity index 61% rename from packages/server/src/workflow-executions/workflows-over-http.test.ts rename to packages/server/src/workflow-runs/workflows-over-http.test.ts index 6881874e7..41072efca 100644 --- a/packages/server/src/workflow-executions/workflows-over-http.test.ts +++ b/packages/server/src/workflow-runs/workflows-over-http.test.ts @@ -1,12 +1,12 @@ -import { answers, jsonResult, textResult, type ScriptedReply } from '@beonauto/inference/testing'; +import { answers, jsonResult, textResult, type ScriptedReply } from '@beonauto/reasoning/testing'; import { Schema } from 'effect'; import { afterEach, describe, expect, it } from 'vitest'; import { alpha, type ReasoningServer } from '../testing/servers/reasoning-server.ts'; import { - executionIdIn, + runIdIn, servingWorkflows, - settledExecution, + settledRun, workflowSource, workflowTestTimeoutMs, } from '../testing/servers/workflow-server.ts'; @@ -34,9 +34,9 @@ const expenseReview = workflowSource( 'expense-review', `do: - judge: - call: execute_spec + call: run_definition with: - primitive: inference + type: reasoning name: verdict input: { expense: '\${ .expense }' } output: @@ -46,9 +46,9 @@ const expenseReview = workflowSource( - approved: { when: .approve == true, then: describe } - declined: { then: decline } - describe: - call: execute_spec + call: run_definition with: - primitive: inference + type: reasoning name: summary input: { text: '\${ .expense }' } output: @@ -65,8 +65,8 @@ const recovering = workflowSource( - attempt: try: - describe: - call: execute_spec - with: { primitive: inference, name: summary, input: { text: 7 } } + call: run_definition + with: { type: reasoning, name: summary, input: { text: 7 } } catch: errors: { with: { status: 400 } } do: @@ -78,8 +78,8 @@ const careless = workflowSource( 'careless', `do: - describe: - call: execute_spec - with: { primitive: inference, name: summary, input: { text: 7 } } + call: run_definition + with: { type: reasoning, name: summary, input: { text: 7 } } `, ); @@ -90,8 +90,8 @@ const historyOf = Schema.decodeUnknownSync( ); const typesOfTheDeclinedReview = [ - 'execution_started', - 'execution_succeeded', + 'run_started', + 'run_succeeded', ...Array.from({ length: 3 }, () => 'step_finished'), 'step_waiting', 'workflow_input_applied', @@ -107,33 +107,33 @@ afterEach(async () => { async function serving(...replies: readonly ScriptedReply[]): Promise { server = await servingWorkflows(replies); await server.call('POST', '/v1/orgs/acme/brains', { body: { brain: 'alpha', name: 'Alpha' } }); - await server.call('POST', `${alpha}/specs/inference`, { body: { name: 'verdict', source: verdict } }); - await server.call('POST', `${alpha}/specs/inference`, { body: { name: 'summary', source: summary } }); - await server.call('POST', `${alpha}/specs/orchestration`, { + await server.call('POST', `${alpha}/definitions/reasoning`, { body: { name: 'verdict', source: verdict } }); + await server.call('POST', `${alpha}/definitions/reasoning`, { body: { name: 'summary', source: summary } }); + await server.call('POST', `${alpha}/definitions/workflow`, { body: { name: 'expense-review', source: expenseReview }, }); - await server.call('POST', `${alpha}/specs/orchestration`, { body: { name: 'recovering', source: recovering } }); - await server.call('POST', `${alpha}/specs/orchestration`, { body: { name: 'careless', source: careless } }); + await server.call('POST', `${alpha}/definitions/workflow`, { body: { name: 'recovering', source: recovering } }); + await server.call('POST', `${alpha}/definitions/workflow`, { body: { name: 'careless', source: careless } }); } -async function executed(name: string, input: object): Promise { - const response = await server.call('POST', `${alpha}/specs/orchestration/${name}/execute`, { body: { input } }); +async function ran(name: string, input: object): Promise { + const response = await server.call('POST', `${alpha}/definitions/workflow/${name}/run`, { body: { input } }); expect(response).toMatchObject({ status: 200, body: { status: 'started' } }); - return executionIdIn(response.body); + return runIdIn(response.body); } describe('a workflow that runs reasoning functions, over HTTP', { timeout: workflowTestTimeoutMs }, () => { - it('branches on the first answer, executes the second spec, and settles succeeded with what it composed', async () => { + it('branches on the first answer, executes the second definition, and settles succeeded with what it composed', async () => { await serving(answers(jsonResult({ approve: true })), answers(textResult('Dinner for six, within policy.'))); - const executionId = await executed('expense-review', { expense: 'a team dinner' }); - const settled = await settledExecution(server, `${alpha}/executions/${executionId}`); + const runId = await ran('expense-review', { expense: 'a team dinner' }); + const settled = await settledRun(server, `${alpha}/runs/${runId}`); expect(settled).toMatchObject({ status: 200, body: { - execution_id: executionId, - primitive: 'orchestration', + run_id: runId, + type: 'workflow', name: 'expense-review', status: 'succeeded', output: { approved: true, summary: 'Dinner for six, within policy.' }, @@ -142,41 +142,41 @@ describe('a workflow that runs reasoning functions, over HTTP', { timeout: workf }); }); - it('leaves each nested execution in the ledger under its own id, run as the caller who started the workflow', async () => { + it('leaves each nested run in the ledger under its own id, run as the caller who started the workflow', async () => { await serving(answers(jsonResult({ approve: true })), answers(textResult('Within policy.'))); - const executionId = await executed('expense-review', { expense: 'a taxi' }); - await settledExecution(server, `${alpha}/executions/${executionId}`); + const runId = await ran('expense-review', { expense: 'a taxi' }); + await settledRun(server, `${alpha}/runs/${runId}`); const nested = await Promise.all( - server.modelExecutions().map((id) => server.call('GET', `${alpha}/executions/${String(id)}`)), + server.modelRunIds().map((id) => server.call('GET', `${alpha}/runs/${String(id)}`)), ); - expect(new Set([executionId, ...server.modelExecutions()]).size).toBe(3); + expect(new Set([runId, ...server.modelRunIds()]).size).toBe(3); expect(nested.map(({ body }) => body)).toMatchObject([ - { primitive: 'inference', name: 'verdict', status: 'succeeded', output: { approve: true }, started_by: 'local' }, - { primitive: 'inference', name: 'summary', status: 'succeeded', output: 'Within policy.', started_by: 'local' }, + { type: 'reasoning', name: 'verdict', status: 'succeeded', output: { approve: true }, started_by: 'local' }, + { type: 'reasoning', name: 'summary', status: 'succeeded', output: 'Within policy.', started_by: 'local' }, ]); }); - it('takes the other branch on the other answer, without executing the second spec', async () => { + it('takes the other branch on the other answer, without executing the second definition', async () => { await serving(answers(jsonResult({ approve: false }))); - const executionId = await executed('expense-review', { expense: 'a yacht' }); + const runId = await ran('expense-review', { expense: 'a yacht' }); - expect(await settledExecution(server, `${alpha}/executions/${executionId}`)).toMatchObject({ + expect(await settledRun(server, `${alpha}/runs/${runId}`)).toMatchObject({ body: { status: 'succeeded', output: { approved: false } }, }); expect(server.modelCalls()).toBe(1); }); }); -describe('a nested spec that rejects its input, over HTTP', { timeout: workflowTestTimeoutMs }, () => { +describe('a nested definition that rejects its input, over HTTP', { timeout: workflowTestTimeoutMs }, () => { it('is an error the workflow catches with try, and recovers from', async () => { await serving(); - const executionId = await executed('recovering', {}); + const runId = await ran('recovering', {}); - expect(await settledExecution(server, `${alpha}/executions/${executionId}`)).toMatchObject({ + expect(await settledRun(server, `${alpha}/runs/${runId}`)).toMatchObject({ body: { status: 'succeeded', output: { recovered: true } }, }); expect(server.modelCalls()).toBe(0); @@ -185,9 +185,9 @@ describe('a nested spec that rejects its input, over HTTP', { timeout: workflowT it('settles the workflow rejected when nothing catches it', async () => { await serving(); - const executionId = await executed('careless', {}); + const runId = await ran('careless', {}); - expect(await settledExecution(server, `${alpha}/executions/${executionId}`)).toMatchObject({ + expect(await settledRun(server, `${alpha}/runs/${runId}`)).toMatchObject({ body: { status: 'rejected', rejection: { reason: 'invalid_input' } }, }); }); @@ -197,12 +197,12 @@ describe('the history of a workflow run, over HTTP', { timeout: workflowTestTime it('shows each input the run took, with the steps that moved and how they ended', async () => { await serving(answers(jsonResult({ approve: false }))); - const executionId = await executed('expense-review', { expense: 'a yacht' }); - await settledExecution(server, `${alpha}/executions/${executionId}`); - const history = await server.call('GET', `${alpha}/executions/${executionId}/history`); + const runId = await ran('expense-review', { expense: 'a yacht' }); + await settledRun(server, `${alpha}/runs/${runId}`); + const history = await server.call('GET', `${alpha}/runs/${runId}/history`); const { events } = historyOf(history.body); - const callKey = JSON.stringify([`acme/alpha/${executionId}`, '/do/0/judge', 1]); + const callKey = JSON.stringify([`acme/alpha/${runId}`, '/do/0/judge', 1]); expect(events.map(({ type }) => type).toSorted()).toEqual(typesOfTheDeclinedReview); expect(events.filter(({ type }) => type === 'workflow_input_applied')).toEqual([ @@ -210,8 +210,8 @@ describe('the history of a workflow run, over HTTP', { timeout: workflowTestTime type: 'workflow_input_applied', summary: 'The workflow started, and 1 step moved.', data: { - execution_id: executionId, - input: { kind: 'started', key: executionId }, + run_id: runId, + input: { kind: 'started', key: runId }, step_count: 1, steps: [{ task: '/do/0/judge', run: 1, outcome: 'waiting' }], output_kinds: ['arm_timer', 'start_call'], @@ -221,7 +221,7 @@ describe('the history of a workflow run, over HTTP', { timeout: workflowTestTime type: 'workflow_input_applied', summary: 'A function the workflow called answered, and 3 steps moved; the workflow ended.', data: { - execution_id: executionId, + run_id: runId, input: { kind: 'call_answered', key: callKey, status: 'succeeded' }, step_count: 3, steps: [ diff --git a/packages/server/src/workflow-executions/workflows-over-mcp.test.ts b/packages/server/src/workflow-runs/workflows-over-mcp.test.ts similarity index 58% rename from packages/server/src/workflow-executions/workflows-over-mcp.test.ts rename to packages/server/src/workflow-runs/workflows-over-mcp.test.ts index 4e433a914..7c7e1c9db 100644 --- a/packages/server/src/workflow-executions/workflows-over-mcp.test.ts +++ b/packages/server/src/workflow-runs/workflows-over-mcp.test.ts @@ -1,7 +1,7 @@ import { setTimeout } from 'node:timers/promises'; import { listedTools, toolNamesIn, withMcpSession, type McpSession, type ToolResult } from '@beonauto/api/testing'; -import { answers, jsonResult, type ScriptedReply } from '@beonauto/inference/testing'; +import { answers, jsonResult, type ScriptedReply } from '@beonauto/reasoning/testing'; import { Schema } from 'effect'; import { afterEach, describe, expect, it } from 'vitest'; @@ -22,8 +22,8 @@ const approval = workflowSource( 'approval', `do: - judge: - call: execute_spec - with: { primitive: inference, name: verdict, input: { expense: '\${ .expense }' } } + call: run_definition + with: { type: reasoning, name: verdict, input: { expense: '\${ .expense }' } } output: as: '\${ { approve: .approve } }' - decide: @@ -37,16 +37,16 @@ const approval = workflowSource( ); const brainTools = [ - 'create_spec', - 'list_specs', - 'get_spec', - 'update_spec', - 'retire_spec', - 'execute_spec', - 'get_execution', - 'cancel_execution', - 'list_executions', - 'get_execution_history', + 'create_definition', + 'list_definitions', + 'get_definition', + 'update_definition', + 'retire_definition', + 'run_definition', + 'get_run', + 'cancel_run', + 'list_runs', + 'get_run_history', 'get_brain_analytics', 'list_brain_events', 'publish_event', @@ -54,7 +54,7 @@ const brainTools = [ 'test_tool_call', 'answer_interaction', 'list_interactions', - 'send_execution_event', + 'send_run_event', ]; let server: ReasoningServer; @@ -75,58 +75,52 @@ async function onAlpha(replies: readonly ScriptedReply[], use: (session: McpS return onAlphaOf(server, use); } -async function settled(session: McpSession, executionId: string): Promise { - const reading = await session.callTool('get_execution', { execution_id: executionId }); +async function settled(session: McpSession, runId: string): Promise { + const reading = await session.callTool('get_run', { run_id: runId }); if (reading.structuredContent?.['status'] !== 'started') { return reading; } await setTimeout(100); - return settled(session, executionId); + return settled(session, runId); } -const primitiveField = Schema.decodeUnknownSync( - Schema.Struct({ properties: Schema.Struct({ primitive: Schema.Struct({ enum: Schema.Array(Schema.String) }) }) }), +const typeField = Schema.decodeUnknownSync( + Schema.Struct({ properties: Schema.Struct({ type: Schema.Struct({ enum: Schema.Array(Schema.String) }) }) }), ); -function primitivesOfCreateSpec(listing: unknown): readonly string[] { - const createSpec = listedTools(listing).filter(({ name }) => name === 'create_spec'); - return createSpec.flatMap(({ inputSchema }) => primitiveField(inputSchema).properties.primitive.enum); +function typesOfCreateDefinition(listing: unknown): readonly string[] { + const createDefinition = listedTools(listing).filter(({ name }) => name === 'create_definition'); + return createDefinition.flatMap(({ inputSchema }) => typeField(inputSchema).properties.type.enum); } describe('workflows over MCP', { timeout: workflowTestTimeoutMs }, () => { - it('lists eighteen tools on the endpoint of a brain, and every primitive in the spec tools', async () => { + it('lists eighteen tools on the endpoint of a brain, and every capability in the definition tools', async () => { const listing = await onAlpha([], (session) => session.listTools()); expect(toolNamesIn(listing)).toEqual([...brainTools, 'get_guide']); - expect(primitivesOfCreateSpec(listing)).toEqual([ - 'inference', - 'interaction', - 'computation', - 'recollection', - 'orchestration', - ]); + expect(typesOfCreateDefinition(listing)).toEqual(['reasoning', 'interaction', 'computation', 'recall', 'workflow']); }); it('executes a workflow that calls a reasoning function definition and waits for an event the tools send', async () => { - const { started, sent, execution } = await onAlpha([answers(jsonResult({ approve: true }))], async (session) => { - await session.callTool('create_spec', { primitive: 'inference', name: 'verdict', source: verdict }); - await session.callTool('create_spec', { primitive: 'orchestration', name: 'approval', source: approval }); - const starting = await session.callTool('execute_spec', { - primitive: 'orchestration', + const { started, sent, run } = await onAlpha([answers(jsonResult({ approve: true }))], async (session) => { + await session.callTool('create_definition', { type: 'reasoning', name: 'verdict', source: verdict }); + await session.callTool('create_definition', { type: 'workflow', name: 'approval', source: approval }); + const starting = await session.callTool('run_definition', { + type: 'workflow', name: 'approval', input: { expense: 'a taxi' }, }); - const executionId = String(starting.structuredContent?.['execution_id']); - const sending = await session.callTool('send_execution_event', { - execution_id: executionId, + const runId = String(starting.structuredContent?.['run_id']); + const sending = await session.callTool('send_run_event', { + run_id: runId, event: { type: 'com.acme.approval.decided', data: 'yes' }, }); - return { started: starting, sent: sending, execution: await settled(session, executionId) }; + return { started: starting, sent: sending, run: await settled(session, runId) }; }); - expect(started.structuredContent).toMatchObject({ primitive: 'orchestration', status: 'started' }); + expect(started.structuredContent).toMatchObject({ type: 'workflow', status: 'started' }); expect(sent.structuredContent).toMatchObject({ event: { type: 'com.acme.approval.decided', data: 'yes' } }); - expect(execution.structuredContent).toMatchObject({ status: 'succeeded', output: { decided: 'yes' } }); + expect(run.structuredContent).toMatchObject({ status: 'succeeded', output: { decided: 'yes' } }); }); }); @@ -136,7 +130,7 @@ const EventsSchema = Schema.Struct({ id: Schema.String, causation_id: Schema.NullOr(Schema.String), type: Schema.String, - data: Schema.Struct({ name: Schema.optionalKey(Schema.String), execution_id: Schema.optionalKey(Schema.String) }), + data: Schema.Struct({ name: Schema.optionalKey(Schema.String), run_id: Schema.optionalKey(Schema.String) }), }), ), }); @@ -149,8 +143,8 @@ function stepOf(events: readonly Event[], type: string, name: string): Event | u return events.find((event) => event.type === type && event.data.name === name); } -function startOf(events: readonly Event[], executionId: string | undefined): Event | undefined { - return events.find(({ type, data }) => type === 'execution_started' && data.execution_id === executionId); +function startOf(events: readonly Event[], runId: string | undefined): Event | undefined { + return events.find(({ type, data }) => type === 'run_started' && data.run_id === runId); } function causesNamedNowhereIn(events: readonly Event[]): readonly string[] { @@ -161,27 +155,27 @@ function causesNamedNowhereIn(events: readonly Event[]): readonly string[] { describe('the graph of a workflow run over MCP', { timeout: workflowTestTimeoutMs }, () => { it('reads the steps of a run with their causes, and the whole tree of the run in the feed of its brain', async () => { const { history, tree } = await onAlpha([answers(jsonResult({ approve: true }))], async (session) => { - await session.callTool('create_spec', { primitive: 'inference', name: 'verdict', source: verdict }); - await session.callTool('create_spec', { primitive: 'orchestration', name: 'approval', source: approval }); - const starting = await session.callTool('execute_spec', { - primitive: 'orchestration', + await session.callTool('create_definition', { type: 'reasoning', name: 'verdict', source: verdict }); + await session.callTool('create_definition', { type: 'workflow', name: 'approval', source: approval }); + const starting = await session.callTool('run_definition', { + type: 'workflow', name: 'approval', input: { expense: 'a taxi' }, }); - const executionId = String(starting.structuredContent?.['execution_id']); - await session.callTool('send_execution_event', { - execution_id: executionId, + const runId = String(starting.structuredContent?.['run_id']); + await session.callTool('send_run_event', { + run_id: runId, event: { type: 'com.acme.approval.decided', data: 'yes' }, }); - await settled(session, executionId); + await settled(session, runId); return { - history: await session.callTool('get_execution_history', { execution_id: executionId, limit: 100 }), - tree: await session.callTool('list_brain_events', { execution_id: executionId, order: 'asc', limit: 100 }), + history: await session.callTool('get_run_history', { run_id: runId, limit: 100 }), + tree: await session.callTool('list_brain_events', { run_id: runId, order: 'asc', limit: 100 }), }; }); const { events } = eventsOf(history.structuredContent); const waiting = stepOf(events, 'step_waiting', 'judge'); - const childStarted = startOf(eventsOf(tree.structuredContent).events, waiting?.data.execution_id); + const childStarted = startOf(eventsOf(tree.structuredContent).events, waiting?.data.run_id); expect(events.filter(({ type }) => type.startsWith('step_')).length).toBeGreaterThan(0); expect(causesNamedNowhereIn(events)).toEqual([]); diff --git a/packages/server/src/workflows/host-dependencies.test.ts b/packages/server/src/workflows/host-dependencies.test.ts index f2de8ae1f..ea7dbdd0b 100644 --- a/packages/server/src/workflows/host-dependencies.test.ts +++ b/packages/server/src/workflows/host-dependencies.test.ts @@ -48,18 +48,18 @@ describe('work the workflow host hands to the runtime of the server', () => { }); describe('the reports of the workflow host', () => { - it('log an execution left started with the reason the host gave, in words', async () => { + it('log a run left started with the reason the host gave, in words', async () => { const lines = await reportedLines(async (reports) => { - const run = { org: 'acme', brain: 'alpha', executionId: 'e-1' }; - await Effect.runPromise(reports.unsettled({ ...run, receipt: 'unknown_execution' })); + const run = { org: 'acme', brain: 'alpha', runId: 'e-1' }; + await Effect.runPromise(reports.unsettled({ ...run, receipt: 'unknown_run' })); await Effect.runPromise(reports.unsettled({ ...run, receipt: 'settled_otherwise' })); }); expect(lines).toEqual([ expect.stringContaining( - '"annotations":{"org":"acme","brain":"alpha","execution_id":"e-1","reason":"The ledger has no such execution"}', + '"annotations":{"org":"acme","brain":"alpha","run_id":"e-1","reason":"The ledger has no such run"}', ), - expect.stringContaining('"reason":"The execution was settled otherwise before"'), + expect.stringContaining('"reason":"The run was settled otherwise before"'), ]); }); @@ -71,7 +71,7 @@ describe('the reports of the workflow host', () => { await Effect.runPromise( reports.note({ kind: 'settled_after_back_off', - run: { org: 'acme', brain: 'alpha', executionId: 'e-1' }, + run: { org: 'acme', brain: 'alpha', runId: 'e-1' }, attempts: 21, }), ); @@ -80,7 +80,7 @@ describe('the reports of the workflow host', () => { expect(lines).toEqual([ expect.stringContaining('"message":"A sweep of the runs failed; the next sweep tries again","level":"WARN"'), expect.stringContaining('"annotations":{"error":"Connection terminated unexpectedly"}'), - expect.stringContaining('"message":"An execution that could not be settled was settled at attempt 21"'), + expect.stringContaining('"message":"A run that could not be settled was settled at attempt 21"'), ]); }); }); diff --git a/packages/server/src/workflows/host-dependencies.ts b/packages/server/src/workflows/host-dependencies.ts index f343ec937..08107aaee 100644 --- a/packages/server/src/workflows/host-dependencies.ts +++ b/packages/server/src/workflows/host-dependencies.ts @@ -1,6 +1,6 @@ import type { AppRuntime } from '@beonauto/api'; +import type { BrainOperation, Capability } from '@beonauto/definitions'; import type { Dispatcher, DispatcherServices } from '@beonauto/operations'; -import type { BrainOperation, Primitive } from '@beonauto/specs'; import { openWorkflowHost, type DatabaseSettings, @@ -21,7 +21,7 @@ import { hostWorkOf } from './host-work.ts'; export interface HostParts { readonly ledger: LedgerSettings; readonly workflows: WorkflowSettings; - readonly primitives: readonly Primitive[]; + readonly capabilities: readonly Capability[]; readonly store: WorkflowStore; readonly views: ProjectorSettings; readonly dueWork: NonNullable; @@ -37,14 +37,14 @@ export function hostDatabaseOf(ledger: LedgerSettings): DatabaseSettings { export async function openedHost( runtime: AppRuntime, dispatcher: Dispatcher, - { workflows, primitives, store, views, dueWork, clock }: HostParts, + { workflows, capabilities, store, views, dueWork, clock }: HostParts, startVersion: BrainOperation, ): Promise { const host = await openWorkflowHost({ database: store, views, dueWork, - ...hostWorkOf(runtime, dispatcher, { primitives, startVersion }), + ...hostWorkOf(runtime, dispatcher, { capabilities, startVersion }), reports: hostReports(runtime), sweepEveryMs: workflows.sweepEveryMs, mostCallsAtOnce: workflows.mostCallsAtOnce, diff --git a/packages/server/src/workflows/host-reports.ts b/packages/server/src/workflows/host-reports.ts index 7d8193ed7..537d9d9c5 100644 --- a/packages/server/src/workflows/host-reports.ts +++ b/packages/server/src/workflows/host-reports.ts @@ -7,14 +7,14 @@ import { logLostWorkflowConnection, logUnsettled, logWorkflowTrouble } from '../ import { inRuntime } from './in-runtime.ts'; const unsettledBecause = { - unknown_execution: 'The ledger has no such execution', - settled_otherwise: 'The execution was settled otherwise before', + unknown_run: 'The ledger has no such run', + settled_otherwise: 'The run was settled otherwise before', } as const; export function hostReports(runtime: AppRuntime): HostReports { return { - unsettled: ({ org, brain, executionId, receipt }) => - inRuntime(runtime, logUnsettled({ org, brain, executionId, reason: unsettledBecause[receipt] })), + unsettled: ({ org, brain, runId, receipt }) => + inRuntime(runtime, logUnsettled({ org, brain, runId, reason: unsettledBecause[receipt] })), trouble: (what, cause) => inRuntime(runtime, logWorkflowTrouble(what, cause)), lostConnection: (error) => { void runtime.run(logLostWorkflowConnection(error)); diff --git a/packages/server/src/workflows/host-work.test.ts b/packages/server/src/workflows/host-work.test.ts index 6bc5424c8..f434e1c25 100644 --- a/packages/server/src/workflows/host-work.test.ts +++ b/packages/server/src/workflows/host-work.test.ts @@ -1,8 +1,8 @@ import { makeAppRuntime } from '@beonauto/api'; +import { defineStartVersion } from '@beonauto/definitions'; +import { echo } from '@beonauto/definitions/testing'; import { ledgerLayer } from '@beonauto/ledger/sqlite3'; import { makeDispatcher } from '@beonauto/operations'; -import { defineStartVersion } from '@beonauto/specs'; -import { echo } from '@beonauto/specs/testing'; import { Effect } from 'effect'; import { describe, expect, it } from 'vitest'; @@ -17,7 +17,7 @@ describe('the cancels the workflow host hands to the runtime of the server', () it('settle nothing for a run the ledger does not have, and record the cancel of a run that has not started yet', async () => { const runtime = await makeAppRuntime(applicationLayer(ledgerLayer({ fileName: ':memory:' }))); const { waiting } = hostWorkOf(runtime, makeDispatcher([]), { - primitives: [echo], + capabilities: [echo], startVersion: defineStartVersion([echo]), }); diff --git a/packages/server/src/workflows/host-work.ts b/packages/server/src/workflows/host-work.ts index b07a6353c..dacde4241 100644 --- a/packages/server/src/workflows/host-work.ts +++ b/packages/server/src/workflows/host-work.ts @@ -1,21 +1,21 @@ import type { AppRuntime } from '@beonauto/api'; -import { Ledger, type Dispatcher, type DispatcherServices } from '@beonauto/operations'; import { callResultOfEnding, definitionCalls, definitionRunResultOf, - orchestrationMachine, + workflowMachineOptions, type RunDefinition, -} from '@beonauto/orchestration'; +} from '@beonauto/coordination'; import { - defineExecuteSpec, + defineRunDefinition, deferredCanceller, - executionCanceller, - executionSettler, + runCanceller, + runSettler, type BrainOperation, - type Primitive, - type SettleExecution, -} from '@beonauto/specs'; + type Capability, + type SettleRun, +} from '@beonauto/definitions'; +import { Ledger, type Dispatcher, type DispatcherServices } from '@beonauto/operations'; import type { HostOptions, WaitingOptions } from '@beonauto/workflow-host'; import { Effect } from 'effect'; @@ -25,23 +25,23 @@ import { reactionsOf } from './reaction-dependencies.ts'; export type HostWork = Pick; export interface WorkParts { - readonly primitives: readonly Primitive[]; + readonly capabilities: readonly Capability[]; readonly startVersion: BrainOperation; } -function nestedExecutions( +function nestedRuns( runtime: AppRuntime, dispatcher: Dispatcher, - executeSpec: BrainOperation, + runDefinition: BrainOperation, ): RunDefinition { - return ({ org, brain, caller, primitive, name, input, executionId, lineage, depth, callDepth, calledBy }) => + return ({ org, brain, caller, type, name, input, runId, lineage, depth, callDepth, calledBy }) => inRuntime( runtime, - dispatcher.dispatchToBrain(executeSpec.registration, { + dispatcher.dispatchToBrain(runDefinition.registration, { caller, org, brain, - input: { primitive, name, input, execution_id: executionId }, + input: { type, name, input, run_id: runId }, encoding: 'json', lineage, depth, @@ -51,27 +51,27 @@ function nestedExecutions( ).pipe(Effect.map(definitionRunResultOf)); } -function settlements(runtime: AppRuntime): SettleExecution { - return (execution, settlement, lineage) => +function settlements(runtime: AppRuntime): SettleRun { + return (run, settlement, lineage) => inRuntime( runtime, - Effect.flatMap(Effect.service(Ledger), (ledger) => executionSettler(ledger)(execution, settlement, lineage)), + Effect.flatMap(Effect.service(Ledger), (ledger) => runSettler(ledger)(run, settlement, lineage)), ); } -function waitingOf(runtime: AppRuntime, primitives: readonly Primitive[]): WaitingOptions { +function waitingOf(runtime: AppRuntime, capabilities: readonly Capability[]): WaitingOptions { return { resultOf: callResultOfEnding, - cancel: (execution, request, lineage) => + cancel: (run, request, lineage) => inRuntime( runtime, - Effect.flatMap(Effect.service(Ledger), (ledger) => executionCanceller(ledger)(execution, request, lineage)), + Effect.flatMap(Effect.service(Ledger), (ledger) => runCanceller(ledger)(run, request, lineage)), ), - cancelDeferred: (execution, request, lineage) => + cancelDeferred: (run, request, lineage) => inRuntime( runtime, Effect.flatMap(Effect.service(Ledger), (ledger) => - deferredCanceller(primitives, ledger)(execution, request, lineage), + deferredCanceller(capabilities, ledger)(run, request, lineage), ), ), }; @@ -80,13 +80,13 @@ function waitingOf(runtime: AppRuntime, primitives: readonly export function hostWorkOf( runtime: AppRuntime, dispatcher: Dispatcher, - { primitives, startVersion }: WorkParts, + { capabilities, startVersion }: WorkParts, ): HostWork { return { - machine: orchestrationMachine, - perform: definitionCalls(nestedExecutions(runtime, dispatcher, defineExecuteSpec(primitives))), + machine: workflowMachineOptions, + perform: definitionCalls(nestedRuns(runtime, dispatcher, defineRunDefinition(capabilities))), settle: settlements(runtime), reactions: reactionsOf(runtime, dispatcher, startVersion), - waiting: waitingOf(runtime, primitives), + waiting: waitingOf(runtime, capabilities), }; } diff --git a/packages/server/src/workflows/reaction-dependencies.test.ts b/packages/server/src/workflows/reaction-dependencies.test.ts index 006738b85..ecdb3c2b6 100644 --- a/packages/server/src/workflows/reaction-dependencies.test.ts +++ b/packages/server/src/workflows/reaction-dependencies.test.ts @@ -1,8 +1,8 @@ import { makeAppRuntime } from '@beonauto/api'; +import { defineStartVersion } from '@beonauto/definitions'; +import { echo } from '@beonauto/definitions/testing'; import { ledgerLayer } from '@beonauto/ledger/sqlite3'; import type { BrainRequest, Dispatcher, Outcome } from '@beonauto/operations'; -import { defineStartVersion } from '@beonauto/specs'; -import { echo } from '@beonauto/specs/testing'; import { StartRefused, StartRejected } from '@beonauto/workflow-host'; import { Effect } from 'effect'; import { describe, expect, it, onTestFinished } from 'vitest'; @@ -15,7 +15,7 @@ const start = { brain: 'alpha', workflow: 'close-the-month', version: 2, - executionId: '0199a3c4-7d2e-7c1a-9b3f-2f1e0d9c8b7a', + runId: '0199a3c4-7d2e-7c1a-9b3f-2f1e0d9c8b7a', input: [{ type: 'com.acme.ledger.closed' }], depth: 3, cause: 'record-1', @@ -49,14 +49,14 @@ describe('a start of a workflow its trigger matched', () => { org: 'acme', brain: 'alpha', input: { - primitive: 'orchestration', + type: 'workflow', name: 'close-the-month', version: 2, input: start.input, - execution_id: start.executionId, + run_id: start.runId, }, encoding: 'json', - lineage: { causationId: 'record-1', correlationId: start.executionId }, + lineage: { causationId: 'record-1', correlationId: start.runId }, depth: 3, trigger: { kind: 'event', reference: '/schedule/on' }, }, @@ -91,7 +91,7 @@ describe('an event a workflow emits', () => { type: 'com.acme.ledger.closed', time: '2026-10-01T09:00:00.000Z', }, - emitter: { execution_id: start.executionId, workflow: 'announce', version: 1 }, + emitter: { run_id: start.runId, workflow: 'announce', version: 1 }, depth: 1, by: 'brain:alpha', at: '2026-10-01T09:00:00.000Z', @@ -100,7 +100,7 @@ describe('an event a workflow emits', () => { const outcome = await Effect.runPromise( reactions.emit({ org: 'acme', brain: 'alpha' }, emission, { causationId: null, - correlationId: start.executionId, + correlationId: start.runId, }), ); diff --git a/packages/server/src/workflows/reaction-dependencies.ts b/packages/server/src/workflows/reaction-dependencies.ts index c90d4ae11..7fcf51316 100644 --- a/packages/server/src/workflows/reaction-dependencies.ts +++ b/packages/server/src/workflows/reaction-dependencies.ts @@ -1,4 +1,5 @@ import type { AppRuntime } from '@beonauto/api'; +import { eventEmitter, type BrainOperation } from '@beonauto/definitions'; import { brainCallerOf, Ledger, @@ -7,13 +8,12 @@ import { type DispatcherServices, type Outcome, } from '@beonauto/operations'; -import { eventEmitter, type BrainOperation } from '@beonauto/specs'; import { StartRefused, StartRejected, type ReactionOptions, type ReactionStart } from '@beonauto/workflow-host'; import { Effect } from 'effect'; import { inRuntime } from './in-runtime.ts'; -const workflows = 'orchestration'; +const workflows = 'workflow'; function requestOf({ org, @@ -21,7 +21,7 @@ function requestOf({ workflow, version, input, - executionId, + runId, depth, cause, trigger, @@ -30,9 +30,9 @@ function requestOf({ caller: brainCallerOf({ org, brain }), org, brain, - input: { primitive: workflows, name: workflow, version, input, execution_id: executionId }, + input: { type: workflows, name: workflow, version, input, run_id: runId }, encoding: 'json', - lineage: { causationId: cause, correlationId: executionId }, + lineage: { causationId: cause, correlationId: runId }, depth, trigger, }; @@ -56,7 +56,7 @@ export function reactionsOf( startVersion: BrainOperation, ): ReactionOptions { return { - primitive: workflows, + definitionType: workflows, start: (start) => Effect.flatMap( inRuntime(runtime, dispatcher.dispatchToBrain(startVersion.registration, requestOf(start))), diff --git a/packages/server/src/workflows/workflow-lifecycle.test.ts b/packages/server/src/workflows/workflow-lifecycle.test.ts index 1811bab0c..a8c65aade 100644 --- a/packages/server/src/workflows/workflow-lifecycle.test.ts +++ b/packages/server/src/workflows/workflow-lifecycle.test.ts @@ -5,7 +5,7 @@ import { afterAll, describe, expect, it } from 'vitest'; import { logLinesOf, requestTo, settledOver, workflowProcess } from '../testing/processes/workflow-process.ts'; import { temporaryLedger } from '../testing/records/temporary-ledger.ts'; -import { executionIdIn, workflowSource, workflowTestTimeoutMs } from '../testing/servers/workflow-server.ts'; +import { runIdIn, workflowSource, workflowTestTimeoutMs } from '../testing/servers/workflow-server.ts'; const ledger = temporaryLedger(); @@ -58,29 +58,25 @@ describe('a server started again on the ledger of its workflows', { timeout: wor const first = workflowProcess(ledger.fileName); const firstPort = await first.port; await requestTo(firstPort, 'POST', '', { brain: 'gamma', name: 'Gamma' }); - await requestTo(firstPort, 'POST', '/gamma/specs/orchestration', { name: 'approval', source: approval }); - await requestTo(firstPort, 'POST', '/gamma/specs/orchestration', { name: 'pausing', source: pausing }); - await requestTo(firstPort, 'POST', '/gamma/specs/orchestration', { name: 'timing', source: timing }); - const waiting = await requestTo(firstPort, 'POST', '/gamma/specs/orchestration/approval/execute', { input: {} }); - const paused = await requestTo(firstPort, 'POST', '/gamma/specs/orchestration/pausing/execute', { input: {} }); - const timed = await requestTo(firstPort, 'POST', '/gamma/specs/orchestration/timing/execute', { input: {} }); + await requestTo(firstPort, 'POST', '/gamma/definitions/workflow', { name: 'approval', source: approval }); + await requestTo(firstPort, 'POST', '/gamma/definitions/workflow', { name: 'pausing', source: pausing }); + await requestTo(firstPort, 'POST', '/gamma/definitions/workflow', { name: 'timing', source: timing }); + const waiting = await requestTo(firstPort, 'POST', '/gamma/definitions/workflow/approval/run', { input: {} }); + const paused = await requestTo(firstPort, 'POST', '/gamma/definitions/workflow/pausing/run', { input: {} }); + const timed = await requestTo(firstPort, 'POST', '/gamma/definitions/workflow/timing/run', { input: {} }); first.signal('SIGTERM'); await first.exited; await setTimeout(1500); const second = workflowProcess(ledger.fileName); const port = await second.port; - const sent = await requestTo(port, 'POST', `/gamma/executions/${executionIdIn(waiting.body)}/events`, { + const sent = await requestTo(port, 'POST', `/gamma/runs/${runIdIn(waiting.body)}/events`, { event: { type: 'com.acme.approved', data: { by: 'Ada' } }, }); - const approved = await settledOver(port, `/gamma/executions/${executionIdIn(waiting.body)}`); - const pausedSettled = await settledOver(port, `/gamma/executions/${executionIdIn(paused.body)}`); - await settledOver(port, `/gamma/executions/${executionIdIn(timed.body)}`); - const timedHistory = await requestTo( - port, - 'GET', - `/gamma/executions/${executionIdIn(timed.body)}/history?limit=100`, - ); + const approved = await settledOver(port, `/gamma/runs/${runIdIn(waiting.body)}`); + const pausedSettled = await settledOver(port, `/gamma/runs/${runIdIn(paused.body)}`); + await settledOver(port, `/gamma/runs/${runIdIn(timed.body)}`); + const timedHistory = await requestTo(port, 'GET', `/gamma/runs/${runIdIn(timed.body)}/history?limit=100`); second.signal('SIGTERM'); const inputs = inputsOf(timedHistory.body).events.filter(({ type }) => type === 'workflow_input_applied'); diff --git a/packages/server/src/workflows/workflow-shutdown.test.ts b/packages/server/src/workflows/workflow-shutdown.test.ts index 490836bdc..3d7fdd3a6 100644 --- a/packages/server/src/workflows/workflow-shutdown.test.ts +++ b/packages/server/src/workflows/workflow-shutdown.test.ts @@ -8,7 +8,7 @@ import { workflowProcess, } from '../testing/processes/workflow-process.ts'; import { temporaryLedger } from '../testing/records/temporary-ledger.ts'; -import { executionIdIn, workflowSource, workflowTestTimeoutMs } from '../testing/servers/workflow-server.ts'; +import { runIdIn, workflowSource, workflowTestTimeoutMs } from '../testing/servers/workflow-server.ts'; const ledger = temporaryLedger(); @@ -23,11 +23,11 @@ describe('a server that ran workflows, told to stop', { timeout: workflowTestTim const child = workflowProcess(ledger.fileName); const port = await child.port; await requestTo(port, 'POST', '', { brain: 'alpha', name: 'Alpha' }); - await requestTo(port, 'POST', '/alpha/specs/orchestration', { name: 'greeting', source: greeting }); - const started = await requestTo(port, 'POST', '/alpha/specs/orchestration/greeting/execute', { + await requestTo(port, 'POST', '/alpha/definitions/workflow', { name: 'greeting', source: greeting }); + const started = await requestTo(port, 'POST', '/alpha/definitions/workflow/greeting/run', { input: { name: 'Ada' }, }); - const settled = await settledOver(port, `/alpha/executions/${executionIdIn(started.body)}`); + const settled = await settledOver(port, `/alpha/runs/${runIdIn(started.body)}`); const stopping = performance.now(); child.signal('SIGTERM'); @@ -47,7 +47,7 @@ describe('a server that ran workflows, told to stop', { timeout: workflowTestTim async (signal, exitedWith, brain) => { const gateway = await gatewayThatHangsFirst(); const first = workflowProcess(ledger.fileName, { MODEL_GATEWAYS: gateway.gateways }); - const executionId = await welcomingStarted(await first.port, brain); + const runId = await welcomingStarted(await first.port, brain); await gateway.firstHeard; const stopping = performance.now(); @@ -55,7 +55,7 @@ describe('a server that ran workflows, told to stop', { timeout: workflowTestTim const exitCode = await first.exited; const stoppedWithinMs = performance.now() - stopping; const second = workflowProcess(ledger.fileName, { MODEL_GATEWAYS: gateway.gateways }); - const settled = await settledOver(await second.port, `/${brain}/executions/${executionId}`); + const settled = await settledOver(await second.port, `/${brain}/runs/${runId}`); second.signal('SIGTERM'); expect(exitCode).toBe(exitedWith); diff --git a/packages/server/src/workflows/workflows.test.ts b/packages/server/src/workflows/workflows.test.ts index c10aa8fc7..4751b1e05 100644 --- a/packages/server/src/workflows/workflows.test.ts +++ b/packages/server/src/workflows/workflows.test.ts @@ -1,20 +1,20 @@ -import { makeReasoningFunctionAdapter } from '@beonauto/inference'; -import { scriptedLanguageModel } from '@beonauto/inference/testing'; +import { makeReasoningFunctionAdapter } from '@beonauto/reasoning'; +import { scriptedLanguageModel } from '@beonauto/reasoning/testing'; import { describe, expect, it } from 'vitest'; import { longestCallOf } from './workflows.ts'; -describe('the longest a nested execution of a workflow may run', () => { - it('is as long as its primitive states, 1660 s for inference, and a minute more', () => { - const inference = makeReasoningFunctionAdapter({ +describe('the longest a nested run of a workflow may run', () => { + it('is as long as its capability states, 1660 s for reasoning, and a minute more', () => { + const reasoning = makeReasoningFunctionAdapter({ languageModel: scriptedLanguageModel().languageModel, offered: { providers: [], aliases: [] }, }); - expect(longestCallOf([inference])).toBe(1_720_000); + expect(longestCallOf([reasoning])).toBe(1_720_000); }); - it('is the longest any primitive states, and a minute more', () => { - expect(longestCallOf([{ longestExecutionMs: 5000 }, { longestExecutionMs: 90_000 }])).toBe(150_000); + it('is the longest any capability states, and a minute more', () => { + expect(longestCallOf([{ longestAnyRunMs: 5000 }, { longestAnyRunMs: 90_000 }])).toBe(150_000); }); }); diff --git a/packages/server/src/workflows/workflows.ts b/packages/server/src/workflows/workflows.ts index d3ed0c10a..7a80aeec7 100644 --- a/packages/server/src/workflows/workflows.ts +++ b/packages/server/src/workflows/workflows.ts @@ -1,7 +1,7 @@ import type { AppRuntime } from '@beonauto/api'; +import { callMarginMs, defineSendRunEvent, makeWorkflowAdapter, runPresenter } from '@beonauto/coordination'; +import { defineStartVersion, type BrainOperation, type Capability } from '@beonauto/definitions'; import { makeCatalog, makeDispatcher, type DispatcherServices, type Registration } from '@beonauto/operations'; -import { callMarginMs, defineSendExecutionEvent, makeWorkflowAdapter, runPresenter } from '@beonauto/orchestration'; -import { defineStartVersion, type BrainOperation, type Primitive } from '@beonauto/specs'; import type { WorkflowHost } from '@beonauto/workflow-host'; import { Effect } from 'effect'; @@ -19,8 +19,8 @@ export interface WorkflowParts extends HostParts { readonly brainOperations: readonly BrainOperation[]; } -export function longestCallOf(primitives: readonly Pick[]): number { - return Math.max(0, ...primitives.map(({ longestExecutionMs }) => longestExecutionMs)) + callMarginMs; +export function longestCallOf(capabilities: readonly Pick[]): number { + return Math.max(0, ...capabilities.map(({ longestAnyRunMs }) => longestAnyRunMs)) + callMarginMs; } function onceOpened(opening: Promise): Pick { @@ -39,16 +39,16 @@ export async function serveWorkflows(runtime: AppRuntime, pa const workflow = makeWorkflowAdapter({ runs: onceOpened(opening.promise), mostDurationMs: parts.workflows.mostDurationMs, - longestCallMs: longestCallOf(parts.primitives), + longestCallMs: longestCallOf(parts.capabilities), }); - const primitives = [...parts.primitives, workflow]; - const host = await openedHost(runtime, dispatcher, { ...parts, primitives }, defineStartVersion(primitives)); + const capabilities = [...parts.capabilities, workflow]; + const host = await openedHost(runtime, dispatcher, { ...parts, capabilities }, defineStartVersion(capabilities)); opening.resolve(host); const catalog = makeCatalog([ ...parts.orgOperations, - ...brainOperationsServing(primitives, [runPresenter]), + ...brainOperationsServing(capabilities, [runPresenter]), ...parts.brainOperations, - defineSendExecutionEvent(host), + defineSendRunEvent(host), ]); - return { routes: routesFor(runtime, catalog, dispatcher, primitives), stopWork: host.stop }; + return { routes: routesFor(runtime, catalog, dispatcher, capabilities), stopWork: host.stop }; } diff --git a/packages/specs/README.md b/packages/specs/README.md deleted file mode 100644 index 0d018d214..000000000 --- a/packages/specs/README.md +++ /dev/null @@ -1,365 +0,0 @@ -# @beonauto/specs - -The definition and run operations of auto-brain: create, list, read, update, retire and run a brain's function and workflow definitions, then inspect their runs and history, and publish events to a brain. They are defined on the application layer, [`@beonauto/operations`](../operations). - -## Definitions, runs and runtime adapters - -`Definition` is a named, versioned definition stored in one brain. A reasoning function and a computation function use Markdown with YAML front matter; a workflow uses a YAML document. `ListedDefinition` is the same view without the source document. A `Run` executes a definition against particular inputs and records how it ended; `RunDetail` includes the detailed record. - -`BrainFunctionDefinition` covers the currently implemented `ReasoningFunctionDefinition`, `InteractionFunctionDefinition`, `ComputationFunctionDefinition` and `RecallFunctionDefinition`, whose `primitive` is `recollection` and which the words call a recall function; `WorkflowDefinition` identifies a stored workflow. `FunctionRun` and `WorkflowRun` identify their runs. The guards `isBrainFunctionDefinition`, `isWorkflowDefinition`, `isFunctionRun` and `isWorkflowRun` narrow decoded records by their `primitive` without copying or modifying them. Planned function kinds and custom adapters are not classified as implemented brain functions. The generic `Definition` and `Run` types still support extension adapters. - -These are stored records with names, versions and audit fields. The adapters' parsed source configurations use the separate names `ReasoningFunctionDefinitionDocument`, `ComputationFunctionDefinitionDocument` and `WorkflowDefinitionDocument`. - -`Primitive` is the low-level adapter contract shared by functions, workflows and custom extension adapters. The server supplies these adapters explicitly. It is deliberately broader than a brain function: workflows coordinate functions rather than belonging to the five function types. The product taxonomy and supporting assets are defined in [Brain terminology](../../docs/concepts/terminology.md). - -The API and the ledger call a saved definition a `spec`, its type a `primitive` and its recorded run an `execution`; the wire-format and storage details below use those words. - -## Defining a runtime adapter - -`definePrimitive` turns a definition into a `Primitive`. The server passes its primitives, in an explicit list, to `makeSpecOperations`. - -This extension interface accepts custom adapters. Product categories use the shared `BrainFunctionKind` metadata: `functionKindOrder`, `functionCategoryLabels`, `functionResourceLabels` and `functionDescriptions`. - -```ts -import { InvalidInput } from '@beonauto/operations'; -import { definePrimitive } from '@beonauto/specs'; -import { Effect, Predicate } from 'effect'; - -const parseGreeting = (source: string) => - source.includes('{name}') - ? Effect.succeed({ template: source.trim() }) - : Effect.fail( - new InvalidInput({ - detail: 'The greeting names no one', - issues: [{ detail: 'Line 1: expected {name} where the name goes', pointer: '' }], - }), - ); - -export const greeting = definePrimitive({ - name: 'greeting', - title: 'Greeting', - guide: { name: 'greeting' }, - noun: { one: 'greeting', other: 'greetings' }, - describeOutput: () => 'It greeted.', - mediaType: 'text/plain', - parse: parseGreeting, - summarize: ({ template }) => ({ - description: `Answers with "${template}"`, - inputSchema: { type: 'object', properties: { name: { type: 'string' } }, required: ['name'] }, - outputSchema: { type: 'string' }, - }), - execute: ({ template }, input) => - Predicate.hasProperty(input, 'name') && Predicate.isString(input.name) - ? Effect.succeed({ output: template.replace('{name}', input.name), record: { template } }) - : Effect.fail( - new InvalidInput({ - detail: 'The input names no one', - issues: [{ detail: 'Expected a string', pointer: '/name' }], - }), - ), -}); -``` - -A primitive has: - -- `name`: 3 to 32 lowercase letters, digits and hyphens, starting with a letter. It names the primitive in routes, in the `primitive` field and in stream names, so it never changes. -- `title`, and `guide`: the `name` of the guide that holds its format, which the MCP layer serves through `get_guide` and as the resource `guide://`, so that no operation description carries a format. The server makes the guide from the public reference page of the type, `docs/reference/-format.md`, and refuses to start without it. A guide may carry `onThisServer`, one sentence of what this server's configuration offers, such as the providers a reasoning function names its model through, which the guide ends with. `create_spec` names each type with the kind of definition it is and its guide, from the registered primitives. -- `mediaType`: the media type of its spec documents, such as `text/markdown`. -- `parse(source)`: turns the document into the primitive's own value. Parsing is validation: everything that can be checked without running is checked here. It fails with `InvalidInput`, whose issues each say in `detail` where in the document and what is wrong (line and problem). An issue's `pointer` addresses the document as a whole, so it is `''`; the operations answer it under `/source`. -- `summarize(parsed)`: what the operations show about a spec without knowing the primitive: an optional `description`, optional JSON Schemas of the input an execution takes (`inputSchema`) and the output it gives (`outputSchema`), `triggers`, the parsed triggers of a spec that starts runs on its own, as a workflow with a schedule does, in the order its document names them, each with its `kind`, `event`, `cron` or `every`, its `reference` in the document, and its rule: an event trigger's `filters`, each a `reference`, a `type` and the `attributes` it matches, a cron's `expression` or an every's `milliseconds` (`TriggerSchema`), and optional `warnings`: what `parse` found that does not stop the spec from being accepted but may not work everywhere, each a line of text that says where in the document (for inference: a schema some providers reject or do not enforce); and optional `details`, a JSON object the registry stores with the definition's record and keeps in its state, but never in a definition an operation answers, so that code which reads the record without the primitive's parser can act on it (for recollection: the fold, `answer`, the filters, `initial` and the view's schema, which the workflow host folds a view by). -- `execute(parsed, input, execution)`: runs the spec. `input` is the caller's JSON value; `execution` carries its `id`, the `org`, the `brain`, the `caller` who started it (the identity the call was authorized for), the `spec` that runs, by `name` and `version`, the `journal` through which it records the tool calls it makes (see [Tool calls](#tool-calls)), its `lineage`: `startId`, the id of the `execution_started` that began this attempt, and `correlationId`, the id of the run at the top of the tree the run belongs to (see [The lineage of a run](#the-lineage-of-a-run)), its `depth`, the reaction depth of the run (see [The reaction depth of a run](#the-reaction-depth-of-a-run)), its `callDepth`, the number of calls above it (see [The call depth of a run](#the-call-depth-of-a-run)), and `longestRunOf(primitive, name)`, the longest a run of the active latest version of that definition may take, or nothing for a definition the brain does not have, which a workflow reads at its start. It answers with the `output`, a JSON value returned to the caller, and a `record`, a JSON object of what happened, stored with the execution (for inference: the rendered prompt, the model, token usage). It fails with `InvalidInput`, with pointers into the input (`/name` above; the operations answer them under `/input`); with `Unavailable` when something the primitive depends on cannot serve now and retrying may work; or with `Conflict` when the spec cannot run as written, which only running it can tell (for inference: a model the provider does not have; for computation: a program that raised an error on the input), so the spec must be updated before it can run, with the kind `unworkable` when it says so. A rejection may carry a `record`, a JSON object of what the primitive did before it (for inference: the tokens and the duration of a model call whose answer it could not use), which the run keeps on its `execution_rejected`, held to the same size limit as any record. A primitive that starts work which finishes after the call returns, such as a workflow, answers `{ finishesLater: true, record }` instead, the record saying what it started (for a workflow: its run reference); see [Runs that finish later](#executions-that-finish-later). - -The compiler holds a primitive to its contract. The value `parse` gives is the value `summarize` and `execute` take. `parse` may fail only with `InvalidInput`, and `execute` only with `InvalidInput`, `Unavailable` or `Conflict` (the union `PrimitiveRejection`). Neither may ask for a service: whatever a primitive needs, such as a model client, it closes over when it is made. The output must be JSON and the record a JSON object. - -TypeScript infers the parsed value from `parse` when `parse` is a function declared elsewhere, as above, or an arrow function. Written inline as `Effect.fnUntraced(function* (source: string) {...})`, `parse` does not give its type to `summarize` and `execute`; declare it as a constant first. - -`parse` runs on every create, update and execution, and its parsed value is not kept, so it must give the same answer for the same document and be quick. A defect in `execute`, or an output that is not JSON, fails the execution. - -A call cancelled while `execute` runs, because its client went away or the server is stopping, stops `execute` and records the execution `failed`. A retry with its id can run it again only if no recorded tool call blocks another attempt; see [Run ids and retries](#execution-ids-and-retries). A primitive that defines `whenCancelled: 'finish'` is not stopped: the call waits for `execute` to end and records its answer. Recording the start and the end of an execution is never cut short. An abrupt process crash can still leave a run `started` when it prevents the ending from being recorded. - -## The operations - -`makeSpecOperations(primitives, presenters?)` returns the eleven operations for a catalog. All are brain operations, so their routes are relative to the brain. `defineCreateSpec`, `defineListSpecs`, `defineGetSpec`, `defineUpdateSpec`, `defineRetireSpec`, `defineExecuteSpec` and `defineListExecutions` make one of them each for a list of primitives, `getExecution` is another, `defineCancelExecution(primitives)` another, `defineGetExecutionHistory(presenters)` another, and `defineGetBrainAnalytics` the last, for a list of primitives too; `presenters` defaults to `makeSpecPresenters(primitives)` (see [Reading runs](#reading-executions)). The list must hold at least one primitive, and no two of the same name. - -| Operation | Kind | Route | Input | Answer | Rejections of the handler | -| ----------------------- | ------- | ---------------------------------------- | -------------------------------------------------------------------------------------- | ----------------------------------------------------- | ------------------------------------------------------- | -| `create_spec` | command | `POST /specs/{primitive}` | `primitive`, `name`, `source` | the spec, `201` | `not_found`, `invalid_input`, `conflict` | -| `list_specs` | query | `GET /specs/{primitive}` | `primitive`, `include_retired` (default `false`) | `{ specs }`, sorted by name, no documents | `not_found` | -| `get_spec` | query | `GET /specs/{primitive}/{name}` | `primitive`, `name` | the spec, active or retired, with document | `not_found` | -| `update_spec` | command | `PUT /specs/{primitive}/{name}` | `primitive`, `name`, `source` | the spec at its new version | `not_found`, `invalid_input`, `conflict` | -| `retire_spec` | command | `POST /specs/{primitive}/{name}/retire` | `primitive`, `name` | the spec | `not_found`, `conflict` | -| `execute_spec` | command | `POST /specs/{primitive}/{name}/execute` | `primitive`, `name`, `input` (any JSON, default `{}`), `execution_id` (optional UUID) | the execution | `not_found`, `conflict`, `invalid_input`, `unavailable` | -| `get_execution` | query | `GET /executions/{execution_id}` | `execution_id` | the execution, with its record | `not_found` | -| `cancel_execution` | command | `POST /executions/{execution_id}/cancel` | `execution_id`, `reason` (optional, 1 to 1,024 characters) | the execution as it stands | `not_found`, `conflict` | -| `list_executions` | query | `GET /executions` | `primitive`, `name`, `status`, `limit`, `cursor`, all optional | `{ executions, has_more, next_cursor }`, newest first | `invalid_input` | -| `get_execution_history` | query | `GET /executions/{execution_id}/history` | `execution_id`, `order` (default `asc`), `limit`, `cursor` | `{ events, has_more, next_cursor }` | `not_found`, `invalid_input` | -| `get_brain_analytics` | query | `GET /analytics` | `days` (7, 14 or 30, default 7), or `from` and `to`; `primitive`, `name`, all optional | the analytics of the brain | `invalid_input` | - -A spec carries `primitive`, `name`, `version`, `status` (`active` or `retired`), `media_type`, the `description`, `input_schema`, `output_schema` and `warnings` its primitive gives when it gives them, `created_at`, `created_by`, `updated_at`, `retired_at` on a retired spec, and its document as `source`. `get_spec` adds `standing` when its primitive's `standing` answers one (below). A listed spec is the same without `source` and `standing`. Times are ISO 8601 UTC strings read from Effect's `Clock`. `DefinitionSchema`, `ListedDefinitionSchema`, `RunSchema` and `RunDetailSchema` are the schemas. - -- `primitive` is the name of a primitive. The published JSON Schema of the field is a plain `{ "type": "string", "enum": [...], "description": ... }` of the known names. Decoding accepts any well-formed name, so a name the server does not know is `not_found` on every operation, and a malformed one `invalid_input`. Effect would publish the names under `allOf`, because it inlines no `enum` from a check, so each operation replaces that one property of its input's JSON Schema. -- `name` is 3 to 48 lowercase letters, digits and hyphens, starting with a letter: unique among the specs of the primitive in the brain, and never reused. -- `source` is at most 65536 bytes in UTF-8, checked at decoding. The JSON Schema says `maxLength: 65536`, which every such document meets. -- A document the primitive's `parse` rejects is `invalid_input` with the primitive's issues under `/source`, and nothing is stored. -- `create_spec` meets `conflict` when an active or a retired spec of the primitive holds the name. -- A spec with triggers is recorded with its `triggers` in the content of `spec_created` and `spec_updated`, which `get_spec` and `list_specs` show, and a brain holds at most 1,024 active specs of one primitive with triggers, however many each has, `mostReactingDefinitions`: a `create_spec` or `update_spec` that would make one more is `conflict`, counted after the version it replaces; `get_spec` of an active spec with triggers shows `triggers_since`, the id of the record that made its current version, from which what its triggers start is a run of that version. It is one id for the definition, while the workflow host keeps, for each trigger, the record that activated it as it is. -- `update_spec` replaces the document. Every update that changes the document makes a new version, one more than the last; an update with the same document succeeds and records nothing. It meets `not_found` for a missing spec and `conflict` for a retired one. -- `retire_spec` is permanent: a retired spec can be read and listed, but not updated, executed or recreated. Retiring a retired spec succeeds and records nothing. -- Every command also meets `conflict` when the specs of the primitive, or the execution, changed while it decided. -- `execute_spec` runs the active latest version of the spec. It meets `not_found` for a missing spec, and `conflict` for a retired spec, a spec whose stored document its primitive no longer parses, or a spec its primitive finds cannot run as written. - -The queries need `brain:read` and the commands `brain:write`. `execute_spec` is a command, because it records an execution, so a caller that may only read cannot execute a spec. - -## Runs - -An execution carries `execution_id`, `primitive`, `name`, `spec_version`, `status`, `output` when it succeeded, `rejection` (`reason`, `detail`, `issues` for `invalid_input`, for `unavailable` the `kind` and `because` the primitive gave, for `conflict` its `kind`, and for `cancelled` the kind of the cancel) when the primitive rejected it or the run was cancelled, `started_at`, `started_by`, and `finished_at` once it ended. `get_execution` also shows the `record` the primitive gave of what it did: of the run that succeeded, of what it did before it rejected the run when its rejection carries a `record`, such as the tokens a model call spent, or of the work it started that finishes later, kept when that work ends rejected or failed and dropped when a retry starts the execution again. `execute_spec` answers without the record, which can be large, since the output is what its caller asked for. Its status is `started` while it runs, while work it started finishes after the call returned, or when the process ended before it finished; then `succeeded`, `rejected` or `failed`. - -`execute_spec` answers with the execution when it succeeded, and in status `started` when the primitive started work that finishes later. When the primitive rejects it with `invalid_input`, `unavailable` or `conflict`, the operation is rejected with that reason, detail and issues, and the rejection is recorded on the execution. When the primitive breaks down, the call fails with an incident, as any defect does, and the execution is recorded as `failed`; the defect itself goes only to the incident reporter. A rejected operation carries no execution id, so a caller that wants to read a rejected execution later gives it an id. - -### Size limits - -The ledger's cloud store holds at most 2 MB in a row, so an execution records bounded values. Sizes are counted on the value encoded as JSON, in UTF-8 bytes. - -- The `input` may take at most 262144 bytes (256 KiB). A larger input is rejected with `invalid_input` at `/input` when the call is decoded, before anything is recorded. -- The `input` may nest at most 512 levels deep, `mostInputDepth`, as deep as a workflow holds a value; `nestsWithin` measures it, counting each array and object a level. A deeper input is rejected the same way, for every runtime adapter, so that none is recorded deeper than a store takes: on SQLite an input 3,000 levels deep failed to append, while PostgreSQL took it. -- The `output` and the `record` of a primitive may take at most 1048576 bytes (1 MiB) together. A primitive that answers with more breaks down: the call fails with an incident and the execution is recorded as `failed`. The same holds for the record of work that finishes later, and for the output and record it is settled with. - -JSON Schema has no keyword for the encoded size of any JSON value, so the published schemas state both limits in the descriptions of `input` and `output`. `mostInputBytes` and `mostResultBytes` export them, so that a primitive can keep what it answers within them. - -### Run ids and retries - -A caller may name an execution with `execution_id`, a UUID; otherwise the operation makes one, a version 7 UUID. Ids are kept in lowercase. An id belongs to one execution: one primitive, one spec and one input. A call with an id of another spec or another input meets `conflict`. - -An execution has a **final result** once it succeeded, once the primitive rejected its input as invalid, once it was cancelled, or once it ended `unanswered`, the request it made expired or never delivered. A call with the id of an execution that has a final result runs nothing: it answers the same execution, or the same `invalid_input` rejection, even when the spec has changed or been retired since. A call with the id of an execution that waits for work it started to end runs nothing either: it answers the execution as it stands, `started`. A call with the id of an execution that has no final result and waits for nothing runs the active latest version of the spec again and records another attempt: when the execution started within its call and never finished because the process ended, when its call was cancelled, when the primitive was unavailable or found a conflict (a retry after the spec was updated runs the new version), and when it failed. - -An execution that called tools is the exception, because a tool may have changed something: once its stream holds a tool call, a call with its id that would run it again is rejected with `conflict`, kind `tools_called`, and runs nothing, whether the execution failed, was rejected as `unavailable` or with a `conflict`, or stays `started` because the process ended; it is never recorded as finished for it, since that could mark a duplicate still running elsewhere as failed. So is a call with the id of a started execution whose spec calls tools before any call is recorded: the first attempt may still be in progress, about to call a tool, or may have stopped without recording how it ended, and running a second would let two runs call tools. The primitive tells from the parsed spec whether it calls tools (`callsTools`), and the start of a run records it on its `execution_started` as `calls_tools: true`; a start is refused while the attempt started last is running and either recorded that it calls tools or the spec calls them now, so a spec that loses its tools while a run of it is going cannot let a second run start under its id. A new run needs another id. An execution that called tools and succeeded, or whose input was rejected, is answered again as any other. - -So execution is **at least once**: the primitive may run more than once for one id, when a call is retried after the server stopped during a run, after `unavailable`, a `conflict` the primitive found, or a failure, or when two calls with the same id run at the same moment and the ledger lets both start. Each id has **exactly one recorded result**: the first final result recorded for it is never replaced, and every later call with the id answers it. A primitive that acts on the world, such as one that sends a message, must tolerate running twice for the same `execution.id`. - -### Runs that finish later - -A primitive may state `mostActive`, the most definitions of its type a brain may keep active, counted by the registry at each save: a creation that would pass it, and an update while the brain keeps more than it, as when the bound was lowered, is `conflict`, and nothing else changes; a retirement is always taken. It is unbounded unless given; recollection takes it from a setting of the server. A primitive may state `standing({ org, brain, name, version, status })`, which `get_spec` asks for the definition it reads and answers as `standing` when it gives one, so a primitive that keeps something for a definition beside the ledger, as recollection keeps a view, says how it stands; without it a definition has none. - -A primitive may state `longestExecutionMs`, the longest one execution may legitimately take (for inference: the deadline of a model call for the most output tokens, 60 seconds and 25 ms a token, 1660000 ms for 64000), and `longestRunOf(parsed)`, the longest a run of one definition may take (for inference: the larger of the bound of its tool loop and its model's deadline; for a workflow: the longest a workflow may run), which the prepared definition carries as `longestRunMs`; a primitive that states neither is given 10 minutes. A workflow gives a call of a definition it names as written that long, and a minute more, before the call fails, and any other call the longest `longestExecutionMs` of the primitives, and a minute more. A primitive states `reachesOutside: true` when its executions call systems outside the server, as inference calls model providers; `execute_spec` then says it reaches outside, which its MCP tool shows as `openWorldHint`. A primitive states `mayChangeOutside: true` when those calls may change something there, as inference does once an MCP server is configured; `execute_spec` then says so, which its MCP tool shows as `destructiveHint`, so that an assistant asks before running a spec. - -A primitive such as a workflow starts work that completes long after the call returns. It states `finishesLater: true`, or a function of the parsed definition when only some of its definitions finish later, as an interaction function's notification to the inbox ends within its call while every other request waits for an answer; the prepared definition carries the answer as `finishesLater`, which the start of each run records as `finishes_later`, and its `execute` answers `{ finishesLater: true, record }`, the record saying what it started. Such a primitive defines `whenCancelled: 'finish'`, so that a call cancelled while `execute` runs, because its client went away or the server is stopping, waits for `execute` to end and records what it started; `execute` must then end within a bounded time (a workflow's start gives up after 10 seconds). The execution is recorded as deferred and stays `started`: `execute_spec` answers with it in status `started` (still `200`), `get_execution` shows it `started` until it is settled, and a retry with its id answers it as it stands without starting the work again. - -Whoever started the work settles the execution when the work ends, with `executionSettler`: - -```ts -import { executionSettler, type SettleExecution } from '@beonauto/specs'; - -const settle: SettleExecution = executionSettler(ledger); - -settle(execution, { status: 'succeeded', output, record }); -settle(execution, { status: 'rejected', reason: 'unavailable', detail: 'The worker pool is gone' }); -settle(execution, { status: 'failed' }); -``` - -`executionSettler(ledger)` takes the unbound `Ledger` and gives a `SettleExecution`, which takes, after the execution and the settlement, the lineage to record the settlement with. It is not an operation and no transport reaches it: the server's composition root, the only code that holds the `Ledger`, makes it and hands it to the primitives that finish later when it makes them. Each call to `settle` names the execution by `org`, `brain` and `id` (the `RunContext` a primitive got carries all three), and binds the ledger to that org and brain alone, through `streamPrefixOfBrain`, after checking the ids are well formed, so it reaches nothing but that brain's `executions/{id}` stream. It records through the same stream and decider as `execute_spec`: - -- an execution whose start says it finishes later, or that was deferred, and that has not been settled is settled: `succeeded` with its output and record, `rejected` with its reason, detail, kind, because, issues and record as given, `unanswered` with the kind `expired` or `undelivered` among the reasons, or `failed` with its incident; so a run that ends in its first input settles before its deferral is recorded, and the finish of the call that started it then records no deferral; -- a settlement has a key, its status, reason and kind, and for a success the SHA-256 digest of its output as canonical JSON: settling it again with the same key, from any actor, records nothing and answers the execution; -- settling an execution that already ended with another key, or one that runs within its call, is `conflict`; -- once a reply brought back an answer, the run's `broughtAnswer`, settling it with anything but a success whose output is that answer is `conflict` (`answeredByAReply`), decided with the run's state at the append, so an answer given meanwhile, or a cancel, cannot settle it otherwise however the reads and the reply interleave; -- settling one the brain does not have, or an ill-formed address, is `not_found`; -- an output and record over the size limit, or not JSON, settle it as `failed`, and the call dies with the defect. - -`brainBoundSettler(writer)` settles the same way through a writer already bound to one brain, as an operation's `StreamWriter` is, so `answer_interaction` settles the run it answers with the caller's ledger and names it by its id alone. - -The settlement is recorded as done by the actor it names, `by`, or else by the brain's own caller, `brain:`. A deferred execution settled as `unavailable` or `failed` has no final result, so a call with its id runs it again, as any other. A call with the id of a run whose start says it finishes later but that recorded no deferral, because the process ended in between, starts it again rather than answering it, so such a run is never left waiting for nothing. - -A deferred execution takes the facts of its own work until it is settled, tool calls and deliveries among them (see [Outbound calls](#outbound-calls)), and refuses them after, with words that say the run has ended. Its state keeps the number of its last call, which the decider gives each call as it appends its start, and whether any call may have changed something outside, which only a tool call of a reasoning run sets. The deferral names the definition that ran, `primitive`, `name` and `spec_version`, as the endings do, so the projection of open requests reads what asked from the deferral alone. - -### The kind of a conflict - -A run rejected with `conflict` records the kind the primitive gave, so `get_execution`, `list_executions`, the history and the run's words all show it. `unworkable` says the spec cannot run as written for this input, which only changing the spec or the input puts right: a computation function's program that raised an error, gave no output or more than one, did more work or nested deeper than a run may, or answered what the output schema refuses; `execute_spec` also answers it, without recording a run, for a stored spec its primitive no longer parses. `tools_called` says a run of a function that may have called tools is not run again under its id (see [Run ids and retries](#execution-ids-and-retries)). `@beonauto/specs/testing` has a probe whose mishap `unworkable` rejects that way. - -## Reading function documents - -`@beonauto/specs/document` is the reader that every primitive whose document is YAML front matter and a body shares, so each primitive supplies only its own keys and what its body means: - -- `splitDocument(source, definition)` splits the front matter between two lines of three dashes from the body, with the line each starts on; `definition` names the document in the message for a source that does not start with the dashes. -- `frontMatterIn(text, firstLine, shape)` reads the front matter with the YAML rules every primitive keeps (YAML 1.2's core schema, strictly, with no anchor, alias or tag and at most 72 levels of nesting) and decodes it with the primitive's `FrontMatterShape`: its `sections`, the keys allowed at the top and under each section, of which any other is an issue at its line; its `decode`; and `required`, which the message for empty front matter names, such as `the model` for inference and `the language` for computation. -- `compileJsonSchema(document, { what, nesting })` checks a JSON Schema's shape, size, references and loops, and compiles a validator that answers issues with JSON pointers; `what` names the schema in its issues and `nesting` bounds the values it validates. `jsonSchemaLimits` are its bounds, `boundedJsonSchema` its first check alone, of the size and nesting of a schema, which inference also runs on the input schema of a tool, and `shapeIssues`, `isKnownKeyword` and the helpers of `json-bounds.ts` the checks it is built from. -- `issueAt` places an issue at the line of the deepest part of its JSON pointer that the front matter has, `issueText` writes it as `Line 7, /input/schema: ...`, or `Line 7: ...` for the document as a whole, and `reportedIssues` sorts a document's issues by line and bounds how many it reports. - -Inference reads its documents with it and keeps its own keys, messages and tests; computation reads its own with the same reader. The subpath is separate from the main entry because it imports the YAML parser, which nothing else in this package needs. - -## Templates of a capability - -`@beonauto/specs/template` is the Liquid engine every capability whose documents hold templates shares, as a factory that gives each capability an instance of its own, so what one registers no other sees. `templateEngine(registrations?)` makes one, with the filters and tags every instance keeps, strict filters and variables, own properties only, and the bounds of `templateLimits`, 64 KiB of template, 1,000 names, 200 ms and 5 MB of a render, and with the filters and tags the capability registers, given as a function that answers them: the reasoning function adapter registers its system block and its prompt filters, `money`, `clip` and `words`, and the interaction capability nothing. `parsedTemplate(engine, body, firstLine)` parses a template, refusing one that does not parse with the line of its issue, and answers it with the variables it reads, each with its line in the document, and an issue beside it when it uses more names than a template may. `renderedTemplate(engine, parsed, { variables, emitter, refusalOf })` renders it with the variables it is given into an emitter of the capability's own, which decides what a written value becomes and may refuse one, and answers a missing variable, a bound of the render or a failed filter, with its line, or what `refusalOf` makes of what the emitter threw. `inputVariableIssues(variables, inputSchema, what)` refuses a template that reads anything but `input`, `today` and `now`, or a property a closed input schema does not have, in the words of `what` the template is. `outputText` writes a value as Liquid writes it, an object as `[object Object]`; a rendered value is text, never parsed again as a template. - -The subpath is separate from the main entry because it imports `liquidjs`, which nothing else in this package needs. - -`@beonauto/specs/json-schema` exports, without the YAML reader, `compileJsonSchema`, `schemaCheckOf(schema, { what, nesting })`, a check of a value that names at most three of its issues, `issuesDetail(issues, what)`, the one wording of them, each at its pointer or as the output or the view itself at the root, and `checkedWorker`, the URL of the one worker module that checks values against their schemas where they were computed, for computation and recall functions alike (`src/checking`). The checked worker serves its jobs with `serveJobs` of `@beonauto/workflow-engine/job-loop`: a program with `answerOf` under the output check of the schema its request carries as its context, and a page of folds with `foldAnswerOf` under the view check of each view's schema. Both checks are `schemaCheckOf` at the value depth of 512, the view check words its issues with `issuesDetail`, and the job loop compiles each once for each schema and keeps it while the worker is warm. The computation and recollection primitives name it for every run, the folds of views among them, with a `null` context when there is no output schema, so production runs one worker module. The entry leaves out the YAML reader because the reader's modules and the YAML parser had taken a worker from 138 ms to 254 ms to load, measured on a busy machine, a cost now paid once for each worker the pool starts rather than for each run. - -## Reading runs - -`list_executions` lists the executions of a brain, newest first by the position of the first message of each execution stream, so an execution started again with the same id keeps the place of its first start, and two started in the same millisecond keep a fixed order. It reads the ledger's selection of the first and the latest message of every execution stream (`BrainReader.readRecorded({ kind: 'executions', notBeginningWith: ['execution_cancel_requested'], primitive?, name? }, page)`), which leaves out a stream that begins with its caller's cancel, since such a stream holds no run, before the page counts, so a page is full while runs remain and `has_more` is false after the last. `status` is the page's `types`, the stored types of a run's latest message in that status (`storedTypesByStatus`), and `primitive` and `name` go to the ledger as they are, which reads them from the run's first start, `execution_started`, so the store answers every filter as it examines the runs, up to 1,000 of them for a page with any filter, and a page of the runs of one definition is full while it finds them; a run started again under its id is always started for the same definition, so its first start names the definition of every start. The operation folds the two with the execution decider's `evolve`, so a `ListedRun` is the run as `get_execution` shows it, without its `output`, its `record` and the `detail` and `issues` of a rejection: `execution_id`, `primitive`, `name`, `spec_version`, `status`, `started_at`, `started_by`, `finished_at`, and a `rejection` of `reason`, with the `kind` and `because` of `unavailable` and the `kind` of `conflict`. When the latest message is a start, the execution shows that start; when the execution was started again and has since finished, it shows its first start, since the selection holds no other, while `get_execution` shows the latest. - -- `status` is answered by the ledger from the stored type of the latest message: `storedTypesByStatus` in `src/reading/execution-status.ts` is the one place that maps a status to the stored types it stands for, `started` to `execution_started`, `execution_deferred`, a cancel and the facts of the run's work, its tool calls and deliveries. -- `primitive` and `name` are applied after decoding the first message of each execution the page looked at, so a filter that matches rarely answers short or empty pages with `next_cursor`. A primitive the server does not offer lists the executions recorded under it, if any. -- `limit` is 1 to 100, 20 when left out. A page also ends at 4 MiB of stored data and, with `status`, after looking at 1,000 executions. Every page carries `has_more` and `next_cursor`, null when nothing remains, and a cursor that does not decode, or that another brain gave, is `invalid_input` at `/cursor`. - -`get_execution_history` reads the two streams of one execution through the ledger's run selection, `executions/{execution_id}` and, for a workflow's run log, `runs/{execution_id}`, one page at a time, oldest first unless `order` is `desc`. Each record is shown through the presenter of its stream kind, as none, one or more events, and hidden when its kind has none; the server gives the presenter of the run log, `runPresenter` of `@beonauto/orchestration`, so a workflow's history shows a `workflow_input_applied` event for each input its run took, followed by an event for each step entry of that input. `limit` counts the events a page answers with, step events included, through `eventsPageOf` of `@beonauto/operations`, so a page may end inside the events of one record, with a `next_cursor` that reads on from the next of them. The events follow the ledger's order, within a page and across pages, so reading on from the cursor of any event answers exactly the events after it; it is also the order of their causes, since a message is appended only after the message that caused it, as a child's start after the record of the step that waits for it. An execution the brain does not have is `not_found`. A page that holds a record other than a cancel proves the execution exists, and the read is that page alone, however long the execution's streams. A page that holds nothing but `execution_cancel_requested`, or nothing, reads one more record, the newest of the two streams, newest first and without its data: none, or a cancel alone at the first place of its stream, which is all a stream its caller cancelled before the start ever holds, is `not_found`, and anything else is the execution, whose page is answered. That read keeps no horizon, since on PostgreSQL only a read oldest first stays behind the oldest write still open in the ledger's database. So the first page of an execution whose records are still behind that horizon is empty, with `has_more` false and `next_cursor` null, and a reader reads it again; and no page loads the execution's whole stream, which has no bound on its size. - -Each event is a `PublicEvent`, `{ id, cursor, causation_id, at, type, summary, data }`: `id` is the id of the message the event presents, or for a step event the id `stepEventIdOf` of `@beonauto/workflow-engine` derives from its entry, `cursor` the place to read on from, the record's cursor, or for an event of a workflow's record a cursor inside it, `causation_id` the id of the message or step event that directly caused it, or null, `at` the event's own time, `type` a public name, `summary` plain words with no ids, and `data` at most 4 KiB as JSON. `makeSpecPresenters(primitives)` gives the presenters of the four stream kinds this package owns, which the server also passes to the feed of the brain, `list_brain_events` of `@beonauto/brains`: - -| Stream kind | Stored type and public name | `data` | -| ------------ | ------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| `executions` | `execution_started` | `execution_id`, `by`, `primitive`, `name`, `spec_version`, `input_bytes` | -| `executions` | `execution_succeeded` | `execution_id`, `by`, `output_bytes`, `record_bytes` | -| `executions` | `execution_rejected` | `execution_id`, `by`, `reason`, `detail` cut at 1024 bytes, `kind` and `because` when given, for `invalid_input` `issue_count` and the first five `issues`, and `record_bytes` when the rejection kept a record | -| `executions` | `execution_failed` | `execution_id`, `by` | -| `executions` | `tool_call_started` | `execution_id`, `by`, `number`, `call_id`, `server`, `tool`, `arguments_bytes`, `arguments_sha256`, and `arguments_json` cut at 2048 bytes when recorded | -| `executions` | `tool_call_answered` | `execution_id`, `by`, `number`, `outcome`, `result_bytes`, `result_sha256`, `duration_ms`, `jsonrpc_id`, `server_request_id` when recorded, and `result_json` cut at 2048 bytes when recorded | -| `executions` | `execution_deferred` | `execution_id`, `by`, `record_bytes`, and what the run's capability shows of the record, when it gives words for it | -| `executions` | `delivery_started` | `execution_id`, `by`, `number`, `delivery` with its `server` and `tool`, `target` cut at 256 bytes, `arguments_bytes`, `arguments_sha256`, and `arguments_json` cut at 2048 bytes when recorded | -| `executions` | `delivery_ended` | `execution_id`, `by`, `number`, `outcome`, `because` and `retry_after_ms` when recorded, `detail` cut at 1024 bytes, the call's fields when a call was answered, `delivered_as`, `replies_in`, `duration_ms` | -| `executions` | `reply_taken`, `reply_refused` | `execution_id`, `by`, `reading` with its `server` and `tool`, the `reply`'s `id` and `sender`, and for a refusal `because`, `told` and the issues, never the reply's words | -| `tool-tests` | `tool_test_started` | `test_id`, `by`, `server`, `tool`, `arguments_bytes`, `arguments_sha256`, and `arguments_json` cut at 2048 bytes when recorded; `toolTestPresenter` of `@beonauto/mcp` presents both, and the server registers it beside these | -| `tool-tests` | `tool_test_answered` | `test_id`, `by`, `outcome`, `result_bytes`, `result_sha256`, `duration_ms`, `jsonrpc_id`, `server_request_id` when recorded, and `result_json` cut at 2048 bytes when recorded | -| `specs` | `spec_created`, `spec_updated` | `primitive`, `name`, `by`, `version`, `source_bytes`, the first 300 characters of `description`, `input_schema_bytes`, `output_schema_bytes`, `warning_count` | -| `specs` | `spec_retired` | `primitive`, `name`, `by` | -| `events` | `event_published` | `event_id` and `event_type` cut at 256 bytes, `source` and `subject` cut at 1024, `time`, `data_bytes` when it has data, the attributes the brain `filled`, for an event a workflow emitted `emitted_by` and `depth`, `by` | -| `reactions` | `reaction_refused` | `workflow` cut at 256 bytes, `count`, the last `reason` cut at 1024, and the `minute` it counts; the workflow host records it (`ReactionRefusedSchema`, `reactionsStreamKind`) | - -The execution presenter asks the run's capability, by its `primitive`, for the words of its deferral and its work, `runWords`: `deferral(record)` answers the summary and the fields of the record it shows, or nothing, under the public type `deferralType`, `execution_deferred` unless the capability names its own, as an interaction function names `interaction_requested`, and `delivery(fact)` the words of an attempt, which `defaultRunWords` gives every capability and a capability the server no longer has. A capability that gives no words for its deferral, as a workflow gives none, shows it as no event: it stays on the execution's stream, where `status` and a retry read it, and nothing names it as its cause, so a history goes on from a start to what the run did next, the inputs and steps of a workflow directly. An interaction function's deferral is its request, shown as “waiting for an answer”. A delivery's words name the tool it calls and say why an attempt failed in words, `deliveryEnded`. Sizes are of the value as JSON in UTF-8, the document's of its own text. A cut is made at a code point, measured as JSON so that escapes count, and `by` is cut at 256 bytes; an issue's `detail` at 256 bytes and its `pointer` at 128; a tool call's `call_id`, `server`, `tool` and `server_request_id` at 256 bytes, its digests and a `jsonrpc_id` that is text at 128. The latest message of a running execution may be a tool event, which `list_executions` shows as `started`. A test over the event schemas of both deciders holds every stored type to a decision of its presenter, and the largest record each type can hold to 4 KiB of `data`. - -## Storage - -The specs of one primitive in a brain are one stream, named `specs/{primitive}` relative to the brain. Its events carry a `type`, the spec `name`, who recorded them (`by`) and when (`at`): - -- `spec_created`, with `version` 1 and the `content`: the `source` and the `description`, `input_schema`, `output_schema`, `warnings` and `details` its primitive gave -- `spec_updated`, with the new `version` and the whole new `content` -- `spec_retired` - -`SpecEventSchema`, the schema of these events, and `specsStreamOf(primitive)`, the name of the stream relative to its brain, are exported, so code that holds the ledger's store, as the workflow host does, reads a brain's definitions of one type, and their `details`, without the operations. - -Each execution is a stream of its own, named `executions/{execution_id}` relative to the brain. Its events carry a `type`, who and when: - -- `execution_started`, with the `primitive`, the spec `name`, the `spec_version` and the `input`, `calls_tools: true` when its spec calls tools, `depth` when the run's reaction depth is above 0, and `trigger`, the kind and reference of the trigger that started the run, which every ending copies; a retry records it again -- `execution_deferred`, with the `record` of the work that finishes later and the definition that ran; an execution that started and never finished has no such event, which is how a retry tells the two apart -- `execution_succeeded`, with the `output` and the primitive's `record` -- `execution_rejected`, with the `rejection`, and the `record` its rejection carried, when it carried one -- `execution_failed` -- `tool_call_started` and `tool_call_answered`, for each tool call it makes, described under [Tool calls](#tool-calls) -- `delivery_started` and `delivery_ended`, for each delivery a run that finishes later makes, described under [Outbound calls](#outbound-calls) - -Each of the three endings also carries the `primitive`, the spec `name`, the `spec_version` and the `depth` of the attempt it ends, which the decider takes from the run's latest start, so an ending says what ran without a read of the start. - -### Cancelling a run - -`cancel_execution` records `execution_cancel_requested` on the run's stream, with the kind `requested`, the caller as `by`, and the `reason`, or words that name the caller: the request is a fact, so it lands on any server and outlasts a crash. It answers the run as it stands. A run that has ended is `conflict`, and so is a run that runs within its call, since no server can interrupt another request's fiber; a run whose start says it finishes later may be cancelled before its deferral is recorded. Asking again before the run ended records nothing more. The workflow host's follower turns the fact into the effect: for a workflow, the engine's input `cancel_requested`; for a run of another primitive that finishes later, `deferredCanceller(primitives, ledger)`, which settles the run with what the primitive's `cancel(run)` answers, a pure decision over the run's record, its kind, its reason, the answer a reply brought back, `broughtAnswer`, when a delivery delivered it, `deliveredAt`, and the run's address, `execution`, `cancelledAsAsked` unless the primitive states one, and settles a run whose `cancel` throws as `failed`; it settles against the version of the run it read (`executionSettlerAsRead`), so a fact recorded since, such as the end of a delivery with an answer or delivered, refuses the settlement as `concurrent_change`, and it reads the run again and decides once more, up to three times more. `executionCanceller(ledger)` records the cancels the host makes of the runs its calls wait for, with the kind `deadline` or `parent_ended`, answering `requested`, `ended`, or `unknown_run` for an address a brain cannot hold. A cancel from the caller is a fact whatever state the run is in: on a stream with no run yet it is the first record, without a definition, and a start that lands after it, of either kind, is refused with `cancelled` and its kind, so the ledger orders the cancel and the start, and the stream still holds no run, so `list_executions` leaves it out and `get_execution` and `get_execution_history` answer `not_found`; on a run within its call it is recorded, and the attempt its interrupt stops ends `rejected` as `cancelled` with that kind rather than `failed`. `cancel_execution` keeps answering `not_found` for a run the brain does not have, and `conflict` for a run within its call. - -A cancelled run ends `rejected` with the reason `cancelled` and its kind, `requested`, `deadline`, `overrun` or `parent_ended`; `RunCancelled` of `@beonauto/operations` is its error, and its problem type `https://on.auto/problems/cancelled`. - -### The call depth of a run - -A run that a workflow's call starts carries, in `CallLineage`, the number of calls above it, `callDepth`, and the call it answers, `calledBy`: the workflow's execution id, the call's reference and its run. `execution_started` records them as `call_depth` and `called_by`, and every finish of the attempt copies them from the start, so the ending of the run names the call it answers. A start more than 8 calls below the run at the top of its tree, `mostCallDepth`, is refused with `conflict` before anything is recorded. - -### The reaction depth of a run - -A run started in the process by another run or by a reaction carries a reaction depth, which the brain request gives the start path in `CallLineage` beside the lineage and never in an input. `execution_started` records it as `depth` when it is above 0, every ending of the attempt records it again from the start, and the run's context gives it to `execute`. The workflow host bounds a chain of reactions by it (`@beonauto/workflow-host`). - -### The trigger of a run - -A run a workflow's trigger started carries that trigger in `CallLineage`, as the brain request gives it: its `kind`, `event`, `cron` or `every`, and its `reference`, the place in the document that names it. `execution_started` records it as `trigger`, every ending of the attempt copies it, and `brainFactOf` puts it in the fact's data, so a trigger filter can test `.trigger.kind`. The history says in words which kind started the run, "was started by its event trigger", "by its cron schedule" or "by its every schedule", and gives the trigger in its data; the words never name the reference. `started_by` stays the actor, `brain:`, since who acts is not what fired. - -### Starting one version once - -`defineStartVersion(primitives)` is `start_definition_version`, a brain command that no catalog serves: the workflow host calls it in the process, through the dispatcher, for the runs reactions start, as the brain's own caller (`brainCallerOf` of `@beonauto/operations`), with the run's depth and lineage in the request. Its input names a `primitive`, a `name`, a `version`, an `input` and an `execution_id`. Its start only creates: the decider records it when the brain has no run under that id, or when the run under it ended without a final result, failed or rejected for any reason but `invalid_input`, as with `unavailable` or `conflict`, without tool calls, and was asked the same, so a start the brain could not take at first goes through on a later delivery; for a run that goes, or that ended with a result, or that another request ended, it refuses with `conflict` of the kind `taken` (`runTaken`), and the command answers the run as it stands and runs nothing, whatever it was asked, also when a second start races it. Otherwise it runs that version of the spec, found in the spec's stream even after a later version or its retirement, and answers as `execute_spec` does. A version the spec never had is `not_found`. - -### The lineage of a run - -Every event of an execution is written with its cause and its correlation (see the ledger port of `@beonauto/operations`). The correlation of a run that no other run started is its own execution id; a run started by another run, as a workflow starts the function it calls, is given a lineage with its request, which the start path takes from `CallLineage` and passes to `execute`, and every event of the run takes the correlation of that lineage. The `execute_spec` input never carries one: its decoding refuses a field it does not know. The causes: - -| Event | Caused by | -| ---------------------------- | ---------------------------------------------------------------------------------------------------------- | -| `execution_started` | nothing for a run no other run started; the cause its lineage gives, the waiting step of a call, otherwise | -| `execution_deferred` | the `execution_started` of the attempt, whose position the start path keeps from what `execute` answers | -| `execution_cancel_requested` | nothing; its correlation is the one the run's first record has | -| `tool_call_started` | the `tool_call_answered` before it, the first by the start | -| `tool_call_answered` | its `tool_call_started`, or the answer before it for a call the journal has no start of | -| the finish of a run | its last `tool_call_answered`, or its start when it called no tool | -| a settlement | the cause the settler is given: for a workflow, the last step event of the record that ended its run | - -There is no read model of the specs or of a run: each call folds the streams it needs. The outcomes of runs, which `get_brain_analytics` reads, are a table the ledger keeps from these events (see [Analytics](#analytics)). Pure deciders hold the rules: one per primitive's specs, and one for executions. The handlers pass them who and when in each command. - -## Analytics - -`defineGetBrainAnalytics(primitives)` makes `get_brain_analytics`, `GET /analytics` relative to the brain, under `brain:read`. It reads the outcomes of the brain's runs over a window of days in UTC, through `BrainReader.readRunOutcomes` (see [`@beonauto/operations`](../operations/README.md#the-outcomes-of-runs)), one statement of the store: - -- the window: `days`, 7, 14 or 30, the last days ending today, 7 when nothing is given; or `from` and `to`, both included, at most 366 days, with `to` not before `from` and not after today. A day must name the same day when read back, so `2026-02-30` is refused at `/from` by the input schema. `days` with `from` or `to` is `invalid_input` at `/days`, `from` without `to` at `/to` and `to` without `from` at `/from`, a window that ends before it starts or after today at `/to`, and one longer than 366 days at `/from`. An unknown `days` or parameter is refused as every input is. -- `primitive` and `name`, as `list_executions` takes them, keep the runs of that API type identifier and definition name. -- The answer: `days`; `runs`, with the `total`, `succeeded`, `failed` and `rejected`; `tokens`, the `input`, `output` and `cached` tokens; `duration_ms`, with `p50` and `p95`, or `null`; `by_day`, the `day` and the same three for every day of the window, oldest first, a day without runs included; and `by_function`, each definition's `primitive`, `name` and `runs`, the number of its runs that ended, the most runs first, then by `primitive` and `name` in the order of their characters. -- A run counts on the day it first started, once it has ended; a run still `started` counts nowhere. Its duration runs from its latest start to its end, for a run that succeeded or failed, workflows included; a rejected run counts in `runs` and in `tokens`, never in `duration_ms`. A percentile is the nearest rank, the duration at place ⌈p × n⌉ of the durations in order; the operation adds the groups the ledger answers and takes the percentiles in code. Tokens sum what was recorded, over every attempt of a run started again under its id, 0 otherwise, and `cached` is the cache-read part of `input`. - -`runOutcomeMapping` is the `RunOutcomeMapping` of the run events, which the server gives the ledger at composition. It reads `execution_started`, `execution_succeeded`, `execution_failed` and `execution_rejected`, and leaves the row as it is for anything it does not understand: - -| Field | From | -| ---------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `startedDay`, `startedAt`, `primitive`, `name` | the first `execution_started` of the run, its `at` and its UTC date; a finish without a start gives its own `at` and empty names | -| `lastStartedAt` | the `at` of the latest `execution_started` | -| `status` | `started` from every start, then `succeeded`, `failed` or `rejected` from the finish | -| `durationMs` | the finish's `at` less `lastStartedAt` for a run that succeeded or failed, never below 0; `null` for a rejected run, a run still started, or a finish without a start | -| the tokens | the sums of `record.usage.input.total`, `record.usage.output.total` and `record.usage.input.cache_read` over every finish of the run, each counted when it is a whole number of at least 0; `null` while no finish gave one, so a run rejected after its model spent tokens and then started again keeps them | - -`measure/measure-outcomes.ts` measures the ledger's table of run outcomes kept with this mapping, on both stores: the read, the append with and without it, and the fill of a new table version (`pnpm --filter @beonauto/specs measure:outcomes`). It lives here with the mapping rather than in the ledger, so the ledger depends on nothing in this package; [the ledger's README](../ledger/README.md#measurement-of-the-outcomes) holds the figures. - -## Events of a brain - -A brain takes events from outside and records facts of its own, both in the shape of [CloudEvents 1.0](https://github.com/cloudevents/spec/blob/v1.0.2/cloudevents/spec.md). `CloudEventSchema` is an event as the brain keeps it: `specversion` `1.0`, `id`, `source`, a URI reference that is not empty, `type` and `time` in RFC 3339, and optionally `subject`, `datacontenttype`, a media type such as `text/plain; charset=utf-8`, `dataschema`, an absolute URI, and `data`, any JSON value that nests at most 510 levels deep, `mostEventDataDepth`, so that a run can hold the whole event in a list, as an input or as what a `listen` task gives; deeper data is `invalid_input` at `/event/data`. Any other attribute is an extension, at most 32 of them, named in 1 to 20 lowercase letters and digits, as CloudEvents recommends, whose value is text, a boolean or an integer from -2147483648 to 2147483647, kept as given. `id` and `type` take at most 256 characters, `source`, `subject` and `dataschema` at most 1,024, and a time at most nine digits of a fraction of a second. As CloudEvents requires, no text holds a control character (U+0000 to U+001F, U+007F to U+009F), a surrogate that is not one of a pair, or a noncharacter, `refusingForbiddenCharacters`; `id`, `type` and `subject` hold a character that is not a space, `refusingBlankText`, both of which `send_execution_event` applies too, as it takes a `source` only as `EventSourceSchema`, the URI reference of a published event; and a time's second is 60 only in the last minute of a day in UTC (`events/event-time.ts`). - -### Publishing an event - -`publishEvent` is `publish_event`, a brain command at `POST /events` under `brain:write`, which the server serves beside the operations of `makeSpecOperations`. Its input is `event`, a CloudEvent whose `specversion`, `id` and `time` may be left out, and it answers `{ id, time, recorded_at }`. - -- The brain fills in what is left out: `specversion` 1.0, an `id`, a version 7 UUID, and as `time` the moment it records the event. With them, the event takes at most 245,760 bytes (240 KiB) as JSON in UTF-8, `mostPublishedEventBytes`, so that it fits a run's input of 256 KiB inside an array; a larger one is `invalid_input` at `/event`. -- `causationid` and `correlationid`, the extension attributes that carry the lineage of the brain's own facts (see [The brain's own facts as events](#the-brains-own-facts-as-events)), are refused as `invalid_input` at `/event/causationid` and `/event/correlationid`, so no event published to a brain or sent to a run claims a cause it does not have. -- The types the brain records on the streams of its runs and its definitions, every stored type of their events, and every type its feed shows, `event_published`, `interaction_requested`, `workflow_input_applied`, the step events of a workflow and `reaction_refused` among them, `reservedEventTypes`, and sources under `/executions/`, `/specs/` and `/callers/`, the last the source the brain gives an event a caller sends a run without one (`callerSourcePrefix`), named in words by `reservedSourcesInWords` and checked by `isReservedSource`, are the brain's own: an event that uses them is `invalid_input` at `/event/type` or `/event/source`. `refusingTheBrainsOwnAttributes` is that check, a filter of an event's schema, which `send_execution_event` of `@beonauto/orchestration` applies too, so that no event sent to a run poses as a fact a `listen` filter matches. -- Each event is a stream of its own, `events/`, the uuid a name-based UUID, version 5, of its `source` and `id` in a namespace of its own. The publish records `event_published`, with the event, the attributes the brain `filled`, who published it and when, on that stream while it is empty. Publishing it again with the same source and id records nothing and answers the first record's `id`, `time` and `recorded_at`, and a different event under the same source and id is `conflict`. The two are compared on what both callers gave: an attribute the brain filled in for either of them, such as a `time` left out on a retry, is left out of the comparison, so a retry that gives a time to an event first published without one answers the time the brain filled in. Times are compared as instants (`instantOf` in `events/event-time.ts`), so one instant spelled two ways is one time, to the nanosecond; a leap second counts as the first second of the next day. A publish without an `id` gets a new one, so it is always a new event. - -### An event a workflow emits - -`eventEmitter(ledger)` gives the workflow host an `EmitEvent`: it records an event a workflow's `emit` task made as `event_published` on the event's own stream, as `publish_event` would, with `emitted_by`, the execution, the workflow and its version, and `depth`, the reaction depth of that run plus one, under the lineage it is given. The same event again records nothing and answers `already_recorded`; an event the brain does not take, by `CloudEventSchema`, its reserved types and sources or its bound of 240 KiB, or one whose source and id another event holds, is `refused` and recorded nowhere; a ledger that kept changing fails, so the output is dispatched again. `emittedEventRefusal(event)` says, in words, why the brain would not take an event a workflow computed, so the run can fail its task instead. `publishedEventOf(data)` reads a stored `event_published`. `list_brain_events` says of an emitted event which workflow emitted it. - -### The brain's own facts as events - -`brainFactOf(record)` turns a record the brain stored into a CloudEvent, or `undefined` for a record that is no fact. It takes the record as `BrainReader.readRecorded` gives it, its stream named relative to the brain, and builds the event from the stored event, never from its presentation: - -| Record | `source` | `subject` | `data` | -| ------------------------------------------------------------------------------------ | --------------------------- | -------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| `execution_started`, `execution_succeeded`, `execution_rejected`, `execution_failed` | `/executions/` | `/` | `primitive`, `name`, `version`, `caller`, `depth`, the run's reaction depth, 0 for a run nothing reacted to, `called_by` and `trigger` when the start names them, for a rejection its `reason` and `kind`, and for a success its `output` when the whole event takes at most 240 KiB and its data nests at most 510 levels, and its `output_bytes` otherwise | -| `spec_created`, `spec_updated`, `spec_retired` | `/specs//` | none | `primitive`, `name`, `version` but on a retirement, `caller` | - -The event's `type` is the stored type, its `id` the record's id, the message's own id, and its `time` the time the stored event holds; the record's cause and correlation, when it has them, are its extension attributes `causationid` and `correlationid`. `brainEventOf(record)` is the event a reader of the brain's history sees for a record: a fact of the brain as `brainFactOf` gives it, or an event published to the brain as its publisher gave it, the `event` of its `event_published`, which is how the workflow host's projector folds both. So an interaction function's answer is the `output` of its `execution_succeeded`, with the subject `interaction/`, and an expiry the `reason` `unanswered` and the `kind` `expired`, both with the actor as `caller`. Deferrals, tool calls, deliveries, the run logs of workflows and every other stream kind yield no event, and so does a record that does not decode as an event of its stream, so a reader of the ledger never fails on one. A finish names what ran because the decider records the definition on it (see [Storage](#storage)). - -## Tool calls - -A primitive whose executions call tools, as inference does through MCP servers ([decision 0003](../../docs/decisions/0003-mcp-servers.md)), records each call on the execution's own stream as it happens, through `execution.journal.record(fact)`, which answers whether the fact was recorded: - -- `tool_call_started`, before the call is sent: its `number` within the run, which the decider gives it as it appends the fact and the journal answers, the `call_id` the model gave it, the `server` and `tool`, the size and SHA-256 digest of its arguments as sent (`arguments_bytes`, `arguments_sha256`), and `arguments_json`, cut to 4 KiB, when the server's operator records content; -- `tool_call_answered`: the `number`, the `outcome` (`result`, `tool_error`, `server_failure`, `timed_out` or `cancelled`), the size and digest of the result (`result_bytes`, `result_sha256`, null when there is none), `duration_ms`, the `jsonrpc_id` sent, `server_request_id` when the server's entry names where it carries one, and `result_json`, cut to 4 KiB, when content is recorded. - -The journal is built with the rest of the context in one place, so executions started directly and by a workflow record alike. It keeps the id of the last answer it recorded and of each call it started, for the causes above. It holds a permit per execution, so the calls of one step, made at once, append one at a time, as an append is retried only three times on a version conflict. The decider refuses a tool event once the execution has ended, however it ended, and a start that names a number other than the next: a call still in flight then keeps a start and no answer, which reads as an outcome unknown. Whether a tool call may have changed something is what keeps an execution that called tools from running again under its id (see [Run ids and retries](#execution-ids-and-retries)). - -## Outbound calls - -A run that finishes later makes calls after the call that started it returned, with no request behind them: an interaction function's deliveries, which the workflow host makes from the projection of open requests, and the replies its reading takes. `outboundCallRecorder(writer)` records the deliveries on the run's stream, through the execution decider, as the brain's own caller, `brain:`, with the lineage it is given, and answers the id of the message it recorded, the cause of what follows: - -- `delivery_started`, before the attempt is sent: its `number`, which must be the next call of the run, so an attempt two hosts make at once is recorded once and the second is `conflict`, the `target`, the `server` and `tool` it calls, and, once its arguments are rendered, the fields a call's start has, `arguments_bytes`, `arguments_sha256` and `arguments_json` where the server records content; -- `delivery_ended`: the `number`, the `outcome` (`delivered`, `failed` or `refused`), `because` (`timed_out`, `too_large`, `tool_not_offered`, `tool_error`, `server_failure` or `lost`), the `retry_after_ms` a server asked for, a `detail`, `duration_ms`, the fields of the call's answer when a call was answered, `delivered_as`, the conversation and the identity of the message the tool sent, and `replies_in`, the server, the tool and the key its replies are read by. - -`replyRecorder(writer)` records the two reply facts on the run's stream as the `reply` command, decided by `decideReply`: `reply_taken`, with the server and tool that read it, the reply's identity and the answer, and `reply_refused`, with its `because` (`not_an_answer`, `invalid`, `too_long` or `ambiguous`), the issues and whether the party was told. Both are refused once the run has ended or a cancel is asked, a reply the run has met already, which the state keeps as `repliesSeen`, a taking once the run holds a `broughtAnswer`, and a refusal past the tenth. - -Each carries the definition that ran, as the endings do. A delivery never says the run may have changed something outside, so a run whose deliveries failed may start again under its id. An address the brain cannot hold is `conflict`, and so is a run that has ended. `recordedRunIn(reader, address)` and `recordedRunInBrain(reader, id)` read a run as it stands, with its input, whether it awaits a settlement and the number of its last call, through a reader of the whole ledger or of one brain, and answer nothing for a run there is not; `executionEventOf(data)` reads one stored execution event. - -## Testing - -`@beonauto/specs/testing` exports `echo`, a small real primitive for the tests of this and other packages. Its spec document is a JSON object with a string `greeting`, an optional string `description` and optional `warnings`, a list of strings its summary gives back; an execution takes a JSON object and answers `{ greeting, input }`. - -## Source - -`src/index.ts` is the main entry point, `src/document.ts` the entry point of the reader of function documents, held in `src/document`, `src/json-schema.ts` the entry point of its JSON Schema compiler alone, of the checks of a value against a schema and of the checked worker, held in `src/checking`, `src/template.ts` the entry point of the template engine of the capabilities, held in `src/template`, and `src/testing/index.ts` the entry point of the test support. `src/primitive` holds the definition of a primitive and the list of known primitives. `src/registry` holds the specs of a primitive in a brain: a spec, the events and commands of its stream, and the decider and its rules. `src/execution` holds an execution: its events, commands, state, decider and rules, its size limits, and `executionSettler`. `src/operations` holds the seven operations that change and read specs and executions, and how they load and record them. `src/reading` holds `list_executions` and `get_execution_history`, `src/analytics` `get_brain_analytics` and `runOutcomeMapping`, and `src/presenting` the presenters of the three stream kinds. `src/events` holds the events of a brain: the shape of a CloudEvent, `publish_event` and the stream of a published event, and the brain's own facts as events. `src/tool-calls` holds the journal of an execution's tool calls, and `src/run-work` the work a run records after its start: the decisions on its calls, the recorder of its outbound calls, the words and accounts of its deliveries and deferral, and a run as it was recorded. `src/testing` holds what the tests share. `operations` depends on the others but `events`, `reading` on `presenting`, on `analytics` and on the fields of `operations`, `analytics` on the fields of `operations` and the words of `plain-language`, `events` on `execution`, `registry` and the command metadata of `operations`, `presenting` on `events`, and `registry` and `execution` on nothing in this package. diff --git a/packages/specs/src/analytics/get-brain-analytics.ts b/packages/specs/src/analytics/get-brain-analytics.ts deleted file mode 100644 index af0016d89..000000000 --- a/packages/specs/src/analytics/get-brain-analytics.ts +++ /dev/null @@ -1,58 +0,0 @@ -import { BrainReader, defineQuery } from '@beonauto/operations'; -import { Clock, Effect, Schema } from 'effect'; - -import { RunsOfNameField } from '../operations/spec-fields.ts'; -import { specWordsFor } from '../plain-language/spec-words.ts'; -import { PrimitiveField, knownPrimitives } from '../primitive/known-primitives.ts'; -import type { Primitive } from '../primitive/primitive.ts'; -import { analyticsOf, BrainAnalyticsSchema } from './analytics-answer.ts'; -import { DaysField, dayFieldOf, dayOf, longestWindowInDays, windowOf } from './analytics-window.ts'; -import { analyticsWords } from './analytics-words.ts'; - -const description = [ - 'Counts what the runs of the brain did over a window of days in UTC: how many succeeded, failed or were rejected,', - 'the tokens their models used, and how long they took at the median and the 95th percentile,', - 'for the window, for each day of it and for each definition.', - 'Use it when the person asks how the brain is doing; list_executions lists the runs themselves.', - '`days` reads the last 7, 14 or 30 days, or `from` and `to` the days between them, and `primitive` and `name` keep the runs of one definition.', -].join(' '); - -const AnalyticsInputSchema = Schema.Struct({ - days: Schema.optionalKey(DaysField), - from: Schema.optionalKey(dayFieldOf('The first day to read, YYYY-MM-DD in UTC, given with to and not with days')), - to: Schema.optionalKey( - dayFieldOf( - `The last day to read, YYYY-MM-DD in UTC, no later than today and at most ${longestWindowInDays} days from the first`, - ), - ), - primitive: Schema.optionalKey(PrimitiveField), - name: Schema.optionalKey(RunsOfNameField), -}); - -type AnalyticsInput = typeof AnalyticsInputSchema.Type; - -function selectionOf({ primitive, name }: AnalyticsInput) { - return { ...(primitive === undefined ? {} : { primitive }), ...(name === undefined ? {} : { name }) }; -} - -const readAnalytics = Effect.fnUntraced(function* (input: AnalyticsInput) { - const window = yield* windowOf(input, dayOf(yield* Clock.currentTimeMillis)); - const groups = yield* (yield* BrainReader).readRunOutcomes({ from: window.from, to: window.to }, selectionOf(input)); - return analyticsOf(window, groups); -}); - -export function defineGetBrainAnalytics(primitives: readonly Primitive[]) { - const known = knownPrimitives(primitives); - const operation = defineQuery('brain', { - name: 'get_brain_analytics', - title: 'Get brain analytics', - description, - route: { method: 'GET', path: '/analytics' }, - inputSchema: AnalyticsInputSchema, - outputSchema: BrainAnalyticsSchema, - reasons: ['invalid_input'], - handle: readAnalytics, - plainLanguage: analyticsWords(specWordsFor(primitives)), - }); - return known.publish(operation, 'Only the runs of this type'); -} diff --git a/packages/specs/src/cancellation/deferred-cancels.ts b/packages/specs/src/cancellation/deferred-cancels.ts deleted file mode 100644 index 23f3a45b0..000000000 --- a/packages/specs/src/cancellation/deferred-cancels.ts +++ /dev/null @@ -1,73 +0,0 @@ -import { - brainCallerOf, - streamPrefixOfBrain, - type Conflict, - type Lineage, - type NotFound, - type Settlement, - type StreamReader, - type StreamWriter, -} from '@beonauto/operations'; -import { Effect, Equal } from 'effect'; - -import { changedSinceRead, executionDeciderAsRead, executionStreamOf } from '../execution/execution-decider.ts'; -import { endedWithAnotherResult } from '../execution/execution-decisions.ts'; -import { executionSettlerAsRead, type ExecutionAddress } from '../execution/execution-settler.ts'; -import { runOf, takesSettlement } from '../execution/execution-state.ts'; -import { cancelledAsAsked, type CancelledRun, type Primitive } from '../primitive/primitive.ts'; -import type { CancelRequest } from './run-cancels.ts'; - -export type SettleCancelled = ( - execution: ExecutionAddress, - request: CancelRequest, - lineage: Lineage, -) => Effect.Effect; - -const brokeDown: Settlement = { status: 'failed' }; - -const readsAgainAtMost = 3; - -function endedOtherwise(error: unknown): boolean { - return Equal.equals(error, endedWithAnotherResult); -} - -function changedMeanwhile(error: unknown): boolean { - return Equal.equals(error, changedSinceRead); -} - -function decided(primitive: Primitive | undefined, run: CancelledRun): Effect.Effect { - const cancel = primitive?.cancel ?? cancelledAsAsked; - return Effect.try({ try: () => cancel(run), catch: () => brokeDown }).pipe(Effect.orElseSucceed(() => brokeDown)); -} - -export function deferredCanceller( - primitives: readonly Primitive[], - ledger: StreamReader & StreamWriter, -): SettleCancelled { - const settle = executionSettlerAsRead(ledger); - const settledAsRead: SettleCancelled = (execution, { kind, reason, by }, lineage) => - Effect.gen(function* () { - const stream = `${streamPrefixOfBrain(execution)}${executionStreamOf(execution.id.toLowerCase())}`; - const read = (yield* ledger.load(stream, executionDeciderAsRead)).state; - const state = runOf(read.state); - if (state === undefined || !takesSettlement(state)) { - return; - } - const primitive = primitives.find(({ name }) => name === state.execution.primitive); - const settlement = yield* decided(primitive, { - execution, - record: state.record ?? {}, - kind, - reason, - broughtAnswer: state.broughtAnswer, - deliveredAt: state.deliveredAt, - }); - const actor = settlement.by ?? by ?? brainCallerOf(execution).id; - yield* settle(execution, { ...settlement, by: actor }, read.version, lineage).pipe( - Effect.asVoid, - Effect.catchIf(endedOtherwise, () => Effect.void), - ); - }); - return (execution, request, lineage) => - settledAsRead(execution, request, lineage).pipe(Effect.retry({ times: readsAgainAtMost, while: changedMeanwhile })); -} diff --git a/packages/specs/src/execution/execution-decider.ts b/packages/specs/src/execution/execution-decider.ts deleted file mode 100644 index ea29392b0..000000000 --- a/packages/specs/src/execution/execution-decider.ts +++ /dev/null @@ -1,51 +0,0 @@ -import { Conflict, type Decider } from '@beonauto/operations'; -import { Result } from 'effect'; - -import type { ExecutionCommand } from './execution-commands.ts'; -import { decideOnExecution } from './execution-decisions.ts'; -import { ExecutionEventSchema, type ExecutionEvent } from './execution-events.ts'; -import { evolveExecution, type ExecutionStreamState } from './execution-state.ts'; - -export const executionDecider: Decider< - ExecutionStreamState, - ExecutionCommand, - ExecutionEvent, - 'not_found' | 'conflict' | 'cancelled' -> = { - initialState: undefined, - evolve: evolveExecution, - decide: decideOnExecution, - eventSchema: ExecutionEventSchema, -}; - -interface ExecutionAsRead { - readonly state: ExecutionStreamState; - readonly version: number; -} - -interface CommandAsRead { - readonly readAt: number; - readonly command: ExecutionCommand; -} - -export const changedSinceRead = new Conflict({ - detail: 'The run changed since it was read, so it is read again', - kind: 'concurrent_change', -}); - -export const executionDeciderAsRead: Decider< - ExecutionAsRead, - CommandAsRead, - ExecutionEvent, - 'not_found' | 'conflict' | 'cancelled' -> = { - initialState: { state: undefined, version: 0 }, - evolve: ({ state, version }, event) => ({ state: evolveExecution(state, event), version: version + 1 }), - decide: ({ readAt, command }, { state, version }) => - readAt === version ? decideOnExecution(command, state) : Result.fail(changedSinceRead), - eventSchema: ExecutionEventSchema, -}; - -export function executionStreamOf(id: string): string { - return `executions/${id}`; -} diff --git a/packages/specs/src/execution/execution-depth.test.ts b/packages/specs/src/execution/execution-depth.test.ts deleted file mode 100644 index ce16c7f08..000000000 --- a/packages/specs/src/execution/execution-depth.test.ts +++ /dev/null @@ -1,136 +0,0 @@ -import { Result } from 'effect'; -import { describe, expect, it } from 'vitest'; - -import type { ExecutionCommand } from './execution-commands.ts'; -import { executionDecider } from './execution-decider.ts'; -import { runTaken } from './execution-decisions.ts'; -import type { ExecutionEvent } from './execution-events.ts'; - -const start = { by: 'brain:alpha', at: '2026-10-01T09:00:00.000Z' }; - -const finish = { by: 'brain:alpha', at: '2026-10-01T09:00:05.000Z' }; - -const greeting = { primitive: 'echo', name: 'greet', input: { who: 'Ada' } }; - -function stateAfter(...events: readonly ExecutionEvent[]) { - return events.reduce((state, event) => executionDecider.evolve(state, event), executionDecider.initialState); -} - -interface StartOptions { - readonly depth?: number; - readonly createOnly?: true; - readonly trigger?: { readonly kind: 'event' | 'cron' | 'every'; readonly reference: string }; -} - -function starting(options: StartOptions): ExecutionCommand { - return { type: 'start', ...greeting, calls_tools: false, spec_version: 1, ...start, ...options }; -} - -const startedDeep: ExecutionEvent = { type: 'execution_started', ...greeting, spec_version: 1, depth: 2, ...start }; - -const finishing: ExecutionCommand = { - type: 'finish', - result: { type: 'execution_succeeded', output: 'Hi', record: {} }, - ...finish, -}; - -describe('the reaction depth of a run', () => { - it('is recorded on its start when it is above 0, and on its ending, from the start', () => { - expect([ - executionDecider.decide(starting({ depth: 2 }), executionDecider.initialState), - executionDecider.decide(starting({ depth: 0 }), executionDecider.initialState), - executionDecider.decide(finishing, stateAfter(startedDeep)), - ]).toEqual([ - Result.succeed([startedDeep]), - Result.succeed([{ type: 'execution_started', ...greeting, spec_version: 1, ...start }]), - Result.succeed([ - { - type: 'execution_succeeded', - output: 'Hi', - record: {}, - primitive: 'echo', - name: 'greet', - spec_version: 1, - depth: 2, - ...finish, - }, - ]), - ]); - }); -}); - -describe('the trigger that started a run', () => { - it('is recorded on its start, and copied from the start onto every ending', () => { - const trigger = { kind: 'cron' as const, reference: '/schedule/cron' }; - const startedByCron: ExecutionEvent = { - type: 'execution_started', - ...greeting, - spec_version: 1, - trigger, - ...start, - }; - const failing: ExecutionCommand = { type: 'finish', result: { type: 'execution_failed' }, ...finish }; - const ofTheRun = { primitive: 'echo', name: 'greet', spec_version: 1, trigger, ...finish }; - - expect([ - executionDecider.decide(starting({ trigger }), executionDecider.initialState), - executionDecider.decide(finishing, stateAfter(startedByCron)), - executionDecider.decide(failing, stateAfter(startedByCron)), - ]).toEqual([ - Result.succeed([startedByCron]), - Result.succeed([{ type: 'execution_succeeded', output: 'Hi', record: {}, ...ofTheRun }]), - Result.succeed([{ type: 'execution_failed', ...ofTheRun }]), - ]); - }); -}); - -describe('a start that only creates', () => { - it('starts a run that does not exist, and again one that ended without a result, under the same request', () => { - const failed: ExecutionEvent = { - type: 'execution_failed', - primitive: 'echo', - name: 'greet', - spec_version: 1, - ...finish, - }; - - expect([ - executionDecider.decide(starting({ createOnly: true, depth: 2 }), executionDecider.initialState), - executionDecider.decide(starting({ createOnly: true, depth: 2 }), stateAfter(startedDeep, failed)), - ]).toEqual([Result.succeed([startedDeep]), Result.succeed([startedDeep])]); - }); - - it('is refused as taken for a run that goes, that ended with a result, or that another request ended', () => { - const succeeded: ExecutionEvent = { - type: 'execution_succeeded', - primitive: 'echo', - name: 'greet', - spec_version: 1, - output: 'Hi', - record: {}, - ...finish, - }; - const failed: ExecutionEvent = { - type: 'execution_failed', - primitive: 'echo', - name: 'greet', - spec_version: 1, - ...finish, - }; - const other: ExecutionCommand = { - type: 'start', - ...greeting, - input: { who: 'Grace' }, - calls_tools: false, - spec_version: 1, - ...start, - createOnly: true, - }; - - expect([ - executionDecider.decide(starting({ createOnly: true }), stateAfter(startedDeep)), - executionDecider.decide(starting({ createOnly: true }), stateAfter(startedDeep, succeeded)), - executionDecider.decide(other, stateAfter(startedDeep, failed)), - ]).toEqual([Result.fail(runTaken), Result.fail(runTaken), Result.fail(runTaken)]); - }); -}); diff --git a/packages/specs/src/execution/execution-lookup.ts b/packages/specs/src/execution/execution-lookup.ts deleted file mode 100644 index 28da689fd..000000000 --- a/packages/specs/src/execution/execution-lookup.ts +++ /dev/null @@ -1,58 +0,0 @@ -import { Conflict, InvalidInput, NotFound, RunCancelled, RunUnanswered, Unavailable } from '@beonauto/operations'; -import { Effect } from 'effect'; - -import { runOf, type ExecutionStreamState, type RecordedExecution } from './execution-state.ts'; -import type { Run, RunDetail, ExecutionRejection } from './execution.ts'; - -export function noRunCalled(id: string): NotFound { - return new NotFound({ detail: `There is no run ${id} in this brain` }); -} - -function recorded(id: string, stream: ExecutionStreamState): Effect.Effect { - const state = runOf(stream); - return state === undefined ? Effect.fail(noRunCalled(id)) : Effect.succeed(state); -} - -export function executionOf(id: string, state: ExecutionStreamState): Effect.Effect { - return recorded(id, state).pipe(Effect.map(({ execution }) => ({ execution_id: id, ...execution }))); -} - -function detailOf(id: string, { execution, record }: RecordedExecution): RunDetail { - return record === undefined ? { execution_id: id, ...execution } : { execution_id: id, ...execution, record }; -} - -export function executionDetailOf(id: string, state: ExecutionStreamState): Effect.Effect { - return recorded(id, state).pipe(Effect.map((execution) => detailOf(id, execution))); -} - -type ReplayedRejection = InvalidInput | Unavailable | Conflict | RunCancelled | RunUnanswered; - -function replayed(rejection: ExecutionRejection): ReplayedRejection { - if (rejection.reason === 'invalid_input') { - return new InvalidInput({ detail: rejection.detail, issues: rejection.issues }); - } - if (rejection.reason === 'unavailable') { - const { detail, kind, because } = rejection; - return new Unavailable({ - detail, - ...(kind === undefined ? {} : { kind }), - ...(because === undefined ? {} : { because }), - }); - } - if (rejection.reason === 'cancelled') { - return new RunCancelled({ detail: rejection.detail, kind: rejection.kind }); - } - if (rejection.reason === 'unanswered') { - return new RunUnanswered({ detail: rejection.detail, kind: rejection.kind }); - } - const { detail, kind } = rejection; - return new Conflict(kind === undefined ? { detail } : { detail, kind }); -} - -function answerWith(execution: Run): Effect.Effect { - return execution.rejection === undefined ? Effect.succeed(execution) : Effect.fail(replayed(execution.rejection)); -} - -export function answerOf(id: string, state: ExecutionStreamState): Effect.Effect { - return executionOf(id, state).pipe(Effect.flatMap(answerWith)); -} diff --git a/packages/specs/src/execution/run-endings.ts b/packages/specs/src/execution/run-endings.ts deleted file mode 100644 index 9bb1eda6d..000000000 --- a/packages/specs/src/execution/run-endings.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { Option, Schema } from 'effect'; - -import { ExecutionEventSchema, type ExecutionCancelRequested, type ExecutionFinished } from './execution-events.ts'; - -export type RunEnding = ExecutionFinished; - -export type CancelRequested = ExecutionCancelRequested; - -const decodeExecutionEvent = Schema.decodeUnknownOption(Schema.toCodecJson(ExecutionEventSchema)); - -function isEnding(event: Schema.Schema.Type): event is RunEnding { - return ( - event.type === 'execution_succeeded' || event.type === 'execution_rejected' || event.type === 'execution_failed' - ); -} - -export function runEndingOf(data: unknown): RunEnding | undefined { - return Option.getOrUndefined(Option.filter(decodeExecutionEvent(data), isEnding)); -} - -export function lastEndingOf(events: readonly unknown[]): RunEnding | undefined { - return runEndingOf(events.at(-1)); -} - -export function cancelRequestOf(data: unknown): CancelRequested | undefined { - return Option.getOrUndefined( - Option.flatMap(decodeExecutionEvent(data), (event) => - event.type === 'execution_cancel_requested' ? Option.some(event) : Option.none(), - ), - ); -} diff --git a/packages/specs/src/execution/run-starts.test.ts b/packages/specs/src/execution/run-starts.test.ts deleted file mode 100644 index bc0c81cf9..000000000 --- a/packages/specs/src/execution/run-starts.test.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { Schema } from 'effect'; -import { describe, expect, it } from 'vitest'; - -import { ExecutionEventSchema } from './execution-events.ts'; -import { runStartedOf } from './run-starts.ts'; - -const encode = Schema.encodeSync(Schema.toCodecJson(ExecutionEventSchema)); - -const fact = { by: 'brain:alpha', at: '2026-10-01T09:00:00.000Z' }; - -describe('the start of a run read from its record', () => { - it('names what ran and its reaction depth, and nothing for any other record', () => { - expect([ - runStartedOf( - encode({ - type: 'execution_started', - primitive: 'orchestration', - name: 'close', - spec_version: 1, - input: {}, - depth: 2, - ...fact, - }), - ), - runStartedOf( - encode({ type: 'execution_started', primitive: 'inference', name: 'sum', spec_version: 1, input: {}, ...fact }), - ), - runStartedOf(encode({ type: 'execution_failed', primitive: 'inference', name: 'sum', spec_version: 1, ...fact })), - runStartedOf('not a record'), - ]).toEqual([ - { primitive: 'orchestration', name: 'close', depth: 2 }, - { primitive: 'inference', name: 'sum', depth: 0 }, - undefined, - undefined, - ]); - }); -}); diff --git a/packages/specs/src/execution/run-starts.ts b/packages/specs/src/execution/run-starts.ts deleted file mode 100644 index 940112b75..000000000 --- a/packages/specs/src/execution/run-starts.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { Option, Schema } from 'effect'; - -import { ExecutionEventSchema } from './execution-events.ts'; - -export interface RunStarted { - readonly primitive: string; - readonly name: string; - readonly depth: number; -} - -const decodeExecutionEvent = Schema.decodeUnknownOption(Schema.toCodecJson(ExecutionEventSchema)); - -export function runStartedOf(data: unknown): RunStarted | undefined { - return Option.getOrUndefined( - Option.flatMap(decodeExecutionEvent(data), (event) => - event.type === 'execution_started' - ? Option.some({ primitive: event.primitive, name: event.name, depth: event.depth ?? 0 }) - : Option.none(), - ), - ); -} diff --git a/packages/specs/src/operations/create-spec.ts b/packages/specs/src/operations/create-spec.ts deleted file mode 100644 index 639e9bafe..000000000 --- a/packages/specs/src/operations/create-spec.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { defineCommand } from '@beonauto/operations'; -import { Effect, Schema } from 'effect'; - -import { specWordsFor, whatItDoes } from '../plain-language/spec-words.ts'; -import { knownPrimitives } from '../primitive/known-primitives.ts'; -import type { Primitive } from '../primitive/primitive.ts'; -import { DefinitionSchema } from '../registry/spec.ts'; -import { recordInRegistry } from './registry-access.ts'; -import { SourceField, SpecNameField } from './spec-fields.ts'; -import { contentOf, specOf } from './spec-views.ts'; - -export function defineCreateSpec(primitives: readonly Primitive[]) { - const known = knownPrimitives(primitives); - const words = specWordsFor(primitives); - return known.publish( - defineCommand('brain', { - name: 'create_spec', - title: 'Create definition', - description: [ - 'Saves a new function or workflow definition in the brain from its document and returns it without running it.', - 'Use it once the person has agreed to the definition; update_spec changes one that exists, and a name is never reused in a brain.', - `\`primitive\` is the definition's type and \`name\` is how workflows and tools refer to it: ${known.typesWithGuides}.`, - "`source` is the whole document in that type's format, which get_guide gives.", - 'A document that does not fit its format is refused with the line and what is wrong, and nothing is saved.', - ].join(' '), - route: { method: 'POST', path: '/specs/{primitive}' }, - successStatus: 201, - inputSchema: Schema.Struct({ primitive: known.field, name: SpecNameField, source: SourceField }), - outputSchema: DefinitionSchema, - reasons: ['not_found', 'invalid_input', 'conflict'], - handle: Effect.fnUntraced(function* ({ primitive: primitiveName, name, source }) { - const primitive = yield* known.primitiveNamed(primitiveName); - const content = yield* contentOf(primitive, source); - return specOf(primitive, yield* recordInRegistry(primitive, { type: 'create', name, content })); - }), - plainLanguage: { - task: `create a new ${words.kinds}`, - attempt: ({ primitive, name }) => `create ${words.named(primitive, name)}`, - outcome: (spec) => - `Created ${words.named(spec.primitive, spec.name)}.${whatItDoes(spec)} It has been saved but has not been run yet.`, - }, - }), - ); -} diff --git a/packages/specs/src/operations/execute-spec.test.ts b/packages/specs/src/operations/execute-spec.test.ts deleted file mode 100644 index 1577745b4..000000000 --- a/packages/specs/src/operations/execute-spec.test.ts +++ /dev/null @@ -1,187 +0,0 @@ -import { describe, expect, it } from 'vitest'; - -import { acmeAdmin } from '../testing/callers.ts'; -import { echo } from '../testing/echo.ts'; -import { firstMoment, harness, toBrain } from '../testing/harness.ts'; -import { probe } from '../testing/probe.ts'; -import { specOperationsFor } from '../testing/spec-operations.ts'; - -const { createSpec, executeSpec, getExecution, updateSpec } = specOperationsFor([echo, probe().primitive]); - -const toAlpha = toBrain('acme', 'alpha'); - -const later = '2026-10-02T14:15:00.000Z'; - -const uuidV7 = /^[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/u; - -const executionId = '0199a3c4-7d2e-7c1a-9b3f-2f1e0d9c8b7a'; - -const greeted = { - execution_id: executionId, - primitive: 'echo', - name: 'greet', - spec_version: 1, - status: 'succeeded', - output: { greeting: 'Hello', input: {} }, - started_at: firstMoment, - started_by: 'acme-admin', - finished_at: firstMoment, -}; - -async function withGreetAndPlain() { - const specs = harness(); - await specs.call( - createSpec, - toAlpha(acmeAdmin, { primitive: 'echo', name: 'greet', source: '{"greeting": "Hello"}' }), - ); - await specs.call(createSpec, toAlpha(acmeAdmin, { primitive: 'probe', name: 'plain', source: 'text' })); - return specs; -} - -function executing(name: string, input?: object) { - const primitive = name === 'greet' ? 'echo' : 'probe'; - return toAlpha(acmeAdmin, input === undefined ? { primitive, name } : { primitive, name, ...input }); -} - -describe('execute_spec', () => { - it('is a brain command at POST /specs/{primitive}/{name}/execute', () => { - expect(executeSpec.registration).toMatchObject({ - scope: 'brain', - kind: 'command', - title: 'Run definition', - route: { method: 'POST', path: '/specs/{primitive}/{name}/execute' }, - pathParameters: ['primitive', 'name'], - successStatus: 200, - reasons: ['not_found', 'conflict', 'invalid_input', 'unavailable', 'cancelled', 'unanswered'], - }); - }); - - it('runs a spec with an input and answers with the execution it recorded under a new id', async () => { - const { call, ledger } = await withGreetAndPlain(); - - const outcome = await call(executeSpec, executing('greet', { input: { who: 'Ada' } }), later); - const [, , executionStream] = ledger.streamNames(); - const id = String(executionStream).replace('brain/acme/alpha/executions/', ''); - - expect(id).toMatch(uuidV7); - expect(outcome).toStrictEqual({ - status: 'succeeded', - output: { - execution_id: id, - primitive: 'echo', - name: 'greet', - spec_version: 1, - status: 'succeeded', - output: { greeting: 'Hello', input: { who: 'Ada' } }, - started_at: later, - started_by: 'acme-admin', - finished_at: later, - }, - }); - }); - - it('answers with the execution that get_execution reads, without the record that get_execution adds', async () => { - const { call } = await withGreetAndPlain(); - - expect(await call(executeSpec, executing('greet', { execution_id: executionId }))).toStrictEqual({ - status: 'succeeded', - output: greeted, - }); - expect(await call(getExecution, toAlpha(acmeAdmin, { execution_id: executionId }))).toStrictEqual({ - status: 'succeeded', - output: { ...greeted, record: { greeting: 'Hello' } }, - }); - }); -}); - -describe('the execution that execute_spec runs', () => { - it('gives the primitive an empty object when the input is left out', async () => { - const { call } = await withGreetAndPlain(); - - expect(await call(executeSpec, executing('greet'))).toMatchObject({ - output: { output: { greeting: 'Hello', input: {} } }, - }); - }); - - it('tells the primitive the execution id, the org, the brain, the caller and the spec it runs', async () => { - const { call } = await withGreetAndPlain(); - - expect(await call(executeSpec, executing('plain', { input: 7, execution_id: executionId }))).toMatchObject({ - output: { - output: { - input: 7, - execution: { - id: executionId, - org: 'acme', - brain: 'alpha', - caller: acmeAdmin, - spec: { name: 'plain', version: 1 }, - }, - }, - }, - }); - }); - - it('runs the active latest version of the spec', async () => { - const { call } = await withGreetAndPlain(); - await call(updateSpec, toAlpha(acmeAdmin, { primitive: 'echo', name: 'greet', source: '{"greeting": "Howdy"}' })); - - expect(await call(executeSpec, executing('greet'))).toMatchObject({ - output: { spec_version: 2, output: { greeting: 'Howdy' } }, - }); - }); - - it('keeps the execution id it is given, in lowercase', async () => { - const { call } = await withGreetAndPlain(); - - expect(await call(executeSpec, executing('greet', { execution_id: executionId.toUpperCase() }))).toMatchObject({ - output: { execution_id: executionId }, - }); - }); -}); - -describe('execute_spec rejected by the primitive', () => { - it('for invalid input, with the issues under /input, and records the rejection', async () => { - const { call } = await withGreetAndPlain(); - - expect(await call(executeSpec, executing('plain', { input: { reject: true }, execution_id: executionId }))).toEqual( - { - status: 'rejected', - reason: 'invalid_input', - detail: 'The probe rejects the input', - issues: [{ detail: 'Expected anything but reject', pointer: '/input/reject' }], - }, - ); - expect(await call(getExecution, toAlpha(acmeAdmin, { execution_id: executionId }))).toStrictEqual({ - status: 'succeeded', - output: { - execution_id: executionId, - primitive: 'probe', - name: 'plain', - spec_version: 1, - status: 'rejected', - rejection: { - reason: 'invalid_input', - detail: 'The probe rejects the input', - issues: [{ detail: 'Expected anything but reject', pointer: '/input/reject' }], - }, - started_at: firstMoment, - started_by: 'acme-admin', - finished_at: firstMoment, - }, - }); - }); - - it('for input that is not what the primitive takes at its root', async () => { - const { call } = await withGreetAndPlain(); - const notAnObject = { - status: 'rejected', - reason: 'invalid_input', - detail: 'The input of an echo spec must be a JSON object', - issues: [{ detail: 'Expected a JSON object', pointer: '/input' }], - }; - - expect(await call(executeSpec, executing('greet', { input: 'Ada' }))).toEqual(notAnObject); - expect(await call(executeSpec, executing('greet', { input: ['Ada'] }))).toEqual(notAnObject); - }); -}); diff --git a/packages/specs/src/operations/execute-spec.ts b/packages/specs/src/operations/execute-spec.ts deleted file mode 100644 index 3d42625c1..000000000 --- a/packages/specs/src/operations/execute-spec.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { defineCommand } from '@beonauto/operations'; -import { Effect, Schema } from 'effect'; - -import { RunSchema } from '../execution/execution.ts'; -import { runPlainLanguage } from '../plain-language/run-words.ts'; -import { knownPrimitives } from '../primitive/known-primitives.ts'; -import type { Primitive } from '../primitive/primitive.ts'; -import { executeRequest } from './execution-running.ts'; -import { ExecutionIdField, InputField, SpecNameField } from './spec-fields.ts'; - -const description = [ - 'Runs a function or workflow with an input and records the run.', - 'A function answers its result; a workflow or an interaction function answers started with an execution_id unless it ends before its first wait, and get_execution shows how it ended.', - "Use it to run a saved definition at the person's request; a workflow's steps call it the same way.", - '`primitive` and `name` say which definition, `input` is the value it takes, as the input_schema get_spec shows,', - 'and `execution_id` is optional: give the same id to retry safely, since a run that ended or waits answers as it stands and one that failed runs again.', - 'A reasoning function that names tools may change something outside the brain, so a run of one that did not succeed is never run again under its id;', - 'get_execution_history shows what it called.', -].join(' '); - -export function defineExecuteSpec(primitives: readonly Primitive[]) { - const known = knownPrimitives(primitives); - return known.publish( - defineCommand('brain', { - name: 'execute_spec', - title: 'Run definition', - description, - route: { method: 'POST', path: '/specs/{primitive}/{name}/execute' }, - reachesOutside: primitives.some(({ reachesOutside }) => reachesOutside), - mayChangeOutside: primitives.some(({ mayChangeOutside }) => mayChangeOutside), - inputSchema: Schema.Struct({ - primitive: known.field, - name: SpecNameField, - input: Schema.optionalKey(InputField), - execution_id: Schema.optionalKey(ExecutionIdField), - }), - outputSchema: RunSchema, - reasons: ['not_found', 'conflict', 'invalid_input', 'unavailable', 'cancelled', 'unanswered'], - handle: Effect.fnUntraced(function* ({ primitive: primitiveName, name, input = {}, execution_id: suppliedId }) { - const primitive = yield* known.primitiveNamed(primitiveName); - return yield* executeRequest(primitives, primitive, { primitive: primitive.name, name, input }, suppliedId); - }), - plainLanguage: runPlainLanguage(primitives), - }), - ); -} diff --git a/packages/specs/src/operations/execution-access.ts b/packages/specs/src/operations/execution-access.ts deleted file mode 100644 index f69b506ac..000000000 --- a/packages/specs/src/operations/execution-access.ts +++ /dev/null @@ -1,45 +0,0 @@ -import { - BrainContext, - BrainReader, - BrainWriter, - messageIdOf, - randomUUIDv7, - streamPrefixOfBrain, - type Lineage, -} from '@beonauto/operations'; -import { Effect } from 'effect'; - -import type { ExecutionFinish, ExecutionStart } from '../execution/execution-commands.ts'; -import { executionDecider, executionStreamOf } from '../execution/execution-decider.ts'; -import { runOf, type ExecutionState, type ExecutionStreamState } from '../execution/execution-state.ts'; -import { commandMetadata } from './command-metadata.ts'; - -export const newExecutionId = Effect.sync(() => randomUUIDv7()); - -export function loadExecutionStream(id: string): Effect.Effect { - return BrainReader.use((reader) => reader.load(executionStreamOf(id), executionDecider)).pipe( - Effect.map(({ state }) => state), - ); -} - -export function loadExecution(id: string): Effect.Effect { - return Effect.map(loadExecutionStream(id), runOf); -} - -export const recordExecution = Effect.fnUntraced(function* ( - id: string, - command: ExecutionStart | ExecutionFinish, - lineage: Lineage, -) { - const metadata = yield* commandMetadata; - const { state, version } = yield* (yield* BrainWriter).execute( - executionStreamOf(id), - executionDecider, - { ...command, ...metadata }, - lineage, - ); - return { - state: runOf(state), - messageId: messageIdOf(`${streamPrefixOfBrain(yield* BrainContext)}${executionStreamOf(id)}`, version), - }; -}); diff --git a/packages/specs/src/operations/execution-failures.test.ts b/packages/specs/src/operations/execution-failures.test.ts deleted file mode 100644 index 53cebd86f..000000000 --- a/packages/specs/src/operations/execution-failures.test.ts +++ /dev/null @@ -1,213 +0,0 @@ -import { Schema } from 'effect'; -import { describe, expect, it } from 'vitest'; - -import { acmeAdmin } from '../testing/callers.ts'; -import { firstMoment, harness, toBrain } from '../testing/harness.ts'; -import { probe, spentUsage } from '../testing/probe.ts'; -import { specOperationsFor } from '../testing/spec-operations.ts'; - -const toAlpha = toBrain('acme', 'alpha'); - -const executionId = '0199a3c4-7d2e-7c1a-9b3f-2f1e0d9c8b7a'; - -const readingTheExecution = toAlpha(acmeAdmin, { execution_id: executionId }); - -async function withPlain() { - const prober = probe(); - const operations = specOperationsFor([prober.primitive]); - const specs = harness(); - await specs.call(operations.createSpec, toAlpha(acmeAdmin, { primitive: 'probe', name: 'plain', source: 'text' })); - const executing = (input: object) => - specs.call( - operations.executeSpec, - toAlpha(acmeAdmin, { primitive: 'probe', name: 'plain', execution_id: executionId, ...input }), - ); - return { ...specs, ...operations, prober, executing }; -} - -const failedExecution = { - execution_id: executionId, - primitive: 'probe', - name: 'plain', - spec_version: 1, - status: 'failed', - started_at: firstMoment, - started_by: 'acme-admin', - finished_at: firstMoment, -}; - -describe('an execution the primitive cannot serve now', () => { - it('is rejected with unavailable, and the rejection is recorded', async () => { - const { call, executing, getExecution, prober } = await withPlain(); - prober.sufferOnNextRun('unavailable'); - - expect(await executing({})).toEqual({ - status: 'rejected', - reason: 'unavailable', - detail: 'The probe cannot answer now', - }); - expect(await call(getExecution, readingTheExecution)).toMatchObject({ - output: { status: 'rejected', rejection: { reason: 'unavailable', detail: 'The probe cannot answer now' } }, - }); - }); -}); - -describe('an execution that names a model the server is not set up for, while it can use others', () => { - it('is rejected with unavailable of that kind and why, both recorded and answered again', async () => { - const { call, executing, getExecution, prober } = await withPlain(); - prober.sufferOnNextRun('unoffered'); - - expect(await executing({})).toEqual({ - status: 'rejected', - reason: 'unavailable', - detail: 'The probe cannot reach that model, only others', - kind: 'model_not_offered', - because: 'provider_not_configured', - }); - expect(await call(getExecution, readingTheExecution)).toMatchObject({ - output: { - status: 'rejected', - rejection: { - reason: 'unavailable', - detail: 'The probe cannot reach that model, only others', - kind: 'model_not_offered', - because: 'provider_not_configured', - }, - }, - }); - }); -}); - -describe('an execution of a spec the primitive cannot run as written', () => { - it('is rejected with conflict, and the rejection is recorded and answered without a kind, as it was given', async () => { - const { call, executing, getExecution, prober } = await withPlain(); - prober.sufferOnNextRun('conflict'); - - expect(await executing({})).toEqual({ - status: 'rejected', - reason: 'conflict', - detail: 'The probe cannot run this spec as written; update it', - }); - expect(await call(getExecution, readingTheExecution)).toStrictEqual({ - status: 'succeeded', - output: { - ...failedExecution, - status: 'rejected', - rejection: { reason: 'conflict', detail: 'The probe cannot run this spec as written; update it' }, - }, - }); - }); -}); - -describe('an execution whose primitive finds, by running it, that its definition is unworkable', () => { - it('is rejected with conflict of that kind, which is recorded and answered again', async () => { - const { call, executing, getExecution, prober } = await withPlain(); - prober.sufferOnNextRun('unworkable'); - const rejection = { - reason: 'conflict', - detail: 'The program of the probe raised an error on line 2: stop', - kind: 'unworkable', - }; - - expect(await executing({})).toEqual({ status: 'rejected', ...rejection }); - expect(await call(getExecution, readingTheExecution)).toStrictEqual({ - status: 'succeeded', - output: { ...failedExecution, status: 'rejected', rejection }, - }); - }); -}); - -describe('an execution whose primitive breaks down', () => { - it('fails with an incident that holds the defect, and is recorded as failed', async () => { - const { call, executing, getExecution, prober, reported } = await withPlain(); - prober.sufferOnNextRun('breakdown'); - - expect(await executing({})).toEqual({ status: 'failed', incident: reported()[0]?.id }); - expect(reported().map(({ original }) => original)).toEqual([new Error('The probe broke down')]); - expect(await call(getExecution, readingTheExecution)).toStrictEqual({ - status: 'succeeded', - output: failedExecution, - }); - }); - - it('fails the same way when the primitive answers with output that is not JSON', async () => { - const { call, executing, getExecution, reported } = await withPlain(); - - expect(await executing({ input: { unmeasurable: true } })).toEqual({ - status: 'failed', - incident: reported()[0]?.id, - }); - expect(Schema.isSchemaError(reported()[0]?.original)).toBe(true); - expect(await call(getExecution, readingTheExecution)).toStrictEqual({ - status: 'succeeded', - output: failedExecution, - }); - }); -}); - -describe('an execution the primitive rejects after it spent something', () => { - it('keeps what the rejection recorded on the run, as get_execution shows it', async () => { - const { call, executing, getExecution, prober } = await withPlain(); - prober.sufferOnNextRun('spent'); - - expect(await executing({})).toEqual({ - status: 'rejected', - reason: 'unavailable', - detail: 'The probe was answered, but not usably', - }); - expect(await call(getExecution, readingTheExecution)).toMatchObject({ - output: { status: 'rejected', record: { usage: spentUsage, duration_ms: 25 } }, - }); - }); - - it('fails with an incident when what it recorded is more than a run may record', async () => { - const { call, executing, getExecution, prober, reported } = await withPlain(); - prober.sufferOnNextRun('overspent'); - - expect(await executing({})).toEqual({ status: 'failed', incident: reported()[0]?.id }); - expect(await call(getExecution, readingTheExecution)).toStrictEqual({ - status: 'succeeded', - output: failedExecution, - }); - }); -}); - -describe('execute_spec rejecting', () => { - it('a spec the brain does not have, and a primitive it does not know', async () => { - const { call, executeSpec } = await withPlain(); - - expect(await call(executeSpec, toAlpha(acmeAdmin, { primitive: 'probe', name: 'ghost' }))).toEqual({ - status: 'rejected', - reason: 'not_found', - detail: 'There is no probe definition ghost in this brain', - }); - expect(await call(executeSpec, toAlpha(acmeAdmin, { primitive: 'echo', name: 'plain' }))).toEqual({ - status: 'rejected', - reason: 'not_found', - detail: 'There is no primitive echo', - }); - }); - - it('a spec whose document its primitive no longer parses, recording nothing', async () => { - const { executing, ledger, prober } = await withPlain(); - prober.rejectEveryDocument(); - - expect(await executing({})).toEqual({ - status: 'rejected', - reason: 'conflict', - detail: - 'The probe definition plain at version 1 no longer parses (The probe document has lines it does not accept); update it', - kind: 'unworkable', - }); - expect(ledger.streamNames()).toEqual(['brain/acme/alpha/specs/probe']); - }); - - it('an execution id that is not a UUID', async () => { - const { executing } = await withPlain(); - - expect(await executing({ execution_id: 'run-1' })).toMatchObject({ - reason: 'invalid_input', - issues: [{ pointer: '/execution_id', detail: 'Expected a UUID' }], - }); - }); -}); diff --git a/packages/specs/src/operations/execution-idempotency.test.ts b/packages/specs/src/operations/execution-idempotency.test.ts deleted file mode 100644 index 92a599de4..000000000 --- a/packages/specs/src/operations/execution-idempotency.test.ts +++ /dev/null @@ -1,135 +0,0 @@ -import { Effect } from 'effect'; -import { describe, expect, it } from 'vitest'; - -import { acmeAdmin } from '../testing/callers.ts'; -import { echo } from '../testing/echo.ts'; -import { firstMoment, harness, toBrain } from '../testing/harness.ts'; -import { probe } from '../testing/probe.ts'; -import { specOperationsFor } from '../testing/spec-operations.ts'; - -const toAlpha = toBrain('acme', 'alpha'); - -const executionId = '0199a3c4-7d2e-7c1a-9b3f-2f1e0d9c8b7a'; - -const later = '2026-10-02T14:15:00.000Z'; - -async function withPlain() { - const prober = probe(); - const operations = specOperationsFor([prober.primitive, echo]); - const specs = harness(); - const creating = (name: string) => - specs.call(operations.createSpec, toAlpha(acmeAdmin, { primitive: 'probe', name, source: name })); - await creating('plain'); - await creating('other'); - const executingOf = (primitive: string, name: string, input: object, at?: string) => - specs.call(operations.executeSpec, toAlpha(acmeAdmin, { primitive, name, input, execution_id: executionId }), at); - const executing = (input: object = {}, at?: string) => executingOf('probe', 'plain', input, at); - return { ...specs, ...operations, prober, executing, executingOf }; -} - -describe('an execution that succeeded', () => { - it('is answered again for its id without running the primitive again', async () => { - const { executing, prober } = await withPlain(); - const first = await executing({ who: 'Ada' }); - - expect(first).toMatchObject({ status: 'succeeded', output: { execution_id: executionId } }); - expect(await executing({ who: 'Ada' }, later)).toEqual(first); - expect(prober.runs()).toBe(1); - }); - - it('is answered again after its spec changed or was retired', async () => { - const { call, executing, prober, retireSpec, updateSpec } = await withPlain(); - const first = await executing(); - await call(updateSpec, toAlpha(acmeAdmin, { primitive: 'probe', name: 'plain', source: 'newer' })); - - expect(await executing()).toEqual(first); - await call(retireSpec, toAlpha(acmeAdmin, { primitive: 'probe', name: 'plain' })); - expect(await executing()).toEqual(first); - expect(prober.runs()).toBe(1); - }); -}); - -describe('an execution whose input the primitive rejected', () => { - it('is rejected again for its id the same way, without running the primitive again', async () => { - const { executing, prober } = await withPlain(); - const first = await executing({ reject: true }); - - expect(first).toMatchObject({ status: 'rejected', reason: 'invalid_input' }); - expect(await executing({ reject: true })).toEqual(first); - expect(prober.runs()).toBe(1); - }); -}); - -describe('an execution id', () => { - it('belongs to one spec and one input: another primitive, spec or input meets conflict', async () => { - const { executing, executingOf, prober } = await withPlain(); - await executing({ who: 'Ada' }); - const taken = { - status: 'rejected', - reason: 'conflict', - detail: 'The run id belongs to a run of another definition or with another input', - }; - - expect(await executingOf('probe', 'other', { who: 'Ada' })).toEqual(taken); - expect(await executingOf('echo', 'plain', { who: 'Ada' })).toEqual(taken); - expect(await executing({ who: 'Bob' })).toEqual(taken); - expect(prober.runs()).toBe(1); - }); - - it('lets one of two calls that start it at the same moment record the start, and the other meets conflict', async () => { - const { dispatch, executeSpec, prober, run } = await withPlain(); - const executing = dispatch( - executeSpec, - toAlpha(acmeAdmin, { primitive: 'probe', name: 'plain', execution_id: executionId }), - ); - - expect(await run(Effect.all([executing, executing], { concurrency: 'unbounded' }))).toMatchObject([ - { status: 'succeeded' }, - { status: 'rejected', reason: 'conflict' }, - ]); - expect(prober.runs()).toBe(1); - }); -}); - -describe('an execution without a final result', () => { - it('is recorded failed when its call is cancelled while the primitive runs, and runs again for its id', async () => { - const { call, callCancelledWhen, executeSpec, executing, getExecution, prober } = await withPlain(); - prober.sufferOnNextRun('stall'); - - expect( - await callCancelledWhen( - prober.stalled, - executeSpec, - toAlpha(acmeAdmin, { primitive: 'probe', name: 'plain', input: {}, execution_id: executionId }), - ), - ).toEqual({ status: 'cancelled' }); - expect(await call(getExecution, toAlpha(acmeAdmin, { execution_id: executionId }))).toMatchObject({ - output: { status: 'failed', finished_at: firstMoment }, - }); - expect(await executing({}, later)).toMatchObject({ - output: { status: 'succeeded', started_at: later, finished_at: later }, - }); - expect(prober.runs()).toBe(2); - }); - - it('runs the updated spec again for its id after the primitive found it could not run as written', async () => { - const { call, executing, prober, updateSpec } = await withPlain(); - prober.sufferOnNextRun('conflict'); - - expect(await executing()).toMatchObject({ status: 'rejected', reason: 'conflict' }); - await call(updateSpec, toAlpha(acmeAdmin, { primitive: 'probe', name: 'plain', source: 'fixed' })); - expect(await executing()).toMatchObject({ status: 'succeeded', output: { spec_version: 2, status: 'succeeded' } }); - expect(prober.runs()).toBe(2); - }); - - it('runs again for its id after the primitive was unavailable or broke down', async () => { - const { executing, prober } = await withPlain(); - prober.sufferOnNextRun('unavailable'); - await executing(); - prober.sufferOnNextRun('breakdown'); - await executing(); - - expect(await executing()).toMatchObject({ status: 'succeeded', output: { status: 'succeeded' } }); - expect(prober.runs()).toBe(3); - }); -}); diff --git a/packages/specs/src/operations/execution-limits.test.ts b/packages/specs/src/operations/execution-limits.test.ts deleted file mode 100644 index e15aa91d1..000000000 --- a/packages/specs/src/operations/execution-limits.test.ts +++ /dev/null @@ -1,97 +0,0 @@ -import { describe, expect, it } from 'vitest'; - -import { acmeAdmin } from '../testing/callers.ts'; -import { harness, toBrain } from '../testing/harness.ts'; -import { probe } from '../testing/probe.ts'; -import { specOperationsFor } from '../testing/spec-operations.ts'; - -const toAlpha = toBrain('acme', 'alpha'); - -const executionId = '0199a3c4-7d2e-7c1a-9b3f-2f1e0d9c8b7a'; - -async function withPlain() { - const operations = specOperationsFor([probe().primitive]); - const specs = harness(); - await specs.call(operations.createSpec, toAlpha(acmeAdmin, { primitive: 'probe', name: 'plain', source: 'text' })); - const executing = (input: unknown, id = executionId) => - specs.call( - operations.executeSpec, - toAlpha(acmeAdmin, { primitive: 'probe', name: 'plain', input, execution_id: id }), - ); - return { ...specs, ...operations, executing }; -} - -const inputTooLarge = { - status: 'rejected', - reason: 'invalid_input', - detail: 'The input does not match the input schema', - issues: [{ detail: 'Expected an input of at most 262144 bytes as JSON in UTF-8', pointer: '/input' }], -}; - -describe('the input of an execution', () => { - it('may take 262144 bytes as JSON in UTF-8, whatever its length in characters', async () => { - const { executing } = await withPlain(); - - expect(await executing('a'.repeat(262_142))).toMatchObject({ status: 'succeeded' }); - expect(await executing('é'.repeat(131_071), '0199a3c4-7d2e-7c1a-9b3f-000000000002')).toMatchObject({ - status: 'succeeded', - }); - }); - - it('is rejected above that, before anything is recorded', async () => { - const { executing, ledger } = await withPlain(); - - expect(await executing('a'.repeat(262_143))).toEqual(inputTooLarge); - expect(await executing({ text: 'é'.repeat(131_070) })).toEqual(inputTooLarge); - expect(ledger.streamNames()).toEqual(['brain/acme/alpha/specs/probe']); - }); -}); - -function nested(levels: number, innermost: unknown = 1): unknown { - return levels === 0 ? innermost : nested(levels - 1, levels % 2 === 0 ? [innermost] : { inner: innermost }); -} - -describe('the nesting of the input of an execution', () => { - it('may go 512 levels deep, as deep as a workflow holds a value', async () => { - const { executing } = await withPlain(); - - expect(await executing(nested(512))).toMatchObject({ status: 'succeeded' }); - }); - - it('is rejected deeper than that, before anything is recorded, however deep it goes', async () => { - const { executing, ledger } = await withPlain(); - const tooDeep = { - status: 'rejected', - reason: 'invalid_input', - detail: 'The input does not match the input schema', - issues: [{ detail: 'Expected an input that nests at most 512 levels deep', pointer: '/input' }], - }; - - expect(await executing(nested(513))).toEqual(tooDeep); - expect(await executing(nested(3000))).toEqual(tooDeep); - expect(ledger.streamNames()).toEqual(['brain/acme/alpha/specs/probe']); - }); -}); - -describe('the output and the record of an execution', () => { - it('may take 1048576 bytes together as JSON in UTF-8', async () => { - const { executing } = await withPlain(); - - expect(await executing({ bulk: 1_048_572 })).toMatchObject({ - status: 'succeeded', - output: { status: 'succeeded' }, - }); - }); - - it('fail the execution above that, as a breakdown of the primitive that is recorded', async () => { - const { call, executing, getExecution, reported } = await withPlain(); - - expect(await executing({ bulk: 1_048_573 })).toEqual({ status: 'failed', incident: reported()[0]?.id }); - expect(reported().map(({ original }) => original)).toEqual([ - new Error('The primitive answered with 1048577 bytes to record, more than the 1048576 allowed'), - ]); - expect(await call(getExecution, toAlpha(acmeAdmin, { execution_id: executionId }))).toMatchObject({ - output: { status: 'failed' }, - }); - }); -}); diff --git a/packages/specs/src/operations/execution-running.ts b/packages/specs/src/operations/execution-running.ts deleted file mode 100644 index 88ea93b1c..000000000 --- a/packages/specs/src/operations/execution-running.ts +++ /dev/null @@ -1,155 +0,0 @@ -import { BrainContext, BrainReader, CallLineage, Caller, Conflict, type GivenLineage } from '@beonauto/operations'; -import { Cause, Effect, Option } from 'effect'; - -import type { ExecutionOutcome, ExecutionRequest, InterruptedAttempt } from '../execution/execution-commands.ts'; -import { claimOf, runTaken } from '../execution/execution-decisions.ts'; -import { answerOf } from '../execution/execution-lookup.ts'; -import type { Primitive, PreparedDefinition, RunContext, RunLineage } from '../primitive/primitive.ts'; -import { toolCallJournal, type RunJournal } from '../tool-calls/tool-call-journal.ts'; -import { loadExecution, loadExecutionStream, newExecutionId, recordExecution } from './execution-access.ts'; -import { attempt, failedAttempt, interruptedAttempt } from './execution-attempt.ts'; -import { preparedSpec, preparedVersion, type VersionToRun } from './spec-preparation.ts'; - -export const mostCallDepth = 8; - -interface JournalledRun extends RunContext { - readonly journal: RunJournal; -} - -interface Called extends GivenLineage { - readonly primitives: readonly Primitive[]; -} - -function finishedBy(id: string, execution: JournalledRun, outcome: ExecutionOutcome | InterruptedAttempt) { - return Effect.gen(function* () { - const causationId = - outcome.type === 'execution_deferred' ? execution.lineage.startId : yield* execution.journal.latest; - const lineage = { causationId, correlationId: execution.lineage.correlationId }; - return yield* recordExecution(id, { type: 'finish', result: outcome }, lineage); - }); -} - -interface Prepared { - readonly spec: VersionToRun; - readonly prepared: PreparedDefinition; - readonly createOnly?: true; -} - -function tooDeep(callDepth: number): Conflict { - return new Conflict({ - detail: `This run would sit ${callDepth} calls below the run at the top of its tree, more than the ${mostCallDepth} a run may: workflows that call workflows reach at most ${mostCallDepth} calls deep`, - }); -} - -const longestRunIn = Effect.fnUntraced(function* (primitive: Primitive, name: string) { - const { prepared } = yield* preparedSpec(primitive, name); - return prepared.longestRunMs; -}); - -const longestRunsIn = Effect.fnUntraced(function* (primitives: readonly Primitive[]) { - const reader = yield* BrainReader; - return (primitiveName: string, name: string): Effect.Effect => { - const primitive = primitives.find((candidate) => candidate.name === primitiveName); - return primitive === undefined - ? Effect.undefined - : longestRunIn(primitive, name).pipe( - Effect.option, - Effect.map(Option.getOrUndefined), - Effect.provideService(BrainReader, reader), - ); - }; -}); - -function startOf(request: ExecutionRequest, { spec, prepared, createOnly }: Prepared, given: GivenLineage) { - const { depth, callDepth, calledBy, trigger } = given; - return { - type: 'start' as const, - ...request, - spec_version: spec.version, - calls_tools: prepared.callsTools, - finishes_later: prepared.finishesLater, - depth, - call_depth: callDepth, - ...(calledBy === null ? {} : { called_by: calledBy }), - ...(trigger === null ? {} : { trigger }), - ...(createOnly === true ? { createOnly } : {}), - }; -} - -const contextOf = Effect.fnUntraced(function* (id: string, { spec }: Prepared, lineage: RunLineage, given: Called) { - const { org, brain } = yield* BrainContext; - const execution: JournalledRun = { - id, - org, - brain, - caller: yield* Caller, - spec: { name: spec.name, version: spec.version }, - journal: yield* toolCallJournal(id, lineage), - lineage, - depth: given.depth, - callDepth: given.callDepth, - longestRunOf: yield* longestRunsIn(given.primitives), - }; - return execution; -}); - -const runExecution = Effect.fnUntraced(function* ( - primitives: readonly Primitive[], - id: string, - request: ExecutionRequest, - run: Prepared, -) { - const given: Called = { ...(yield* CallLineage), primitives }; - if (given.callDepth > mostCallDepth) { - return yield* tooDeep(given.callDepth); - } - const correlationId = given.lineage?.correlationId ?? id; - const recorded = yield* Effect.uninterruptibleMask((restore) => - Effect.gen(function* () { - const { messageId } = yield* recordExecution(id, startOf(request, run, given), { - causationId: given.lineage?.causationId ?? null, - correlationId, - }); - const execution = yield* contextOf(id, run, { startId: messageId, correlationId }, given); - const executing = run.prepared.execute(request.input, execution); - const result = yield* attempt(run.prepared.whenCancelled === 'finish' ? executing : restore(executing)).pipe( - Effect.onError((cause) => - Effect.ignore(finishedBy(id, execution, Cause.hasInterruptsOnly(cause) ? interruptedAttempt : failedAttempt)), - ), - ); - return yield* finishedBy(id, execution, result); - }), - ); - return yield* answerOf(id, recorded.state); -}); - -export const executeRequest = Effect.fnUntraced(function* ( - primitives: readonly Primitive[], - primitive: Primitive, - request: ExecutionRequest, - suppliedId: string | undefined, -) { - const id = suppliedId ?? (yield* newExecutionId); - const recorded = yield* loadExecutionStream(id); - const claim = yield* Effect.fromResult(claimOf(recorded, request)); - return claim === 'answer' - ? yield* answerOf(id, recorded) - : yield* runExecution(primitives, id, request, yield* preparedSpec(primitive, request.name)); -}); - -function isTaken(error: unknown): error is Conflict { - return error instanceof Conflict && error.kind === runTaken.kind; -} - -export const startVersionOnce = Effect.fnUntraced(function* ( - primitives: readonly Primitive[], - primitive: Primitive, - request: ExecutionRequest & { readonly version: number }, - id: string, -) { - const { version, ...asked } = request; - const run = yield* preparedVersion(primitive, asked.name, version); - return yield* runExecution(primitives, id, asked, { ...run, createOnly: true }).pipe( - Effect.catchIf(isTaken, () => Effect.flatMap(loadExecution(id), (recorded) => answerOf(id, recorded))), - ); -}); diff --git a/packages/specs/src/operations/get-execution.test.ts b/packages/specs/src/operations/get-execution.test.ts deleted file mode 100644 index 67e11287a..000000000 --- a/packages/specs/src/operations/get-execution.test.ts +++ /dev/null @@ -1,68 +0,0 @@ -import { describe, expect, it } from 'vitest'; - -import { getExecution } from '../index.ts'; -import { acmeAdmin } from '../testing/callers.ts'; -import { echo } from '../testing/echo.ts'; -import { asQueryString, harness, toBrain } from '../testing/harness.ts'; -import { specOperationsFor } from '../testing/spec-operations.ts'; - -const { createSpec, executeSpec } = specOperationsFor([echo]); - -const toAlpha = toBrain('acme', 'alpha'); - -const executionId = '0199a3c4-7d2e-7c1a-9b3f-2f1e0d9c8b7a'; - -async function withExecution() { - const specs = harness(); - await specs.call(createSpec, toAlpha(acmeAdmin, { primitive: 'echo', name: 'greet', source: '{"greeting": "Hi"}' })); - await specs.call(executeSpec, toAlpha(acmeAdmin, { primitive: 'echo', name: 'greet', execution_id: executionId })); - return specs; -} - -describe('get_execution', () => { - it('is a brain query at GET /executions/{execution_id} that may meet not_found', () => { - expect(getExecution.registration).toMatchObject({ - scope: 'brain', - kind: 'query', - title: 'Get run', - route: { method: 'GET', path: '/executions/{execution_id}' }, - pathParameters: ['execution_id'], - successStatus: 200, - reasons: ['not_found'], - }); - }); - - it('reads an execution by its id in any case, from JSON and from a query string', async () => { - const { call } = await withExecution(); - const found = { status: 'succeeded', output: { execution_id: executionId, status: 'succeeded' } }; - - expect(await call(getExecution, toAlpha(acmeAdmin, { execution_id: executionId.toUpperCase() }))).toMatchObject( - found, - ); - expect(await call(getExecution, asQueryString(toAlpha(acmeAdmin, { execution_id: executionId })))).toMatchObject( - found, - ); - }); -}); - -describe('get_execution rejecting', () => { - it('an id the brain has no execution for', async () => { - const { call } = await withExecution(); - const unknownId = '0199a3c4-7d2e-7c1a-9b3f-000000000000'; - - expect(await call(getExecution, toAlpha(acmeAdmin, { execution_id: unknownId }))).toEqual({ - status: 'rejected', - reason: 'not_found', - detail: `There is no run ${unknownId} in this brain`, - }); - }); - - it('an id that is not a UUID and a field the operation does not know', async () => { - expect( - await harness().call(getExecution, toAlpha(acmeAdmin, { execution_id: 'latest', primitive: 'echo' })), - ).toMatchObject({ - reason: 'invalid_input', - issues: [{ pointer: '/primitive' }, { pointer: '/execution_id', detail: 'Expected a UUID' }], - }); - }); -}); diff --git a/packages/specs/src/operations/get-execution.ts b/packages/specs/src/operations/get-execution.ts deleted file mode 100644 index fa561fa4d..000000000 --- a/packages/specs/src/operations/get-execution.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { defineQuery } from '@beonauto/operations'; -import { Effect, Schema } from 'effect'; - -import { executionDetailOf } from '../execution/execution-lookup.ts'; -import { RunDetailSchema } from '../execution/execution.ts'; -import { runWordsFor } from '../plain-language/run-words.ts'; -import type { Primitive } from '../primitive/primitive.ts'; -import { loadExecution } from './execution-access.ts'; -import { ExecutionIdField } from './spec-fields.ts'; - -export function defineGetExecution(primitives: readonly Primitive[]) { - const runWords = runWordsFor(primitives); - return defineQuery('brain', { - name: 'get_execution', - title: 'Get run', - description: [ - 'Reads one run of the brain by its id: the definition and version that ran, who started it and when, its status,', - 'and its output or why it did not succeed, with the record its type keeps, such as the prompt and tokens of a reasoning function.', - 'A run is started until it ends as succeeded, rejected or failed.', - 'Use it to tell the person how a run ended; get_execution_history shows each step and tool call of the run.', - '`execution_id` is the id execute_spec answered with or was given.', - ].join(' '), - route: { method: 'GET', path: '/executions/{execution_id}' }, - inputSchema: Schema.Struct({ execution_id: ExecutionIdField }), - outputSchema: RunDetailSchema, - reasons: ['not_found'], - handle: ({ execution_id: id }) => loadExecution(id).pipe(Effect.flatMap((state) => executionDetailOf(id, state))), - plainLanguage: { - task: 'look up a run', - attempt: () => 'look up the run', - outcome: (execution) => runWords(execution, 'looked up'), - }, - }); -} - -export const getExecution = defineGetExecution([]); diff --git a/packages/specs/src/operations/get-spec.test.ts b/packages/specs/src/operations/get-spec.test.ts deleted file mode 100644 index cde51ac8f..000000000 --- a/packages/specs/src/operations/get-spec.test.ts +++ /dev/null @@ -1,80 +0,0 @@ -import { describe, expect, it } from 'vitest'; - -import { acmeAdmin } from '../testing/callers.ts'; -import { echo } from '../testing/echo.ts'; -import { asQueryString, firstMoment, harness, toBrain } from '../testing/harness.ts'; -import { probe } from '../testing/probe.ts'; -import { specOperationsFor } from '../testing/spec-operations.ts'; - -const { createSpec, getSpec, retireSpec } = specOperationsFor([echo, probe().primitive]); - -const toAlpha = toBrain('acme', 'alpha'); - -const later = '2026-10-02T14:15:00.000Z'; - -async function withActiveAndRetiredSpecs() { - const specs = harness(); - await specs.call(createSpec, toAlpha(acmeAdmin, { primitive: 'probe', name: 'plain', source: 'text' })); - await specs.call(createSpec, toAlpha(acmeAdmin, { primitive: 'probe', name: 'stale', source: 'old' })); - await specs.call(retireSpec, toAlpha(acmeAdmin, { primitive: 'probe', name: 'stale' }), later); - return specs; -} - -describe('get_spec', () => { - it('is a brain query at GET /specs/{primitive}/{name} that may meet not_found', () => { - expect(getSpec.registration).toMatchObject({ - scope: 'brain', - kind: 'query', - title: 'Get definition', - route: { method: 'GET', path: '/specs/{primitive}/{name}' }, - pathParameters: ['primitive', 'name'], - successStatus: 200, - reasons: ['not_found'], - }); - }); - - it('reads a spec with its document, active or retired, from JSON and from a query string', async () => { - const { call } = await withActiveAndRetiredSpecs(); - - expect(await call(getSpec, toAlpha(acmeAdmin, { primitive: 'probe', name: 'plain' }))).toStrictEqual({ - status: 'succeeded', - output: { - primitive: 'probe', - name: 'plain', - version: 1, - status: 'active', - media_type: 'text/plain', - created_at: firstMoment, - created_by: 'acme-admin', - updated_at: firstMoment, - source: 'text', - }, - }); - expect(await call(getSpec, asQueryString(toAlpha(acmeAdmin, { primitive: 'probe', name: 'stale' })))).toMatchObject( - { status: 'succeeded', output: { status: 'retired', retired_at: later, source: 'old' } }, - ); - }); -}); - -describe('get_spec rejecting', () => { - it('a spec the primitive does not have in the brain, and a primitive it does not know', async () => { - const { call } = await withActiveAndRetiredSpecs(); - - expect(await call(getSpec, toAlpha(acmeAdmin, { primitive: 'echo', name: 'plain' }))).toEqual({ - status: 'rejected', - reason: 'not_found', - detail: 'There is no echo definition plain in this brain', - }); - expect(await call(getSpec, toAlpha(acmeAdmin, { primitive: 'reason', name: 'plain' }))).toEqual({ - status: 'rejected', - reason: 'not_found', - detail: 'There is no primitive reason', - }); - }); - - it('a malformed name and a field the operation does not know', async () => { - expect( - await harness().call(getSpec, toAlpha(acmeAdmin, { primitive: 'probe', name: 'no_where', verbose: true })), - ).toMatchObject({ reason: 'invalid_input', issues: [{ pointer: '/verbose' }, { pointer: '/name' }] }); - }); -}); diff --git a/packages/specs/src/operations/get-spec.ts b/packages/specs/src/operations/get-spec.ts deleted file mode 100644 index fe47d1517..000000000 --- a/packages/specs/src/operations/get-spec.ts +++ /dev/null @@ -1,50 +0,0 @@ -import { BrainContext, defineQuery } from '@beonauto/operations'; -import { Effect, Schema } from 'effect'; - -import { specStanding, specWordsFor, whatItDoes } from '../plain-language/spec-words.ts'; -import { knownPrimitives } from '../primitive/known-primitives.ts'; -import type { Primitive } from '../primitive/primitive.ts'; -import { findSpec } from '../registry/registry-lookup.ts'; -import { DefinitionSchema } from '../registry/spec.ts'; -import { loadRegistry, triggersSince } from './registry-access.ts'; -import { SpecNameField } from './spec-fields.ts'; -import { specOf } from './spec-views.ts'; - -export function defineGetSpec(primitives: readonly Primitive[]) { - const known = knownPrimitives(primitives); - const words = specWordsFor(primitives); - return known.publish( - defineQuery('brain', { - name: 'get_spec', - title: 'Get definition', - description: [ - 'Reads one function or workflow definition with its document and returns it, active or retired, with its version and the input and output its document declares.', - 'For a recall function it also returns the standing of its view: live, rebuilding, waiting or stalled, the events it has folded and how far it lags the brain.', - 'For a workflow it also returns its triggers, the event trigger and the schedules that start it on its own.', - 'Use it to show the person a definition or to learn the input a run takes; list_specs lists the definitions of a type.', - "`primitive` is the definition's type and `name` its name.", - ].join(' '), - route: { method: 'GET', path: '/specs/{primitive}/{name}' }, - inputSchema: Schema.Struct({ primitive: known.field, name: SpecNameField }), - outputSchema: DefinitionSchema, - reasons: ['not_found'], - handle: Effect.fnUntraced(function* ({ primitive: primitiveName, name }) { - const primitive = yield* known.primitiveNamed(primitiveName); - const registry = yield* loadRegistry(primitive.name); - const stored = yield* findSpec(registry, primitive.name, name); - const spec = - (stored.triggers ?? []).length > 0 && stored.status === 'active' - ? { ...specOf(primitive, stored), triggers_since: yield* triggersSince(primitive.name, stored) } - : specOf(primitive, stored); - const { org, brain } = yield* BrainContext; - const standing = yield* primitive.standing({ org, brain, name, version: spec.version, status: spec.status }); - return standing === undefined ? spec : { ...spec, standing }; - }), - plainLanguage: { - task: `look up a ${words.kinds}`, - attempt: ({ primitive, name }) => `look up ${words.named(primitive, name)}`, - outcome: (spec) => `${specStanding(words, spec)}${whatItDoes(spec)}`, - }, - }), - ); -} diff --git a/packages/specs/src/operations/isolation.test.ts b/packages/specs/src/operations/isolation.test.ts deleted file mode 100644 index f41477a69..000000000 --- a/packages/specs/src/operations/isolation.test.ts +++ /dev/null @@ -1,95 +0,0 @@ -import { describe, expect, it } from 'vitest'; - -import { acmeAdmin, acmeAlphaKeeper, acmeReader, globexAdmin } from '../testing/callers.ts'; -import { echo } from '../testing/echo.ts'; -import { harness, toBrain } from '../testing/harness.ts'; -import { specOperationsFor } from '../testing/spec-operations.ts'; - -const { createSpec, executeSpec, getExecution, getSpec, listSpecs, retireSpec, updateSpec } = specOperationsFor([echo]); - -const toAlpha = toBrain('acme', 'alpha'); - -const toBeta = toBrain('acme', 'beta'); - -const toGamma = toBrain('globex', 'gamma'); - -const executionId = '0199a3c4-7d2e-7c1a-9b3f-2f1e0d9c8b7a'; - -const greet = { primitive: 'echo', name: 'greet' }; - -const hello = { ...greet, source: '{"greeting": "Hello"}' }; - -describe('the specs and executions of a brain', () => { - it('are invisible from another brain of the org and from a brain of another org', async () => { - const { call, ledger } = harness(); - await call(createSpec, toAlpha(acmeAdmin, hello)); - await call(executeSpec, toAlpha(acmeAdmin, { ...greet, execution_id: executionId })); - - expect(await call(getSpec, toBeta(acmeAdmin, greet))).toMatchObject({ reason: 'not_found' }); - expect(await call(listSpecs, toGamma(globexAdmin, { primitive: 'echo' }))).toEqual({ - status: 'succeeded', - output: { specs: [] }, - }); - expect(await call(getExecution, toBeta(acmeAdmin, { execution_id: executionId }))).toMatchObject({ - reason: 'not_found', - }); - expect(await call(getExecution, toGamma(globexAdmin, { execution_id: executionId }))).toMatchObject({ - reason: 'not_found', - }); - expect(ledger.streamNames()).toEqual(['brain/acme/alpha/specs/echo', `brain/acme/alpha/executions/${executionId}`]); - }); - - it('are apart from those of another brain that uses the same names and execution ids', async () => { - const { call } = harness(); - await call(createSpec, toAlpha(acmeAdmin, hello)); - await call(createSpec, toGamma(globexAdmin, { ...greet, source: '{"greeting": "Hola"}' })); - await call(executeSpec, toAlpha(acmeAdmin, { ...greet, execution_id: executionId })); - - expect(await call(executeSpec, toGamma(globexAdmin, { ...greet, execution_id: executionId }))).toMatchObject({ - output: { output: { greeting: 'Hola' }, started_by: 'globex-admin' }, - }); - }); - - it('cannot be reached by a caller of another org, whether they exist or not', async () => { - const { call } = harness(); - await call(createSpec, toAlpha(acmeAdmin, hello)); - const foreign = { status: 'rejected', reason: 'forbidden', detail: 'The caller does not belong to this org' }; - - expect(await call(getSpec, toAlpha(globexAdmin, greet))).toEqual(foreign); - expect(await call(executeSpec, toAlpha(globexAdmin, greet))).toEqual(foreign); - expect(await call(getExecution, toAlpha(globexAdmin, { execution_id: executionId }))).toEqual(foreign); - }); -}); - -describe('a caller that may only read', () => { - it('reads specs and executions, and is rejected for the commands, executing included', async () => { - const { call } = harness(); - await call(createSpec, toAlpha(acmeAdmin, hello)); - await call(executeSpec, toAlpha(acmeAdmin, { ...greet, execution_id: executionId })); - const readOnly = { status: 'rejected', reason: 'forbidden', detail: 'The caller lacks the brain:write permission' }; - - expect(await call(createSpec, toAlpha(acmeReader, { ...hello, name: 'wave' }))).toEqual(readOnly); - expect(await call(updateSpec, toAlpha(acmeReader, hello))).toEqual(readOnly); - expect(await call(retireSpec, toAlpha(acmeReader, greet))).toEqual(readOnly); - expect(await call(executeSpec, toAlpha(acmeReader, greet))).toEqual(readOnly); - expect(await call(getSpec, toAlpha(acmeReader, greet))).toMatchObject({ status: 'succeeded' }); - expect(await call(listSpecs, toAlpha(acmeReader, { primitive: 'echo' }))).toMatchObject({ status: 'succeeded' }); - expect(await call(getExecution, toAlpha(acmeReader, { execution_id: executionId }))).toMatchObject({ - status: 'succeeded', - }); - }); -}); - -describe('a caller limited to some brains', () => { - it('works with the specs of its brains and is denied every other brain', async () => { - const { call } = harness(); - const denied = { status: 'rejected', reason: 'forbidden', detail: 'The caller may not access this brain' }; - - expect(await call(createSpec, toAlpha(acmeAlphaKeeper, hello))).toMatchObject({ - output: { created_by: 'acme-alpha-keeper' }, - }); - expect(await call(executeSpec, toAlpha(acmeAlphaKeeper, greet))).toMatchObject({ status: 'succeeded' }); - expect(await call(createSpec, toBeta(acmeAlphaKeeper, hello))).toEqual(denied); - expect(await call(listSpecs, toBeta(acmeAlphaKeeper, { primitive: 'echo' }))).toEqual(denied); - }); -}); diff --git a/packages/specs/src/operations/list-specs.test.ts b/packages/specs/src/operations/list-specs.test.ts deleted file mode 100644 index 2f09c490a..000000000 --- a/packages/specs/src/operations/list-specs.test.ts +++ /dev/null @@ -1,123 +0,0 @@ -import { describe, expect, it } from 'vitest'; - -import { acmeAdmin } from '../testing/callers.ts'; -import { echo } from '../testing/echo.ts'; -import { asQueryString, firstMoment, harness, toBrain } from '../testing/harness.ts'; -import { probe } from '../testing/probe.ts'; -import { specOperationsFor } from '../testing/spec-operations.ts'; - -const { createSpec, listSpecs, retireSpec } = specOperationsFor([echo, probe().primitive]); - -const toAlpha = toBrain('acme', 'alpha'); - -const later = '2026-10-02T14:15:00.000Z'; - -const activeOnly = { status: 'succeeded', output: { specs: [{ name: 'alpha' }, { name: 'gamma' }] } }; - -const retiredToo = { - status: 'succeeded', - output: { specs: [{ name: 'alpha' }, { name: 'beta', status: 'retired', retired_at: later }, { name: 'gamma' }] }, -}; - -function listed(name: string) { - return { - primitive: 'probe', - name, - version: 1, - status: 'active', - media_type: 'text/plain', - created_at: firstMoment, - created_by: 'acme-admin', - updated_at: firstMoment, - }; -} - -async function withGammaAlphaAndRetiredBeta() { - const specs = harness(); - const creating = (name: string) => - specs.call(createSpec, toAlpha(acmeAdmin, { primitive: 'probe', name, source: name })); - await creating('gamma'); - await creating('alpha'); - await creating('beta'); - await specs.call(retireSpec, toAlpha(acmeAdmin, { primitive: 'probe', name: 'beta' }), later); - await specs.call(createSpec, toAlpha(acmeAdmin, { primitive: 'echo', name: 'delta', source: '{"greeting": "Hi"}' })); - return specs; -} - -describe('list_specs', () => { - it('is a brain query at GET /specs/{primitive} that may meet not_found', () => { - expect(listSpecs.registration).toMatchObject({ - scope: 'brain', - kind: 'query', - title: 'List definitions', - route: { method: 'GET', path: '/specs/{primitive}' }, - pathParameters: ['primitive'], - successStatus: 200, - reasons: ['not_found'], - }); - }); - - it('lists nothing for a primitive without specs', async () => { - expect(await harness().call(listSpecs, toAlpha(acmeAdmin, { primitive: 'echo' }))).toEqual({ - status: 'succeeded', - output: { specs: [] }, - }); - }); - - it('lists the active specs of the primitive sorted by name, without their documents', async () => { - const { call } = await withGammaAlphaAndRetiredBeta(); - - expect(await call(listSpecs, toAlpha(acmeAdmin, { primitive: 'probe' }))).toStrictEqual({ - status: 'succeeded', - output: { specs: [listed('alpha'), listed('gamma')] }, - }); - expect(await call(listSpecs, toAlpha(acmeAdmin, { primitive: 'echo' }))).toMatchObject({ - output: { specs: [{ primitive: 'echo', name: 'delta', media_type: 'application/json' }] }, - }); - }); - - it('rejects a primitive it does not know', async () => { - expect(await harness().call(listSpecs, toAlpha(acmeAdmin, { primitive: 'inference' }))).toEqual({ - status: 'rejected', - reason: 'not_found', - detail: 'There is no primitive inference', - }); - }); -}); - -describe('include_retired', () => { - it('lists the retired specs too when true in JSON', async () => { - const { call } = await withGammaAlphaAndRetiredBeta(); - const listing = (input: object) => call(listSpecs, toAlpha(acmeAdmin, { primitive: 'probe', ...input })); - - expect(await listing({ include_retired: true })).toMatchObject(retiredToo); - expect(await listing({ include_retired: false })).toMatchObject(activeOnly); - expect(await listing({ include_retired: 'true' })).toMatchObject({ - reason: 'invalid_input', - issues: [{ pointer: '/include_retired', detail: 'Expected boolean' }], - }); - }); - - it('lists the retired specs too when true in a query string', async () => { - const { call } = await withGammaAlphaAndRetiredBeta(); - const listing = (input: object) => - call(listSpecs, asQueryString(toAlpha(acmeAdmin, { primitive: 'probe', ...input }))); - - expect(await listing({ include_retired: 'true' })).toMatchObject(retiredToo); - expect(await listing({ include_retired: 'false' })).toMatchObject(activeOnly); - expect(await listing({})).toMatchObject(activeOnly); - expect(await listing({ include_retired: 'yes' })).toMatchObject({ - reason: 'invalid_input', - issues: [{ pointer: '/include_retired' }], - }); - }); - - it('and primitive are the only fields list_specs knows', async () => { - expect( - await harness().call(listSpecs, toAlpha(acmeAdmin, { primitive: 'probe', status: 'retired' })), - ).toMatchObject({ - reason: 'invalid_input', - issues: [{ pointer: '/status', detail: 'Expected no excess property' }], - }); - }); -}); diff --git a/packages/specs/src/operations/list-specs.ts b/packages/specs/src/operations/list-specs.ts deleted file mode 100644 index 0e34eea27..000000000 --- a/packages/specs/src/operations/list-specs.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { defineQuery } from '@beonauto/operations'; -import { Effect, Schema } from 'effect'; - -import { specsListed, specWordsFor } from '../plain-language/spec-words.ts'; -import { knownPrimitives } from '../primitive/known-primitives.ts'; -import type { Primitive } from '../primitive/primitive.ts'; -import { ListedDefinitionSchema } from '../registry/spec.ts'; -import { loadRegistry } from './registry-access.ts'; -import { IncludeRetiredField } from './spec-fields.ts'; -import { byName, listedSpecOf } from './spec-views.ts'; - -export function defineListSpecs(primitives: readonly Primitive[]) { - const known = knownPrimitives(primitives); - const words = specWordsFor(primitives); - return known.publish( - defineQuery('brain', { - name: 'list_specs', - title: 'List definitions', - description: [ - 'Lists the definitions of one type in the brain, sorted by name, each with its version, its status and what its document says it does, without the document.', - 'Use it to find a function or workflow the person names, or to see which exist before one is made; get_spec reads one with its document.', - '`primitive` is the type to list, and `include_retired` adds the retired definitions, which are left out otherwise.', - ].join(' '), - route: { method: 'GET', path: '/specs/{primitive}' }, - inputSchema: Schema.Struct({ - primitive: known.field, - include_retired: Schema.optionalKey(IncludeRetiredField), - }), - outputSchema: Schema.Struct({ specs: Schema.Array(ListedDefinitionSchema) }), - reasons: ['not_found'], - handle: Effect.fnUntraced(function* ({ primitive: primitiveName, include_retired: includeRetired = false }) { - const primitive = yield* known.primitiveNamed(primitiveName); - const registry = yield* loadRegistry(primitive.name); - const specs = [...registry.values()].filter(({ status }) => includeRetired || status === 'active'); - return { specs: specs.toSorted(byName).map((spec) => listedSpecOf(primitive, spec)) }; - }), - plainLanguage: { - task: `list the ${words.allKinds}`, - attempt: ({ primitive }) => `list the ${words.nounOf(primitive).other}`, - outcome: ({ specs }, { primitive }) => specsListed(words.nounOf(primitive), specs), - }, - }), - ); -} diff --git a/packages/specs/src/operations/registry-access.ts b/packages/specs/src/operations/registry-access.ts deleted file mode 100644 index 3c65e4195..000000000 --- a/packages/specs/src/operations/registry-access.ts +++ /dev/null @@ -1,63 +0,0 @@ -import { - BrainContext, - BrainReader, - BrainWriter, - messageIdOf, - NotFound, - streamPrefixOfBrain, -} from '@beonauto/operations'; -import { Effect } from 'effect'; - -import { definitionResourceLabel } from '../primitive/function-terminology.ts'; -import { findSpec } from '../registry/registry-lookup.ts'; -import type { SpecCommandData } from '../registry/spec-commands.ts'; -import type { SpecRegistry } from '../registry/spec-registry.ts'; -import { specVersionDecider, type RecordedVersion } from '../registry/spec-versions.ts'; -import type { StoredDefinition } from '../registry/spec.ts'; -import { specsDecider, specsStreamOf } from '../registry/specs-decider.ts'; -import { commandMetadata } from './command-metadata.ts'; - -export function loadRegistry(primitive: string): Effect.Effect { - return BrainReader.use((reader) => reader.load(specsStreamOf(primitive), specsDecider(primitive))).pipe( - Effect.map(({ state }) => state), - ); -} - -function recordedVersionOf( - primitive: string, - name: string, - version: number, -): Effect.Effect { - return BrainReader.use((reader) => reader.load(specsStreamOf(primitive), specVersionDecider(name, version))).pipe( - Effect.flatMap(({ state: { found } }) => - found === undefined - ? Effect.fail( - new NotFound({ - detail: `There is no version ${version} of the ${definitionResourceLabel(primitive)} ${name} in this brain`, - }), - ) - : Effect.succeed(found), - ), - ); -} - -export const triggersSince = Effect.fnUntraced(function* (primitive: string, { name, version }: StoredDefinition) { - const { position } = yield* Effect.orDie(recordedVersionOf(primitive, name, version)); - return messageIdOf(`${streamPrefixOfBrain(yield* BrainContext)}${specsStreamOf(primitive)}`, position); -}); - -export function versionOf(primitive: string, name: string, version: number) { - return Effect.map(recordedVersionOf(primitive, name, version), ({ source }) => ({ name, version, source })); -} - -export const recordInRegistry = Effect.fnUntraced(function* ( - { name: primitive, mostActive }: { readonly name: string; readonly mostActive: number }, - data: SpecCommandData, -) { - const metadata = yield* commandMetadata; - const { state } = yield* (yield* BrainWriter).execute(specsStreamOf(primitive), specsDecider(primitive, mostActive), { - ...data, - ...metadata, - }); - return yield* Effect.orDie(findSpec(state, primitive, data.name)); -}); diff --git a/packages/specs/src/operations/retire-spec.test.ts b/packages/specs/src/operations/retire-spec.test.ts deleted file mode 100644 index 68b319fb8..000000000 --- a/packages/specs/src/operations/retire-spec.test.ts +++ /dev/null @@ -1,107 +0,0 @@ -import { Effect } from 'effect'; -import { describe, expect, it } from 'vitest'; - -import { acmeAdmin } from '../testing/callers.ts'; -import { echo } from '../testing/echo.ts'; -import { firstMoment, harness, toBrain } from '../testing/harness.ts'; -import { probe } from '../testing/probe.ts'; -import { specOperationsFor } from '../testing/spec-operations.ts'; - -const { createSpec, executeSpec, retireSpec } = specOperationsFor([echo, probe().primitive]); - -const toAlpha = toBrain('acme', 'alpha'); - -const later = '2026-10-02T14:15:00.000Z'; - -const muchLater = '2026-11-20T08:00:00.000Z'; - -const retiringPlain = toAlpha(acmeAdmin, { primitive: 'probe', name: 'plain' }); - -const retiredPlain = { - primitive: 'probe', - name: 'plain', - version: 1, - status: 'retired', - media_type: 'text/plain', - created_at: firstMoment, - created_by: 'acme-admin', - updated_at: later, - retired_at: later, - source: 'text', -}; - -async function withPlain() { - const specs = harness(); - await specs.call(createSpec, toAlpha(acmeAdmin, { primitive: 'probe', name: 'plain', source: 'text' })); - return specs; -} - -describe('retire_spec', () => { - it('is a brain command at POST /specs/{primitive}/{name}/retire', () => { - expect(retireSpec.registration).toMatchObject({ - scope: 'brain', - kind: 'command', - title: 'Retire definition', - route: { method: 'POST', path: '/specs/{primitive}/{name}/retire' }, - pathParameters: ['primitive', 'name'], - successStatus: 200, - reasons: ['not_found', 'conflict'], - }); - }); - - it('retires a spec for good, recording when', async () => { - const { call } = await withPlain(); - - expect(await call(retireSpec, retiringPlain, later)).toStrictEqual({ status: 'succeeded', output: retiredPlain }); - }); - - it('succeeds and records nothing for a spec that is already retired', async () => { - const { call } = await withPlain(); - await call(retireSpec, retiringPlain, later); - - expect(await call(retireSpec, retiringPlain, muchLater)).toStrictEqual({ - status: 'succeeded', - output: retiredPlain, - }); - }); - - it('leaves a spec that can no longer be executed', async () => { - const { call } = await withPlain(); - await call(retireSpec, retiringPlain); - - expect(await call(executeSpec, retiringPlain)).toEqual({ - status: 'rejected', - reason: 'conflict', - detail: 'The probe definition plain is retired and can no longer be run', - kind: 'retired', - }); - }); -}); - -describe('retire_spec rejecting', () => { - it('a spec the brain does not have, and a primitive it does not know', async () => { - const { call } = await withPlain(); - - expect(await call(retireSpec, toAlpha(acmeAdmin, { primitive: 'echo', name: 'plain' }))).toEqual({ - status: 'rejected', - reason: 'not_found', - detail: 'There is no echo definition plain in this brain', - }); - expect(await call(retireSpec, toAlpha(acmeAdmin, { primitive: 'reason', name: 'plain' }))).toEqual({ - status: 'rejected', - reason: 'not_found', - detail: 'There is no primitive reason', - }); - }); - - it('with conflict when another change to the specs of the primitive landed at the same moment', async () => { - const { call, dispatch, run } = await withPlain(); - await call(createSpec, toAlpha(acmeAdmin, { primitive: 'probe', name: 'other', source: 'text' })); - const retiring = (name: string) => dispatch(retireSpec, toAlpha(acmeAdmin, { primitive: 'probe', name })); - - expect(await run(Effect.all([retiring('plain'), retiring('other')], { concurrency: 'unbounded' }))).toMatchObject([ - { status: 'succeeded', output: { name: 'plain', status: 'retired' } }, - { status: 'rejected', reason: 'conflict' }, - ]); - }); -}); diff --git a/packages/specs/src/operations/retire-spec.ts b/packages/specs/src/operations/retire-spec.ts deleted file mode 100644 index f67f24693..000000000 --- a/packages/specs/src/operations/retire-spec.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { defineCommand } from '@beonauto/operations'; -import { Effect, Schema } from 'effect'; - -import { specWordsFor } from '../plain-language/spec-words.ts'; -import { knownPrimitives } from '../primitive/known-primitives.ts'; -import type { Primitive } from '../primitive/primitive.ts'; -import { DefinitionSchema } from '../registry/spec.ts'; -import { recordInRegistry } from './registry-access.ts'; -import { SpecNameField } from './spec-fields.ts'; -import { specOf } from './spec-views.ts'; - -export function defineRetireSpec(primitives: readonly Primitive[]) { - const known = knownPrimitives(primitives); - const words = specWordsFor(primitives); - return known.publish( - defineCommand('brain', { - name: 'retire_spec', - title: 'Retire definition', - description: [ - 'Retires a function or workflow definition for good and returns it: it can still be read and listed, but it can no longer run or change, its name is not used again in the brain, and there is no way to restore it.', - 'Use it only when the person asks to retire that definition; update_spec changes it instead.', - '`primitive` and `name` say which definition, and retiring one already retired changes nothing.', - ].join(' '), - irreversible: true, - repeatable: true, - route: { method: 'POST', path: '/specs/{primitive}/{name}/retire' }, - inputSchema: Schema.Struct({ primitive: known.field, name: SpecNameField }), - outputSchema: DefinitionSchema, - reasons: ['not_found', 'conflict'], - handle: Effect.fnUntraced(function* ({ primitive: primitiveName, name }) { - const primitive = yield* known.primitiveNamed(primitiveName); - return specOf(primitive, yield* recordInRegistry(primitive, { type: 'retire', name })); - }), - plainLanguage: { - task: `retire a ${words.kinds}`, - attempt: ({ primitive, name }) => `retire ${words.named(primitive, name)}`, - outcome: ({ primitive, name }) => - `Retired ${words.named(primitive, name)}. It can no longer be run or changed, and its name cannot be used again in this brain.`, - }, - }), - ); -} diff --git a/packages/specs/src/operations/spec-operations.test.ts b/packages/specs/src/operations/spec-operations.test.ts deleted file mode 100644 index 4c4cd289c..000000000 --- a/packages/specs/src/operations/spec-operations.test.ts +++ /dev/null @@ -1,81 +0,0 @@ -import { brainOperations } from '@beonauto/brains'; -import { makeCatalog } from '@beonauto/operations'; -import { describe, expect, it } from 'vitest'; - -import { makeSpecOperations } from '../index.ts'; -import { echo } from '../testing/echo.ts'; -import { probe } from '../testing/probe.ts'; - -const operations = makeSpecOperations([echo, probe().primitive]); - -const catalog = makeCatalog(operations); - -describe('the spec operations', () => { - it('make one catalog of eleven brain operations', () => { - expect(catalog.operationsIn('brain').map(({ name, title }) => `${name}: ${title}`)).toEqual([ - 'create_spec: Create definition', - 'list_specs: List definitions', - 'get_spec: Get definition', - 'update_spec: Update definition', - 'retire_spec: Retire definition', - 'execute_spec: Run definition', - 'get_execution: Get run', - 'cancel_execution: Cancel run', - 'list_executions: List runs', - 'get_execution_history: Get run history', - 'get_brain_analytics: Get brain analytics', - ]); - expect(catalog.operationsIn('org')).toEqual([]); - }); - - it('answer at eleven routes relative to the brain', () => { - expect(catalog.operations.map(({ route }) => `${route.method} ${route.path}`)).toEqual([ - 'POST /specs/{primitive}', - 'GET /specs/{primitive}', - 'GET /specs/{primitive}/{name}', - 'PUT /specs/{primitive}/{name}', - 'POST /specs/{primitive}/{name}/retire', - 'POST /specs/{primitive}/{name}/execute', - 'GET /executions/{execution_id}', - 'POST /executions/{execution_id}/cancel', - 'GET /executions', - 'GET /executions/{execution_id}/history', - 'GET /analytics', - ]); - }); -}); - -describe('a catalog of the spec operations', () => { - it('takes the brain operations as well, without a clash of names or routes', () => { - expect(makeCatalog([...brainOperations, ...operations]).operations.map(({ name }) => name)).toEqual([ - 'create_brain', - 'list_brains', - 'get_brain', - 'update_brain', - 'retire_brain', - 'create_spec', - 'list_specs', - 'get_spec', - 'update_spec', - 'retire_spec', - 'execute_spec', - 'get_execution', - 'cancel_execution', - 'list_executions', - 'get_execution_history', - 'get_brain_analytics', - ]); - }); -}); - -describe('the spec operations for a list of primitives', () => { - it('need at least one primitive', () => { - expect(() => makeSpecOperations([])).toThrow('The spec operations need at least one primitive'); - }); - - it('need primitives of distinct names', () => { - expect(() => makeSpecOperations([echo, probe().primitive, { ...echo, title: 'Another echo' }])).toThrow( - 'The primitive name echo is used more than once', - ); - }); -}); diff --git a/packages/specs/src/operations/spec-operations.ts b/packages/specs/src/operations/spec-operations.ts deleted file mode 100644 index e23a6e2ae..000000000 --- a/packages/specs/src/operations/spec-operations.ts +++ /dev/null @@ -1,29 +0,0 @@ -import type { Presenter, Registration } from '@beonauto/operations'; - -import type { Primitive } from '../primitive/primitive.ts'; -import { runOperations } from '../reading/run-operations.ts'; -import { defineCreateSpec } from './create-spec.ts'; -import { defineExecuteSpec } from './execute-spec.ts'; -import { defineGetSpec } from './get-spec.ts'; -import { defineListSpecs } from './list-specs.ts'; -import { defineRetireSpec } from './retire-spec.ts'; -import { defineUpdateSpec } from './update-spec.ts'; - -export interface BrainOperation { - readonly registration: Registration<'brain'>; -} - -export function makeSpecOperations( - primitives: readonly Primitive[], - presenters?: readonly Presenter[], -): readonly BrainOperation[] { - return [ - defineCreateSpec(primitives), - defineListSpecs(primitives), - defineGetSpec(primitives), - defineUpdateSpec(primitives), - defineRetireSpec(primitives), - defineExecuteSpec(primitives), - ...runOperations(primitives, presenters), - ]; -} diff --git a/packages/specs/src/operations/spec-preparation.ts b/packages/specs/src/operations/spec-preparation.ts deleted file mode 100644 index a57cd4d0e..000000000 --- a/packages/specs/src/operations/spec-preparation.ts +++ /dev/null @@ -1,49 +0,0 @@ -import { Conflict } from '@beonauto/operations'; -import { Effect } from 'effect'; - -import { definitionResourceLabel } from '../primitive/function-terminology.ts'; -import type { Primitive } from '../primitive/primitive.ts'; -import { findSpec } from '../registry/registry-lookup.ts'; -import type { StoredDefinition } from '../registry/spec.ts'; -import type { Rejection } from './issue-pointers.ts'; -import { loadRegistry, versionOf } from './registry-access.ts'; - -const activeSpec = Effect.fnUntraced(function* (primitive: string, name: string) { - const spec = yield* findSpec(yield* loadRegistry(primitive), primitive, name); - if (spec.status === 'retired') { - return yield* new Conflict({ - detail: `The ${definitionResourceLabel(primitive)} ${name} is retired and can no longer be run`, - kind: 'retired', - }); - } - return spec; -}); - -function unparseable( - primitive: string, - { name, version }: Pick, -): (rejection: Rejection) => Conflict { - return ({ detail }) => - new Conflict({ - detail: `The ${definitionResourceLabel(primitive)} ${name} at version ${version} no longer parses (${detail}); update it`, - kind: 'unworkable', - }); -} - -export interface VersionToRun { - readonly name: string; - readonly version: number; - readonly source: string; -} - -export const preparedSpec = Effect.fnUntraced(function* (primitive: Primitive, name: string) { - const spec = yield* activeSpec(primitive.name, name); - const prepared = yield* primitive.prepare(spec.source).pipe(Effect.mapError(unparseable(primitive.name, spec))); - return { spec, prepared }; -}); - -export const preparedVersion = Effect.fnUntraced(function* (primitive: Primitive, name: string, version: number) { - const spec = yield* versionOf(primitive.name, name, version); - const prepared = yield* primitive.prepare(spec.source).pipe(Effect.mapError(unparseable(primitive.name, spec))); - return { spec, prepared }; -}); diff --git a/packages/specs/src/operations/start-version.ts b/packages/specs/src/operations/start-version.ts deleted file mode 100644 index 924e9f098..000000000 --- a/packages/specs/src/operations/start-version.ts +++ /dev/null @@ -1,47 +0,0 @@ -import { defineCommand } from '@beonauto/operations'; -import { Effect, Schema } from 'effect'; - -import { RunSchema } from '../execution/execution.ts'; -import { runPlainLanguage } from '../plain-language/run-words.ts'; -import { specWordsFor } from '../plain-language/spec-words.ts'; -import { knownPrimitives } from '../primitive/known-primitives.ts'; -import type { Primitive } from '../primitive/primitive.ts'; -import { startVersionOnce } from './execution-running.ts'; -import { ExecutionIdField, InputField, SpecNameField } from './spec-fields.ts'; - -const description = [ - 'Starts a run of one version of a definition under a run id, once: when the brain has a run under that id,', - 'it answers that run as it stands and starts nothing. The workflow host calls it in the process for the runs', - 'that triggers start, as the brain itself, with their reaction depth, lineage and trigger in the request; no transport serves it.', -].join(' '); - -export function defineStartVersion(primitives: readonly Primitive[]) { - const known = knownPrimitives(primitives); - const words = runPlainLanguage(primitives); - const specWords = specWordsFor(primitives); - return known.publish( - defineCommand('brain', { - name: 'start_definition_version', - title: 'Start a version once', - description, - route: { method: 'POST', path: '/specs/{primitive}/{name}/start-once' }, - inputSchema: Schema.Struct({ - primitive: known.field, - name: SpecNameField, - version: Schema.Int.check(Schema.isGreaterThanOrEqualTo(1)), - input: InputField, - execution_id: ExecutionIdField, - }), - outputSchema: RunSchema, - reasons: ['not_found', 'conflict', 'invalid_input', 'unavailable', 'cancelled', 'unanswered'], - handle: Effect.fnUntraced(function* ({ primitive: primitiveName, name, version, input, execution_id: id }) { - const primitive = yield* known.primitiveNamed(primitiveName); - return yield* startVersionOnce(primitives, primitive, { primitive: primitive.name, name, version, input }, id); - }), - plainLanguage: { - ...words, - attempt: ({ primitive, name }) => `start ${specWords.named(primitive, name)} once, for one of its triggers`, - }, - }), - ); -} diff --git a/packages/specs/src/operations/update-spec.ts b/packages/specs/src/operations/update-spec.ts deleted file mode 100644 index 9bdcbc77a..000000000 --- a/packages/specs/src/operations/update-spec.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { defineCommand } from '@beonauto/operations'; -import { Effect, Schema } from 'effect'; - -import { specWordsFor, whatItDoes } from '../plain-language/spec-words.ts'; -import { knownPrimitives } from '../primitive/known-primitives.ts'; -import type { Primitive } from '../primitive/primitive.ts'; -import { DefinitionSchema } from '../registry/spec.ts'; -import { recordInRegistry } from './registry-access.ts'; -import { SourceField, SpecNameField } from './spec-fields.ts'; -import { contentOf, specOf } from './spec-views.ts'; - -export function defineUpdateSpec(primitives: readonly Primitive[]) { - const known = knownPrimitives(primitives); - const words = specWordsFor(primitives); - return known.publish( - defineCommand('brain', { - name: 'update_spec', - title: 'Update definition', - description: [ - 'Replaces the whole document of an active function or workflow definition and returns its new version, which every run uses from then on.', - 'Use it when the person changes a saved definition; create_spec saves a new one, and a retired definition cannot change.', - "`primitive` and `name` say which definition, and `source` is the whole new document in its type's format, which get_guide gives.", - 'A document that does not fit its format is refused with the line and what is wrong, and one the same as the saved document records nothing.', - "A new version of a recall function builds its view again from the brain's history.", - ].join(' '), - repeatable: true, - route: { method: 'PUT', path: '/specs/{primitive}/{name}' }, - inputSchema: Schema.Struct({ primitive: known.field, name: SpecNameField, source: SourceField }), - outputSchema: DefinitionSchema, - reasons: ['not_found', 'invalid_input', 'conflict'], - handle: Effect.fnUntraced(function* ({ primitive: primitiveName, name, source }) { - const primitive = yield* known.primitiveNamed(primitiveName); - const content = yield* contentOf(primitive, source); - return specOf(primitive, yield* recordInRegistry(primitive, { type: 'update', name, content })); - }), - plainLanguage: { - task: `update a ${words.kinds}`, - attempt: ({ primitive, name }) => `update ${words.named(primitive, name)}`, - outcome: (spec) => - `Updated ${words.named(spec.primitive, spec.name)}.${whatItDoes(spec)} The change applies from its next run.`, - }, - }), - ); -} diff --git a/packages/specs/src/plain-language/spec-words.ts b/packages/specs/src/plain-language/spec-words.ts deleted file mode 100644 index 10260fc3e..000000000 --- a/packages/specs/src/plain-language/spec-words.ts +++ /dev/null @@ -1,85 +0,0 @@ -import { alternatives, asSentence, capitalized, counted, listed, quoted, type Noun } from '@beonauto/operations'; -import { Option, Schema } from 'effect'; - -import { defaultRunWords, type Primitive, type RunWords } from '../primitive/primitive.ts'; -import type { ListedDefinition } from '../registry/spec.ts'; -import { wordsOf } from './in-words.ts'; - -const mostNamed = 20; - -const propertiesOf = Schema.decodeUnknownOption( - Schema.Struct({ properties: Schema.Record(Schema.String, Schema.Unknown) }), -); - -export interface SpecWords { - readonly kinds: string; - readonly allKinds: string; - readonly nounOf: (primitive: string) => Noun; - readonly named: (primitive: string, name: string) => string; - readonly runWordsOf: (primitive: string) => RunWords; - readonly deferralTypes: readonly string[]; -} - -const unknownNoun: Noun = { one: 'item', other: 'items' }; - -export function specWordsFor(primitives: readonly Primitive[]): SpecWords { - const nounOf = (name: string) => primitives.find((primitive) => primitive.name === name)?.noun ?? unknownNoun; - return { - kinds: alternatives(primitives.map(({ noun }) => noun.one)), - allKinds: listed(primitives.map(({ noun }) => noun.other)), - nounOf, - named: (primitive, name) => `the ${nounOf(primitive).one} ${quoted(name)}`, - runWordsOf: (name) => primitives.find((primitive) => primitive.name === name)?.runWords ?? defaultRunWords, - deferralTypes: [ - ...new Set([defaultRunWords.deferralType, ...primitives.map(({ runWords }) => runWords.deferralType)]), - ], - }; -} - -function propertyWordsOf(schema: Schema.JsonObject | undefined): readonly string[] { - return Option.match(propertiesOf(schema), { - onNone: () => [], - onSome: ({ properties }) => Object.keys(properties).map((name) => wordsOf(name)), - }); -} - -function takesAndGives(inputs: readonly string[], outputs: readonly string[]): string { - const gives = outputs.length === 0 ? '' : `gives back ${listed(outputs)}`; - if (inputs.length === 0) { - return gives === '' ? '' : ` It ${gives}.`; - } - return ` It takes ${listed(inputs)}${gives === '' ? '' : `, and ${gives}`}.`; -} - -export function whatItDoes(spec: Pick): string { - return spec.description === undefined - ? takesAndGives(propertyWordsOf(spec.input_schema), propertyWordsOf(spec.output_schema)) - : ` What it does: ${asSentence(spec.description)}`; -} - -export function specStanding( - words: SpecWords, - { primitive, name, status }: Pick, -): string { - const named = capitalized(words.named(primitive, name)); - return status === 'active' ? `${named} is in use.` : `${named} has been retired; it can no longer be run or changed.`; -} - -function namesOf(specs: readonly ListedDefinition[]): string { - const named = specs.slice(0, mostNamed).map(({ name }) => quoted(name)); - const others = specs.length - named.length; - return listed(others === 0 ? named : [...named, `${others} more`]); -} - -export function specsListed(noun: Noun, specs: readonly ListedDefinition[]): string { - const active = specs.filter(({ status }) => status === 'active'); - const retired = specs.filter(({ status }) => status === 'retired'); - const inUse = - active.length === 0 - ? `This brain has no ${noun.other} in use yet.` - : `This brain has ${counted(active.length, noun)}: ${namesOf(active)}.`; - const retiredNoun = { one: `retired ${noun.one}`, other: `retired ${noun.other}` }; - return retired.length === 0 - ? inUse - : `${inUse} Also listed, ${counted(retired.length, retiredNoun)}: ${namesOf(retired)}.`; -} diff --git a/packages/specs/src/presenting/spec-presenter.ts b/packages/specs/src/presenting/spec-presenter.ts deleted file mode 100644 index 24231f1f6..000000000 --- a/packages/specs/src/presenting/spec-presenter.ts +++ /dev/null @@ -1,45 +0,0 @@ -import { Buffer } from 'node:buffer'; - -import type { Presenter } from '@beonauto/operations'; - -import { jsonBytesOf } from '../execution/recorded-size.ts'; -import { specCreated, specRetired, specUpdated } from '../plain-language/event-words.ts'; -import type { SpecWords } from '../plain-language/spec-words.ts'; -import { SpecEventSchema, type SpecContent, type SpecEvent } from '../registry/spec-events.ts'; -import { cutAtCodePoint, firstCharacters, mostCallerBytes, mostDescriptionCharacters } from './event-data.ts'; -import { eventPresenter, type Account } from './event-presenter.ts'; - -function contentShown({ source, description, input_schema, output_schema, warnings = [] }: SpecContent) { - return { - source_bytes: Buffer.byteLength(source, 'utf8'), - ...(description === undefined ? {} : { description: firstCharacters(description, mostDescriptionCharacters) }), - ...(input_schema === undefined ? {} : { input_schema_bytes: jsonBytesOf(input_schema) }), - ...(output_schema === undefined ? {} : { output_schema_bytes: jsonBytesOf(output_schema) }), - warning_count: warnings.length, - }; -} - -function accountOf(words: SpecWords, event: SpecEvent, primitive: string): Account { - const { name } = event; - const fact = { primitive, name, by: cutAtCodePoint(event.by, mostCallerBytes) }; - if (event.type === 'spec_retired') { - return { summary: specRetired(words, primitive, name), data: fact }; - } - const { version, content } = event; - return { - summary: - event.type === 'spec_created' - ? specCreated(words, primitive, name) - : specUpdated(words, primitive, name, version), - data: { ...fact, version, ...contentShown(content) }, - }; -} - -export function specPresenter(words: SpecWords): Presenter { - return eventPresenter({ - streamKind: 'specs', - eventSchema: SpecEventSchema, - publicNames: { spec_created: ['spec_created'], spec_updated: ['spec_updated'], spec_retired: ['spec_retired'] }, - account: (event, primitive) => accountOf(words, event, primitive), - }); -} diff --git a/packages/specs/src/presenting/spec-presenters.ts b/packages/specs/src/presenting/spec-presenters.ts deleted file mode 100644 index c832ace14..000000000 --- a/packages/specs/src/presenting/spec-presenters.ts +++ /dev/null @@ -1,13 +0,0 @@ -import type { Presenter } from '@beonauto/operations'; - -import { specWordsFor } from '../plain-language/spec-words.ts'; -import type { Primitive } from '../primitive/primitive.ts'; -import { executionPresenter } from './execution-presenter.ts'; -import { publishedEventPresenter } from './published-event-presenter.ts'; -import { reactionRefusedPresenter } from './reaction-refused-presenter.ts'; -import { specPresenter } from './spec-presenter.ts'; - -export function makeSpecPresenters(primitives: readonly Primitive[]): readonly Presenter[] { - const words = specWordsFor(primitives); - return [executionPresenter(words), specPresenter(words), publishedEventPresenter, reactionRefusedPresenter]; -} diff --git a/packages/specs/src/primitive/function-terminology.ts b/packages/specs/src/primitive/function-terminology.ts deleted file mode 100644 index 92816c01c..000000000 --- a/packages/specs/src/primitive/function-terminology.ts +++ /dev/null @@ -1,39 +0,0 @@ -export type BrainFunctionKind = 'reason' | 'interact' | 'predict' | 'recall' | 'compute'; - -export const functionCategoryLabels = { - reason: 'Reasoning', - interact: 'Interaction', - predict: 'Prediction', - recall: 'Recall', - compute: 'Computation', -} satisfies Record; - -export const functionResourceLabels = { - reason: { singular: 'reasoning function', plural: 'reasoning functions' }, - interact: { singular: 'interaction function', plural: 'interaction functions' }, - predict: { singular: 'prediction function', plural: 'prediction functions' }, - recall: { singular: 'recall function', plural: 'recall functions' }, - compute: { singular: 'computation function', plural: 'computation functions' }, -} satisfies Record; - -export const functionDescriptions = { - reason: 'Use a prompt, skills, and tools to interpret information or produce a response.', - interact: 'Exchange information with people or systems.', - predict: 'Create and use an ML model to make predictions.', - recall: 'Answer from what the brain keeps of its own history.', - compute: 'Run defined code or expressions to calculate or transform data.', -} satisfies Record; - -export const functionKindOrder: readonly BrainFunctionKind[] = ['reason', 'interact', 'predict', 'recall', 'compute']; - -const resourceLabels: ReadonlyMap = new Map([ - ['inference', functionResourceLabels.reason.singular], - ['interaction', functionResourceLabels.interact.singular], - ['computation', functionResourceLabels.compute.singular], - ['recollection', functionResourceLabels.recall.singular], - ['orchestration', 'workflow'], -]); - -export function definitionResourceLabel(primitive: string): string { - return resourceLabels.get(primitive) ?? `${primitive} definition`; -} diff --git a/packages/specs/src/primitive/known-primitives.ts b/packages/specs/src/primitive/known-primitives.ts deleted file mode 100644 index 42f74bd5e..000000000 --- a/packages/specs/src/primitive/known-primitives.ts +++ /dev/null @@ -1,76 +0,0 @@ -import { NotFound, alternatives, articled, type JsonSchemaDocument, type Registration } from '@beonauto/operations'; -import { Effect, Schema } from 'effect'; - -import { isPrimitiveName, type Primitive } from './primitive.ts'; - -interface PublishedOperation { - readonly registration: Registration<'brain'>; -} - -export const PrimitiveField = Schema.String.check( - Schema.makeFilter(isPrimitiveName, { - expected: 'a primitive name: 3 to 32 lowercase letters, digits and hyphens, starting with a letter', - }), -); - -export interface KnownPrimitives { - readonly field: typeof PrimitiveField; - readonly typesWithGuides: string; - readonly publish: (operation: Operation, meaning?: string) => Operation; - readonly primitiveNamed: (name: string) => Effect.Effect; -} - -function requireSomePrimitive(names: readonly string[]): void { - if (names.length === 0) { - throw new Error('The spec operations need at least one primitive'); - } -} - -function requireDistinctNames(names: readonly string[]): void { - const repeated = names.find((name, index) => names.indexOf(name) !== index); - if (repeated !== undefined) { - throw new Error(`The primitive name ${repeated} is used more than once`); - } -} - -const primitiveMeaning = "The definition's type"; - -function withPrimitiveField( - { schema, definitions }: JsonSchemaDocument, - primitives: readonly Primitive[], - meaning: string, -): JsonSchemaDocument { - const types = alternatives(primitives.map(({ name, noun }) => `${name} (${noun.one})`)); - const primitive = { - type: 'string', - enum: primitives.map(({ name }) => name), - description: `${meaning}: ${types}`, - }; - return { schema: { ...schema, properties: Object.assign({}, schema['properties'], { primitive }) }, definitions }; -} - -export function knownPrimitives(primitives: readonly Primitive[]): KnownPrimitives { - const names = primitives.map(({ name }) => name); - requireSomePrimitive(names); - requireDistinctNames(names); - const byName = new Map(primitives.map((primitive) => [primitive.name, primitive])); - return { - field: PrimitiveField, - typesWithGuides: primitives - .map(({ name, noun, guide }) => `${name}, ${articled(noun.one)}, guide ${guide.name}`) - .join('; '), - publish: (operation, meaning = primitiveMeaning) => ({ - ...operation, - registration: { - ...operation.registration, - input: withPrimitiveField(operation.registration.input, primitives, meaning), - }, - }), - primitiveNamed: (name) => { - const primitive = byName.get(name); - return primitive === undefined - ? Effect.fail(new NotFound({ detail: `There is no primitive ${name}` })) - : Effect.succeed(primitive); - }, - }; -} diff --git a/packages/specs/src/reading/execution-status.ts b/packages/specs/src/reading/execution-status.ts deleted file mode 100644 index 8ef6821a3..000000000 --- a/packages/specs/src/reading/execution-status.ts +++ /dev/null @@ -1,20 +0,0 @@ -import type { Run } from '../execution/execution.ts'; - -export type ExecutionStatus = Run['status']; - -export const storedTypesByStatus: Readonly> = { - started: [ - 'execution_started', - 'execution_deferred', - 'execution_cancel_requested', - 'tool_call_started', - 'tool_call_answered', - 'delivery_started', - 'delivery_ended', - 'reply_taken', - 'reply_refused', - ], - succeeded: ['execution_succeeded'], - rejected: ['execution_rejected'], - failed: ['execution_failed'], -}; diff --git a/packages/specs/src/reading/list-executions.test.ts b/packages/specs/src/reading/list-executions.test.ts deleted file mode 100644 index cd5caaa76..000000000 --- a/packages/specs/src/reading/list-executions.test.ts +++ /dev/null @@ -1,290 +0,0 @@ -import type { Outcome } from '@beonauto/operations'; -import { Schema } from 'effect'; -import { describe, expect, it } from 'vitest'; - -import { acmeAdmin, globexAdmin } from '../testing/callers.ts'; -import { echo } from '../testing/echo.ts'; -import { asQueryString, harness, toBrain } from '../testing/harness.ts'; -import { probe } from '../testing/probe.ts'; -import { relay } from '../testing/relay.ts'; -import { specOperationsFor } from '../testing/spec-operations.ts'; - -const toAlpha = toBrain('acme', 'alpha'); - -const callerChosen = 'ffffffff-7d2e-7c1a-9b3f-2f1e0d9c8b7a'; - -const hashedChild = '6b1e2f30-9c4d-5a8b-8e7f-0a1b2c3d4e5f'; - -const startedTwice = '00000000-7d2e-7c1a-9b3f-2f1e0d9c8b7a'; - -const listedIdsOf = Schema.decodeUnknownSync( - Schema.Struct({ - output: Schema.Struct({ executions: Schema.Array(Schema.Struct({ execution_id: Schema.String })) }), - }), -); - -function idStartsIn(outcome: Outcome): readonly string[] { - return listedIdsOf(outcome).output.executions.map(({ execution_id: id }) => id.slice(0, 8)); -} - -type SpecName = readonly [primitive: string, name: string]; - -const greet: SpecName = ['echo', 'greet']; - -const wave: SpecName = ['echo', 'wave']; - -const plain: SpecName = ['probe', 'plain']; - -const handOn: SpecName = ['relay', 'hand-on']; - -async function brainWithRuns() { - const prober = probe(); - const relayer = relay(); - const operations = specOperationsFor([echo, prober.primitive, relayer.primitive]); - const specs = harness(); - const creating = (primitive: string, name: string, source: string) => - specs.call(operations.createSpec, toAlpha(acmeAdmin, { primitive, name, source })); - await creating('echo', 'greet', '{"greeting":"Hi"}'); - await creating('echo', 'wave', '{"greeting":"Hey"}'); - await creating('probe', 'plain', 'plain'); - await creating('relay', 'hand-on', 'text'); - const executing = ([primitive, name]: SpecName, input: object, executionId: string, at: string) => - specs.call(operations.executeSpec, toAlpha(acmeAdmin, { primitive, name, input, execution_id: executionId }), at); - const listing = (input: object = {}) => specs.call(operations.listExecutions, toAlpha(acmeAdmin, input)); - return { ...specs, ...operations, prober, executing, listing }; -} - -async function brainWithEveryEnding() { - const brain = await brainWithRuns(); - const { executing, prober } = brain; - await executing(greet, { who: 'Ada' }, callerChosen, '2026-10-01T09:01:00.000Z'); - await executing(plain, { reject: true }, hashedChild, '2026-10-01T09:02:00.000Z'); - prober.sufferOnNextRun('unavailable'); - await executing(plain, {}, startedTwice, '2026-10-01T09:03:00.000Z'); - await executing(handOn, {}, '22222222-7d2e-7c1a-9b3f-2f1e0d9c8b7a', '2026-10-01T09:04:00.000Z'); - prober.sufferOnNextRun('breakdown'); - await executing(plain, {}, '33333333-7d2e-7c1a-9b3f-2f1e0d9c8b7a', '2026-10-01T09:05:00.000Z'); - await executing(wave, {}, '44444444-7d2e-7c1a-9b3f-2f1e0d9c8b7a', '2026-10-01T09:06:00.000Z'); - await executing(plain, {}, startedTwice, '2026-10-01T09:07:00.000Z'); - return brain; -} - -const everyEndingNewestFirst = [ - { - execution_id: '44444444-7d2e-7c1a-9b3f-2f1e0d9c8b7a', - primitive: 'echo', - name: 'wave', - spec_version: 1, - status: 'succeeded', - started_at: '2026-10-01T09:06:00.000Z', - started_by: 'acme-admin', - finished_at: '2026-10-01T09:06:00.000Z', - }, - { - execution_id: '33333333-7d2e-7c1a-9b3f-2f1e0d9c8b7a', - primitive: 'probe', - name: 'plain', - spec_version: 1, - status: 'failed', - started_at: '2026-10-01T09:05:00.000Z', - started_by: 'acme-admin', - finished_at: '2026-10-01T09:05:00.000Z', - }, - { - execution_id: '22222222-7d2e-7c1a-9b3f-2f1e0d9c8b7a', - primitive: 'relay', - name: 'hand-on', - spec_version: 1, - status: 'started', - started_at: '2026-10-01T09:04:00.000Z', - started_by: 'acme-admin', - }, - { - execution_id: startedTwice, - primitive: 'probe', - name: 'plain', - spec_version: 1, - status: 'succeeded', - started_at: '2026-10-01T09:03:00.000Z', - started_by: 'acme-admin', - finished_at: '2026-10-01T09:07:00.000Z', - }, - { - execution_id: hashedChild, - primitive: 'probe', - name: 'plain', - spec_version: 1, - status: 'rejected', - rejection: { reason: 'invalid_input' }, - started_at: '2026-10-01T09:02:00.000Z', - started_by: 'acme-admin', - finished_at: '2026-10-01T09:02:00.000Z', - }, - { - execution_id: callerChosen, - primitive: 'echo', - name: 'greet', - spec_version: 1, - status: 'succeeded', - started_at: '2026-10-01T09:01:00.000Z', - started_by: 'acme-admin', - finished_at: '2026-10-01T09:01:00.000Z', - }, -]; - -describe('list_executions', () => { - it('is a brain query at GET /executions that names the primitives it may filter by', () => { - const { listExecutions } = specOperationsFor([echo, probe().primitive]); - - expect(listExecutions.registration).toMatchObject({ - scope: 'brain', - kind: 'query', - title: 'List runs', - route: { method: 'GET', path: '/executions' }, - reasons: ['invalid_input'], - }); - expect(listExecutions.registration.input.schema).toMatchObject({ - properties: { - primitive: { - type: 'string', - enum: ['echo', 'probe'], - description: 'Only the runs of this type: echo (greeting) or probe (probe)', - }, - }, - }); - }); - - it('lists the runs newest first by their first start, whatever their ids, without outputs, records or issues', async () => { - const { listing } = await brainWithEveryEnding(); - - expect(await listing()).toEqual({ - status: 'succeeded', - output: { - executions: everyEndingNewestFirst, - has_more: false, - next_cursor: null, - }, - }); - }); -}); - -describe('a run listed after it started again or did not go through', () => { - it('shows a run started again and still running by its latest start, in the place of its first', async () => { - const { callCancelledWhen, executeSpec, executing, listing, prober, call, updateSpec } = await brainWithRuns(); - prober.sufferOnNextRun('unavailable'); - await executing(plain, {}, startedTwice, '2026-10-01T09:01:00.000Z'); - await executing(greet, {}, callerChosen, '2026-10-01T09:02:00.000Z'); - await call(updateSpec, toAlpha(acmeAdmin, { primitive: 'probe', name: 'plain', source: 'newer' })); - prober.sufferOnNextRun('stall'); - const finishing = Promise.withResolvers(); - const again = toAlpha(acmeAdmin, { primitive: 'probe', name: 'plain', input: {}, execution_id: startedTwice }); - const runningAgain = callCancelledWhen(finishing.promise, executeSpec, again); - await prober.stalled; - - const listed = await listing(); - finishing.resolve(); - await runningAgain; - - expect(listed).toMatchObject({ - output: { - executions: [ - { execution_id: callerChosen }, - { execution_id: startedTwice, status: 'started', spec_version: 2, started_at: '2026-10-01T09:00:00.000Z' }, - ], - }, - }); - }); - - it('shows the reason, kind and because of a rejection, and never its detail', async () => { - const { executing, listing, prober } = await brainWithRuns(); - prober.sufferOnNextRun('unoffered'); - await executing(plain, {}, callerChosen, '2026-10-01T09:01:00.000Z'); - prober.sufferOnNextRun('unworkable'); - await executing(plain, {}, hashedChild, '2026-10-01T09:02:00.000Z'); - prober.sufferOnNextRun('unavailable'); - await executing(plain, {}, startedTwice, '2026-10-01T09:03:00.000Z'); - - expect(await listing()).toMatchObject({ - output: { - executions: [ - { execution_id: startedTwice, rejection: { reason: 'unavailable' } }, - { execution_id: hashedChild, rejection: { reason: 'conflict', kind: 'unworkable' } }, - { - execution_id: callerChosen, - rejection: { reason: 'unavailable', kind: 'model_not_offered', because: 'provider_not_configured' }, - }, - ], - }, - }); - }); -}); - -describe('a conflict in the list of runs', () => { - it('shows its reason alone when it has no kind', async () => { - const { executing, listing, prober } = await brainWithRuns(); - prober.sufferOnNextRun('conflict'); - await executing(plain, {}, callerChosen, '2026-10-01T09:01:00.000Z'); - - expect(await listing()).toMatchObject({ - output: { executions: [{ execution_id: callerChosen, rejection: { reason: 'conflict' } }] }, - }); - expect(await listing()).not.toMatchObject({ output: { executions: [{ rejection: { kind: 'unworkable' } }] } }); - }); -}); - -describe('list_executions filtering', () => { - it.each([ - [{ status: 'succeeded' }, ['44444444', '00000000', 'ffffffff']], - [{ status: 'rejected' }, ['6b1e2f30']], - [{ status: 'failed' }, ['33333333']], - [{ status: 'started' }, ['22222222']], - [{ primitive: 'echo' }, ['44444444', 'ffffffff']], - [{ name: 'plain' }, ['33333333', '00000000', '6b1e2f30']], - [{ primitive: 'probe', name: 'plain', status: 'succeeded' }, ['00000000']], - [{ primitive: 'echo', name: 'plain' }, []], - [{ primitive: 'gone' }, []], - ] as const)('keeps the runs %j', async (filters, kept) => { - const { listing } = await brainWithEveryEnding(); - - const listed = await listing(filters); - - expect(listed).toMatchObject({ status: 'succeeded', output: { has_more: false, next_cursor: null } }); - expect(idStartsIn(listed)).toEqual(kept); - }); - - it('reads its filters, limit and cursor from a query string', async () => { - const { call, listExecutions } = await brainWithEveryEnding(); - - expect( - await call(listExecutions, asQueryString(toAlpha(acmeAdmin, { status: 'succeeded', limit: '1' }))), - ).toMatchObject({ - status: 'succeeded', - output: { executions: [{ execution_id: '44444444-7d2e-7c1a-9b3f-2f1e0d9c8b7a' }], has_more: true }, - }); - }); - - it('refuses a status, name or limit it does not know', async () => { - const { listing } = await brainWithRuns(); - - expect(await listing({ status: 'deferred', name: 'No', limit: 0 })).toMatchObject({ - status: 'rejected', - reason: 'invalid_input', - issues: [{ pointer: '/name' }, { pointer: '/status' }, { pointer: '/limit' }], - }); - }); -}); - -describe('the runs of a brain', () => { - it('are its own alone', async () => { - const { listing, call, listExecutions } = await brainWithEveryEnding(); - - expect(await call(listExecutions, toBrain('globex', 'gamma')(globexAdmin))).toEqual({ - status: 'succeeded', - output: { executions: [], has_more: false, next_cursor: null }, - }); - expect(await call(listExecutions, toBrain('acme', 'beta')(acmeAdmin))).toMatchObject({ - output: { executions: [] }, - }); - expect(await listing()).toMatchObject({ output: { executions: { length: 6 } } }); - }); -}); diff --git a/packages/specs/src/reading/list-executions.ts b/packages/specs/src/reading/list-executions.ts deleted file mode 100644 index ee8dab25f..000000000 --- a/packages/specs/src/reading/list-executions.ts +++ /dev/null @@ -1,94 +0,0 @@ -import { - BrainReader, - PagingInputFields, - PagingOutputFields, - defaultPageLimit, - defineQuery, - mostExaminedInAPage, - type RecordedSelection, -} from '@beonauto/operations'; -import { Effect, Schema } from 'effect'; - -import { RunSchema } from '../execution/execution.ts'; -import { RunsOfNameField } from '../operations/spec-fields.ts'; -import { runsListed, runsToList } from '../plain-language/reading-words.ts'; -import { specWordsFor } from '../plain-language/spec-words.ts'; -import { PrimitiveField, knownPrimitives } from '../primitive/known-primitives.ts'; -import type { Primitive } from '../primitive/primitive.ts'; -import { storedTypesByStatus } from './execution-status.ts'; -import { ListedRunSchema, listedExecutionsOf } from './listed-execution.ts'; - -const description = [ - 'Lists the runs of the brain a page at a time, newest first, each with its definition, its status, who started it and when it ended, without its output.', - 'Use it to find a run the person means, such as the runs of a scheduled workflow; get_execution reads one run in full.', - '`primitive` and `name` keep the runs of one definition and `status` those in one status.', - `A filtered page looks at up to ${mostExaminedInAPage} runs, so it may hold fewer runs than \`limit\`, or none, while has_more is true.`, - '`cursor` is the next_cursor of the page before.', -].join(' '); - -const ListExecutionsInput = Schema.Struct({ - primitive: Schema.optionalKey(PrimitiveField), - name: Schema.optionalKey(RunsOfNameField), - status: Schema.optionalKey( - RunSchema.fields.status.annotate({ - description: 'Only runs in this status: started, succeeded, rejected or failed', - }), - ), - limit: PagingInputFields.limit, - cursor: PagingInputFields.cursor, -}); - -const ListedExecutionsPage = Schema.Struct({ - executions: Schema.Array(ListedRunSchema), - ...PagingOutputFields, -}); - -function streamsOfRuns(primitive: string | undefined, name: string | undefined): RecordedSelection { - return { - kind: 'executions', - notBeginningWith: ['execution_cancel_requested'], - ...(primitive === undefined ? {} : { primitive }), - ...(name === undefined ? {} : { name }), - }; -} - -const listExecutions = Effect.fnUntraced(function* ({ - primitive, - name, - status, - limit = defaultPageLimit, - cursor, -}: typeof ListExecutionsInput.Type) { - const page = yield* (yield* BrainReader).readRecorded(streamsOfRuns(primitive, name), { - order: 'desc', - limit, - ...(cursor === undefined ? {} : { cursor }), - ...(status === undefined ? {} : { types: storedTypesByStatus[status] }), - }); - return { - executions: yield* listedExecutionsOf(page.records), - has_more: page.hasMore, - next_cursor: page.nextCursor, - }; -}); - -export function defineListExecutions(primitives: readonly Primitive[]) { - const known = knownPrimitives(primitives); - const words = specWordsFor(primitives); - const operation = defineQuery('brain', { - name: 'list_executions', - title: 'List runs', - description, - route: { method: 'GET', path: '/executions' }, - inputSchema: ListExecutionsInput, - outputSchema: ListedExecutionsPage, - reasons: ['invalid_input'], - handle: listExecutions, - plainLanguage: { - task: 'list the runs', - attempt: (filters) => runsToList(words, filters), - outcome: (page, filters) => runsListed(words, page, filters), - }, - }); - return known.publish(operation, 'Only the runs of this type'); -} diff --git a/packages/specs/src/reading/run-operations.ts b/packages/specs/src/reading/run-operations.ts deleted file mode 100644 index 631d94371..000000000 --- a/packages/specs/src/reading/run-operations.ts +++ /dev/null @@ -1,22 +0,0 @@ -import type { Presenter } from '@beonauto/operations'; - -import { defineGetBrainAnalytics } from '../analytics/get-brain-analytics.ts'; -import { defineCancelExecution } from '../cancellation/cancel-execution.ts'; -import { defineGetExecution } from '../operations/get-execution.ts'; -import { makeSpecPresenters } from '../presenting/spec-presenters.ts'; -import type { Primitive } from '../primitive/primitive.ts'; -import { defineGetExecutionHistory } from './get-execution-history.ts'; -import { defineListExecutions } from './list-executions.ts'; - -export function runOperations( - primitives: readonly Primitive[], - presenters: readonly Presenter[] = makeSpecPresenters(primitives), -) { - return [ - defineGetExecution(primitives), - defineCancelExecution(primitives), - defineListExecutions(primitives), - defineGetExecutionHistory(presenters), - defineGetBrainAnalytics(primitives), - ]; -} diff --git a/packages/specs/src/reading/tenant-data.test.ts b/packages/specs/src/reading/tenant-data.test.ts deleted file mode 100644 index 3512ac947..000000000 --- a/packages/specs/src/reading/tenant-data.test.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { describe, expect, it } from 'vitest'; - -import { acmeAdmin } from '../testing/callers.ts'; -import { echo } from '../testing/echo.ts'; -import { harness, toBrain } from '../testing/harness.ts'; -import { specOperationsFor } from '../testing/spec-operations.ts'; - -const toAlpha = toBrain('acme', 'alpha'); - -const executionId = '0199a3c4-7d2e-7c1a-9b3f-2f1e0d9c8b7a'; - -describe('a run whose input and output hold U+0000', () => { - it('lists, filters and reads like any other', async () => { - const { createSpec, executeSpec, getExecutionHistory, listExecutions } = specOperationsFor([echo]); - const { call } = harness(); - await call( - createSpec, - toAlpha(acmeAdmin, { primitive: 'echo', name: 'greet', source: '{"greeting":"Hi\\u0000"}' }), - ); - await call( - executeSpec, - toAlpha(acmeAdmin, { primitive: 'echo', name: 'greet', input: { text: 'a\u0000b' }, execution_id: executionId }), - ); - - expect( - await call(listExecutions, toAlpha(acmeAdmin, { status: 'succeeded', name: 'greet', primitive: 'echo' })), - ).toMatchObject({ status: 'succeeded', output: { executions: [{ execution_id: executionId }] } }); - expect(await call(getExecutionHistory, toAlpha(acmeAdmin, { execution_id: executionId }))).toMatchObject({ - status: 'succeeded', - output: { events: [{ data: { input_bytes: 19 } }, { data: { output_bytes: 51 } }] }, - }); - }); -}); diff --git a/packages/specs/src/registry/registry-decisions.ts b/packages/specs/src/registry/registry-decisions.ts deleted file mode 100644 index 2c49f4f6b..000000000 --- a/packages/specs/src/registry/registry-decisions.ts +++ /dev/null @@ -1,135 +0,0 @@ -import { Conflict, plainNumber, type Rejection } from '@beonauto/operations'; -import { Result } from 'effect'; - -import { definitionResourceLabel } from '../primitive/function-terminology.ts'; -import { specNotFound } from './registry-lookup.ts'; -import type { CommandMetadata, SpecCommand, SpecCreation, SpecRetirement, SpecUpdate } from './spec-commands.ts'; -import type { SpecEvent } from './spec-events.ts'; -import type { SpecRegistry } from './spec-registry.ts'; -import { hasTriggers } from './spec-triggers.ts'; -import type { StoredDefinition } from './spec.ts'; - -type Decision = Result.Result>; - -const nothingToRecord: Decision = Result.succeed([]); - -export const mostReactingDefinitions = 1024; - -function reactingOtherThan(registry: SpecRegistry, name: string): number { - return [...registry.values()].filter((spec) => spec.status === 'active' && hasTriggers(spec) && spec.name !== name) - .length; -} - -function beyondTheReactingBound( - primitive: string, - registry: SpecRegistry, - { name, content }: Pick, -): Conflict | undefined { - return hasTriggers(content) && reactingOtherThan(registry, name) >= mostReactingDefinitions - ? new Conflict({ - detail: `The brain already has ${mostReactingDefinitions} ${definitionResourceLabel(primitive)}s that start on their own, the most a brain holds; retire one, or save this one without its schedule`, - }) - : undefined; -} - -function recording(event: SpecEvent): Decision { - return Result.succeed([event]); -} - -function takenBy(primitive: string, { name, status }: StoredDefinition): Conflict { - return new Conflict({ - detail: - status === 'active' - ? `The brain already has the ${definitionResourceLabel(primitive)} ${name}` - : `The ${definitionResourceLabel(primitive)} ${name} was retired, and a definition name is never reused`, - kind: 'taken', - }); -} - -export interface RegistryRules { - readonly primitive: string; - readonly mostActive: number; -} - -function activeIn(registry: SpecRegistry): number { - return [...registry.values()].filter(({ status }) => status === 'active').length; -} - -function tooMany({ primitive, mostActive }: RegistryRules, active: number, saving: string): Conflict { - const label = definitionResourceLabel(primitive); - return new Conflict({ - detail: `The brain keeps ${plainNumber(active)} active ${label}s, and a brain may keep at most ${plainNumber(mostActive)}; retire one before ${saving}`, - }); -} - -function decideCreation( - rules: RegistryRules, - { name, content, by, at }: SpecCreation & CommandMetadata, - registry: SpecRegistry, -): Decision { - const existing = registry.get(name); - if (existing !== undefined) { - return Result.fail(takenBy(rules.primitive, existing)); - } - const active = activeIn(registry); - if (active >= rules.mostActive) { - return Result.fail(tooMany(rules, active, 'creating another')); - } - const beyond = beyondTheReactingBound(rules.primitive, registry, { name, content }); - return beyond === undefined - ? recording({ type: 'spec_created', name, version: 1, content, by, at }) - : Result.fail(beyond); -} - -function decideUpdate( - rules: RegistryRules, - { name, content, by, at }: SpecUpdate & CommandMetadata, - registry: SpecRegistry, -): Decision { - const { primitive } = rules; - const existing = registry.get(name); - if (existing === undefined) { - return Result.fail(specNotFound(primitive, name)); - } - if (existing.status === 'retired') { - return Result.fail( - new Conflict({ - detail: `The ${definitionResourceLabel(primitive)} ${name} is retired and can no longer change`, - kind: 'retired', - }), - ); - } - if (existing.source === content.source) { - return nothingToRecord; - } - const active = activeIn(registry); - if (active > rules.mostActive) { - return Result.fail(tooMany(rules, active, 'saving another version')); - } - const beyond = beyondTheReactingBound(primitive, registry, { name, content }); - return beyond === undefined - ? recording({ type: 'spec_updated', name, version: existing.version + 1, content, by, at }) - : Result.fail(beyond); -} - -function decideRetirement( - primitive: string, - { name, by, at }: SpecRetirement & CommandMetadata, - registry: SpecRegistry, -): Decision { - const existing = registry.get(name); - if (existing === undefined) { - return Result.fail(specNotFound(primitive, name)); - } - return existing.status === 'retired' ? nothingToRecord : recording({ type: 'spec_retired', name, by, at }); -} - -export function decideOnSpecs(rules: RegistryRules, command: SpecCommand, registry: SpecRegistry): Decision { - if (command.type === 'create') { - return decideCreation(rules, command, registry); - } - if (command.type === 'update') { - return decideUpdate(rules, command, registry); - } - return decideRetirement(rules.primitive, command, registry); -} diff --git a/packages/specs/src/registry/registry-lookup.ts b/packages/specs/src/registry/registry-lookup.ts deleted file mode 100644 index 1af1ee4e5..000000000 --- a/packages/specs/src/registry/registry-lookup.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { NotFound } from '@beonauto/operations'; -import { Effect } from 'effect'; - -import { definitionResourceLabel } from '../primitive/function-terminology.ts'; -import type { SpecRegistry } from './spec-registry.ts'; -import type { StoredDefinition } from './spec.ts'; - -export function specNotFound(primitive: string, name: string): NotFound { - return new NotFound({ detail: `There is no ${definitionResourceLabel(primitive)} ${name} in this brain` }); -} - -export function findSpec( - registry: SpecRegistry, - primitive: string, - name: string, -): Effect.Effect { - const spec = registry.get(name); - return spec === undefined ? Effect.fail(specNotFound(primitive, name)) : Effect.succeed(spec); -} diff --git a/packages/specs/src/registry/spec-changes.test.ts b/packages/specs/src/registry/spec-changes.test.ts deleted file mode 100644 index f30922d39..000000000 --- a/packages/specs/src/registry/spec-changes.test.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { Schema } from 'effect'; -import { describe, expect, it } from 'vitest'; - -import { specChangeOf } from './spec-changes.ts'; -import { SpecEventSchema, type SpecContent, type SpecEvent } from './spec-events.ts'; - -const encode = Schema.encodeSync(Schema.toCodecJson(SpecEventSchema)); - -const at = { by: 'acme-admin', at: '2026-10-01T09:00:00.000Z' }; - -const triggers: SpecContent['triggers'] = [ - { kind: 'cron', reference: '/schedule/cron', expression: '0 9 * * 1-5' }, - { kind: 'every', reference: '/schedule/every', milliseconds: 900_000 }, -]; - -const reacting: SpecContent = { source: 'schedule: ...', triggers }; - -const plain = { source: 'do: []' }; - -function changeOf(event: SpecEvent) { - return specChangeOf(encode(event)); -} - -describe('the change a record of a spec makes to what starts on its own', () => { - it('activates a version with triggers, and deactivates the spec at a version without or at its retirement', () => { - expect([ - changeOf({ type: 'spec_created', name: 'close', version: 1, content: reacting, ...at }), - changeOf({ type: 'spec_updated', name: 'close', version: 2, content: plain, ...at }), - changeOf({ type: 'spec_retired', name: 'close', ...at }), - changeOf({ type: 'spec_created', name: 'plain', version: 1, content: plain, ...at }), - specChangeOf({ type: 'spec_created' }), - ]).toEqual([ - { kind: 'activated', name: 'close', version: 1, triggers, at: at.at }, - { kind: 'deactivated', name: 'close' }, - { kind: 'deactivated', name: 'close' }, - { kind: 'unchanged' }, - { kind: 'unreadable' }, - ]); - }); -}); diff --git a/packages/specs/src/registry/spec-commands.ts b/packages/specs/src/registry/spec-commands.ts deleted file mode 100644 index cd9f6ae59..000000000 --- a/packages/specs/src/registry/spec-commands.ts +++ /dev/null @@ -1,27 +0,0 @@ -import type { SpecContent } from './spec-events.ts'; - -export interface SpecCreation { - readonly type: 'create'; - readonly name: string; - readonly content: SpecContent; -} - -export interface SpecUpdate { - readonly type: 'update'; - readonly name: string; - readonly content: SpecContent; -} - -export interface SpecRetirement { - readonly type: 'retire'; - readonly name: string; -} - -export type SpecCommandData = SpecCreation | SpecUpdate | SpecRetirement; - -export interface CommandMetadata { - readonly by: string; - readonly at: string; -} - -export type SpecCommand = SpecCommandData & CommandMetadata; diff --git a/packages/specs/src/registry/spec-events.ts b/packages/specs/src/registry/spec-events.ts deleted file mode 100644 index 5fc5c4a9d..000000000 --- a/packages/specs/src/registry/spec-events.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { Schema } from 'effect'; - -import { TriggerSchema } from './spec-triggers.ts'; - -const SpecContentSchema = Schema.Struct({ - source: Schema.String, - description: Schema.optionalKey(Schema.String), - input_schema: Schema.optionalKey(Schema.JsonObject), - output_schema: Schema.optionalKey(Schema.JsonObject), - warnings: Schema.optionalKey(Schema.Array(Schema.String)), - triggers: Schema.optionalKey(Schema.Array(TriggerSchema)), - details: Schema.optionalKey(Schema.JsonObject), -}); - -export type SpecContent = typeof SpecContentSchema.Type; - -const fact = { name: Schema.String, by: Schema.String, at: Schema.String }; - -const SpecCreatedSchema = Schema.Struct({ - type: Schema.Literal('spec_created'), - ...fact, - version: Schema.Int, - content: SpecContentSchema, -}); - -const SpecUpdatedSchema = Schema.Struct({ - type: Schema.Literal('spec_updated'), - ...fact, - version: Schema.Int, - content: SpecContentSchema, -}); - -const SpecRetiredSchema = Schema.Struct({ type: Schema.Literal('spec_retired'), ...fact }); - -export const SpecEventSchema = Schema.Union([SpecCreatedSchema, SpecUpdatedSchema, SpecRetiredSchema]); - -export type SpecEvent = typeof SpecEventSchema.Type; - -export type SpecCreated = Extract; - -export type SpecUpdated = Extract; - -export type SpecRetired = Extract; diff --git a/packages/specs/src/registry/spec-registry.ts b/packages/specs/src/registry/spec-registry.ts deleted file mode 100644 index 1826f2379..000000000 --- a/packages/specs/src/registry/spec-registry.ts +++ /dev/null @@ -1,36 +0,0 @@ -import type { SpecCreated, SpecEvent, SpecRetired, SpecUpdated } from './spec-events.ts'; -import type { StoredDefinition } from './spec.ts'; - -export type SpecRegistry = ReadonlyMap; - -export const initialRegistry: SpecRegistry = new Map(); - -function createdSpec({ name, version, content, by, at }: SpecCreated): StoredDefinition { - return { name, version, status: 'active', ...content, created_at: at, created_by: by, updated_at: at }; -} - -function updatedSpec( - { name, status, created_at, created_by }: StoredDefinition, - { version, content, at }: SpecUpdated, -): StoredDefinition { - return { name, version, status, ...content, created_at, created_by, updated_at: at }; -} - -function retiredSpec(spec: StoredDefinition, { at }: SpecRetired): StoredDefinition { - return { ...spec, status: 'retired', updated_at: at, retired_at: at }; -} - -function withSpec(registry: SpecRegistry, spec: StoredDefinition): SpecRegistry { - return new Map(registry).set(spec.name, spec); -} - -export function evolveRegistry(registry: SpecRegistry, event: SpecEvent): SpecRegistry { - if (event.type === 'spec_created') { - return withSpec(registry, createdSpec(event)); - } - const spec = registry.get(event.name); - if (spec === undefined) { - return registry; - } - return withSpec(registry, event.type === 'spec_updated' ? updatedSpec(spec, event) : retiredSpec(spec, event)); -} diff --git a/packages/specs/src/registry/specs-decider.ts b/packages/specs/src/registry/specs-decider.ts deleted file mode 100644 index 79283d232..000000000 --- a/packages/specs/src/registry/specs-decider.ts +++ /dev/null @@ -1,22 +0,0 @@ -import type { Decider } from '@beonauto/operations'; - -import { decideOnSpecs } from './registry-decisions.ts'; -import type { SpecCommand } from './spec-commands.ts'; -import { SpecEventSchema, type SpecEvent } from './spec-events.ts'; -import { evolveRegistry, initialRegistry, type SpecRegistry } from './spec-registry.ts'; - -export function specsDecider( - primitive: string, - mostActive = Number.POSITIVE_INFINITY, -): Decider { - return { - initialState: initialRegistry, - evolve: evolveRegistry, - decide: (command, registry) => decideOnSpecs({ primitive, mostActive }, command, registry), - eventSchema: SpecEventSchema, - }; -} - -export function specsStreamOf(primitive: string): string { - return `specs/${primitive}`; -} diff --git a/packages/specs/src/run-work/recorded-runs.ts b/packages/specs/src/run-work/recorded-runs.ts deleted file mode 100644 index 52c240c00..000000000 --- a/packages/specs/src/run-work/recorded-runs.ts +++ /dev/null @@ -1,55 +0,0 @@ -import { BrainIdSchema, OrgIdSchema, streamPrefixOfBrain, type StreamReader } from '@beonauto/operations'; -import { Effect, Option, Schema } from 'effect'; - -import { executionDecider, executionStreamOf } from '../execution/execution-decider.ts'; -import { ExecutionEventSchema, type ExecutionEvent } from '../execution/execution-events.ts'; -import { executionDetailOf } from '../execution/execution-lookup.ts'; -import type { ExecutionAddress } from '../execution/execution-settler.ts'; -import { runOf, takesSettlement, type ExecutionStreamState } from '../execution/execution-state.ts'; -import type { RunDetail } from '../execution/execution.ts'; - -export interface RecordedRun { - readonly run: RunDetail; - readonly input: Schema.Json; - readonly awaitsSettlement: boolean; - readonly lastCall: number; -} - -const isWellFormed = Schema.is( - Schema.Struct({ org: OrgIdSchema, brain: BrainIdSchema, id: Schema.String.check(Schema.isUUID()) }), -); - -const decodeExecutionEvent = Schema.decodeUnknownOption(Schema.toCodecJson(ExecutionEventSchema)); - -export function executionEventOf(data: unknown): ExecutionEvent | undefined { - return Option.getOrUndefined(decodeExecutionEvent(data)); -} - -function recordedOf(id: string, state: ExecutionStreamState): Effect.Effect { - const run = runOf(state); - return run === undefined - ? Effect.undefined - : Effect.map(Effect.orDie(executionDetailOf(id, state)), (detail) => ({ - run: detail, - input: run.input, - awaitsSettlement: takesSettlement(run), - lastCall: run.lastCall, - })); -} - -export function recordedRunIn(reader: StreamReader, address: ExecutionAddress): Effect.Effect { - if (!isWellFormed(address)) { - return Effect.undefined; - } - const id = address.id.toLowerCase(); - return reader - .load(`${streamPrefixOfBrain(address)}${executionStreamOf(id)}`, executionDecider) - .pipe(Effect.flatMap(({ state }) => recordedOf(id, state))); -} - -export function recordedRunInBrain(reader: StreamReader, id: string): Effect.Effect { - const run = id.toLowerCase(); - return reader - .load(executionStreamOf(run), executionDecider) - .pipe(Effect.flatMap(({ state }) => recordedOf(run, state))); -} diff --git a/packages/specs/src/testing/relaying.ts b/packages/specs/src/testing/relaying.ts deleted file mode 100644 index 2e571cae7..000000000 --- a/packages/specs/src/testing/relaying.ts +++ /dev/null @@ -1,54 +0,0 @@ -import type { BrainRequest, Lineage } from '@beonauto/operations'; -import { Effect } from 'effect'; - -import { executionSettler, type ExecutionAddress, type Settlement } from '../index.ts'; -import { acmeAdmin } from './callers.ts'; -import { harness, toBrain } from './harness.ts'; -import { probe } from './probe.ts'; -import { relay } from './relay.ts'; -import { specOperationsFor } from './spec-operations.ts'; - -export const relayedId = '0199a3c4-7d2e-7c1a-9b3f-2f1e0d9c8b7a'; - -export const settledAt = '2026-10-01T11:00:00.000Z'; - -export const cancelledAt = '2026-10-01T10:00:00.000Z'; - -const toAlpha = toBrain('acme', 'alpha'); - -const relayed: ExecutionAddress = { org: 'acme', brain: 'alpha', id: relayedId }; - -function handingOn(input: unknown): BrainRequest { - return toAlpha(acmeAdmin, { primitive: 'relay', name: 'hand-on', input, execution_id: relayedId }); -} - -export async function withHandOn() { - const relayer = relay(); - const prober = probe(); - const operations = specOperationsFor([relayer.primitive, prober.primitive]); - const specs = harness(); - await specs.call(operations.createSpec, toAlpha(acmeAdmin, { primitive: 'relay', name: 'hand-on', source: 'text' })); - await specs.call(operations.createSpec, toAlpha(acmeAdmin, { primitive: 'probe', name: 'plain', source: 'text' })); - const settle = executionSettler(specs.ledger.service); - return { - ...specs, - ...operations, - relayer, - prober, - executing: (input: unknown = {}) => specs.call(operations.executeSpec, handingOn(input)), - executingCancelledOnceStarted: (input: unknown) => - specs.callCancelledWhen(relayer.started, operations.executeSpec, handingOn(input)), - reading: () => specs.call(operations.getExecution, toAlpha(acmeAdmin, { execution_id: relayedId })), - cancelling: (input: object = {}, id: string = relayedId) => - specs.call(operations.cancelExecution, toAlpha(acmeAdmin, { execution_id: id, ...input }), cancelledAt), - history: () => - specs.call(operations.getExecutionHistory, toAlpha(acmeAdmin, { execution_id: relayedId, limit: 100 })), - settling: (settlement: Settlement, address: ExecutionAddress = relayed, lineage?: Lineage) => - specs.run(Effect.result(settle(address, settlement, lineage)), settledAt), - breakingDown: (settlement: Settlement) => - specs.run( - Effect.catchDefect(Effect.result(settle(relayed, settlement)), (defect) => Effect.succeed(defect)), - settledAt, - ), - }; -} diff --git a/packages/specs/src/testing/spec-operations.ts b/packages/specs/src/testing/spec-operations.ts deleted file mode 100644 index 721afd8eb..000000000 --- a/packages/specs/src/testing/spec-operations.ts +++ /dev/null @@ -1,34 +0,0 @@ -import type { Presenter } from '@beonauto/operations'; - -import { - defineCancelExecution, - defineCreateSpec, - defineExecuteSpec, - defineGetExecutionHistory, - defineGetSpec, - defineListExecutions, - defineListSpecs, - defineRetireSpec, - defineUpdateSpec, - getExecution, - makeSpecPresenters, - type Primitive, -} from '../index.ts'; - -export function specOperationsFor( - primitives: readonly Primitive[], - presenters: readonly Presenter[] = makeSpecPresenters(primitives), -) { - return { - createSpec: defineCreateSpec(primitives), - listSpecs: defineListSpecs(primitives), - getSpec: defineGetSpec(primitives), - updateSpec: defineUpdateSpec(primitives), - retireSpec: defineRetireSpec(primitives), - executeSpec: defineExecuteSpec(primitives), - getExecution, - cancelExecution: defineCancelExecution(primitives), - listExecutions: defineListExecutions(primitives), - getExecutionHistory: defineGetExecutionHistory(presenters), - }; -} diff --git a/packages/specs/src/tool-calls/tool-call-journal.test.ts b/packages/specs/src/tool-calls/tool-call-journal.test.ts deleted file mode 100644 index d28fb8bbc..000000000 --- a/packages/specs/src/tool-calls/tool-call-journal.test.ts +++ /dev/null @@ -1,172 +0,0 @@ -import { Effect, Schema } from 'effect'; -import { describe, expect, it } from 'vitest'; - -import type { ExecutionCommand } from '../execution/execution-commands.ts'; -import { executionDecider, executionStreamOf } from '../execution/execution-decider.ts'; -import { acmeAdmin } from '../testing/callers.ts'; -import { harness, toBrain } from '../testing/harness.ts'; -import { specOperationsFor } from '../testing/spec-operations.ts'; -import { startOfCall, toolUser } from '../testing/tool-user.ts'; - -const toAlpha = toBrain('acme', 'alpha'); - -const executionId = '0199a3c4-7d2e-7c1a-9b3f-2f1e0d9c8b7a'; - -const at = { by: 'acme-admin', at: '2026-10-01T09:00:00.000Z' }; - -const typesOf = Schema.decodeUnknownSync( - Schema.Struct({ output: Schema.Struct({ events: Schema.Array(Schema.Struct({ type: Schema.String })) }) }), -); - -async function brainWithToolUser() { - const user = toolUser(); - const operations = specOperationsFor([user.primitive]); - const specs = harness(); - await specs.call(operations.createSpec, toAlpha(acmeAdmin, { primitive: 'tool-user', name: 'caller', source: 'x' })); - const executing = (input: object) => - specs.call( - operations.executeSpec, - toAlpha(acmeAdmin, { primitive: 'tool-user', name: 'caller', input, execution_id: executionId }), - ); - const history = async () => - typesOf( - await specs.call(operations.getExecutionHistory, toAlpha(acmeAdmin, { execution_id: executionId, limit: 100 })), - ).output.events.map(({ type }) => type); - const recordedDirectly = (...commands: readonly ExecutionCommand[]) => - specs.run( - Effect.forEach(commands, (command) => - Effect.orDie( - specs.ledger.service.execute(`brain/acme/alpha/${executionStreamOf(executionId)}`, executionDecider, command), - ), - ), - ); - return { ...specs, ...operations, user, executing, history, recordedDirectly }; -} - -const calls = (count: number, type: string): readonly string[] => Array.from({ length: count }, () => type); - -describe('the journal of a run', () => { - it('records ten calls made at once, one append at a time, each start before its answer', async () => { - const { executing, history, user } = await brainWithToolUser(); - - expect(await executing({ calls: 10 })).toMatchObject({ - status: 'succeeded', - output: { output: { recorded: Array.from({ length: 20 }, () => true) } }, - }); - expect(await history()).toEqual([ - 'execution_started', - ...calls(10, 'tool_call_started'), - ...calls(10, 'tool_call_answered'), - 'execution_succeeded', - ]); - expect(user.primitive.describeOutput({ recorded: [] })).toBe('It called its tools.'); - }); - - it('numbers the calls made at once in the order their starts land, each from the run, never twice', async () => { - const { call, executing, getExecutionHistory } = await brainWithToolUser(); - await executing({ calls: 10 }); - - const read = await call(getExecutionHistory, toAlpha(acmeAdmin, { execution_id: executionId, limit: 100 })); - const { events } = Schema.decodeUnknownSync( - Schema.Struct({ - output: Schema.Struct({ - events: Schema.Array( - Schema.Struct({ type: Schema.String, data: Schema.Struct({ number: Schema.optionalKey(Schema.Int) }) }), - ), - }), - }), - )(read).output; - - expect(events.filter(({ type }) => type === 'tool_call_started').map(({ data }) => data.number)).toEqual([ - 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, - ]); - }); -}); - -describe('the journal of a run that has finished', () => { - it('records nothing more', async () => { - const { executing, history, user } = await brainWithToolUser(); - await executing({ calls: 1 }); - - expect(await user.recordedLate()).toEqual([false]); - expect(await user.startedLate()).toEqual([undefined]); - expect(await history()).toEqual([ - 'execution_started', - 'tool_call_started', - 'tool_call_answered', - 'execution_succeeded', - ]); - }); - - it('leaves a call in flight when the run is cancelled with a start and no answer', async () => { - const { callCancelledWhen, executeSpec, history, user } = await brainWithToolUser(); - const request = toAlpha(acmeAdmin, { - primitive: 'tool-user', - name: 'caller', - input: { calls: 1, ending: 'stall' }, - execution_id: executionId, - }); - - expect(await callCancelledWhen(user.stalled, executeSpec, request)).toEqual({ status: 'cancelled' }); - expect(await user.recordedLate()).toEqual([false]); - expect(await history()).toEqual(['execution_started', 'tool_call_started', 'execution_failed']); - }); -}); - -describe('a run that called tools', () => { - it.each(['unavailable', 'conflict'])( - 'is not run again under its id after it ended %s, and says to start a new run', - async (ending) => { - const { executing, user } = await brainWithToolUser(); - await executing({ calls: 1, ending }); - - expect(await executing({ calls: 1, ending })).toMatchObject({ - status: 'rejected', - reason: 'conflict', - kind: 'tools_called', - }); - expect(await user.recordedLate()).toEqual([false]); - }, - ); - - it('is answered again when it succeeded', async () => { - const { executing, user } = await brainWithToolUser(); - const first = await executing({ calls: 2 }); - - expect(await executing({ calls: 2 })).toEqual(first); - expect(await user.recordedLate()).toEqual([false]); - }); - - it('left started by a server that died stays started, is listed as running, and is not run again', async () => { - const { call, executing, getExecution, listExecutions, recordedDirectly } = await brainWithToolUser(); - await recordedDirectly( - { type: 'start', primitive: 'tool-user', name: 'caller', input: {}, spec_version: 1, calls_tools: true, ...at }, - { type: 'tool_call', fact: startOfCall(1), ...at }, - ); - - expect(await executing({})).toMatchObject({ status: 'rejected', reason: 'conflict', kind: 'tools_called' }); - expect(await call(getExecution, toAlpha(acmeAdmin, { execution_id: executionId }))).toMatchObject({ - output: { status: 'started' }, - }); - expect(await call(listExecutions, toAlpha(acmeAdmin, { status: 'started' }))).toMatchObject({ - output: { executions: [{ execution_id: executionId, status: 'started' }] }, - }); - }); -}); - -describe('a run of a spec that calls tools, still in progress', () => { - it('is not run again under its id while an earlier call still runs it, before any tool was called', async () => { - const { callCancelledWhen, executeSpec, executing, history, user } = await brainWithToolUser(); - const first = toAlpha(acmeAdmin, { - primitive: 'tool-user', - name: 'caller', - input: { calls: 0, ending: 'stall' }, - execution_id: executionId, - }); - const retried = user.stalled.then(() => executing({ calls: 0, ending: 'stall' })); - - expect(await callCancelledWhen(retried, executeSpec, first)).toEqual({ status: 'cancelled' }); - expect(await retried).toMatchObject({ status: 'rejected', reason: 'conflict', kind: 'tools_called' }); - expect(await history()).toEqual(['execution_started', 'execution_failed']); - }); -}); diff --git a/packages/workflow-engine/README.md b/packages/workflow-engine/README.md index 459ce6849..a83a42246 100644 --- a/packages/workflow-engine/README.md +++ b/packages/workflow-engine/README.md @@ -1,6 +1,6 @@ # @beonauto/workflow-engine -The contract of the workflow machine that runs on the ledger: the DSL it runs, the state it keeps, the inputs it takes and the ports to the adapters that store, time and execute for it. The machine's state is shaped by the workflow DSL, so this package is the workflow machine's contract, not a generic runtime. It knows workflows, their DSL and an executor that performs calls. It does not know brains, prompts, models, specs or primitives: the functions a workflow may call come from the caller, and whatever an adapter needs to know about a run, such as who started it, it passes as opaque `attributes` and gets back with every output. +The contract of the workflow machine that runs on the ledger: the DSL it runs, the state it keeps, the inputs it takes and the ports to the adapters that store, time and execute for it. The machine's state is shaped by the workflow DSL, so this package is the workflow machine's contract, not a generic runtime. It knows workflows, their DSL and an executor that performs calls. It does not know brains, prompts, models, definitions or capabilities: the functions a workflow may call come from the caller, and whatever an adapter needs to know about a run, such as who started it, it passes as opaque `attributes` and gets back with every output. The same code runs in Node, where one server keeps every run in one SQLite file, and in Auto's cloud hosting, the hosted runtime, where each run is an isolate of its own. The decision record states what the hosted runtime allows: a single-threaded isolate per run, woken by alarms that fire at least once and are dropped after a bounded number of failed retries, with about 128 MB of memory and bounded CPU per wake-up, no long-lived process and no code generation, and its own SQLite with rows of at most 2 MB; the shared database has no interactive transactions. The machine decides each input (below). In Node, [`@beonauto/workflow-host`](../workflow-host) hosts it over the ledger, and the workflow adapter runs every workflow on it; the hosted runtime's adapters are the next step. [The decision record](../../docs/decisions/0001-workflow-engine-on-the-ledger.md) says why. @@ -9,48 +9,48 @@ The same code runs in Node, where one server keeps every run in one SQLite file, - `@beonauto/workflow-engine`: the contract, the machine, `workflowMachine(options)`, the engine an adapter builds over its ports, `workflowEngineOf(ports, options, cache)`, and what the workflow adapter reads a document with: JSON helpers, the policy, its checks and the limits of a document, from `src/index.ts`. - `@beonauto/workflow-engine/testing`: the memory adapter of `src/memory` and its virtual clock, a driver over it, the probes of the ports' contract that every adapter runs, and `scriptedPool(script, otherwise)`, a `ProgramPool` that answers runs with the outcomes of its script in turn and then hands them, its folds and its closing to `otherwise`, for tests that need an outcome a real worker gives only rarely (`src/testing/index.ts`). Neither the entry nor anything it imports takes a Node module or the YAML parser, so a hosted adapter can run the probes (`src/engine/portability.test.ts`). - `@beonauto/workflow-engine/worker`: the answerers a worker module hands its jobs to, `answerOf(request, host)`, which runs a program's request with the compiler, the clock and the output `check` its `host` gives, and `foldAnswerOf(request, host)`, which folds a page with the compiler, the clock, the place it marks and the view checks of its `host` (see [A page of folds](#a-page-of-folds)), with `progressOf(shared)`, the place of a page's folds in memory shared with the pool, and `mostValueDepth` (`src/worker.ts`). They are portable: neither they nor anything they import reaches the pool or a Node module, so a hosted adapter can answer the same requests (`src/engine/portability.test.ts`). -- `@beonauto/workflow-engine/job-loop`: `serveJobs({ program, fold, checks })`, the loop a worker module of the pool runs (`src/job-loop.ts`, `src/program-pool/job-loop.ts`; see [The workers of the pool](#the-workers-of-the-pool)). It is an entry of its own because it listens on `node:worker_threads`' parent port, which the hosted runtime does not have; the pool's own two worker modules run it, as does the checked worker of `@beonauto/specs/json-schema`. -- `@beonauto/workflow-engine/dsl`: the evaluator that workflow expressions run on, for any other capability that evaluates a program in jq, the format's duration reader, `readDuration`, which an interaction function reads its `expires` with, and a pool of worker threads, kept warm between jobs, that runs a program or a page of folds in a worker of the module the job names (`src/dsl.ts`; see [Programs](#programs), [A page of folds](#a-page-of-folds) and [The workers of the pool](#the-workers-of-the-pool)). The computation primitive runs computation functions with it, the recollection primitive the `answer` of a recall function, and the workflow host the folds of the views recall functions keep. It is an entry of its own because the pool imports `node:worker_threads` and arms host timers, which the hosted runtime's single-threaded isolates do not have: the main, testing and worker entries reach neither the pool nor anything that imports it, and only this entry and the job loop's do (`src/engine/portability.test.ts`). +- `@beonauto/workflow-engine/job-loop`: `serveJobs({ program, fold, checks })`, the loop a worker module of the pool runs (`src/job-loop.ts`, `src/program-pool/job-loop.ts`; see [The workers of the pool](#the-workers-of-the-pool)). It is an entry of its own because it listens on `node:worker_threads`' parent port, which the hosted runtime does not have; the pool's own two worker modules run it, as does the checked worker of `@beonauto/definitions/json-schema`. +- `@beonauto/workflow-engine/dsl`: the evaluator that workflow expressions run on, for any other capability that evaluates a program in jq, the format's duration reader, `readDuration`, which an interaction function reads its `expires` with, and a pool of worker threads, kept warm between jobs, that runs a program or a page of folds in a worker of the module the job names (`src/dsl.ts`; see [Programs](#programs), [A page of folds](#a-page-of-folds) and [The workers of the pool](#the-workers-of-the-pool)). The computation capability runs computation functions with it, the recall capability the `answer` of a recall function, and the workflow host the folds of the views recall functions keep. It is an entry of its own because the pool imports `node:worker_threads` and arms host timers, which the hosted runtime's single-threaded isolates do not have: the main, testing and worker entries reach neither the pool nor anything that imports it, and only this entry and the job loop's do (`src/engine/portability.test.ts`). ## How a run moves -1. An adapter submits an input for an execution, with `at`, the time on its own clock. The machine never reads a clock. It takes `max(at, lastInputAt)` as the time of the input, and for a fired timer at least the time it was due, so time in a run never goes back however the adapters' clocks drift (`inputTimeOf`). -2. Holding the run's serialisation, the engine runs the ledger's own load-decide-append loop, `decisionLoop` from `@beonauto/ledger`, with a load of its own: the run the engine kept after the input before, when its stream has no event after the version it kept, or else `RunStore.load`, the latest snapshot and the events after it, which `loadedRunOf` folds (`runLoopOf`, and The cache of loaded runs below). +1. An adapter submits an input for a run, with `at`, the time on its own clock. The machine never reads a clock. It takes `max(at, lastInputAt)` as the time of the input, and for a fired timer at least the time it was due, so time in a run never goes back however the adapters' clocks drift (`inputTimeOf`). +2. Holding the run's serialisation, the engine runs the ledger's own load-decide-append loop, `decisionLoop` from `@beonauto/ledger`, with a load of its own: the run the engine kept after the input before, when its stream has no event after the version it kept, or else `RunLogStore.load`, the latest snapshot and the events after it, which `loadedRunOf` folds (`runLoopOf`, and The cache of loaded runs below). 3. `staleReasonOf(state, input)` says whether the input can still change the run. An input to a run whose `started` has not arrived is `not_started`: the engine answers `{ outcome: 'not_started' }`, which an adapter answers as `not_found` so the caller tries again, as the API does today. Any other reason is `stale`. Neither appends anything (`submissionOf`). 4. Otherwise the machine decides, and the decision is one event, appended with the version the loop read as the expected version. After a version conflict the loop loads and decides again, up to three more times, and then fails with the ledger's `Conflict`. The engine answers `{ outcome: 'applied' }`. 5. When a snapshot is due, the engine saves one. -6. It dispatches the outputs of every event above the run's dispatch watermark, in the order of the stream, and stops at the first output that fails. The watermark moves to the last event whose outputs were all dispatched. An event that arms, cancels or fires a timer also notes the run's next due time in the record (`runDueOf`), so a run whose last timer fired while it went on to wait for an event is no longer due (`src/engine/sweep.test.ts`); a note that fails is a dispatch failure too, so the watermark stays where it was, below the stream's version, and the next wake or input dispatches the events again and notes the time (`src/engine/wake.test.ts`). The execution a dispatch is for is the one the input or the wake names, never the loaded state's, so a wake of a run that has no event reads and moves that run's watermark. -7. `wake(executionId)` does step 6 again. `sweep(before)` wakes the runs that are overdue (below). +6. It dispatches the outputs of every event above the run's dispatch watermark, in the order of the stream, and stops at the first output that fails. The watermark moves to the last event whose outputs were all dispatched. An event that arms, cancels or fires a timer also notes the run's next due time in the record (`runDueOf`), so a run whose last timer fired while it went on to wait for an event is no longer due (`src/engine/sweep.test.ts`); a note that fails is a dispatch failure too, so the watermark stays where it was, below the stream's version, and the next wake or input dispatches the events again and notes the time (`src/engine/wake.test.ts`). The run a dispatch is for is the one the input or the wake names, never the loaded state's, so a wake of a run that has no event reads and moves that run's watermark. +7. `wake(runId)` does step 6 again. `sweep(before)` wakes the runs that are overdue (below). ## Layers and ports -| Layer | Folder | What it holds | Port | -| ------------- | ------------------- | ----------------------------------------------------------------------- | --------------------------------------- | -| DSL | `src/dsl` | JSON, durations, jq expressions with their work budget, tasks, policy | none: pure | -| programs | `src/programs` | the evaluator: compiling a program for a dialect, its checks, its runs | none: pure | -| jobs | `src/jobs` | the pool's contract and messages, an answer, the job loop's caches | none: pure | -| program pool | `src/program-pool` | warm worker threads, their permits, the job loop, a deadline and a heap | none: Node only, reached from `dsl.ts` | -| workers | `src/workers` | the pool's own program and fold workers, on the job loop | none: Node only, loaded by the pool | -| folds | `src/folds` | a page of events folded into views, and how a page crosses to a worker | none: pure, given its clock | -| machine | `src/machine` | inputs, state, held values, admission, the clock, UTC time, draws | none: pure | -| runner | `src/runner` | the session of one input, the list and task runners | none: pure | -| tasks | `src/tasks` | each task body: start, resume and cancel over its frame | none: pure | -| decider | `src/decider` | the run's lifecycle, its bounds, the patch and outputs of an event | none: pure | -| run log | `src/run-log` | events, state patches, state formats, the fold, snapshots | `RunStore` (one Emmett stream per run) | -| steps | `src/steps` | the step entries of an event, what caused each, the ids of step events | none: pure | -| timers | `src/timers` | timer ids and what each timer is for | `Timers` | -| inbox | `src/inbox` | the external events a run receives | none: events arrive as `event_received` | -| filters | `src/filters` | an event filter read and matched over an event alone | none: pure | -| reactions | `src/reactions` | the ports a run reacts through, its listeners and its emissions | `Listeners`, `Emitter` | -| executor | `src/executor` | call keys | `Executor` | -| dispatch | `src/dispatch` | outputs, the watermark, the order of a dispatch, a run's next due time | `DispatchWatermark` | -| serialisation | `src/serialisation` | one input at a time for each run | `RunSerialiser` | -| settlement | `src/settlement` | settle receipts, due times, troubling receipts | `RecordStore`, `RunReporter` | -| cache | `src/cache` | the runs the engine keeps loaded between inputs, bounded | none | -| engine | `src/engine` | the loop on the ledger, the ports together, the engine's interface | `WorkflowEngine` | -| memory | `src/memory` | a run store, timers, executor, record store and watermark in memory | every port, in memory | -| testing | `src/testing` | a driver over the memory adapter, the ports' probes, run readers | none | -| pool testing | `src/pool-testing` | the scripted pool and the counting modules the pool's tests use | none | +| Layer | Folder | What it holds | Port | +| ------------- | ------------------- | ----------------------------------------------------------------------- | ----------------------------------------- | +| DSL | `src/dsl` | JSON, durations, jq expressions with their work budget, tasks, policy | none: pure | +| programs | `src/programs` | the evaluator: compiling a program for a dialect, its checks, its runs | none: pure | +| jobs | `src/jobs` | the pool's contract and messages, an answer, the job loop's caches | none: pure | +| program pool | `src/program-pool` | warm worker threads, their permits, the job loop, a deadline and a heap | none: Node only, reached from `dsl.ts` | +| workers | `src/workers` | the pool's own program and fold workers, on the job loop | none: Node only, loaded by the pool | +| folds | `src/folds` | a page of events folded into views, and how a page crosses to a worker | none: pure, given its clock | +| machine | `src/machine` | inputs, state, held values, admission, the clock, UTC time, draws | none: pure | +| runner | `src/runner` | the session of one input, the list and task runners | none: pure | +| tasks | `src/tasks` | each task body: start, resume and cancel over its frame | none: pure | +| decider | `src/decider` | the run's lifecycle, its bounds, the patch and outputs of an event | none: pure | +| run log | `src/run-log` | events, state patches, state formats, the fold, snapshots | `RunLogStore` (one Emmett stream per run) | +| steps | `src/steps` | the step entries of an event, what caused each, the ids of step events | none: pure | +| timers | `src/timers` | timer ids and what each timer is for | `Timers` | +| inbox | `src/inbox` | the external events a run receives | none: events arrive as `event_received` | +| filters | `src/filters` | an event filter read and matched over an event alone | none: pure | +| reactions | `src/reactions` | the ports a run reacts through, its listeners and its emissions | `Listeners`, `Emitter` | +| executor | `src/executor` | call keys | `Executor` | +| dispatch | `src/dispatch` | outputs, the watermark, the order of a dispatch, a run's next due time | `DispatchWatermark` | +| serialisation | `src/serialisation` | one input at a time for each run | `RunSerialiser` | +| settlement | `src/settlement` | settle receipts, due times, troubling receipts | `RecordStore`, `RunReporter` | +| cache | `src/cache` | the runs the engine keeps loaded between inputs, bounded | none | +| engine | `src/engine` | the loop on the ledger, the ports together, the engine's interface | `WorkflowEngine` | +| memory | `src/memory` | a run store, timers, executor, record store and watermark in memory | every port, in memory | +| testing | `src/testing` | a driver over the memory adapter, the ports' probes, run readers | none | +| pool testing | `src/pool-testing` | the scripted pool and the counting modules the pool's tests use | none | Every port answers with an Effect. None of them is a clock: time comes in with the inputs, and the sweep is given the time before which a run is overdue. @@ -58,7 +58,7 @@ The settlement and call-result vocabulary lives once, in `@beonauto/operations` ## The DSL -`policyOf(functions)` gives the policy a document is checked against. The caller gives the functions a workflow may call, each with the checks of its arguments, a description of a call for the messages it raises, and the words that explain where a workflow reaches the world and how it starts; the workflow adapter gives `execute_spec` (`primitives/orchestration/src/document/workflow-functions.ts`). The DSL itself names no function. +`policyOf(functions)` gives the policy a document is checked against. The caller gives the functions a workflow may call, each with the checks of its arguments, a description of a call for the messages it raises, and the words that explain where a workflow reaches the world and how it starts; the workflow adapter gives `run_definition` (`capabilities/coordination/src/document/workflow-functions.ts`). The DSL itself names no function. Compiled expressions are kept in one cache for the process, of at most 262,144 characters of expression source, letting go of the expression used longest ago: a compiled expression measured 22 to 34 bytes of heap per character of source, so the cache holds at most about 9 MiB, and compiling one again took 10 to 150 µs. @@ -76,7 +76,7 @@ Workflow expressions and every other caller evaluate jq through one evaluator, ` `liftedLimits(mostWork)` gives the limits of a caller that wants work alone to govern: no bound on steps or outputs, a value depth of 512, and an evaluation depth of `mostEvaluationDepth`, 10,000 levels, kept so that a recursion ends the same way on every host. `def g: if . == 0 then 0 else (. - 1 | g) end` reaches it after 1,999 calls, and with no bound would reach 26,703 in a stack of 64 MiB before the stack overflowed. The patch makes `until`, `while` and `recurse` iterative, so a loop of any length leaves the depth where it found it and only work bounds it. -`programPool({ workers, heapMegabytes })` runs a `ProgramRequest` (the source, the input, the dialect, the limits, `deadlineMs`, the most bytes its output may take as JSON, and optionally the `worker` module that runs it and a JSON `context` that module reads from the request, as the checked worker of `@beonauto/specs` checks an output against the schema it names; a pool given its own `worker` in its settings, as tests do, runs every request with that one) in a worker thread of that module, kept warm between jobs, at most `workers` at once (`src/program-pool`; see [The workers of the pool](#the-workers-of-the-pool)). A run waits for a permit until its deadline, and the deadline also stops it there: the worker evaluates with the deadline inside the work charge, and the pool terminates it when the deadline passes, so a run whose evaluation never charges, or whose worker never answers, still ends. The program and the input cross to the worker as copies, the input as JSON text, and the answer comes back as JSON text with its size, so the thread that holds the ledger never waits on a program. Every worker has a heap of `heapMegabytes`, a stack of `workerStackMegabytes`, 64 MiB, none of the server's flags, and an empty environment unless the pool's settings give it one, `environment`, which the server never does, so no setting of the server, its keys among them, reaches a worker. A worker given no `execArgv` takes the flags of the process that starts it, and Node would then read an `--env-file` of the server, which the development runner gives, into every worker over the environment the pool gives, and run a preload of the server, `--import`, in every worker; the pool therefore gives each an empty `execArgv` (`src/program-pool/worker-flags.test.ts`), and Node strips the types of a worker module without a flag. The tests give only `NODE_V8_COVERAGE`, so the coverage of a worker is measured. An outcome is the worker's answer, `oversized` for an output over its bytes, which the worker measures as JSON before it writes any of it, counting each escape as JSON writes it and stopping once the count passes the bound (`src/programs/byte-sizes.ts`), so an output whose JSON would take hundreds of megabytes is refused without being written, `refused` for a program the worker would not compile, `mismatched` with the issues a request's worker found in the output, each a `pointer` and a `detail` cut at 1,024 bytes apart, and the detail of every issue in an answer cut at `mostIssueBytes`, 1,024 bytes of UTF-8, marked with `…` (`textWithin`), so an error a program raises with a text of many megabytes crosses back at that size, `stopped` because of the `deadline`, `memory` (the worker's `ERR_WORKER_OUT_OF_MEMORY`), `busy` when no worker came free, `cancelled` by the caller's signal or `closing`, or `crashed` for any other ending, and each says how many `milliseconds` it took. `close()` turns away the runs still waiting and terminates every worker, idle or busy, a busy job ending `closing`. +`programPool({ workers, heapMegabytes })` runs a `ProgramRequest` (the source, the input, the dialect, the limits, `deadlineMs`, the most bytes its output may take as JSON, and optionally the `worker` module that runs it and a JSON `context` that module reads from the request, as the checked worker of `@beonauto/definitions` checks an output against the schema it names; a pool given its own `worker` in its settings, as tests do, runs every request with that one) in a worker thread of that module, kept warm between jobs, at most `workers` at once (`src/program-pool`; see [The workers of the pool](#the-workers-of-the-pool)). A run waits for a permit until its deadline, and the deadline also stops it there: the worker evaluates with the deadline inside the work charge, and the pool terminates it when the deadline passes, so a run whose evaluation never charges, or whose worker never answers, still ends. The program and the input cross to the worker as copies, the input as JSON text, and the answer comes back as JSON text with its size, so the thread that holds the ledger never waits on a program. Every worker has a heap of `heapMegabytes`, a stack of `workerStackMegabytes`, 64 MiB, none of the server's flags, and an empty environment unless the pool's settings give it one, `environment`, which the server never does, so no setting of the server, its keys among them, reaches a worker. A worker given no `execArgv` takes the flags of the process that starts it, and Node would then read an `--env-file` of the server, which the development runner gives, into every worker over the environment the pool gives, and run a preload of the server, `--import`, in every worker; the pool therefore gives each an empty `execArgv` (`src/program-pool/worker-flags.test.ts`), and Node strips the types of a worker module without a flag. The tests give only `NODE_V8_COVERAGE`, so the coverage of a worker is measured. An outcome is the worker's answer, `oversized` for an output over its bytes, which the worker measures as JSON before it writes any of it, counting each escape as JSON writes it and stopping once the count passes the bound (`src/programs/byte-sizes.ts`), so an output whose JSON would take hundreds of megabytes is refused without being written, `refused` for a program the worker would not compile, `mismatched` with the issues a request's worker found in the output, each a `pointer` and a `detail` cut at 1,024 bytes apart, and the detail of every issue in an answer cut at `mostIssueBytes`, 1,024 bytes of UTF-8, marked with `…` (`textWithin`), so an error a program raises with a text of many megabytes crosses back at that size, `stopped` because of the `deadline`, `memory` (the worker's `ERR_WORKER_OUT_OF_MEMORY`), `busy` when no worker came free, `cancelled` by the caller's signal or `closing`, or `crashed` for any other ending, and each says how many `milliseconds` it took. `close()` turns away the runs still waiting and terminates every worker, idle or busy, a busy job ending `closing`. A `ProgramRequest` may also give the program `variables`, which cross to the worker as JSON text beside the input, as the `answer` of a recall function takes `$input`; `programPool` also runs a page of folds in a worker of its own, `fold(request, signal)` (see [A page of folds](#a-page-of-folds)). @@ -88,20 +88,20 @@ A worker's heap limit stops a run that allocates in the evaluator, but Node ends A view whose filter does not compile, runs out of work or nests too deep, or whose fold raises, gives no output or more than one, does more work than the limits allow, nests a value, its evaluation or the thread's stack deeper than they allow, gives a number JSON cannot carry, answers a view over its bytes or one its check refuses, or does not compile, stalls at that event, `{ at, kind, message, span }`, the message cut at `mostIssueBytes`, and folds nothing more in the page; one whose filter or fold ran past the fold's deadline, which the two share, is marked `overtime` at that event and folds nothing more, so its caller can try it again. Neither touches another view. Before each fold the page checks its budget, `pageBudgetMs`, counted from the start of its first fold, so starting a worker and compiling the page's programs never spend it: it runs at least one fold, and once the budget is spent it ends early, before the next fold, so at most one fold runs past the budget and a slow fold never makes the folds after it look slow. Each view answers `through`, the index of the last event it considered, so a page that ended early between two views of the same event says how far each went. -`programPool`'s `fold(request, signal)` runs such a page in a worker, the events and views crossing as JSON text and the views coming back so: the request's `worker` module, which serves the page with `foldAnswerOf` and the view checks it gives the job loop, as the checked worker of `@beonauto/specs` checks a view against its schema for the recollection primitive, or the pool's own fold worker, which refuses every view that keeps a schema, since it checks none. The page waits for a permit at most `waitMs` and gives the worker `deadlineMs` from the moment it takes the page, so a page that waited for a worker never loses its time to the wait, while the worker's start counts against the deadline and not against the page's budget. The worker marks, in memory it shares with the pool, the event and the view of each fold before it matches the event against the view's filters, so a page the pool stops at its deadline, by its memory or because it crashed names the fold that was going as `progress`, and its caller can count that against the one view it belongs to. +`programPool`'s `fold(request, signal)` runs such a page in a worker, the events and views crossing as JSON text and the views coming back so: the request's `worker` module, which serves the page with `foldAnswerOf` and the view checks it gives the job loop, as the checked worker of `@beonauto/definitions` checks a view against its schema for the recall capability, or the pool's own fold worker, which refuses every view that keeps a schema, since it checks none. The page waits for a permit at most `waitMs` and gives the worker `deadlineMs` from the moment it takes the page, so a page that waited for a worker never loses its time to the wait, while the worker's start counts against the deadline and not against the page's budget. The worker marks, in memory it shares with the pool, the event and the view of each fold before it matches the event against the view's filters, so a page the pool stops at its deadline, by its memory or because it crashed names the fold that was going as `progress`, and its caller can count that against the one view it belongs to. ## The workers of the pool The pool keeps the workers of each module URL between jobs, every module's workers sharing the pool's permits (`src/program-pool/pool-workers.ts`). A job takes a permit, then the most recently used idle worker of its module or, when there is none, starts one, and posts it an envelope, `{ job, kind, request }`, the job's id from a counter of the pool's, `program` or `fold`, and the request as its schema in `src/jobs` encodes it, `context` among its fields; a page's progress buffer, a `SharedArrayBuffer`, travels beside the request as `progress`. The worker answers `{ job, answer, keep }`, and the pool matches the answer to the job by its id: a message that is not an envelope, an answer naming another job or one the job's kind does not decode ends the job `crashed` and lets the worker go, and a message, an error or an exit of an idle worker forgets that worker and ends nothing. An answer that arrives after the pool terminated its worker is never read. -A worker module runs `serveJobs({ program, fold, checks })` of the `job-loop` entry, which listens on the parent port, decodes each envelope with the schema the pool encoded it with, and hands the whole request to the handler of its kind with a host: the clock, a compiler that keeps the programs it compiled, by source and dialect, in a cache of at most 262,144 characters, the workflow caller's bound, and the output check of the request's `context` or the view checks of each view's schema, compiled once for each schema from the `checks` the module gives and kept the same way (`src/jobs/job-kit.ts`); with no `checks` a program goes unchecked and a view that keeps a schema stalls. A request the loop cannot read is answered `unreadable`, which a page's job reads as such and a program's as a crash, and the worker is not kept. The pool's own `src/workers/program-worker.ts` and `fold-worker.ts` serve `answerOf` and `foldAnswerOf` so; the pool names them for a request that names no worker, as the engine's own tests do, while the capabilities name the checked worker of `@beonauto/specs` for every run. +A worker module runs `serveJobs({ program, fold, checks })` of the `job-loop` entry, which listens on the parent port, decodes each envelope with the schema the pool encoded it with, and hands the whole request to the handler of its kind with a host: the clock, a compiler that keeps the programs it compiled, by source and dialect, in a cache of at most 262,144 characters, the workflow caller's bound, and the output check of the request's `context` or the view checks of each view's schema, compiled once for each schema from the `checks` the module gives and kept the same way (`src/jobs/job-kit.ts`); with no `checks` a program goes unchecked and a view that keeps a schema stalls. A request the loop cannot read is answered `unreadable`, which a page's job reads as such and a program's as a crash, and the worker is not kept. The pool's own `src/workers/program-worker.ts` and `fold-worker.ts` serve `answerOf` and `foldAnswerOf` so; the pool names them for a request that names no worker, as the engine's own tests do, while the capabilities name the checked worker of `@beonauto/definitions` for every run. The answer says whether the worker may be kept, by the rule of its kind: a program keeps it unless its evaluator ended it at the deadline, since the thread was busy past the job's deadline; a page keeps it unless a view ran past its fold's deadline, `overtime`, or the page was `unreadable`. The pool lets a worker go, terminating it before the job's permit is released: - after any job the pool stopped, by its deadline, the memory, a cancellation or the closing, after any job that crashed, and after any answer that says not to keep it; - after its 1,000th job, `jobsPerWorker`, and after a minute idle, `idleMs`, on a timer that is unreferenced, as an idle worker is, so neither holds the process open. Both are settings of the pool for tests, never for the operator. -A worker keeps between jobs its loaded modules, its compiled programs and its compiled checks, which are pure functions of their text, and nothing of a job: the evaluator's tracker, its regular expressions and its meter of work are made for each evaluation, and the caches of sizes, depths and conversions are weak maps keyed by values parsed for that job. So the same program on the same input spends the same work in a warm worker as in a fresh one, and a program that writes through `__proto__` leaves nothing a later job sees (`primitives/computation/src/run/warm-isolation.test.ts`). Jobs never queue on a worker, only for a permit; a worker is let go of while its last job still holds the permit, and a job whose module has no idle worker when the threads alive equal the permits first lets go of the oldest idle worker of any module, so the threads alive never exceed `workers` (`src/program-pool/pool-permits.test.ts`). Nothing starts before it is needed: the first job of a module pays its worker's start inside its own deadline. A job cancelled while it waits for its seat ends `cancelled` without a worker being started for it, and a warm worker taken for a job already cancelled goes back to rest without serving it (`src/program-pool/warm-workers.test.ts`). Since the oldest idle worker is let go of to start one of another module, jobs that alternate between two modules on a full pool keep letting go of the worker the next job needs; production therefore names one module for every job. +A worker keeps between jobs its loaded modules, its compiled programs and its compiled checks, which are pure functions of their text, and nothing of a job: the evaluator's tracker, its regular expressions and its meter of work are made for each evaluation, and the caches of sizes, depths and conversions are weak maps keyed by values parsed for that job. So the same program on the same input spends the same work in a warm worker as in a fresh one, and a program that writes through `__proto__` leaves nothing a later job sees (`capabilities/computation/src/run/warm-isolation.test.ts`). Jobs never queue on a worker, only for a permit; a worker is let go of while its last job still holds the permit, and a job whose module has no idle worker when the threads alive equal the permits first lets go of the oldest idle worker of any module, so the threads alive never exceed `workers` (`src/program-pool/pool-permits.test.ts`). Nothing starts before it is needed: the first job of a module pays its worker's start inside its own deadline. A job cancelled while it waits for its seat ends `cancelled` without a worker being started for it, and a warm worker taken for a job already cancelled goes back to rest without serving it (`src/program-pool/warm-workers.test.ts`). Since the oldest idle worker is let go of to start one of another module, jobs that alternate between two modules on a full pool keep letting go of the worker the next job needs; production therefore names one module for every job. ## Event filters @@ -122,11 +122,11 @@ A `listen` filter reaches events beyond its run when its `with` names a `type` w | `event_offered` | the key of the offer, 1 to 256 characters, the key of a listener, and the event | the run took an offer of that key, holds no such listener, or the listen declines it | | `cancel_requested` | the cancel: who asked, its kind and its reason, and optionally its cause | a cancel was requested before | -Every input carries the execution id and `at`. Any input but `started` is `not_started` for a run that has not started and `run_ended` for one that has ended. Three inputs are never taken as stale, because they mean an adapter routed wrongly: an input for another execution, and a second `started` with another document or another input. `staleReasonOf` dies on them with `RunMismatch`. +Every input carries the run id and `at`. Any input but `started` is `not_started` for a run that has not started and `run_ended` for one that has ended. Three inputs are never taken as stale, because they mean an adapter routed wrongly: an input for another run, and a second `started` with another document or another input. `staleReasonOf` dies on them with `RunMismatch`. An offer is an event a reader found beyond the run, for a listen that may want it (see [Listeners and offers](#listeners-and-offers)). The machine applies it only while that listen is open: it evaluates the listen's filters that name a type, with the run's variables, in the session of the input and under its meter, takes the offer into the first of them it fits, and carries that verdict into the decision, so the filters are evaluated once. An offer the listen declines is stale and appends nothing; one whose check fails, as an expression that raises, is stale too, and `submit` answers it with `declined`, the words of the error, for the adapter to report, since an offer is speculative; an event sent to the run whose check fails raises in the run, as before. -For `send_execution_event`, the API answers an event the run received before (`event_received_before`) with success, since the event is delivered, and an event for a run that has ended (`run_ended`) with `not_found`, as today. A repeated event appends nothing, so it no longer counts toward the 1,024 events and 4 MiB a run takes over its life; under Temporal it did. +For `send_run_event`, the API answers an event the run received before (`event_received_before`) with success, since the event is delivered, and an event for a run that has ended (`run_ended`) with `not_found`, as today. A repeated event appends nothing, so it no longer counts toward the 1,024 events and 4 MiB a run takes over its life; under Temporal it did. ## The run's log @@ -158,21 +158,24 @@ Compression is not part of the format. A run store may compress the events and s ## State formats -Every event and every snapshot names its state format; `stateFormat` is 6. A change to the state's schema is a new format, and: +Every event and every snapshot names its state format; `stateFormat` is 7. A change to the state's schema, or to the schema of an event or a snapshot, is a new format, and: +- an event and a snapshot are read with the schemas of the format they name: an older format's are frozen copies in `src/run-log/formats/`, one reader for each format with its test beside it, read strictly, and an event of an older format is measured as it was written (`eventBytesOf`), so its bytes are those its history counted; - each event is folded under its own format, and a state that crosses to a newer format is read strictly under the old one and upcast by that format's upcaster (`OlderFormat.read`, `OlderFormat.upcast`) before the next event applies; - formats never go back within a stream, and a format newer than the code is refused, both when the run loads (`UnreadableRun`); - `packages/workflow-engine/corpus/format-.json` holds a committed stream and snapshot of every format, which must load to the state it recorded (`src/run-log/corpus.test.ts`). A new format adds its corpus and keeps every older one loading. -Format 2 came with the machine: a frame records when it started and the context it started with, since a task that waits evaluates its `output.as` and its listen filters later with the variables it started with; an armed timer records when it was armed, since a timeout names the milliseconds it allowed; a fork branch can yield before it starts, as a task in a list can; and a failed branch records the order it failed in, since a competing fork that loses every branch raises the first failure. A list always names what it waits for, the task it runs or the timer due at once it yields to, and a loop always holds the list of the iteration it is in, so neither is ever null: the machine only stores a list or a loop that waits. Format 1 is read strictly with its own frozen schema (`src/run-log/format-one.ts`) and upcast: frames start at the last input with the context then, timers were armed at the last input, and failures are ordered as their branches. A format-1 state with a list between its tasks has no format-2 form and does not load; no runtime ever wrote one, since format 1 had no machine. +Format 2 came with the machine: a frame records when it started and the context it started with, since a task that waits evaluates its `output.as` and its listen filters later with the variables it started with; an armed timer records when it was armed, since a timeout names the milliseconds it allowed; a fork branch can yield before it starts, as a task in a list can; and a failed branch records the order it failed in, since a competing fork that loses every branch raises the first failure. A list always names what it waits for, the task it runs or the timer due at once it yields to, and a loop always holds the list of the iteration it is in, so neither is ever null: the machine only stores a list or a loop that waits. Format 1 is read strictly with its own frozen schema (`src/run-log/formats/format-one.ts`) and upcast: frames start at the last input with the context then, timers were armed at the last input, and failures are ordered as their branches. A format-1 state with a list between its tasks has no format-2 form and does not load; no runtime ever wrote one, since format 1 had no machine. -Format 3 came with the kind and because of a rejection: a call's result, `CallResultSchema` of `@beonauto/operations`, may name the `kind` and `because` of a rejection, and the error it raises, `DslError`, keeps them, so a failed branch, a `try` backing off and a run that ended raised hold them, and a `catch` reads them in its error. A state of format 2 is a state of format 3 as it is: format 2 is read strictly with its own frozen schema (`src/run-log/format-two.ts`), which refuses an error with either member, and passed on unchanged. Format 1 reads its errors and its outcome with format 2's frozen schemas, as it did, and both read with frozen copies of every schema they use, the instant, the call key, a received event, the run's limits and the purposes of timers among them, so a change to the current schemas cannot change how a state of format 1 or 2 reads. +Format 3 came with the kind and because of a rejection: a call's result, `CallResultSchema` of `@beonauto/operations`, may name the `kind` and `because` of a rejection, and the error it raises, `DslError`, keeps them, so a failed branch, a `try` backing off and a run that ended raised hold them, and a `catch` reads them in its error. A state of format 2 is a state of format 3 as it is: format 2 is read strictly with its own frozen schema (`src/run-log/formats/format-two.ts`), which refuses an error with either member, and passed on unchanged. Format 1 reads its errors and its outcome with format 2's frozen schemas, as it did, and both read with frozen copies of every schema they use, the instant, the call key, a received event, the run's limits and the purposes of timers among them, so a change to the current schemas cannot change how a state of format 1 or 2 reads. -Format 4 came with the step entries: across inputs the state keeps, in a list waiting for a yield's timer, `after`, the entry that came before the yield, so the task after it is caused by that entry; in a `try` backing off, `failed`, the entry that failed the attempt, so the next attempt is caused by it; and in a listen, `waited`, the `waiting` entries it recorded, so the next one counts on. Format 3 is read strictly with its own frozen schema (`src/run-log/format-three.ts`) and upcast: a yield and a back-off were caused by `input`, and a listen waited once. Events of formats 1 to 3 keep their steps as they were recorded, `{ reference, run, outcome }`, one for each run of a task with how it ended the input, and no `resumed`; the event schema reads both. +Format 4 came with the step entries: across inputs the state keeps, in a list waiting for a yield's timer, `after`, the entry that came before the yield, so the task after it is caused by that entry; in a `try` backing off, `failed`, the entry that failed the attempt, so the next attempt is caused by it; and in a listen, `waited`, the `waiting` entries it recorded, so the next one counts on. Format 3 is read strictly with its own frozen schema (`src/run-log/formats/format-three.ts`) and upcast: a yield and a back-off were caused by `input`, and a listen waited once. Events of formats 1 to 3 keep their steps as they were recorded, `{ reference, run, outcome }`, one for each run of a task with how it ended the input, and no `resumed`; the event schema reads both. -Format 5 came with reactions: the state keeps `listeners`, the listen tasks open with a filter that names a type, by the key of their task run; `emitted`, the count and the bytes of the events the run emitted; `inbox.offeredIds`, the keys of the offers it took, a list apart from `receivedIds`; and a listen for all of several filters keeps its events by filter, `consumed[i]` the event of filter `i` or null, since it takes events in any order. Format 4 is read strictly with its own frozen schema (`src/run-log/format-four.ts`) and upcast: no offers and no emissions, a listener for every open listen whose task names a type in a filter, found in the document the state holds, and its events, taken in filter order, already where format 5 keeps them. +Format 5 came with reactions: the state keeps `listeners`, the listen tasks open with a filter that names a type, by the key of their task run; `emitted`, the count and the bytes of the events the run emitted; `inbox.offeredIds`, the keys of the offers it took, a list apart from `receivedIds`; and a listen for all of several filters keeps its events by filter, `consumed[i]` the event of filter `i` or null, since it takes events in any order. Format 4 is read strictly with its own frozen schema (`src/run-log/formats/format-four.ts`) and upcast: no offers and no emissions, a listener for every open listen whose task names a type in a filter, found in the document the state holds, and its events, taken in filter order, already where format 5 keeps them. -Format 6 came with waiting calls and cancellations: the run's limits may hold `longestCallMsByTask`, the longest a call of each task may take, by its reference, and a cancelled outcome holds `cancel`, who asked, the kind (`requested`, `deadline` or `parent_ended`) and the reason, so the run's settlement says who cancelled it and why. Format 5 is read strictly with its own frozen schema (`src/run-log/format-five.ts`) and upcast: a cancelled outcome gains a cancel by `unknown`, of the kind `requested`, whose reason says it was recorded before a cancel named who asked. +Format 6 came with waiting calls and cancellations: the run's limits may hold `longestCallMsByTask`, the longest a call of each task may take, by its reference, and a cancelled outcome holds `cancel`, who asked, the kind (`requested`, `deadline` or `parent_ended`) and the reason, so the run's settlement says who cancelled it and why. Format 5 is read strictly with its own frozen schema (`src/run-log/formats/format-five.ts`) and upcast: a cancelled outcome gains a cancel by `unknown`, of the kind `requested`, whose reason says it was recorded before a cancel named who asked. + +Format 7 came with the one vocabulary: the state, its call keys, the outputs of an event and the envelope of a snapshot name the run `runId`, where formats 1 to 6 named it otherwise. Format 6 is read strictly with its own frozen schema (`src/run-log/formats/format-six.ts`) and upcast by naming the run `runId` in the state, in the keys of its calls and listeners, and in the key of every call frame. The events and snapshots of formats 1 to 6 are read strictly with the frozen schemas of `src/run-log/formats/format-six-records.ts`, which give their outputs and envelope the new name, and are written back and measured with the old. What a run holds as data, its document, its held values and a call's function and arguments, keeps the words it was written with. Patches are never rewritten: a patch applies only to the format it was written for. `evolve` applies a patch strictly, `add` to a member that exists or `replace` and `remove` of one that does not die with `PatchFailed`, and the result must decode as the state with no member the format does not describe (`onExcessProperty: 'error'`), so a skew between a log and the code that reads it is caught when the run loads, never folded into a wrong state. @@ -199,34 +202,34 @@ Frames never store the document: they name tasks by reference, a JSON Pointer in | `arm_listener` | the key of the listen's task run, its filters that name a type | the key | `Listeners.arm` | `armed`, `already_armed`, `refused` | | `cancel_listener` | the key | the key | `Listeners.cancel` | `cancelled`, `not_armed` | | `emit_event` | the key of the emit's task run, the event | the event's id | `Emitter.emit` | `recorded`, `already_recorded`, `refused` | -| `settle` | execution id and settlement | execution id | `RecordStore.settle` | `recorded`, `already_recorded`, `settled_otherwise`, `unknown_execution` | +| `settle` | run id and settlement | run id | `RecordStore.settle` | `recorded`, `already_recorded`, `settled_otherwise`, `unknown_run` | `Timers.arm`, `Executor.cancel` and `RecordStore.settle` are also given the origin of the output, `{ version, lastStep }`: the version of the event that holds it and the key of that event's last step entry, or null. An adapter keeps the version an arm came from, so the record of a timer's fire can name the record that armed it, settles a run with the last step of the event that ended it as the cause, and cancels the run a cancelled call waited for with that entry as the cause. The run store's `append` takes, with the event, its lineage, `{ cause, attributes }`: the run's attributes, and what caused the input, `start` for `started`, `resumed` with the key of the `waiting` entry for an input that resumed one, `timer` with the timer id for any other fire, and `none` otherwise. An adapter turns them into the ids its store writes; the engine knows no ids of messages. -Answers come back as inputs: a fired timer as `timer_fired`, a finished call as `call_answered`. Every receipt is final; only a failure to answer, `DispatchFailed`, leaves an output to be dispatched again, except that a `start_call` that fails for a run that has ended is passed over, since nothing can use it any more and it would hold the run's later outputs, its `settle` among them; a `cancel_call` is never passed over, since the run its call waits for must still be cancelled, and neither is a `settle`. A settle receipt of `settled_otherwise` or `unknown_execution` is troubling (`isTroubling`): the engine hands it to `RunReporter.unsettled`, which the adapter logs, as `reportUnsettled` does today. +Answers come back as inputs: a fired timer as `timer_fired`, a finished call as `call_answered`. Every receipt is final; only a failure to answer, `DispatchFailed`, leaves an output to be dispatched again, except that a `start_call` that fails for a run that has ended is passed over, since nothing can use it any more and it would hold the run's later outputs, its `settle` among them; a `cancel_call` is never passed over, since the run its call waits for must still be cancelled, and neither is a `settle`. A settle receipt of `settled_otherwise` or `unknown_run` is troubling (`isTroubling`): the engine hands it to `RunReporter.unsettled`, which the adapter logs, as `reportUnsettled` does today. A call is idempotent by its key in this sense: it is answered at most once. A start for a key the executor has answered delivers the answer again (`answered_again`); a start for a key it is running leaves it running (`running`); a start for a key that is neither, because the host that ran it died, starts it again (`started_again`), as the Node spike's `ensureJob` restarted a call. And every open call is answered eventually: each `start_call` comes with an `arm_timer` of purpose `call_deadline`, due at the input's time plus the longest the call may take, which is `longestCallMsByTask` of the call's task when the limits name it and `longestCallMs` otherwise, never later than the run's own deadline; when it fires with the call still open the task fails with a `timeout` error, status 408, which a retry policy matches only when it names it, and the call is cancelled with the reason `deadline`. A dead executor host therefore holds a run for at most that long, not until its 30-day deadline. Every cancel the timer store or the executor takes for a key that has not fired or been answered is recorded as a tombstone, whether the key was armed, running or never seen, so an arm or a start of that key that arrives later is refused (`refused_after_cancel`). Dispatch takes outputs in the order of the stream, so a start always reaches the port before its cancel; tombstones matter when a dispatch is repeated from a watermark left behind, as a due note that fails leaves it, which sends an arm or a start again after the cancel that followed it, and to an executor that takes work asynchronously, such as from a queue, where a cancel can overtake its start. -The machine checks only the size of a call's arguments, at most 264 KiB as JSON. The executor checks what they mean, such as a primitive, a name and an input, and answers `rejected` with reason `invalid_arguments` and a detail when they are wrong. The machine maps a rejection's reason to the task's error through one table (`callErrorOf` in `src/dsl/raised-error.ts`), with `invalid_arguments` as a `validation` error, status 400, carrying the executor's detail. +The machine checks only the size of a call's arguments, at most 264 KiB as JSON. The executor checks what they mean, such as a type, a name and an input, and answers `rejected` with reason `invalid_arguments` and a detail when they are wrong. The machine maps a rejection's reason to the task's error through one table (`callErrorOf` in `src/dsl/raised-error.ts`), with `invalid_arguments` as a `validation` error, status 400, carrying the executor's detail. ## Idempotency keys | Key | Made of | Deduplicated in | | ------------ | ----------------------------------------------------------- | ----------------------------------------------- | -| execution id | given by the adapter that starts the run | the run's status; `RecordStore` by execution id | +| run id | given by the adapter that starts the run | the run's status; `RecordStore` by run id | | timer id | ``, counting up within the run | `state.timers.armed`; `Timers` by run and id | -| call key | execution id, the task's reference, the run of that task | `state.calls`; `Executor` by `callKeyText(key)` | +| call key | run id, the task's reference, the run of that task | `state.calls`; `Executor` by `callKeyText(key)` | | event id | the external event's own `id` | `state.inbox.receivedIds` | | offer key | given by the adapter that offers, the id of what it found | `state.inbox.offeredIds` | -| listener key | execution id, the listen's reference, the run of that task | `state.listeners`; `Listeners` by the key | +| listener key | run id, the listen's reference, the run of that task | `state.listeners`; `Listeners` by the key | | emitted id | a version 5 UUID of the emit's call key, `emittedEventIdOf` | the store the `Emitter` writes | | value id | n counting up within the run | `state.machine.values` | -A run keeps one counter of runs for each task reference, `state.runs`, so the third time a task runs its run is 3, and a call it starts has that run in its key. An adapter that gives a call its own execution, as a call to a spec has, derives that execution's id from the call key, so a call dispatched twice is one execution. +A run keeps one counter of runs for each task reference, `state.runs`, so the third time a task runs its run is 3, and a call it starts has that run in its key. An adapter that gives a call its own run, as a call to a definition has, derives that run's id from the call key, so a call dispatched twice is one run. The event store deduplicates nothing: Emmett appends a message with an id it has seen before as a new message, on the self-hosted SQLite and on the hosted runtime's shared database. Deduplication lives in the run's state. @@ -250,7 +253,7 @@ The record keeps, for each live run, its next due time, the earliest of its arme A run's state is plain JSON: no `Map`, `Set`, `Date`, `undefined`, class or function, so `JSON.parse(JSON.stringify(state))` is the state. -A snapshot is `{ format, executionId, version, historyBytes, state }`, the state folded from events 1 to `version`, written only once event `version` is durable; the run store keeps only the latest. A snapshot is due once the events since the last one take as many bytes as that snapshot did, and at least 1 MiB, so writing snapshots never costs more bytes than the history they cover. +A snapshot is `{ format, runId, version, historyBytes, state }`, the state folded from events 1 to `version`, written only once event `version` is durable; the run store keeps only the latest. A snapshot is due once the events since the last one take as many bytes as that snapshot did, and at least 1 MiB, so writing snapshots never costs more bytes than the history they cover. A snapshot holds at most about 5.3 MiB: the held data (4 MiB: the values, the document and the frames), the events waiting in the inbox (1 MiB), the ids of the events received (1,024 of at most 256 characters), and the timers, calls and run counters, a few dozen bytes for each frame. It is stored in chunks of at most 1 MiB of UTF-8, cut by `TextEncoder.encodeInto` at a code point, never inside one; a 5.2 MB snapshot took 4 ms to encode and chunk. The hosted runtime's SQLite takes rows of at most 2 MB; a 1 MiB chunk leaves room for the row's other columns, and an event, at most 1.5 MiB, fits in a row too. @@ -300,13 +303,13 @@ Each sentence is something a reviewer can check against the code or a test. **[e 11. **[adapter]** A timer is armed at most once and fires at least once until cancelled; a call is answered at most once, a start of a call neither answered nor running starts it again, and every cancel of a key that has not fired or been answered, seen or not, leaves a tombstone that refuses a later arm or start. A timer id is unique only within its run, so the timer store keys a timer by its run and its id (the probes of `src/testing/port-probes.ts`). 12. **[engine]** The watermark never goes down, and every output of every event at or below it has been dispatched at least once (`src/dispatch/dispatch-watermark.test.ts`); a run whose watermark is below the version of its stream is behind, and a sweep wakes it, whether or not its due time was noted (`src/engine/wake.test.ts`, the watermark's probes of `src/testing/store-probes.ts`). 13. **[engine]** A run's outcome is in its stream before the record store is asked to record it, and the `settle` output is dispatched again until the record store answers (`src/engine/engine.test.ts`). -14. **[adapter]** The run log and the record store are two writes, each idempotent by execution id, the second retried; `EventStore.append` writes one stream, so neither adapter makes them one transaction. +14. **[adapter]** The run log and the record store are two writes, each idempotent by run id, the second retried; `EventStore.append` writes one stream, so neither adapter makes them one transaction. 15. **[engine]** Loading a run from its latest snapshot and the events after it gives the same state as folding its whole stream (`src/run-log/run-fold.test.ts`, `src/run-log/corpus.test.ts`, `src/engine/engine.test.ts`, `src/engine/long-run.test.ts`). 16. **[adapter]** Only the latest snapshot of a run is kept, in chunks of at most 1 MiB of UTF-8 (`src/run-log/snapshot.test.ts` for the chunks; the probes of `src/testing/store-probes.ts`). 17. **[engine]** The state of a run is plain JSON, and the data it holds stays at or under 4 MiB (`src/decider/run-bounds.test.ts`). 18. **[engine]** No event is larger than 1.5 MiB as JSON: the machine measures each event before the append and ends the run instead (`src/run-log/run-event.test.ts` for the measure, `src/decider/run-bounds.test.ts`); and no input runs more than 100 tasks (`src/runner/list-runner.test.ts`). 19. **[adapter]** Inputs of one run are applied one at a time; the machine relies only on the expected version of each append. -20. **[engine]** Nothing in this package but the program pool, nor jq, uses a Node-only API, a dynamic import, code generation or a host timer, or imports Temporal; the pool, `src/program-pool`, uses `node:worker_threads` and host timers, and only the `dsl` and `job-loop` entries reach it (`src/engine/portability.test.ts`); outside this package, a lint rule refuses an import of `node:worker_threads` but in the pool and the measure scripts of the computation primitive (`.oxlintrc.json`). +20. **[engine]** Nothing in this package but the program pool, nor jq, uses a Node-only API, a dynamic import, code generation or a host timer, or imports Temporal; the pool, `src/program-pool`, uses `node:worker_threads` and host timers, and only the `dsl` and `job-loop` entries reach it (`src/engine/portability.test.ts`); outside this package, a lint rule refuses an import of `node:worker_threads` but in the pool and the measure scripts of the computation capability (`.oxlintrc.json`). 21. **[engine]** No module of the engine keeps a cache that grows with the history of a run; the one cache of the process, compiled expressions, is bounded, and so is each engine's cache of loaded runs, by runs and by bytes, and each job loop's caches of compiled programs and checks, which live in its closure (`src/engine/portability.test.ts`, `src/dsl/bounded-cache.test.ts`, `src/cache/run-cache.test.ts`, `src/program-pool/job-loop.test.ts`). 22. **[engine]** No two events of a stream have the same receipt kind and key (`src/decider/deduplication.test.ts`). 23. **[engine]** `lastInputAt` never decreases, and a fired timer's input is never earlier than the time it was due (`src/machine/input-receipt.test.ts`). @@ -334,11 +337,11 @@ Each sentence is something a reviewer can check against the code or a test. **[e - A run ends `completed`, settled `succeeded`; `raised`, settled `rejected` by the error's status, or as `conflict` of the kind `tools_called` or `unavailable` of the kind `tools_unfinished` for those types; an uncaught error of the type `cancelled`, raised by a call whose run was cancelled, settles by its status too, 409, since the workflow itself was not cancelled; an uncaught error of the type `unanswered`, raised by a call whose request nobody answered, status 410, settles `rejected` as `unanswered` with its kind, `expired` or `undelivered`, as the request it waited for ended; `cancelled`, settled `rejected` as `cancelled` with the cancel's kind, reason and actor; `oversized`, settled `rejected` as a `conflict` of the kind `oversized`; and `broken`, settled `failed`. - An event leaves out a timer or a call that the same input opened and closed (invariant 26), and its other outputs keep the order the session emitted them in. - A run ends with its outcome, settled once in its last event. An input that would pass a bound (inputs, history, held data, the size of one event) ends the run, raised, in a small event of its own. -- The machine's tests run it through the memory driver of `src/testing`; each piece of the design has one that fails without it, the step entries and their causes in `src/steps`. The workflow adapter runs its tests of how a workflow runs through this driver (`primitives/orchestration/src/workflows`), and replays its 15 recorded input logs through the machine (`primitives/orchestration/input-logs/`). +- The machine's tests run it through the memory driver of `src/testing`; each piece of the design has one that fails without it, the step entries and their causes in `src/steps`. The workflow adapter runs its tests of how a workflow runs through this driver (`capabilities/coordination/src/workflows`), and replays its 15 recorded input logs through the machine (`capabilities/coordination/input-logs/`). ## Listeners and offers -A `listen` task that waits arms a listener when at least one of its filters names a type: its event carries `arm_listener`, with the key of the task run, `{ executionId, reference, run }`, and the `with` of those filters, and the state keeps the key in `listeners`. When the listen ends, by taking its events, by a timeout, a cancel or the end of the run, its event carries `cancel_listener`. A listen that an event already waiting satisfies at once never arms one. An adapter keeps the listeners its dispatch hands it and offers them the events it finds, as `event_offered`, keyed by what it found them by; the machine applies an offer only while that exact listen is open. A listen for one or any filter takes an offer that one of its filters naming a type accepts; a listen for all takes it into the first of those filters, in list order, that no event satisfies yet and that it fits, and hands its events on in filter order once each filter has one. Events sent to the run take the same order-free way: each filter takes the earliest waiting event it fits. A filter that names no type keeps its meaning: it sees only the events sent to its run. The keys of offers and the ids of sent events are two lists, so neither blocks the other, and both count toward the 1,024 events a run takes (`src/tasks/listen-offers.test.ts`, `src/tasks/listen-orders.test.ts`). +A `listen` task that waits arms a listener when at least one of its filters names a type: its event carries `arm_listener`, with the key of the task run, `{ runId, reference, run }`, and the `with` of those filters, and the state keeps the key in `listeners`. When the listen ends, by taking its events, by a timeout, a cancel or the end of the run, its event carries `cancel_listener`. A listen that an event already waiting satisfies at once never arms one. An adapter keeps the listeners its dispatch hands it and offers them the events it finds, as `event_offered`, keyed by what it found them by; the machine applies an offer only while that exact listen is open. A listen for one or any filter takes an offer that one of its filters naming a type accepts; a listen for all takes it into the first of those filters, in list order, that no event satisfies yet and that it fits, and hands its events on in filter order once each filter has one. Events sent to the run take the same order-free way: each filter takes the earliest waiting event it fits. A filter that names no type keeps its meaning: it sees only the events sent to its run. The keys of offers and the ids of sent events are two lists, so neither blocks the other, and both count toward the 1,024 events a run takes (`src/tasks/listen-offers.test.ts`, `src/tasks/listen-orders.test.ts`). ## Emitting an event @@ -346,7 +349,7 @@ An `emit` task, `emit: { event: { with: { type, source, … } } }`, evaluates `w ## A schedule -The policy refuses `schedule` unless the functions it is given check it, with `scheduleRejections(schedule, pointer)`, which the workflow adapter gives (`primitives/orchestration`). The machine never reads a schedule: a run starts the same way however it was started. +The policy refuses `schedule` unless the functions it is given check it, with `scheduleRejections(schedule, pointer)`, which the workflow adapter gives (`capabilities/coordination`). The machine never reads a schedule: a run starts the same way however it was started. ## The cache of loaded runs @@ -359,7 +362,7 @@ Each engine keeps the runs it loaded between their inputs (`src/cache/run-cache. ## Measurements -`pnpm --filter @beonauto/workflow-engine measure` measures, outside the tests (`measure.ts`, with its parts in `measure/`), a loop that waits a second, counts and goes round again. Each row says what it measures: the machine alone, `decide` and `evolve` called in turn on a state kept in memory; a load, a snapshot and the events after it decoded and folded; or the engine over the memory ports, with its store, dispatch, snapshots and cache. Measured on Node 26.10.0, on 2026-10-06 with format 4; Temporal's figures are the spike's (`spikes/node/results/replay.json` on branch `spike/engine-node`) and the orchestration's replay test. +`pnpm --filter @beonauto/workflow-engine measure` measures, outside the tests (`measure.ts`, with its parts in `measure/`), a loop that waits a second, counts and goes round again. Each row says what it measures: the machine alone, `decide` and `evolve` called in turn on a state kept in memory; a load, a snapshot and the events after it decoded and folded; or the engine over the memory ports, with its store, dispatch, snapshots and cache. Measured on Node 26.10.0, on 2026-10-06 with format 4; Temporal's figures are the spike's (`spikes/node/results/replay.json` on branch `spike/engine-node`) and the workflow's replay test. | What | Measured | The machine | Temporal | | ------------------------------------------------ | -------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------ | -------------------------------------------------------------- | @@ -379,7 +382,7 @@ An input to a run the engine keeps costs its decision, one fold and the engine's ### The workers of the pool, measured -The same script measures a page of the example of the recall reference, 1,000 runs of 100 campaigns folded from an empty view, 313,339 bytes as JSON in and out: a fold worker's start alone, a page with no events on a fresh pool; then nine times in turn the page cold, on a fresh pool, warm, on one pool, and folded on the thread that measures; and the JSON round trip of its bytes there (`measure/pages.ts`). `pnpm --filter @beonauto/computation measure` measures the runs of a program that answers at once (`primitives/computation/measure/runs.ts`). Measured on 2026-10-07 on Node 26.10.0 on an Apple M4 Max, the code before this pool, which started a worker for each job, and this pool alternately, twice, under a load average of 94 to 186 on its 16 cores from other work, so an idle machine is faster and the figures of one round differ from the other's by the load alone: +The same script measures a page of the example of the recall reference, 1,000 runs of 100 campaigns folded from an empty view, 313,339 bytes as JSON in and out: a fold worker's start alone, a page with no events on a fresh pool; then nine times in turn the page cold, on a fresh pool, warm, on one pool, and folded on the thread that measures; and the JSON round trip of its bytes there (`measure/pages.ts`). `pnpm --filter @beonauto/computation measure` measures the runs of a program that answers at once (`capabilities/computation/measure/runs.ts`). Measured on 2026-10-07 on Node 26.10.0 on an Apple M4 Max, the code before this pool, which started a worker for each job, and this pool alternately, twice, under a load average of 94 to 186 on its 16 cores from other work, so an idle machine is faster and the figures of one round differ from the other's by the load alone: | What, at the median | A worker for each job | Workers kept between jobs | | -------------------------------------------------------------------- | ------------------------------------ | -------------------------------------- | @@ -416,7 +419,7 @@ On the machine at rest, against the bounds of the decision: - the first job of a worker starts in at most 200 ms, 100.7 and 104.1 ms, and 125.4 ms after a minute idle: met. It is about three and a half times the 29 ms of a worker that loads no `effect`, which the job loop decodes its envelopes with; - a warm page costs at most 10 ms beyond its folds, measured at rest: 4.5, 1.0, 4.9, 4.9, 8.1 and −1.4 ms in six runs, a median of about 4.7 ms, on folds of 281 to 290 ms, and 0.7 and −0.3 ms in an independent measurement at rest: met. The decision first allowed the round trip of the page's bytes and 2 ms, about 2.6 to 2.7 ms, which the second measurement met and the first did not: at this resolution the difference of a page of about 285 ms and its folds on another thread spreads over several milliseconds from run to run, more than that allowance. -`pnpm --filter @beonauto/computation measure` also measures a burst, 200 jobs at once on 4 permits, half with an output schema, three times in turn for each way of naming their workers (`primitives/computation/measure/burst.ts`). With the jobs without a schema naming the pool's plain worker and the others the checked worker, the two modules alternating, it took 3,495 to 5,103 ms, a median of 4,564 and 3,782 ms in two measurements; with every job naming the checked worker, as production now does, 118 to 197 ms, a median of 120 ms in both, at a load average of 3.0 to 8.8. On a full pool every job of the other module let go of the worker the next job needed. +`pnpm --filter @beonauto/computation measure` also measures a burst, 200 jobs at once on 4 permits, half with an output schema, three times in turn for each way of naming their workers (`capabilities/computation/measure/burst.ts`). With the jobs without a schema naming the pool's plain worker and the others the checked worker, the two modules alternating, it took 3,495 to 5,103 ms, a median of 4,564 and 3,782 ms in two measurements; with every job naming the checked worker, as production now does, 118 to 197 ms, a median of 120 ms in both, at a load average of 3.0 to 8.8. On a full pool every job of the other module let go of the worker the next job needed. The tests assert behaviour at the smallest size that proves it and measure nothing: `src/engine/long-run.test.ts` resumes a run of 3,000 inputs from its last snapshot, and no test of the engine is given more than 60 s. diff --git a/packages/workflow-engine/corpus/format-7.json b/packages/workflow-engine/corpus/format-7.json new file mode 100644 index 000000000..a1e1f7d8a --- /dev/null +++ b/packages/workflow-engine/corpus/format-7.json @@ -0,0 +1,1653 @@ +{ + "format": 7, + "stream": [ + { + "version": 1, + "event": { + "type": "input_applied", + "format": 7, + "receipt": { + "kind": "started", + "key": "0199a3c4-7d2e-7c1a-9b3f-2f1e0d9c8b7a", + "at": 1790845200000 + }, + "steps": [ + { + "reference": "/do/0/all", + "run": 1, + "outcome": "started", + "name": "all", + "times": 1, + "caused_by": "input" + }, + { + "reference": "/do/0/all/fork/branches/0/backing", + "run": 1, + "outcome": "started", + "name": "backing", + "times": 1, + "caused_by": { + "reference": "/do/0/all", + "run": 1, + "outcome": "started", + "times": 1 + } + }, + { + "reference": "/do/0/all/fork/branches/0/backing/try/0/ask", + "run": 1, + "outcome": "waiting", + "name": "ask", + "times": 1, + "caused_by": { + "reference": "/do/0/all/fork/branches/0/backing", + "run": 1, + "outcome": "started", + "times": 1 + }, + "waits_for": "call", + "child": "notify at /do/0/all/fork/branches/0/backing/try/0/ask #1" + }, + { + "reference": "/do/0/all/fork/branches/1/both", + "run": 1, + "outcome": "waiting", + "name": "both", + "times": 1, + "caused_by": { + "reference": "/do/0/all", + "run": 1, + "outcome": "started", + "times": 1 + }, + "waits_for": "event" + }, + { + "reference": "/do/0/all/fork/branches/2/telling", + "run": 1, + "outcome": "completed", + "name": "telling", + "times": 1, + "caused_by": { + "reference": "/do/0/all", + "run": 1, + "outcome": "started", + "times": 1 + } + }, + { + "reference": "/do/0/all/fork/branches/3/asking", + "run": 1, + "outcome": "started", + "name": "asking", + "times": 1, + "caused_by": { + "reference": "/do/0/all", + "run": 1, + "outcome": "started", + "times": 1 + } + }, + { + "reference": "/do/0/all/fork/branches/3/asking/try/0/ask", + "run": 1, + "outcome": "waiting", + "name": "ask", + "times": 1, + "caused_by": { + "reference": "/do/0/all/fork/branches/3/asking", + "run": 1, + "outcome": "started", + "times": 1 + }, + "waits_for": "call", + "child": "notify at /do/0/all/fork/branches/3/asking/try/0/ask #1" + } + ], + "resumed": null, + "patch": [ + { + "op": "replace", + "path": "/runId", + "value": "0199a3c4-7d2e-7c1a-9b3f-2f1e0d9c8b7a" + }, + { + "op": "replace", + "path": "/status", + "value": "running" + }, + { + "op": "replace", + "path": "/workflow", + "value": { + "document": { + "document": { + "dsl": "1.0.3", + "namespace": "acme", + "name": "test", + "version": "1.0.0" + }, + "do": [ + { + "all": { + "fork": { + "branches": [ + { + "backing": { + "try": [ + { + "ask": { + "call": "notify", + "with": { + "to": "bob" + } + } + } + ], + "catch": { + "retry": { + "delay": "PT1H", + "limit": { + "attempt": { + "count": 2 + } + } + } + } + } + }, + { + "both": { + "listen": { + "to": { + "all": [ + { + "with": { + "type": "first" + } + }, + { + "with": { + "type": "second" + } + }, + { + "with": { + "type": "third" + } + } + ] + } + } + } + }, + { + "telling": { + "emit": { + "event": { + "with": { + "type": "com.acme.told", + "source": "/acme", + "data": { + "to": "ada" + } + } + } + } + } + }, + { + "asking": { + "try": [ + { + "ask": { + "call": "notify", + "with": { + "to": "ada" + } + } + } + ], + "catch": { + "errors": { + "with": { + "status": 408 + } + } + } + } + } + ] + } + } + } + ] + }, + "input": 1 + } + }, + { + "op": "replace", + "path": "/limits/mostDurationMs", + "value": 2592000000 + }, + { + "op": "replace", + "path": "/limits/longestCallMs", + "value": 600000 + }, + { + "op": "add", + "path": "/limits/longestCallMsByTask", + "value": { + "/do/0/all/fork/branches/3/asking/try/0/ask": 90000 + } + }, + { + "op": "replace", + "path": "/startedAt", + "value": 1790845200000 + }, + { + "op": "replace", + "path": "/lastInputAt", + "value": 1790845200000 + }, + { + "op": "replace", + "path": "/inputs", + "value": 1 + }, + { + "op": "replace", + "path": "/random/seed", + "value": 7 + }, + { + "op": "add", + "path": "/runs/~1do~10~1all", + "value": 1 + }, + { + "op": "add", + "path": "/runs/~1do~10~1all~1fork~1branches~10~1backing", + "value": 1 + }, + { + "op": "add", + "path": "/runs/~1do~10~1all~1fork~1branches~10~1backing~1try~10~1ask", + "value": 1 + }, + { + "op": "add", + "path": "/runs/~1do~10~1all~1fork~1branches~11~1both", + "value": 1 + }, + { + "op": "add", + "path": "/runs/~1do~10~1all~1fork~1branches~12~1telling", + "value": 1 + }, + { + "op": "add", + "path": "/runs/~1do~10~1all~1fork~1branches~13~1asking", + "value": 1 + }, + { + "op": "add", + "path": "/runs/~1do~10~1all~1fork~1branches~13~1asking~1try~10~1ask", + "value": 1 + }, + { + "op": "replace", + "path": "/timers/next", + "value": 4 + }, + { + "op": "add", + "path": "/timers/armed/1", + "value": { + "purpose": "deadline", + "reference": "/", + "armedAt": 1790845200000, + "dueAt": 1793437200000 + } + }, + { + "op": "add", + "path": "/timers/armed/2", + "value": { + "purpose": "call_deadline", + "reference": "/do/0/all/fork/branches/0/backing/try/0/ask", + "armedAt": 1790845200000, + "dueAt": 1790845800000 + } + }, + { + "op": "add", + "path": "/timers/armed/3", + "value": { + "purpose": "call_deadline", + "reference": "/do/0/all/fork/branches/3/asking/try/0/ask", + "armedAt": 1790845200000, + "dueAt": 1790845290000 + } + }, + { + "op": "add", + "path": "/calls/[\"0199a3c4-7d2e-7c1a-9b3f-2f1e0d9c8b7a\",\"~1do~10~1all~1fork~1branches~10~1backing~1try~10~1ask\",1]", + "value": { + "runId": "0199a3c4-7d2e-7c1a-9b3f-2f1e0d9c8b7a", + "reference": "/do/0/all/fork/branches/0/backing/try/0/ask", + "run": 1 + } + }, + { + "op": "add", + "path": "/calls/[\"0199a3c4-7d2e-7c1a-9b3f-2f1e0d9c8b7a\",\"~1do~10~1all~1fork~1branches~13~1asking~1try~10~1ask\",1]", + "value": { + "runId": "0199a3c4-7d2e-7c1a-9b3f-2f1e0d9c8b7a", + "reference": "/do/0/all/fork/branches/3/asking/try/0/ask", + "run": 1 + } + }, + { + "op": "add", + "path": "/listeners/[\"0199a3c4-7d2e-7c1a-9b3f-2f1e0d9c8b7a\",\"~1do~10~1all~1fork~1branches~11~1both\",1]", + "value": { + "runId": "0199a3c4-7d2e-7c1a-9b3f-2f1e0d9c8b7a", + "reference": "/do/0/all/fork/branches/1/both", + "run": 1 + } + }, + { + "op": "replace", + "path": "/emitted/count", + "value": 1 + }, + { + "op": "replace", + "path": "/emitted/bytes", + "value": 159 + }, + { + "op": "replace", + "path": "/heldBytes", + "value": 29280 + }, + { + "op": "add", + "path": "/machine/values/1", + "value": { + "value": 1, + "bytes": 1 + } + }, + { + "op": "add", + "path": "/machine/values/2", + "value": { + "value": { + "to": "bob" + }, + "bytes": 12 + } + }, + { + "op": "add", + "path": "/machine/values/3", + "value": { + "value": { + "to": "ada" + }, + "bytes": 12 + } + }, + { + "op": "replace", + "path": "/machine/nextValue", + "value": 4 + }, + { + "op": "replace", + "path": "/machine/root", + "value": { + "reference": "/", + "run": 1, + "startedAt": 1790845200000, + "context": 0, + "rawInput": 1, + "input": 1, + "variables": {}, + "timeout": null, + "body": { + "kind": "list", + "list": { + "pointer": "/do", + "position": 0, + "data": 1, + "variables": {}, + "current": { + "kind": "running", + "task": { + "reference": "/do/0/all", + "run": 1, + "startedAt": 1790845200000, + "context": 0, + "rawInput": 1, + "input": 1, + "variables": {}, + "timeout": null, + "body": { + "kind": "fork", + "compete": false, + "branches": [ + { + "state": "running", + "task": { + "reference": "/do/0/all/fork/branches/0/backing", + "run": 1, + "startedAt": 1790845200000, + "context": 0, + "rawInput": 1, + "input": 1, + "variables": {}, + "timeout": null, + "body": { + "kind": "try", + "attempt": 0, + "startedAt": 1790845200000, + "phase": { + "kind": "trying", + "list": { + "pointer": "/do/0/all/fork/branches/0/backing/try", + "position": 0, + "data": 1, + "variables": {}, + "current": { + "kind": "running", + "task": { + "reference": "/do/0/all/fork/branches/0/backing/try/0/ask", + "run": 1, + "startedAt": 1790845200000, + "context": 0, + "rawInput": 1, + "input": 1, + "variables": {}, + "timeout": null, + "body": { + "kind": "call", + "key": { + "runId": "0199a3c4-7d2e-7c1a-9b3f-2f1e0d9c8b7a", + "reference": "/do/0/all/fork/branches/0/backing/try/0/ask", + "run": 1 + }, + "function": "notify", + "arguments": 2, + "label": "the function notify", + "deadline": "2" + } + } + } + }, + "attemptLimit": null + } + } + } + }, + { + "state": "running", + "task": { + "reference": "/do/0/all/fork/branches/1/both", + "run": 1, + "startedAt": 1790845200000, + "context": 0, + "rawInput": 1, + "input": 1, + "variables": {}, + "timeout": null, + "body": { + "kind": "listen", + "consumed": [null, null, null], + "waited": 1 + } + } + }, + { + "state": "finished", + "output": 1, + "flow": "continue" + }, + { + "state": "running", + "task": { + "reference": "/do/0/all/fork/branches/3/asking", + "run": 1, + "startedAt": 1790845200000, + "context": 0, + "rawInput": 1, + "input": 1, + "variables": {}, + "timeout": null, + "body": { + "kind": "try", + "attempt": 0, + "startedAt": 1790845200000, + "phase": { + "kind": "trying", + "list": { + "pointer": "/do/0/all/fork/branches/3/asking/try", + "position": 0, + "data": 1, + "variables": {}, + "current": { + "kind": "running", + "task": { + "reference": "/do/0/all/fork/branches/3/asking/try/0/ask", + "run": 1, + "startedAt": 1790845200000, + "context": 0, + "rawInput": 1, + "input": 1, + "variables": {}, + "timeout": null, + "body": { + "kind": "call", + "key": { + "runId": "0199a3c4-7d2e-7c1a-9b3f-2f1e0d9c8b7a", + "reference": "/do/0/all/fork/branches/3/asking/try/0/ask", + "run": 1 + }, + "function": "notify", + "arguments": 3, + "label": "the function notify", + "deadline": "3" + } + } + } + }, + "attemptLimit": null + } + } + } + } + ] + } + } + } + } + } + } + }, + { + "op": "replace", + "path": "/historyBytes", + "value": 8950 + } + ], + "outputs": [ + { + "kind": "arm_timer", + "runId": "0199a3c4-7d2e-7c1a-9b3f-2f1e0d9c8b7a", + "timerId": "1", + "dueAt": 1793437200000, + "purpose": "deadline", + "label": "the most the workflow may run" + }, + { + "kind": "start_call", + "key": { + "runId": "0199a3c4-7d2e-7c1a-9b3f-2f1e0d9c8b7a", + "reference": "/do/0/all/fork/branches/0/backing/try/0/ask", + "run": 1 + }, + "function": "notify", + "arguments": { + "to": "bob" + }, + "longestMs": 600000 + }, + { + "kind": "arm_timer", + "runId": "0199a3c4-7d2e-7c1a-9b3f-2f1e0d9c8b7a", + "timerId": "2", + "dueAt": 1790845800000, + "purpose": "call_deadline", + "label": "/do/0/all/fork/branches/0/backing/try/0/ask deadline" + }, + { + "kind": "arm_listener", + "key": { + "runId": "0199a3c4-7d2e-7c1a-9b3f-2f1e0d9c8b7a", + "reference": "/do/0/all/fork/branches/1/both", + "run": 1 + }, + "filters": [ + { + "type": "first" + }, + { + "type": "second" + }, + { + "type": "third" + } + ] + }, + { + "kind": "emit_event", + "key": { + "runId": "0199a3c4-7d2e-7c1a-9b3f-2f1e0d9c8b7a", + "reference": "/do/0/all/fork/branches/2/telling", + "run": 1 + }, + "event": { + "type": "com.acme.told", + "source": "/acme", + "data": { + "to": "ada" + }, + "specversion": "1.0", + "id": "716f8618-f183-5b77-936b-31c1e39c7260", + "time": "2026-10-01T09:00:00.000Z" + } + }, + { + "kind": "start_call", + "key": { + "runId": "0199a3c4-7d2e-7c1a-9b3f-2f1e0d9c8b7a", + "reference": "/do/0/all/fork/branches/3/asking/try/0/ask", + "run": 1 + }, + "function": "notify", + "arguments": { + "to": "ada" + }, + "longestMs": 90000 + }, + { + "kind": "arm_timer", + "runId": "0199a3c4-7d2e-7c1a-9b3f-2f1e0d9c8b7a", + "timerId": "3", + "dueAt": 1790845290000, + "purpose": "call_deadline", + "label": "/do/0/all/fork/branches/3/asking/try/0/ask deadline" + } + ] + } + }, + { + "version": 2, + "event": { + "type": "input_applied", + "format": 7, + "receipt": { + "kind": "call_answered", + "key": "[\"0199a3c4-7d2e-7c1a-9b3f-2f1e0d9c8b7a\",\"/do/0/all/fork/branches/0/backing/try/0/ask\",1]", + "at": 1790845200010, + "status": "rejected", + "rejection": { + "kind": "mcp_server_failed", + "because": "unreachable" + } + }, + "steps": [ + { + "reference": "/do/0/all/fork/branches/0/backing/try/0/ask", + "run": 1, + "outcome": "raised", + "name": "ask", + "times": 1, + "caused_by": { + "reference": "/do/0/all/fork/branches/0/backing/try/0/ask", + "run": 1, + "outcome": "waiting", + "times": 1 + }, + "error": { + "type": "https://open-workflow-specification.org/spec/1.0.0/errors/communication", + "title": "The function notify rejected the run with unavailable" + } + } + ], + "resumed": { + "reference": "/do/0/all/fork/branches/0/backing/try/0/ask", + "run": 1, + "times": 1 + }, + "patch": [ + { + "op": "replace", + "path": "/lastInputAt", + "value": 1790845200010 + }, + { + "op": "replace", + "path": "/inputs", + "value": 2 + }, + { + "op": "replace", + "path": "/timers/next", + "value": 5 + }, + { + "op": "remove", + "path": "/timers/armed/2" + }, + { + "op": "add", + "path": "/timers/armed/4", + "value": { + "purpose": "retry_delay", + "reference": "/do/0/all/fork/branches/0/backing", + "armedAt": 1790845200010, + "dueAt": 1790848800010 + } + }, + { + "op": "remove", + "path": "/calls/[\"0199a3c4-7d2e-7c1a-9b3f-2f1e0d9c8b7a\",\"~1do~10~1all~1fork~1branches~10~1backing~1try~10~1ask\",1]" + }, + { + "op": "replace", + "path": "/heldBytes", + "value": 25172 + }, + { + "op": "remove", + "path": "/machine/values/2" + }, + { + "op": "remove", + "path": "/machine/root/body/list/current/task/body/branches/0/task/body/phase/list" + }, + { + "op": "remove", + "path": "/machine/root/body/list/current/task/body/branches/0/task/body/phase/attemptLimit" + }, + { + "op": "replace", + "path": "/machine/root/body/list/current/task/body/branches/0/task/body/phase/kind", + "value": "backing_off" + }, + { + "op": "add", + "path": "/machine/root/body/list/current/task/body/branches/0/task/body/phase/timer", + "value": "4" + }, + { + "op": "add", + "path": "/machine/root/body/list/current/task/body/branches/0/task/body/phase/error", + "value": { + "type": "https://open-workflow-specification.org/spec/1.0.0/errors/communication", + "status": 503, + "title": "The function notify rejected the run with unavailable", + "detail": "The tool server is gone", + "instance": "/do/0/all/fork/branches/0/backing/try/0/ask", + "kind": "mcp_server_failed", + "because": "unreachable" + } + }, + { + "op": "add", + "path": "/machine/root/body/list/current/task/body/branches/0/task/body/phase/failed", + "value": { + "reference": "/do/0/all/fork/branches/0/backing/try/0/ask", + "run": 1, + "outcome": "raised", + "times": 1 + } + }, + { + "op": "replace", + "path": "/historyBytes", + "value": 11677 + } + ], + "outputs": [ + { + "kind": "cancel_timer", + "runId": "0199a3c4-7d2e-7c1a-9b3f-2f1e0d9c8b7a", + "timerId": "2" + }, + { + "kind": "arm_timer", + "runId": "0199a3c4-7d2e-7c1a-9b3f-2f1e0d9c8b7a", + "timerId": "4", + "dueAt": 1790848800010, + "purpose": "retry_delay", + "label": "/do/0/all/fork/branches/0/backing retry 1" + } + ] + } + }, + { + "version": 3, + "event": { + "type": "input_applied", + "format": 7, + "receipt": { + "kind": "event_offered", + "key": "record-1", + "at": 1790845200010, + "eventType": "second" + }, + "steps": [ + { + "reference": "/do/0/all/fork/branches/1/both", + "run": 1, + "outcome": "waiting", + "name": "both", + "times": 2, + "caused_by": { + "reference": "/do/0/all/fork/branches/1/both", + "run": 1, + "outcome": "waiting", + "times": 1 + }, + "waits_for": "event" + } + ], + "resumed": { + "reference": "/do/0/all/fork/branches/1/both", + "run": 1, + "times": 1 + }, + "patch": [ + { + "op": "replace", + "path": "/inputs", + "value": 3 + }, + { + "op": "add", + "path": "/inbox/offeredIds/-", + "value": "record-1" + }, + { + "op": "replace", + "path": "/inbox/received", + "value": 1 + }, + { + "op": "replace", + "path": "/inbox/receivedBytes", + "value": 33 + }, + { + "op": "replace", + "path": "/heldBytes", + "value": 25205 + }, + { + "op": "add", + "path": "/machine/values/4", + "value": { + "value": { + "id": "e-second", + "type": "second" + }, + "bytes": 33 + } + }, + { + "op": "replace", + "path": "/machine/nextValue", + "value": 5 + }, + { + "op": "replace", + "path": "/machine/root/body/list/current/task/body/branches/1/task/body/consumed/1", + "value": 4 + }, + { + "op": "replace", + "path": "/machine/root/body/list/current/task/body/branches/1/task/body/waited", + "value": 2 + }, + { + "op": "replace", + "path": "/historyBytes", + "value": 12822 + } + ], + "outputs": [] + } + }, + { + "version": 4, + "event": { + "type": "input_applied", + "format": 7, + "receipt": { + "kind": "timer_fired", + "key": "3", + "at": 1790845290000 + }, + "steps": [ + { + "reference": "/do/0/all/fork/branches/3/asking/try/0/ask", + "run": 1, + "outcome": "raised", + "name": "ask", + "times": 1, + "caused_by": { + "reference": "/do/0/all/fork/branches/3/asking/try/0/ask", + "run": 1, + "outcome": "waiting", + "times": 1 + }, + "error": { + "type": "https://open-workflow-specification.org/spec/1.0.0/errors/timeout", + "title": "The function notify did not finish within 90000 ms, the most it may take" + } + }, + { + "reference": "/do/0/all/fork/branches/3/asking", + "run": 1, + "outcome": "completed", + "name": "asking", + "times": 1, + "caused_by": { + "reference": "/do/0/all/fork/branches/3/asking", + "run": 1, + "outcome": "started", + "times": 1 + } + } + ], + "resumed": { + "reference": "/do/0/all/fork/branches/3/asking/try/0/ask", + "run": 1, + "times": 1 + }, + "patch": [ + { + "op": "replace", + "path": "/lastInputAt", + "value": 1790845290000 + }, + { + "op": "replace", + "path": "/inputs", + "value": 4 + }, + { + "op": "remove", + "path": "/timers/armed/3" + }, + { + "op": "remove", + "path": "/calls/[\"0199a3c4-7d2e-7c1a-9b3f-2f1e0d9c8b7a\",\"~1do~10~1all~1fork~1branches~13~1asking~1try~10~1ask\",1]" + }, + { + "op": "replace", + "path": "/heldBytes", + "value": 17001 + }, + { + "op": "remove", + "path": "/machine/values/3" + }, + { + "op": "remove", + "path": "/machine/root/body/list/current/task/body/branches/3/task" + }, + { + "op": "replace", + "path": "/machine/root/body/list/current/task/body/branches/3/state", + "value": "finished" + }, + { + "op": "add", + "path": "/machine/root/body/list/current/task/body/branches/3/output", + "value": 1 + }, + { + "op": "add", + "path": "/machine/root/body/list/current/task/body/branches/3/flow", + "value": "continue" + }, + { + "op": "replace", + "path": "/historyBytes", + "value": 14597 + } + ], + "outputs": [ + { + "kind": "cancel_call", + "key": { + "runId": "0199a3c4-7d2e-7c1a-9b3f-2f1e0d9c8b7a", + "reference": "/do/0/all/fork/branches/3/asking/try/0/ask", + "run": 1 + }, + "reason": "deadline" + } + ] + } + }, + { + "version": 5, + "event": { + "type": "input_applied", + "format": 7, + "receipt": { + "kind": "cancel_requested", + "key": "0199a3c4-7d2e-7c1a-9b3f-2f1e0d9c8b7a", + "at": 1790845290005, + "cancel": { + "by": "acme-admin", + "kind": "requested" + } + }, + "steps": [], + "resumed": null, + "patch": [ + { + "op": "replace", + "path": "/status", + "value": "ended" + }, + { + "op": "replace", + "path": "/lastInputAt", + "value": 1790845290005 + }, + { + "op": "replace", + "path": "/inputs", + "value": 5 + }, + { + "op": "remove", + "path": "/timers/armed/1" + }, + { + "op": "remove", + "path": "/timers/armed/4" + }, + { + "op": "remove", + "path": "/listeners/[\"0199a3c4-7d2e-7c1a-9b3f-2f1e0d9c8b7a\",\"~1do~10~1all~1fork~1branches~11~1both\",1]" + }, + { + "op": "replace", + "path": "/heldBytes", + "value": 584 + }, + { + "op": "replace", + "path": "/cancelRequested", + "value": true + }, + { + "op": "remove", + "path": "/machine/values/4" + }, + { + "op": "replace", + "path": "/machine/root", + "value": null + }, + { + "op": "replace", + "path": "/outcome", + "value": { + "kind": "cancelled", + "cancel": { + "by": "acme-admin", + "kind": "requested", + "reason": "Not needed any more" + } + } + }, + { + "op": "replace", + "path": "/historyBytes", + "value": 16082 + } + ], + "outputs": [ + { + "kind": "cancel_listener", + "key": { + "runId": "0199a3c4-7d2e-7c1a-9b3f-2f1e0d9c8b7a", + "reference": "/do/0/all/fork/branches/1/both", + "run": 1 + } + }, + { + "kind": "cancel_timer", + "runId": "0199a3c4-7d2e-7c1a-9b3f-2f1e0d9c8b7a", + "timerId": "1" + }, + { + "kind": "cancel_timer", + "runId": "0199a3c4-7d2e-7c1a-9b3f-2f1e0d9c8b7a", + "timerId": "4" + }, + { + "kind": "settle", + "runId": "0199a3c4-7d2e-7c1a-9b3f-2f1e0d9c8b7a", + "settlement": { + "status": "rejected", + "detail": "Not needed any more", + "by": "acme-admin", + "reason": "cancelled", + "kind": "requested" + } + } + ] + } + } + ], + "snapshot": { + "chunks": [ + "{\"format\":7,\"runId\":\"0199a3c4-7d2e-7c1a-9b3f-2f1e0d9c8b7a\",\"version\":2,\"historyBytes\":11677,\"state\":{\"runId\":\"0199a3c4-7d2e-7c1a-9b3f-2f1e0d9c8b7a\",\"status\":\"running\",\"workflow\":{\"document\":{\"document\":{\"dsl\":\"1.0.3\",\"namespace\":\"acme\",\"name\":\"test\",\"version\":\"1.0.0\"},\"do\":[{\"all\":{\"fork\":{\"branches\":[{\"backing\":{\"try\":[{\"ask\":{\"call\":\"notify\",\"with\":{\"to\":\"bob\"}}}],\"catch\":{\"retry\":{\"delay\":\"PT1H\",\"limit\":{\"attempt\":{\"count\":2}}}}}},{\"both\":{\"listen\":{\"to\":{\"all\":[{\"with\":{\"type\":\"first\"}},{\"with\":{\"type\":\"second\"}},{\"with\":{\"type\":\"third\"}}]}}}},{\"telling\":{\"emit\":{\"event\":{\"with\":{\"type\":\"com.acme.told\",\"source\":\"/acme\",\"data\":{\"to\":\"ada\"}}}}}},{\"asking\":{\"try\":[{\"ask\":{\"call\":\"notify\",\"with\":{\"to\":\"ada\"}}}],\"catch\":{\"errors\":{\"with\":{\"status\":408}}}}}]}}}]},\"input\":1},\"attributes\":{},\"limits\":{\"mostDurationMs\":2592000000,\"longestCallMs\":600000,\"longestCallMsByTask\":{\"/do/0/all/fork/branches/3/asking/try/0/ask\":90000}},\"startedAt\":1790845200000,\"lastInputAt\":1790845200010,\"inputs\":2,\"random\":{\"seed\":7,\"draws\":0},\"runs\":{\"/do/0/all\":1,\"/do/0/all/fork/branches/0/backing\":1,\"/do/0/all/fork/branches/0/backing/try/0/ask\":1,\"/do/0/all/fork/branches/1/both\":1,\"/do/0/all/fork/branches/2/telling\":1,\"/do/0/all/fork/branches/3/asking\":1,\"/do/0/all/fork/branches/3/asking/try/0/ask\":1},\"timers\":{\"next\":5,\"armed\":{\"1\":{\"purpose\":\"deadline\",\"reference\":\"/\",\"armedAt\":1790845200000,\"dueAt\":1793437200000},\"3\":{\"purpose\":\"call_deadline\",\"reference\":\"/do/0/all/fork/branches/3/asking/try/0/ask\",\"armedAt\":1790845200000,\"dueAt\":1790845290000},\"4\":{\"purpose\":\"retry_delay\",\"reference\":\"/do/0/all/fork/branches/0/backing\",\"armedAt\":1790845200010,\"dueAt\":1790848800010}}},\"calls\":{\"[\\\"0199a3c4-7d2e-7c1a-9b3f-2f1e0d9c8b7a\\\",\\\"/do/0/all/fork/branches/3/asking/try/0/ask\\\",1]\":{\"runId\":\"0199a3c4-7d2e-7c1a-9b3f-2f1e0d9c8b7a\",\"reference\":\"/do/0/all/fork/branches/3/asking/try/0/ask\",\"run\":1}},\"listeners\":{\"[\\\"0199a3c4-7d2e-7c1a-9b3f-2f1e0d9c8b7a\\\",\\\"/do/0/all/fork/branches/1/both\\\",1]\":{\"runId\":\"0199a3c4-7d2e-7c1a-9b3f-2f1e0d9c8b7a\",\"reference\":\"/do/0/all/fork/branches/1/both\",\"run\":1}},\"emitted\":{\"count\":1,\"bytes\":159},\"inbox\":{\"waiting\":[],\"waitingBytes\":0,\"receivedIds\":[],\"offeredIds\":[],\"received\":0,\"receivedBytes\":0},\"heldBytes\":25172,\"historyBytes\":11677,\"stepsWithoutWaiting\":0,\"cancelRequested\":false,\"machine\":{\"values\":{\"0\":{\"value\":{},\"bytes\":2},\"1\":{\"value\":1,\"bytes\":1},\"3\":{\"value\":{\"to\":\"ada\"},\"bytes\":12}},\"nextValue\":4,\"context\":0,\"root\":{\"reference\":\"/\",\"run\":1,\"startedAt\":1790845200000,\"context\":0,\"rawInput\":1,\"input\":1,\"variables\":{},\"timeout\":null,\"body\":{\"kind\":\"list\",\"list\":{\"pointer\":\"/do\",\"position\":0,\"data\":1,\"variables\":{},\"current\":{\"kind\":\"running\",\"task\":{\"reference\":\"/do/0/all\",\"run\":1,\"startedAt\":1790845200000,\"context\":0,\"rawInput\":1,\"input\":1,\"variables\":{},\"timeout\":null,\"body\":{\"kind\":\"fork\",\"compete\":false,\"branches\":[{\"state\":\"running\",\"task\":{\"reference\":\"/do/0/all/fork/branches/0/backing\",\"run\":1,\"startedAt\":1790845200000,\"context\":0,\"rawInput\":1,\"input\":1,\"variables\":{},\"timeout\":null,\"body\":{\"kind\":\"try\",\"attempt\":0,\"startedAt\":1790845200000,\"phase\":{\"kind\":\"backing_off\",\"timer\":\"4\",\"error\":{\"type\":\"https://open-workflow-specification.org/spec/1.0.0/errors/communication\",\"status\":503,\"instance\":\"/do/0/all/fork/branches/0/backing/try/0/ask\",\"title\":\"The function notify rejected the run with unavailable\",\"detail\":\"The tool server is gone\",\"kind\":\"mcp_server_failed\",\"because\":\"unreachable\"},\"failed\":{\"reference\":\"/do/0/all/fork/branches/0/backing/try/0/ask\",\"run\":1,\"outcome\":\"raised\",\"times\":1}}}}},{\"state\":\"running\",\"task\":{\"reference\":\"/do/0/all/fork/branches/1/both\",\"run\":1,\"startedAt\":1790845200000,\"context\":0,\"rawInput\":1,\"input\":1,\"variables\":{},\"timeout\":null,\"body\":{\"kind\":\"listen\",\"consumed\":[null,null,null],\"waited\":1}}},{\"state\":\"finished\",\"output\":1,\"flow\":\"continue\"},{\"state\":\"running\",\"task\":{\"reference\":\"/do/0/all/fork/branches/3/asking\",\"run\":1,\"startedAt\":1790845200000,\"context\":0,\"rawInput\":1,\"input\":1,\"variables\":{},\"timeout\":null,\"body\":{\"kind\":\"try\",\"attempt\":0,\"startedAt\":1790845200000,\"phase\":{\"kind\":\"trying\",\"list\":{\"pointer\":\"/do/0/all/fork/branches/3/asking/try\",\"position\":0,\"data\":1,\"variables\":{},\"current\":{\"kind\":\"running\",\"task\":{\"reference\":\"/do/0/all/fork/branches/3/asking/try/0/ask\",\"run\":1,\"startedAt\":1790845200000,\"context\":0,\"rawInput\":1,\"input\":1,\"variables\":{},\"timeout\":null,\"body\":{\"kind\":\"call\",\"key\":{\"runId\":\"0199a3c4-7d2e-7c1a-9b3f-2f1e0d9c8b7a\",\"reference\":\"/do/0/all/fork/branches/3/asking/try/0/ask\",\"run\":1},\"function\":\"notify\",\"arguments\":3,\"label\":\"the function notify\",\"deadline\":\"3\"}}}},\"attemptLimit\":null}}}}]}}}}}}},\"outcome\":null}}" + ], + "bytes": 4669, + "tail": [ + { + "version": 3, + "event": { + "type": "input_applied", + "format": 7, + "receipt": { + "kind": "event_offered", + "key": "record-1", + "at": 1790845200010, + "eventType": "second" + }, + "steps": [ + { + "reference": "/do/0/all/fork/branches/1/both", + "run": 1, + "outcome": "waiting", + "name": "both", + "times": 2, + "caused_by": { + "reference": "/do/0/all/fork/branches/1/both", + "run": 1, + "outcome": "waiting", + "times": 1 + }, + "waits_for": "event" + } + ], + "resumed": { + "reference": "/do/0/all/fork/branches/1/both", + "run": 1, + "times": 1 + }, + "patch": [ + { + "op": "replace", + "path": "/inputs", + "value": 3 + }, + { + "op": "add", + "path": "/inbox/offeredIds/-", + "value": "record-1" + }, + { + "op": "replace", + "path": "/inbox/received", + "value": 1 + }, + { + "op": "replace", + "path": "/inbox/receivedBytes", + "value": 33 + }, + { + "op": "replace", + "path": "/heldBytes", + "value": 25205 + }, + { + "op": "add", + "path": "/machine/values/4", + "value": { + "value": { + "id": "e-second", + "type": "second" + }, + "bytes": 33 + } + }, + { + "op": "replace", + "path": "/machine/nextValue", + "value": 5 + }, + { + "op": "replace", + "path": "/machine/root/body/list/current/task/body/branches/1/task/body/consumed/1", + "value": 4 + }, + { + "op": "replace", + "path": "/machine/root/body/list/current/task/body/branches/1/task/body/waited", + "value": 2 + }, + { + "op": "replace", + "path": "/historyBytes", + "value": 12822 + } + ], + "outputs": [] + } + }, + { + "version": 4, + "event": { + "type": "input_applied", + "format": 7, + "receipt": { + "kind": "timer_fired", + "key": "3", + "at": 1790845290000 + }, + "steps": [ + { + "reference": "/do/0/all/fork/branches/3/asking/try/0/ask", + "run": 1, + "outcome": "raised", + "name": "ask", + "times": 1, + "caused_by": { + "reference": "/do/0/all/fork/branches/3/asking/try/0/ask", + "run": 1, + "outcome": "waiting", + "times": 1 + }, + "error": { + "type": "https://open-workflow-specification.org/spec/1.0.0/errors/timeout", + "title": "The function notify did not finish within 90000 ms, the most it may take" + } + }, + { + "reference": "/do/0/all/fork/branches/3/asking", + "run": 1, + "outcome": "completed", + "name": "asking", + "times": 1, + "caused_by": { + "reference": "/do/0/all/fork/branches/3/asking", + "run": 1, + "outcome": "started", + "times": 1 + } + } + ], + "resumed": { + "reference": "/do/0/all/fork/branches/3/asking/try/0/ask", + "run": 1, + "times": 1 + }, + "patch": [ + { + "op": "replace", + "path": "/lastInputAt", + "value": 1790845290000 + }, + { + "op": "replace", + "path": "/inputs", + "value": 4 + }, + { + "op": "remove", + "path": "/timers/armed/3" + }, + { + "op": "remove", + "path": "/calls/[\"0199a3c4-7d2e-7c1a-9b3f-2f1e0d9c8b7a\",\"~1do~10~1all~1fork~1branches~13~1asking~1try~10~1ask\",1]" + }, + { + "op": "replace", + "path": "/heldBytes", + "value": 17001 + }, + { + "op": "remove", + "path": "/machine/values/3" + }, + { + "op": "remove", + "path": "/machine/root/body/list/current/task/body/branches/3/task" + }, + { + "op": "replace", + "path": "/machine/root/body/list/current/task/body/branches/3/state", + "value": "finished" + }, + { + "op": "add", + "path": "/machine/root/body/list/current/task/body/branches/3/output", + "value": 1 + }, + { + "op": "add", + "path": "/machine/root/body/list/current/task/body/branches/3/flow", + "value": "continue" + }, + { + "op": "replace", + "path": "/historyBytes", + "value": 14597 + } + ], + "outputs": [ + { + "kind": "cancel_call", + "key": { + "runId": "0199a3c4-7d2e-7c1a-9b3f-2f1e0d9c8b7a", + "reference": "/do/0/all/fork/branches/3/asking/try/0/ask", + "run": 1 + }, + "reason": "deadline" + } + ] + } + }, + { + "version": 5, + "event": { + "type": "input_applied", + "format": 7, + "receipt": { + "kind": "cancel_requested", + "key": "0199a3c4-7d2e-7c1a-9b3f-2f1e0d9c8b7a", + "at": 1790845290005, + "cancel": { + "by": "acme-admin", + "kind": "requested" + } + }, + "steps": [], + "resumed": null, + "patch": [ + { + "op": "replace", + "path": "/status", + "value": "ended" + }, + { + "op": "replace", + "path": "/lastInputAt", + "value": 1790845290005 + }, + { + "op": "replace", + "path": "/inputs", + "value": 5 + }, + { + "op": "remove", + "path": "/timers/armed/1" + }, + { + "op": "remove", + "path": "/timers/armed/4" + }, + { + "op": "remove", + "path": "/listeners/[\"0199a3c4-7d2e-7c1a-9b3f-2f1e0d9c8b7a\",\"~1do~10~1all~1fork~1branches~11~1both\",1]" + }, + { + "op": "replace", + "path": "/heldBytes", + "value": 584 + }, + { + "op": "replace", + "path": "/cancelRequested", + "value": true + }, + { + "op": "remove", + "path": "/machine/values/4" + }, + { + "op": "replace", + "path": "/machine/root", + "value": null + }, + { + "op": "replace", + "path": "/outcome", + "value": { + "kind": "cancelled", + "cancel": { + "by": "acme-admin", + "kind": "requested", + "reason": "Not needed any more" + } + } + }, + { + "op": "replace", + "path": "/historyBytes", + "value": 16082 + } + ], + "outputs": [ + { + "kind": "cancel_listener", + "key": { + "runId": "0199a3c4-7d2e-7c1a-9b3f-2f1e0d9c8b7a", + "reference": "/do/0/all/fork/branches/1/both", + "run": 1 + } + }, + { + "kind": "cancel_timer", + "runId": "0199a3c4-7d2e-7c1a-9b3f-2f1e0d9c8b7a", + "timerId": "1" + }, + { + "kind": "cancel_timer", + "runId": "0199a3c4-7d2e-7c1a-9b3f-2f1e0d9c8b7a", + "timerId": "4" + }, + { + "kind": "settle", + "runId": "0199a3c4-7d2e-7c1a-9b3f-2f1e0d9c8b7a", + "settlement": { + "status": "rejected", + "detail": "Not needed any more", + "by": "acme-admin", + "reason": "cancelled", + "kind": "requested" + } + } + ] + } + } + ] + }, + "state": { + "runId": "0199a3c4-7d2e-7c1a-9b3f-2f1e0d9c8b7a", + "status": "ended", + "workflow": { + "document": { + "document": { + "dsl": "1.0.3", + "namespace": "acme", + "name": "test", + "version": "1.0.0" + }, + "do": [ + { + "all": { + "fork": { + "branches": [ + { + "backing": { + "try": [ + { + "ask": { + "call": "notify", + "with": { + "to": "bob" + } + } + } + ], + "catch": { + "retry": { + "delay": "PT1H", + "limit": { + "attempt": { + "count": 2 + } + } + } + } + } + }, + { + "both": { + "listen": { + "to": { + "all": [ + { + "with": { + "type": "first" + } + }, + { + "with": { + "type": "second" + } + }, + { + "with": { + "type": "third" + } + } + ] + } + } + } + }, + { + "telling": { + "emit": { + "event": { + "with": { + "type": "com.acme.told", + "source": "/acme", + "data": { + "to": "ada" + } + } + } + } + } + }, + { + "asking": { + "try": [ + { + "ask": { + "call": "notify", + "with": { + "to": "ada" + } + } + } + ], + "catch": { + "errors": { + "with": { + "status": 408 + } + } + } + } + } + ] + } + } + } + ] + }, + "input": 1 + }, + "attributes": {}, + "limits": { + "mostDurationMs": 2592000000, + "longestCallMs": 600000, + "longestCallMsByTask": { + "/do/0/all/fork/branches/3/asking/try/0/ask": 90000 + } + }, + "startedAt": 1790845200000, + "lastInputAt": 1790845290005, + "inputs": 5, + "random": { + "seed": 7, + "draws": 0 + }, + "runs": { + "/do/0/all": 1, + "/do/0/all/fork/branches/0/backing": 1, + "/do/0/all/fork/branches/0/backing/try/0/ask": 1, + "/do/0/all/fork/branches/1/both": 1, + "/do/0/all/fork/branches/2/telling": 1, + "/do/0/all/fork/branches/3/asking": 1, + "/do/0/all/fork/branches/3/asking/try/0/ask": 1 + }, + "timers": { + "next": 5, + "armed": {} + }, + "calls": {}, + "listeners": {}, + "emitted": { + "count": 1, + "bytes": 159 + }, + "inbox": { + "waiting": [], + "waitingBytes": 0, + "receivedIds": [], + "offeredIds": ["record-1"], + "received": 1, + "receivedBytes": 33 + }, + "heldBytes": 584, + "historyBytes": 16082, + "stepsWithoutWaiting": 0, + "cancelRequested": true, + "machine": { + "values": { + "0": { + "value": {}, + "bytes": 2 + }, + "1": { + "value": 1, + "bytes": 1 + } + }, + "nextValue": 5, + "context": 0, + "root": null + }, + "outcome": { + "kind": "cancelled", + "cancel": { + "by": "acme-admin", + "kind": "requested", + "reason": "Not needed any more" + } + } + } +} diff --git a/packages/workflow-engine/measure/common.ts b/packages/workflow-engine/measure/common.ts index 16397eba5..128fb90c4 100644 --- a/packages/workflow-engine/measure/common.ts +++ b/packages/workflow-engine/measure/common.ts @@ -3,7 +3,7 @@ import type { RunState } from '../src/machine/run-state.ts'; import { armedTimerIds } from '../src/testing/run-history.ts'; import { workflow } from '../src/testing/workflows.ts'; -export const executionId = '0199a3c4-7d2e-7c1a-9b3f-000000040000'; +export const runId = '0199a3c4-7d2e-7c1a-9b3f-000000040000'; export const startedAt = 1_790_845_200_000; @@ -29,7 +29,7 @@ do: export function nextTick(state: RunState): RunInput { const [timerId = ''] = armedTimerIds(state, 'wait'); const at = state.timers.armed[timerId]?.dueAt ?? state.lastInputAt; - return { kind: 'timer_fired', executionId: state.executionId, at, timerId }; + return { kind: 'timer_fired', runId: state.runId, at, timerId }; } export function millisecondsOf(work: () => void): number { diff --git a/packages/workflow-engine/measure/decide.ts b/packages/workflow-engine/measure/decide.ts index 64ce44a6b..c0bfe6c00 100644 --- a/packages/workflow-engine/measure/decide.ts +++ b/packages/workflow-engine/measure/decide.ts @@ -5,7 +5,7 @@ import { snapshotChunks, snapshotOf } from '../src/run-log/snapshot.ts'; import { startedOf, testCancel, testMachine } from '../src/testing/driver-inputs.ts'; import { memoryDriver } from '../src/testing/memory-driver.ts'; import { workflow } from '../src/testing/workflows.ts'; -import { executionId, looping, medianMillisecondsOf, nextTick, startedAt, textBytesOf } from './common.ts'; +import { runId, looping, medianMillisecondsOf, nextTick, startedAt, textBytesOf } from './common.ts'; const calling = workflow('do:\n - ask: { call: notify, with: { to: ada } }'); @@ -19,13 +19,13 @@ do: function waitingState(document: ReturnType): RunState { const driver = memoryDriver({ respond: () => 'never' }); - driver.start({ executionId, document }); - return driver.state(executionId); + driver.start({ runId, document }); + return driver.state(runId); } function answerTo(state: RunState): RunInput { - const [key = { executionId, reference: '/do/0/ask', run: 1 }] = Object.values(state.calls); - return { kind: 'call_answered', executionId, at: state.lastInputAt, key, result: { status: 'succeeded', output: 1 } }; + const [key = { runId, reference: '/do/0/ask', run: 1 }] = Object.values(state.calls); + return { kind: 'call_answered', runId, at: state.lastInputAt, key, result: { status: 'succeeded', output: 1 } }; } function decideMicroseconds(input: RunInput, state: RunState): string { @@ -43,11 +43,11 @@ export function decideMeasured(): readonly string[] { const awaiting = waitingState(listening); const event = { id: 'e1', type: 'approved', data: 'yes' }; const medians = [ - `started ${decideMicroseconds(startedOf({ executionId, document: looping(40_000) }, startedAt), newRun)} µs`, + `started ${decideMicroseconds(startedOf({ runId, document: looping(40_000) }, startedAt), newRun)} µs`, `timer ${decideMicroseconds(nextTick(ticking), ticking)} µs`, `answer ${decideMicroseconds(answerTo(asking), asking)} µs`, - `event ${decideMicroseconds({ kind: 'event_received', executionId, at: awaiting.lastInputAt, event }, awaiting)} µs`, - `cancel ${decideMicroseconds({ kind: 'cancel_requested', executionId, at: ticking.lastInputAt, cancel: testCancel }, ticking)} µs`, + `event ${decideMicroseconds({ kind: 'event_received', runId, at: awaiting.lastInputAt, event }, awaiting)} µs`, + `cancel ${decideMicroseconds({ kind: 'cancel_requested', runId, at: ticking.lastInputAt, cancel: testCancel }, ticking)} µs`, ]; return [`decide, at the median: ${medians.join(', ')}`]; } diff --git a/packages/workflow-engine/measure/engine.ts b/packages/workflow-engine/measure/engine.ts index 284424202..edc662031 100644 --- a/packages/workflow-engine/measure/engine.ts +++ b/packages/workflow-engine/measure/engine.ts @@ -6,7 +6,7 @@ import type { RunInput } from '../src/machine/run-input.ts'; import { memoryPorts } from '../src/memory/memory-ports.ts'; import { virtualClock } from '../src/memory/virtual-clock.ts'; import { startedOf, testMachine } from '../src/testing/driver-inputs.ts'; -import { executionId, looping, millisecondsOf } from './common.ts'; +import { runId, looping, millisecondsOf } from './common.ts'; function throughTheEngine(inputs: number, cache: RunCache): number { const clock = virtualClock(); @@ -21,9 +21,9 @@ function throughTheEngine(inputs: number, cache: RunCache): number { function submitted(input: RunInput): void { Effect.runSync(engine.submit(input)); } - ports.recordStore.known(executionId); + ports.recordStore.known(runId); return millisecondsOf(() => { - submitted(startedOf({ executionId, document: looping(inputs) }, clock.now())); + submitted(startedOf({ runId, document: looping(inputs) }, clock.now())); let advancing = true; while (advancing) { advancing = clock.advance(); diff --git a/packages/workflow-engine/measure/loop.ts b/packages/workflow-engine/measure/loop.ts index 6ea1ceb74..5cd6b6cb4 100644 --- a/packages/workflow-engine/measure/loop.ts +++ b/packages/workflow-engine/measure/loop.ts @@ -9,7 +9,7 @@ import type { StoredRun } from '../src/run-log/run-store.ts'; import { isSnapshotDue, snapshotChunks, snapshotOf } from '../src/run-log/snapshot.ts'; import { startedOf, testMachine } from '../src/testing/driver-inputs.ts'; import { - executionId, + runId, jsonBytesOf, looping, medianMillisecondsOf, @@ -35,7 +35,7 @@ function decidedAlone(inputs: number): Loop { const events: PositionedEvent[] = []; const run: { state: RunState; input: RunInput } = { state: newRun, - input: startedOf({ executionId, document: looping(inputs) }, startedAt), + input: startedOf({ runId, document: looping(inputs) }, startedAt), }; const milliseconds = millisecondsOf(() => { while (run.state.status !== 'ended') { diff --git a/packages/workflow-engine/measure/pages.ts b/packages/workflow-engine/measure/pages.ts index 38fc8fa2e..752099f12 100644 --- a/packages/workflow-engine/measure/pages.ts +++ b/packages/workflow-engine/measure/pages.ts @@ -19,12 +19,12 @@ const turns = 9; function reviewed(index: number): JsonObject { return { specversion: '1.0', - source: `/executions/0199a3c4-7d2e-7c1a-9b3f-${String(index).padStart(12, '0')}`, - type: 'execution_succeeded', - subject: 'inference/review-brief', + source: `/runs/0199a3c4-7d2e-7c1a-9b3f-${String(index).padStart(12, '0')}`, + type: 'run_succeeded', + subject: 'reasoning/review-brief', time: new Date(Date.UTC(2026, 9, 6) + index * 1000).toISOString(), data: { - primitive: 'inference', + type: 'reasoning', name: 'review-brief', version: 1, output: { campaign: `campaign-${index % 100}`, verdict: index % 3 === 0 ? 'reject' : 'approve' }, @@ -42,7 +42,7 @@ function pageOf(count: number): FoldRequest { : [ { fold: reviewsFold, - filters: [{ type: 'execution_succeeded', subject: 'inference/review-brief' }], + filters: [{ type: 'run_succeeded', subject: 'reasoning/review-brief' }], view: {}, events: page.map((_, index) => index), }, diff --git a/packages/workflow-engine/src/cache/cached-inputs.test.ts b/packages/workflow-engine/src/cache/cached-inputs.test.ts index 8db465bcc..b5ce39206 100644 --- a/packages/workflow-engine/src/cache/cached-inputs.test.ts +++ b/packages/workflow-engine/src/cache/cached-inputs.test.ts @@ -4,23 +4,21 @@ import { describe, expect, it } from 'vitest'; import { workflowMachine } from '../decider/workflow-machine.ts'; import type { RunInput } from '../machine/run-input.ts'; import { newRun, type RunState } from '../machine/run-state.ts'; -import type { RunEvent } from '../run-log/run-event.ts'; +import type { RunLogEvent } from '../run-log/run-event.ts'; import { testMachine } from '../testing/driver-inputs.ts'; import { testCancel } from '../testing/driver-inputs.ts'; import { memoryDriver, type MemoryDriver } from '../testing/memory-driver.ts'; -import { armedTimerIds, drivenExecutionId, statesAlong } from '../testing/run-history.ts'; +import { armedTimerIds, drivenRunId, statesAlong } from '../testing/run-history.ts'; import { workflow } from '../testing/workflows.ts'; function cancelAt(at: number): RunInput { - return { kind: 'cancel_requested', executionId: drivenExecutionId, at, cancel: testCancel }; + return { kind: 'cancel_requested', runId: drivenRunId, at, cancel: testCancel }; } const elsewhere = { cause: { kind: 'none' as const }, attributes: {} }; -function appendedElsewhere(driver: MemoryDriver, events: readonly RunEvent[]): void { - Effect.runSync( - Effect.forEach(events, (event) => driver.ports.runStore.append(drivenExecutionId, event, 1, elsewhere)), - ); +function appendedElsewhere(driver: MemoryDriver, events: readonly RunLogEvent[]): void { + Effect.runSync(Effect.forEach(events, (event) => driver.ports.runStore.append(drivenRunId, event, 1, elsewhere))); } function ticking(times: number): ReturnType { @@ -34,7 +32,7 @@ do: function startedTicking(times: number): MemoryDriver { const driver = memoryDriver(); - driver.start({ executionId: drivenExecutionId, document: ticking(times) }); + driver.start({ runId: drivenRunId, document: ticking(times) }); return driver; } @@ -46,10 +44,10 @@ function advancedToTheEnd(driver: MemoryDriver): void { } function storedState(driver: MemoryDriver): RunState { - return statesAlong(driver.ports.runStore.events(drivenExecutionId)).at(-1) ?? newRun; + return statesAlong(driver.ports.runStore.events(drivenRunId)).at(-1) ?? newRun; } -function decidedElsewhere(driver: MemoryDriver, input: RunInput): readonly RunEvent[] { +function decidedElsewhere(driver: MemoryDriver, input: RunInput): readonly RunLogEvent[] { return Result.getOrThrow(workflowMachine(testMachine).decide(input, storedState(driver))); } @@ -59,18 +57,18 @@ function armedTick(driver: MemoryDriver): string { } function fired(timerId: string, at: number): RunInput { - return { kind: 'timer_fired', executionId: drivenExecutionId, at, timerId }; + return { kind: 'timer_fired', runId: drivenRunId, at, timerId }; } describe('an input to a run the engine keeps', () => { it('loads neither the snapshot nor the events the inputs before it left', () => { const driver = startedTicking(20); advancedToTheEnd(driver); - const events = driver.ports.runStore.events(drivenExecutionId); + const events = driver.ports.runStore.events(drivenRunId); expect(statesAlong(events).at(-1)?.outcome).toEqual({ kind: 'completed', output: { n: 20 } }); expect(events).toHaveLength(21); - expect(driver.ports.runStore.loads(drivenExecutionId)).toBe(1); + expect(driver.ports.runStore.loads(drivenRunId)).toBe(1); }); it('loads the run again once its stream moved past the version the engine keeps', () => { @@ -82,7 +80,7 @@ describe('an input to a run the engine keeps', () => { const answer = driver.submit(cancelAt(at + 1)); expect(answer).toEqual({ outcome: 'stale', version: 2 }); - expect(driver.ports.runStore.loads(drivenExecutionId)).toBe(2); + expect(driver.ports.runStore.loads(drivenRunId)).toBe(2); }); it('takes an input another host made possible, since its stream moved past the version the engine keeps', () => { @@ -96,16 +94,16 @@ describe('an input to a run the engine keeps', () => { expect(secondTick).not.toBe(firstTick); expect(answer).toEqual({ outcome: 'applied', version: 3 }); - expect(driver.ports.runStore.events(drivenExecutionId)).toHaveLength(3); + expect(driver.ports.runStore.events(drivenRunId)).toHaveLength(3); }); it('loads the run from the store when its append meets a conflict, rather than decide again on what it kept', () => { const driver = startedTicking(3); driver.ports.runStore.failNextAppend('conflict'); - const answer = driver.cancel(drivenExecutionId); + const answer = driver.cancel(drivenRunId); expect(answer).toEqual({ outcome: 'applied', version: 2 }); - expect(driver.ports.runStore.loads(drivenExecutionId)).toBe(2); + expect(driver.ports.runStore.loads(drivenRunId)).toBe(2); }); }); diff --git a/packages/workflow-engine/src/cache/cached-snapshots.test.ts b/packages/workflow-engine/src/cache/cached-snapshots.test.ts index 0b7b4b664..f33b9a8fb 100644 --- a/packages/workflow-engine/src/cache/cached-snapshots.test.ts +++ b/packages/workflow-engine/src/cache/cached-snapshots.test.ts @@ -3,7 +3,7 @@ import { describe, expect, it } from 'vitest'; import { eventBytesOf, type PositionedEvent } from '../run-log/run-event.ts'; import { isSnapshotDue, snapshotChunks, snapshotOf } from '../run-log/snapshot.ts'; import { memoryDriver, type MemoryDriver } from '../testing/memory-driver.ts'; -import { drivenExecutionId, statesAlong } from '../testing/run-history.ts'; +import { drivenRunId, statesAlong } from '../testing/run-history.ts'; import { workflow } from '../testing/workflows.ts'; const holdingMuch = workflow(` @@ -34,16 +34,16 @@ function snapshotsDueAlong(events: readonly PositionedEvent[]): readonly number[ function ranToItsEnd(): MemoryDriver { const driver = memoryDriver(); - driver.start({ executionId: drivenExecutionId, document: holdingMuch }); - driver.runUntilEnded(drivenExecutionId); + driver.start({ runId: drivenRunId, document: holdingMuch }); + driver.runUntilEnded(drivenRunId); return driver; } describe('a run the engine keeps between its inputs', () => { it('counts the bytes of the snapshot its store holds, so each snapshot is written when it is due', () => { const driver = ranToItsEnd(); - const events = driver.ports.runStore.events(drivenExecutionId); - const saved = driver.ports.runStore.snapshotsSaved(drivenExecutionId); + const events = driver.ports.runStore.events(drivenRunId); + const saved = driver.ports.runStore.snapshotsSaved(drivenRunId); expect(statesAlong(events).at(-1)?.outcome).toMatchObject({ kind: 'completed' }); expect(saved.length).toBeGreaterThan(2); diff --git a/packages/workflow-engine/src/cache/kept-runs.test.ts b/packages/workflow-engine/src/cache/kept-runs.test.ts index 115c0ced6..45d811dfd 100644 --- a/packages/workflow-engine/src/cache/kept-runs.test.ts +++ b/packages/workflow-engine/src/cache/kept-runs.test.ts @@ -7,12 +7,12 @@ import type { RunInput } from '../machine/run-input.ts'; import { memoryRunStore } from '../memory/run-store.ts'; import { countingDecider } from '../testing/counting-decider.ts'; import { deeplyFrozen, frozenRuns } from '../testing/frozen-runs.ts'; -import { at, executionId, runningState, started } from '../testing/runs.ts'; +import { at, runId, runningState, started } from '../testing/runs.ts'; import { runCacheOf } from './run-cache.ts'; const cancelled: RunInput = { kind: 'cancel_requested', - executionId, + runId, at: at + 1, cancel: { by: 'tester', kind: 'requested', reason: 'The test cancelled the run' }, }; @@ -30,37 +30,35 @@ describe('a run the cache keeps', () => { const store = memoryRunStore(); const cache = runCacheOf(); const loop = runLoopOf(store, countingDecider, cache); - Effect.runSync(loop(executionId, started)); + Effect.runSync(loop(runId, started)); store.failNextAppend('unknown_outcome'); - const lost = Effect.runSyncExit(loop(executionId, cancelled)); - const kept = cache.get(executionId); - const next = Effect.runSync(loop(executionId, { ...cancelled, at: at + 2 })); + const lost = Effect.runSyncExit(loop(runId, cancelled)); + const kept = cache.get(runId); + const next = Effect.runSync(loop(runId, { ...cancelled, at: at + 2 })); expect(Exit.isFailure(lost)).toBe(true); expect(kept).toBeUndefined(); expect([next.loaded.version, next.events.length]).toEqual([2, 0]); - expect(store.loads(executionId)).toBe(2); + expect(store.loads(runId)).toBe(2); }); it('is let go of when its append meets a conflict, so the retry loads the run from the store', () => { const store = memoryRunStore(); const cache = runCacheOf(); const loop = runLoopOf(store, countingDecider, cache); - Effect.runSync(loop(executionId, started)); + Effect.runSync(loop(runId, started)); store.failNextAppend('conflict'); - const decided = Effect.runSync(loop(executionId, cancelled)); + const decided = Effect.runSync(loop(runId, cancelled)); - expect([decided.version, cache.get(executionId)?.version]).toEqual([2, 2]); - expect(store.loads(executionId)).toBe(2); + expect([decided.version, cache.get(runId)?.version]).toEqual([2, 2]); + expect(store.loads(runId)).toBe(2); }); it('is frozen to its leaves under the memory driver, so a machine that wrote to a state in place would fail', () => { - const unfrozen = Effect.runSyncExit(runLoopOf(memoryRunStore(), writing, runCacheOf())(executionId, started)); - const frozen = Effect.runSyncExit( - runLoopOf(memoryRunStore(), writing, frozenRuns(runCacheOf()))(executionId, started), - ); + const unfrozen = Effect.runSyncExit(runLoopOf(memoryRunStore(), writing, runCacheOf())(runId, started)); + const frozen = Effect.runSyncExit(runLoopOf(memoryRunStore(), writing, frozenRuns(runCacheOf()))(runId, started)); const state = deeplyFrozen(structuredClone(runningState)); expect([Exit.isSuccess(unfrozen), Exit.isFailure(frozen)]).toEqual([true, true]); diff --git a/packages/workflow-engine/src/cache/run-cache.ts b/packages/workflow-engine/src/cache/run-cache.ts index c5652b2cc..ffdf2a0a4 100644 --- a/packages/workflow-engine/src/cache/run-cache.ts +++ b/packages/workflow-engine/src/cache/run-cache.ts @@ -1,9 +1,9 @@ import { Effect } from 'effect'; import type { RunState } from '../machine/run-state.ts'; -import type { RunEvent } from '../run-log/run-event.ts'; +import type { RunLogEvent } from '../run-log/run-event.ts'; import { loadedRunOf, type LoadedRun } from '../run-log/run-fold.ts'; -import type { RunStore } from '../run-log/run-store.ts'; +import type { RunLogStore } from '../run-log/run-store.ts'; import { sinceSnapshotAfter } from '../run-log/snapshot.ts'; export interface RunCacheBounds { @@ -12,14 +12,14 @@ export interface RunCacheBounds { } export interface RunCache { - readonly get: (executionId: string) => LoadedRun | undefined; - readonly put: (executionId: string, loaded: LoadedRun) => void; - readonly drop: (executionId: string) => void; + readonly get: (runId: string) => LoadedRun | undefined; + readonly put: (runId: string, loaded: LoadedRun) => void; + readonly drop: (runId: string) => void; } interface DecidedRun { readonly loaded: LoadedRun; - readonly events: readonly RunEvent[]; + readonly events: readonly RunLogEvent[]; readonly state: RunState; readonly version: number; } @@ -33,33 +33,33 @@ function bytesOf({ state }: LoadedRun): number { export function runCacheOf({ mostRuns, mostBytes }: RunCacheBounds = runCacheBounds): RunCache { const runs = new Map(); const held = { bytes: 0 }; - const drop = (executionId: string): void => { - const kept = runs.get(executionId); + const drop = (runId: string): void => { + const kept = runs.get(runId); if (kept !== undefined) { - runs.delete(executionId); + runs.delete(runId); held.bytes -= bytesOf(kept); } }; const evictLeastRecentlyUsed = (): void => { - for (const executionId of runs.keys()) { + for (const runId of runs.keys()) { if (runs.size <= mostRuns && held.bytes <= mostBytes) { return; } - drop(executionId); + drop(runId); } }; return { - get: (executionId) => { - const kept = runs.get(executionId); + get: (runId) => { + const kept = runs.get(runId); if (kept !== undefined) { - runs.delete(executionId); - runs.set(executionId, kept); + runs.delete(runId); + runs.set(runId, kept); } return kept; }, - put: (executionId, loaded) => { - drop(executionId); - runs.set(executionId, loaded); + put: (runId, loaded) => { + drop(runId); + runs.set(runId, loaded); held.bytes += bytesOf(loaded); evictLeastRecentlyUsed(); }, @@ -67,33 +67,33 @@ export function runCacheOf({ mostRuns, mostBytes }: RunCacheBounds = runCacheBou }; } -function loadedFromStore(runStore: RunStore, cache: RunCache, executionId: string): Effect.Effect { - return Effect.map(runStore.load(executionId), (stored) => { +function loadedFromStore(runStore: RunLogStore, cache: RunCache, runId: string): Effect.Effect { + return Effect.map(runStore.load(runId), (stored) => { const loaded = loadedRunOf(stored); - cache.put(executionId, loaded); + cache.put(runId, loaded); return loaded; }); } -export function cachedLoadOf(runStore: RunStore, cache: RunCache): (executionId: string) => Effect.Effect { - return (executionId) => { - const kept = cache.get(executionId); +export function cachedLoadOf(runStore: RunLogStore, cache: RunCache): (runId: string) => Effect.Effect { + return (runId) => { + const kept = cache.get(runId); if (kept === undefined) { - return loadedFromStore(runStore, cache, executionId); + return loadedFromStore(runStore, cache, runId); } - return Effect.flatMap(runStore.eventsAfter(executionId, kept.version), (newer) => { + return Effect.flatMap(runStore.eventsAfter(runId, kept.version), (newer) => { if (newer.length === 0) { return Effect.succeed(kept); } - cache.drop(executionId); - return loadedFromStore(runStore, cache, executionId); + cache.drop(runId); + return loadedFromStore(runStore, cache, runId); }); }; } -export function keptAfter(cache: RunCache, executionId: string, decided: DecidedRun): void { +export function keptAfter(cache: RunCache, runId: string, decided: DecidedRun): void { if (decided.events.length > 0) { const sinceSnapshot = sinceSnapshotAfter(decided.loaded.sinceSnapshot, decided.events); - cache.put(executionId, { state: decided.state, version: decided.version, sinceSnapshot }); + cache.put(runId, { state: decided.state, version: decided.version, sinceSnapshot }); } } diff --git a/packages/workflow-engine/src/decider/call-deadlines.test.ts b/packages/workflow-engine/src/decider/call-deadlines.test.ts index c1d812785..79d1bd103 100644 --- a/packages/workflow-engine/src/decider/call-deadlines.test.ts +++ b/packages/workflow-engine/src/decider/call-deadlines.test.ts @@ -15,7 +15,7 @@ do: - slow: { call: notify, with: { to: grace } } `); -const executionId = '0199a3c4-7d2e-7c1a-9b3f-000000000061'; +const runId = '0199a3c4-7d2e-7c1a-9b3f-000000000061'; const pausingThenAsking = workflow('do:\n - pause: { wait: PT1M }\n - ask: { call: notify, with: { to: ada } }'); @@ -34,8 +34,8 @@ function waitOf(positioned: PositionedEvent | undefined): string { function deadlinesOf(limits: Partial) { const driver = memoryDriver({ respond: () => 'never' }); - driver.start({ executionId, document: twoCalls, limits }); - const [first] = Effect.runSync(driver.ports.runStore.eventsAfter(executionId, 0)); + driver.start({ runId, document: twoCalls, limits }); + const [first] = Effect.runSync(driver.ports.runStore.eventsAfter(runId, 0)); return (first?.event.outputs ?? []).flatMap((output) => output.kind === 'start_call' ? [{ reference: output.key.reference, longestMs: output.longestMs }] : [], ); @@ -57,18 +57,18 @@ describe('the deadline of a call', () => { it('is a millisecond at the least, for a call that starts when the run has no time left', () => { const driver = memoryDriver({ respond: () => 'never' }); driver.start({ - executionId, + runId, document: pausingThenAsking, limits: { mostDurationMs: 60_000, longestCallMs: 600_000 }, }); - const [first] = Effect.runSync(driver.ports.runStore.eventsAfter(executionId, 0)); + const [first] = Effect.runSync(driver.ports.runStore.eventsAfter(runId, 0)); driver.submit({ kind: 'timer_fired', - executionId, + runId, at: Number(first?.event.receipt.at) + 90_000, timerId: waitOf(first), }); - const [, second] = Effect.runSync(driver.ports.runStore.eventsAfter(executionId, 0)); + const [, second] = Effect.runSync(driver.ports.runStore.eventsAfter(runId, 0)); expect(startsIn(second)).toEqual([{ reference: '/do/1/ask', longestMs: 1 }]); }); diff --git a/packages/workflow-engine/src/decider/decision.ts b/packages/workflow-engine/src/decider/decision.ts index ab04bd300..c35ed4a74 100644 --- a/packages/workflow-engine/src/decider/decision.ts +++ b/packages/workflow-engine/src/decider/decision.ts @@ -2,14 +2,14 @@ import { staleReasonOf } from '../machine/admission.ts'; import { inputTimeOf, receiptOf } from '../machine/input-receipt.ts'; import type { RunInput } from '../machine/run-input.ts'; import type { RunState } from '../machine/run-state.ts'; -import type { RunEvent } from '../run-log/run-event.ts'; +import type { RunLogEvent } from '../run-log/run-event.ts'; import type { MachineOptions } from '../runner/run-descriptors.ts'; import { resultOf } from './input-result.ts'; import { brokenBound, inputsBound } from './run-bounds.ts'; import { endingEvent } from './run-endings.ts'; import { eventOf } from './run-events.ts'; -function appliedEvents(state: RunState, options: MachineOptions, input: RunInput): readonly RunEvent[] { +function appliedEvents(state: RunState, options: MachineOptions, input: RunInput): readonly RunLogEvent[] { const at = inputTimeOf(state, input); const result = resultOf(state, at, options, input); if (input.kind === 'event_offered' && result.offer?.kind !== 'accepted') { @@ -20,7 +20,7 @@ function appliedEvents(state: RunState, options: MachineOptions, input: RunInput return [broken === undefined ? event : endingEvent(state, options, input, broken)]; } -export function decided(options: MachineOptions, input: RunInput, state: RunState): readonly RunEvent[] { +export function decided(options: MachineOptions, input: RunInput, state: RunState): readonly RunLogEvent[] { if (staleReasonOf(state, input) !== undefined) { return []; } diff --git a/packages/workflow-engine/src/decider/deduplication.test.ts b/packages/workflow-engine/src/decider/deduplication.test.ts index 7f2e6505b..d823618b4 100644 --- a/packages/workflow-engine/src/decider/deduplication.test.ts +++ b/packages/workflow-engine/src/decider/deduplication.test.ts @@ -4,7 +4,7 @@ import type { RunInput } from '../machine/run-input.ts'; import { startedOf } from '../testing/driver-inputs.ts'; import { testCancel } from '../testing/driver-inputs.ts'; import { memoryDriver, type MemoryDriver } from '../testing/memory-driver.ts'; -import { armedTimerIds, drivenExecutionId as executionId } from '../testing/run-history.ts'; +import { armedTimerIds, drivenRunId as runId } from '../testing/run-history.ts'; import { workflow } from '../testing/workflows.ts'; const document = workflow(` @@ -19,19 +19,19 @@ do: function started(): MemoryDriver { const driver = memoryDriver({ respond: () => 'never' }); - driver.start({ executionId, document }); + driver.start({ runId, document }); return driver; } function twice(driver: MemoryDriver, input: RunInput): readonly string[] { - const before = driver.ports.runStore.events(executionId).length; + const before = driver.ports.runStore.events(runId).length; const outcomes = [driver.submit(input), driver.submit(input)].map((submission) => submission.outcome); - return [...outcomes, `${driver.ports.runStore.events(executionId).length - before} appended`]; + return [...outcomes, `${driver.ports.runStore.events(runId).length - before} appended`]; } function receiptsOf(driver: MemoryDriver): readonly string[] { return driver.ports.runStore - .events(executionId) + .events(runId) .map(({ event }) => JSON.stringify([event.receipt.kind, event.receipt.key])); } @@ -39,31 +39,23 @@ describe('an input given twice is applied once', () => { it('when it starts the run', () => { const driver = started(); - expect(twice(driver, startedOf({ executionId, document }, driver.clock.now()))).toEqual([ - 'stale', - 'stale', - '0 appended', - ]); + expect(twice(driver, startedOf({ runId, document }, driver.clock.now()))).toEqual(['stale', 'stale', '0 appended']); }); it('when it fires a timer', () => { const driver = started(); - const [timerId = ''] = armedTimerIds(driver.state(executionId), 'wait'); + const [timerId = ''] = armedTimerIds(driver.state(runId), 'wait'); const at = driver.clock.now() + 3_600_000; - expect(twice(driver, { kind: 'timer_fired', executionId, at, timerId })).toEqual([ - 'applied', - 'stale', - '1 appended', - ]); + expect(twice(driver, { kind: 'timer_fired', runId, at, timerId })).toEqual(['applied', 'stale', '1 appended']); }); it('when it answers a call', () => { const driver = started(); - const key = { executionId, reference: '/do/0/both/fork/branches/0/ask', run: 1 }; + const key = { runId, reference: '/do/0/both/fork/branches/0/ask', run: 1 }; const result = { status: 'succeeded', output: 1 } as const; - expect(twice(driver, { kind: 'call_answered', executionId, at: driver.clock.now(), key, result })).toEqual([ + expect(twice(driver, { kind: 'call_answered', runId, at: driver.clock.now(), key, result })).toEqual([ 'applied', 'stale', '1 appended', @@ -76,33 +68,35 @@ describe('an event or a cancel given twice is applied once', () => { const driver = started(); const event = { id: 'e1', type: 'b' }; - expect(twice(driver, { kind: 'event_received', executionId, at: driver.clock.now(), event })).toEqual([ + expect(twice(driver, { kind: 'event_received', runId, at: driver.clock.now(), event })).toEqual([ 'applied', 'stale', '1 appended', ]); - expect(driver.state(executionId).inbox.received).toBe(1); + expect(driver.state(runId).inbox.received).toBe(1); }); it('when it asks for a cancel', () => { const driver = started(); - expect( - twice(driver, { kind: 'cancel_requested', executionId, at: driver.clock.now(), cancel: testCancel }), - ).toEqual(['applied', 'stale', '1 appended']); + expect(twice(driver, { kind: 'cancel_requested', runId, at: driver.clock.now(), cancel: testCancel })).toEqual([ + 'applied', + 'stale', + '1 appended', + ]); }); }); describe('a stream', () => { it('has no two events with the same receipt', () => { const driver = memoryDriver({ respond: () => ({ after: 5, result: { status: 'succeeded', output: 1 } }) }); - driver.start({ executionId, document }); + driver.start({ runId, document }); for (const id of ['a', 'b']) { driver.at(10, () => { - driver.deliver(executionId, { id, type: id }); + driver.deliver(runId, { id, type: id }); }); } - driver.runUntilEnded(executionId); + driver.runUntilEnded(runId); const receipts = receiptsOf(driver); expect(new Set(receipts).size).toBe(receipts.length); @@ -110,6 +104,6 @@ describe('a stream', () => { }); it('takes nothing for a run that has not started, and says so', () => { - expect(memoryDriver().cancel(executionId)).toEqual({ outcome: 'not_started', version: 0 }); + expect(memoryDriver().cancel(runId)).toEqual({ outcome: 'not_started', version: 0 }); }); }); diff --git a/packages/workflow-engine/src/decider/open-calls.test.ts b/packages/workflow-engine/src/decider/open-calls.test.ts index 9da0ef992..1eaa8591f 100644 --- a/packages/workflow-engine/src/decider/open-calls.test.ts +++ b/packages/workflow-engine/src/decider/open-calls.test.ts @@ -134,9 +134,9 @@ function openCallsAlong(states: readonly RunState[]): readonly Opened[] { ); } -function endedRun(driver: MemoryDriver, executionId: string, document: Case['document']): RunState { - driver.start({ executionId, document, limits: { longestCallMs } }); - return driver.runUntilEnded(executionId); +function endedRun(driver: MemoryDriver, runId: string, document: Case['document']): RunState { + driver.start({ runId, document, limits: { longestCallMs } }); + return driver.runUntilEnded(runId); } function hostThatDiesOnce(): DyingHost { @@ -163,9 +163,9 @@ describe('every open call', () => { 'has an armed call deadline no later than its start and the longest a call runs, and leaves none once it closes: $shape, $answer', ({ document, respond }) => { const driver = memoryDriver({ respond }); - const executionId = '0199a3c4-7d2e-7c1a-9b3f-000000000033'; - const ended = endedRun(driver, executionId, document); - const states = statesAlong(driver.ports.runStore.events(executionId)); + const runId = '0199a3c4-7d2e-7c1a-9b3f-000000000033'; + const ended = endedRun(driver, runId, document); + const states = statesAlong(driver.ports.runStore.events(runId)); expect(ended.status).toBe('ended'); expect(openCallsAlong(states).filter((open) => isUnguarded(open))).toEqual([]); @@ -177,8 +177,8 @@ describe('every open call', () => { it('is answered by its deadline when the executor never answers, as a timeout that cancels it', () => { const driver = memoryDriver({ respond: () => 'never' }); - const executionId = '0199a3c4-7d2e-7c1a-9b3f-000000000034'; - const ended = endedRun(driver, executionId, aCall); + const runId = '0199a3c4-7d2e-7c1a-9b3f-000000000034'; + const ended = endedRun(driver, runId, aCall); expect(driver.clock.now() - ended.startedAt).toBe(longestCallMs); expect(ended.outcome).toEqual({ @@ -191,7 +191,7 @@ describe('every open call', () => { }, }); expect(driver.ports.executor.cancelled()).toEqual([ - { kind: 'cancel_call', key: { executionId, reference: '/do/0/ask', run: 1 }, reason: 'deadline' }, + { kind: 'cancel_call', key: { runId, reference: '/do/0/ask', run: 1 }, reason: 'deadline' }, ]); }); }); @@ -203,15 +203,15 @@ describe('a call whose host died before the dispatch of its start finished', () host.onDeath(() => { driver.ports.faults.failNext('arm_timer'); }); - const executionId = '0199a3c4-7d2e-7c1a-9b3f-000000000035'; - driver.start({ executionId, document: aCall, limits: { longestCallMs } }); + const runId = '0199a3c4-7d2e-7c1a-9b3f-000000000035'; + driver.start({ runId, document: aCall, limits: { longestCallMs } }); - const wake = Effect.runSync(driver.engine.wake(executionId)); + const wake = Effect.runSync(driver.engine.wake(runId)); const [key] = host.started(); expect(wake).toEqual({ version: 1, dispatchedThrough: 1 }); expect(host.started()).toEqual([key, key]); - expect(driver.runUntilEnded(executionId).outcome).toEqual({ kind: 'completed', output: 'again' }); - expect(driver.ports.recordStore.settlementOf(executionId)).toEqual({ status: 'succeeded', output: 'again' }); + expect(driver.runUntilEnded(runId).outcome).toEqual({ kind: 'completed', output: 'again' }); + expect(driver.ports.recordStore.settlementOf(runId)).toEqual({ status: 'succeeded', output: 'again' }); }); }); diff --git a/packages/workflow-engine/src/decider/run-bounds.ts b/packages/workflow-engine/src/decider/run-bounds.ts index afc3b1d12..101e39edb 100644 --- a/packages/workflow-engine/src/decider/run-bounds.ts +++ b/packages/workflow-engine/src/decider/run-bounds.ts @@ -2,14 +2,14 @@ import { raised } from '../dsl/raised-error.ts'; import type { DslError } from '../machine/dsl-error.ts'; import { mostEventBytes, mostHeldBytes, mostHistoryBytes, mostInputs } from '../machine/limits.ts'; import type { RunState } from '../machine/run-state.ts'; -import { eventBytesOf, type RunEvent } from '../run-log/run-event.ts'; +import { eventBytesOf, type RunLogEvent } from '../run-log/run-event.ts'; import type { SessionResult } from '../runner/session.ts'; function boundError(title: string): DslError { return raised('runtime', 500, title, '/').error; } -export function brokenBound(state: RunState, event: RunEvent, result: SessionResult): DslError | undefined { +export function brokenBound(state: RunState, event: RunLogEvent, result: SessionResult): DslError | undefined { const bytes = eventBytesOf(event); if (bytes > mostEventBytes) { return boundError(`An input changed the run by ${bytes} bytes, more than the ${mostEventBytes} one event holds`); diff --git a/packages/workflow-engine/src/decider/run-endings.ts b/packages/workflow-engine/src/decider/run-endings.ts index 7c1a1ff5a..5b9d53f31 100644 --- a/packages/workflow-engine/src/decider/run-endings.ts +++ b/packages/workflow-engine/src/decider/run-endings.ts @@ -2,12 +2,12 @@ import type { DslError } from '../machine/dsl-error.ts'; import { inputTimeOf, receiptOf } from '../machine/input-receipt.ts'; import type { RunInput } from '../machine/run-input.ts'; import type { RunState } from '../machine/run-state.ts'; -import type { RunEvent } from '../run-log/run-event.ts'; +import type { RunLogEvent } from '../run-log/run-event.ts'; import type { MachineOptions } from '../runner/run-descriptors.ts'; import { sessionOf } from '../runner/session.ts'; import { eventOf } from './run-events.ts'; -export function endingEvent(state: RunState, options: MachineOptions, input: RunInput, error: DslError): RunEvent { +export function endingEvent(state: RunState, options: MachineOptions, input: RunInput, error: DslError): RunLogEvent { const at = inputTimeOf(state, input); const session = sessionOf(state, at, options); if (input.kind === 'started') { diff --git a/packages/workflow-engine/src/decider/run-events.test.ts b/packages/workflow-engine/src/decider/run-events.test.ts index a04d88809..f38c2368a 100644 --- a/packages/workflow-engine/src/decider/run-events.test.ts +++ b/packages/workflow-engine/src/decider/run-events.test.ts @@ -7,7 +7,7 @@ import { drivenRun, outputKindsIn } from '../testing/run-history.ts'; import { workflow } from '../testing/workflows.ts'; import { withoutUndone } from './run-events.ts'; -const executionId = '0199a3c4-7d2e-7c1a-9b3f-2f1e0d9c8b7a'; +const runId = '0199a3c4-7d2e-7c1a-9b3f-2f1e0d9c8b7a'; const inboxLists = /^\/inbox\/(?:receivedIds|waiting)(?:\/|$)/u; @@ -22,8 +22,8 @@ function declinedThenApproved(driver: MemoryDriver, running: string): void { describe('the outputs of an event', () => { it('leave out a listener the same input armed and cancelled, and keep one armed and cancelled apart', () => { - const key = { executionId, reference: '/do/0/await', run: 1 }; - const other = { executionId, reference: '/do/1/await', run: 1 }; + const key = { runId, reference: '/do/0/await', run: 1 }; + const other = { runId, reference: '/do/1/await', run: 1 }; expect( withoutUndone([ @@ -54,11 +54,11 @@ do: }); it('keep a cancel of what an earlier input armed or started', () => { - const key = { executionId, reference: '/do/0/ask', run: 1 }; + const key = { runId, reference: '/do/0/ask', run: 1 }; const outputs = [ - { kind: 'cancel_timer', executionId, timerId: '1' }, + { kind: 'cancel_timer', runId, timerId: '1' }, { kind: 'cancel_call', key }, - { kind: 'arm_timer', executionId, timerId: '2', dueAt: 1, purpose: 'wait' }, + { kind: 'arm_timer', runId, timerId: '2', dueAt: 1, purpose: 'wait' }, ] as const; expect(withoutUndone(outputs)).toEqual(outputs); diff --git a/packages/workflow-engine/src/decider/run-events.ts b/packages/workflow-engine/src/decider/run-events.ts index e9ceb26e4..8edf255bf 100644 --- a/packages/workflow-engine/src/decider/run-events.ts +++ b/packages/workflow-engine/src/decider/run-events.ts @@ -2,7 +2,7 @@ import type { RunOutput } from '../dispatch/run-output.ts'; import { callKeyText } from '../executor/call-key.ts'; import type { InputReceipt } from '../machine/input-receipt.ts'; import type { RunState } from '../machine/run-state.ts'; -import { withHistoryBytes, type RunEvent } from '../run-log/run-event.ts'; +import { withHistoryBytes, type RunLogEvent } from '../run-log/run-event.ts'; import { stateFormat } from '../run-log/state-format.ts'; import type { SessionResult } from '../runner/session.ts'; import { patchBetween } from './state-diff.ts'; @@ -29,7 +29,7 @@ export function withoutUndone(outputs: readonly RunOutput[]): readonly RunOutput return outputs.filter((output) => !undone.has(keyOf(output))); } -export function eventOf(state: RunState, result: SessionResult, receipt: InputReceipt): RunEvent { +export function eventOf(state: RunState, result: SessionResult, receipt: InputReceipt): RunLogEvent { return withHistoryBytes( { type: 'input_applied', diff --git a/packages/workflow-engine/src/decider/run-lifecycle.test.ts b/packages/workflow-engine/src/decider/run-lifecycle.test.ts index 48c441dd2..655213b70 100644 --- a/packages/workflow-engine/src/decider/run-lifecycle.test.ts +++ b/packages/workflow-engine/src/decider/run-lifecycle.test.ts @@ -3,7 +3,7 @@ import { describe, expect, it } from 'vitest'; import { mostValueDepth, type Json } from '../dsl/json.ts'; import { errorType } from '../dsl/raised-error.ts'; import { mostOutputBytes, mostStepsWithoutWaiting } from '../machine/limits.ts'; -import { armedTimersAlong, drivenExecutionId, drivenRun, outputKindsIn, outputsIn } from '../testing/run-history.ts'; +import { armedTimersAlong, drivenRunId, drivenRun, outputKindsIn, outputsIn } from '../testing/run-history.ts'; import { workflow } from '../testing/workflows.ts'; function nested(depth: number): Json { @@ -29,7 +29,7 @@ do: const run = drivenRun(workflow('do:\n - greet: { set: { done: true } }')); const settlement = { status: 'succeeded', output: { done: true } }; - expect(outputsIn(run.events)).toContainEqual({ kind: 'settle', executionId: drivenExecutionId, settlement }); + expect(outputsIn(run.events)).toContainEqual({ kind: 'settle', runId: drivenRunId, settlement }); expect(outputKindsIn(run.events).filter((kind) => kind === 'settle')).toHaveLength(1); expect(outputKindsIn(run.events.slice(-1)).at(-1)).toBe('settle'); }); @@ -49,7 +49,7 @@ do: const run = drivenRun(document, { limits: { mostDurationMs: 3_600_000 } }); expect(run.outcome).toEqual({ kind: 'overran', milliseconds: 3_600_000 }); - expect(run.driver.ports.recordStore.settlementOf(drivenExecutionId)).toEqual({ + expect(run.driver.ports.recordStore.settlementOf(drivenRunId)).toEqual({ status: 'rejected', reason: 'cancelled', kind: 'overrun', @@ -63,9 +63,9 @@ describe('a run that is cancelled or refused', () => { const cancel = { by: 'acme-admin', kind: 'requested', reason: 'Not needed any more' } as const; const run = drivenRun(workflow('do:\n - ask: { call: notify, with: { to: ada } }'), { respond: () => 'never', - meanwhile: (driver, executionId) => { + meanwhile: (driver, runId) => { driver.at(5, () => { - driver.cancel(executionId, cancel); + driver.cancel(runId, cancel); }); }, }); @@ -75,7 +75,7 @@ describe('a run that is cancelled or refused', () => { expect(outputKindsIn(run.events.slice(-1))).toEqual(['cancel_call', 'cancel_timer', 'cancel_timer', 'settle']); expect(run.events.at(-1)?.event.outputs[0]).toMatchObject({ kind: 'cancel_call', reason: 'parent_ended' }); expect(run.events.at(-1)?.event.receipt).toMatchObject({ cancel: { by: 'acme-admin', kind: 'requested' } }); - expect(run.driver.ports.recordStore.settlementOf(drivenExecutionId)).toEqual({ + expect(run.driver.ports.recordStore.settlementOf(drivenRunId)).toEqual({ status: 'rejected', reason: 'cancelled', kind: 'requested', @@ -106,7 +106,7 @@ describe('a run that is cancelled or refused', () => { }); describe('a run that does too much', () => { - it('ends oversized when its output is larger than an execution records', () => { + it('ends oversized when its output is larger than a run records', () => { const run = drivenRun(workflow("do:\n - big: { set: '${ { text: .text } }' }"), { input: { text: 'x'.repeat(mostOutputBytes) }, }); diff --git a/packages/workflow-engine/src/decider/run-lifecycle.ts b/packages/workflow-engine/src/decider/run-lifecycle.ts index 21d94f4ff..43914b68d 100644 --- a/packages/workflow-engine/src/decider/run-lifecycle.ts +++ b/packages/workflow-engine/src/decider/run-lifecycle.ts @@ -109,7 +109,7 @@ export function startRun(machine: Machine, started: Started): void { const { session } = machine; const input = session.hold(started.input); session.begin({ - executionId: started.executionId, + runId: started.runId, document: started.document, input, limits: started.limits, diff --git a/packages/workflow-engine/src/decider/workflow-machine.test.ts b/packages/workflow-engine/src/decider/workflow-machine.test.ts index d1dd5a175..7b4a050ff 100644 --- a/packages/workflow-engine/src/decider/workflow-machine.test.ts +++ b/packages/workflow-engine/src/decider/workflow-machine.test.ts @@ -7,7 +7,7 @@ import { describe, expect, it } from 'vitest'; import { newRun } from '../machine/run-state.ts'; import { evolveRun, stateInCurrentFormat } from '../run-log/run-fold.ts'; import { startedOf, testMachine } from '../testing/driver-inputs.ts'; -import { drivenExecutionId as executionId } from '../testing/run-history.ts'; +import { drivenRunId as runId } from '../testing/run-history.ts'; import { workflow } from '../testing/workflows.ts'; import { workflowMachine } from './workflow-machine.ts'; @@ -24,7 +24,7 @@ const corpusState = stateInCurrentFormat( describe('the workflow machine', () => { it('decides the same events for the same state and input', () => { const machine = workflowMachine(testMachine); - const input = startedOf({ executionId, document: calling }, 1_790_845_200_000); + const input = startedOf({ runId, document: calling }, 1_790_845_200_000); expect(machine.decide(input, newRun)).toEqual(machine.decide(input, newRun)); }); @@ -40,16 +40,14 @@ describe('the workflow machine', () => { }, }); - expect(() => machine.decide(startedOf({ executionId, document: calling }, 0), newRun)).toThrow( - 'The description broke', - ); + expect(() => machine.decide(startedOf({ runId, document: calling }, 0), newRun)).toThrow('The description broke'); }); it('applies an input to a run upcast from the format-1 corpus, which has no frame, without stepping a task', () => { const machine = workflowMachine(testMachine); const input = { kind: 'event_received', - executionId: corpusState.executionId, + runId: corpusState.runId, at: corpusState.lastInputAt + 1, event: { id: 'corpus-event', type: 'com.acme.tick' }, } as const; diff --git a/packages/workflow-engine/src/decider/workflow-machine.ts b/packages/workflow-engine/src/decider/workflow-machine.ts index 7867ce75c..bcf9f3ee5 100644 --- a/packages/workflow-engine/src/decider/workflow-machine.ts +++ b/packages/workflow-engine/src/decider/workflow-machine.ts @@ -2,7 +2,7 @@ import { Result } from 'effect'; import type { RunDecider } from '../machine/run-decider.ts'; import { newRun } from '../machine/run-state.ts'; -import { RunEventSchema } from '../run-log/run-event.ts'; +import { RunLogEventSchema } from '../run-log/run-event.ts'; import { evolveRun } from '../run-log/run-fold.ts'; import type { MachineOptions } from '../runner/run-descriptors.ts'; import { decided } from './decision.ts'; @@ -11,7 +11,7 @@ export function workflowMachine(options: MachineOptions): RunDecider { return { initialState: newRun, evolve: evolveRun, - eventSchema: RunEventSchema, + eventSchema: RunLogEventSchema, decide: (input, state) => Result.succeed(decided(options, input, state)), }; } diff --git a/packages/workflow-engine/src/dispatch/dispatch-watermark.test.ts b/packages/workflow-engine/src/dispatch/dispatch-watermark.test.ts index 55c2594e3..703d404fd 100644 --- a/packages/workflow-engine/src/dispatch/dispatch-watermark.test.ts +++ b/packages/workflow-engine/src/dispatch/dispatch-watermark.test.ts @@ -8,19 +8,25 @@ import { type PositionedEvent, type RunOutput, } from '../index.ts'; -import { at, executionId, openCall } from '../testing/runs.ts'; +import { at, runId, openCall } from '../testing/runs.ts'; const arm: RunOutput = { kind: 'arm_timer', - executionId, + runId, timerId: '1', dueAt: at + 60_000, purpose: 'timeout', }; -const start: RunOutput = { kind: 'start_call', key: openCall, function: 'executeSpec', arguments: {}, longestMs: 600 }; +const start: RunOutput = { + kind: 'start_call', + key: openCall, + function: 'runDefinition', + arguments: {}, + longestMs: 600, +}; -const settle: RunOutput = { kind: 'settle', executionId, settlement: { status: 'failed' } }; +const settle: RunOutput = { kind: 'settle', runId, settlement: { status: 'failed' } }; function applied(version: number, outputs: readonly RunOutput[]): PositionedEvent { return { diff --git a/packages/workflow-engine/src/dispatch/dispatch-watermark.ts b/packages/workflow-engine/src/dispatch/dispatch-watermark.ts index 87999f4ea..46c9759cd 100644 --- a/packages/workflow-engine/src/dispatch/dispatch-watermark.ts +++ b/packages/workflow-engine/src/dispatch/dispatch-watermark.ts @@ -5,13 +5,13 @@ import type { StepKey } from '../steps/step-entry.ts'; import type { RunOutput } from './run-output.ts'; export interface DispatchWatermark { - readonly read: (executionId: string) => Effect.Effect; - readonly advance: (executionId: string, through: number) => Effect.Effect; + readonly read: (runId: string) => Effect.Effect; + readonly advance: (runId: string, through: number) => Effect.Effect; readonly behindRuns: (limit: number) => Effect.Effect; } export interface RunContext { - readonly executionId: string; + readonly runId: string; readonly attributes: Schema.JsonObject; } diff --git a/packages/workflow-engine/src/dispatch/run-due.test.ts b/packages/workflow-engine/src/dispatch/run-due.test.ts index 78f2a4624..740b60430 100644 --- a/packages/workflow-engine/src/dispatch/run-due.test.ts +++ b/packages/workflow-engine/src/dispatch/run-due.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from 'vitest'; import { changesTimers, isTroubling, nextDueAtOf, runDueOf } from '../index.ts'; -import { at, executionId, runningState } from '../testing/runs.ts'; +import { at, runId, runningState } from '../testing/runs.ts'; import { exampleStream, streamOf } from '../testing/streams.ts'; describe('the next time a run is due', () => { @@ -11,15 +11,15 @@ describe('the next time a run is due', () => { }); it('is noted in the record by version', () => { - expect(runDueOf(runningState, 7)).toEqual({ executionId, version: 7, nextDueAt: at + 60_000 }); + expect(runDueOf(runningState, 7)).toEqual({ runId, version: 7, nextDueAt: at + 60_000 }); }); it('changes only with an event that arms, cancels or fires a timer', () => { const cancelling = streamOf([ { - receipt: { kind: 'cancel_requested', key: executionId, at }, + receipt: { kind: 'cancel_requested', key: runId, at }, patch: [], - outputs: [{ kind: 'cancel_timer', executionId, timerId: '1' }], + outputs: [{ kind: 'cancel_timer', runId, timerId: '1' }], }, ]); @@ -28,9 +28,9 @@ describe('the next time a run is due', () => { }); describe('a settle receipt', () => { - it('is troubling when the record was settled otherwise or names no execution, and is then reported', () => { + it('is troubling when the record was settled otherwise or names no run, and is then reported', () => { expect( - (['recorded', 'already_recorded', 'settled_otherwise', 'unknown_execution'] as const).map((receipt) => + (['recorded', 'already_recorded', 'settled_otherwise', 'unknown_run'] as const).map((receipt) => isTroubling(receipt), ), ).toEqual([false, false, true, true]); diff --git a/packages/workflow-engine/src/dispatch/run-due.ts b/packages/workflow-engine/src/dispatch/run-due.ts index c0001b833..a6d410b32 100644 --- a/packages/workflow-engine/src/dispatch/run-due.ts +++ b/packages/workflow-engine/src/dispatch/run-due.ts @@ -15,5 +15,5 @@ export function changesTimers({ event }: PositionedEvent): boolean { } export function runDueOf(state: RunState, version: number): RunDue { - return { executionId: state.executionId, version, nextDueAt: nextDueAtOf(state) }; + return { runId: state.runId, version, nextDueAt: nextDueAtOf(state) }; } diff --git a/packages/workflow-engine/src/dispatch/run-output.ts b/packages/workflow-engine/src/dispatch/run-output.ts index 9c58aae92..2a6076cbf 100644 --- a/packages/workflow-engine/src/dispatch/run-output.ts +++ b/packages/workflow-engine/src/dispatch/run-output.ts @@ -5,11 +5,11 @@ import { CallKeySchema } from '../executor/call-key.ts'; import { InstantSchema } from '../machine/instant.ts'; import { TimerPurposeSchema } from '../timers/timer-id.ts'; -const ExecutionIdSchema = Schema.NonEmptyString; +const RunIdSchema = Schema.NonEmptyString; const ArmTimerSchema = Schema.Struct({ kind: Schema.Literal('arm_timer'), - executionId: ExecutionIdSchema, + runId: RunIdSchema, timerId: Schema.NonEmptyString, dueAt: InstantSchema, purpose: TimerPurposeSchema, @@ -18,7 +18,7 @@ const ArmTimerSchema = Schema.Struct({ const CancelTimerSchema = Schema.Struct({ kind: Schema.Literal('cancel_timer'), - executionId: ExecutionIdSchema, + runId: RunIdSchema, timerId: Schema.NonEmptyString, }); @@ -57,7 +57,7 @@ const EmitEventSchema = Schema.Struct({ const SettleSchema = Schema.Struct({ kind: Schema.Literal('settle'), - executionId: ExecutionIdSchema, + runId: RunIdSchema, settlement: SettlementSchema, }); diff --git a/packages/workflow-engine/src/dsl/raised-error.test.ts b/packages/workflow-engine/src/dsl/raised-error.test.ts index 6086f0beb..074b54706 100644 --- a/packages/workflow-engine/src/dsl/raised-error.test.ts +++ b/packages/workflow-engine/src/dsl/raised-error.test.ts @@ -30,13 +30,13 @@ describe('the rejection of an uncaught error of a kind with a type of its own', { status: 'rejected', reason: 'conflict', - detail: 'The function notify rejected the execution with conflict: Called before (at /do/0/ask)', + detail: 'The function notify rejected the run with conflict: Called before (at /do/0/ask)', kind: 'tools_called', }, { status: 'rejected', reason: 'unavailable', - detail: 'The function notify rejected the execution with unavailable: Stopped (at /do/0/ask)', + detail: 'The function notify rejected the run with unavailable: Stopped (at /do/0/ask)', kind: 'tools_unfinished', because: 'run_bound', }, @@ -54,7 +54,7 @@ describe('the error of a call that did not succeed', () => { expect(callErrorOf({ status: 'rejected', reason: 'teapot', detail: 'short and stout' }, site)).toEqual({ type: errorType('runtime'), status: 500, - title: 'The function notify rejected the execution with teapot', + title: 'The function notify rejected the run with teapot', detail: 'short and stout', instance: '/do/0/ask', }); @@ -72,7 +72,7 @@ describe('the error of a call that did not succeed', () => { expect(errorAsJson(callErrorOf(rejected, site))).toEqual({ type: 'https://on.auto/problems/tools_unfinished', status: 503, - title: 'The function notify rejected the execution with unavailable', + title: 'The function notify rejected the run with unavailable', detail: 'A tool server kept failing', instance: '/do/0/ask', kind: 'tools_unfinished', @@ -100,7 +100,7 @@ describe('the type of the error of a call rejected with a kind', () => { { type: 'https://on.auto/problems/tools_called', status: 409, - title: 'The function notify rejected the execution with conflict', + title: 'The function notify rejected the run with conflict', detail: 'No', instance: '/do/0/ask', kind: 'tools_called', @@ -118,7 +118,7 @@ describe('the error of a call whose run was cancelled', () => { ).toEqual({ type: 'https://on.auto/problems/cancelled', status: 409, - title: 'The function notify rejected the execution with cancelled', + title: 'The function notify rejected the run with cancelled', detail: 'Out of time', instance: '/do/0/ask', kind: 'deadline', @@ -131,12 +131,12 @@ describe('the error of a call whose run was cancelled', () => { expect(settlementOf({ kind: 'raised', error })).toEqual({ status: 'rejected', reason: 'invalid_input', - detail: 'The function notify rejected the execution with cancelled: Out (at /do/0/ask)', + detail: 'The function notify rejected the run with cancelled: Out (at /do/0/ask)', }); expect(settlementOf({ kind: 'raised', error: { ...error, kind: 'overrun' } })).toEqual({ status: 'rejected', reason: 'invalid_input', - detail: 'The function notify rejected the execution with cancelled: Out (at /do/0/ask)', + detail: 'The function notify rejected the run with cancelled: Out (at /do/0/ask)', }); }); }); @@ -152,7 +152,7 @@ describe('the error of a call whose request went unanswered', () => { expect(expired).toEqual({ type: 'https://on.auto/problems/unanswered', status: 410, - title: 'The function approve rejected the execution with unanswered', + title: 'The function approve rejected the run with unanswered', detail: 'Nobody answered', instance: '/do/0/ask', kind: 'expired', @@ -169,18 +169,18 @@ describe('the error of a call whose request went unanswered', () => { status: 'rejected', reason: 'unanswered', kind: 'expired', - detail: 'The function approve rejected the execution with unanswered: Nobody answered (at /do/0/ask)', + detail: 'The function approve rejected the run with unanswered: Nobody answered (at /do/0/ask)', }, { status: 'rejected', reason: 'unanswered', kind: 'undelivered', - detail: 'The function approve rejected the execution with unanswered: Nobody answered (at /do/0/ask)', + detail: 'The function approve rejected the run with unanswered: Nobody answered (at /do/0/ask)', }, { status: 'rejected', reason: 'invalid_input', - detail: 'The function approve rejected the execution with unanswered: Nobody answered (at /do/0/ask)', + detail: 'The function approve rejected the run with unanswered: Nobody answered (at /do/0/ask)', }, ]); }); @@ -239,7 +239,7 @@ describe('the settlement of a workflow whose output or run broke', () => { expect(settlementOf({ kind: 'raised', error: rebuilding })).toEqual({ status: 'rejected', reason: 'unavailable', - detail: 'The function notify rejected the execution with unavailable: Still building (at /do/0/ask)', + detail: 'The function notify rejected the run with unavailable: Still building (at /do/0/ask)', kind: 'rebuilding', }); }); diff --git a/packages/workflow-engine/src/dsl/raised-error.ts b/packages/workflow-engine/src/dsl/raised-error.ts index 20c1f7d6b..65e4d241b 100644 --- a/packages/workflow-engine/src/dsl/raised-error.ts +++ b/packages/workflow-engine/src/dsl/raised-error.ts @@ -233,7 +233,7 @@ export function callErrorOf(result: FailedCall, call: CallSite): DslError { status, title: result.status === 'rejected' - ? `${capitalized(call.label)} rejected the execution with ${result.reason}` + ? `${capitalized(call.label)} rejected the run with ${result.reason}` : `${capitalized(call.label)} failed`, detail: result.detail, instance: call.reference, diff --git a/packages/workflow-engine/src/engine/ended-dispatch.test.ts b/packages/workflow-engine/src/engine/ended-dispatch.test.ts index f8b17cd73..262866938 100644 --- a/packages/workflow-engine/src/engine/ended-dispatch.test.ts +++ b/packages/workflow-engine/src/engine/ended-dispatch.test.ts @@ -4,18 +4,18 @@ import { describe, expect, it } from 'vitest'; import { memoryDriver } from '../testing/memory-driver.ts'; import { workflow } from '../testing/workflows.ts'; -const executionId = '0199a3c4-7d2e-7c1a-9b3f-000000000071'; +const runId = '0199a3c4-7d2e-7c1a-9b3f-000000000071'; const asking = workflow('do:\n - ask: { call: notify, with: { to: ada } }'); function cancelledWhileItAsks(failing: 'cancel_call' | 'settle') { const driver = memoryDriver({ respond: () => 'never' }); - driver.start({ executionId, document: asking }); + driver.start({ runId, document: asking }); driver.ports.faults.failNext(failing); - driver.cancel(executionId); + driver.cancel(runId); return { - watermark: Effect.runSync(driver.ports.watermark.read(executionId)), - settled: driver.ports.recordStore.settlementOf(executionId), + watermark: Effect.runSync(driver.ports.watermark.read(runId)), + settled: driver.ports.recordStore.settlementOf(runId), }; } @@ -27,12 +27,12 @@ describe('the outputs of a run that has ended', () => { it('pass a start of a call that fails, which nothing can use once the run has ended', () => { const driver = memoryDriver({ respond: () => 'never' }); driver.ports.faults.failNext('start_call'); - driver.start({ executionId, document: asking }); + driver.start({ runId, document: asking }); driver.ports.faults.failNext('start_call'); - driver.cancel(executionId); + driver.cancel(runId); - expect(Effect.runSync(driver.ports.watermark.read(executionId))).toBe(2); - expect(driver.ports.recordStore.settlementOf(executionId)).toMatchObject({ status: 'rejected' }); + expect(Effect.runSync(driver.ports.watermark.read(runId))).toBe(2); + expect(driver.ports.recordStore.settlementOf(runId)).toMatchObject({ status: 'rejected' }); }); it('stop at a settlement that fails, which is never passed', () => { @@ -44,9 +44,9 @@ describe('the outputs of a run still going', () => { it('stop at a start of a call that fails, which is dispatched again', () => { const driver = memoryDriver({ respond: () => 'never' }); driver.ports.faults.failNext('start_call'); - driver.start({ executionId, document: asking }); + driver.start({ runId, document: asking }); - expect(Effect.runSync(driver.ports.watermark.read(executionId))).toBe(0); - expect(Effect.runSync(driver.engine.wake(executionId))).toEqual({ version: 1, dispatchedThrough: 1 }); + expect(Effect.runSync(driver.ports.watermark.read(runId))).toBe(0); + expect(Effect.runSync(driver.engine.wake(runId))).toEqual({ version: 1, dispatchedThrough: 1 }); }); }); diff --git a/packages/workflow-engine/src/engine/engine-ports.ts b/packages/workflow-engine/src/engine/engine-ports.ts index a388dcc38..dd1e0db61 100644 --- a/packages/workflow-engine/src/engine/engine-ports.ts +++ b/packages/workflow-engine/src/engine/engine-ports.ts @@ -1,13 +1,13 @@ import type { DispatchWatermark } from '../dispatch/dispatch-watermark.ts'; import type { Executor } from '../executor/executor.ts'; import type { Emitter, Listeners } from '../reactions/reaction-ports.ts'; -import type { RunStore } from '../run-log/run-store.ts'; +import type { RunLogStore } from '../run-log/run-store.ts'; import type { RunSerialiser } from '../serialisation/run-serialiser.ts'; import type { RecordStore, RunReporter } from '../settlement/record-store.ts'; import type { Timers } from '../timers/timers.ts'; export interface EnginePorts { - readonly runStore: RunStore; + readonly runStore: RunLogStore; readonly watermark: DispatchWatermark; readonly timers: Timers; readonly executor: Executor; diff --git a/packages/workflow-engine/src/engine/engine.test.ts b/packages/workflow-engine/src/engine/engine.test.ts index 224e6881c..2731d6ca2 100644 --- a/packages/workflow-engine/src/engine/engine.test.ts +++ b/packages/workflow-engine/src/engine/engine.test.ts @@ -4,7 +4,7 @@ import { describe, expect, it } from 'vitest'; import { loadedRunOf } from '../run-log/run-fold.ts'; import { startedOf } from '../testing/driver-inputs.ts'; import { memoryDriver, type MemoryDriver } from '../testing/memory-driver.ts'; -import { drivenExecutionId as executionId, outputKindsIn, statesAlong } from '../testing/run-history.ts'; +import { drivenRunId as runId, outputKindsIn, statesAlong } from '../testing/run-history.ts'; import { workflow } from '../testing/workflows.ts'; const answeringLarge = () => ({ after: 1000, result: { status: 'succeeded', output: 'x'.repeat(300_000) } }) as const; @@ -19,7 +19,7 @@ do: `); function snapshotVersionOf(driver: MemoryDriver): number { - return driver.ports.runStore.snapshotOf(executionId)?.snapshot.version ?? 0; + return driver.ports.runStore.snapshotOf(runId)?.snapshot.version ?? 0; } function untilTheFirstSnapshot(driver: MemoryDriver): number { @@ -31,11 +31,11 @@ function untilTheFirstSnapshot(driver: MemoryDriver): number { describe('the snapshots of a run', () => { it('are saved once the events since the last take as many bytes, and give the state its whole stream gives', () => { const driver = memoryDriver({ respond: answeringLarge }); - driver.start({ executionId, document: callingInALoop }); + driver.start({ runId, document: callingInALoop }); - const ended = driver.runUntilEnded(executionId); - const events = driver.ports.runStore.events(executionId); - const fromSnapshot = loadedRunOf(Effect.runSync(driver.ports.runStore.load(executionId))); + const ended = driver.runUntilEnded(runId); + const events = driver.ports.runStore.events(runId); + const fromSnapshot = loadedRunOf(Effect.runSync(driver.ports.runStore.load(runId))); expect(ended.outcome).toEqual({ kind: 'completed', output: { done: true } }); expect(snapshotVersionOf(driver)).toBeGreaterThan(1); @@ -46,12 +46,12 @@ describe('the snapshots of a run', () => { it('let the input that follows one be decided from it alone', () => { const driver = memoryDriver({ respond: answeringLarge }); - driver.start({ executionId, document: callingInALoop }); + driver.start({ runId, document: callingInALoop }); const snapshotAt = untilTheFirstSnapshot(driver); - expect(Effect.runSync(driver.ports.runStore.load(executionId)).tail).toEqual([]); - expect(driver.runUntilEnded(executionId).outcome).toEqual({ kind: 'completed', output: { done: true } }); - expect(driver.ports.runStore.events(executionId).length).toBeGreaterThan(snapshotAt); + expect(Effect.runSync(driver.ports.runStore.load(runId)).tail).toEqual([]); + expect(driver.runUntilEnded(runId).outcome).toEqual({ kind: 'completed', output: { done: true } }); + expect(driver.ports.runStore.events(runId).length).toBeGreaterThan(snapshotAt); }); }); @@ -67,34 +67,32 @@ do: `); const driver = memoryDriver(); driver.ports.faults.failNext('arm_timer'); - driver.start({ executionId, document }); + driver.start({ runId, document }); const dispatchedBefore = driver.ports.faults.dispatched().length; - driver.deliver(executionId, { id: 'e1', type: 'go' }); + driver.deliver(runId, { id: 'e1', type: 'go' }); expect(dispatchedBefore).toBe(0); - expect(driver.runUntilEnded(executionId).outcome).toEqual({ kind: 'completed', output: [{}, [null]] }); + expect(driver.runUntilEnded(runId).outcome).toEqual({ kind: 'completed', output: [{}, [null]] }); }); it('settles again, when woken, a settlement whose dispatch failed', () => { const driver = memoryDriver(); driver.ports.faults.failNext('settle'); - driver.start({ executionId, document: workflow('do:\n - greet: { set: { done: true } }') }); - const before = driver.ports.recordStore.settlementOf(executionId); + driver.start({ runId, document: workflow('do:\n - greet: { set: { done: true } }') }); + const before = driver.ports.recordStore.settlementOf(runId); - const woken = Effect.runSync(driver.engine.wake(executionId)); + const woken = Effect.runSync(driver.engine.wake(runId)); expect(before).toBeUndefined(); expect(woken).toEqual({ version: 1, dispatchedThrough: 1 }); - expect(driver.ports.recordStore.settlementOf(executionId)).toEqual({ status: 'succeeded', output: { done: true } }); + expect(driver.ports.recordStore.settlementOf(runId)).toEqual({ status: 'succeeded', output: { done: true } }); }); it('reports a settle receipt that troubles, and does not drop it', () => { const driver = memoryDriver(); - driver.submit(startedOf({ executionId, document: workflow('do:\n - greet: { set: { done: true } }') }, 0)); + driver.submit(startedOf({ runId, document: workflow('do:\n - greet: { set: { done: true } }') }, 0)); - expect(driver.ports.reporter.reports()).toEqual([ - { run: { executionId, attributes: {} }, receipt: 'unknown_execution' }, - ]); - expect(outputKindsIn(driver.ports.runStore.events(executionId))).toEqual(['settle']); + expect(driver.ports.reporter.reports()).toEqual([{ run: { runId, attributes: {} }, receipt: 'unknown_run' }]); + expect(outputKindsIn(driver.ports.runStore.events(runId))).toEqual(['settle']); }); }); diff --git a/packages/workflow-engine/src/engine/engine.ts b/packages/workflow-engine/src/engine/engine.ts index c0564a942..900699fca 100644 --- a/packages/workflow-engine/src/engine/engine.ts +++ b/packages/workflow-engine/src/engine/engine.ts @@ -26,32 +26,32 @@ export function workflowEngineOf( cache: RunCache = runCacheOf(), ): WorkflowEngine { const loop = runLoopOf(ports.runStore, workflowMachine(options), cache); - const loaded = (executionId: string) => loadedFrom(ports, executionId); - const wake = (executionId: string): Effect.Effect => + const loaded = (runId: string) => loadedFrom(ports, runId); + const wake = (runId: string): Effect.Effect => ports.serialiser.serialise( - executionId, - Effect.flatMap(loaded(executionId), ({ state, version }) => dispatchRun(ports, { executionId, state, version })), + runId, + Effect.flatMap(loaded(runId), ({ state, version }) => dispatchRun(ports, { runId, state, version })), ); - const swept = (executionId: string): Effect.Effect => + const swept = (runId: string): Effect.Effect => ports.serialiser.serialise( - executionId, + runId, Effect.gen(function* () { - const { state, version } = yield* loaded(executionId); - yield* dispatchRun(ports, { executionId, state, version }); - const run = { executionId, attributes: state.attributes }; - return yield* Effect.orElseSucceed(ports.timers.sweep(run, armedTimersOf(executionId, state)), () => 0); + const { state, version } = yield* loaded(runId); + yield* dispatchRun(ports, { runId, state, version }); + const run = { runId, attributes: state.attributes }; + return yield* Effect.orElseSucceed(ports.timers.sweep(run, armedTimersOf(runId, state)), () => 0); }), ); return { submit: (input) => ports.serialiser.serialise( - input.executionId, + input.runId, Effect.gen(function* () { - const decision = yield* loop(input.executionId, input); + const decision = yield* loop(input.runId, input); if (decision.events.length > 0) { - yield* snapshotIfDue(ports, cache, input.executionId, decision); + yield* snapshotIfDue(ports, cache, input.runId, decision); yield* dispatchRun(ports, { - executionId: input.executionId, + runId: input.runId, state: decision.state, version: decision.version, }); @@ -63,7 +63,7 @@ export function workflowEngineOf( sweep: (before) => Effect.gen(function* () { const due = yield* dueRunsOf(ports, before); - const armedAgain = yield* Effect.forEach(due, (executionId) => swept(executionId)); + const armedAgain = yield* Effect.forEach(due, (runId) => swept(runId)); return { runs: due.length, timersArmedAgain: armedAgain.reduce((sum, count) => sum + count, 0) }; }), }; diff --git a/packages/workflow-engine/src/engine/long-run.test.ts b/packages/workflow-engine/src/engine/long-run.test.ts index 5747346c1..b217967b6 100644 --- a/packages/workflow-engine/src/engine/long-run.test.ts +++ b/packages/workflow-engine/src/engine/long-run.test.ts @@ -9,7 +9,7 @@ import { workflow } from '../testing/workflows.ts'; const inputs = 3000; -const executionId = '0199a3c4-7d2e-7c1a-9b3f-000000003000'; +const runId = '0199a3c4-7d2e-7c1a-9b3f-000000003000'; const ticking = workflow(` do: @@ -21,10 +21,10 @@ do: describe(`a run of ${inputs} inputs`, () => { it('resumes from its last snapshot and the events after it alone, to the state its whole stream folds to', () => { const driver = memoryDriver(); - driver.start({ executionId, document: ticking }); - driver.runUntilEnded(executionId); - const events = driver.ports.runStore.events(executionId); - const stored = Effect.runSync(driver.ports.runStore.load(executionId)); + driver.start({ runId, document: ticking }); + driver.runUntilEnded(runId); + const events = driver.ports.runStore.events(runId); + const stored = Effect.runSync(driver.ports.runStore.load(runId)); const resumed = loadedRunOf(stored); diff --git a/packages/workflow-engine/src/engine/output-dispatch.ts b/packages/workflow-engine/src/engine/output-dispatch.ts index cb8c6420c..9fb414a4b 100644 --- a/packages/workflow-engine/src/engine/output-dispatch.ts +++ b/packages/workflow-engine/src/engine/output-dispatch.ts @@ -9,7 +9,7 @@ import { import { changesTimers, runDueOf } from '../dispatch/run-due.ts'; import type { RunOutput } from '../dispatch/run-output.ts'; import type { RunState } from '../machine/run-state.ts'; -import type { PositionedEvent, RunEvent } from '../run-log/run-event.ts'; +import type { PositionedEvent, RunLogEvent } from '../run-log/run-event.ts'; import { isTroubling } from '../settlement/record-store.ts'; import { isRecordedStep, keyOf } from '../steps/step-entry.ts'; import type { EnginePorts } from './engine-ports.ts'; @@ -43,11 +43,11 @@ function performed( return ports.emitter.emit(output, run, origin); } return ports.recordStore - .settle({ executionId: output.executionId, settlement: output.settlement }, run, origin) + .settle({ runId: output.runId, settlement: output.settlement }, run, origin) .pipe(Effect.tap((receipt) => (isTroubling(receipt) ? ports.reporter.unsettled({ run, receipt }) : Effect.void))); } -function originOf(version: number, { steps }: RunEvent): OutputOrigin { +function originOf(version: number, { steps }: RunLogEvent): OutputOrigin { const last = steps.at(-1); return { version, lastStep: last !== undefined && isRecordedStep(last) ? keyOf(last) : null }; } @@ -77,27 +77,27 @@ function firstFailureIn( } export interface LoadedForDispatch { - readonly executionId: string; + readonly runId: string; readonly state: RunState; readonly version: number; } function notedDue(ports: EnginePorts, run: RunContext, loaded: LoadedForDispatch): Effect.Effect { - const due = { ...runDueOf(loaded.state, loaded.version), executionId: loaded.executionId }; + const due = { ...runDueOf(loaded.state, loaded.version), runId: loaded.runId }; return Effect.match(ports.recordStore.noteDue(due, run), { onFailure: () => false, onSuccess: () => true }); } export function dispatchRun(ports: EnginePorts, loaded: LoadedForDispatch): Effect.Effect { - const { executionId, state, version } = loaded; - const run: RunContext = { executionId, attributes: state.attributes }; + const { runId, state, version } = loaded; + const run: RunContext = { runId, attributes: state.attributes }; return Effect.gen(function* () { - const watermark = yield* ports.watermark.read(executionId); - const events = yield* ports.runStore.eventsAfter(executionId, watermark); + const watermark = yield* ports.watermark.read(runId); + const events = yield* ports.runStore.eventsAfter(runId, watermark); const failed = yield* firstFailureIn(ports, run, events, state.status === 'ended'); const changed = failed !== null || events.some((event) => changesTimers(event)); const noted = changed ? yield* notedDue(ports, run, loaded) : true; const through = noted ? dispatchedThrough(watermark, events, failed ?? undefined) : watermark; - yield* ports.watermark.advance(executionId, through); + yield* ports.watermark.advance(runId, through); return { version, dispatchedThrough: through }; }); } diff --git a/packages/workflow-engine/src/engine/record-lineage.ts b/packages/workflow-engine/src/engine/record-lineage.ts index 445760b58..39732f8f9 100644 --- a/packages/workflow-engine/src/engine/record-lineage.ts +++ b/packages/workflow-engine/src/engine/record-lineage.ts @@ -1,9 +1,9 @@ import type { RunInput } from '../machine/run-input.ts'; import type { RunState } from '../machine/run-state.ts'; -import type { RunEvent } from '../run-log/run-event.ts'; +import type { RunLogEvent } from '../run-log/run-event.ts'; import type { RecordCause, RecordLineage } from '../run-log/run-store.ts'; -function causeOf(input: RunInput, { resumed }: RunEvent): RecordCause { +function causeOf(input: RunInput, { resumed }: RunLogEvent): RecordCause { if (input.kind === 'started') { return { kind: 'start' }; } @@ -16,7 +16,7 @@ function causeOf(input: RunInput, { resumed }: RunEvent): RecordCause { return input.kind === 'timer_fired' ? { kind: 'timer', timerId: input.timerId } : { kind: 'none' }; } -export function recordLineageOf(input: RunInput, state: RunState, event: RunEvent): RecordLineage { +export function recordLineageOf(input: RunInput, state: RunState, event: RunLogEvent): RecordLineage { return { cause: causeOf(input, event), attributes: input.kind === 'started' ? input.attributes : state.attributes, diff --git a/packages/workflow-engine/src/engine/run-loop.test.ts b/packages/workflow-engine/src/engine/run-loop.test.ts index 3169a74d8..f61ab967e 100644 --- a/packages/workflow-engine/src/engine/run-loop.test.ts +++ b/packages/workflow-engine/src/engine/run-loop.test.ts @@ -14,12 +14,12 @@ import { import { memoryRunStore } from '../memory/run-store.ts'; import { countingDecider } from '../testing/counting-decider.ts'; import { testCancel } from '../testing/driver-inputs.ts'; -import { at, executionId, runningState, started } from '../testing/runs.ts'; +import { at, runId, runningState, started } from '../testing/runs.ts'; -const cancelled: RunInput = { kind: 'cancel_requested', executionId, at: at + 1, cancel: testCancel }; +const cancelled: RunInput = { kind: 'cancel_requested', runId, at: at + 1, cancel: testCancel }; function submitted(store: ReturnType, input: RunInput) { - return runLoopOf(store, countingDecider)(input.executionId, input).pipe( + return runLoopOf(store, countingDecider)(input.runId, input).pipe( Effect.map((decided) => submissionOf(decided, input)), ); } @@ -37,22 +37,22 @@ describe('the engine on the ledger loop', () => { { outcome: 'applied', version: 2 }, { outcome: 'stale', version: 2 }, ]); - expect(store.events(executionId).map(({ version }) => version)).toEqual([1, 2]); + expect(store.events(runId).map(({ version }) => version)).toEqual([1, 2]); }); it('answers not_started for an input to a run whose start has not arrived, and appends nothing', async () => { const store = memoryRunStore(); expect(await Effect.runPromise(submitted(store, cancelled))).toEqual({ outcome: 'not_started', version: 0 }); - expect(store.events(executionId)).toEqual([]); + expect(store.events(runId)).toEqual([]); }); it('counts the bytes of every event it appends in the state it folds', async () => { const store = memoryRunStore(); - const { state } = await Effect.runPromise(runLoopOf(store, countingDecider)(executionId, started)); + const { state } = await Effect.runPromise(runLoopOf(store, countingDecider)(runId, started)); - expect(state.historyBytes).toBe(store.events(executionId).reduce((sum, { event }) => sum + eventBytesOf(event), 0)); + expect(state.historyBytes).toBe(store.events(runId).reduce((sum, { event }) => sum + eventBytesOf(event), 0)); }); it('loads and decides again after a version conflict, and fails with the ledger Conflict after three more', async () => { @@ -76,10 +76,10 @@ describe('a decision', () => { decide: (input, state) => Result.map(countingDecider.decide(input, state), (events) => [...events, ...events]), }; - const exit = await Effect.runPromise(Effect.exit(runLoopOf(store, twice)(executionId, started))); + const exit = await Effect.runPromise(Effect.exit(runLoopOf(store, twice)(runId, started))); - expect(exit).toEqual(Exit.die(new SplitDecision({ executionId, events: 2 }))); - expect(store.events(executionId)).toEqual([]); + expect(exit).toEqual(Exit.die(new SplitDecision({ runId, events: 2 }))); + expect(store.events(runId)).toEqual([]); }); }); @@ -88,7 +88,7 @@ describe('the run store the tests use', () => { const store = memoryRunStore(); await Effect.runPromise(Effect.all([submitted(store, started), submitted(store, cancelled)])); - const after = await Effect.runPromise(store.eventsAfter(executionId, 1)); + const after = await Effect.runPromise(store.eventsAfter(runId, 1)); expect(after.map(({ version }) => version)).toEqual([2]); await Effect.runPromise(store.saveSnapshot(snapshotOf(runningState, 2))); diff --git a/packages/workflow-engine/src/engine/run-loop.ts b/packages/workflow-engine/src/engine/run-loop.ts index 3aa222050..c3a2a3e6d 100644 --- a/packages/workflow-engine/src/engine/run-loop.ts +++ b/packages/workflow-engine/src/engine/run-loop.ts @@ -5,45 +5,45 @@ import { cachedLoadOf, keptAfter, runCacheOf, type RunCache } from '../cache/run import type { RunDecider } from '../machine/run-decider.ts'; import type { RunInput } from '../machine/run-input.ts'; import type { RunState } from '../machine/run-state.ts'; -import type { RunEvent } from '../run-log/run-event.ts'; +import type { RunLogEvent } from '../run-log/run-event.ts'; import type { LoadedRun } from '../run-log/run-fold.ts'; -import type { RunStore } from '../run-log/run-store.ts'; +import type { RunLogStore } from '../run-log/run-store.ts'; import { recordLineageOf } from './record-lineage.ts'; -export type RunDecision = Decided; +export type RunDecision = Decided; export class SplitDecision extends Data.TaggedError('split_decision')<{ - readonly executionId: string; + readonly runId: string; readonly events: number; }> {} -function appendOf(runStore: RunStore, cache: RunCache, input: RunInput): StreamAppend { - return (executionId, events, expectedVersion, loaded) => +function appendOf(runStore: RunLogStore, cache: RunCache, input: RunInput): StreamAppend { + return (runId, events, expectedVersion, loaded) => events.length > 1 - ? Effect.die(new SplitDecision({ executionId, events: events.length })) + ? Effect.die(new SplitDecision({ runId, events: events.length })) : Effect.forEach( events, - (event) => runStore.append(executionId, event, expectedVersion, recordLineageOf(input, loaded.state, event)), + (event) => runStore.append(runId, event, expectedVersion, recordLineageOf(input, loaded.state, event)), { discard: true }, ).pipe( Effect.onError(() => Effect.sync(() => { - cache.drop(executionId); + cache.drop(runId); }), ), ); } export function runLoopOf( - runStore: RunStore, + runStore: RunLogStore, decider: RunDecider, cache: RunCache = runCacheOf(), -): DecisionLoop { +): DecisionLoop { const load = cachedLoadOf(runStore, cache); - return (executionId, input) => - Effect.tap(decisionLoop(load, appendOf(runStore, cache, input), decider)(executionId, input), (decision) => + return (runId, input) => + Effect.tap(decisionLoop(load, appendOf(runStore, cache, input), decider)(runId, input), (decision) => Effect.sync(() => { - keptAfter(cache, executionId, decision); + keptAfter(cache, runId, decision); }), ); } diff --git a/packages/workflow-engine/src/engine/run-upkeep.ts b/packages/workflow-engine/src/engine/run-upkeep.ts index 42f0724ff..bdbf57d12 100644 --- a/packages/workflow-engine/src/engine/run-upkeep.ts +++ b/packages/workflow-engine/src/engine/run-upkeep.ts @@ -11,26 +11,26 @@ import type { RunDecision } from './run-loop.ts'; export function snapshotIfDue( ports: EnginePorts, cache: RunCache, - executionId: string, + runId: string, decision: RunDecision, ): Effect.Effect { if (!isSnapshotDue(sinceSnapshotAfter(decision.loaded.sinceSnapshot, decision.events))) { return Effect.void; } - cache.drop(executionId); + cache.drop(runId); return ports.runStore.saveSnapshot(snapshotOf(decision.state, decision.version)); } -export function armedTimersOf(executionId: string, state: RunState): readonly ArmTimer[] { +export function armedTimersOf(runId: string, state: RunState): readonly ArmTimer[] { return Object.entries(state.timers.armed).map(([timerId, { dueAt, purpose }]: readonly [string, ArmedTimer]) => ({ kind: 'arm_timer', - executionId, + runId, timerId, dueAt, purpose, })); } -export function loadedFrom(ports: EnginePorts, executionId: string): Effect.Effect { - return Effect.map(ports.runStore.load(executionId), (stored) => loadedRunOf(stored)); +export function loadedFrom(ports: EnginePorts, runId: string): Effect.Effect { + return Effect.map(ports.runStore.load(runId), (stored) => loadedRunOf(stored)); } diff --git a/packages/workflow-engine/src/engine/sweep.test.ts b/packages/workflow-engine/src/engine/sweep.test.ts index e07baa139..e95b31fa7 100644 --- a/packages/workflow-engine/src/engine/sweep.test.ts +++ b/packages/workflow-engine/src/engine/sweep.test.ts @@ -10,7 +10,7 @@ import { memoryDriver } from '../testing/memory-driver.ts'; import { workflow } from '../testing/workflows.ts'; import { workflowEngineOf } from './engine.ts'; -const executionId = '0199a3c4-7d2e-7c1a-9b3f-2f1e0d9c8b7a'; +const runId = '0199a3c4-7d2e-7c1a-9b3f-2f1e0d9c8b7a'; const pausing = workflow('do:\n - pause: { wait: PT1M }'); @@ -22,28 +22,28 @@ describe('a sweep', () => { it('wakes a run whose dispatch fell behind, which dispatches what it missed', () => { const driver = memoryDriver(); driver.ports.faults.failNext('arm_timer'); - driver.start({ executionId, document: pausing }); + driver.start({ runId, document: pausing }); expect(Effect.runSync(driver.engine.sweep(driver.clock.now()))).toEqual({ runs: 1, timersArmedAgain: 0 }); - expect(driver.runUntilEnded(executionId).outcome).toEqual({ kind: 'completed', output: {} }); + expect(driver.runUntilEnded(runId).outcome).toEqual({ kind: 'completed', output: {} }); }); it('arms again the timers the timer store lost of a run that is overdue', () => { const driver = memoryDriver(); - driver.start({ executionId, document: pausing }); + driver.start({ runId, document: pausing }); driver.ports.timers.forget(); - const stuck = driver.runUntilEnded(executionId); + const stuck = driver.runUntilEnded(runId); const swept = Effect.runSync(driver.engine.sweep(driver.clock.now() + 120_000)); expect(stuck.status).toBe('running'); expect(swept).toEqual({ runs: 1, timersArmedAgain: 2 }); - expect(driver.runUntilEnded(executionId).outcome).toEqual({ kind: 'completed', output: {} }); + expect(driver.runUntilEnded(runId).outcome).toEqual({ kind: 'completed', output: {} }); }); it('wakes no run that is not due', () => { const driver = memoryDriver(); - driver.start({ executionId, document: pausing }); + driver.start({ runId, document: pausing }); expect(Effect.runSync(driver.engine.sweep(driver.clock.now()))).toEqual({ runs: 0, timersArmedAgain: 0 }); }); @@ -59,8 +59,8 @@ describe('a sweep', () => { () => 'never', ); const engine = workflowEngineOf({ ...ports, timers: { ...ports.timers, sweep: failingSweep } }, testMachine); - ports.recordStore.known(executionId); - Effect.runSync(engine.submit(startedOf({ executionId, document: pausing }, clock.now()))); + ports.recordStore.known(runId); + Effect.runSync(engine.submit(startedOf({ runId, document: pausing }, clock.now()))); expect(Effect.runSync(engine.sweep(clock.now() + 120_000))).toEqual({ runs: 1, timersArmedAgain: 0 }); }); @@ -72,10 +72,10 @@ describe('a sweep after a timer fired', () => { const listening = workflow( 'do:\n - pause: { wait: PT1M }\n - approval: { listen: { to: { one: { with: { type: com.acme.approved } } } } }', ); - driver.start({ executionId, document: listening }); + driver.start({ runId, document: listening }); driver.clock.advance(); - expect(Object.values(driver.state(executionId).timers.armed).map(({ purpose }) => purpose)).toEqual(['deadline']); + expect(Object.values(driver.state(runId).timers.armed).map(({ purpose }) => purpose)).toEqual(['deadline']); expect(Effect.runSync(driver.engine.sweep(driver.clock.now() + 3_600_000))).toEqual({ runs: 0, timersArmedAgain: 0, diff --git a/packages/workflow-engine/src/engine/wake.test.ts b/packages/workflow-engine/src/engine/wake.test.ts index 5012fdd38..c2ca4f5ff 100644 --- a/packages/workflow-engine/src/engine/wake.test.ts +++ b/packages/workflow-engine/src/engine/wake.test.ts @@ -8,7 +8,7 @@ import { memoryDriver } from '../testing/memory-driver.ts'; import { workflow } from '../testing/workflows.ts'; import { workflowEngineOf } from './engine.ts'; -const executionId = '0199a3c4-7d2e-7c1a-9b3f-2f1e0d9c8b7a'; +const runId = '0199a3c4-7d2e-7c1a-9b3f-2f1e0d9c8b7a'; const pausing = workflow('do:\n - pause: { wait: PT1M }'); @@ -16,25 +16,25 @@ describe('a run whose note of its due time failed', () => { it('is found by a sweep an hour later, though nothing woke it and its alarm was lost, and runs to its end', () => { const driver = memoryDriver(); driver.ports.faults.failNext('note_due'); - const started = driver.start({ executionId, document: pausing }); + const started = driver.start({ runId, document: pausing }); driver.ports.timers.forget(); - const stuck = driver.runUntilEnded(executionId); + const stuck = driver.runUntilEnded(runId); const swept = Effect.runSync(driver.engine.sweep(driver.clock.now() + 3_600_000)); expect(started).toEqual({ outcome: 'applied', version: 1 }); expect(stuck.status).toBe('running'); expect(swept).toEqual({ runs: 1, timersArmedAgain: 0 }); - expect(driver.runUntilEnded(executionId).outcome).toEqual({ kind: 'completed', output: {} }); + expect(driver.runUntilEnded(runId).outcome).toEqual({ kind: 'completed', output: {} }); }); it('leaves its dispatch behind until a wake dispatches it again and notes its due time', () => { const driver = memoryDriver(); driver.ports.faults.failNext('note_due'); - driver.start({ executionId, document: pausing }); + driver.start({ runId, document: pausing }); - const watermark = Effect.runSync(driver.ports.watermark.read(executionId)); - const woken = Effect.runSync(driver.engine.wake(executionId)); + const watermark = Effect.runSync(driver.ports.watermark.read(runId)); + const woken = Effect.runSync(driver.engine.wake(runId)); const swept = Effect.runSync(driver.engine.sweep(driver.clock.now() + 3_600_000)); expect(watermark).toBe(0); @@ -46,14 +46,14 @@ describe('a run whose note of its due time failed', () => { const driver = memoryDriver(); driver.ports.faults.failNext('arm_timer'); driver.ports.faults.failNext('note_due'); - driver.start({ executionId, document: pausing }); - const stuck = driver.runUntilEnded(executionId); + driver.start({ runId, document: pausing }); + const stuck = driver.runUntilEnded(runId); const swept = Effect.runSync(driver.engine.sweep(driver.clock.now())); expect(stuck.status).toBe('running'); expect(swept).toEqual({ runs: 1, timersArmedAgain: 0 }); - expect(driver.runUntilEnded(executionId).outcome).toEqual({ kind: 'completed', output: {} }); + expect(driver.runUntilEnded(runId).outcome).toEqual({ kind: 'completed', output: {} }); }); }); diff --git a/packages/workflow-engine/src/engine/workflow-engine.ts b/packages/workflow-engine/src/engine/workflow-engine.ts index 96c040079..643d7f04f 100644 --- a/packages/workflow-engine/src/engine/workflow-engine.ts +++ b/packages/workflow-engine/src/engine/workflow-engine.ts @@ -22,6 +22,6 @@ export interface SweepReport { export interface WorkflowEngine { readonly submit: (input: RunInput) => Effect.Effect; - readonly wake: (executionId: string) => Effect.Effect; + readonly wake: (runId: string) => Effect.Effect; readonly sweep: (before: number) => Effect.Effect; } diff --git a/packages/workflow-engine/src/executor/call-key.test.ts b/packages/workflow-engine/src/executor/call-key.test.ts index bc30cd74f..39a81ecf5 100644 --- a/packages/workflow-engine/src/executor/call-key.test.ts +++ b/packages/workflow-engine/src/executor/call-key.test.ts @@ -3,15 +3,15 @@ import { describe, expect, it } from 'vitest'; import { callKeyText, type CallKey } from '../index.ts'; describe('the text of a call key', () => { - it('is the same for the same execution, reference and run, and different whenever one differs', () => { + it('is the same for the same run, reference and run, and different whenever one differs', () => { const keys = [ - { executionId: 'e', reference: '/do/0', run: 1 }, - { executionId: 'e', reference: '/do/0', run: 2 }, - { executionId: 'e/do', reference: '/0', run: 1 }, - { executionId: 'e', reference: '/do/0","x', run: 1 }, + { runId: 'e', reference: '/do/0', run: 1 }, + { runId: 'e', reference: '/do/0', run: 2 }, + { runId: 'e/do', reference: '/0', run: 1 }, + { runId: 'e', reference: '/do/0","x', run: 1 }, ].map((key: CallKey) => callKeyText(key)); expect(new Set(keys).size).toBe(4); - expect(callKeyText({ executionId: 'e', reference: '/do/0', run: 1 })).toBe(keys[0]); + expect(callKeyText({ runId: 'e', reference: '/do/0', run: 1 })).toBe(keys[0]); }); }); diff --git a/packages/workflow-engine/src/executor/call-key.ts b/packages/workflow-engine/src/executor/call-key.ts index 80ec7b0bf..2e0509d80 100644 --- a/packages/workflow-engine/src/executor/call-key.ts +++ b/packages/workflow-engine/src/executor/call-key.ts @@ -1,13 +1,13 @@ import { Schema } from 'effect'; export const CallKeySchema = Schema.Struct({ - executionId: Schema.NonEmptyString, + runId: Schema.NonEmptyString, reference: Schema.String, run: Schema.Int.check(Schema.isGreaterThanOrEqualTo(1)), }); export type CallKey = typeof CallKeySchema.Type; -export function callKeyText({ executionId, reference, run }: CallKey): string { - return JSON.stringify([executionId, reference, run]); +export function callKeyText({ runId, reference, run }: CallKey): string { + return JSON.stringify([runId, reference, run]); } diff --git a/packages/workflow-engine/src/folds/fold-filters.test.ts b/packages/workflow-engine/src/folds/fold-filters.test.ts index 0ff3b3031..a37fa9fb9 100644 --- a/packages/workflow-engine/src/folds/fold-filters.test.ts +++ b/packages/workflow-engine/src/folds/fold-filters.test.ts @@ -7,10 +7,10 @@ import { matchingOf, preparedFilters, type Matching, type RunTest } from './fold const dialect = { refused: [{ name: 'now', why: 'reads the clock' }], variables: ['event'] }; -const reviewed = { type: 'execution_succeeded', subject: 'inference/review-brief' }; +const reviewed = { type: 'run_succeeded', subject: 'reasoning/review-brief' }; function succeeded(verdict: Json): JsonObject { - return { ...reviewed, source: '/executions/run', data: { output: { campaign: 'spring', verdict } } }; + return { ...reviewed, source: '/runs/run', data: { output: { campaign: 'spring', verdict } } }; } const runTest: RunTest = (test, actual) => @@ -39,18 +39,18 @@ describe('the filters of a view', () => { }); it('never match an event that lacks an attribute a filter names', () => { - expect(matched([{ type: 'execution_succeeded', time: '2026-10-01T09:00:00Z' }], succeeded('reject'))).toEqual({ + expect(matched([{ type: 'run_succeeded', time: '2026-10-01T09:00:00Z' }], succeeded('reject'))).toEqual({ matched: false, work: 0, }); }); it('never match by a filter that names no type', () => { - expect(matched([{ subject: 'inference/review-brief' }], succeeded('reject'))).toEqual({ matched: false, work: 0 }); + expect(matched([{ subject: 'reasoning/review-brief' }], succeeded('reject'))).toEqual({ matched: false, work: 0 }); }); it('match when any one filter matches, counting the work of every test they ran', () => { - const approved = { type: 'execution_succeeded', data: '${ .output.verdict == "approve" }' }; + const approved = { type: 'run_succeeded', data: '${ .output.verdict == "approve" }' }; const alone = matched(rejected, succeeded('reject')); const both = matched([approved, ...rejected], succeeded('reject')); @@ -60,7 +60,7 @@ describe('the filters of a view', () => { }); it('answer the filter that does not compile, without running it', () => { - expect(matched([{ type: 'execution_succeeded', data: '${ $x }' }], succeeded('reject'))).toMatchObject({ + expect(matched([{ type: 'run_succeeded', data: '${ $x }' }], succeeded('reject'))).toMatchObject({ refused: { issues: [{ detail: unbound }] }, }); }); diff --git a/packages/workflow-engine/src/folds/fold-page.test.ts b/packages/workflow-engine/src/folds/fold-page.test.ts index f571e2031..eff48e4fb 100644 --- a/packages/workflow-engine/src/folds/fold-page.test.ts +++ b/packages/workflow-engine/src/folds/fold-page.test.ts @@ -14,17 +14,17 @@ const foldDialect = { variables: ['event'], }; -const reviewed = { type: 'execution_succeeded', subject: 'inference/review-brief' }; +const reviewed = { type: 'run_succeeded', subject: 'reasoning/review-brief' }; function succeeded(campaign: Json, verdict: Json, time: string): JsonObject { - return { ...reviewed, source: '/executions/run', time, data: { output: { campaign, verdict } } }; + return { ...reviewed, source: '/runs/run', time, data: { output: { campaign, verdict } } }; } const springApproved = succeeded('spring', 'approve', '2026-10-01T09:00:00Z'); const events: readonly JsonObject[] = [ springApproved, - { type: 'execution_started', subject: 'inference/review-brief', time: '2026-10-01T09:00:01Z', data: {} }, + { type: 'run_started', subject: 'reasoning/review-brief', time: '2026-10-01T09:00:01Z', data: {} }, succeeded('summer', 'reject', '2026-10-01T09:00:02Z'), succeeded('spring', 'reject', '2026-10-01T09:00:03Z'), ]; @@ -265,8 +265,8 @@ describe('a view that stalls on what its fold answers', () => { describe('the filters of a view in a page of folds', () => { it('run under the deadline and the limits of its fold, after the fold that was going is marked', () => { - const slowFilter = [{ type: 'execution_succeeded', data: '${ reduce range(100000) as $i (0; . + $i) > 0 }' }]; - const greedyFilter = [{ type: 'execution_succeeded', data: '${ ("x" * 20000000 | length) > 0 }' }]; + const slowFilter = [{ type: 'run_succeeded', data: '${ reduce range(100000) as $i (0; . + $i) > 0 }' }]; + const greedyFilter = [{ type: 'run_succeeded', data: '${ ("x" * 20000000 | length) > 0 }' }]; const views = [ viewOf('. + 1', { view: 0, filters: slowFilter }), viewOf('. + 1', { view: 0, filters: greedyFilter }), @@ -281,7 +281,7 @@ describe('the filters of a view in a page of folds', () => { }); it('stall a view whose filter does not compile on this server', () => { - const filters = [{ type: 'execution_succeeded', data: '${ $x }' }]; + const filters = [{ type: 'run_succeeded', data: '${ $x }' }]; expect(foldPage(pageOf([viewOf('. + 1', { view: 0, filters })]), stillClock()).views[0]).toMatchObject({ folded: 0, diff --git a/packages/workflow-engine/src/index.ts b/packages/workflow-engine/src/index.ts index 74700b444..25b218d2c 100644 --- a/packages/workflow-engine/src/index.ts +++ b/packages/workflow-engine/src/index.ts @@ -135,12 +135,12 @@ export { type WaitingEvent, } from './machine/run-state.ts'; export { - RunEventSchema, + RunLogEventSchema, eventBytesOf, fitsInOneEvent, withHistoryBytes, type PositionedEvent, - type RunEvent, + type RunLogEvent, } from './run-log/run-event.ts'; export { StepSchema, @@ -156,7 +156,7 @@ export { } from './steps/step-entry.ts'; export { stepEventIdOf } from './steps/step-ids.ts'; export { UnreadableRun, evolveRun, loadedRunOf, stateInCurrentFormat, type LoadedRun } from './run-log/run-fold.ts'; -export type { RecordCause, RecordLineage, RunStore, StoredRun, StoredSnapshot } from './run-log/run-store.ts'; +export type { RecordCause, RecordLineage, RunLogStore, StoredRun, StoredSnapshot } from './run-log/run-store.ts'; export { SnapshotSchema, isSnapshotDue, diff --git a/packages/workflow-engine/src/machine/admission.test.ts b/packages/workflow-engine/src/machine/admission.test.ts index 9c82dec16..6dec262d7 100644 --- a/packages/workflow-engine/src/machine/admission.test.ts +++ b/packages/workflow-engine/src/machine/admission.test.ts @@ -2,13 +2,13 @@ import { describe, expect, it } from 'vitest'; import { newRun, outcomeOf, RunMismatch, staleReasonOf, type RunInput } from '../index.ts'; import { testCancel } from '../testing/driver-inputs.ts'; -import { armedTimer, at, document, executionId, openCall, runningState, started } from '../testing/runs.ts'; +import { armedTimer, at, document, runId, openCall, runningState, started } from '../testing/runs.ts'; -const fired = (timerId: string): RunInput => ({ kind: 'timer_fired', executionId, at, timerId }); +const fired = (timerId: string): RunInput => ({ kind: 'timer_fired', runId, at, timerId }); const answered = (run: number): RunInput => ({ kind: 'call_answered', - executionId, + runId, at, key: { ...openCall, run }, result: { status: 'succeeded', output: { approved: true } }, @@ -16,12 +16,12 @@ const answered = (run: number): RunInput => ({ const received = (id: string): RunInput => ({ kind: 'event_received', - executionId, + runId, at, event: { id, type: 'com.acme.approval' }, }); -const cancelled: RunInput = { kind: 'cancel_requested', executionId, at, cancel: testCancel }; +const cancelled: RunInput = { kind: 'cancel_requested', runId, at, cancel: testCancel }; describe('an input that can still change a run', () => { it('is a start of a new run, the fire of an armed timer, the answer of an open call, a new event or a first cancel', () => { @@ -71,14 +71,14 @@ describe('a stale input', () => { }); describe('an input that belongs to no run like this one', () => { - it('dies instead of being taken as stale: another execution, or a start with another document or input', () => { - expect(() => staleReasonOf(runningState, { ...fired(armedTimer), executionId: 'another' })).toThrow(RunMismatch); - expect(() => staleReasonOf(runningState, { ...started, executionId: 'another' })).toThrow(RunMismatch); + it('dies instead of being taken as stale: another run, or a start with another document or input', () => { + expect(() => staleReasonOf(runningState, { ...fired(armedTimer), runId: 'another' })).toThrow(RunMismatch); + expect(() => staleReasonOf(runningState, { ...started, runId: 'another' })).toThrow(RunMismatch); expect(() => staleReasonOf(runningState, { ...started, document: { do: [{ other: {} }] } })).toThrow( - new RunMismatch({ detail: `The run of ${executionId} was started again with another document` }), + new RunMismatch({ detail: `The run of ${runId} was started again with another document` }), ); expect(() => staleReasonOf(runningState, { ...started, input: { ticket: 8 } })).toThrow( - new RunMismatch({ detail: `The run of ${executionId} was started again with another input` }), + new RunMismatch({ detail: `The run of ${runId} was started again with another input` }), ); expect(() => staleReasonOf({ ...runningState, workflow: null }, started)).toThrow(RunMismatch); }); diff --git a/packages/workflow-engine/src/machine/admission.ts b/packages/workflow-engine/src/machine/admission.ts index 9682ce36d..26ec0bbb2 100644 --- a/packages/workflow-engine/src/machine/admission.ts +++ b/packages/workflow-engine/src/machine/admission.ts @@ -23,8 +23,8 @@ export type SubmissionOutcome = 'applied' | 'stale' | 'not_started'; export class RunMismatch extends Data.TaggedError('run_mismatch')<{ readonly detail: string }> {} function requireSameRun(state: RunState, input: RunInput): void { - if (input.executionId !== state.executionId) { - throw new RunMismatch({ detail: `An input for ${input.executionId} reached the run of ${state.executionId}` }); + if (input.runId !== state.runId) { + throw new RunMismatch({ detail: `An input for ${input.runId} reached the run of ${state.runId}` }); } } @@ -37,10 +37,10 @@ function startedReason( } requireSameRun(state, input); if (state.workflow === null || !sameJson(state.workflow.document, input.document)) { - throw new RunMismatch({ detail: `The run of ${state.executionId} was started again with another document` }); + throw new RunMismatch({ detail: `The run of ${state.runId} was started again with another document` }); } if (!sameJson(heldValueOf(state, state.workflow.input).value, input.input)) { - throw new RunMismatch({ detail: `The run of ${state.executionId} was started again with another input` }); + throw new RunMismatch({ detail: `The run of ${state.runId} was started again with another input` }); } return 'started_before'; } diff --git a/packages/workflow-engine/src/machine/held-values.test.ts b/packages/workflow-engine/src/machine/held-values.test.ts index 67bcc038f..6b037ca72 100644 --- a/packages/workflow-engine/src/machine/held-values.test.ts +++ b/packages/workflow-engine/src/machine/held-values.test.ts @@ -89,7 +89,7 @@ function bodyOf(choose: Choose, depth: number): FrameBody { () => ({ kind: 'wait', timer: 't' }), () => ({ kind: 'call', - key: { executionId: 'e', reference: '/do/0', run: 1 }, + key: { runId: 'e', reference: '/do/0', run: 1 }, function: 'notify', arguments: choose(valueCount), label: 'notify', @@ -121,7 +121,7 @@ function stateOf(seed: number): RunState { ); return { ...newRun, - executionId: 'e', + runId: 'e', status: 'running', workflow: { document: { do: [{ seed }] }, input: choose(valueCount) }, machine: { values, nextValue: valueCount, context: choose(valueCount), root: frameOf(choose, 3) }, diff --git a/packages/workflow-engine/src/machine/input-receipt.test.ts b/packages/workflow-engine/src/machine/input-receipt.test.ts index 2478b1651..c7df31648 100644 --- a/packages/workflow-engine/src/machine/input-receipt.test.ts +++ b/packages/workflow-engine/src/machine/input-receipt.test.ts @@ -2,15 +2,15 @@ import { describe, expect, it } from 'vitest'; import { callKeyText, clampedAt, inputTimeOf, receiptOf, type RunInput } from '../index.ts'; import { testCancel } from '../testing/driver-inputs.ts'; -import { armedTimer, at, executionId, openCall, runningState, started } from '../testing/runs.ts'; +import { armedTimer, at, runId, openCall, runningState, started } from '../testing/runs.ts'; const inputs: readonly RunInput[] = [ started, - { kind: 'timer_fired', executionId, at, timerId: armedTimer }, - { kind: 'call_answered', executionId, at, key: openCall, result: { status: 'failed', detail: 'boom' } }, + { kind: 'timer_fired', runId, at, timerId: armedTimer }, + { kind: 'call_answered', runId, at, key: openCall, result: { status: 'failed', detail: 'boom' } }, { kind: 'call_answered', - executionId, + runId, at, key: openCall, result: { @@ -23,26 +23,26 @@ const inputs: readonly RunInput[] = [ }, { kind: 'call_answered', - executionId, + runId, at, key: openCall, result: { status: 'rejected', reason: 'conflict', detail: 'no', kind: 'tools_called' }, }, { kind: 'call_answered', - executionId, + runId, at, key: openCall, result: { status: 'rejected', reason: 'conflict', detail: 'no' }, }, - { kind: 'event_received', executionId, at, event: { id: 'event-9', type: 'com.acme.approval' } }, - { kind: 'cancel_requested', executionId, at, cancel: testCancel }, + { kind: 'event_received', runId, at, event: { id: 'event-9', type: 'com.acme.approval' } }, + { kind: 'cancel_requested', runId, at, cancel: testCancel }, ]; describe('the receipt of an input', () => { it('names the input by the key it is deduplicated by, with the status of an answer, the kind and because of a rejection, or the type of an event', () => { expect(inputs.map((input) => receiptOf(input, at))).toEqual([ - { kind: 'started', key: executionId, at }, + { kind: 'started', key: runId, at }, { kind: 'timer_fired', key: armedTimer, at }, { kind: 'call_answered', key: callKeyText(openCall), at, status: 'failed' }, { @@ -61,7 +61,7 @@ describe('the receipt of an input', () => { }, { kind: 'call_answered', key: callKeyText(openCall), at, status: 'rejected' }, { kind: 'event_received', key: 'event-9', at, eventType: 'com.acme.approval' }, - { kind: 'cancel_requested', key: executionId, at, cancel: { by: 'tester', kind: 'requested' } }, + { kind: 'cancel_requested', key: runId, at, cancel: { by: 'tester', kind: 'requested' } }, ]); }); }); @@ -71,15 +71,13 @@ describe('the time of an input', () => { const later = { ...runningState, lastInputAt: at + 5000 }; expect(inputTimeOf(later, started)).toBe(at + 5000); - expect(inputTimeOf(runningState, { kind: 'cancel_requested', executionId, at: at + 1, cancel: testCancel })).toBe( - at + 1, - ); + expect(inputTimeOf(runningState, { kind: 'cancel_requested', runId, at: at + 1, cancel: testCancel })).toBe(at + 1); expect([clampedAt(at, at - 1), clampedAt(at, at + 1)]).toEqual([at, at + 1]); }); it('is never before the time a fired timer was due, so a timer that fires early still fires at its time', () => { - const early: RunInput = { kind: 'timer_fired', executionId, at, timerId: armedTimer }; - const unknown: RunInput = { kind: 'timer_fired', executionId, at, timerId: '9' }; + const early: RunInput = { kind: 'timer_fired', runId, at, timerId: armedTimer }; + const unknown: RunInput = { kind: 'timer_fired', runId, at, timerId: '9' }; expect([inputTimeOf(runningState, early), inputTimeOf(runningState, unknown)]).toEqual([at + 60_000, at]); }); diff --git a/packages/workflow-engine/src/machine/input-receipt.ts b/packages/workflow-engine/src/machine/input-receipt.ts index 44732e2ac..020bf44bf 100644 --- a/packages/workflow-engine/src/machine/input-receipt.ts +++ b/packages/workflow-engine/src/machine/input-receipt.ts @@ -75,7 +75,7 @@ export function receiptOf(input: RunInput, at: number): InputReceipt { } if (input.kind === 'cancel_requested') { const { by, kind } = input.cancel; - return { kind: input.kind, key: input.executionId, at, cancel: { by, kind } }; + return { kind: input.kind, key: input.runId, at, cancel: { by, kind } }; } - return { kind: input.kind, key: input.executionId, at }; + return { kind: input.kind, key: input.runId, at }; } diff --git a/packages/workflow-engine/src/machine/run-decider.ts b/packages/workflow-engine/src/machine/run-decider.ts index 873a407f2..5e564cb66 100644 --- a/packages/workflow-engine/src/machine/run-decider.ts +++ b/packages/workflow-engine/src/machine/run-decider.ts @@ -1,7 +1,7 @@ import type { Decider } from '@beonauto/operations'; -import type { RunEvent } from '../run-log/run-event.ts'; +import type { RunLogEvent } from '../run-log/run-event.ts'; import type { RunInput } from './run-input.ts'; import type { RunState } from './run-state.ts'; -export type RunDecider = Decider; +export type RunDecider = Decider; diff --git a/packages/workflow-engine/src/machine/run-input.ts b/packages/workflow-engine/src/machine/run-input.ts index 115304d4d..1abe8a8a2 100644 --- a/packages/workflow-engine/src/machine/run-input.ts +++ b/packages/workflow-engine/src/machine/run-input.ts @@ -6,7 +6,7 @@ import { ReceivedEventSchema } from '../inbox/received-event.ts'; import { InstantSchema } from './instant.ts'; import { mostEventIdLength } from './limits.ts'; -const ExecutionIdSchema = Schema.NonEmptyString; +const RunIdSchema = Schema.NonEmptyString; const PositiveMillisecondsSchema = Schema.Int.check(Schema.isGreaterThanOrEqualTo(1)); @@ -24,7 +24,7 @@ export const CancelOrderSchema = Schema.Struct({ const StartedSchema = Schema.Struct({ kind: Schema.Literal('started'), - executionId: ExecutionIdSchema, + runId: RunIdSchema, at: InstantSchema, document: Schema.JsonObject, input: Schema.Json, @@ -35,14 +35,14 @@ const StartedSchema = Schema.Struct({ const TimerFiredSchema = Schema.Struct({ kind: Schema.Literal('timer_fired'), - executionId: ExecutionIdSchema, + runId: RunIdSchema, at: InstantSchema, timerId: Schema.NonEmptyString, }); const CallAnsweredSchema = Schema.Struct({ kind: Schema.Literal('call_answered'), - executionId: ExecutionIdSchema, + runId: RunIdSchema, at: InstantSchema, key: CallKeySchema, result: CallResultSchema, @@ -50,7 +50,7 @@ const CallAnsweredSchema = Schema.Struct({ const EventReceivedSchema = Schema.Struct({ kind: Schema.Literal('event_received'), - executionId: ExecutionIdSchema, + runId: RunIdSchema, at: InstantSchema, event: ReceivedEventSchema, }); @@ -59,7 +59,7 @@ const OfferKeySchema = Schema.String.check(Schema.isMinLength(1), Schema.isMaxLe const EventOfferedSchema = Schema.Struct({ kind: Schema.Literal('event_offered'), - executionId: ExecutionIdSchema, + runId: RunIdSchema, at: InstantSchema, key: OfferKeySchema, listener: CallKeySchema, @@ -68,7 +68,7 @@ const EventOfferedSchema = Schema.Struct({ const CancelRequestedSchema = Schema.Struct({ kind: Schema.Literal('cancel_requested'), - executionId: ExecutionIdSchema, + runId: RunIdSchema, at: InstantSchema, cancel: CancelOrderSchema, cause: Schema.optionalKey(Schema.NonEmptyString), diff --git a/packages/workflow-engine/src/machine/run-state.ts b/packages/workflow-engine/src/machine/run-state.ts index 65f040128..d82a2e7f2 100644 --- a/packages/workflow-engine/src/machine/run-state.ts +++ b/packages/workflow-engine/src/machine/run-state.ts @@ -116,7 +116,7 @@ export interface EmittedEvents { } export interface RunState { - readonly executionId: string; + readonly runId: string; readonly status: 'new' | 'running' | 'ended'; readonly workflow: { readonly document: Schema.JsonObject; readonly input: ValueId } | null; readonly attributes: Schema.JsonObject; @@ -227,7 +227,7 @@ const RunOutcomeSchema: Schema.Codec = Schema.Union([ ]); export const RunStateSchema: Schema.Codec = Schema.Struct({ - executionId: Schema.String, + runId: Schema.String, status: Schema.Literals(['new', 'running', 'ended']), workflow: Schema.NullOr(Schema.Struct({ document: Schema.JsonObject, input: ValueIdSchema })), attributes: Schema.JsonObject, @@ -274,7 +274,7 @@ export const RunStateSchema: Schema.Codec = Schema.Struct({ }); export const newRun: RunState = { - executionId: '', + runId: '', status: 'new', workflow: null, attributes: {}, diff --git a/packages/workflow-engine/src/memory/memory-executor.test.ts b/packages/workflow-engine/src/memory/memory-executor.test.ts index e0306e022..a9e901e6d 100644 --- a/packages/workflow-engine/src/memory/memory-executor.test.ts +++ b/packages/workflow-engine/src/memory/memory-executor.test.ts @@ -10,7 +10,7 @@ import { memoryExecutor } from './memory-executor.ts'; import { faultsOf } from './memory-timers.ts'; import { virtualClock, type VirtualClock } from './virtual-clock.ts'; -const run = { executionId: '0199a3c4-7d2e-7c1a-9b3f-2f1e0d9c8b7a', attributes: {} }; +const run = { runId: '0199a3c4-7d2e-7c1a-9b3f-2f1e0d9c8b7a', attributes: {} }; async function settledOf(clock: VirtualClock, take: () => readonly string[]): Promise { await setImmediate(); diff --git a/packages/workflow-engine/src/memory/memory-executor.ts b/packages/workflow-engine/src/memory/memory-executor.ts index 8622cb215..6c9795a7c 100644 --- a/packages/workflow-engine/src/memory/memory-executor.ts +++ b/packages/workflow-engine/src/memory/memory-executor.ts @@ -35,7 +35,7 @@ function callBookOf(clock: VirtualClock, submit: Submit, responder: Responder): const key = callKeyText(call.key); awaited.delete(key); answered.set(key, result); - submit({ kind: 'call_answered', executionId: call.key.executionId, at: clock.now(), key: call.key, result }); + submit({ kind: 'call_answered', runId: call.key.runId, at: clock.now(), key: call.key, result }); return result; }; const reply = (call: StartCall, key: string, given: Exclude): void => { diff --git a/packages/workflow-engine/src/memory/memory-ports.ts b/packages/workflow-engine/src/memory/memory-ports.ts index 698e00717..adaa267aa 100644 --- a/packages/workflow-engine/src/memory/memory-ports.ts +++ b/packages/workflow-engine/src/memory/memory-ports.ts @@ -35,7 +35,7 @@ export function memoryPorts(clock: VirtualClock, submit: Submit, responder: Resp emitter: memoryEmitter(faults), recordStore: memoryRecordStore(faults), reporter: memoryReporter(), - serialiser: { serialise: (_executionId, work) => work }, + serialiser: { serialise: (_runId, work) => work }, faults, }; } diff --git a/packages/workflow-engine/src/memory/memory-reactions.test.ts b/packages/workflow-engine/src/memory/memory-reactions.test.ts index f8ff7b968..9893807f2 100644 --- a/packages/workflow-engine/src/memory/memory-reactions.test.ts +++ b/packages/workflow-engine/src/memory/memory-reactions.test.ts @@ -7,9 +7,9 @@ import { memoryEmitter, memoryListeners } from './memory-reactions.ts'; import { faultsOf } from './memory-timers.ts'; import { virtualClock } from './virtual-clock.ts'; -const run = { executionId: 'acme/alpha/r1', attributes: {} }; +const run = { runId: 'acme/alpha/r1', attributes: {} }; -const otherRun = { executionId: 'acme/alpha/r2', attributes: {} }; +const otherRun = { runId: 'acme/alpha/r2', attributes: {} }; describe('the listeners kept in memory', () => { it.each(listenerProbes)('$title', async (probe) => { @@ -22,7 +22,7 @@ describe('the listeners kept in memory', () => { const listeners = memoryListeners(faultsOf(virtualClock()), 1); const listener = (reference: string): ArmListener => ({ kind: 'arm_listener', - key: { executionId: run.executionId, reference, run: 1 }, + key: { runId: run.runId, reference, run: 1 }, filters: [], }); diff --git a/packages/workflow-engine/src/memory/memory-records.test.ts b/packages/workflow-engine/src/memory/memory-records.test.ts index f85460891..747f621e6 100644 --- a/packages/workflow-engine/src/memory/memory-records.test.ts +++ b/packages/workflow-engine/src/memory/memory-records.test.ts @@ -1,7 +1,7 @@ import { Effect } from 'effect'; import { describe, expect, it } from 'vitest'; -import { executionId } from '../testing/runs.ts'; +import { runId } from '../testing/runs.ts'; import { recordStoreProbes, watermarkProbes, type RecordStoreSubject } from '../testing/store-probes.ts'; import { memoryRecordStore, memoryWatermark } from './memory-records.ts'; import { faultsOf } from './memory-timers.ts'; @@ -12,7 +12,7 @@ function recordStoreSubject(): RecordStoreSubject { const recordStore = memoryRecordStore(faultsOf(virtualClock())); return { recordStore, - run: { executionId, attributes: {} }, + run: { runId, attributes: {} }, know: (known) => Effect.sync(() => { recordStore.known(known); @@ -29,7 +29,7 @@ describe('the memory record store meets the contract every record store meets', describe('the memory watermark meets the contract every watermark meets', () => { it.each(watermarkProbes)('$title', async (probe) => { const runStore = memoryRunStore(); - const subject = { watermark: memoryWatermark(runStore), runStore, executionId }; + const subject = { watermark: memoryWatermark(runStore), runStore, runId }; expect(await Effect.runPromise(probe.run(subject))).toEqual(probe.expected); }); diff --git a/packages/workflow-engine/src/memory/memory-records.ts b/packages/workflow-engine/src/memory/memory-records.ts index 547ea1e20..947d1dd21 100644 --- a/packages/workflow-engine/src/memory/memory-records.ts +++ b/packages/workflow-engine/src/memory/memory-records.ts @@ -8,8 +8,8 @@ import type { Faults } from './memory-timers.ts'; import type { MemoryRunStore } from './run-store.ts'; export interface MemoryRecordStore extends RecordStore { - readonly known: (executionId: string) => void; - readonly settlementOf: (executionId: string) => Settlement | undefined; + readonly known: (runId: string) => void; + readonly settlementOf: (runId: string) => Settlement | undefined; } export interface MemoryReporter extends RunReporter { @@ -20,20 +20,20 @@ export function memoryRecordStore(faults: Faults): MemoryRecordStore { const known = new Set(); const settled = new Map(); const dues = new Map(); - const recorded = (executionId: string, settlement: Settlement): SettleReceipt => { - const earlier = settled.get(executionId); - if (!known.has(executionId)) { - return 'unknown_execution'; + const recorded = (runId: string, settlement: Settlement): SettleReceipt => { + const earlier = settled.get(runId); + if (!known.has(runId)) { + return 'unknown_run'; } if (earlier === undefined) { - settled.set(executionId, settlement); + settled.set(runId, settlement); return 'recorded'; } return sameJson(earlier, settlement) ? 'already_recorded' : 'settled_otherwise'; }; return { settle: (request) => - faults.attempt({ kind: 'settle', ...request }, () => recorded(request.executionId, request.settlement)), + faults.attempt({ kind: 'settle', ...request }, () => recorded(request.runId, request.settlement)), noteDue: (due) => Effect.suspend(() => { if (faults.fails('note_due')) { @@ -41,23 +41,23 @@ export function memoryRecordStore(faults: Faults): MemoryRecordStore { new DispatchFailed({ output: 'note_due', detail: 'The record store was told to fail once' }), ); } - const noted = dues.get(due.executionId); + const noted = dues.get(due.runId); if (noted === undefined || noted.version <= due.version) { - dues.set(due.executionId, due); + dues.set(due.runId, due); } return Effect.void; }), dueRuns: (before) => Effect.sync(() => [...dues.values()] - .filter((due) => !settled.has(due.executionId)) + .filter((due) => !settled.has(due.runId)) .filter(({ nextDueAt }) => nextDueAt !== null && nextDueAt < before) - .map(({ executionId }) => executionId), + .map(({ runId }) => runId), ), - known: (executionId) => { - known.add(executionId); + known: (runId) => { + known.add(runId); }, - settlementOf: (executionId) => settled.get(executionId), + settlementOf: (runId) => settled.get(runId), }; } @@ -73,21 +73,21 @@ export function memoryReporter(): MemoryReporter { } interface Handouts { - readonly takenAt: (executionId: string) => number; - readonly handOut: (executionIds: readonly string[]) => readonly string[]; + readonly takenAt: (runId: string) => number; + readonly handOut: (runIds: readonly string[]) => readonly string[]; } function handoutsOf(): Handouts { const taken = new Map(); const clock = { now: 0 }; return { - takenAt: (executionId) => taken.get(executionId) ?? 0, - handOut: (executionIds) => { + takenAt: (runId) => taken.get(runId) ?? 0, + handOut: (runIds) => { clock.now += 1; - for (const executionId of executionIds) { - taken.set(executionId, clock.now); + for (const runId of runIds) { + taken.set(runId, clock.now); } - return executionIds; + return runIds; }, }; } @@ -95,19 +95,19 @@ function handoutsOf(): Handouts { export function memoryWatermark(runStore: Pick): DispatchWatermark { const marks = new Map(); const handouts = handoutsOf(); - const markOf = (executionId: string): number => marks.get(executionId) ?? 0; + const markOf = (runId: string): number => marks.get(runId) ?? 0; return { - read: (executionId) => Effect.sync(() => markOf(executionId)), - advance: (executionId, through) => + read: (runId) => Effect.sync(() => markOf(runId)), + advance: (runId, through) => Effect.sync(() => { - marks.set(executionId, Math.max(markOf(executionId), through)); + marks.set(runId, Math.max(markOf(runId), through)); }), behindRuns: (limit) => Effect.sync(() => handouts.handOut( [...runStore.versions()] - .filter(([executionId, version]: readonly [string, number]) => markOf(executionId) < version) - .map(([executionId]: readonly [string, number]) => executionId) + .filter(([runId, version]: readonly [string, number]) => markOf(runId) < version) + .map(([runId]: readonly [string, number]) => runId) .toSorted((first, second) => handouts.takenAt(first) - handouts.takenAt(second)) .slice(0, limit), ), diff --git a/packages/workflow-engine/src/memory/memory-timers.test.ts b/packages/workflow-engine/src/memory/memory-timers.test.ts index 9a44725b9..e289cb82c 100644 --- a/packages/workflow-engine/src/memory/memory-timers.test.ts +++ b/packages/workflow-engine/src/memory/memory-timers.test.ts @@ -7,9 +7,9 @@ import { timerProbes, type TimerSubject } from '../testing/port-probes.ts'; import { faultsOf, memoryTimers } from './memory-timers.ts'; import { virtualClock, type VirtualClock } from './virtual-clock.ts'; -const run = { executionId: '0199a3c4-7d2e-7c1a-9b3f-2f1e0d9c8b7a', attributes: {} }; +const run = { runId: '0199a3c4-7d2e-7c1a-9b3f-2f1e0d9c8b7a', attributes: {} }; -const otherRun = { executionId: '0199a3c4-7d2e-7c1a-9b3f-2f1e0d9c8b7b', attributes: {} }; +const otherRun = { runId: '0199a3c4-7d2e-7c1a-9b3f-2f1e0d9c8b7b', attributes: {} }; async function settledOf(clock: VirtualClock, take: () => readonly string[]): Promise { await setImmediate(); diff --git a/packages/workflow-engine/src/memory/memory-timers.ts b/packages/workflow-engine/src/memory/memory-timers.ts index 8ba4c61ed..893e1d550 100644 --- a/packages/workflow-engine/src/memory/memory-timers.ts +++ b/packages/workflow-engine/src/memory/memory-timers.ts @@ -46,7 +46,7 @@ export function faultsOf(clock: VirtualClock): Faults { }; } -type TimerOfRun = Pick; +type TimerOfRun = Pick; interface Firing { readonly fire: (timer: ArmTimer) => void; @@ -56,21 +56,21 @@ interface Firing { readonly forget: () => void; } -function timerKeyOf({ executionId, timerId }: TimerOfRun): string { - return JSON.stringify([executionId, timerId]); +function timerKeyOf({ runId, timerId }: TimerOfRun): string { + return JSON.stringify([runId, timerId]); } function firingOf(clock: VirtualClock, submit: Submit): Firing { const fired = new Set(); const armed = new Set(); return { - fire: ({ executionId, timerId, dueAt }) => { - const key = timerKeyOf({ executionId, timerId }); + fire: ({ runId, timerId, dueAt }) => { + const key = timerKeyOf({ runId, timerId }); armed.add(key); clock.schedule(dueAt, key, () => { armed.delete(key); fired.add(key); - submit({ kind: 'timer_fired', executionId, at: clock.now(), timerId }); + submit({ kind: 'timer_fired', runId, at: clock.now(), timerId }); }); }, hasFired: (timer) => fired.has(timerKeyOf(timer)), diff --git a/packages/workflow-engine/src/memory/run-store.test.ts b/packages/workflow-engine/src/memory/run-store.test.ts index 3f9a65009..9025e2cc3 100644 --- a/packages/workflow-engine/src/memory/run-store.test.ts +++ b/packages/workflow-engine/src/memory/run-store.test.ts @@ -1,22 +1,22 @@ import { Effect } from 'effect'; import { describe, expect, it } from 'vitest'; -import { executionId } from '../testing/runs.ts'; +import { runId } from '../testing/runs.ts'; import { runStoreProbes } from '../testing/store-probes.ts'; import { memoryRunStore } from './run-store.ts'; describe('the memory run store meets the contract every run store meets', () => { it.each(runStoreProbes)('$title', async (probe) => { - expect(await Effect.runPromise(probe.run({ runStore: memoryRunStore(), executionId }))).toEqual(probe.expected); + expect(await Effect.runPromise(probe.run({ runStore: memoryRunStore(), runId }))).toEqual(probe.expected); }); }); describe('the memory run store, for the tests that read it', () => { it('counts the loads and the snapshots saved of each run, and none of a run it never saw', async () => { const store = memoryRunStore(); - await Effect.runPromise(store.load(executionId)); + await Effect.runPromise(store.load(runId)); - expect([store.loads(executionId), store.loads('unseen')]).toEqual([1, 0]); + expect([store.loads(runId), store.loads('unseen')]).toEqual([1, 0]); expect(store.snapshotsSaved('unseen')).toEqual([]); }); }); diff --git a/packages/workflow-engine/src/memory/run-store.ts b/packages/workflow-engine/src/memory/run-store.ts index 5c2f9018b..e6afc93dd 100644 --- a/packages/workflow-engine/src/memory/run-store.ts +++ b/packages/workflow-engine/src/memory/run-store.ts @@ -1,23 +1,23 @@ import { VersionConflict } from '@beonauto/ledger'; import { Effect, Result, Schema } from 'effect'; -import { RunEventSchema, type PositionedEvent, type RunEvent } from '../run-log/run-event.ts'; -import type { RecordLineage, RunStore, StoredSnapshot } from '../run-log/run-store.ts'; +import { RunLogEventSchema, type PositionedEvent, type RunLogEvent } from '../run-log/run-event.ts'; +import type { RecordLineage, RunLogStore, StoredSnapshot } from '../run-log/run-store.ts'; import { snapshotChunks, snapshotFromChunks, type Snapshot } from '../run-log/snapshot.ts'; type AppendFault = 'conflict' | 'unknown_outcome'; -export interface MemoryRunStore extends RunStore { - readonly events: (executionId: string) => readonly PositionedEvent[]; - readonly lineages: (executionId: string) => readonly RecordLineage[]; - readonly snapshotOf: (executionId: string) => StoredSnapshot | null; +export interface MemoryRunStore extends RunLogStore { + readonly events: (runId: string) => readonly PositionedEvent[]; + readonly lineages: (runId: string) => readonly RecordLineage[]; + readonly snapshotOf: (runId: string) => StoredSnapshot | null; readonly versions: () => ReadonlyMap; - readonly loads: (executionId: string) => number; - readonly snapshotsSaved: (executionId: string) => readonly number[]; + readonly loads: (runId: string) => number; + readonly snapshotsSaved: (runId: string) => readonly number[]; readonly failNextAppend: (fault: AppendFault) => void; } -const EventTextSchema = Schema.fromJsonString(Schema.toCodecJson(RunEventSchema)); +const EventTextSchema = Schema.fromJsonString(Schema.toCodecJson(RunLogEventSchema)); const encodeEvent = Schema.encodeSync(EventTextSchema); @@ -26,58 +26,55 @@ const decodeEvent = Schema.decodeUnknownSync(EventTextSchema); const utf8 = new TextEncoder(); interface Streams { - readonly eventsOf: (executionId: string) => readonly PositionedEvent[]; - readonly lineagesOf: (executionId: string) => readonly RecordLineage[]; - readonly push: (executionId: string, event: RunEvent, lineage: RecordLineage) => void; + readonly eventsOf: (runId: string) => readonly PositionedEvent[]; + readonly lineagesOf: (runId: string) => readonly RecordLineage[]; + readonly push: (runId: string, event: RunLogEvent, lineage: RecordLineage) => void; readonly versions: () => ReadonlyMap; } function streamsOf(): Streams { const streams = new Map(); const lineages = new Map(); - const eventsOf = (executionId: string): readonly PositionedEvent[] => streams.get(executionId) ?? []; + const eventsOf = (runId: string): readonly PositionedEvent[] => streams.get(runId) ?? []; return { eventsOf, - lineagesOf: (executionId) => lineages.get(executionId) ?? [], - push: (executionId, event, lineage) => { - const stream = streams.get(executionId) ?? []; - streams.set(executionId, stream); + lineagesOf: (runId) => lineages.get(runId) ?? [], + push: (runId, event, lineage) => { + const stream = streams.get(runId) ?? []; + streams.set(runId, stream); stream.push({ version: stream.length + 1, event: decodeEvent(encodeEvent(event)) }); - lineages.set(executionId, [...(lineages.get(executionId) ?? []), lineage]); + lineages.set(runId, [...(lineages.get(runId) ?? []), lineage]); }, versions: () => new Map( - [...streams].map(([executionId, stream]: readonly [string, readonly PositionedEvent[]]) => [ - executionId, - stream.length, - ]), + [...streams].map(([runId, stream]: readonly [string, readonly PositionedEvent[]]) => [runId, stream.length]), ), }; } interface Snapshots { - readonly snapshotOf: (executionId: string) => StoredSnapshot | null; + readonly snapshotOf: (runId: string) => StoredSnapshot | null; readonly save: (snapshot: Snapshot) => void; - readonly savedOf: (executionId: string) => readonly number[]; + readonly savedOf: (runId: string) => readonly number[]; } function snapshotsOf(): Snapshots { const kept = new Map(); const saved = new Map(); - const snapshotOf = (executionId: string): StoredSnapshot | null => kept.get(executionId) ?? null; + const snapshotOf = (runId: string): StoredSnapshot | null => kept.get(runId) ?? null; return { snapshotOf, save: (snapshot) => { - const { executionId, version } = snapshot; - saved.set(executionId, [...(saved.get(executionId) ?? []), version]); + const { runId, version } = snapshot; + saved.set(runId, [...(saved.get(runId) ?? []), version]); const chunks = snapshotChunks(snapshot); const bytes = chunks.reduce((sum, chunk) => sum + utf8.encode(chunk).byteLength, 0); - const newest = snapshotOf(executionId); + const newest = snapshotOf(runId); if (newest === null || newest.snapshot.version < version) { - kept.set(executionId, { snapshot: Result.getOrThrow(snapshotFromChunks(chunks)), bytes }); + kept.set(runId, { snapshot: Result.getOrThrow(snapshotFromChunks(chunks)), bytes }); } }, - savedOf: (executionId) => saved.get(executionId) ?? [], + savedOf: (runId) => saved.get(runId) ?? [], }; } @@ -89,39 +86,39 @@ export function memoryRunStore(conflicts = 0): MemoryRunStore { const loads = new Map(); const pending: { conflicts: number; fault: AppendFault | 'none' } = { conflicts, fault: 'none' }; const appended = ( - executionId: string, - event: RunEvent, + runId: string, + event: RunLogEvent, expectedVersion: number, lineage: RecordLineage, ): Effect.Effect => { const { fault } = pending; pending.conflicts -= 1; pending.fault = 'none'; - if (fault === 'conflict' || pending.conflicts >= 0 || streams.eventsOf(executionId).length !== expectedVersion) { + if (fault === 'conflict' || pending.conflicts >= 0 || streams.eventsOf(runId).length !== expectedVersion) { return Effect.fail(new VersionConflict()); } - streams.push(executionId, event, lineage); + streams.push(runId, event, lineage); return fault === 'unknown_outcome' ? Effect.die(unknownOutcome) : Effect.void; }; return { - load: (executionId) => + load: (runId) => Effect.sync(() => { - loads.set(executionId, (loads.get(executionId) ?? 0) + 1); - const snapshot = snapshots.snapshotOf(executionId); - return { snapshot, tail: streams.eventsOf(executionId).slice(snapshot?.snapshot.version ?? 0) }; + loads.set(runId, (loads.get(runId) ?? 0) + 1); + const snapshot = snapshots.snapshotOf(runId); + return { snapshot, tail: streams.eventsOf(runId).slice(snapshot?.snapshot.version ?? 0) }; }), - append: (executionId, event, expectedVersion, lineage) => - Effect.suspend(() => appended(executionId, event, expectedVersion, lineage)), - eventsAfter: (executionId, version) => Effect.sync(() => streams.eventsOf(executionId).slice(version)), + append: (runId, event, expectedVersion, lineage) => + Effect.suspend(() => appended(runId, event, expectedVersion, lineage)), + eventsAfter: (runId, version) => Effect.sync(() => streams.eventsOf(runId).slice(version)), saveSnapshot: (snapshot) => Effect.sync(() => { snapshots.save(snapshot); }), - events: (executionId) => streams.eventsOf(executionId).slice(), + events: (runId) => streams.eventsOf(runId).slice(), lineages: streams.lineagesOf, snapshotOf: snapshots.snapshotOf, versions: streams.versions, - loads: (executionId) => loads.get(executionId) ?? 0, + loads: (runId) => loads.get(runId) ?? 0, snapshotsSaved: snapshots.savedOf, failNextAppend: (fault) => { pending.fault = fault; diff --git a/packages/workflow-engine/src/reactions/reaction-probes.ts b/packages/workflow-engine/src/reactions/reaction-probes.ts index 863d9c316..50de72420 100644 --- a/packages/workflow-engine/src/reactions/reaction-probes.ts +++ b/packages/workflow-engine/src/reactions/reaction-probes.ts @@ -18,10 +18,10 @@ export interface EmitterSubject { const armedBy: OutputOrigin = { version: 2, lastStep: null }; -function listenerOf({ executionId }: RunContext, reference: string): ArmListener { +function listenerOf({ runId }: RunContext, reference: string): ArmListener { return { kind: 'arm_listener', - key: { executionId, reference, run: 1 }, + key: { runId, reference, run: 1 }, filters: [{ type: 'com.acme.closed', data: { region: 'eu' } }], }; } @@ -59,10 +59,10 @@ export const listenerProbes: readonly Probe[] = [ }, ]; -function emissionOf({ executionId }: RunContext): EmitEvent { +function emissionOf({ runId }: RunContext): EmitEvent { return { kind: 'emit_event', - key: { executionId, reference: '/do/0/announce', run: 1 }, + key: { runId, reference: '/do/0/announce', run: 1 }, event: { specversion: '1.0', id: '0b1c2d3e-4f50-5a6b-8c7d-8e9fa0b1c2d3', diff --git a/packages/workflow-engine/src/run-log/corpus.test.ts b/packages/workflow-engine/src/run-log/corpus.test.ts index b9fb0cae3..5081828d4 100644 --- a/packages/workflow-engine/src/run-log/corpus.test.ts +++ b/packages/workflow-engine/src/run-log/corpus.test.ts @@ -6,14 +6,15 @@ import { describe, expect, it } from 'vitest'; import { loadedRunOf, - RunEventSchema, + RunLogEventSchema, + SnapshotSchema, snapshotFromChunks, stateFormats, stateInCurrentFormat, StateFormatSchema, } from '../index.ts'; -const StoredEventsSchema = Schema.Array(Schema.Struct({ version: Schema.Int, event: RunEventSchema })); +const StoredEventsSchema = Schema.Array(Schema.Struct({ version: Schema.Int, event: RunLogEventSchema })); const CorpusSchema = Schema.Struct({ format: StateFormatSchema, @@ -24,28 +25,60 @@ const CorpusSchema = Schema.Struct({ type Corpus = typeof CorpusSchema.Type; -const decodeCorpus = Schema.decodeUnknownSync(Schema.fromJsonString(CorpusSchema)); +const CorpusJsonSchema = Schema.toCodecJson(CorpusSchema); + +const decodeCorpus = Schema.decodeUnknownSync(CorpusJsonSchema); + +const writeCorpus = Schema.encodeSync(CorpusJsonSchema); + +const writeSnapshot = Schema.encodeSync(Schema.toCodecJson(SnapshotSchema)); + +const readJson = Schema.decodeUnknownSync(Schema.fromJsonString(Schema.Json)); const directory = fileURLToPath(new URL('../../corpus/', import.meta.url)); -const corpora: readonly Corpus[] = readdirSync(directory) +const committed: readonly unknown[] = readdirSync(directory) .filter((name) => name.endsWith('.json')) - .map((name) => decodeCorpus(readFileSync(`${directory}${name}`, 'utf8'))); + .map((name) => readJson(readFileSync(`${directory}${name}`, 'utf8'))); + +const corpora: readonly Corpus[] = committed.map((file) => decodeCorpus(file)); + +function snapshotIn({ snapshot }: Corpus) { + return Result.getOrThrow(snapshotFromChunks(snapshot.chunks)); +} -function loadedBothWays({ stream, snapshot }: Corpus): readonly unknown[] { - const whole = loadedRunOf({ snapshot: null, tail: stream }); +function loadedBothWays(corpus: Corpus): readonly unknown[] { + const whole = loadedRunOf({ snapshot: null, tail: corpus.stream }); const fromSnapshot = loadedRunOf({ - snapshot: { snapshot: Result.getOrThrow(snapshotFromChunks(snapshot.chunks)), bytes: snapshot.bytes }, - tail: snapshot.tail, + snapshot: { snapshot: snapshotIn(corpus), bytes: corpus.snapshot.bytes }, + tail: corpus.snapshot.tail, }); return [whole.state, fromSnapshot.state, whole.version, fromSnapshot.version]; } +function formatsNamed(corpus: Corpus): readonly number[] { + return [ + ...new Set([ + ...corpus.stream.map(({ event }) => event.format), + ...corpus.snapshot.tail.map(({ event }) => event.format), + snapshotIn(corpus).format, + ]), + ]; +} + describe('the committed corpus of past state formats', () => { it('holds a stream and a snapshot for every state format up to the current one', () => { expect(corpora.map(({ format }) => format).toSorted((first, second) => first - second)).toEqual( Array.from({ length: stateFormats.current }, (_, index) => index + 1), ); + expect(corpora.map((corpus) => formatsNamed(corpus))).toEqual(corpora.map(({ format }) => [format])); + }); + + it('reads each event and snapshot with the schemas of the format it names, and writes it back as it was committed', () => { + expect(corpora.map((corpus) => writeCorpus(corpus))).toEqual(committed); + expect(corpora.map((corpus) => writeSnapshot(snapshotIn(corpus)))).toEqual( + corpora.map(({ snapshot }) => readJson(snapshot.chunks.join(''))), + ); }); it('still loads, from the whole stream and from the snapshot and its tail, to the state it recorded', () => { diff --git a/packages/workflow-engine/src/run-log/format-five.test.ts b/packages/workflow-engine/src/run-log/formats/format-five.test.ts similarity index 90% rename from packages/workflow-engine/src/run-log/format-five.test.ts rename to packages/workflow-engine/src/run-log/formats/format-five.test.ts index aff4d3c50..bd5fd54a9 100644 --- a/packages/workflow-engine/src/run-log/format-five.test.ts +++ b/packages/workflow-engine/src/run-log/formats/format-five.test.ts @@ -4,12 +4,12 @@ import { fileURLToPath } from 'node:url'; import { Schema } from 'effect'; import { describe, expect, it } from 'vitest'; -import { stateFormats, stateInCurrentFormat } from '../index.ts'; +import { stateFormats, stateInCurrentFormat } from '../../index.ts'; const CorpusStateSchema = Schema.Struct({ state: Schema.JsonObject }); const { state: stateOfFormatFive } = Schema.decodeUnknownSync(Schema.fromJsonString(CorpusStateSchema))( - readFileSync(fileURLToPath(new URL('../../corpus/format-5.json', import.meta.url)), 'utf8'), + readFileSync(fileURLToPath(new URL('../../../corpus/format-5.json', import.meta.url)), 'utf8'), ); const [, , , , formatFive] = stateFormats.older; diff --git a/packages/workflow-engine/src/run-log/format-five.ts b/packages/workflow-engine/src/run-log/formats/format-five.ts similarity index 99% rename from packages/workflow-engine/src/run-log/format-five.ts rename to packages/workflow-engine/src/run-log/formats/format-five.ts index 0abded9e1..1ae7d4fcb 100644 --- a/packages/workflow-engine/src/run-log/format-five.ts +++ b/packages/workflow-engine/src/run-log/formats/format-five.ts @@ -1,5 +1,6 @@ import { Schema } from 'effect'; +import type { OlderFormat } from '../state-format.ts'; import { CallKeySchema, InstantSchema, @@ -7,7 +8,6 @@ import { RunLimitsSchema, TimerPurposeSchema, } from './format-two.ts'; -import type { OlderFormat } from './state-format.ts'; const DslErrorSchema = Schema.Struct({ type: Schema.String, diff --git a/packages/workflow-engine/src/run-log/format-four.test.ts b/packages/workflow-engine/src/run-log/formats/format-four.test.ts similarity index 87% rename from packages/workflow-engine/src/run-log/format-four.test.ts rename to packages/workflow-engine/src/run-log/formats/format-four.test.ts index 8725ea4c4..2a49fc416 100644 --- a/packages/workflow-engine/src/run-log/format-four.test.ts +++ b/packages/workflow-engine/src/run-log/formats/format-four.test.ts @@ -4,12 +4,12 @@ import { fileURLToPath } from 'node:url'; import { Schema } from 'effect'; import { describe, expect, it } from 'vitest'; -import { stateFormats, stateInCurrentFormat } from '../index.ts'; +import { stateFormats, stateInCurrentFormat } from '../../index.ts'; const CorpusStateSchema = Schema.Struct({ state: Schema.Json }); const { state: stateOfFormatFour } = Schema.decodeUnknownSync(Schema.fromJsonString(CorpusStateSchema))( - readFileSync(fileURLToPath(new URL('../../corpus/format-4.json', import.meta.url)), 'utf8'), + readFileSync(fileURLToPath(new URL('../../../corpus/format-4.json', import.meta.url)), 'utf8'), ); const [, , , formatFour] = stateFormats.older; @@ -30,7 +30,7 @@ describe('a state of format 4', () => { expect([upcast.inbox.offeredIds, upcast.emitted, Object.values(upcast.listeners)]).toEqual([ [], { count: 0, bytes: 0 }, - [{ executionId: upcast.executionId, reference: listening, run: 1 }], + [{ runId: upcast.runId, reference: listening, run: 1 }], ]); }); diff --git a/packages/workflow-engine/src/run-log/format-four.ts b/packages/workflow-engine/src/run-log/formats/format-four.ts similarity index 99% rename from packages/workflow-engine/src/run-log/format-four.ts rename to packages/workflow-engine/src/run-log/formats/format-four.ts index 555d75266..c3d860558 100644 --- a/packages/workflow-engine/src/run-log/format-four.ts +++ b/packages/workflow-engine/src/run-log/formats/format-four.ts @@ -1,5 +1,6 @@ import { Schema } from 'effect'; +import type { OlderFormat } from '../state-format.ts'; import { CallKeySchema, InstantSchema, @@ -7,7 +8,6 @@ import { RunLimitsSchema, TimerPurposeSchema, } from './format-two.ts'; -import type { OlderFormat } from './state-format.ts'; const DslErrorSchema = Schema.Struct({ type: Schema.String, diff --git a/packages/workflow-engine/src/run-log/format-one.test.ts b/packages/workflow-engine/src/run-log/formats/format-one.test.ts similarity index 86% rename from packages/workflow-engine/src/run-log/format-one.test.ts rename to packages/workflow-engine/src/run-log/formats/format-one.test.ts index 9d616cda4..02f20bde6 100644 --- a/packages/workflow-engine/src/run-log/format-one.test.ts +++ b/packages/workflow-engine/src/run-log/formats/format-one.test.ts @@ -1,8 +1,8 @@ import { Schema } from 'effect'; import { describe, expect, it } from 'vitest'; -import { stateFormats, stateInCurrentFormat } from '../index.ts'; -import { at, executionId } from '../testing/runs.ts'; +import { stateFormats, stateInCurrentFormat } from '../../index.ts'; +import { at, runId } from '../../testing/runs.ts'; const toJson = Schema.decodeUnknownSync(Schema.Json); @@ -21,7 +21,7 @@ const askingOfFormatOne = { timeout: null, body: { kind: 'call', - key: { executionId, reference: '/do/0/race/fork/branches/3/ask', run: 1 }, + key: { executionId: runId, reference: '/do/0/race/fork/branches/3/ask', run: 1 }, function: 'notify', arguments: 0, label: 'notify', @@ -49,7 +49,7 @@ const frameOfFormatOne = { input: 0, variables: {}, timeout: null, - body: { kind: 'wait', timer: `${executionId}/timers/1` }, + body: { kind: 'wait', timer: `${runId}/timers/1` }, }, }, { state: 'failed', error: { type: 'runtime', status: 500, instance: '/c' } }, @@ -65,7 +65,7 @@ const askDeadline = { purpose: 'call_deadline', reference: askingOfFormatOne.ref function runningOfFormatOne(root: unknown, armed: Readonly>): Schema.Json { return toJson({ ...initialOfFormatOne, - executionId, + executionId: runId, status: 'running', lastInputAt: at, timers: { next: 3, armed }, @@ -74,8 +74,8 @@ function runningOfFormatOne(root: unknown, armed: Readonly { @@ -83,8 +83,8 @@ describe('a state of format 1', () => { const upcast = stateInCurrentFormat(1, stateOfFormatOne); expect(upcast.timers.armed).toEqual({ - [`${executionId}/timers/1`]: { ...waitTimer, armedAt: at }, - [`${executionId}/timers/2`]: { ...askDeadline, armedAt: at }, + [`${runId}/timers/1`]: { ...waitTimer, armedAt: at }, + [`${runId}/timers/2`]: { ...askDeadline, armedAt: at }, }); expect(upcast.machine.root).toMatchObject({ startedAt: at, @@ -94,7 +94,7 @@ describe('a state of format 1', () => { { state: 'failed', order: 0 }, { state: 'running', task: { startedAt: at, context: 0 } }, { state: 'failed', order: 2 }, - { state: 'running', task: { body: { kind: 'call', deadline: `${executionId}/timers/2` } } }, + { state: 'running', task: { body: { kind: 'call', deadline: `${runId}/timers/2` } } }, ], }, }); @@ -127,7 +127,7 @@ describe('the inbox of a state of format 1', () => { it('does not load when a call in it has no deadline armed, which every call of format 1 armed', () => { expect(() => - stateInCurrentFormat(1, runningOfFormatOne(frameOfFormatOne, { [`${executionId}/timers/1`]: waitTimer })), + stateInCurrentFormat(1, runningOfFormatOne(frameOfFormatOne, { [`${runId}/timers/1`]: waitTimer })), ).toThrow(/deadline/u); }); }); diff --git a/packages/workflow-engine/src/run-log/format-one.ts b/packages/workflow-engine/src/run-log/formats/format-one.ts similarity index 98% rename from packages/workflow-engine/src/run-log/format-one.ts rename to packages/workflow-engine/src/run-log/formats/format-one.ts index 1b4723a92..e8c7730e0 100644 --- a/packages/workflow-engine/src/run-log/format-one.ts +++ b/packages/workflow-engine/src/run-log/formats/format-one.ts @@ -1,5 +1,7 @@ import { Schema } from 'effect'; +import type { OlderFormat } from '../state-format.ts'; +import { isRecord } from '../state-patch.ts'; import { CallKeySchema, DslErrorSchema, @@ -9,8 +11,6 @@ import { RunOutcomeSchema, TimerPurposeSchema, } from './format-two.ts'; -import type { OlderFormat } from './state-format.ts'; -import { isRecord } from './state-patch.ts'; type Fields = Readonly>; diff --git a/packages/workflow-engine/src/run-log/formats/format-six-records.test.ts b/packages/workflow-engine/src/run-log/formats/format-six-records.test.ts new file mode 100644 index 000000000..4a07833f9 --- /dev/null +++ b/packages/workflow-engine/src/run-log/formats/format-six-records.test.ts @@ -0,0 +1,225 @@ +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; + +import { Result, Schema } from 'effect'; +import { describe, expect, it } from 'vitest'; + +import { loadedRunOf, RunLogEventSchema, snapshotFromChunks, type RunOutput } from '../../index.ts'; + +const CorpusOfFormatSixSchema = Schema.Struct({ + stream: Schema.Array(Schema.Struct({ version: Schema.Int, event: Schema.JsonObject })), + snapshot: Schema.Struct({ chunks: Schema.Array(Schema.String) }), +}); + +const { stream, snapshot } = Schema.decodeUnknownSync(Schema.fromJsonString(CorpusOfFormatSixSchema))( + readFileSync(fileURLToPath(new URL('../../../corpus/format-6.json', import.meta.url)), 'utf8'), +); + +const snapshotText = snapshot.chunks.join(''); + +const decodeEvent = Schema.decodeUnknownResult(Schema.toCodecJson(RunLogEventSchema)); + +const readJson = Schema.decodeUnknownSync(Schema.fromJsonString(Schema.Json)); + +const runId = '0199a3c4-7d2e-7c1a-9b3f-2f1e0d9c8b7a'; + +const heldArguments = { primitive: 'inference', name: 'summarize', input: { text: 'hello' } }; + +const callOfFormatSix = { + type: 'input_applied', + format: 6, + receipt: { kind: 'started', key: '0199a3c4-7d2e-7c1a-9b3f-000000000302', at: 1790845200000 }, + steps: [ + { + reference: '/do/0/summarize', + run: 1, + outcome: 'waiting', + name: 'summarize', + times: 1, + caused_by: 'input', + waits_for: 'call', + }, + ], + resumed: null, + patch: [ + { op: 'replace', path: '/executionId', value: '0199a3c4-7d2e-7c1a-9b3f-000000000302' }, + { op: 'replace', path: '/status', value: 'running' }, + { + op: 'replace', + path: '/workflow', + value: { + document: { + document: { dsl: '1.0.3', namespace: 'acme', name: 'test', version: '1.0.0' }, + do: [ + { + summarize: { + call: 'execute_spec', + with: { primitive: 'inference', name: 'summarize', input: { text: '${ .text }' } }, + }, + }, + { answer: { set: { summary: '${ .summary }', runtime: '${ $runtime.name }' } } }, + ], + }, + input: 1, + }, + }, + { op: 'replace', path: '/limits/mostDurationMs', value: 2592000000 }, + { op: 'replace', path: '/limits/longestCallMs', value: 600000 }, + { op: 'replace', path: '/startedAt', value: 1790845200000 }, + { op: 'replace', path: '/lastInputAt', value: 1790845200000 }, + { op: 'replace', path: '/inputs', value: 1 }, + { op: 'replace', path: '/random/seed', value: 7 }, + { op: 'add', path: '/runs/~1do~10~1summarize', value: 1 }, + { op: 'replace', path: '/timers/next', value: 3 }, + { + op: 'add', + path: '/timers/armed/1', + value: { purpose: 'deadline', reference: '/', armedAt: 1790845200000, dueAt: 1793437200000 }, + }, + { + op: 'add', + path: '/timers/armed/2', + value: { purpose: 'call_deadline', reference: '/do/0/summarize', armedAt: 1790845200000, dueAt: 1790845800000 }, + }, + { + op: 'add', + path: '/calls/["0199a3c4-7d2e-7c1a-9b3f-000000000302","~1do~10~1summarize",1]', + value: { executionId: '0199a3c4-7d2e-7c1a-9b3f-000000000302', reference: '/do/0/summarize', run: 1 }, + }, + { op: 'replace', path: '/heldBytes', value: 8563 }, + { op: 'add', path: '/machine/values/1', value: { value: { text: 'hello' }, bytes: 16 } }, + { + op: 'add', + path: '/machine/values/2', + value: { value: { primitive: 'inference', name: 'summarize', input: { text: 'hello' } }, bytes: 69 }, + }, + { op: 'replace', path: '/machine/nextValue', value: 3 }, + { + op: 'replace', + path: '/machine/root', + value: { + reference: '/', + run: 1, + startedAt: 1790845200000, + context: 0, + rawInput: 1, + input: 1, + variables: {}, + timeout: null, + body: { + kind: 'list', + list: { + pointer: '/do', + position: 0, + data: 1, + variables: {}, + current: { + kind: 'running', + task: { + reference: '/do/0/summarize', + run: 1, + startedAt: 1790845200000, + context: 0, + rawInput: 1, + input: 1, + variables: {}, + timeout: null, + body: { + kind: 'call', + key: { executionId: '0199a3c4-7d2e-7c1a-9b3f-000000000302', reference: '/do/0/summarize', run: 1 }, + function: 'execute_spec', + arguments: 2, + label: 'the reasoning function summarize', + deadline: '2', + }, + }, + }, + }, + }, + }, + }, + { op: 'replace', path: '/historyBytes', value: 3332 }, + ], + outputs: [ + { + kind: 'arm_timer', + executionId: '0199a3c4-7d2e-7c1a-9b3f-000000000302', + timerId: '1', + dueAt: 1793437200000, + purpose: 'deadline', + label: 'the most the workflow may run', + }, + { + kind: 'start_call', + key: { executionId: '0199a3c4-7d2e-7c1a-9b3f-000000000302', reference: '/do/0/summarize', run: 1 }, + function: 'execute_spec', + arguments: { primitive: 'inference', name: 'summarize', input: { text: 'hello' } }, + longestMs: 600000, + }, + { + kind: 'arm_timer', + executionId: '0199a3c4-7d2e-7c1a-9b3f-000000000302', + timerId: '2', + dueAt: 1790845800000, + purpose: 'call_deadline', + label: '/do/0/summarize deadline', + }, + ], +}; + +function decoded(event: unknown) { + return Result.getOrThrow(decodeEvent(event)); +} + +function runIdOf(output: RunOutput): string { + return 'key' in output ? output.key.runId : output.runId; +} + +describe('an event of formats 1 to 6', () => { + it('is read with its outputs and call keys naming the run executionId, and gives them naming it runId', () => { + const outputs = stream.flatMap(({ event }) => decoded(event).outputs); + + expect(outputs.map((output) => runIdOf(output))).toEqual(Array.from({ length: 14 }, () => runId)); + }); + + it('is refused when an output names the run as format 7 does, or when it holds a member format 6 does not describe', () => { + const settling = JSON.stringify(stream.at(-1)?.event).replaceAll('"executionId":', '"runId":'); + + expect([ + Result.isFailure(decodeEvent(readJson(settling))), + Result.isFailure(decodeEvent({ ...stream[0]?.event, noted: true })), + ]).toEqual([true, true]); + }); +}); + +describe('a snapshot of formats 1 to 6', () => { + it('is read with the run named executionId, and gives it naming the run runId', () => { + expect(Result.getOrThrow(snapshotFromChunks(snapshot.chunks))).toMatchObject({ format: 6, runId, version: 2 }); + }); + + it('is refused when it names the run as format 7 does, or holds a member format 6 does not describe', () => { + expect([ + Result.isFailure(snapshotFromChunks([snapshotText.replace('"executionId":', '"runId":')])), + Result.isFailure(snapshotFromChunks([snapshotText.replace('"version":', '"noted":true,"version":')])), + ]).toEqual([true, true]); + }); +}); + +describe('a log of format 6 of a call', () => { + it('loads unchanged when its arguments say execute_spec and primitive, which are data the document holds', () => { + const event = decoded(callOfFormatSix); + const { state } = loadedRunOf({ snapshot: null, tail: [{ version: 1, event }] }); + + expect(event.outputs).toContainEqual({ + kind: 'start_call', + key: { runId: callOfFormatSix.receipt.key, reference: '/do/0/summarize', run: 1 }, + function: 'execute_spec', + arguments: heldArguments, + longestMs: 600_000, + }); + expect(state.workflow?.document).toMatchObject({ + do: [{ summarize: { call: 'execute_spec', with: { primitive: 'inference', name: 'summarize' } } }, {}], + }); + expect(state.machine.values[2]?.value).toEqual(heldArguments); + }); +}); diff --git a/packages/workflow-engine/src/run-log/formats/format-six-records.ts b/packages/workflow-engine/src/run-log/formats/format-six-records.ts new file mode 100644 index 000000000..e1fb27b22 --- /dev/null +++ b/packages/workflow-engine/src/run-log/formats/format-six-records.ts @@ -0,0 +1,213 @@ +import { Schema } from 'effect'; + +import type { RunOutput } from '../../dispatch/run-output.ts'; +import { RunCountSchema, StepCauseSchema, StepOutcomeSchema, withExecutionId, withRunId } from './format-six.ts'; +import { CallKeySchema, InstantSchema, TimerPurposeSchema } from './format-two.ts'; + +export const FormatsOneToSixSchema = Schema.Literals([1, 2, 3, 4, 5, 6]); + +const keyed = { key: Schema.String, at: InstantSchema }; + +const ReceiptSchema = Schema.Union([ + Schema.Struct({ kind: Schema.Literal('started'), ...keyed }), + Schema.Struct({ kind: Schema.Literal('timer_fired'), ...keyed }), + Schema.Struct({ + kind: Schema.Literal('call_answered'), + ...keyed, + status: Schema.Literals(['succeeded', 'rejected', 'failed', 'unreachable']), + rejection: Schema.optionalKey( + Schema.Struct({ kind: Schema.optionalKey(Schema.String), because: Schema.optionalKey(Schema.String) }), + ), + }), + Schema.Struct({ kind: Schema.Literal('event_received'), ...keyed, eventType: Schema.String }), + Schema.Struct({ kind: Schema.Literal('event_offered'), ...keyed, eventType: Schema.String }), + Schema.Struct({ + kind: Schema.Literal('cancel_requested'), + ...keyed, + cancel: Schema.optionalKey( + Schema.Struct({ by: Schema.String, kind: Schema.Literals(['requested', 'deadline', 'parent_ended']) }), + ), + }), +]); + +const StepSchema = Schema.Struct({ + reference: Schema.String, + run: RunCountSchema, + outcome: StepOutcomeSchema, + name: Schema.String, + times: RunCountSchema, + caused_by: StepCauseSchema, + error: Schema.optionalKey(Schema.Struct({ type: Schema.String, title: Schema.optionalKey(Schema.String) })), + waits_for: Schema.optionalKey(Schema.Literals(['call', 'timer', 'event'])), + child: Schema.optionalKey(Schema.String), +}); + +const EarlierStepSchema = Schema.Struct({ reference: Schema.String, run: RunCountSchema, outcome: StepOutcomeSchema }); + +const ResumedSchema = Schema.Struct({ reference: Schema.String, run: RunCountSchema, times: RunCountSchema }); + +const PatchOperationSchema = Schema.Union([ + Schema.Struct({ op: Schema.Literal('add'), path: Schema.String, value: Schema.Json }), + Schema.Struct({ op: Schema.Literal('replace'), path: Schema.String, value: Schema.Json }), + Schema.Struct({ op: Schema.Literal('remove'), path: Schema.String }), +]); + +const IssueSchema = Schema.Struct({ detail: Schema.String, pointer: Schema.String }); + +const settledBy = { by: Schema.optionalKey(Schema.NonEmptyString) }; + +const recorded = { record: Schema.optionalKey(Schema.JsonObject), ...settledBy }; + +const rejected = { status: Schema.Literal('rejected'), detail: Schema.String, ...recorded }; + +const UnavailableKindSchema = Schema.Literals([ + 'model_not_offered', + 'tool_not_offered', + 'mcp_server_failed', + 'tools_unfinished', + 'rebuilding', + 'requests_full', +]); + +const UnavailableBecauseSchema = Schema.Literals([ + 'provider_not_configured', + 'model_not_allowed', + 'mcp_server_not_configured', + 'tool_not_allowed', + 'tool_not_listed', + 'not_testable', + 'failing', + 'rate_limited', + 'unreachable', + 'key_refused', + 'server_failed', + 'model_unavailable', + 'run_bound', + 'no_answer', +]); + +const ConflictKindSchema = Schema.Literals([ + 'taken', + 'retired', + 'concurrent_change', + 'unworkable', + 'stalled', + 'tools_called', + 'oversized', +]); + +const SettlementSchema = Schema.Union([ + Schema.Struct({ status: Schema.Literal('succeeded'), output: Schema.Json, ...recorded }), + Schema.Struct({ + ...rejected, + reason: Schema.Literal('invalid_input'), + issues: Schema.optionalKey(Schema.Array(IssueSchema)), + }), + Schema.Struct({ + ...rejected, + reason: Schema.Literal('unavailable'), + kind: Schema.optionalKey(UnavailableKindSchema), + because: Schema.optionalKey(UnavailableBecauseSchema), + }), + Schema.Struct({ ...rejected, reason: Schema.Literal('conflict'), kind: Schema.optionalKey(ConflictKindSchema) }), + Schema.Struct({ + ...rejected, + reason: Schema.Literal('cancelled'), + kind: Schema.Literals(['requested', 'deadline', 'overrun', 'parent_ended']), + }), + Schema.Struct({ + ...rejected, + reason: Schema.Literal('unanswered'), + kind: Schema.Literals(['expired', 'undelivered']), + }), + Schema.Struct({ status: Schema.Literal('failed'), incident: Schema.optionalKey(Schema.String), ...settledBy }), +]); + +const OutputSchema = Schema.Union([ + Schema.Struct({ + kind: Schema.Literal('arm_timer'), + executionId: Schema.NonEmptyString, + timerId: Schema.NonEmptyString, + dueAt: InstantSchema, + purpose: TimerPurposeSchema, + label: Schema.optionalKey(Schema.String), + }), + Schema.Struct({ + kind: Schema.Literal('cancel_timer'), + executionId: Schema.NonEmptyString, + timerId: Schema.NonEmptyString, + }), + Schema.Struct({ + kind: Schema.Literal('start_call'), + key: CallKeySchema, + function: Schema.NonEmptyString, + arguments: Schema.Json, + longestMs: Schema.Int.check(Schema.isGreaterThanOrEqualTo(1)), + }), + Schema.Struct({ + kind: Schema.Literal('cancel_call'), + key: CallKeySchema, + reason: Schema.optionalKey(Schema.Literals(['deadline', 'parent_ended'])), + }), + Schema.Struct({ kind: Schema.Literal('arm_listener'), key: CallKeySchema, filters: Schema.Array(Schema.JsonObject) }), + Schema.Struct({ kind: Schema.Literal('cancel_listener'), key: CallKeySchema }), + Schema.Struct({ kind: Schema.Literal('emit_event'), key: CallKeySchema, event: Schema.JsonObject }), + Schema.Struct({ kind: Schema.Literal('settle'), executionId: Schema.NonEmptyString, settlement: SettlementSchema }), +]); + +type OutputOfFormatSix = typeof OutputSchema.Type; + +export const EventOfFormatsOneToSixSchema = Schema.Struct({ + type: Schema.Literal('input_applied'), + format: FormatsOneToSixSchema, + receipt: ReceiptSchema, + steps: Schema.Array(Schema.Union([StepSchema, EarlierStepSchema])), + resumed: Schema.optionalKey(Schema.NullOr(ResumedSchema)), + patch: Schema.Array(PatchOperationSchema), + outputs: Schema.Array(OutputSchema), +}); + +function outputWithRunId(output: OutputOfFormatSix): RunOutput { + if (output.kind === 'arm_timer') { + return withRunId(output); + } + if (output.kind === 'cancel_timer') { + return withRunId(output); + } + if (output.kind === 'settle') { + return withRunId(output); + } + return { ...output, key: withRunId(output.key) }; +} + +function outputWithExecutionId(output: RunOutput): unknown { + return 'key' in output ? { ...output, key: withExecutionId(output.key) } : withExecutionId(output); +} + +function eventWithRunIds({ outputs, ...event }: typeof EventOfFormatsOneToSixSchema.Type) { + return { ...event, outputs: outputs.map((output) => outputWithRunId(output)) }; +} + +function eventWithExecutionIds({ outputs, ...event }: { readonly outputs: readonly RunOutput[] }): unknown { + return { ...event, outputs: outputs.map((output) => outputWithExecutionId(output)) }; +} + +export const eventNamesOfFormatsOneToSix = { current: eventWithRunIds, written: eventWithExecutionIds }; + +export const SnapshotOfFormatsOneToSixSchema = Schema.Struct({ + format: FormatsOneToSixSchema, + executionId: Schema.NonEmptyString, + version: Schema.Int.check(Schema.isGreaterThanOrEqualTo(1)), + historyBytes: Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)), + state: Schema.Json, +}); + +function snapshotWithRunId(snapshot: typeof SnapshotOfFormatsOneToSixSchema.Type) { + return withRunId(snapshot); +} + +function snapshotWithExecutionId(snapshot: { readonly runId: string }): unknown { + return withExecutionId(snapshot); +} + +export const snapshotNamesOfFormatsOneToSix = { current: snapshotWithRunId, written: snapshotWithExecutionId }; diff --git a/packages/workflow-engine/src/run-log/formats/format-six.test.ts b/packages/workflow-engine/src/run-log/formats/format-six.test.ts new file mode 100644 index 000000000..feb8a792a --- /dev/null +++ b/packages/workflow-engine/src/run-log/formats/format-six.test.ts @@ -0,0 +1,74 @@ +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; + +import { Schema } from 'effect'; +import { describe, expect, it } from 'vitest'; + +import { stateFormats, stateInCurrentFormat } from '../../index.ts'; + +const CorpusOfFormatSixSchema = Schema.Struct({ snapshot: Schema.Struct({ chunks: Schema.Array(Schema.String) }) }); + +const SnapshotOfFormatSixSchema = Schema.Struct({ executionId: Schema.String, state: Schema.JsonObject }); + +const readSnapshot = Schema.decodeUnknownSync(Schema.fromJsonString(SnapshotOfFormatSixSchema)); + +const { snapshot } = Schema.decodeUnknownSync(Schema.fromJsonString(CorpusOfFormatSixSchema))( + readFileSync(fileURLToPath(new URL('../../../corpus/format-6.json', import.meta.url)), 'utf8'), +); + +const snapshotText = snapshot.chunks.join(''); + +const { executionId: runId, state: stateOfFormatSix } = readSnapshot(snapshotText); + +const [, , , , , formatSix] = stateFormats.older; + +const asking = '/do/0/all/fork/branches/3/asking/try/0/ask'; + +const heldValue = { value: { executionId: 'a value the run holds' }, bytes: 41 }; + +describe('a state of format 6', () => { + it('is read strictly as format 6 and upcast to name its run runId, in its calls, its listeners and the key of every call it waits on', () => { + const upcast = stateInCurrentFormat(6, stateOfFormatSix); + + expect(formatSix?.format).toBe(6); + expect([upcast.runId, Object.values(upcast.calls), Object.values(upcast.listeners)]).toEqual([ + runId, + [{ runId, reference: asking, run: 1 }], + [{ runId, reference: '/do/0/all/fork/branches/1/both', run: 1 }], + ]); + expect(upcast.machine.root).toMatchObject({ + body: { + list: { + current: { + task: { + body: { + branches: [ + {}, + {}, + {}, + { + task: { + body: { phase: { list: { current: { task: { body: { key: { runId, reference: asking } } } } } } }, + }, + }, + ], + }, + }, + }, + }, + }, + }); + }); + + it('keeps the names of the values it holds, which are data', () => { + const { state } = readSnapshot(snapshotText.replace('"values":{', `"values":{"9":${JSON.stringify(heldValue)},`)); + + expect(stateInCurrentFormat(6, state).machine.values[9]).toEqual(heldValue); + }); + + it('is refused when it names its run as format 7 does', () => { + const { executionId: _executionId, ...withoutExecutionId } = stateOfFormatSix; + + expect(() => stateInCurrentFormat(6, { ...withoutExecutionId, runId })).toThrow(/excess property/u); + }); +}); diff --git a/packages/workflow-engine/src/run-log/formats/format-six.ts b/packages/workflow-engine/src/run-log/formats/format-six.ts new file mode 100644 index 000000000..e876b2845 --- /dev/null +++ b/packages/workflow-engine/src/run-log/formats/format-six.ts @@ -0,0 +1,256 @@ +import { Schema } from 'effect'; + +import type { CallKey } from '../../executor/call-key.ts'; +import type { OlderFormat } from '../state-format.ts'; +import { CallKeySchema, InstantSchema, ReceivedEventSchema, TimerPurposeSchema } from './format-two.ts'; + +const DslErrorSchema = Schema.Struct({ + type: Schema.String, + status: Schema.Int, + instance: Schema.String, + title: Schema.optionalKey(Schema.String), + detail: Schema.optionalKey(Schema.String), + kind: Schema.optionalKey(Schema.String), + because: Schema.optionalKey(Schema.String), +}); + +export const RunCountSchema = Schema.Int.check(Schema.isGreaterThanOrEqualTo(1)); + +export const StepOutcomeSchema = Schema.Literals([ + 'started', + 'skipped', + 'waiting', + 'completed', + 'raised', + 'timed_out', + 'cancelled', +]); + +export const StepCauseSchema = Schema.Union([ + Schema.Literal('input'), + Schema.Struct({ reference: Schema.String, run: RunCountSchema, outcome: StepOutcomeSchema, times: RunCountSchema }), +]); + +const ValueIdSchema = Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)); + +const VariablesSchema = Schema.Record(Schema.String, ValueIdSchema); + +const TaskFrameReference = Schema.suspend((): Schema.Codec => TaskFrameSchema); + +const ListCursorSchema = Schema.Struct({ + pointer: Schema.String, + position: Schema.Int, + data: ValueIdSchema, + variables: VariablesSchema, + current: Schema.Union([ + Schema.Struct({ kind: Schema.Literal('running'), task: TaskFrameReference }), + Schema.Struct({ kind: Schema.Literal('yielding'), timer: Schema.String, after: StepCauseSchema }), + ]), +}); + +const BranchSchema = Schema.Union([ + Schema.Struct({ state: Schema.Literal('yielding'), timer: Schema.String }), + Schema.Struct({ state: Schema.Literal('running'), task: TaskFrameReference }), + Schema.Struct({ state: Schema.Literal('finished'), output: ValueIdSchema, flow: Schema.String }), + Schema.Struct({ state: Schema.Literal('failed'), error: DslErrorSchema, order: Schema.Int }), +]); + +const TryPhaseSchema = Schema.Union([ + Schema.Struct({ kind: Schema.Literal('trying'), list: ListCursorSchema, attemptLimit: Schema.NullOr(Schema.String) }), + Schema.Struct({ + kind: Schema.Literal('backing_off'), + timer: Schema.String, + error: DslErrorSchema, + failed: StepCauseSchema, + }), + Schema.Struct({ kind: Schema.Literal('recovering'), list: ListCursorSchema }), +]); + +const FrameBodySchema = Schema.Union([ + Schema.Struct({ kind: Schema.Literal('list'), list: ListCursorSchema }), + Schema.Struct({ + kind: Schema.Literal('for'), + items: ValueIdSchema, + index: Schema.Int, + data: ValueIdSchema, + list: ListCursorSchema, + }), + Schema.Struct({ kind: Schema.Literal('fork'), compete: Schema.Boolean, branches: Schema.Array(BranchSchema) }), + Schema.Struct({ kind: Schema.Literal('try'), attempt: Schema.Int, startedAt: InstantSchema, phase: TryPhaseSchema }), + Schema.Struct({ kind: Schema.Literal('wait'), timer: Schema.String }), + Schema.Struct({ + kind: Schema.Literal('call'), + key: CallKeySchema, + function: Schema.NonEmptyString, + arguments: ValueIdSchema, + label: Schema.String, + deadline: Schema.String, + }), + Schema.Struct({ + kind: Schema.Literal('listen'), + consumed: Schema.Array(Schema.NullOr(ValueIdSchema)), + waited: Schema.Int, + }), +]); + +const TaskFrameSchema = Schema.Struct({ + reference: Schema.String, + run: Schema.Int, + startedAt: InstantSchema, + context: ValueIdSchema, + rawInput: ValueIdSchema, + input: ValueIdSchema, + variables: VariablesSchema, + timeout: Schema.NullOr(Schema.String), + body: FrameBodySchema, +}); + +const PositiveMillisecondsSchema = Schema.Int.check(Schema.isGreaterThanOrEqualTo(1)); + +const RunLimitsSchema = Schema.Struct({ + mostDurationMs: PositiveMillisecondsSchema, + longestCallMs: PositiveMillisecondsSchema, + longestCallMsByTask: Schema.optionalKey(Schema.Record(Schema.String, PositiveMillisecondsSchema)), +}); + +const CancelOrderSchema = Schema.Struct({ + by: Schema.NonEmptyString, + kind: Schema.Literals(['requested', 'deadline', 'parent_ended']), + reason: Schema.String, +}); + +const RunOutcomeSchema = Schema.Union([ + Schema.Struct({ kind: Schema.Literal('completed'), output: Schema.Json }), + Schema.Struct({ kind: Schema.Literal('raised'), error: DslErrorSchema }), + Schema.Struct({ kind: Schema.Literal('cancelled'), cancel: CancelOrderSchema }), + Schema.Struct({ kind: Schema.Literal('broken'), reason: Schema.String }), + Schema.Struct({ kind: Schema.Literal('oversized'), bytes: Schema.Int, most: Schema.Int }), + Schema.Struct({ kind: Schema.Literal('overran'), milliseconds: Schema.Int }), +]); + +const FormatSixSchema = Schema.Struct({ + executionId: Schema.String, + status: Schema.Literals(['new', 'running', 'ended']), + workflow: Schema.NullOr(Schema.Struct({ document: Schema.JsonObject, input: ValueIdSchema })), + attributes: Schema.JsonObject, + limits: RunLimitsSchema, + startedAt: InstantSchema, + lastInputAt: InstantSchema, + inputs: Schema.Int, + random: Schema.Struct({ seed: Schema.Int, draws: Schema.Int }), + runs: Schema.Record(Schema.String, Schema.Int), + timers: Schema.Struct({ + next: Schema.Int, + armed: Schema.Record( + Schema.String, + Schema.Struct({ + purpose: TimerPurposeSchema, + reference: Schema.String, + armedAt: InstantSchema, + dueAt: InstantSchema, + }), + ), + }), + calls: Schema.Record(Schema.String, CallKeySchema), + listeners: Schema.Record(Schema.String, CallKeySchema), + emitted: Schema.Struct({ count: Schema.Int, bytes: Schema.Int }), + inbox: Schema.Struct({ + waiting: Schema.Array(Schema.Struct({ event: ReceivedEventSchema, bytes: Schema.Int })), + waitingBytes: Schema.Int, + receivedIds: Schema.Array(Schema.String), + offeredIds: Schema.Array(Schema.String), + received: Schema.Int, + receivedBytes: Schema.Int, + }), + heldBytes: Schema.Int, + historyBytes: Schema.Int, + stepsWithoutWaiting: Schema.Int, + cancelRequested: Schema.Boolean, + machine: Schema.Struct({ + values: Schema.Record(Schema.String, Schema.Struct({ value: Schema.Json, bytes: Schema.Int })), + nextValue: ValueIdSchema, + context: ValueIdSchema, + root: Schema.NullOr(TaskFrameSchema), + }), + outcome: Schema.NullOr(RunOutcomeSchema), +}); + +type FormatSix = typeof FormatSixSchema.Type; + +const readFormatSix = Schema.decodeUnknownSync(FormatSixSchema, { onExcessProperty: 'error' }); + +type Fields = Readonly>; + +function isRecord(node: unknown): node is Fields { + return typeof node === 'object' && node !== null && !Array.isArray(node); +} + +export function withRunId({ executionId, ...named }: Named) { + return { ...named, runId: executionId }; +} + +export function withExecutionId({ runId, ...named }: Named) { + return { ...named, executionId: runId }; +} + +const isKeyOfFormatSix = Schema.is(CallKeySchema); + +function keysWithRunId(keys: Readonly>): Readonly> { + return Object.fromEntries( + Object.entries(keys).map(([text, key]: readonly [string, typeof CallKeySchema.Type]) => [text, withRunId(key)]), + ); +} + +function callsWithRunId(node: unknown): unknown { + if (Array.isArray(node)) { + return node.map((item: unknown) => callsWithRunId(item)); + } + if (!isRecord(node)) { + return node; + } + const walked = Object.fromEntries( + Object.entries(node).map(([name, item]: readonly [string, unknown]) => [name, callsWithRunId(item)]), + ); + const { kind, key } = node; + return kind === 'call' && isKeyOfFormatSix(key) ? { ...walked, key: withRunId(key) } : walked; +} + +function upcastFormatSix({ calls, listeners, machine, ...state }: FormatSix): unknown { + return withRunId({ + ...state, + calls: keysWithRunId(calls), + listeners: keysWithRunId(listeners), + machine: { ...machine, root: callsWithRunId(machine.root) }, + }); +} + +const initialOfFormatSix = { + executionId: '', + status: 'new', + workflow: null, + attributes: {}, + limits: { mostDurationMs: 1, longestCallMs: 1 }, + startedAt: 0, + lastInputAt: 0, + inputs: 0, + random: { seed: 0, draws: 0 }, + runs: {}, + timers: { next: 1, armed: {} }, + calls: {}, + listeners: {}, + emitted: { count: 0, bytes: 0 }, + inbox: { waiting: [], waitingBytes: 0, receivedIds: [], offeredIds: [], received: 0, receivedBytes: 0 }, + heldBytes: 0, + historyBytes: 0, + stepsWithoutWaiting: 0, + cancelRequested: false, + machine: { values: { 0: { value: {}, bytes: 2 } }, nextValue: 1, context: 0, root: null }, + outcome: null, +}; + +export const formatSix: OlderFormat = { + format: 6, + initial: initialOfFormatSix, + read: (state) => readFormatSix(state), + upcast: (state) => upcastFormatSix(readFormatSix(state)), +}; diff --git a/packages/workflow-engine/src/run-log/format-three.test.ts b/packages/workflow-engine/src/run-log/formats/format-three.test.ts similarity index 97% rename from packages/workflow-engine/src/run-log/format-three.test.ts rename to packages/workflow-engine/src/run-log/formats/format-three.test.ts index 7d1b2c0e2..f3c7a299f 100644 --- a/packages/workflow-engine/src/run-log/format-three.test.ts +++ b/packages/workflow-engine/src/run-log/formats/format-three.test.ts @@ -1,7 +1,7 @@ import { Schema } from 'effect'; import { describe, expect, it } from 'vitest'; -import { stateFormats, stateInCurrentFormat } from '../index.ts'; +import { stateFormats, stateInCurrentFormat } from '../../index.ts'; const [, , formatThree] = stateFormats.older; diff --git a/packages/workflow-engine/src/run-log/format-three.ts b/packages/workflow-engine/src/run-log/formats/format-three.ts similarity index 99% rename from packages/workflow-engine/src/run-log/format-three.ts rename to packages/workflow-engine/src/run-log/formats/format-three.ts index 93bac888f..8c83380b5 100644 --- a/packages/workflow-engine/src/run-log/format-three.ts +++ b/packages/workflow-engine/src/run-log/formats/format-three.ts @@ -1,5 +1,6 @@ import { Schema } from 'effect'; +import type { OlderFormat } from '../state-format.ts'; import { CallKeySchema, InstantSchema, @@ -7,7 +8,6 @@ import { RunLimitsSchema, TimerPurposeSchema, } from './format-two.ts'; -import type { OlderFormat } from './state-format.ts'; const DslErrorSchema = Schema.Struct({ type: Schema.String, diff --git a/packages/workflow-engine/src/run-log/format-two.test.ts b/packages/workflow-engine/src/run-log/formats/format-two.test.ts similarity index 84% rename from packages/workflow-engine/src/run-log/format-two.test.ts rename to packages/workflow-engine/src/run-log/formats/format-two.test.ts index beb915845..066958d57 100644 --- a/packages/workflow-engine/src/run-log/format-two.test.ts +++ b/packages/workflow-engine/src/run-log/formats/format-two.test.ts @@ -1,8 +1,8 @@ import { Schema } from 'effect'; import { describe, expect, it } from 'vitest'; -import { snapshotOf, stateFormats, stateInCurrentFormat } from '../index.ts'; -import { beforeFormatFive, runningState } from '../testing/runs.ts'; +import { snapshotOf, stateFormats, stateInCurrentFormat } from '../../index.ts'; +import { beforeFormatFive, runningState } from '../../testing/runs.ts'; const toJson = Schema.decodeUnknownSync(Schema.Json); @@ -10,7 +10,11 @@ const [, formatTwo] = stateFormats.older; const failedBranch = { type: 'runtime', status: 500, instance: '/do/1/fork/branches/2' }; -const stateOfFormatTwo = beforeFormatFive(snapshotOf(runningState, 1).state); +const stateOfFormatTwo = toJson( + JSON.parse( + JSON.stringify(beforeFormatFive(snapshotOf(runningState, 1).state)).replaceAll('"runId":', '"executionId":'), + ), +); function withBranchError(error: Readonly>): Schema.Json { return toJson( diff --git a/packages/workflow-engine/src/run-log/format-two.ts b/packages/workflow-engine/src/run-log/formats/format-two.ts similarity index 99% rename from packages/workflow-engine/src/run-log/format-two.ts rename to packages/workflow-engine/src/run-log/formats/format-two.ts index f223ab3ab..461f6eb68 100644 --- a/packages/workflow-engine/src/run-log/format-two.ts +++ b/packages/workflow-engine/src/run-log/formats/format-two.ts @@ -1,6 +1,6 @@ import { Schema } from 'effect'; -import type { OlderFormat } from './state-format.ts'; +import type { OlderFormat } from '../state-format.ts'; export const InstantSchema = Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)); diff --git a/packages/workflow-engine/src/run-log/known-formats.ts b/packages/workflow-engine/src/run-log/known-formats.ts index 37dc88398..623d4efc3 100644 --- a/packages/workflow-engine/src/run-log/known-formats.ts +++ b/packages/workflow-engine/src/run-log/known-formats.ts @@ -1,11 +1,12 @@ -import { formatFive } from './format-five.ts'; -import { formatFour } from './format-four.ts'; -import { formatOne } from './format-one.ts'; -import { formatThree } from './format-three.ts'; -import { formatTwo } from './format-two.ts'; +import { formatFive } from './formats/format-five.ts'; +import { formatFour } from './formats/format-four.ts'; +import { formatOne } from './formats/format-one.ts'; +import { formatSix } from './formats/format-six.ts'; +import { formatThree } from './formats/format-three.ts'; +import { formatTwo } from './formats/format-two.ts'; import { stateFormat, type StateFormats } from './state-format.ts'; export const stateFormats: StateFormats = { current: stateFormat, - older: [formatOne, formatTwo, formatThree, formatFour, formatFive], + older: [formatOne, formatTwo, formatThree, formatFour, formatFive, formatSix], }; diff --git a/packages/workflow-engine/src/run-log/run-event.test.ts b/packages/workflow-engine/src/run-log/run-event.test.ts index 537e7008a..2a113df9e 100644 --- a/packages/workflow-engine/src/run-log/run-event.test.ts +++ b/packages/workflow-engine/src/run-log/run-event.test.ts @@ -6,16 +6,16 @@ import { fitsInOneEvent, mostEventBytes, newRun, - RunEventSchema, + RunLogEventSchema, RunInputSchema, RunStateSchema, stateFormat, withHistoryBytes, - type RunEvent, + type RunLogEvent, type RunInput, } from '../index.ts'; import { testCancel } from '../testing/driver-inputs.ts'; -import { at, executionId, openCall, runningState, started } from '../testing/runs.ts'; +import { at, runId, openCall, runningState, started } from '../testing/runs.ts'; function asStored>(schema: S, value: S['Type']): unknown { return JSON.parse(JSON.stringify(Schema.encodeUnknownSync(Schema.toCodecJson(schema))(value))); @@ -28,7 +28,7 @@ function readBack>( return Schema.decodeUnknownResult(Schema.toCodecJson(schema))(stored); } -const event: RunEvent = { +const event: RunLogEvent = { type: 'input_applied', format: stateFormat, receipt: { kind: 'call_answered', key: 'k', at, status: 'succeeded' }, @@ -42,10 +42,10 @@ const event: RunEvent = { { op: 'remove', path: '/calls/k' }, ], outputs: [ - { kind: 'cancel_timer', executionId, timerId: '2' }, - { kind: 'arm_timer', executionId, timerId: '3', dueAt: at + 1000, purpose: 'wait' }, + { kind: 'cancel_timer', runId, timerId: '2' }, + { kind: 'arm_timer', runId, timerId: '3', dueAt: at + 1000, purpose: 'wait' }, { kind: 'cancel_call', key: openCall }, - { kind: 'settle', executionId, settlement: { status: 'succeeded', output: { approved: true } } }, + { kind: 'settle', runId, settlement: { status: 'succeeded', output: { approved: true } } }, ], }; @@ -54,33 +54,33 @@ function countedAfter(before: number): readonly [unknown, unknown] { return [applied.patch.at(-1), { op: 'replace', path: '/historyBytes', value: before + eventBytesOf(applied) }]; } -function near(text: string): RunEvent { +function near(text: string): RunLogEvent { return { ...event, patch: [{ op: 'replace', path: '/machine/context', value: text }] }; } const inputs: readonly RunInput[] = [ started, - { kind: 'timer_fired', executionId, at, timerId: '1' }, + { kind: 'timer_fired', runId, at, timerId: '1' }, { kind: 'call_answered', - executionId, + runId, at, key: openCall, result: { status: 'rejected', reason: 'invalid_arguments', detail: 'x' }, }, - { kind: 'event_received', executionId, at, event: { id: 'event-3', type: 'com.acme.approval', data: [1, 2] } }, - { kind: 'cancel_requested', executionId, at, cancel: testCancel }, + { kind: 'event_received', runId, at, event: { id: 'event-3', type: 'com.acme.approval', data: [1, 2] } }, + { kind: 'cancel_requested', runId, at, cancel: testCancel }, ]; describe('a run event', () => { it('is stored as JSON, naming its state format, and read back as it was decided', () => { - expect(readBack(RunEventSchema, asStored(RunEventSchema, event))).toEqual(Result.succeed(event)); + expect(readBack(RunLogEventSchema, asStored(RunLogEventSchema, event))).toEqual(Result.succeed(event)); }); it('is refused when it names no state format, or holds an output the engine does not dispatch', () => { expect([ - Result.isFailure(readBack(RunEventSchema, { ...event, format: 0 })), - Result.isFailure(readBack(RunEventSchema, { ...event, outputs: [{ kind: 'send_email', to: 'someone' }] })), + Result.isFailure(readBack(RunLogEventSchema, { ...event, format: 0 })), + Result.isFailure(readBack(RunLogEventSchema, { ...event, outputs: [{ kind: 'send_email', to: 'someone' }] })), ]).toEqual([true, true]); }); @@ -111,8 +111,8 @@ describe('a run input', () => { it('is refused for an event without an id, or a time before the epoch', () => { expect([ - Result.isFailure(readBack(RunInputSchema, { kind: 'event_received', executionId, at, event: { type: 't' } })), - Result.isFailure(readBack(RunInputSchema, { kind: 'cancel_requested', executionId, at: -1 })), + Result.isFailure(readBack(RunInputSchema, { kind: 'event_received', runId, at, event: { type: 't' } })), + Result.isFailure(readBack(RunInputSchema, { kind: 'cancel_requested', runId, at: -1 })), ]).toEqual([true, true]); }); }); diff --git a/packages/workflow-engine/src/run-log/run-event.ts b/packages/workflow-engine/src/run-log/run-event.ts index 707e3f9a4..eb4c2639c 100644 --- a/packages/workflow-engine/src/run-log/run-event.ts +++ b/packages/workflow-engine/src/run-log/run-event.ts @@ -4,42 +4,64 @@ import { RunOutputSchema } from '../dispatch/run-output.ts'; import { InputReceiptSchema } from '../machine/input-receipt.ts'; import { mostEventBytes } from '../machine/limits.ts'; import { EarlierStepSchema, ResumedSchema, StepSchema } from '../steps/step-entry.ts'; -import { StateFormatSchema } from './state-format.ts'; +import { + EventOfFormatsOneToSixSchema, + eventNamesOfFormatsOneToSix, + FormatsOneToSixSchema, +} from './formats/format-six-records.ts'; +import { stateFormat, ThisFormatOrNewerSchema, writtenInAnOlderFormat } from './state-format.ts'; import { PatchOperationSchema } from './state-patch.ts'; -export const RunEventSchema = Schema.Struct({ - type: Schema.Literal('input_applied'), - format: StateFormatSchema, - receipt: InputReceiptSchema, - steps: Schema.Array(Schema.Union([StepSchema, EarlierStepSchema])), - resumed: Schema.optionalKey(Schema.NullOr(ResumedSchema)), - patch: Schema.Array(PatchOperationSchema), - outputs: Schema.Array(RunOutputSchema), -}); +function eventInFormat(format: Format) { + return Schema.Struct({ + type: Schema.Literal('input_applied'), + format, + receipt: InputReceiptSchema, + steps: Schema.Array(Schema.Union([StepSchema, EarlierStepSchema])), + resumed: Schema.optionalKey(Schema.NullOr(ResumedSchema)), + patch: Schema.Array(PatchOperationSchema), + outputs: Schema.Array(RunOutputSchema), + }); +} + +export const RunLogEventSchema = Schema.Union([ + eventInFormat(ThisFormatOrNewerSchema), + writtenInAnOlderFormat( + EventOfFormatsOneToSixSchema, + eventInFormat(FormatsOneToSixSchema), + eventNamesOfFormatsOneToSix, + ), +]); -export type RunEvent = typeof RunEventSchema.Type; +export type RunLogEvent = typeof RunLogEventSchema.Type; export interface PositionedEvent { readonly version: number; - readonly event: RunEvent; + readonly event: RunLogEvent; } const utf8 = new TextEncoder(); -export function eventBytesOf(event: RunEvent): number { - return utf8.encode(JSON.stringify(event)).byteLength; +const writeEvent = Schema.encodeSync(RunLogEventSchema); + +function asWritten(event: RunLogEvent): unknown { + return event.format < stateFormat ? writeEvent(event) : event; +} + +export function eventBytesOf(event: RunLogEvent): number { + return utf8.encode(JSON.stringify(asWritten(event))).byteLength; } -export function fitsInOneEvent(event: RunEvent): boolean { +export function fitsInOneEvent(event: RunLogEvent): boolean { return eventBytesOf(event) <= mostEventBytes; } -function withHistoryBytesOf(event: RunEvent, historyBytes: number): RunEvent { +function withHistoryBytesOf(event: RunLogEvent, historyBytes: number): RunLogEvent { return { ...event, patch: [...event.patch, { op: 'replace', path: '/historyBytes', value: historyBytes }] }; } -export function withHistoryBytes(event: RunEvent, before: number): RunEvent { - const settled = (guess: number): RunEvent => { +export function withHistoryBytes(event: RunLogEvent, before: number): RunLogEvent { + const settled = (guess: number): RunLogEvent => { const counted = withHistoryBytesOf(event, before + guess); const bytes = eventBytesOf(counted); return bytes === guess ? counted : settled(bytes); diff --git a/packages/workflow-engine/src/run-log/run-fold.test.ts b/packages/workflow-engine/src/run-log/run-fold.test.ts index c463afefc..0cc84afef 100644 --- a/packages/workflow-engine/src/run-log/run-fold.test.ts +++ b/packages/workflow-engine/src/run-log/run-fold.test.ts @@ -13,23 +13,23 @@ import { withHistoryBytes, type OlderFormat, type PositionedEvent, - type RunEvent, + type RunLogEvent, type StateFormats, type StatePatch, } from '../index.ts'; -import { at, executionId } from '../testing/runs.ts'; +import { at, runId } from '../testing/runs.ts'; import { exampleStream } from '../testing/streams.ts'; function bytesOf(events: readonly PositionedEvent[]): number { return events.reduce((sum, { event }: PositionedEvent) => sum + eventBytesOf(event), 0); } -function eventIn(format: number, patch: StatePatch, before: number): RunEvent { +function eventIn(format: number, patch: StatePatch, before: number): RunLogEvent { return withHistoryBytes( { type: 'input_applied', format, - receipt: { kind: 'cancel_requested', key: executionId, at }, + receipt: { kind: 'cancel_requested', key: runId, at }, steps: [], patch, outputs: [], @@ -72,11 +72,11 @@ const countingApplied: OlderFormat = { const twoFormats: StateFormats = { current: 2, older: [countingApplied] }; const started: StatePatch = [ - { op: 'replace', path: '/executionId', value: executionId }, + { op: 'replace', path: '/runId', value: runId }, { op: 'replace', path: '/status', value: 'running' }, ]; -function patched(patch: StatePatch): RunEvent { +function patched(patch: StatePatch): RunLogEvent { return eventIn(stateFormat, patch, 0); } @@ -87,7 +87,7 @@ describe('a run loaded from its stream', () => { expect(loaded).toMatchObject({ version: 3, sinceSnapshot: { bytes: bytesOf(exampleStream), snapshotBytes: 0 }, - state: { executionId, status: 'running', inputs: 3, historyBytes: bytesOf(exampleStream), timers: { armed: {} } }, + state: { runId, status: 'running', inputs: 3, historyBytes: bytesOf(exampleStream), timers: { armed: {} } }, }); expect(loadedRunOf({ snapshot: null, tail: [] }).state).toEqual(newRun); }); @@ -113,10 +113,10 @@ describe('a run loaded from a snapshot', () => { describe('a run that cannot be read', () => { it('dies on a gap in its events, or on events whose sizes are not the bytes of history the state counts', () => { const afterAGap = streamIn([ - [1, started], - [1, []], + [stateFormat, started], + [stateFormat, []], ]).slice(1); - const miscounted = streamIn([[1, started]]).map(({ version, event }) => ({ + const miscounted = streamIn([[stateFormat, started]]).map(({ version, event }) => ({ version, event: { ...event, @@ -155,7 +155,7 @@ describe('the state formats of a stream', () => { it('upcast a snapshot of an older format before the events after it', () => { const olderState = Schema.decodeUnknownSync(Schema.Json)({ ...withoutInputs, applied: 4 }); - const snapshot = { format: 1, executionId, version: 4, historyBytes: 0, state: olderState }; + const snapshot = { format: 1, runId, version: 4, historyBytes: 0, state: olderState }; const tail = streamIn([[2, [{ op: 'replace', path: '/inputs', value: 5 }]]]).map(({ event }) => ({ version: 5, event, diff --git a/packages/workflow-engine/src/run-log/run-fold.ts b/packages/workflow-engine/src/run-log/run-fold.ts index dc4747dfc..ad8dddf30 100644 --- a/packages/workflow-engine/src/run-log/run-fold.ts +++ b/packages/workflow-engine/src/run-log/run-fold.ts @@ -2,7 +2,7 @@ import { Data, Schema } from 'effect'; import { newRun, RunStateSchema, type RunState } from '../machine/run-state.ts'; import { stateFormats } from './known-formats.ts'; -import { eventBytesOf, type PositionedEvent, type RunEvent } from './run-event.ts'; +import { eventBytesOf, type PositionedEvent, type RunLogEvent } from './run-event.ts'; import type { StoredRun } from './run-store.ts'; import type { SinceSnapshot } from './snapshot.ts'; import type { OlderFormat, StateFormats } from './state-format.ts'; @@ -87,7 +87,7 @@ export function stateInCurrentFormat(format: number, state: unknown, formats: St return decodeState(upcastTo(formats, { format, state }, formats.current).state); } -export function evolveRun(state: RunState, event: RunEvent): RunState { +export function evolveRun(state: RunState, event: RunLogEvent): RunState { const { state: patched } = upcastTo(stateFormats, { format: stateFormats.current, state }, event.format); return decodeState(applyStatePatch(patched, event.patch)); } diff --git a/packages/workflow-engine/src/run-log/run-store.ts b/packages/workflow-engine/src/run-log/run-store.ts index c323d8823..3a9c8aaa8 100644 --- a/packages/workflow-engine/src/run-log/run-store.ts +++ b/packages/workflow-engine/src/run-log/run-store.ts @@ -2,7 +2,7 @@ import type { VersionConflict } from '@beonauto/ledger'; import type { Effect, Schema } from 'effect'; import type { StepKey } from '../steps/step-entry.ts'; -import type { PositionedEvent, RunEvent } from './run-event.ts'; +import type { PositionedEvent, RunLogEvent } from './run-event.ts'; import type { Snapshot } from './snapshot.ts'; export interface StoredSnapshot { @@ -27,14 +27,14 @@ export interface RecordLineage { readonly attributes: Schema.JsonObject; } -export interface RunStore { - readonly load: (executionId: string) => Effect.Effect; +export interface RunLogStore { + readonly load: (runId: string) => Effect.Effect; readonly append: ( - executionId: string, - event: RunEvent, + runId: string, + event: RunLogEvent, expectedVersion: number, lineage: RecordLineage, ) => Effect.Effect; - readonly eventsAfter: (executionId: string, version: number) => Effect.Effect; + readonly eventsAfter: (runId: string, version: number) => Effect.Effect; readonly saveSnapshot: (snapshot: Snapshot) => Effect.Effect; } diff --git a/packages/workflow-engine/src/run-log/snapshot.test.ts b/packages/workflow-engine/src/run-log/snapshot.test.ts index 9afa86d11..177754b31 100644 --- a/packages/workflow-engine/src/run-log/snapshot.test.ts +++ b/packages/workflow-engine/src/run-log/snapshot.test.ts @@ -39,7 +39,7 @@ describe('a snapshot', () => { expect(snapshot).toMatchObject({ format: stateFormat, - executionId: runningState.executionId, + runId: runningState.runId, version: 1000, historyBytes: 9000, }); diff --git a/packages/workflow-engine/src/run-log/snapshot.ts b/packages/workflow-engine/src/run-log/snapshot.ts index 3de9708a5..501fcb837 100644 --- a/packages/workflow-engine/src/run-log/snapshot.ts +++ b/packages/workflow-engine/src/run-log/snapshot.ts @@ -1,20 +1,36 @@ import { Schema } from 'effect'; import { RunStateSchema, type RunState } from '../machine/run-state.ts'; -import { eventBytesOf, type RunEvent } from './run-event.ts'; -import { stateFormat, StateFormatSchema } from './state-format.ts'; +import { + FormatsOneToSixSchema, + SnapshotOfFormatsOneToSixSchema, + snapshotNamesOfFormatsOneToSix, +} from './formats/format-six-records.ts'; +import { eventBytesOf, type RunLogEvent } from './run-event.ts'; +import { stateFormat, ThisFormatOrNewerSchema, writtenInAnOlderFormat } from './state-format.ts'; export const snapshotEveryBytes = 1_048_576; export const mostSnapshotChunkBytes = 1_048_576; -export const SnapshotSchema = Schema.Struct({ - format: StateFormatSchema, - executionId: Schema.NonEmptyString, - version: Schema.Int.check(Schema.isGreaterThanOrEqualTo(1)), - historyBytes: Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)), - state: Schema.Json, -}); +function snapshotInFormat(format: Format) { + return Schema.Struct({ + format, + runId: Schema.NonEmptyString, + version: Schema.Int.check(Schema.isGreaterThanOrEqualTo(1)), + historyBytes: Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)), + state: Schema.Json, + }); +} + +export const SnapshotSchema = Schema.Union([ + snapshotInFormat(ThisFormatOrNewerSchema), + writtenInAnOlderFormat( + SnapshotOfFormatsOneToSixSchema, + snapshotInFormat(FormatsOneToSixSchema), + snapshotNamesOfFormatsOneToSix, + ), +]); export type Snapshot = typeof SnapshotSchema.Type; @@ -36,14 +52,14 @@ const utf8 = new TextEncoder(); export function snapshotOf(state: RunState, version: number): Snapshot { return { format: stateFormat, - executionId: state.executionId, + runId: state.runId, version, historyBytes: state.historyBytes, state: encodeState(state), }; } -export function sinceSnapshotAfter(since: SinceSnapshot, events: readonly RunEvent[]): SinceSnapshot { +export function sinceSnapshotAfter(since: SinceSnapshot, events: readonly RunLogEvent[]): SinceSnapshot { return { ...since, bytes: events.reduce((sum, event) => sum + eventBytesOf(event), since.bytes) }; } diff --git a/packages/workflow-engine/src/run-log/state-format.ts b/packages/workflow-engine/src/run-log/state-format.ts index b8a32b8f4..0be6b7155 100644 --- a/packages/workflow-engine/src/run-log/state-format.ts +++ b/packages/workflow-engine/src/run-log/state-format.ts @@ -1,9 +1,11 @@ -import { Schema } from 'effect'; +import { Effect, Schema, SchemaGetter, type SchemaIssue } from 'effect'; -export const stateFormat = 6; +export const stateFormat = 7; export const StateFormatSchema = Schema.Int.check(Schema.isGreaterThanOrEqualTo(1)); +export const ThisFormatOrNewerSchema = StateFormatSchema.check(Schema.isGreaterThanOrEqualTo(stateFormat)); + export interface OlderFormat { readonly format: number; readonly initial: unknown; @@ -15,3 +17,29 @@ export interface StateFormats { readonly current: number; readonly older: readonly OlderFormat[]; } + +interface RecordNames { + readonly current: (written: Written) => Current; + readonly written: (current: Current) => unknown; +} + +export function writtenInAnOlderFormat( + written: Schema.Codec, + current: Schema.Codec, + names: RecordNames, +) { + const read = Schema.decodeUnknownEffect(written); + return Schema.Unknown.pipe( + Schema.decodeTo(current, { + decode: SchemaGetter.transformEffect((record: unknown, options) => + read(record, { ...options, onExcessProperty: 'error' }).pipe( + Effect.mapBoth({ + onFailure: ({ issue }: { readonly issue: SchemaIssue.Issue }) => issue, + onSuccess: names.current, + }), + ), + ), + encode: SchemaGetter.transform(names.written), + }), + ); +} diff --git a/packages/workflow-engine/src/runner/list-runner.test.ts b/packages/workflow-engine/src/runner/list-runner.test.ts index ac365e165..8134415a9 100644 --- a/packages/workflow-engine/src/runner/list-runner.test.ts +++ b/packages/workflow-engine/src/runner/list-runner.test.ts @@ -37,8 +37,8 @@ do: - await: { listen: { to: { one: { with: { type: go } } } } }${counting} `); const run = drivenRun(document, { - meanwhile: (driver, executionId) => { - driver.deliver(executionId, { id: 'e1', type: 'go', data: 'won' }); + meanwhile: (driver, runId) => { + driver.deliver(runId, { id: 'e1', type: 'go', data: 'won' }); }, }); diff --git a/packages/workflow-engine/src/runner/run-descriptors.ts b/packages/workflow-engine/src/runner/run-descriptors.ts index f34e8fea1..72841d723 100644 --- a/packages/workflow-engine/src/runner/run-descriptors.ts +++ b/packages/workflow-engine/src/runner/run-descriptors.ts @@ -12,7 +12,7 @@ export interface MachineOptions { } export interface Descriptors { - readonly executionId: () => string; + readonly runId: () => string; readonly attributes: () => JsonObject; readonly document: () => JsonObject; readonly limits: () => RunLimits; @@ -27,7 +27,7 @@ function documentOf(cell: RunCell): JsonObject { export function descriptorsOf(cell: RunCell, values: ValueTable): Descriptors { return { - executionId: () => cell.get().state.executionId, + runId: () => cell.get().state.runId, attributes: () => cell.get().state.attributes, document: () => documentOf(cell), limits: () => cell.get().state.limits, @@ -43,7 +43,7 @@ export function descriptorsOf(cell: RunCell, values: ValueTable): Descriptors { workflow: () => { const { state } = cell.get(); const input: Json = state.workflow === null ? null : values.valueOf(state.workflow.input); - return { id: state.executionId, definition: documentOf(cell), input, startedAt: dateTimeOf(state.startedAt) }; + return { id: state.runId, definition: documentOf(cell), input, startedAt: dateTimeOf(state.startedAt) }; }, }; } diff --git a/packages/workflow-engine/src/runner/run-ending.ts b/packages/workflow-engine/src/runner/run-ending.ts index a8ae75448..ec39735b9 100644 --- a/packages/workflow-engine/src/runner/run-ending.ts +++ b/packages/workflow-engine/src/runner/run-ending.ts @@ -8,7 +8,7 @@ import type { Journal } from './run-tables.ts'; import type { CallTable, ListenerTable, TimerTable } from './run-timers.ts'; interface RunStart { - readonly executionId: string; + readonly runId: string; readonly document: JsonObject; readonly input: ValueId; readonly limits: RunLimits; @@ -32,12 +32,12 @@ export interface Ending { export function lifecycleOf(cell: RunCell, ending: Ending, now: number): Lifecycle { return { - begin: ({ executionId, document, input, limits, attributes, seed }) => { + begin: ({ runId, document, input, limits, attributes, seed }) => { const { state } = cell.get(); cell.update({ state: { ...state, - executionId, + runId, status: 'running', workflow: { document, input }, attributes, @@ -57,7 +57,7 @@ export function lifecycleOf(cell: RunCell, ending: Ending, now: number): Lifecyc ending.inbox.clear(); const { state } = cell.get(); cell.update({ state: { ...state, status: 'ended', outcome }, root: null }); - ending.journal.emit({ kind: 'settle', executionId: state.executionId, settlement: settlementOf(outcome) }); + ending.journal.emit({ kind: 'settle', runId: state.runId, settlement: settlementOf(outcome) }); }, }; } diff --git a/packages/workflow-engine/src/runner/run-inbox.test.ts b/packages/workflow-engine/src/runner/run-inbox.test.ts index aab0ede6f..5768ff2e5 100644 --- a/packages/workflow-engine/src/runner/run-inbox.test.ts +++ b/packages/workflow-engine/src/runner/run-inbox.test.ts @@ -7,7 +7,7 @@ import { mostWaitingEvents, } from '../machine/limits.ts'; import { memoryDriver, type MemoryDriver } from '../testing/memory-driver.ts'; -import { drivenExecutionId, drivenRun } from '../testing/run-history.ts'; +import { drivenRunId, drivenRun } from '../testing/run-history.ts'; import { workflow } from '../testing/workflows.ts'; const waitingForever = workflow('do:\n - await: { listen: { to: { one: { with: { type: never } } } } }'); @@ -15,10 +15,10 @@ const waitingForever = workflow('do:\n - await: { listen: { to: { one: { with: const consumingForever = workflow('do:\n - await: { listen: { to: { one: { with: { type: tick } } } }, then: await }'); function flooding(count: number, type: string, data = '') { - return (driver: MemoryDriver, executionId: string): void => { + return (driver: MemoryDriver, runId: string): void => { driver.at(1, () => { for (let index = 0; index < count; index += 1) { - driver.deliver(executionId, { id: `e${index}`, type, data }); + driver.deliver(runId, { id: `e${index}`, type, data }); } }); }; @@ -48,13 +48,13 @@ describe('the events a run has not consumed', () => { describe('the events a run receives over its life', () => { it(`may number ${mostReceivedEvents}, consumed or not; one more ends the run, which keeps the ids of the ${mostReceivedEvents} it took`, () => { const driver = memoryDriver(); - driver.start({ executionId: drivenExecutionId, document: consumingForever }); + driver.start({ runId: drivenRunId, document: consumingForever }); for (let index = 0; index < mostReceivedEvents; index += 1) { - driver.deliver(drivenExecutionId, { id: index.toString(36), type: 'tick' }); + driver.deliver(drivenRunId, { id: index.toString(36), type: 'tick' }); } - const atTheLimit = driver.state(drivenExecutionId); - driver.deliver(drivenExecutionId, { id: 'past', type: 'tick' }); - const pastIt = driver.state(drivenExecutionId); + const atTheLimit = driver.state(drivenRunId); + driver.deliver(drivenRunId, { id: 'past', type: 'tick' }); + const pastIt = driver.state(drivenRunId); expect(atTheLimit.status).toBe('running'); expect(atTheLimit.inbox.receivedIds).toHaveLength(mostReceivedEvents); diff --git a/packages/workflow-engine/src/runner/run-tables.test.ts b/packages/workflow-engine/src/runner/run-tables.test.ts index 268c26d93..d168f5332 100644 --- a/packages/workflow-engine/src/runner/run-tables.test.ts +++ b/packages/workflow-engine/src/runner/run-tables.test.ts @@ -49,7 +49,7 @@ describe('the call table of an input', () => { const timers = timerTableOf(newRun, descriptors, 0, journal); const calls = callTableOf(newRun, descriptors, { timers, journal, now: 0 }); - calls.cancelCall({ key: { executionId: 'run', reference: '/do/0/ask', run: 1 }, deadline: '1' }, 'deadline'); + calls.cancelCall({ key: { runId: 'run', reference: '/do/0/ask', run: 1 }, deadline: '1' }, 'deadline'); expect(journal.outputs()).toEqual([]); }); diff --git a/packages/workflow-engine/src/runner/run-timers.ts b/packages/workflow-engine/src/runner/run-timers.ts index 385ffa8aa..a7be28ddd 100644 --- a/packages/workflow-engine/src/runner/run-timers.ts +++ b/packages/workflow-engine/src/runner/run-timers.ts @@ -49,7 +49,7 @@ export interface ListenerTable { export function timerTableOf( state: RunState, - run: Pick, + run: Pick, now: number, journal: Journal, ): TimerTable { @@ -58,7 +58,7 @@ export function timerTableOf( const disarm = (timerId: string | null): void => { if (timerId !== null && Object.hasOwn(armed, timerId)) { delete armed[timerId]; - journal.emit({ kind: 'cancel_timer', executionId: run.executionId(), timerId }); + journal.emit({ kind: 'cancel_timer', runId: run.runId(), timerId }); } }; return { @@ -67,7 +67,7 @@ export function timerTableOf( counter.next += 1; const dueAt = now + milliseconds; armed[timerId] = { purpose, reference, armedAt: now, dueAt }; - journal.emit({ kind: 'arm_timer', executionId: run.executionId(), timerId, dueAt, purpose, label }); + journal.emit({ kind: 'arm_timer', runId: run.runId(), timerId, dueAt, purpose, label }); return timerId; }, disarm, diff --git a/packages/workflow-engine/src/serialisation/run-serialiser.ts b/packages/workflow-engine/src/serialisation/run-serialiser.ts index 05d6b453f..20cf59fe1 100644 --- a/packages/workflow-engine/src/serialisation/run-serialiser.ts +++ b/packages/workflow-engine/src/serialisation/run-serialiser.ts @@ -1,5 +1,5 @@ import type { Effect } from 'effect'; export interface RunSerialiser { - readonly serialise: (executionId: string, work: Effect.Effect) => Effect.Effect; + readonly serialise: (runId: string, work: Effect.Effect) => Effect.Effect; } diff --git a/packages/workflow-engine/src/settlement/record-store.ts b/packages/workflow-engine/src/settlement/record-store.ts index 7d5372c52..d5e4efc3f 100644 --- a/packages/workflow-engine/src/settlement/record-store.ts +++ b/packages/workflow-engine/src/settlement/record-store.ts @@ -3,17 +3,17 @@ import type { Effect } from 'effect'; import type { DispatchFailed, OutputOrigin, RunContext } from '../dispatch/dispatch-watermark.ts'; -export type SettleReceipt = 'recorded' | 'already_recorded' | 'settled_otherwise' | 'unknown_execution'; +export type SettleReceipt = 'recorded' | 'already_recorded' | 'settled_otherwise' | 'unknown_run'; -export type TroublingReceipt = Extract; +export type TroublingReceipt = Extract; export interface SettleRequest { - readonly executionId: string; + readonly runId: string; readonly settlement: Settlement; } export interface RunDue { - readonly executionId: string; + readonly runId: string; readonly version: number; readonly nextDueAt: number | null; } @@ -38,5 +38,5 @@ export interface RunReporter { } export function isTroubling(receipt: SettleReceipt): receipt is TroublingReceipt { - return receipt === 'settled_otherwise' || receipt === 'unknown_execution'; + return receipt === 'settled_otherwise' || receipt === 'unknown_run'; } diff --git a/packages/workflow-engine/src/steps/step-causes.test.ts b/packages/workflow-engine/src/steps/step-causes.test.ts index a9761d884..375eafda2 100644 --- a/packages/workflow-engine/src/steps/step-causes.test.ts +++ b/packages/workflow-engine/src/steps/step-causes.test.ts @@ -3,7 +3,7 @@ import { describe, expect, it } from 'vitest'; import type { StartCall } from '../dispatch/run-output.ts'; import { taskNameOf } from '../dsl/tasks.ts'; -import type { RunEvent } from '../run-log/run-event.ts'; +import type { RunLogEvent } from '../run-log/run-event.ts'; import { drivenRun, type DriveOptions, type DrivenRun } from '../testing/run-history.ts'; import { workflow } from '../testing/workflows.ts'; import { isRecordedStep, type Step, type StepCause } from './step-entry.ts'; @@ -16,11 +16,11 @@ function stepShown(step: Step): string { return `${step.name}#${step.run} ${step.outcome} ${step.times} <- ${causeShown(step.caused_by)}`; } -function shown({ steps }: RunEvent): readonly string[] { +function shown({ steps }: RunLogEvent): readonly string[] { return steps.filter((step) => isRecordedStep(step)).map((step) => stepShown(step)); } -function recordedIn({ steps }: RunEvent): readonly Step[] { +function recordedIn({ steps }: RunLogEvent): readonly Step[] { return steps.filter((step) => isRecordedStep(step)); } @@ -141,12 +141,12 @@ do: const steps = stepsOf( 'do:\n - both: { listen: { to: { all: [{ with: { type: a } }, { with: { type: b } }] } } }', { - meanwhile: (driver, executionId) => { + meanwhile: (driver, runId) => { driver.at(10, () => { - driver.deliver(executionId, { id: 'a', type: 'a' }); + driver.deliver(runId, { id: 'a', type: 'a' }); }); driver.at(20, () => { - driver.deliver(executionId, { id: 'b', type: 'b' }); + driver.deliver(runId, { id: 'b', type: 'b' }); }); }, }, @@ -178,8 +178,8 @@ describe('the cause of a step after a yield, a timeout or a cancel', () => { it('is the waiting entry for a step that timed out, with its error, and a cancel records no step', () => { const run = drivenRun(workflow('do:\n - slow: { timeout: { after: PT1S }, wait: PT1H }')); const cancelled = drivenRun(workflow('do:\n - slow: { wait: PT1H }'), { - meanwhile: (driver, executionId) => { - driver.cancel(executionId); + meanwhile: (driver, runId) => { + driver.cancel(runId); }, }); diff --git a/packages/workflow-engine/src/steps/step-ids.test.ts b/packages/workflow-engine/src/steps/step-ids.test.ts index cc38592ad..1f481f0b8 100644 --- a/packages/workflow-engine/src/steps/step-ids.test.ts +++ b/packages/workflow-engine/src/steps/step-ids.test.ts @@ -3,28 +3,28 @@ import { describe, expect, it } from 'vitest'; import { stepEventIdOf } from './step-ids.ts'; -const executionId = '0199a3c4-7d2e-7c1a-9b3f-2f1e0d9c8b7a'; +const runId = '0199a3c4-7d2e-7c1a-9b3f-2f1e0d9c8b7a'; const waiting = { reference: '/do/0/ask', run: 2, outcome: 'waiting', times: 1 } as const; describe('the id of a step event', () => { it('is a version 5 UUID of the run, the reference, the run count, the outcome and the times, in a namespace of its own', () => { - expect(stepEventIdOf(executionId, waiting)).toBe( - uuidV5('1e08cd36-b0d0-4ce3-bd92-cd36f7e6c276', JSON.stringify([executionId, '/do/0/ask', 2, 'waiting', 1])), + expect(stepEventIdOf(runId, waiting)).toBe( + uuidV5('1e08cd36-b0d0-4ce3-bd92-cd36f7e6c276', JSON.stringify([runId, '/do/0/ask', 2, 'waiting', 1])), ); }); it('differs when any of the five differs, and stays the same on every derivation', () => { const ids = [ - stepEventIdOf(executionId, waiting), + stepEventIdOf(runId, waiting), stepEventIdOf('0199a3c4-7d2e-7c1a-9b3f-2f1e0d9c8b7b', waiting), - stepEventIdOf(executionId, { ...waiting, reference: '/do/1/ask' }), - stepEventIdOf(executionId, { ...waiting, run: 3 }), - stepEventIdOf(executionId, { ...waiting, outcome: 'started' }), - stepEventIdOf(executionId, { ...waiting, times: 2 }), + stepEventIdOf(runId, { ...waiting, reference: '/do/1/ask' }), + stepEventIdOf(runId, { ...waiting, run: 3 }), + stepEventIdOf(runId, { ...waiting, outcome: 'started' }), + stepEventIdOf(runId, { ...waiting, times: 2 }), ]; expect(new Set(ids).size).toBe(6); - expect(stepEventIdOf(executionId, waiting)).toBe(ids[0]); + expect(stepEventIdOf(runId, waiting)).toBe(ids[0]); }); }); diff --git a/packages/workflow-engine/src/steps/step-ids.ts b/packages/workflow-engine/src/steps/step-ids.ts index d4f152501..ca74713f4 100644 --- a/packages/workflow-engine/src/steps/step-ids.ts +++ b/packages/workflow-engine/src/steps/step-ids.ts @@ -4,6 +4,6 @@ import type { StepKey } from './step-entry.ts'; const stepEvents = '1e08cd36-b0d0-4ce3-bd92-cd36f7e6c276'; -export function stepEventIdOf(executionId: string, { reference, run, outcome, times }: StepKey): string { - return uuidV5(stepEvents, JSON.stringify([executionId, reference, run, outcome, times])); +export function stepEventIdOf(runId: string, { reference, run, outcome, times }: StepKey): string { + return uuidV5(stepEvents, JSON.stringify([runId, reference, run, outcome, times])); } diff --git a/packages/workflow-engine/src/steps/step-resumptions.test.ts b/packages/workflow-engine/src/steps/step-resumptions.test.ts index 3a9748ee2..d8073f4cd 100644 --- a/packages/workflow-engine/src/steps/step-resumptions.test.ts +++ b/packages/workflow-engine/src/steps/step-resumptions.test.ts @@ -8,8 +8,8 @@ function resumedAlong({ events }: DrivenRun): readonly unknown[] { return events.map(({ event }) => event.resumed); } -function causesAlong({ driver }: DrivenRun, executionId: string): readonly unknown[] { - return driver.ports.runStore.lineages(executionId).map(({ cause }) => cause); +function causesAlong({ driver }: DrivenRun, runId: string): readonly unknown[] { + return driver.ports.runStore.lineages(runId).map(({ cause }) => cause); } const waited = (reference: string, times = 1) => ({ reference, run: 1, times }); @@ -33,12 +33,12 @@ describe('the waiting entry an input resumed', () => { limits: { longestCallMs: 1000 }, }), drivenRun(workflow('do:\n - hear: { listen: { to: { one: { with: { type: go } } } } }'), { - meanwhile: (driver, executionId) => { + meanwhile: (driver, runId) => { driver.at(10, () => { - driver.deliver(executionId, { id: 'n', type: 'other' }); + driver.deliver(runId, { id: 'n', type: 'other' }); }); driver.at(20, () => { - driver.deliver(executionId, { id: 'g', type: 'go' }); + driver.deliver(runId, { id: 'g', type: 'go' }); }); }, }), @@ -57,8 +57,8 @@ describe('the waiting entry an input resumed', () => { drivenRun(workflow('do:\n - slow: { timeout: { after: PT1S }, wait: PT1H }')), drivenRun(workflow("do:\n - each: { for: { in: '${ [range(0; 120)] }' }, do: [{ one: { set: {} } }] }")), drivenRun(workflow('do:\n - slow: { wait: PT1H }'), { - meanwhile: (driver, executionId) => { - driver.cancel(executionId); + meanwhile: (driver, runId) => { + driver.cancel(runId); }, }), ]; @@ -76,20 +76,20 @@ describe('the cause the engine gives the run store with each record', () => { const run = drivenRun( workflow('do:\n - pause: { wait: PT1S }\n - slow: { timeout: { after: PT1S }, wait: PT1H }'), ); - const executionId = run.ended.executionId; + const runId = run.ended.runId; const cancelled = drivenRun(workflow('do:\n - slow: { wait: PT1H }'), { meanwhile: (driver, running) => { driver.cancel(running); }, }); - expect(causesAlong(run, executionId)).toEqual([ + expect(causesAlong(run, runId)).toEqual([ { kind: 'start' }, { kind: 'resumed', step: { reference: '/do/0/pause', run: 1, outcome: 'waiting', times: 1 } }, { kind: 'timer', timerId: timersArmedIn(run.events, 'timeout')[0]?.timerId }, ]); - expect(causesAlong(cancelled, executionId)).toEqual([{ kind: 'start' }, { kind: 'none' }]); - expect(run.driver.ports.runStore.lineages(executionId).map(({ attributes }) => attributes)).toEqual([{}, {}, {}]); + expect(causesAlong(cancelled, runId)).toEqual([{ kind: 'start' }, { kind: 'none' }]); + expect(run.driver.ports.runStore.lineages(runId).map(({ attributes }) => attributes)).toEqual([{}, {}, {}]); expect(run.driver.ports.runStore.lineages('no run')).toEqual([]); }); @@ -98,7 +98,7 @@ describe('the cause the engine gives the run store with each record', () => { meanwhile: (driver, running) => { driver.submit({ kind: 'cancel_requested', - executionId: running, + runId: running, at: driver.clock.now(), cancel: { by: 'acme-admin', kind: 'requested', reason: 'Not needed' }, cause: 'a-cancel-request', @@ -106,7 +106,7 @@ describe('the cause the engine gives the run store with each record', () => { }, }); - expect(causesAlong(cancelled, cancelled.ended.executionId)).toEqual([ + expect(causesAlong(cancelled, cancelled.ended.runId)).toEqual([ { kind: 'start' }, { kind: 'given', id: 'a-cancel-request' }, ]); @@ -121,9 +121,9 @@ describe('what a waiting step waits for', () => { ), { respond: () => ({ result: { status: 'succeeded', output: null } }), - meanwhile: (driver, executionId) => { + meanwhile: (driver, runId) => { driver.at(2000, () => { - driver.deliver(executionId, { id: 'g', type: 'go' }); + driver.deliver(runId, { id: 'g', type: 'go' }); }); }, }, diff --git a/packages/workflow-engine/src/tasks/call-task.test.ts b/packages/workflow-engine/src/tasks/call-task.test.ts index dde4ad05f..7fdc3e366 100644 --- a/packages/workflow-engine/src/tasks/call-task.test.ts +++ b/packages/workflow-engine/src/tasks/call-task.test.ts @@ -6,14 +6,14 @@ import { errorType } from '../dsl/raised-error.ts'; import { mostCallArgumentsBytes } from '../machine/limits.ts'; import type { Responder } from '../memory/memory-executor.ts'; import type { MemoryDriver } from '../testing/memory-driver.ts'; -import { drivenExecutionId, drivenRun, outputKindsIn, outputsIn } from '../testing/run-history.ts'; +import { drivenRunId, drivenRun, outputKindsIn, outputsIn } from '../testing/run-history.ts'; import { workflow } from '../testing/workflows.ts'; const calling = workflow( "do:\n - ask: { call: notify, with: { to: '${ .name }' } }\n - after: { set: { answer: '${ . }' } }", ); -const firstAsk = { executionId: drivenExecutionId, reference: '/do/0/ask', run: 1 }; +const firstAsk = { runId: drivenRunId, reference: '/do/0/ask', run: 1 }; function answeredWith(result: CallResult) { return drivenRun(calling, { input: { name: 'ada' }, respond: () => ({ after: 10, result }) }); @@ -35,11 +35,11 @@ function answeringOnlyTheSecondRun(record: (entry: string) => void): Responder { } function answeringTheFirstRunLate(record: (entry: string) => void) { - return (driver: MemoryDriver, executionId: string): void => { + return (driver: MemoryDriver, runId: string): void => { driver.at(1500, () => { - const key = { executionId, reference: '/do/0/guarded/try/0/ask', run: 1 }; + const key = { runId, reference: '/do/0/guarded/try/0/ask', run: 1 }; const result = { status: 'succeeded', output: 'first' } as const; - record(driver.submit({ kind: 'call_answered', executionId, at: driver.clock.now(), key, result }).outcome); + record(driver.submit({ kind: 'call_answered', runId, at: driver.clock.now(), key, result }).outcome); }); }; } @@ -67,7 +67,7 @@ describe('a call task', () => { ['unavailable', 'communication', 503], ] as const)('raises a rejection for %s as a %s error, status %d', (reason, kind, status) => { expect(answeredWith({ status: 'rejected', reason, detail: 'no' }).outcome).toEqual( - raisedBy(kind, status, `The function notify rejected the execution with ${reason}`, 'no'), + raisedBy(kind, status, `The function notify rejected the run with ${reason}`, 'no'), ); }); @@ -109,7 +109,7 @@ describe('an answer that comes too late', () => { const late = run.driver.submit({ kind: 'call_answered', - executionId: drivenExecutionId, + runId: drivenRunId, at: 0, key: firstAsk, result, @@ -118,7 +118,7 @@ describe('an answer that comes too late', () => { expect(run.outcome).toMatchObject({ kind: 'raised', error: { status: 408 } }); expect(outputKindsIn(run.events)).toContain('cancel_call'); expect(late).toEqual({ outcome: 'stale', version: before }); - expect(run.driver.ports.runStore.events(drivenExecutionId)).toHaveLength(before); + expect(run.driver.ports.runStore.events(drivenRunId)).toHaveLength(before); }); it('is stale when it answers an earlier attempt of a retried call, and the attempt that runs takes its own', () => { diff --git a/packages/workflow-engine/src/tasks/call-task.ts b/packages/workflow-engine/src/tasks/call-task.ts index b1aab91ad..804ef93c9 100644 --- a/packages/workflow-engine/src/tasks/call-task.ts +++ b/packages/workflow-engine/src/tasks/call-task.ts @@ -38,7 +38,7 @@ export function startCall(invocation: Invocation): BodyAdvance { ); } session.beforeWaiting(); - const key = { executionId: session.executionId(), reference: entry.reference, run: frame.run }; + const key = { runId: session.runId(), reference: entry.reference, run: frame.run }; const deadline = session.calls.startCall({ key, function: name, arguments: given }); const { reference } = entry; const child = session.options.functions.childOf?.({ diff --git a/packages/workflow-engine/src/tasks/emit-task.test.ts b/packages/workflow-engine/src/tasks/emit-task.test.ts index 661f05be1..94f238bbe 100644 --- a/packages/workflow-engine/src/tasks/emit-task.test.ts +++ b/packages/workflow-engine/src/tasks/emit-task.test.ts @@ -6,7 +6,7 @@ import { mostEmittedEvents } from '../machine/limits.ts'; import { isoInstantOf } from '../machine/utc-time.ts'; import { testMachine } from '../testing/driver-inputs.ts'; import { memoryDriver } from '../testing/memory-driver.ts'; -import { drivenExecutionId, drivenRun, outputsIn } from '../testing/run-history.ts'; +import { drivenRunId, drivenRun, outputsIn } from '../testing/run-history.ts'; import { workflow } from '../testing/workflows.ts'; import { emittedEventIdOf } from './emit-task.ts'; @@ -30,7 +30,7 @@ do: `), { input: { month: 'september', total: 12 } }, ); - const key = { executionId: drivenExecutionId, reference: '/do/0/announce', run: 1 }; + const key = { runId: drivenRunId, reference: '/do/0/announce', run: 1 }; expect(run.outcome).toEqual({ kind: 'completed', output: { month: 'september', total: 12 } }); expect(emissionsIn(run)).toEqual([ @@ -94,11 +94,11 @@ describe('an emit task that cannot emit', () => { }, }); driver.start({ - executionId: drivenExecutionId, + runId: drivenRunId, document: workflow('do:\n - announce: { emit: { event: { with: { type: reserved, source: /a } } } }'), }); - expect(driver.runUntilEnded(drivenExecutionId).outcome).toMatchObject({ + expect(driver.runUntilEnded(drivenRunId).outcome).toMatchObject({ kind: 'raised', error: { status: 400, title: 'The type reserved is the brain’s own' }, }); diff --git a/packages/workflow-engine/src/tasks/emit-task.ts b/packages/workflow-engine/src/tasks/emit-task.ts index f29a6bf20..0725f3c0a 100644 --- a/packages/workflow-engine/src/tasks/emit-task.ts +++ b/packages/workflow-engine/src/tasks/emit-task.ts @@ -73,7 +73,7 @@ export function startEmit(invocation: Invocation): BodyAdvance { const { session } = machine; const attributes = attributesOf(invocation); checked(attributes, entry.reference); - const key = { executionId: session.executionId(), reference: entry.reference, run: frame.run }; + const key = { runId: session.runId(), reference: entry.reference, run: frame.run }; const bound = session.emissions.emit(key, eventOf(invocation, attributes, key)); if (bound !== undefined) { throw new RaisedError(bound); diff --git a/packages/workflow-engine/src/tasks/listen-offers.test.ts b/packages/workflow-engine/src/tasks/listen-offers.test.ts index f05dfb94f..9c342a3c5 100644 --- a/packages/workflow-engine/src/tasks/listen-offers.test.ts +++ b/packages/workflow-engine/src/tasks/listen-offers.test.ts @@ -6,9 +6,9 @@ import { memoryDriver, type MemoryDriver } from '../testing/memory-driver.ts'; import { outputsIn } from '../testing/run-history.ts'; import { workflow } from '../testing/workflows.ts'; -const executionId = '0199a3c4-7d2e-7c1a-9b3f-2f1e0d9c8b7a'; +const runId = '0199a3c4-7d2e-7c1a-9b3f-2f1e0d9c8b7a'; -const awaiting: CallKey = { executionId, reference: '/do/0/await', run: 1 }; +const awaiting: CallKey = { runId, reference: '/do/0/await', run: 1 }; type Offered = Parameters[0]; @@ -20,11 +20,11 @@ interface Offering { function offering(to: string, input: JsonObject = {}): Offering { const driver = memoryDriver(); - driver.start({ executionId, document: workflow(`do:\n - await: { listen: { to: ${to} } }`), input }); + driver.start({ runId, document: workflow(`do:\n - await: { listen: { to: ${to} } }`), input }); return { driver, - offer: (key, event, listener = awaiting) => driver.offer({ executionId, key, listener, event }), - state: () => driver.state(executionId), + offer: (key, event, listener = awaiting) => driver.offer({ runId, key, listener, event }), + state: () => driver.state(runId), }; } @@ -36,14 +36,14 @@ describe('a listen task whose filter names a type', () => { expect(submission.outcome).toBe('applied'); expect(run.state().outcome).toEqual({ kind: 'completed', output: [{ region: 'eu' }] }); - expect(outputsIn(run.driver.ports.runStore.events(executionId)).map(({ kind }) => kind)).toEqual([ + expect(outputsIn(run.driver.ports.runStore.events(runId)).map(({ kind }) => kind)).toEqual([ 'arm_timer', 'arm_listener', 'cancel_listener', 'cancel_timer', 'settle', ]); - expect(outputsIn(run.driver.ports.runStore.events(executionId))[1]).toEqual({ + expect(outputsIn(run.driver.ports.runStore.events(runId))[1]).toEqual({ kind: 'arm_listener', key: awaiting, filters: [{ type: 'com.acme.closed', data: { region: 'eu' } }], @@ -57,10 +57,10 @@ describe('a listen task whose filter names a type', () => { region: 'eu', }, ); - const before = run.driver.ports.runStore.events(executionId).length; + const before = run.driver.ports.runStore.events(runId).length; const declined = run.offer('record-1', { id: 'e1', type: 'com.acme.closed', data: { region: 'us' } }); - const unchanged = run.driver.ports.runStore.events(executionId).length; + const unchanged = run.driver.ports.runStore.events(runId).length; const accepted = run.offer('record-2', { id: 'e2', type: 'com.acme.closed', data: { region: 'eu' } }); expect([declined.outcome, unchanged - before, accepted.outcome]).toEqual(['stale', 0, 'applied']); @@ -112,7 +112,7 @@ describe('an offer a listen task does not take', () => { it('keeps the keys of offers apart from the ids of events sent to it, so neither blocks the other', () => { const run = offering('{ all: [{ with: { type: a } }, { with: { type: b } }] }'); - run.driver.deliver(executionId, { id: 'same', type: 'a' }); + run.driver.deliver(runId, { id: 'same', type: 'a' }); const offered = run.offer('same', { id: 'same', type: 'b' }); expect(offered.outcome).toBe('applied'); diff --git a/packages/workflow-engine/src/tasks/listen-orders.test.ts b/packages/workflow-engine/src/tasks/listen-orders.test.ts index 493f7f253..f07d6ac89 100644 --- a/packages/workflow-engine/src/tasks/listen-orders.test.ts +++ b/packages/workflow-engine/src/tasks/listen-orders.test.ts @@ -10,13 +10,13 @@ import { testMachine } from '../testing/driver-inputs.ts'; import { memoryDriver, type MemoryDriver } from '../testing/memory-driver.ts'; import { workflow } from '../testing/workflows.ts'; -const executionId = '0199a3c4-7d2e-7c1a-9b3f-2f1e0d9c8b7a'; +const runId = '0199a3c4-7d2e-7c1a-9b3f-2f1e0d9c8b7a'; function outputKindsOf(driver: MemoryDriver): readonly string[] { - return driver.ports.runStore.events(executionId).flatMap(({ event }) => event.outputs.map(({ kind }) => kind)); + return driver.ports.runStore.events(runId).flatMap(({ event }) => event.outputs.map(({ kind }) => kind)); } -const awaiting: CallKey = { executionId, reference: '/do/0/await', run: 1 }; +const awaiting: CallKey = { runId, reference: '/do/0/await', run: 1 }; type Offered = Parameters[0]; @@ -28,11 +28,11 @@ interface Offering { function offering(to: string): Offering { const driver = memoryDriver(); - driver.start({ executionId, document: workflow(`do:\n - await: { listen: { to: ${to} } }`) }); + driver.start({ runId, document: workflow(`do:\n - await: { listen: { to: ${to} } }`) }); return { driver, - offer: (key, event, listener = awaiting) => driver.offer({ executionId, key, listener, event }), - state: () => driver.state(executionId), + offer: (key, event, listener = awaiting) => driver.offer({ runId, key, listener, event }), + state: () => driver.state(runId), }; } @@ -61,8 +61,8 @@ describe('a listen task that waits for all of its filters', () => { it('takes the events sent to it in any order too', () => { const run = offering('{ all: [{ with: { type: a } }, { with: { type: b } }] }'); - run.driver.deliver(executionId, { id: 'e1', type: 'b', data: 2 }); - run.driver.deliver(executionId, { id: 'e2', type: 'a', data: 1 }); + run.driver.deliver(runId, { id: 'e1', type: 'b', data: 2 }); + run.driver.deliver(runId, { id: 'e2', type: 'a', data: 1 }); expect(run.state().outcome).toEqual({ kind: 'completed', output: [1, 2] }); }); @@ -87,10 +87,10 @@ do: - pause: { wait: PT1S } - await: { listen: { to: { one: { with: { type: go } } } } } `); - driver.start({ executionId, document }); - driver.deliver(executionId, { id: 'e1', type: 'go' }); + driver.start({ runId, document }); + driver.deliver(runId, { id: 'e1', type: 'go' }); - const ended = driver.runUntilEnded(executionId); + const ended = driver.runUntilEnded(runId); expect(ended.outcome).toEqual({ kind: 'completed', output: [null] }); expect(outputKindsOf(driver)).not.toContain('arm_listener'); @@ -99,19 +99,19 @@ do: it('is cancelled when the listen times out, and when the run ends while it listens', () => { const timedOut = memoryDriver(); timedOut.start({ - executionId, + runId, document: workflow( 'do:\n - await: { listen: { to: { one: { with: { type: go } } } }, timeout: { after: PT1M } }', ), }); const cancelled = memoryDriver(); cancelled.start({ - executionId, + runId, document: workflow('do:\n - await: { listen: { to: { one: { with: { type: go } } } } }'), }); - cancelled.cancel(executionId); + cancelled.cancel(runId); - const states = [timedOut.runUntilEnded(executionId), cancelled.runUntilEnded(executionId)]; + const states = [timedOut.runUntilEnded(runId), cancelled.runUntilEnded(runId)]; expect(states.map(({ listeners }) => listeners)).toEqual([{}, {}]); expect( @@ -125,7 +125,7 @@ describe('the offers a run accepts', () => { const run = offering('{ one: { with: { type: go } } }'); const waiting = run.state(); const full: RunState = { ...waiting, inbox: { ...waiting.inbox, received: mostReceivedEvents } }; - const offer = { executionId, at: waiting.lastInputAt + 1, key: 'record-1', listener: awaiting }; + const offer = { runId, at: waiting.lastInputAt + 1, key: 'record-1', listener: awaiting }; const events = Result.getOrThrow( workflowMachine(testMachine).decide({ ...offer, kind: 'event_offered', event: { id: 'e1', type: 'go' } }, full), @@ -142,7 +142,7 @@ describe('an offer to a listen nested in other tasks', () => { it('reaches a listen in a branch of a fork, in a try and in a loop', () => { const driver = memoryDriver(); driver.start({ - executionId, + runId, document: workflow(` do: - both: @@ -158,11 +158,11 @@ do: - pause: { wait: PT1H } `), }); - const listener = { executionId, reference: '/do/0/both/fork/branches/0/guarded/try/0/each/do/0/await', run: 1 }; + const listener = { runId, reference: '/do/0/both/fork/branches/0/guarded/try/0/each/do/0/await', run: 1 }; - const offered = driver.offer({ executionId, key: 'record-1', listener, event: { id: 'e1', type: 'go' } }); + const offered = driver.offer({ runId, key: 'record-1', listener, event: { id: 'e1', type: 'go' } }); const elsewhere = driver.offer({ - executionId, + runId, key: 'record-2', listener: { ...listener, reference: '/do/0/both/fork/branches/1/pause' }, event: { id: 'e2', type: 'go' }, @@ -175,7 +175,7 @@ do: const run = offering('{ one: { with: { type: go } } }'); const waiting = run.state(); const astray: RunState = { ...waiting, machine: { ...waiting.machine, root: null } }; - const offer = { executionId, at: waiting.lastInputAt + 1, key: 'record-1', listener: awaiting }; + const offer = { runId, at: waiting.lastInputAt + 1, key: 'record-1', listener: awaiting }; const events = Result.getOrThrow( workflowMachine(testMachine).decide({ ...offer, kind: 'event_offered', event: { id: 'e1', type: 'go' } }, astray), @@ -189,7 +189,7 @@ describe('an event no branch of a fork takes', () => { it('leaves the fork as it was, while a branch that has finished and a branch that yields are passed over', () => { const driver = memoryDriver(); driver.start({ - executionId, + runId, document: workflow(` do: - all: @@ -203,12 +203,12 @@ do: - noop: { set: {} } `), }); - const listener = { executionId, reference: '/do/0/all/fork/branches/0/await', run: 1 }; - const before = driver.ports.runStore.events(executionId).length; + const listener = { runId, reference: '/do/0/all/fork/branches/0/await', run: 1 }; + const before = driver.ports.runStore.events(runId).length; - driver.deliver(executionId, { id: 'e1', type: 'stop' }); - const offered = driver.offer({ executionId, key: 'record-1', listener, event: { id: 'e2', type: 'go' } }); + driver.deliver(runId, { id: 'e1', type: 'stop' }); + const offered = driver.offer({ runId, key: 'record-1', listener, event: { id: 'e2', type: 'go' } }); - expect([driver.ports.runStore.events(executionId).length - before, offered.outcome]).toEqual([2, 'applied']); + expect([driver.ports.runStore.events(runId).length - before, offered.outcome]).toEqual([2, 'applied']); }); }); diff --git a/packages/workflow-engine/src/tasks/listen-task.test.ts b/packages/workflow-engine/src/tasks/listen-task.test.ts index e72bb0b8d..b2c28dd83 100644 --- a/packages/workflow-engine/src/tasks/listen-task.test.ts +++ b/packages/workflow-engine/src/tasks/listen-task.test.ts @@ -7,10 +7,10 @@ import { workflow } from '../testing/workflows.ts'; type Delivery = readonly [number, { readonly id: string; readonly type: string; readonly data?: number | string }]; function delivering(...deliveries: readonly Delivery[]) { - return (driver: MemoryDriver, executionId: string): void => { + return (driver: MemoryDriver, runId: string): void => { for (const [milliseconds, event] of deliveries) { driver.at(milliseconds, () => { - driver.deliver(executionId, event); + driver.deliver(runId, event); }); } }; diff --git a/packages/workflow-engine/src/tasks/listen-task.ts b/packages/workflow-engine/src/tasks/listen-task.ts index bc0be2448..da232d969 100644 --- a/packages/workflow-engine/src/tasks/listen-task.ts +++ b/packages/workflow-engine/src/tasks/listen-task.ts @@ -41,7 +41,7 @@ function acceptsBy(filter: Json, invocation: Invocation): EventFilter { function keyOf(invocation: Invocation): CallKey { const { frame, machine } = invocation; - return { executionId: machine.session.executionId(), reference: frame.reference, run: frame.run }; + return { runId: machine.session.runId(), reference: frame.reference, run: frame.run }; } function slotsOf(listening: Listening, consumed: Slots): Slots { @@ -152,7 +152,7 @@ export function resumeListen(invocation: Invocation, body: ListenBody, signal: S export function cancelListen(machine: Machine, frame: Pick): void { machine.session.listeners.cancel({ - executionId: machine.session.executionId(), + runId: machine.session.runId(), reference: frame.reference, run: frame.run, }); diff --git a/packages/workflow-engine/src/tasks/malformed-tasks.test.ts b/packages/workflow-engine/src/tasks/malformed-tasks.test.ts index e7cbf56c0..146c452d4 100644 --- a/packages/workflow-engine/src/tasks/malformed-tasks.test.ts +++ b/packages/workflow-engine/src/tasks/malformed-tasks.test.ts @@ -10,9 +10,9 @@ function titleOf(outcome: RunOutcome | null): string { } function delivering(event: { readonly id: string; readonly type: string; readonly data?: number }) { - return (driver: MemoryDriver, executionId: string): void => { + return (driver: MemoryDriver, runId: string): void => { driver.at(1, () => { - driver.deliver(executionId, event); + driver.deliver(runId, event); }); }; } diff --git a/packages/workflow-engine/src/testing/counting-decider.ts b/packages/workflow-engine/src/testing/counting-decider.ts index f5472b16d..6ae292ba3 100644 --- a/packages/workflow-engine/src/testing/counting-decider.ts +++ b/packages/workflow-engine/src/testing/counting-decider.ts @@ -4,14 +4,14 @@ import { staleReasonOf } from '../machine/admission.ts'; import { inputTimeOf, receiptOf } from '../machine/input-receipt.ts'; import type { RunDecider } from '../machine/run-decider.ts'; import { newRun } from '../machine/run-state.ts'; -import { RunEventSchema, withHistoryBytes } from '../run-log/run-event.ts'; +import { RunLogEventSchema, withHistoryBytes } from '../run-log/run-event.ts'; import { evolveRun } from '../run-log/run-fold.ts'; import { stateFormat } from '../run-log/state-format.ts'; export const countingDecider: RunDecider = { initialState: newRun, evolve: evolveRun, - eventSchema: RunEventSchema, + eventSchema: RunLogEventSchema, decide: (input, state) => { if (staleReasonOf(state, input) !== undefined) { return Result.succeed([]); @@ -24,7 +24,7 @@ export const countingDecider: RunDecider = { receipt: receiptOf(input, at), steps: [], patch: [ - { op: 'replace', path: '/executionId', value: input.executionId }, + { op: 'replace', path: '/runId', value: input.runId }, { op: 'replace', path: '/status', value: 'running' }, { op: 'replace', path: '/inputs', value: state.inputs + 1 }, { op: 'replace', path: '/lastInputAt', value: at }, diff --git a/packages/workflow-engine/src/testing/driver-inputs.ts b/packages/workflow-engine/src/testing/driver-inputs.ts index a60dc9d45..eb375c0e9 100644 --- a/packages/workflow-engine/src/testing/driver-inputs.ts +++ b/packages/workflow-engine/src/testing/driver-inputs.ts @@ -5,7 +5,7 @@ import type { CancelOrder, RunLimits, Started } from '../machine/run-input.ts'; import type { MachineOptions } from '../runner/run-descriptors.ts'; export interface StartRequest { - readonly executionId: string; + readonly runId: string; readonly document: JsonObject; readonly input?: Json; readonly limits?: Partial; @@ -38,10 +38,10 @@ export const defaultSeed = 7; export const testCancel: CancelOrder = { by: 'tester', kind: 'requested', reason: 'The test cancelled the run' }; export function startedOf(request: StartRequest, at: number): Started { - const { executionId, document, input = {}, limits = {}, attributes = {}, seed = defaultSeed } = request; + const { runId, document, input = {}, limits = {}, attributes = {}, seed = defaultSeed } = request; return { kind: 'started', - executionId, + runId, at, document, input, diff --git a/packages/workflow-engine/src/testing/frozen-runs.ts b/packages/workflow-engine/src/testing/frozen-runs.ts index db0e170f9..928ba1f86 100644 --- a/packages/workflow-engine/src/testing/frozen-runs.ts +++ b/packages/workflow-engine/src/testing/frozen-runs.ts @@ -18,8 +18,8 @@ export function deeplyFrozen(value: T): T { export function frozenRuns(cache: RunCache): RunCache { return { ...cache, - put: (executionId, loaded) => { - cache.put(executionId, deeplyFrozen(loaded)); + put: (runId, loaded) => { + cache.put(runId, deeplyFrozen(loaded)); }, }; } diff --git a/packages/workflow-engine/src/testing/memory-driver.test.ts b/packages/workflow-engine/src/testing/memory-driver.test.ts index 5653e73ce..77baea28b 100644 --- a/packages/workflow-engine/src/testing/memory-driver.test.ts +++ b/packages/workflow-engine/src/testing/memory-driver.test.ts @@ -1,15 +1,15 @@ import { describe, expect, it } from 'vitest'; import { memoryDriver } from './memory-driver.ts'; -import { drivenExecutionId as executionId } from './run-history.ts'; +import { drivenRunId as runId } from './run-history.ts'; import { workflow } from './workflows.ts'; describe('the memory driver', () => { it('answers a call with null when it is given no responder', () => { const driver = memoryDriver(); - driver.start({ executionId, document: workflow('do:\n - ask: { call: notify, with: { to: ada } }') }); + driver.start({ runId, document: workflow('do:\n - ask: { call: notify, with: { to: ada } }') }); - expect(driver.runUntilEnded(executionId).outcome).toEqual({ kind: 'completed', output: null }); + expect(driver.runUntilEnded(runId).outcome).toEqual({ kind: 'completed', output: null }); }); it('waits for answers given later, between the steps of its clock', async () => { @@ -17,11 +17,11 @@ describe('the memory driver', () => { respond: () => ({ later: Promise.resolve({ status: 'succeeded', output: 'later' }) }), }); driver.start({ - executionId, + runId, document: workflow('do:\n - pause: { wait: PT1M }\n - ask: { call: notify, with: { to: ada } }'), }); - expect(await driver.outcomeOf(executionId)).toEqual({ kind: 'completed', output: 'later' }); + expect(await driver.outcomeOf(runId)).toEqual({ kind: 'completed', output: 'later' }); }); it('says so when a run does not end within the steps of its clock it is given', () => { @@ -32,36 +32,28 @@ do: try: [{ fail: { raise: { error: { type: x, status: 503 } } } }] catch: { retry: { delay: PT1S } } `); - driver.start({ executionId, document: retryingForever }); + driver.start({ runId, document: retryingForever }); - expect(() => driver.runUntilEnded(executionId)).toThrow( - `The run ${executionId} did not end within 5 steps of its clock`, - ); + expect(() => driver.runUntilEnded(runId)).toThrow(`The run ${runId} did not end within 5 steps of its clock`); }); it('says so when a run waits for something that never comes', async () => { const driver = memoryDriver(); - driver.start({ executionId, document: workflow('do:\n - pause: { wait: PT1M }') }); + driver.start({ runId, document: workflow('do:\n - pause: { wait: PT1M }') }); driver.ports.timers.forget(); - await expect(driver.outcomeOf(executionId)).rejects.toThrow( - `The run ${executionId} waits for something that never comes`, - ); + await expect(driver.outcomeOf(runId)).rejects.toThrow(`The run ${runId} waits for something that never comes`); }); }); describe('the memory driver as a clock and a log', () => { it('keeps every input it was given, applied or not, as the input log of each run', () => { const driver = memoryDriver(); - driver.start({ executionId, document: workflow('do:\n - pause: { wait: PT1M }') }); - driver.cancel(executionId); - driver.cancel(executionId); + driver.start({ runId, document: workflow('do:\n - pause: { wait: PT1M }') }); + driver.cancel(runId); + driver.cancel(runId); - expect(driver.inputsOf(executionId).map(({ kind }) => kind)).toEqual([ - 'started', - 'cancel_requested', - 'cancel_requested', - ]); + expect(driver.inputsOf(runId).map(({ kind }) => kind)).toEqual(['started', 'cancel_requested', 'cancel_requested']); expect(driver.inputsOf('another')).toEqual([]); }); diff --git a/packages/workflow-engine/src/testing/memory-driver.ts b/packages/workflow-engine/src/testing/memory-driver.ts index e57c8e889..8efa0ad47 100644 --- a/packages/workflow-engine/src/testing/memory-driver.ts +++ b/packages/workflow-engine/src/testing/memory-driver.ts @@ -23,11 +23,11 @@ export interface MemoryDriver extends RunWatch { readonly clock: VirtualClock; readonly start: (request: StartRequest) => Submission; readonly submit: (input: RunInput) => Submission; - readonly deliver: (executionId: string, event: EventReceived['event']) => Submission; + readonly deliver: (runId: string, event: EventReceived['event']) => Submission; readonly offer: (offer: Omit) => Submission; - readonly cancel: (executionId: string, order?: CancelOrder) => Submission; + readonly cancel: (runId: string, order?: CancelOrder) => Submission; readonly at: (milliseconds: number, action: () => void) => void; - readonly inputsOf: (executionId: string) => readonly RunInput[]; + readonly inputsOf: (runId: string) => readonly RunInput[]; } const succeedWithNull: Responder = () => ({ result: { status: 'succeeded', output: null } }); @@ -53,18 +53,17 @@ export function memoryDriver(options: DriverOptions = {}): MemoryDriver { engine, clock, start: (request) => { - ports.recordStore.known(request.executionId); + ports.recordStore.known(request.runId); return submit(startedOf(request, clock.now())); }, submit, - deliver: (executionId, event) => submit({ kind: 'event_received', executionId, at: clock.now(), event }), + deliver: (runId, event) => submit({ kind: 'event_received', runId, at: clock.now(), event }), offer: (offer) => submit({ ...offer, kind: 'event_offered', at: clock.now() }), - cancel: (executionId, order = testCancel) => - submit({ kind: 'cancel_requested', executionId, at: clock.now(), cancel: order }), + cancel: (runId, order = testCancel) => submit({ kind: 'cancel_requested', runId, at: clock.now(), cancel: order }), at: (milliseconds, action) => { const due = clock.now() + milliseconds; clock.schedule(due, `scheduled ${due} ${clock.pending()}`, action); }, - inputsOf: (executionId) => given.filter((input) => input.executionId === executionId), + inputsOf: (runId) => given.filter((input) => input.runId === runId), }; } diff --git a/packages/workflow-engine/src/testing/port-probes.ts b/packages/workflow-engine/src/testing/port-probes.ts index cd19fb5a0..d5a64f696 100644 --- a/packages/workflow-engine/src/testing/port-probes.ts +++ b/packages/workflow-engine/src/testing/port-probes.ts @@ -32,7 +32,7 @@ export interface ExecutorSubject { function timerOf({ run, now }: Pick, sequence: number): ArmTimer { return { kind: 'arm_timer', - executionId: run.executionId, + runId: run.runId, timerId: String(sequence), dueAt: now() + 1000, purpose: 'wait', @@ -41,12 +41,12 @@ function timerOf({ run, now }: Pick, sequence: numb const armedBy: OutputOrigin = { version: 1, lastStep: null }; -function cancelOf({ executionId, timerId }: ArmTimer): { +function cancelOf({ runId, timerId }: ArmTimer): { readonly kind: 'cancel_timer'; - readonly executionId: string; + readonly runId: string; readonly timerId: string; } { - return { kind: 'cancel_timer', executionId, timerId }; + return { kind: 'cancel_timer', runId, timerId }; } function firedOf(timerIds: readonly string[]): string { @@ -125,7 +125,7 @@ export const timerProbes: readonly Probe[] = [ function callOf({ run }: ExecutorSubject, reference: string): StartCall { return { kind: 'start_call', - key: { executionId: run.executionId, reference, run: 1 }, + key: { runId: run.runId, reference, run: 1 }, function: 'notify', arguments: { to: 'ada' }, longestMs: 60_000, diff --git a/packages/workflow-engine/src/testing/run-history.ts b/packages/workflow-engine/src/testing/run-history.ts index 7b4ed0bcf..eb5d61fc1 100644 --- a/packages/workflow-engine/src/testing/run-history.ts +++ b/packages/workflow-engine/src/testing/run-history.ts @@ -9,14 +9,14 @@ import type { EarlierStep, Step } from '../steps/step-entry.ts'; import type { TimerPurpose } from '../timers/timer-id.ts'; import { memoryDriver, type MemoryDriver } from './memory-driver.ts'; -export const drivenExecutionId = '0199a3c4-7d2e-7c1a-9b3f-2f1e0d9c8b7a'; +export const drivenRunId = '0199a3c4-7d2e-7c1a-9b3f-2f1e0d9c8b7a'; export interface DriveOptions { readonly input?: Json; readonly respond?: Responder; readonly limits?: Partial; readonly seed?: number; - readonly meanwhile?: (driver: MemoryDriver, executionId: string) => void; + readonly meanwhile?: (driver: MemoryDriver, runId: string) => void; } export interface DrivenRun { @@ -29,17 +29,17 @@ export interface DrivenRun { export function drivenRun(document: JsonObject, options: DriveOptions = {}): DrivenRun { const { input, respond, limits, seed, meanwhile } = options; const driver = memoryDriver(respond === undefined ? {} : { respond }); - const executionId = drivenExecutionId; + const runId = drivenRunId; driver.start({ - executionId, + runId, document, ...(input === undefined ? {} : { input }), ...(limits === undefined ? {} : { limits }), ...(seed === undefined ? {} : { seed }), }); - meanwhile?.(driver, executionId); - const ended = driver.runUntilEnded(executionId); - return { driver, ended, outcome: ended.outcome, events: driver.ports.runStore.events(executionId) }; + meanwhile?.(driver, runId); + const ended = driver.runUntilEnded(runId); + return { driver, ended, outcome: ended.outcome, events: driver.ports.runStore.events(runId) }; } export function statesAlong(events: readonly PositionedEvent[]): readonly RunState[] { diff --git a/packages/workflow-engine/src/testing/run-watch.ts b/packages/workflow-engine/src/testing/run-watch.ts index 9ccd22e7e..e483c6405 100644 --- a/packages/workflow-engine/src/testing/run-watch.ts +++ b/packages/workflow-engine/src/testing/run-watch.ts @@ -4,12 +4,12 @@ import type { RunOutcome, RunState } from '../machine/run-state.ts'; import type { VirtualClock } from '../memory/virtual-clock.ts'; import type { PositionedEvent } from '../run-log/run-event.ts'; import { loadedRunOf } from '../run-log/run-fold.ts'; -import type { RunStore } from '../run-log/run-store.ts'; +import type { RunLogStore } from '../run-log/run-store.ts'; export interface RunWatch { - readonly state: (executionId: string) => RunState; - readonly runUntilEnded: (executionId: string) => RunState; - readonly outcomeOf: (executionId: string) => Promise; + readonly state: (runId: string) => RunState; + readonly runUntilEnded: (runId: string) => RunState; + readonly outcomeOf: (runId: string) => Promise; } const mostClockSteps = 10_000; @@ -20,8 +20,8 @@ function nextTurn(): Promise { }); } -function endless(executionId: string, steps: number): Error { - return new Error(`The run ${executionId} did not end within ${steps} steps of its clock`); +function endless(runId: string, steps: number): Error { + return new Error(`The run ${runId} did not end within ${steps} steps of its clock`); } function settledIn(events: readonly PositionedEvent[]): boolean { @@ -33,36 +33,36 @@ interface Watched { readonly ended: boolean; } -export function runWatchOf(runStore: RunStore, clock: VirtualClock, mostSteps = mostClockSteps): RunWatch { - const state = (executionId: string): RunState => loadedRunOf(Effect.runSync(runStore.load(executionId))).state; +export function runWatchOf(runStore: RunLogStore, clock: VirtualClock, mostSteps = mostClockSteps): RunWatch { + const state = (runId: string): RunState => loadedRunOf(Effect.runSync(runStore.load(runId))).state; const watched = new Map(); - const hasEnded = (executionId: string): boolean => { - const seen = watched.get(executionId) ?? { version: 0, ended: false }; - const events = Effect.runSync(runStore.eventsAfter(executionId, seen.version)); + const hasEnded = (runId: string): boolean => { + const seen = watched.get(runId) ?? { version: 0, ended: false }; + const events = Effect.runSync(runStore.eventsAfter(runId, seen.version)); const now = { version: seen.version + events.length, ended: seen.ended || settledIn(events) }; - watched.set(executionId, now); + watched.set(runId, now); return now.ended; }; - const outcomeOf = async (executionId: string): Promise => { + const outcomeOf = async (runId: string): Promise => { await nextTurn(); - const { outcome } = hasEnded(executionId) ? state(executionId) : { outcome: null }; + const { outcome } = hasEnded(runId) ? state(runId) : { outcome: null }; if (outcome !== null) { return outcome; } if (!clock.advance()) { - throw new Error(`The run ${executionId} waits for something that never comes`); + throw new Error(`The run ${runId} waits for something that never comes`); } - return outcomeOf(executionId); + return outcomeOf(runId); }; return { state, - runUntilEnded: (executionId) => { - for (let steps = 0; !hasEnded(executionId) && clock.advance(); steps += 1) { + runUntilEnded: (runId) => { + for (let steps = 0; !hasEnded(runId) && clock.advance(); steps += 1) { if (steps === mostSteps) { - throw endless(executionId, mostSteps); + throw endless(runId, mostSteps); } } - return state(executionId); + return state(runId); }, outcomeOf, }; diff --git a/packages/workflow-engine/src/testing/runs.ts b/packages/workflow-engine/src/testing/runs.ts index f2ca26fa6..ec6caf43b 100644 --- a/packages/workflow-engine/src/testing/runs.ts +++ b/packages/workflow-engine/src/testing/runs.ts @@ -5,11 +5,11 @@ import { heldBytesOf } from '../machine/held-values.ts'; import type { Started } from '../machine/run-input.ts'; import { newRun, type RunState, type TaskFrame } from '../machine/run-state.ts'; -export const executionId = '0199a3c4-7d2e-7c1a-9b3f-2f1e0d9c8b7a'; +export const runId = '0199a3c4-7d2e-7c1a-9b3f-2f1e0d9c8b7a'; export const document = { document: { dsl: '1.0.3', namespace: 'acme', name: 'triage', version: '1.0.0' }, do: [] }; -export const openCall: CallKey = { executionId, reference: '/do/1/fork/branches/0/ask', run: 1 }; +export const openCall: CallKey = { runId, reference: '/do/1/fork/branches/0/ask', run: 1 }; export const armedTimer = '1'; @@ -76,7 +76,7 @@ const forking: TaskFrame = { const running: RunState = { ...newRun, - executionId, + runId, status: 'running', workflow: { document, input: ticket }, attributes: { owner: 'tests' }, @@ -160,7 +160,7 @@ export function beforeFormatFive(state: unknown): unknown { export const started: Started = { kind: 'started', - executionId, + runId, at, document, input: { ticket: 7 }, diff --git a/packages/workflow-engine/src/testing/store-probes.ts b/packages/workflow-engine/src/testing/store-probes.ts index c9c5b194d..2e4e62709 100644 --- a/packages/workflow-engine/src/testing/store-probes.ts +++ b/packages/workflow-engine/src/testing/store-probes.ts @@ -4,7 +4,7 @@ import { Effect } from 'effect'; import type { DispatchWatermark, OutputOrigin, RunContext } from '../dispatch/dispatch-watermark.ts'; import { newRun, type RunState } from '../machine/run-state.ts'; import { evolveRun } from '../run-log/run-fold.ts'; -import type { RecordLineage, RunStore, StoredRun } from '../run-log/run-store.ts'; +import type { RecordLineage, RunLogStore, StoredRun } from '../run-log/run-store.ts'; import { snapshotOf } from '../run-log/snapshot.ts'; import type { RecordStore } from '../settlement/record-store.ts'; import type { Probe } from './port-probes.ts'; @@ -13,61 +13,60 @@ import { exampleStream } from './streams.ts'; export interface RecordStoreSubject { readonly recordStore: RecordStore; readonly run: RunContext; - readonly know: (executionId: string) => Effect.Effect; + readonly know: (runId: string) => Effect.Effect; } export interface RunStoreSubject { - readonly runStore: RunStore; - readonly executionId: string; + readonly runStore: RunLogStore; + readonly runId: string; } export interface WatermarkSubject { readonly watermark: DispatchWatermark; - readonly runStore: RunStore; - readonly executionId: string; + readonly runStore: RunLogStore; + readonly runId: string; } const succeeded: Settlement = { status: 'succeeded', output: 'done' }; const settledBy: OutputOrigin = { version: 2, lastStep: null }; -function settled(subject: RecordStoreSubject, executionId: string, settlement: Settlement) { - return subject.recordStore.settle({ executionId, settlement }, { ...subject.run, executionId }, settledBy); +function settled(subject: RecordStoreSubject, runId: string, settlement: Settlement) { + return subject.recordStore.settle({ runId, settlement }, { ...subject.run, runId }, settledBy); } export const recordStoreProbes: readonly Probe[] = [ { - title: 'records a settlement once, and tells another settlement of the same execution apart', + title: 'records a settlement once, and tells another settlement of the same run apart', expected: ['recorded', 'already_recorded', 'settled_otherwise'], run: (subject) => Effect.gen(function* () { - const { executionId } = subject.run; - yield* subject.know(executionId); - const first = yield* settled(subject, executionId, succeeded); - const again = yield* settled(subject, executionId, succeeded); - const other = yield* settled(subject, executionId, { status: 'failed' }); + const { runId } = subject.run; + yield* subject.know(runId); + const first = yield* settled(subject, runId, succeeded); + const again = yield* settled(subject, runId, succeeded); + const other = yield* settled(subject, runId, { status: 'failed' }); return [first, again, other]; }), }, { - title: 'records no settlement of an execution it does not know', - expected: ['unknown_execution'], - run: (subject) => - Effect.map(settled(subject, `${subject.run.executionId}-unknown`, succeeded), (receipt) => [receipt]), + title: 'records no settlement of a run it does not know', + expected: ['unknown_run'], + run: (subject) => Effect.map(settled(subject, `${subject.run.runId}-unknown`, succeeded), (receipt) => [receipt]), }, { title: 'gives the runs due before a time, by the latest note of each, and no run that settled', expected: ['due at 2000: 1', 'due at 500: 0', 'after an older note: 1', 'settled: 0'], run: (subject) => Effect.gen(function* () { - const { executionId } = subject.run; - yield* subject.know(executionId); - yield* subject.recordStore.noteDue({ executionId, version: 2, nextDueAt: 1000 }, subject.run); + const { runId } = subject.run; + yield* subject.know(runId); + yield* subject.recordStore.noteDue({ runId, version: 2, nextDueAt: 1000 }, subject.run); const dueLater = yield* subject.recordStore.dueRuns(2000); const dueEarlier = yield* subject.recordStore.dueRuns(500); - yield* subject.recordStore.noteDue({ executionId, version: 1, nextDueAt: null }, subject.run); + yield* subject.recordStore.noteDue({ runId, version: 1, nextDueAt: null }, subject.run); const afterOlder = yield* subject.recordStore.dueRuns(2000); - yield* settled(subject, executionId, succeeded); + yield* settled(subject, runId, succeeded); const afterSettling = yield* subject.recordStore.dueRuns(2000); return [ `due at 2000: ${dueLater.length}`, @@ -89,12 +88,8 @@ const first = exampleStream.slice(0, 1); const appendedBy: RecordLineage = { cause: { kind: 'none' }, attributes: {} }; -function appendedTo( - runStore: RunStore, - executionId: string, - events: typeof exampleStream, -): Effect.Effect { - return Effect.forEach(events, ({ version, event }) => runStore.append(executionId, event, version - 1, appendedBy), { +function appendedTo(runStore: RunLogStore, runId: string, events: typeof exampleStream): Effect.Effect { + return Effect.forEach(events, ({ version, event }) => runStore.append(runId, event, version - 1, appendedBy), { discard: true, }); } @@ -107,13 +102,13 @@ export const watermarkProbes: readonly Probe[] = [ { title: 'starts at nothing dispatched and never goes down', expected: ['0', '3', '3'], - run: ({ watermark, executionId }) => + run: ({ watermark, runId }) => Effect.gen(function* () { - const start = yield* watermark.read(executionId); - yield* watermark.advance(executionId, 3); - const advanced = yield* watermark.read(executionId); - yield* watermark.advance(executionId, 2); - const kept = yield* watermark.read(executionId); + const start = yield* watermark.read(runId); + yield* watermark.advance(runId, 3); + const advanced = yield* watermark.read(runId); + yield* watermark.advance(runId, 2); + const kept = yield* watermark.read(runId); return [`${start}`, `${advanced}`, `${kept}`]; }), }, @@ -127,25 +122,25 @@ export const watermarkProbes: readonly Probe[] = [ 'at 2 of 3: other run', 'at most 1: 1', ], - run: ({ watermark, runStore, executionId }) => + run: ({ watermark, runStore, runId }) => Effect.gen(function* () { - const other = `${executionId}-other`; + const other = `${runId}-other`; const named = (behind: readonly string[]): string => behind.length === 0 ? 'none' : behind - .map((id) => (id === executionId ? 'run' : 'other')) + .map((id) => (id === runId ? 'run' : 'other')) .toSorted() .join(' '); - yield* appendedTo(runStore, executionId, firstTwo); + yield* appendedTo(runStore, runId, firstTwo); const atStart = yield* watermark.behindRuns(10); - yield* watermark.advance(executionId, 1); + yield* watermark.advance(runId, 1); const atOne = yield* watermark.behindRuns(10); - yield* watermark.advance(executionId, 2); + yield* watermark.advance(runId, 2); const atTwo = yield* watermark.behindRuns(10); yield* appendedTo(runStore, other, first); const withOther = yield* watermark.behindRuns(10); - yield* appendedTo(runStore, executionId, exampleStream.slice(2)); + yield* appendedTo(runStore, runId, exampleStream.slice(2)); const bothBehind = yield* watermark.behindRuns(10); const limited = yield* watermark.behindRuns(1); return [ @@ -161,9 +156,9 @@ export const watermarkProbes: readonly Probe[] = [ { title: 'gives the runs behind taken longest ago first, so the runs past its limit are taken next', expected: ['first: 2 runs', 'second: 2 runs', 'each of the 4 taken once', 'third: 2 of the first'], - run: ({ watermark, runStore, executionId }) => + run: ({ watermark, runStore, runId }) => Effect.gen(function* () { - const runs = ['a', 'b', 'c', 'd'].map((name) => `${executionId}-${name}`); + const runs = ['a', 'b', 'c', 'd'].map((name) => `${runId}-${name}`); yield* Effect.forEach(runs, (run) => appendedTo(runStore, run, first), { discard: true }); const firstTaken = yield* watermark.behindRuns(2); const secondTaken = yield* watermark.behindRuns(2); @@ -184,18 +179,18 @@ export const runStoreProbes: readonly Probe[] = [ { title: 'appends at the version it expects, loads from its latest snapshot and the events after it', expected: ['appended', 'appended', 'conflict', 'loaded 0 + 2', 'after 1: 2', 'loaded 1 + 1'], - run: ({ runStore, executionId }) => + run: ({ runStore, runId }) => Effect.gen(function* () { const appended = yield* Effect.forEach(firstTwo, ({ version, event }) => - Effect.as(runStore.append(executionId, event, version - 1, appendedBy), 'appended'), + Effect.as(runStore.append(runId, event, version - 1, appendedBy), 'appended'), ); const conflicts = yield* Effect.forEach(first, ({ event }) => - Effect.as(Effect.flip(runStore.append(executionId, event, 0, appendedBy)), 'conflict'), + Effect.as(Effect.flip(runStore.append(runId, event, 0, appendedBy)), 'conflict'), ); - const loaded = yield* runStore.load(executionId); - const after = yield* runStore.eventsAfter(executionId, 1); - yield* runStore.saveSnapshot({ ...snapshotOf(stateAt(1), 1), executionId }); - const fromSnapshot = yield* runStore.load(executionId); + const loaded = yield* runStore.load(runId); + const after = yield* runStore.eventsAfter(runId, 1); + yield* runStore.saveSnapshot({ ...snapshotOf(stateAt(1), 1), runId }); + const fromSnapshot = yield* runStore.load(runId); return [ ...appended, ...conflicts, @@ -208,13 +203,13 @@ export const runStoreProbes: readonly Probe[] = [ { title: 'keeps the newest snapshot it was given, so an older one saved after it is not kept', expected: ['loaded 2 + 0', 'loaded 2 + 0'], - run: ({ runStore, executionId }) => + run: ({ runStore, runId }) => Effect.gen(function* () { - yield* appendedTo(runStore, executionId, firstTwo); - yield* runStore.saveSnapshot({ ...snapshotOf(stateAt(2), 2), executionId }); - const fromNewer = yield* runStore.load(executionId); - yield* runStore.saveSnapshot({ ...snapshotOf(stateAt(1), 1), executionId }); - const afterOlder = yield* runStore.load(executionId); + yield* appendedTo(runStore, runId, firstTwo); + yield* runStore.saveSnapshot({ ...snapshotOf(stateAt(2), 2), runId }); + const fromNewer = yield* runStore.load(runId); + yield* runStore.saveSnapshot({ ...snapshotOf(stateAt(1), 1), runId }); + const afterOlder = yield* runStore.load(runId); return [loadedOf(fromNewer), loadedOf(afterOlder)]; }), }, diff --git a/packages/workflow-engine/src/testing/stored-runs.ts b/packages/workflow-engine/src/testing/stored-runs.ts index 3aa8f1f38..d2250e60f 100644 --- a/packages/workflow-engine/src/testing/stored-runs.ts +++ b/packages/workflow-engine/src/testing/stored-runs.ts @@ -20,7 +20,7 @@ export function afterTheWaits(state: RunState, milliseconds: number): RunState { return armedTimerIds(state, 'wait') .map((timerId): RunInput => ({ kind: 'timer_fired', - executionId: state.executionId, + runId: state.runId, at: state.lastInputAt + milliseconds, timerId, })) diff --git a/packages/workflow-engine/src/testing/streams.ts b/packages/workflow-engine/src/testing/streams.ts index 83c94050b..2283667f1 100644 --- a/packages/workflow-engine/src/testing/streams.ts +++ b/packages/workflow-engine/src/testing/streams.ts @@ -3,7 +3,7 @@ import type { InputReceipt } from '../machine/input-receipt.ts'; import { eventBytesOf, withHistoryBytes, type PositionedEvent } from '../run-log/run-event.ts'; import { stateFormat } from '../run-log/state-format.ts'; import type { StatePatch } from '../run-log/state-patch.ts'; -import { at, document, executionId } from './runs.ts'; +import { at, document, runId } from './runs.ts'; export interface Change { readonly receipt: InputReceipt; @@ -28,9 +28,9 @@ export function streamOf(changes: readonly Change[]): readonly PositionedEvent[] export const exampleStream = streamOf([ { - receipt: { kind: 'started', key: executionId, at }, + receipt: { kind: 'started', key: runId, at }, patch: [ - { op: 'replace', path: '/executionId', value: executionId }, + { op: 'replace', path: '/runId', value: runId }, { op: 'replace', path: '/status', value: 'running' }, { op: 'replace', path: '/workflow', value: { document, input: 1 } }, { op: 'add', path: '/machine/values/1', value: { value: { ticket: 7 }, bytes: 12 } }, @@ -51,7 +51,7 @@ export const exampleStream = streamOf([ { op: 'replace', path: '/inputs', value: 2 }, { op: 'replace', path: '/lastInputAt', value: at + 10 }, ], - outputs: [{ kind: 'arm_timer', executionId, timerId: timer, dueAt: at + 1000, purpose: 'wait' }], + outputs: [{ kind: 'arm_timer', runId, timerId: timer, dueAt: at + 1000, purpose: 'wait' }], }, { receipt: { kind: 'timer_fired', key: timer, at: at + 1000 }, diff --git a/packages/workflow-host/README.md b/packages/workflow-host/README.md index 699d476c9..44d88b9dd 100644 --- a/packages/workflow-host/README.md +++ b/packages/workflow-host/README.md @@ -6,7 +6,7 @@ The workflow engine of [`@beonauto/workflow-engine`](../workflow-engine) on Node `@beonauto/workflow-host` (`src/index.ts`) exports `openWorkflowStore(settings, lostConnection)`, which opens the host's database and migrates its tables, answering a `WorkflowStore` with the database and the `ViewsPort` of [the views of recall functions](#the-views-of-recall-functions), and `openWorkflowHost(options)`, which opens the host's database, or takes the store it is given, migrates its tables, claims the database's workflows (see [One host for a database](#one-host-for-a-database)), starts its loop if it holds them, and answers with a `WorkflowHost`: -- `start(run, start)`: starts the run of an execution with the `started` input, and answers `started`; `going` when the run started before and has not settled its execution, so nothing starts again, after trying again at once the settlement of a run that ended; or `settled` when the run ended and its execution was settled, since a run never starts twice in one log. +- `start(run, start)`: starts the run's log with the `started` input, and answers `started`; `going` when the run started before and is not settled, so nothing starts again, after trying again at once the settlement of a run that ended; or `settled` when the run ended and was settled, since a run never starts twice in one log. - `deliver(run, event)`: gives the run an `event_received` input, and answers `delivered`, also for an event the run took before; `not_started` for a run whose `started` has not arrived; or `ended` for a run that ended. - `stateOf(run)`: the run's state, loaded from its store. - `stop()`: a clean stop, below. @@ -21,23 +21,23 @@ Both fail with the engine's `Conflict` when the run's log kept changing while an | `perform` | `(call, run) => Effect`: what a call does, a `CallResult` or `{ status: 'waiting', child }` for a call whose run finishes later; it never fails, a function that cannot answer answers `unreachable` | | `waiting` | what [waiting calls](#waiting-calls) need: `resultOf`, the call's answer for the ending of the run it waits for, `cancel`, which records a cancel on a run, and `cancelDeferred`, which settles a cancelled run of another capability | | `mostOpenCalls` | how many calls may be open under one run at the top of a tree, `mostOpenCallsOfATree`, 1,000, unless given | -| `settle` | `SettleExecution` of `@beonauto/specs`: how a run's outcome is recorded on its execution | +| `settle` | `SettleRun` of `@beonauto/definitions`: how a workflow's outcome is recorded on its run | | `reports` | where the host tells the operator of a run it could not settle (`unsettled`), of a failure it retries (`trouble`), of a lost connection, and its notes (`note`) | | `sweepEveryMs` | how often the loop sweeps, 1,000 in the server | | `mostCallsAtOnce` | how many calls run at once | | `clock`, `cacheBounds` | the clock, `Date.now` unless given, and the bounds of the engine's cache of loaded runs, `runCacheBounds` unless given | | `holder` | the id the host claims the database's workflows with, a random UUID unless given | -| `reactions` | what the host needs to react to the records of the brains, below: the primitive whose specs are workflows, how a trigger starts a run, how an emitted event is recorded, and the signal of an append | +| `reactions` | what the host needs to react to the records of the brains, below: the capability whose definitions are workflows, how a trigger starts a run, how an emitted event is recorded, and the signal of an append | | `consumers` | more consumers of the records of the brains, each naming the event types it wants, after the host's own, none unless given (see [The follower](#the-follower)) | | `dueWork` | the due rows of projections the loop hands out before its timers, none unless given (see [Due rows](#due-rows)) | ## Where a run is kept -A run is addressed by its brain and its execution id, `{ org, brain, executionId }`, since an execution id is unique within a brain only. The engine knows a run by one opaque id, which the host makes `//`; org ids hold no `/`, brain ids hold none, and execution ids are UUIDs, so the id splits back into its address. +A run is addressed by its brain and its run id, `{ org, brain, runId }`, since a run id is unique within a brain only. The engine knows a run by one opaque id, the run key, which the host makes `//` (`runKeyOf`) and keeps in its tables as `run_key` beside `brain_key`; org ids hold no `/`, brain ids hold none, and run ids are UUIDs, so the id splits back into its address. | What | Where | | ----------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| the run's log | the stream `brain///runs/` on the ledger | +| the run's log | the stream `brain///run-logs/` on the ledger | | the latest snapshot | `workflow_snapshot_chunks`, in chunks of at most 1 MiB of UTF-8 | | timers and their tombstones | `workflow_timers`, keyed by run and timer id, with the version of the record that armed each | | calls, answers and tombstones | `workflow_calls`, keyed by the call key | @@ -52,13 +52,13 @@ A run is addressed by its brain and its execution id, `{ org, brain, executionId | starts and refusals | `workflow_reaction_rates`, the starts of the current minute of each workflow; `workflow_reaction_backlog`, the starts waiting for a later minute; `workflow_reaction_refusals`, the refusals of the current minute, counted | | the views of recall functions | `recall_views`, one row a recall function of a brain; see [The views of recall functions](#the-views-of-recall-functions) | -The log is written with the ledger's own append, `eventAppenderOf` over the event store, with the lineage of each record (see [The lineage of a run's records](#the-lineage-of-a-runs-records)), and read with its stream read, `EventStore.read`, so the history of a run (`get_execution_history`, which reads `runs/` beside `executions/`) and the events of the brain find it, and the ledger's rule holds: the ledger writes no SQL of its own on its write path, and the host owns its tables. +The log is written with the ledger's own append, `eventAppenderOf` over the event store, with the lineage of each record (see [The lineage of a run's records](#the-lineage-of-a-runs-records)), and read with its stream read, `EventStore.read`, so the history of a run (`get_run_history`, which reads `run-logs/` beside `runs/`) and the events of the brain find it, and the ledger's rule holds: the ledger writes no SQL of its own on its write path, and the host owns its tables. The tables are in the ledger's own database: the same SQLite file, opened through the same `sqlite3` library the ledger uses, or the same PostgreSQL database. `behindRuns` joins the watermarks to Emmett's `emt_streams`, and a join needs both in one database. One SQLite library in the process also keeps clear of what the spike on branch `spike/engine-node` found, that a second SQLite library writing to the ledger's file lost committed writes (`spikes/node/results/lost-write-repeat.json`). The host opens its own connections from the settings the ledger is opened from (`LEDGER_FILE` or `DATABASE_URL`): on SQLite one pool, its writer serialising the host's writes and its run logs' appends, and on PostgreSQL the event store's pool and one of at most four connections for its tables. It creates its tables with `CREATE TABLE IF NOT EXISTS` when it opens, on PostgreSQL in one transaction under an advisory lock of its own. `LEDGER_FILE=:memory:` opens a private database in memory, so the host's runs are not in the ledger's; it is for tests. ### The lineage of a run's records -The host reads, from the attributes a run is started with, `lineage`: `start`, the id of the `execution_started` that began the run, and `correlation`, the id of the run at the top of its tree; a run started without it is a run of its own, correlated to its execution id, with no cause for its first record. Every record of the run's log is written with that correlation and, from the cause the engine gives with it: +The host reads, from the attributes a run is started with, `lineage`: `start`, the id of the `run_started` that began the run, and `correlation`, the id of the run at the top of its tree; a run started without it is a run of its own, correlated to its run id, with no cause for its first record. Every record of the run's log is written with that correlation and, from the cause the engine gives with it: | The engine's cause | The cause written | | ------------------ | ----------------------------------------------------------------------------------------------------------------------------------------- | @@ -77,7 +77,7 @@ A snapshot is not an event: it is the run's state at a version, a cache of its f - **Timers.** `arm` inserts a timer unless its row exists, with the version of the record that armed it: `armed`; an armed or fired timer is `already_armed`, a tombstone `refused_after_cancel`. `cancel` disarms an armed timer, `cancelled`; a timer never seen gets a tombstone, `tombstoned`; a fired one is `already_fired`. `sweep` inserts the timers of the run's state the table lost. The loop fires a due timer as a `timer_fired` input and marks it fired only after the engine took it, so a crash in between fires it again, and the run takes the second fire as stale. - **Executor.** `start` records the call as running and runs `perform` in a fiber of its own, at most `mostCallsAtOnce` at once; its answer is recorded, then given to the run as `call_answered`, then marked given. A start of a call answered before gives the answer again, `answered_again`; of one this host runs, `running`; of one recorded as running that no fiber runs, because the host that ran it died, starts it again, `started_again`. A call whose run finishes later moves to `waiting` instead, with the id of that run, and is never performed again (see [Waiting calls](#waiting-calls)). `cancel` handles the call by the state of its row, as that section says. A call that outlasts its step is closed by the run itself when its `call_deadline` timer fires, and the cancel that follows interrupts it here. Every sweep resumes the calls recorded as running that no fiber runs, and gives again the answers recorded but not given; so does the first sweep after a start. A call never has two fibers: a start and a resumption that race for one call begin it once. An answer that cannot be written is written again, 50 ms after the first failure and then twice as long each time up to 30 s, until it is, and the first failure is reported; the call is performed again only after the host stopped and started again with its answer unwritten. -- **Record store.** `settle` records the run's outcome on its execution with `settle`, the execution settler of `@beonauto/specs`, with an empty record for a run that succeeded, and keeps the settlement as the run's receipt: the same settlement again is `already_recorded`, another `settled_otherwise`. An execution the brain does not have is `unknown_execution`. A settlement the record refuses, as when the run ended before the call that started it recorded the execution as finishing later, fails, so the watermark stays below the run's last event and every sweep dispatches it again; after 20 failed attempts it is tried once a minute, for ever, and the host notes it once when it begins backing off and once when it is settled at last. A start of a run that ended with its settlement pending tries the settlement again at once, whatever the back-off, and answers `settled` if it then is, and `going` otherwise, leaving the next sweep to try again rather than a minute later. `noteDue` keeps the newest due time of a run by version, a settled run is due no more, and `dueRuns` reads them by an index on the due time. +- **Record store.** `settle` records the workflow's outcome on its run with `settle`, the run settler of `@beonauto/definitions`, with an empty record for a run that succeeded, and keeps the settlement as the run's receipt: the same settlement again is `already_recorded`, another `settled_otherwise`. A run the brain does not have is `unknown_run`. A settlement the record refuses, as when the run ended before the call that started it recorded the run as finishing later, fails, so the watermark stays below the run's last event and every sweep dispatches it again; after 20 failed attempts it is tried once a minute, for ever, and the host notes it once when it begins backing off and once when it is settled at last. A start of a run that ended with its settlement pending tries the settlement again at once, whatever the back-off, and answers `settled` if it then is, and `going` otherwise, leaving the next sweep to try again rather than a minute later. `noteDue` keeps the newest due time of a run by version, a settled run is due no more, and `dueRuns` reads them by an index on the due time. - **Watermark.** `read` and `advance`, which never goes down. `behindRuns(limit)` joins the runs to `emt_streams` and takes those whose stream holds an event above their watermark, least recently taken first, marking each with a number that counts up with every hand-out, as the memory watermark does. A run whose last event ended it and whose watermark reached that event leaves the partial index the join walks, so a sweep reads only the runs still going. - **Serialiser.** One semaphore a run, made when an input comes and let go of when none waits. - **Reporter.** The operator's log, through `reports.unsettled`, and `reports.note` for the notes of the back-off and of the claim below. @@ -104,13 +104,13 @@ A workflow with an event trigger is started for every event of its brain that ma ### The follower -The follower keeps a place in every brain: at its start it reads every registry of brains of the orgs (`org//brains`) and, the first time it sees a registry, follows each brain it names from its latest record and then makes the brain's triggers again from its specs; after the start, a sweep, like a signal, follows from its first record any brain one of whose streams is appended to, before it passes the brain, and a registry is read, by its name, only when an append to it is signalled or swept, which follows from its first record every brain the registry names that is not followed yet, even one with records in the ledger already. A brain is passed when an append to one of its streams other than a run's log raises the ledger's signal (`streamAppends` of `@beonauto/ledger`, raised by the event store in this process), since a run's records matter only once an event follows them, and a host whose runs append thousands of records a second would otherwise pass its brains as often. +The follower keeps a place in every brain: at its start it reads every registry of brains of the orgs (`org//brains`) and, the first time it sees a registry, follows each brain it names from its latest record and then makes the brain's triggers again from its definitions; after the start, a sweep, like a signal, follows from its first record any brain one of whose streams is appended to, before it passes the brain, and a registry is read, by its name, only when an append to it is signalled or swept, which follows from its first record every brain the registry names that is not followed yet, even one with records in the ledger already. A brain is passed when an append to one of its streams other than a run's log raises the ledger's signal (`streamAppends` of `@beonauto/ledger`, raised by the event store in this process), since a run's records matter only once an event follows them, and a host whose runs append thousands of records a second would otherwise pass its brains as often. Every `sweepEveryMs` the follower sweeps, so that a brain written by another process, which raises no signal here, is caught up too. A sweep reads which streams were appended to since the last, through the ledger's `readAppended`, from the point where the last read stopped, at most 10,000 records at a time; it reads the registries among them, naming a registry again at every sweep until a read of it succeeded, while a read that fails is said and the sweep's upkeep and passes go on, and passes up to 128 brains: up to 64 of those whose deliveries or run records wait, those a sweep took longest ago first, and in the places left the brains appended to by anything but a run's log, so that brains that wait never take every place. Brains it has no place for, and records past the 10,000, are taken by later sweeps: the next comes at once while the read stopped at its 10,000 records or brains appended to are still left over, and otherwise after a whole `sweepEveryMs`, so brains that wait past the 64, the rest of the round after a start, and failed brains handed back that do not fit, wait for it. A pass that fails is said, the brains after it in the sweep are passed all the same, and its brain is handed back to the sweeps, which queue it in order with the brains appended to without hurrying the next sweep: at least 64 places of every sweep go to that queue, so a brain with n brains queued before it is passed within ⌈(n + 1) / 64⌉ of the sweeps that reach their passes, however many are appended to after it. A sweep that fails before its passes hands back every brain it took, and takes nothing out of what is queued until it has read the round. At its start the follower takes its place in the ledger before it reads the registries, and its sweeps go once over every brain it follows, in the places left, since nothing read the ledger while the host was down. A sweep that finds nothing new reads the ledger once and the brains that wait once, and passes no brain. -A pass reads the brain's records oldest first through the ledger's recorded read, 100 records a page and at most 10 pages, and keeps its place in `workflow_followed_brains` after each record it made a delivery of and once more when the pass ends, so a pass that fails makes again only the deliveries of the record it was at, and reads again the records it passed over since its last delivery. It first glances at the records without their data, the specs' records apart, and reads them with their data only for the event types that the brain's triggers and listeners and the consumers its caller gave name (`workflow_subscription_types` and `workflow_listener_types`): a fact is read with its data when its type is named, and every published event when any type that is not a fact's is named, since an event's type lies in its data. A record whose data the pass did not read is passed over, so a brain where nothing names a type costs one read of identifiers a wake; a record that names more types ends the pass: a run's record that armed a listener is passed first, and a spec that activates a trigger is read again by the next pass, which reads it and the records after it with the data of those types too. A record of a run's log is passed only once the dispatch watermark of the run covers its version, so the listeners a record armed are kept by the time the follower reaches it, and passing that record marks them as passed: a listener takes only the events recorded after the record that armed it. A record the watermark has not covered by the twentieth sweep that met it is passed all the same, as a delivery is skipped, so a run whose dispatch is stuck does not hold back its brain: the host notes `run_record_passed`, keeps in `workflow_passed_runs` how far it passed the run's log, and then marks as passed every listener of the run armed by that record or before; a listener's insert, for its part, marks the listener passed afterwards when that row is there, so each side writes before it reads, and a listener the dispatch keeps while the record is passed or later is marked, and takes the events recorded from then on. The row is written only while the run is going and its dispatch is behind the record, taken back at once if the dispatch reached it meanwhile, and deleted once the run's dispatch reaches that far or the run ends. A record the follower cannot read is noted (`record_unreadable`) and passed over. +A pass reads the brain's records oldest first through the ledger's recorded read, 100 records a page and at most 10 pages, and keeps its place in `workflow_followed_brains` after each record it made a delivery of and once more when the pass ends, so a pass that fails makes again only the deliveries of the record it was at, and reads again the records it passed over since its last delivery. It first glances at the records without their data, the definitions' records apart, and reads them with their data only for the event types that the brain's triggers and listeners and the consumers its caller gave name (`workflow_subscription_types` and `workflow_listener_types`): a fact is read with its data when its type is named, and every published event when any type that is not a fact's is named, since an event's type lies in its data. A record whose data the pass did not read is passed over, so a brain where nothing names a type costs one read of identifiers a wake; a record that names more types ends the pass: a run's record that armed a listener is passed first, and a definition that activates a trigger is read again by the next pass, which reads it and the records after it with the data of those types too. A record of a run's log is passed only once the dispatch watermark of the run covers its version, so the listeners a record armed are kept by the time the follower reaches it, and passing that record marks them as passed: a listener takes only the events recorded after the record that armed it. A record the watermark has not covered by the twentieth sweep that met it is passed all the same, as a delivery is skipped, so a run whose dispatch is stuck does not hold back its brain: the host notes `run_record_passed`, keeps in `workflow_passed_runs` how far it passed the run's log, and then marks as passed every listener of the run armed by that record or before; a listener's insert, for its part, marks the listener passed afterwards when that row is there, so each side writes before it reads, and a listener the dispatch keeps while the record is passed or later is marked, and takes the events recorded from then on. The row is written only while the run is going and its dispatch is behind the record, taken back at once if the dispatch reached it meanwhile, and deleted once the run's dispatch reaches that far or the run ends. A record the follower cannot read is noted (`record_unreadable`) and passed over. -Each event, and each fact the brain records about a run or a spec (`brainFactOf` of `@beonauto/specs`), whose data the pass read is handed to the consumers in turn. A consumer is the shape the loop knows: +Each event, and each fact the brain records about a run or a definition (`brainFactOf` of `@beonauto/definitions`), whose data the pass read is handed to the consumers in turn. A consumer is the shape the loop knows: ```ts interface Consumer { @@ -130,20 +130,20 @@ The engine's `arm_listener` output inserts the run's listener with its filters a ### Triggers -A record of a spec of the primitive carries the version's `triggers`, which the workflow's adapter parsed once, at save, and `specChangeOf` of `@beonauto/specs` reads from the record; the host reads no document. A trigger is identified by its kind, `event`, `cron` or `every`, and its reference in the document, `/schedule/on`, `/schedule/cron` or `/schedule/every`, so a run names its trigger exactly and two versions compare their triggers like with like. Each trigger has a row of `workflow_subscriptions`, keyed by the brain, the workflow and the reference, with the version it starts, its kind, its rule, the id and time of the record that activated it as it is, and, for a schedule, its next due time and its running run. When the follower passes a spec record, in the ledger's order, or replays the spec records of a brain it begins to follow from its latest record (`src/triggers/spec-records.ts`), it compares the version's triggers with the workflow's rows by reference and rule (`src/triggers/trigger-changes.ts`): +A record of a workflow's definition carries the version's `triggers`, which the workflow's adapter parsed once, at save, and `definitionChangeOf` of `@beonauto/definitions` reads from the record; the host reads no document. A trigger is identified by its kind, `event`, `cron` or `every`, and its reference in the document, `/schedule/on`, `/schedule/cron` or `/schedule/every`, so a run names its trigger exactly and two versions compare their triggers like with like. Each trigger has a row of `workflow_subscriptions`, keyed by the brain, the workflow and the reference, with the version it starts, its kind, its rule, the id and time of the record that activated it as it is, and, for a schedule, its next due time and its running run. When the follower passes a definition record, in the ledger's order, or replays the definition records of a brain it begins to follow from its latest record (`src/triggers/definition-records.ts`), it compares the version's triggers with the workflow's rows by reference and rule (`src/triggers/trigger-changes.ts`): - an unchanged trigger keeps its row with the version moved on, so its activation, its anchor, its next due time and its running run survive a save of the body; - a changed schedule moves its activation, its anchor and its next due time to that record and keeps its running run, so one run at a time holds whichever version started that run; - a changed event trigger, and a trigger of any kind the version adds, get a row activated by that record; - a trigger the version removes loses its row, and a version without a schedule and a retirement delete every row of the workflow. -`workflow_subscription_types` holds the types the workflow's event trigger names. Applying a record again, as a pass that reads it again does, changes nothing, and replaying the spec records of a brain gives the rows the follower kept as it passed them one by one. A spec record that cannot be decoded is noted as `record_unreadable`, as an unreadable event is, and passed over. A trigger therefore applies from the record that activated it in its current form, starts the version current at each record, and ends at the record that removes it: nothing earlier is matched, and a match before the removing record starts the version then current. A workflow has at most three triggers, so a brain of 1,024 workflows with triggers holds at most 3,072 rows. +`workflow_subscription_types` holds the types the workflow's event trigger names. Applying a record again, as a pass that reads it again does, changes nothing, and replaying the definition records of a brain gives the rows the follower kept as it passed them one by one. A definition record that cannot be decoded is noted as `record_unreadable`, as an unreadable event is, and passed over. A trigger therefore applies from the record that activated it in its current form, starts the version current at each record, and ends at the record that removes it: nothing earlier is matched, and a match before the removing record starts the version then current. A workflow has at most three triggers, so a brain of 1,024 workflows with triggers holds at most 3,072 rows. `workflow_subscriptions` changed its key to the brain, the workflow and the trigger's reference, with the columns `reference` and `activated_by`, and the host makes its tables with `CREATE TABLE IF NOT EXISTS`, so a database an earlier build made keeps the old table. On it the read of the due schedules, which comes first in every round of the follower, fails, and so does every round: no trigger starts a run, no run that listens is offered an event, and no waiting call is answered, while the host reports only that the follower failed and tries again. Nothing is live, so there is no migration: delete such a database, `packages/server/.data/ledger.db` for `pnpm dev`, or else the file `LEDGER_FILE` names or the PostgreSQL database `DATABASE_URL` names, and the server makes it again. ### Starts -An event or fact that a workflow's event trigger matches starts a run of the version its row names through `start`, create-only, under an id made of the workflow, the version, the trigger's reference and the record's id in a namespace of its own, with the event as its input, a reaction depth one more than the record's, the record as its cause, and the trigger, its kind and reference, which the start records on `execution_started`. A record is matched by at most one trigger, since only `on` matches records and it gives one verdict a record. For a record the host reads only the event triggers that name its type, through `workflow_subscription_types` and its index on the brain, the type and the workflow, a page of as many as it may still deliver for the record and one more to know whether more are left: a filter names its type as written text, so no other trigger can match, and a filter's `data` expression is never evaluated on an event of another type. It does not start a workflow for a fact about one of its own runs, about a run one of its runs began, or for an event one of its runs emitted, whichever trigger started that run. A record at a reaction depth past 8 starts nothing and is refused. A start fails with `StartRefused` when the brain cannot take it now, so the delivery is tried again, or with `StartRejected` when it never will, as for an input the workflow does not take, which is said at once and not tried again. A workflow is started 60 times a minute at most by its event trigger, across its filters; further starts wait for a later minute, 1,000 at most, and one more is refused; a waiting start that fails is tried in each of 20 minutes and then dropped and said. A filter that fails on an event is said once a trigger and version. +An event or fact that a workflow's event trigger matches starts a run of the version its row names through `start`, create-only, under an id made of the workflow, the version, the trigger's reference and the record's id in a namespace of its own, with the event as its input, a reaction depth one more than the record's, the record as its cause, and the trigger, its kind and reference, which the start records on `run_started`. A record is matched by at most one trigger, since only `on` matches records and it gives one verdict a record. For a record the host reads only the event triggers that name its type, through `workflow_subscription_types` and its index on the brain, the type and the workflow, a page of as many as it may still deliver for the record and one more to know whether more are left: a filter names its type as written text, so no other trigger can match, and a filter's `data` expression is never evaluated on an event of another type. It does not start a workflow for a fact about one of its own runs, about a run one of its runs began, or for an event one of its runs emitted, whichever trigger started that run. A record at a reaction depth past 8 starts nothing and is refused. A start fails with `StartRefused` when the brain cannot take it now, so the delivery is tried again, or with `StartRejected` when it never will, as for an input the workflow does not take, which is said at once and not tried again. A workflow is started 60 times a minute at most by its event trigger, across its filters; further starts wait for a later minute, 1,000 at most, and one more is refused; a waiting start that fails is tried in each of 20 minutes and then dropped and said. A filter that fails on an event is said once a trigger and version. A schedule is due at the multiples of its period after its trigger was activated, or at the times its cron names in UTC. At a due time the host starts a run of the version its row names under an id made of the workflow, the version, the trigger's reference and that time, with `{ schedule: { due } }` as input, the trigger, and as its cause the record that activated the trigger, which precedes the record of the run's version when the trigger was kept across versions; unless the run its trigger started before still runs, whichever version started it, when the time is skipped and said. After the server was down, only the latest due time runs and the ones missed are said. A start the brain refuses is said, and the schedule goes on. Schedule starts never pass the start rate, so they are not counted in it. A cron and an every due at one instant start two runs, as a schedule and an event due in one moment do, under two ids. At most 64 schedules fire in one round, and the rest in the next rounds. @@ -159,7 +159,7 @@ A call whose function answers that its run finishes later, `{ status: 'waiting', The run a call waits for records the call it answers on its start, `called_by`, and every finish of that run copies it, so the ending is a fact on the run's stream that names the call. The follower's consumer of child endings (`src/waiting/child-endings.ts`), which names the three types of a finish, gives a call that is `waiting` the ending of its run as `call_answered`, mapped by `waiting.resultOf`, then marks the row `answered` and the answer given; the engine takes a key once, so a delivery made again after a crash is stale and appends nothing. An ending that reaches a call that is closed, or one that is still `running`, whose `perform` answers it, is a receipt and no failure. When the run ended before the row was marked `waiting`, the executor finds its ending as it marks the row and answers the call at once; and since a host can die between marking the row and reading the ending, the first resume after a host opens or takes the claim on the workflows reads every `waiting` row and answers each whose run has ended. The run's waiting rows are also read before any timer of the run fires, and the endings of their runs given first, so a run that ended while the server was down answers before the deadline that came due meanwhile. -The consumer of cancel requests (`src/waiting/cancel-requests.ts`) takes `execution_cancel_requested`, which `cancel_execution` of `@beonauto/specs` records on the run's stream on any server. For a workflow it gives the run the input `cancel_requested`, with who asked, the kind and the reason, caused by the request; a run whose start has not reached the host yet takes nothing, and the consumer records the request it passed over in `workflow_pending_cancels`, one row a run, with the request's message id as `cause`, `cancelled_by`, `kind` and `reason` (`src/waiting/pending-cancel-rows.ts`), before it moves on. The host's start of that run reads the run's stream for a cancel asked since its start and gives it at once (`src/waiting/pending-cancels.ts`), so the run starts and ends cancelled. Since a host can die between applying the start and that read, the first resume after a host opens or takes the claim on the workflows reads that table alone, a hundred rows at a time, and gives each row's cancel to its run, four at a time: a row whose run took it, or had ended, is deleted, one whose run has not started stays for the start to meet, and a run that cannot take its cancel is reported without holding the others, and given it again at the next sweep. A row whose run answers that it has not started is deleted once the newest record of the run, read newest first without its data, is a finish, so a run that ended without ever reaching the host leaves no row after the next first resume; a run never handed to the host and never ended keeps its row, one a stranded run. The consumer takes no finish, so the finish of a run nothing waits on writes nothing. The first resume reads none of the runs still going, so it costs the same however many there are. A cancel recorded before the run's start, which only its caller records, is no delivery: the start it precedes is refused. For a run of another capability it hands the request to `waiting.cancelDeferred`, the capability's pure decision over the run's record and the settlement. A conflict because the run has already ended is a success for both consumers, which never skip a delivery: one that fails holds the record until it succeeds. +The consumer of cancel requests (`src/waiting/cancel-requests.ts`) takes `run_cancel_requested`, which `cancel_run` of `@beonauto/definitions` records on the run's stream on any server. For a workflow it gives the run the input `cancel_requested`, with who asked, the kind and the reason, caused by the request; a run whose start has not reached the host yet takes nothing, and the consumer records the request it passed over in `workflow_pending_cancels`, one row a run, with the request's message id as `cause`, `cancelled_by`, `kind` and `reason` (`src/waiting/pending-cancel-rows.ts`), before it moves on. The host's start of that run reads the run's stream for a cancel asked since its start and gives it at once (`src/waiting/pending-cancels.ts`), so the run starts and ends cancelled. Since a host can die between applying the start and that read, the first resume after a host opens or takes the claim on the workflows reads that table alone, a hundred rows at a time, and gives each row's cancel to its run, four at a time: a row whose run took it, or had ended, is deleted, one whose run has not started stays for the start to meet, and a run that cannot take its cancel is reported without holding the others, and given it again at the next sweep. A row whose run answers that it has not started is deleted once the newest record of the run, read newest first without its data, is a finish, so a run that ended without ever reaching the host leaves no row after the next first resume; a run never handed to the host and never ended keeps its row, one a stranded run. The consumer takes no finish, so the finish of a run nothing waits on writes nothing. The first resume reads none of the runs still going, so it costs the same however many there are. A cancel recorded before the run's start, which only its caller records, is no delivery: the start it precedes is refused. For a run of another capability it hands the request to `waiting.cancelDeferred`, the capability's pure decision over the run's record and the settlement. A conflict because the run has already ended is a success for both consumers, which never skip a delivery: one that fails holds the record until it succeeds. A cancel of a call (`src/calls/call-cancels.ts`) handles each state of its row by name, and cancels the run the call waits for, by the id in `child`, before it marks the row: a `waiting` call is then marked `cancelled`; a `running` call is marked and its fiber interrupted, the run its arguments name being the one the function's `childOf` derived when it started; a `cancelled` call cancels its run again, which is harmless; an `answered` call is `already_answered`; and a call never seen leaves a tombstone. A cancel that fails, or whose run has an address its brain cannot hold, fails its dispatch, which is made again with the row unchanged. The cancel the host records is a fact on the child's stream whatever state the child is in: on a stream with no run yet it is the first record, and the start that lands after it is refused as `cancelled`, so the ledger orders the cancel and the start and no child outlives the call that wanted it; on a run within its call it is recorded too, and the run its interrupt stops ends `rejected` as `cancelled` with the kind of the cancel rather than `failed`. A run cancelled by a call ends with the kind of the call's cancel, `deadline` or `parent_ended`, caused by the entry of the run's log that cancelled the call. @@ -167,7 +167,7 @@ Before a call starts, the host counts the calls open, `running` or `waiting`, un ## The views of recall functions -A recall function of [`@beonauto/recollection`](../../primitives/recollection) keeps a view of its brain's history, and the host keeps it, in `recall_views` beside its other tables, one row a function of a brain, keyed by the brain and the name: +A recall function of [`@beonauto/recall`](../../capabilities/recall) keeps a view of its brain's history, and the host keeps it, in `recall_views` beside its other tables, one row a function of a brain, keyed by the brain and the name: | Column | What it holds | | ------------------------------------------ | -------------------------------------------------------------------------------------------------------------------- | @@ -202,11 +202,11 @@ A fold that raises, gives no output or more than one, runs out of work, nests to - the follower (`measure/reactions.ts`, `measure/sweeps.ts`): timer lateness as above in a brain that the follower follows, once with no triggers and once with 1,000 workflows with event triggers; reaction latency, 100 workflows with event triggers and 1,000 events published one after another, each matching one of them, from the end of each event's append to the start of its run, with the append signal and with the follower woken only by its sweep, every second; and the sweeps over 1,000 followed brains, 10 of them with an event trigger: the sweeps after the start, which go once over every brain, reading one record in each; a sweep with nothing new, the median of 20; and the sweeps after one new record in each brain, written with no signal, as another process writes. - a rebuild: the view of the example recall function built over 100,000 matching runs of 100 campaigns, appended 100 to a stream, counting the pages read and the writes of its row (`measure/rebuild.ts`); - idle views: 32 live views of a brain, each of 523,891 bytes, with no new events, the share of the event loop the host kept busy over 10 seconds, in milliseconds a second (`measure/idle-views.ts`); -- waiting calls (`measure/waiting.ts`, `measure/waiting-host.ts`, `measure/waiting-lines.ts`): workflow runs whose call waits for a run that finishes later, and the ending of that run appended to its stream with the call it answers, from the end of the append to the settlement of the workflow, which the answer completes; and workflow runs that wait for an hour, and `execution_cancel_requested` appended to each, from the end of the append to its settlement as cancelled. One at a time, 50 records each wait for their settlement before the next is appended; back to back, 200 are appended one after another, so the figures hold the follower's queue. Each with the append signal and with the follower woken by its sweep alone, every second, as a record another process writes is; +- waiting calls (`measure/waiting.ts`, `measure/waiting-host.ts`, `measure/waiting-lines.ts`): workflow runs whose call waits for a run that finishes later, and the ending of that run appended to its stream with the call it answers, from the end of the append to the settlement of the workflow, which the answer completes; and workflow runs that wait for an hour, and `run_cancel_requested` appended to each, from the end of the append to its settlement as cancelled. One at a time, 50 records each wait for their settlement before the next is appended; back to back, 200 are appended one after another, so the figures hold the follower's queue. Each with the append signal and with the follower woken by its sweep alone, every second, as a record another process writes is; - the count of open calls (`measure/open-calls.ts`): 1,000 open calls under one run at the top of a tree, among 10,000 under ten, and the median and p99 of 200 counts of them, the count a call makes before it starts; - the first resume (`measure/first-resume.ts`): 10,000 workflow runs started and going, then the first resume's give of the cancels the follower passed over, timed alone, once with none in `workflow_pending_cancels` and once with 100, for 100 of those runs; - several triggers (`measure/trigger-lines.ts`, `measure/schedule-firing.ts`, and alone with `measure triggers`): timer lateness as above, and reaction latency, 1,000 events published one after another, each matching one workflow, with the append signal and with the follower woken by its sweep alone, in a brain of 1,000 workflows that each carry an event trigger, a cron schedule and an every schedule, and the same in a brain of 1,000 workflows that each carry an event trigger alone; and 1,000 every schedules of a minute saved at one moment, so all are due at one time, from that time to the start of each run; -- the catch-up over finishes (`measure/finish-catch-up.ts`): a host follows a brain and stops, 1,000 `execution_succeeded` of workflow runs that no call waits for are appended to it, one stream each, and a host opens again; the time from its open until its follower's place reaches the last of them. +- the catch-up over finishes (`measure/finish-catch-up.ts`): a host follows a brain and stops, 1,000 `run_succeeded` of workflow runs that no call waits for are appended to it, one stream each, and a host opens again; the time from its open until its follower's place reaches the last of them. Measured on 2026-10-05 on an Apple M4 Max with Node 26.10.0, SQLite 3.52.0 through `sqlite3` 6.0.1, and PostgreSQL 18.6 in a local container with its default settings, three times for the timers, whose lateness varies from one measurement to the next, and once for the rest: @@ -394,7 +394,7 @@ Measured the same way earlier that day, from 08:54 to 09:15 UTC with the first r The suites of `src/testing` run on SQLite in `src/host/host-on-sqlite.test.ts` and on PostgreSQL in `src/host/host-on-postgresql.test.ts`, which needs `LEDGER_TEST_POSTGRESQL_URL` and makes a database of its own for each test: - the probes of every port; -- a run that waits, calls a function, takes an event and ends, settling its execution once; +- a run that waits, calls a function, takes an event and ends, settled once; - a long loop of 130 inputs, each of which records a value of 8,000 characters, so that its events reach 1 MiB and a snapshot is due at input 109, past half the loop, leaving a tail of 21 events; the run is loaded again from that snapshot and the events after it to the state its whole log folds to. It took 0.27 s on SQLite and 1.0 s on PostgreSQL under coverage, on the machine above. The engine's loop, whose inputs hold a number alone, needs 593 inputs for a snapshot and a tail, and 700 of them took 1.1 s on SQLite and 4.3 s on PostgreSQL, so the loop's inputs are large to keep the test within seconds on a runner ten times slower; the measurements above run the 3,000 inputs of the engine's loop; - a host killed through its process handle while it dispatches, once while a call runs and once while it records the run's settlement, then started again on the same database: the call starts again, and the run settles once (`host-process.ts` at the root of the package is the host the test starts); - the reactions of a brain: a workflow started by an event its trigger matches, a run that takes an event published to its brain, a workflow started at the due time of its schedule, and a workflow with an event trigger, a cron schedule and an every schedule, started once for an event and twice when its two schedules are due at one time; @@ -416,4 +416,4 @@ docker rm --force workflow-host-pg ## Source -`src/database` opens the host's database on either store and holds its tables; `src/views` the table of the views, its rows, its points and the views port; `src/projector` the projector, its schedule, its passes and the writes of its rows; `src/pages` a page of events read, folded and turned into changes of the rows; `src/runs` the run's address and its store; `src/dispatch` the watermark and the serialiser; `src/timers` the timers; `src/calls` the executor, its call rows and the cancels of calls; `src/waiting` the consumers of child endings and cancel requests, the cancels passed over and the first resume's give of them, and what the executor needs of waiting calls; `src/waiting-testing` what the tests of waiting calls share; `src/settlement` the record store and its back-off; `src/lease` the claim on the database's workflows and its keeper; `src/loop` the clock and the loop; `src/due-work` the due rows the loop performs, the waits of rows that failed, and a fake work for the tests; `src/follower` the follower of the brains and its consumers' loop; `src/sweeps` what wakes the follower, the signals and the sweeps; `src/listeners` the listeners; `src/triggers` the rows of the triggers and their comparison at a spec record; `src/reactions` the starts, the offers and the refusals; `src/schedules` the schedules; `src/emissions` the emitter; `src/host` the host itself; `src/testing` what the tests share, the suites both stores run among it; `src/reaction-testing` what the tests of reactions share; and `src/views-testing` the same for the views. +`src/database` opens the host's database on either store and holds its tables; `src/views` the table of the views, its rows, its points and the views port; `src/projector` the projector, its schedule, its passes and the writes of its rows; `src/pages` a page of events read, folded and turned into changes of the rows; `src/runs` the run's address and its store; `src/dispatch` the watermark and the serialiser; `src/timers` the timers; `src/calls` the executor, its call rows and the cancels of calls; `src/waiting` the consumers of child endings and cancel requests, the cancels passed over and the first resume's give of them, and what the executor needs of waiting calls; `src/waiting-testing` what the tests of waiting calls share; `src/settlement` the record store and its back-off; `src/lease` the claim on the database's workflows and its keeper; `src/loop` the clock and the loop; `src/due-work` the due rows the loop performs, the waits of rows that failed, and a fake work for the tests; `src/follower` the follower of the brains and its consumers' loop; `src/sweeps` what wakes the follower, the signals and the sweeps; `src/listeners` the listeners; `src/triggers` the rows of the triggers and their comparison at a definition record; `src/reactions` the starts, the offers and the refusals; `src/schedules` the schedules; `src/emissions` the emitter; `src/host` the host itself; `src/testing` what the tests share, the suites both stores run among it; `src/reaction-testing` what the tests of reactions share; and `src/views-testing` the same for the views. diff --git a/packages/workflow-host/host-process.ts b/packages/workflow-host/host-process.ts index 1850fd434..bed6222dd 100644 --- a/packages/workflow-host/host-process.ts +++ b/packages/workflow-host/host-process.ts @@ -1,8 +1,8 @@ import { appendFileSync, existsSync, readFileSync } from 'node:fs'; import { setTimeout } from 'node:timers/promises'; +import type { SettleRun } from '@beonauto/definitions'; import type { CallResult } from '@beonauto/operations'; -import type { SettleExecution } from '@beonauto/specs'; import { defaultLimits, defaultSeed, testMachine } from '@beonauto/workflow-engine/testing'; import { Effect, Function, Schema } from 'effect'; @@ -22,7 +22,7 @@ const settings: DatabaseSettings = Schema.decodeUnknownSync( ), )(settingsText); -const run = { org: 'acme', brain: 'alpha', executionId: '0199a3c4-7d2e-7c1a-9b3f-2f1e0d9c8b7a' }; +const run = { org: 'acme', brain: 'alpha', runId: '0199a3c4-7d2e-7c1a-9b3f-2f1e0d9c8b7a' }; const notifying = { document: { dsl: '1.0.3', namespace: 'acme', name: 'notifying', version: '1.0.0' }, @@ -39,16 +39,16 @@ const hangs = Effect.never; const answered: Effect.Effect = Effect.succeed({ status: 'succeeded', output: 'sent' }); -const settle: SettleExecution = ({ id }, settlement) => +const settle: SettleRun = ({ id }, settlement) => mode === 'hang-on-settle' ? Effect.andThen(said('settling'), hangs) : Effect.sync(() => { appendFileSync(settlementsFile, `${JSON.stringify({ id, settlement })}\n`); return { - execution_id: id, - primitive: 'orchestration', + run_id: id, + type: 'workflow', name: 'notifying', - spec_version: 1, + definition_version: 1, status: 'succeeded', started_at: '2026-10-05T09:00:00.000Z', started_by: 'acme-admin', diff --git a/packages/workflow-host/measure/finish-catch-up.ts b/packages/workflow-host/measure/finish-catch-up.ts index e7a1507c2..b3ffe5655 100644 --- a/packages/workflow-host/measure/finish-catch-up.ts +++ b/packages/workflow-host/measure/finish-catch-up.ts @@ -19,12 +19,12 @@ const Places = Schema.Array(Schema.Struct({ cursor: Schema.NullOr(Schema.String) function finishOf(index: number) { return { - type: 'execution_succeeded', + type: 'run_succeeded', output: { index }, record: {}, - primitive: 'orchestration', + definition_type: 'workflow', name: 'measured', - spec_version: 1, + definition_version: 1, by: 'acme-admin', at, }; @@ -62,7 +62,7 @@ async function untilAt(opened: Opened, cursor: string | null): Promise { async function finishedInTurn(opened: Opened, count: number, index = 0): Promise { if (index < count) { - await recorded(opened.store, `${alpha}executions/${runAt(index).executionId}`, finishOf(index)); + await recorded(opened.store, `${alpha}runs/${runAt(index).runId}`, finishOf(index)); await finishedInTurn(opened, count, index + 1); } } diff --git a/packages/workflow-host/measure/first-resume.ts b/packages/workflow-host/measure/first-resume.ts index bcb3c9bad..4ff4eaa1b 100644 --- a/packages/workflow-host/measure/first-resume.ts +++ b/packages/workflow-host/measure/first-resume.ts @@ -19,8 +19,8 @@ const pausing: Schema.JsonObject = { document: header, do: [{ pause: { wait: 'PT const cancel = { by: 'acme-admin', kind: 'requested', reason: 'Measured' } as const; -function runIdAt(index: number): string { - return `acme/alpha/${runAt(index).executionId}`; +function runKeyAt(index: number): string { + return `acme/alpha/${runAt(index).runId}`; } async function goingRuns(database: DatabaseSettings, going: number): Promise { @@ -43,7 +43,7 @@ export async function firstResumeOn(database: DatabaseSettings, going: number, p await Effect.runPromise( Effect.forEach( Array.from({ length: pending }, (_, index) => index), - (index) => passedOverRow(opened, { runId: runIdAt(index), cause: `${runIdAt(index)}-asked`, cancel }), + (index) => passedOverRow(opened, { runKey: runKeyAt(index), cause: `${runKeyAt(index)}-asked`, cancel }), { discard: true }, ), ); diff --git a/packages/workflow-host/measure/idle-views.ts b/packages/workflow-host/measure/idle-views.ts index 2ee7f8c21..9de8414e7 100644 --- a/packages/workflow-host/measure/idle-views.ts +++ b/packages/workflow-host/measure/idle-views.ts @@ -29,14 +29,14 @@ const details = { function saved(index: number) { const name = `view-${index}`; const data = { - type: 'spec_created', + type: 'definition_created', name, version: 1, content: { source: name, details }, by: 'acme-admin', at: '2026-10-06T09:00:00.000Z', }; - return { type: 'spec_created', data }; + return { type: 'definition_created', data }; } async function allLive(store: WorkflowStore, views: number): Promise { @@ -55,7 +55,7 @@ async function allLive(store: WorkflowStore, views: number): Promise { export async function idleViewsOn(settings: DatabaseSettings, views: number, seconds: number): Promise { const store = await openWorkflowStore(settings, Function.constVoid); await store.database.store.append( - `${brain}specs/recollection`, + `${brain}definitions/recall`, Array.from({ length: views }, (_, index) => saved(index)), 0, ); @@ -63,7 +63,7 @@ export async function idleViewsOn(settings: DatabaseSettings, views: number, sec const projector = startProjector({ database: store.database, settings: { - definitionType: 'recollection', + definitionType: 'recall', pool, folding: { dialect: { refused: [], variables: ['event'] }, diff --git a/packages/workflow-host/measure/lateness.ts b/packages/workflow-host/measure/lateness.ts index e71b9dadf..21f91c509 100644 --- a/packages/workflow-host/measure/lateness.ts +++ b/packages/workflow-host/measure/lateness.ts @@ -2,9 +2,9 @@ import { Effect, Function } from 'effect'; import type { DatabaseSettings } from '../src/database/host-databases.ts'; import { openHostDatabase } from '../src/database/host-databases.ts'; -import { brainCreated, specRecorded } from '../src/reaction-testing/brain-writes.ts'; -import { ledgerRunStore } from '../src/runs/ledger-run-store.ts'; -import { runIdOf } from '../src/runs/run-address.ts'; +import { brainCreated, definitionRecorded } from '../src/reaction-testing/brain-writes.ts'; +import { ledgerRunLogStore } from '../src/runs/ledger-run-store.ts'; +import { runKeyOf } from '../src/runs/run-address.ts'; import { header, measuredHost, runAt, startOf } from './measured-host.ts'; import { anEventTrigger, savedNow, type TriggersOf } from './trigger-sets.ts'; @@ -23,12 +23,12 @@ function percentile(sorted: readonly number[], fraction: number): number { async function latenessOf(database: DatabaseSettings, runs: number): Promise { const opened = await openHostDatabase(database, Function.constVoid); - const runStore = ledgerRunStore(opened); + const runStore = ledgerRunLogStore(opened); const late = await Effect.runPromise( Effect.forEach( Array.from({ length: runs }, (_, index) => runAt(index)), (run) => - Effect.map(runStore.eventsAfter(runIdOf(run), 0), ([started, fired]) => { + Effect.map(runStore.eventsAfter(runKeyOf(run), 0), ([started, fired]) => { const armed = started?.event.outputs.find( (output) => output.kind === 'arm_timer' && output.purpose === 'wait', ); @@ -47,7 +47,7 @@ async function reactingWorkflows(database: DatabaseSettings, count: number, trig await Array.from({ length: count }, (_, index) => index).reduce>( (before, index) => before.then(() => - specRecorded(opened.store, { + definitionRecorded(opened.store, { name: `w${index}`, version: 1, triggers: triggersOf(`com.measure.t${index}`), diff --git a/packages/workflow-host/measure/measured-host.ts b/packages/workflow-host/measure/measured-host.ts index 2313ccf99..c7ec4e8dc 100644 --- a/packages/workflow-host/measure/measured-host.ts +++ b/packages/workflow-host/measure/measured-host.ts @@ -1,6 +1,6 @@ import { setTimeout } from 'node:timers/promises'; -import type { Run, SettleExecution } from '@beonauto/specs'; +import type { Run, SettleRun } from '@beonauto/definitions'; import { defaultLimits, defaultSeed, testMachine } from '@beonauto/workflow-engine/testing'; import { Effect, Function, type Schema } from 'effect'; @@ -17,11 +17,11 @@ export interface MeasuredHost { readonly untilSettled: (count: number) => Promise; } -const execution: Run = { - execution_id: '0199a3c4-7d2e-7c1a-9b3f-2f1e0d9c8b7a', - primitive: 'orchestration', +const run: Run = { + run_id: '0199a3c4-7d2e-7c1a-9b3f-2f1e0d9c8b7a', + type: 'workflow', name: 'measured', - spec_version: 1, + definition_version: 1, status: 'succeeded', started_at: '2026-10-05T09:00:00.000Z', started_by: 'acme-admin', @@ -33,16 +33,16 @@ export function startOf(document: Schema.JsonObject, input: Schema.Json = {}): R return { document, input, limits: defaultLimits, attributes: {}, seed: defaultSeed }; } -export function runAt(index: number): { readonly org: string; readonly brain: string; readonly executionId: string } { - return { org: 'acme', brain: 'alpha', executionId: `0199a3c4-7d2e-7c1a-9b3f-${String(index).padStart(12, '0')}` }; +export function runAt(index: number): { readonly org: string; readonly brain: string; readonly runId: string } { + return { org: 'acme', brain: 'alpha', runId: `0199a3c4-7d2e-7c1a-9b3f-${String(index).padStart(12, '0')}` }; } export async function measuredHost(database: DatabaseSettings, clock?: HostClock): Promise { const counts = { settled: 0 }; - const settle: SettleExecution = () => + const settle: SettleRun = () => Effect.sync(() => { counts.settled += 1; - return execution; + return run; }); const host = await openWorkflowHost({ database, diff --git a/packages/workflow-host/measure/open-calls.ts b/packages/workflow-host/measure/open-calls.ts index eedeb918e..d414bc30c 100644 --- a/packages/workflow-host/measure/open-calls.ts +++ b/packages/workflow-host/measure/open-calls.ts @@ -13,16 +13,16 @@ export interface OpenCallsCount { const counts = 200; function startedUnder(root: string, index: number) { - const executionId = `acme/alpha/${root}-${index}`; + const runId = `acme/alpha/${root}-${index}`; return { call: { kind: 'start_call' as const, - key: { executionId, reference: '/do/0/ask', run: 1 }, + key: { runId, reference: '/do/0/ask', run: 1 }, function: 'notify', arguments: { to: 'ada' }, longestMs: 60_000, }, - run: { executionId, attributes: {} }, + run: { runId, attributes: {} }, child: `${root}-${index}-child`, root, }; diff --git a/packages/workflow-host/measure/reactions.ts b/packages/workflow-host/measure/reactions.ts index 207851d6a..46a0963a4 100644 --- a/packages/workflow-host/measure/reactions.ts +++ b/packages/workflow-host/measure/reactions.ts @@ -7,7 +7,7 @@ import { Effect, Function } from 'effect'; import type { DatabaseSettings } from '../src/database/host-databases.ts'; import { openHostDatabase } from '../src/database/host-databases.ts'; import { openWorkflowHost } from '../src/host/workflow-host.ts'; -import { brainCreated, eventRecordOf, published, specRecorded } from '../src/reaction-testing/brain-writes.ts'; +import { brainCreated, eventRecordOf, published, definitionRecorded } from '../src/reaction-testing/brain-writes.ts'; import { recordedReactions } from '../src/reaction-testing/recorded-reactions.ts'; import { recordedWaiting } from '../src/waiting-testing/recorded-waiting.ts'; import { anEventTrigger, savedNow, type TriggersOf } from './trigger-sets.ts'; @@ -67,11 +67,11 @@ async function publishedInTurn( ); } -function workflowsSaved(store: Parameters[0], measured: LatencyCase): Promise { +function workflowsSaved(store: Parameters[0], measured: LatencyCase): Promise { return Array.from({ length: measured.workflows }, (_, index) => index).reduce>( (before, index) => before.then(() => - specRecorded(store, { + definitionRecorded(store, { name: `w${index}`, version: 1, triggers: (measured.triggersOf ?? anEventTrigger)(typeOf(index, measured.workflows)), diff --git a/packages/workflow-host/measure/rebuild.ts b/packages/workflow-host/measure/rebuild.ts index 5e91a453a..639c725d2 100644 --- a/packages/workflow-host/measure/rebuild.ts +++ b/packages/workflow-host/measure/rebuild.ts @@ -32,7 +32,7 @@ const details = { language: 'jq', fold: reviewsFold, foldLine: 15, - filters: [{ type: 'execution_succeeded', subject: 'inference/review-brief' }], + filters: [{ type: 'run_succeeded', subject: 'reasoning/review-brief' }], initial: {}, }; @@ -40,16 +40,16 @@ function succeeded(index: number) { const at = new Date(Date.UTC(2026, 9, 6) + index * 1000).toISOString(); const output = { campaign: `campaign-${index % 100}`, verdict: index % 3 === 0 ? 'reject' : 'approve' }; const data = { - type: 'execution_succeeded', - primitive: 'inference', + type: 'run_succeeded', + definition_type: 'reasoning', name: 'review-brief', - spec_version: 1, + definition_version: 1, output, record: {}, by: 'acme-admin', at, }; - return { type: 'execution_succeeded', data }; + return { type: 'run_succeeded', data }; } async function recorded(database: HostDatabase, events: number): Promise { @@ -58,7 +58,7 @@ async function recorded(database: HostDatabase, events: number): Promise { (before, stream) => before.then(() => database.store.append( - `${brain}executions/0199a3c4-7d2e-7c1a-9b3f-${String(stream).padStart(12, '0')}`, + `${brain}runs/0199a3c4-7d2e-7c1a-9b3f-${String(stream).padStart(12, '0')}`, Array.from({ length: runsInAStream }, (_, run) => succeeded(stream * runsInAStream + run)), 0, ), @@ -66,14 +66,14 @@ async function recorded(database: HostDatabase, events: number): Promise { Promise.resolve(), ); const saved = { - type: 'spec_created', + type: 'definition_created', name: 'reviews', version: 1, content: { source: 'reviews', details }, by: 'acme-admin', at: '2026-10-06T09:00:00.000Z', }; - await database.store.append(`${brain}specs/recollection`, [{ type: 'spec_created', data: saved }], 0); + await database.store.append(`${brain}definitions/recall`, [{ type: 'definition_created', data: saved }], 0); } interface Counted { @@ -113,7 +113,7 @@ export async function rebuildOn(settings: DatabaseSettings, events: number): Pro const projector = startProjector({ database: counting.database, settings: { - definitionType: 'recollection', + definitionType: 'recall', pool, folding: { dialect: { refused: [], variables: ['event'] }, diff --git a/packages/workflow-host/measure/schedule-firing.ts b/packages/workflow-host/measure/schedule-firing.ts index aa0514c18..a1b078bb5 100644 --- a/packages/workflow-host/measure/schedule-firing.ts +++ b/packages/workflow-host/measure/schedule-firing.ts @@ -5,7 +5,7 @@ import { Effect, Function } from 'effect'; import { openHostDatabase, type DatabaseSettings } from '../src/database/host-databases.ts'; import { openWorkflowHost } from '../src/host/workflow-host.ts'; -import { brainCreated, everyTrigger, specRecorded } from '../src/reaction-testing/brain-writes.ts'; +import { brainCreated, everyTrigger, definitionRecorded } from '../src/reaction-testing/brain-writes.ts'; import { recordedReactions } from '../src/reaction-testing/recorded-reactions.ts'; import { recordedWaiting } from '../src/waiting-testing/recorded-waiting.ts'; @@ -63,7 +63,7 @@ export async function everyFiringOn(database: DatabaseSettings, schedules: numbe await Array.from({ length: schedules }, (_, index) => index).reduce>( (before, index) => before.then(() => - specRecorded(opened.store, { name: `w${index}`, version: 1, triggers: [everyTrigger(aMinute)], when }), + definitionRecorded(opened.store, { name: `w${index}`, version: 1, triggers: [everyTrigger(aMinute)], when }), ), Promise.resolve(), ); diff --git a/packages/workflow-host/measure/sweeps.ts b/packages/workflow-host/measure/sweeps.ts index d9ec210d9..bdee623bc 100644 --- a/packages/workflow-host/measure/sweeps.ts +++ b/packages/workflow-host/measure/sweeps.ts @@ -4,9 +4,9 @@ import { openHostDatabase, type DatabaseSettings } from '../src/database/host-da import { passOf } from '../src/follower/brain-pass.ts'; import { brainRecordsOf } from '../src/follower/brain-records.ts'; import { followedBrainsOn } from '../src/follower/followed-brains.ts'; -import { eventTrigger, published, specRecorded } from '../src/reaction-testing/brain-writes.ts'; +import { eventTrigger, published, definitionRecorded } from '../src/reaction-testing/brain-writes.ts'; import { brainSweepsOn, type BrainSweeps } from '../src/sweeps/brain-sweeps.ts'; -import { specRecordsOn } from '../src/triggers/spec-records.ts'; +import { definitionRecordsOn } from '../src/triggers/definition-records.ts'; import { resultOfEnding } from '../src/waiting-testing/recorded-waiting.ts'; import { servedWaitingOf } from '../src/waiting/waiting-parts.ts'; @@ -63,7 +63,7 @@ export async function sweepCostOn(database: DatabaseSettings, brains: number, re await Effect.runPromise(followed.follow(brainKeyOf(index), null)); }); await inTurn(reacting, (index) => - specRecorded( + definitionRecorded( opened.store, { name: 'react', version: 1, triggers: [eventTrigger({ type: 'com.measure.wanted' })] }, brainKeyOf(index), @@ -79,12 +79,12 @@ export async function sweepCostOn(database: DatabaseSettings, brains: number, re submitted: Effect.die, resultOf: resultOfEnding, cancelDeferred: () => Effect.void, - workflows: 'orchestration', + workflows: 'workflow', now: Date.now, trouble: () => Effect.void, }).calls, - primitive: 'orchestration', - applySpecRecord: specRecordsOn(opened), + definitionType: 'workflow', + applyDefinitionRecord: definitionRecordsOn(opened), unreadable: () => Effect.void, passedEarly: () => Effect.void, registered: [], diff --git a/packages/workflow-host/measure/trigger-sets.ts b/packages/workflow-host/measure/trigger-sets.ts index 73c58f2c7..85aa8cfcc 100644 --- a/packages/workflow-host/measure/trigger-sets.ts +++ b/packages/workflow-host/measure/trigger-sets.ts @@ -1,4 +1,4 @@ -import type { Trigger } from '@beonauto/specs'; +import type { Trigger } from '@beonauto/definitions'; import { cronTrigger, eventTrigger, everyTrigger } from '../src/reaction-testing/brain-writes.ts'; diff --git a/packages/workflow-host/measure/waiting-host.ts b/packages/workflow-host/measure/waiting-host.ts index 625b81de8..a2a88570b 100644 --- a/packages/workflow-host/measure/waiting-host.ts +++ b/packages/workflow-host/measure/waiting-host.ts @@ -1,7 +1,7 @@ import { setTimeout } from 'node:timers/promises'; +import type { SettleRun } from '@beonauto/definitions'; import { streamSignalOf } from '@beonauto/ledger'; -import type { SettleExecution } from '@beonauto/specs'; import { testMachine } from '@beonauto/workflow-engine/testing'; import { Effect, Function } from 'effect'; @@ -17,11 +17,11 @@ export interface WaitingHost { readonly host: WorkflowHost; readonly settledAt: ReadonlyMap; readonly untilSettled: (count: number) => Promise; - readonly untilWaiting: (runIds: readonly string[]) => Promise; + readonly untilWaiting: (runKeys: readonly string[]) => Promise; } -export function childOf(runId: string): string { - return runId.slice(runId.lastIndexOf('/') + 1).replace('-9b3f-', '-8b3f-'); +export function childOf(runKey: string): string { + return runKey.slice(runKey.lastIndexOf('/') + 1).replace('-9b3f-', '-8b3f-'); } async function until(done: () => Promise, everyMs: number): Promise { @@ -31,15 +31,15 @@ async function until(done: () => Promise, everyMs: number): Promise): SettleExecution { +function settlingAt(settledAt: Map): SettleRun { return (address) => Effect.sync(() => { settledAt.set(address.id, Date.now()); return { - execution_id: address.id, - primitive: 'orchestration', + run_id: address.id, + type: 'workflow', name: 'measured', - spec_version: 1, + definition_version: 1, status: 'succeeded', started_at: '2026-10-07T09:00:00.000Z', started_by: 'acme-admin', @@ -53,7 +53,7 @@ export async function waitingHost(settings: DatabaseSettings, reads: Reads, sign const host = await openWorkflowHost({ database: settings, machine: testMachine, - perform: (call) => Effect.succeed({ status: 'waiting', child: childOf(call.key.executionId) }), + perform: (call) => Effect.succeed({ status: 'waiting', child: childOf(call.key.runId) }), settle: settlingAt(settledAt), reports: { unsettled: () => Effect.void, @@ -66,14 +66,14 @@ export async function waitingHost(settings: DatabaseSettings, reads: Reads, sign reactions: signalled ? reactions : { ...reactions, appended: streamSignalOf() }, waiting: recordedWaiting().options, }); - const allWaiting = async (runIds: readonly string[]) => { - const waiting = await Promise.all(runIds.map((runId) => Effect.runPromise(waitingCallsOf(reads, runId)))); + const allWaiting = async (runKeys: readonly string[]) => { + const waiting = await Promise.all(runKeys.map((runKey) => Effect.runPromise(waitingCallsOf(reads, runKey)))); return waiting.every((calls) => calls.length === 1); }; return { host, settledAt, untilSettled: (count) => until(() => Promise.resolve(settledAt.size >= count), 5), - untilWaiting: (runIds) => until(() => allWaiting(runIds), 50), + untilWaiting: (runKeys) => until(() => allWaiting(runKeys), 50), }; } diff --git a/packages/workflow-host/measure/waiting.ts b/packages/workflow-host/measure/waiting.ts index 224c27e5d..eeb43f2ab 100644 --- a/packages/workflow-host/measure/waiting.ts +++ b/packages/workflow-host/measure/waiting.ts @@ -24,7 +24,7 @@ const calling: Schema.JsonObject = { document: header, do: [{ ask: { call: 'noti const pausing: Schema.JsonObject = { document: header, do: [{ pause: { wait: 'PT1H' } }] }; -const ofTheRun = { primitive: 'orchestration', name: 'measured', spec_version: 1, at }; +const ofTheRun = { definition_type: 'workflow', name: 'measured', definition_version: 1, at }; function percentile(sorted: readonly number[], fraction: number): number { return sorted[Math.min(sorted.length - 1, Math.floor(sorted.length * fraction))] ?? 0; @@ -39,7 +39,7 @@ function inTurn(count: number, each: (index: number) => Promise): Promi function latencyOf(recordedAt: ReadonlyMap, { settledAt }: WaitingHost): WaitingLatency { const late = [...recordedAt] - .map(([executionId, when]: readonly [string, number]) => (settledAt.get(executionId) ?? when) - when) + .map(([runId, when]: readonly [string, number]) => (settledAt.get(runId) ?? when) - when) .toSorted((a, b) => a - b); return { runs: late.length, p50: percentile(late, 0.5), p99: percentile(late, 0.99), most: late.at(-1) ?? 0 }; } @@ -48,20 +48,20 @@ async function measuredOn( database: DatabaseSettings, { runs, signalled, oneAtATime }: WaitingCase, document: Schema.JsonObject, - record: (store: Store, executionId: string) => Promise, + record: (store: Store, runId: string) => Promise, ): Promise { const opened = await openHostDatabase(database, Function.constVoid); await brainCreated(opened.store, 'alpha'); const waiting = await waitingHost(database, opened, signalled); await inTurn(runs, (index) => Effect.runPromise(waiting.host.start(runAt(index), startOf(document)))); if (document === calling) { - await waiting.untilWaiting(Array.from({ length: runs }, (_, index) => `acme/alpha/${runAt(index).executionId}`)); + await waiting.untilWaiting(Array.from({ length: runs }, (_, index) => `acme/alpha/${runAt(index).runId}`)); } const recordedAt = new Map(); await inTurn(runs, async (index) => { - const { executionId } = runAt(index); - await record(opened.store, executionId); - recordedAt.set(executionId, Date.now()); + const { runId } = runAt(index); + await record(opened.store, runId); + recordedAt.set(runId, Date.now()); await waiting.untilSettled(oneAtATime ? index + 1 : 0); }); await waiting.untilSettled(runs); @@ -71,22 +71,22 @@ async function measuredOn( } export function childEndingLatencyOn(database: DatabaseSettings, measured: WaitingCase): Promise { - return measuredOn(database, measured, calling, (store, executionId) => - recorded(store, `${alpha}executions/${childOf(executionId)}`, { - type: 'execution_succeeded', + return measuredOn(database, measured, calling, (store, runId) => + recorded(store, `${alpha}runs/${childOf(runId)}`, { + type: 'run_succeeded', output: 'done', record: {}, by: 'brain:alpha', - called_by: { execution_id: executionId, reference: '/do/0/ask', run: 1 }, + called_by: { run_id: runId, reference: '/do/0/ask', run: 1 }, ...ofTheRun, }), ); } export function cancelLatencyOn(database: DatabaseSettings, measured: WaitingCase): Promise { - return measuredOn(database, measured, pausing, (store, executionId) => - recorded(store, `${alpha}executions/${executionId}`, { - type: 'execution_cancel_requested', + return measuredOn(database, measured, pausing, (store, runId) => + recorded(store, `${alpha}runs/${runId}`, { + type: 'run_cancel_requested', kind: 'requested', reason: 'Measured', by: 'acme-admin', diff --git a/packages/workflow-host/package.json b/packages/workflow-host/package.json index 5798382a5..159102f5f 100644 --- a/packages/workflow-host/package.json +++ b/packages/workflow-host/package.json @@ -16,9 +16,9 @@ }, "dependencies": { "@beonauto/brains": "workspace:*", + "@beonauto/definitions": "workspace:*", "@beonauto/ledger": "workspace:*", "@beonauto/operations": "workspace:*", - "@beonauto/specs": "workspace:*", "@beonauto/workflow-engine": "workspace:*", "@event-driven-io/dumbo": "0.13.0-beta.56", "@event-driven-io/emmett-sqlite": "0.43.0-beta.50", diff --git a/packages/workflow-host/src/calls/call-cancels.ts b/packages/workflow-host/src/calls/call-cancels.ts index bb9d86609..29f3e7170 100644 --- a/packages/workflow-host/src/calls/call-cancels.ts +++ b/packages/workflow-host/src/calls/call-cancels.ts @@ -50,9 +50,9 @@ function childCancelled( if (child === null) { return Effect.void; } - const { org, brain } = addressOfRun(run.executionId); + const { org, brain } = addressOfRun(run.runId); return cancelChild({ - child: { org, brain, executionId: child }, + child: { org, brain, runId: child }, reason: call.reason ?? 'parent_ended', lineage: lineageOfSettlement(run, origin), }).pipe( @@ -92,7 +92,7 @@ export function cancelledCall( if (row !== undefined) { return yield* cancelledAs(parts, row, cancelling); } - yield* tombstonedRow(parts.database, key, cancelling.run.executionId); + yield* tombstonedRow(parts.database, key, cancelling.run.runId); return 'tombstoned'; }); } diff --git a/packages/workflow-host/src/calls/call-rows.ts b/packages/workflow-host/src/calls/call-rows.ts index 8bbccc2b4..4d0247e0d 100644 --- a/packages/workflow-host/src/calls/call-rows.ts +++ b/packages/workflow-host/src/calls/call-rows.ts @@ -33,7 +33,7 @@ const CallRowSchema = Schema.Struct({ const UnfinishedRow = Schema.Struct({ call_key: Schema.String, - run_id: Schema.String, + run_key: Schema.String, call: CallText, attributes: AttributesText, result: Schema.NullOr(ResultText), @@ -72,8 +72,8 @@ export function startedRow( ): Effect.Effect { return database .write( - statement`INSERT INTO workflow_calls (call_key, run_id, state, call, attributes, child, root_id) - VALUES (${key}, ${run.executionId}, 'running', ${encodeCall(call)}, ${encodeAttributes(run.attributes)}, + statement`INSERT INTO workflow_calls (call_key, run_key, state, call, attributes, child, root_id) + VALUES (${key}, ${run.runId}, 'running', ${encodeCall(call)}, ${encodeAttributes(run.attributes)}, ${child}, ${root}) ON CONFLICT (call_key) DO NOTHING RETURNING state`, ) @@ -88,7 +88,7 @@ export function refusedRow( ): Effect.Effect { return database .write( - statement`INSERT INTO workflow_calls (call_key, run_id, state, result) VALUES (${key}, ${run.executionId}, + statement`INSERT INTO workflow_calls (call_key, run_key, state, result) VALUES (${key}, ${run.runId}, 'answered', ${encodeResult(refusal)}) ON CONFLICT (call_key) DO NOTHING RETURNING state`, ) @@ -116,11 +116,11 @@ export function cancelledRow(database: HostDatabase, key: string): Effect.Effect export function tombstonedRow( database: HostDatabase, key: string, - runId: string, + runKey: string, ): Effect.Effect { return database .write( - statement`INSERT INTO workflow_calls (call_key, run_id, state) VALUES (${key}, ${runId}, 'cancelled') + statement`INSERT INTO workflow_calls (call_key, run_key, state) VALUES (${key}, ${runKey}, 'cancelled') ON CONFLICT (call_key) DO NOTHING RETURNING state`, ) .pipe(Effect.map((rows) => rows.length > 0)); @@ -173,12 +173,12 @@ function waitingOf(rows: readonly (typeof WaitingRow.Type)[]): readonly WaitingC export function waitingCallsOf( database: HostDatabase, - runId: string, + runKey: string, ): Effect.Effect { return rowsOf( WaitingRow, database.read( - statement`SELECT call_key, call, child FROM workflow_calls WHERE run_id = ${runId} AND state = 'waiting' + statement`SELECT call_key, call, child FROM workflow_calls WHERE run_key = ${runKey} AND state = 'waiting' ORDER BY call_key`, ), ).pipe(Effect.map(waitingOf)); @@ -197,15 +197,15 @@ export function unfinishedCalls(database: HostDatabase): Effect.Effect - rows.map(({ call_key: key, run_id: executionId, call, attributes, result }) => ({ + rows.map(({ call_key: key, run_key: runKey, call, attributes, result }) => ({ key, call, - run: { executionId, attributes }, + run: { runId: runKey, attributes }, result, })), ), diff --git a/packages/workflow-host/src/calls/first-resume.test.ts b/packages/workflow-host/src/calls/first-resume.test.ts index ea2045dae..5d016d9a3 100644 --- a/packages/workflow-host/src/calls/first-resume.test.ts +++ b/packages/workflow-host/src/calls/first-resume.test.ts @@ -8,11 +8,11 @@ import type { Statement } from '../database/statement.ts'; import { aSQLiteFile, openedOn } from '../testing/host-files.ts'; import { hostExecutor } from './host-executor.ts'; -const run = { executionId: 'acme/alpha/0199a3c4-7d2e-7c1a-9b3f-2f1e0d9c8b7a', attributes: {} }; +const run = { runId: 'acme/alpha/0199a3c4-7d2e-7c1a-9b3f-2f1e0d9c8b7a', attributes: {} }; const call: StartCall = { kind: 'start_call', - key: { executionId: run.executionId, reference: '/do/0/ask', run: 1 }, + key: { runId: run.runId, reference: '/do/0/ask', run: 1 }, function: 'notify', arguments: { to: 'ada' }, longestMs: 60_000, diff --git a/packages/workflow-host/src/calls/host-executor.test.ts b/packages/workflow-host/src/calls/host-executor.test.ts index 95301250e..d58aa3bcf 100644 --- a/packages/workflow-host/src/calls/host-executor.test.ts +++ b/packages/workflow-host/src/calls/host-executor.test.ts @@ -8,16 +8,16 @@ import { describe, expect, it } from 'vitest'; import { eventually } from '../testing/eventually.ts'; import { faultyDatabase, type FaultyDatabase } from '../testing/faulty-database.ts'; import { aSQLiteFile, openedOn } from '../testing/host-files.ts'; -import { runId } from '../testing/probe-subjects.ts'; +import { runKey } from '../testing/probe-subjects.ts'; import { hostExecutor, type Deliver, type HostExecutor } from './host-executor.ts'; const origin = { version: 2, lastStep: null }; -const run = { executionId: runId, attributes: { org: 'acme' } }; +const run = { runId: runKey, attributes: { org: 'acme' } }; const call: StartCall = { kind: 'start_call', - key: { executionId: runId, reference: '/do/0/notify', run: 1 }, + key: { runId: runKey, reference: '/do/0/notify', run: 1 }, function: 'notify', arguments: { to: 'ada' }, longestMs: 60_000, @@ -156,7 +156,7 @@ describe('the executor of the host, resuming recorded answers', () => { { status: 'rejected', reason: 'invalid_arguments', - detail: 'The input of execute_spec takes 262211 bytes as JSON, more than the 262144 a run takes', + detail: 'The input of run_definition takes 262211 bytes as JSON, more than the 262144 a run takes', }, ])('gives a recorded $status answer, as it was recorded, when it next resumes', async (recorded) => { const calls = await executing(() => Effect.succeed(recorded)); diff --git a/packages/workflow-host/src/calls/host-executor.ts b/packages/workflow-host/src/calls/host-executor.ts index 8c5117996..a5e5e7720 100644 --- a/packages/workflow-host/src/calls/host-executor.ts +++ b/packages/workflow-host/src/calls/host-executor.ts @@ -51,7 +51,7 @@ export interface ExecutorParts { readonly mostAtOnce: number; readonly mostOpen: number; readonly childOf: (call: StartCall, run: RunContext) => string | null; - readonly childAnswerOf: (runId: string, child: string) => Effect.Effect; + readonly childAnswerOf: (runKey: string, child: string) => Effect.Effect; readonly cancelChild: CancelChild; } @@ -117,7 +117,7 @@ function callsOf(parts: ExecutorParts, running: Background): Calls { written ? delivered(key, call.key, result) : Effect.void, ); const answeredIfEnded = ({ key, call, child }: WaitingCall): Effect.Effect => - Effect.flatMap(parts.childAnswerOf(call.key.executionId, child), (ended) => + Effect.flatMap(parts.childAnswerOf(call.key.runId, child), (ended) => ended === undefined ? Effect.void : settled(key, call, ended), ); const waited = (key: string, call: StartCall, child: string): Effect.Effect => @@ -155,7 +155,7 @@ function startedOnce( ): Effect.Effect { return Effect.gen(function* () { const key = callKeyText(call.key); - const root = correlationOfRun(run.executionId, run.attributes); + const root = correlationOfRun(run.runId, run.attributes); const open = yield* openCallsUnder(database, root); if (open >= mostOpen) { const refusal = tooManyOpen(mostOpen); @@ -170,7 +170,7 @@ function startedOnce( calls.begin(key, call, run); } return inserted; - }).pipe((counted) => calls.underItsRoot(correlationOfRun(run.executionId, run.attributes), counted)); + }).pipe((counted) => calls.underItsRoot(correlationOfRun(run.runId, run.attributes), counted)); } function startedAgain(calls: Calls, call: StartCall, run: RunContext, { state, result }: CallRow): StartReceipt { diff --git a/packages/workflow-host/src/calls/open-call-bound.test.ts b/packages/workflow-host/src/calls/open-call-bound.test.ts index d45305366..f851f2774 100644 --- a/packages/workflow-host/src/calls/open-call-bound.test.ts +++ b/packages/workflow-host/src/calls/open-call-bound.test.ts @@ -14,13 +14,13 @@ const root = '0199a3c4-7d2e-7c1a-9b3f-000000000999'; const attributes = { lineage: { start: 'start-1', correlation: root } }; function runOf(id: string) { - return { executionId: `acme/alpha/0199a3c4-7d2e-7c1a-9b3f-00000000000${id}`, attributes }; + return { runId: `acme/alpha/0199a3c4-7d2e-7c1a-9b3f-00000000000${id}`, attributes }; } -function callOf(executionId: string): StartCall { +function callOf(runId: string): StartCall { return { kind: 'start_call', - key: { executionId, reference: '/do/0/ask', run: 1 }, + key: { runId, reference: '/do/0/ask', run: 1 }, function: 'notify', arguments: { to: 'ada' }, longestMs: 60_000, @@ -69,10 +69,7 @@ describe('the open calls of runs of one tree that start at the same moment', () await Effect.runPromise( Effect.all( - [ - executor.executor.start(callOf(first.executionId), first), - executor.executor.start(callOf(second.executionId), second), - ], + [executor.executor.start(callOf(first.runId), first), executor.executor.start(callOf(second.runId), second)], { concurrency: 'unbounded', }, diff --git a/packages/workflow-host/src/calls/waiting-calls.test.ts b/packages/workflow-host/src/calls/waiting-calls.test.ts index a404b523f..575dfe0d3 100644 --- a/packages/workflow-host/src/calls/waiting-calls.test.ts +++ b/packages/workflow-host/src/calls/waiting-calls.test.ts @@ -10,18 +10,18 @@ import { aSQLiteFile, openedOn } from '../testing/host-files.ts'; import type { ChildCancel, ChildReceipt } from './call-cancels.ts'; import { hostExecutor, type CallAnswer } from './host-executor.ts'; -const runId = 'acme/alpha/0199a3c4-7d2e-7c1a-9b3f-2f1e0d9c8b7a'; +const runKey = 'acme/alpha/0199a3c4-7d2e-7c1a-9b3f-2f1e0d9c8b7a'; const root = '0199a3c4-7d2e-7c1a-9b3f-000000000999'; -const run = { executionId: runId, attributes: { lineage: { start: 'start-1', correlation: root } } }; +const run = { runId: runKey, attributes: { lineage: { start: 'start-1', correlation: root } } }; const child = '0199a3c4-7d2e-7c1a-9b3f-0000000000c1'; -function callAt(reference: string, executionId = runId): StartCall { +function callAt(reference: string, runId = runKey): StartCall { return { kind: 'start_call', - key: { executionId, reference, run: 1 }, + key: { runId, reference, run: 1 }, function: 'notify', arguments: { to: 'ada' }, longestMs: 60_000, @@ -135,7 +135,7 @@ describe('a call whose run ended before the call was marked waiting', () => { describe('the open calls under one run at the top of a tree', () => { it('are bounded, the call past the bound answered as a conflict without being performed', async () => { const calls = await executing(() => Effect.never, { mostOpen: 2 }); - const elsewhere = { executionId: 'acme/alpha/other', attributes: {} }; + const elsewhere = { runId: 'acme/alpha/other', attributes: {} }; await Effect.runPromise(calls.executor.executor.start(callAt('/do/0/a'), run)); await Effect.runPromise(calls.executor.executor.start(callAt('/do/0/b'), run)); @@ -175,7 +175,7 @@ describe('a cancel of a waiting call', () => { expect(await calls.rows()).toEqual([{ state: 'cancelled', child }]); expect(calls.cancels()).toEqual([ { - child: { org: 'acme', brain: 'alpha', executionId: child }, + child: { org: 'acme', brain: 'alpha', runId: child }, reason: 'deadline', lineage: { causationId: stepEventIdOf('0199a3c4-7d2e-7c1a-9b3f-2f1e0d9c8b7a', origin.lastStep), @@ -207,7 +207,7 @@ describe('a cancel of a running call', () => { await Effect.runPromise(Deferred.succeed(finishing, { status: 'succeeded', output: 2 })); expect(receipts).toEqual(['cancelled', 'already_answered', 'tombstoned']); - expect(calls.cancels().map((cancel) => cancel.child.executionId)).toEqual(['a-derived']); + expect(calls.cancels().map((cancel) => cancel.child.runId)).toEqual(['a-derived']); expect(calls.answered()).toHaveLength(1); }); }); diff --git a/packages/workflow-host/src/database/host-tables.ts b/packages/workflow-host/src/database/host-tables.ts index 862ac401d..9934f20a8 100644 --- a/packages/workflow-host/src/database/host-tables.ts +++ b/packages/workflow-host/src/database/host-tables.ts @@ -36,7 +36,7 @@ export const addedColumns: readonly AddedColumn[] = [ export const indexesOfAddedColumns: readonly Statement[] = [ statement`CREATE INDEX IF NOT EXISTS workflow_calls_open_by_root ON workflow_calls (root_id) WHERE state IN ('running', 'waiting')`, - statement`CREATE INDEX IF NOT EXISTS workflow_calls_waiting_by_run ON workflow_calls (run_id) WHERE state = 'waiting'`, + statement`CREATE INDEX IF NOT EXISTS workflow_calls_waiting_by_run ON workflow_calls (run_key) WHERE state = 'waiting'`, ]; const followerTables: readonly Statement[] = [ @@ -55,11 +55,11 @@ const followerTables: readonly Statement[] = [ )`, statement`CREATE TABLE IF NOT EXISTS workflow_followed_scans (name TEXT NOT NULL PRIMARY KEY)`, statement`CREATE TABLE IF NOT EXISTS workflow_passed_runs ( - run_id TEXT NOT NULL PRIMARY KEY, + run_key TEXT NOT NULL PRIMARY KEY, passed_through BIGINT NOT NULL )`, statement`CREATE TABLE IF NOT EXISTS workflow_listeners ( - run_id TEXT NOT NULL, + run_key TEXT NOT NULL, listener TEXT NOT NULL, brain_key TEXT NOT NULL, stream_id TEXT NOT NULL, @@ -67,16 +67,16 @@ const followerTables: readonly Statement[] = [ filters TEXT NOT NULL, workflow TEXT NOT NULL, passed INTEGER NOT NULL DEFAULT 0, - PRIMARY KEY (run_id, listener) + PRIMARY KEY (run_key, listener) )`, statement`CREATE INDEX IF NOT EXISTS workflow_listeners_by_stream ON workflow_listeners (stream_id, passed)`, statement`CREATE INDEX IF NOT EXISTS workflow_listeners_by_brain ON workflow_listeners (brain_key)`, statement`CREATE TABLE IF NOT EXISTS workflow_listener_types ( brain_key TEXT NOT NULL, type TEXT NOT NULL, - run_id TEXT NOT NULL, + run_key TEXT NOT NULL, listener TEXT NOT NULL, - PRIMARY KEY (brain_key, type, run_id, listener) + PRIMARY KEY (brain_key, type, run_key, listener) )`, statement`CREATE TABLE IF NOT EXISTS workflow_subscriptions ( brain_key TEXT NOT NULL, @@ -111,11 +111,11 @@ const followerTables: readonly Statement[] = [ statement`CREATE TABLE IF NOT EXISTS workflow_reaction_backlog ( brain_key TEXT NOT NULL, workflow TEXT NOT NULL, - execution_id TEXT NOT NULL, + run_id TEXT NOT NULL, start TEXT NOT NULL, due BIGINT NOT NULL, attempts INTEGER NOT NULL DEFAULT 0, - PRIMARY KEY (brain_key, execution_id) + PRIMARY KEY (brain_key, run_id) )`, statement`CREATE INDEX IF NOT EXISTS workflow_reaction_backlog_due ON workflow_reaction_backlog (due)`, statement`CREATE TABLE IF NOT EXISTS workflow_reaction_refusals ( @@ -131,35 +131,35 @@ const followerTables: readonly Statement[] = [ export const hostTables: readonly Statement[] = [ statement`CREATE TABLE IF NOT EXISTS workflow_runs ( - run_id TEXT NOT NULL PRIMARY KEY, + run_key TEXT NOT NULL PRIMARY KEY, stream_id TEXT NOT NULL, dispatched_through BIGINT NOT NULL DEFAULT 0, ended_at BIGINT, taken BIGINT NOT NULL DEFAULT 0 )`, - statement`CREATE INDEX IF NOT EXISTS workflow_runs_live ON workflow_runs (taken, run_id) + statement`CREATE INDEX IF NOT EXISTS workflow_runs_live ON workflow_runs (taken, run_key) WHERE ended_at IS NULL OR dispatched_through < ended_at`, statement`CREATE TABLE IF NOT EXISTS workflow_snapshot_chunks ( - run_id TEXT NOT NULL, + run_key TEXT NOT NULL, version BIGINT NOT NULL, chunk INTEGER NOT NULL, chunks INTEGER NOT NULL, bytes INTEGER NOT NULL, text TEXT NOT NULL, - PRIMARY KEY (run_id, version, chunk) + PRIMARY KEY (run_key, version, chunk) )`, statement`CREATE TABLE IF NOT EXISTS workflow_timers ( - run_id TEXT NOT NULL, + run_key TEXT NOT NULL, timer_id TEXT NOT NULL, state TEXT NOT NULL, due_at BIGINT, armed_by BIGINT, - PRIMARY KEY (run_id, timer_id) + PRIMARY KEY (run_key, timer_id) )`, statement`CREATE INDEX IF NOT EXISTS workflow_timers_due ON workflow_timers (due_at) WHERE state = 'armed'`, statement`CREATE TABLE IF NOT EXISTS workflow_calls ( call_key TEXT NOT NULL PRIMARY KEY, - run_id TEXT NOT NULL, + run_key TEXT NOT NULL, state TEXT NOT NULL, call TEXT, attributes TEXT, @@ -171,21 +171,21 @@ export const hostTables: readonly Statement[] = [ statement`CREATE INDEX IF NOT EXISTS workflow_calls_unfinished ON workflow_calls (call_key) WHERE state = 'running' OR (state = 'answered' AND delivered = 0)`, statement`CREATE TABLE IF NOT EXISTS workflow_pending_cancels ( - run_id TEXT NOT NULL PRIMARY KEY, + run_key TEXT NOT NULL PRIMARY KEY, cause TEXT NOT NULL, cancelled_by TEXT NOT NULL, kind TEXT NOT NULL, reason TEXT NOT NULL )`, statement`CREATE TABLE IF NOT EXISTS workflow_due ( - run_id TEXT NOT NULL PRIMARY KEY, + run_key TEXT NOT NULL PRIMARY KEY, version BIGINT NOT NULL, next_due_at BIGINT )`, statement`CREATE INDEX IF NOT EXISTS workflow_due_by_time ON workflow_due (next_due_at) WHERE next_due_at IS NOT NULL`, statement`CREATE TABLE IF NOT EXISTS workflow_settlements ( - run_id TEXT NOT NULL PRIMARY KEY, + run_key TEXT NOT NULL PRIMARY KEY, settlement TEXT, attempts INTEGER NOT NULL DEFAULT 0, last_attempt_at BIGINT diff --git a/packages/workflow-host/src/database/postgresql-database.test.ts b/packages/workflow-host/src/database/postgresql-database.test.ts index c56dbd24b..6e51cd2ad 100644 --- a/packages/workflow-host/src/database/postgresql-database.test.ts +++ b/packages/workflow-host/src/database/postgresql-database.test.ts @@ -128,16 +128,16 @@ describe('the host database on PostgreSQL, open', () => { const migration = asked().length; const read = await Effect.runPromise(database.read(statement`SELECT ${1} AS one`)); - const written = await Effect.runPromise(database.write(statement`DELETE FROM workflow_due WHERE run_id = ${'a'}`)); + const written = await Effect.runPromise(database.write(statement`DELETE FROM workflow_due WHERE run_key = ${'a'}`)); await database.close(); expect(asked().slice(migration)).toEqual([ { text: 'SELECT $1 AS one', values: [1] }, - { text: 'DELETE FROM workflow_due WHERE run_id = $1', values: ['a'] }, + { text: 'DELETE FROM workflow_due WHERE run_key = $1', values: ['a'] }, ]); expect([read, written]).toEqual([ [{ answered: 'SELECT $1 AS one' }], - [{ answered: 'DELETE FROM workflow_due WHERE run_id = $1' }], + [{ answered: 'DELETE FROM workflow_due WHERE run_key = $1' }], ]); expect(said().slice(-2)).toEqual(['store closed', 'connections ended']); }); diff --git a/packages/workflow-host/src/database/statement.test.ts b/packages/workflow-host/src/database/statement.test.ts index f1dc92fa5..b04e3cc4c 100644 --- a/packages/workflow-host/src/database/statement.test.ts +++ b/packages/workflow-host/src/database/statement.test.ts @@ -4,15 +4,15 @@ import { describe, expect, it } from 'vitest'; import { openedOn } from '../testing/host-files.ts'; import { statement, textOnPostgreSQL } from './statement.ts'; -const runId = 'acme/alpha/0199a3c4-7d2e-7c1a-9b3f-2f1e0d9c8b7a'; +const runKey = 'acme/alpha/0199a3c4-7d2e-7c1a-9b3f-2f1e0d9c8b7a'; describe('a statement of the host', () => { it('numbers its values on PostgreSQL, in the order they appear', () => { - const due = statement`UPDATE workflow_timers SET due_at = ${2000} WHERE run_id = ${runId} AND timer_id = ${'1'}`; + const due = statement`UPDATE workflow_timers SET due_at = ${2000} WHERE run_key = ${runKey} AND timer_id = ${'1'}`; expect([textOnPostgreSQL(due), due.values]).toEqual([ - 'UPDATE workflow_timers SET due_at = $1 WHERE run_id = $2 AND timer_id = $3', - [2000, runId, '1'], + 'UPDATE workflow_timers SET due_at = $1 WHERE run_key = $2 AND timer_id = $3', + [2000, runKey, '1'], ]); }); diff --git a/packages/workflow-host/src/dispatch/run-serialiser.ts b/packages/workflow-host/src/dispatch/run-serialiser.ts index 3eedf2506..bdbea892c 100644 --- a/packages/workflow-host/src/dispatch/run-serialiser.ts +++ b/packages/workflow-host/src/dispatch/run-serialiser.ts @@ -8,24 +8,24 @@ interface RunLock { export function runSerialiser(): RunSerialiser { const locks = new Map(); - const lockOf = (runId: string): RunLock => { - const lock = locks.get(runId) ?? { semaphore: Semaphore.makeUnsafe(1), holders: 0 }; + const lockOf = (runKey: string): RunLock => { + const lock = locks.get(runKey) ?? { semaphore: Semaphore.makeUnsafe(1), holders: 0 }; lock.holders += 1; - locks.set(runId, lock); + locks.set(runKey, lock); return { semaphore: lock.semaphore, release: () => { lock.holders -= 1; if (lock.holders === 0) { - locks.delete(runId); + locks.delete(runKey); } }, }; }; return { - serialise: (runId, work) => + serialise: (runKey, work) => Effect.suspend(() => { - const { semaphore, release } = lockOf(runId); + const { semaphore, release } = lockOf(runKey); return semaphore.withPermit(work).pipe(Effect.ensuring(Effect.sync(release))); }), }; diff --git a/packages/workflow-host/src/dispatch/sql-watermark.ts b/packages/workflow-host/src/dispatch/sql-watermark.ts index daad69c5b..1c40c118c 100644 --- a/packages/workflow-host/src/dispatch/sql-watermark.ts +++ b/packages/workflow-host/src/dispatch/sql-watermark.ts @@ -4,30 +4,30 @@ import { Effect, Schema } from 'effect'; import { rowsOf, WholeNumber, type HostDatabase } from '../database/host-database.ts'; import { statement } from '../database/statement.ts'; import { runCaughtUp } from '../listeners/listener-rows.ts'; -import { streamOfRun } from '../runs/run-address.ts'; +import { runLogStreamOf } from '../runs/run-address.ts'; const WatermarkRow = Schema.Struct({ dispatched_through: WholeNumber }); -const RunRow = Schema.Struct({ run_id: Schema.String }); +const RunRow = Schema.Struct({ run_key: Schema.String }); export function sqlWatermark(database: HostDatabase): DispatchWatermark { return { - read: (runId) => + read: (runKey) => Effect.orDie( rowsOf( WatermarkRow, - database.read(statement`SELECT dispatched_through FROM workflow_runs WHERE run_id = ${runId}`), + database.read(statement`SELECT dispatched_through FROM workflow_runs WHERE run_key = ${runKey}`), ), ).pipe(Effect.map(([row]) => row?.dispatched_through ?? 0)), - advance: (runId, through) => + advance: (runKey, through) => Effect.orDie( database.write( - statement`INSERT INTO workflow_runs (run_id, stream_id, dispatched_through) - VALUES (${runId}, ${streamOfRun(runId)}, ${through}) - ON CONFLICT (run_id) DO UPDATE SET dispatched_through = excluded.dispatched_through + statement`INSERT INTO workflow_runs (run_key, stream_id, dispatched_through) + VALUES (${runKey}, ${runLogStreamOf(runKey)}, ${through}) + ON CONFLICT (run_key) DO UPDATE SET dispatched_through = excluded.dispatched_through WHERE workflow_runs.dispatched_through < excluded.dispatched_through`, ), - ).pipe(Effect.andThen(runCaughtUp(database, runId, through))), + ).pipe(Effect.andThen(runCaughtUp(database, runKey, through))), behindRuns: (limit) => Effect.orDie( rowsOf( @@ -38,17 +38,17 @@ export function sqlWatermark(database: HostDatabase): DispatchWatermark { SELECT COALESCE(MAX(taken), 0) + 1 FROM workflow_runs WHERE ended_at IS NULL OR dispatched_through < ended_at ) - WHERE run_id IN ( - SELECT run.run_id FROM workflow_runs AS run + WHERE run_key IN ( + SELECT run.run_key FROM workflow_runs AS run JOIN emt_streams AS stream ON stream.stream_id = run.stream_id AND stream.is_archived = FALSE WHERE (run.ended_at IS NULL OR run.dispatched_through < run.ended_at) AND stream.stream_position > run.dispatched_through - ORDER BY run.taken, run.run_id + ORDER BY run.taken, run.run_key LIMIT ${limit} ) - RETURNING run_id`, + RETURNING run_key`, ), ), - ).pipe(Effect.map((rows) => rows.map(({ run_id: runId }) => runId))), + ).pipe(Effect.map((rows) => rows.map(({ run_key: runKey }) => runKey))), }; } diff --git a/packages/workflow-host/src/due-work/due-looping.ts b/packages/workflow-host/src/due-work/due-looping.ts index b6247209f..f77d2bdd1 100644 --- a/packages/workflow-host/src/due-work/due-looping.ts +++ b/packages/workflow-host/src/due-work/due-looping.ts @@ -8,7 +8,7 @@ import { aSQLiteFile, openedOn } from '../testing/host-files.ts'; import { sqlTimers } from '../timers/sql-timers.ts'; import type { FakeDueWork } from './fake-due-work.ts'; -const runId = 'acme/alpha/0199a3c4-7d2e-7c1a-9b3f-2f1e0d9c8b7a'; +const runKey = 'acme/alpha/0199a3c4-7d2e-7c1a-9b3f-2f1e0d9c8b7a'; const engine: WorkflowEngine = { submit: Function.constant(Effect.die(new Error('The loop submits through the host'))), @@ -56,9 +56,9 @@ export async function dueLooping( order: () => order, troubles: () => troubles, armTimer: async (dueAt, timerId = '1') => { - const timer: ArmTimer = { kind: 'arm_timer', executionId: runId, timerId, dueAt, purpose: 'wait' }; + const timer: ArmTimer = { kind: 'arm_timer', runId: runKey, timerId, dueAt, purpose: 'wait' }; await Effect.runPromise( - timers.timers.arm(timer, { executionId: runId, attributes: {} }, { version: 1, lastStep: null }), + timers.timers.arm(timer, { runId: runKey, attributes: {} }, { version: 1, lastStep: null }), ); }, }; diff --git a/packages/workflow-host/src/emissions/host-emissions.test.ts b/packages/workflow-host/src/emissions/host-emissions.test.ts index a23f562d1..65433f299 100644 --- a/packages/workflow-host/src/emissions/host-emissions.test.ts +++ b/packages/workflow-host/src/emissions/host-emissions.test.ts @@ -5,7 +5,7 @@ import { reactingHost } from '../reaction-testing/reacting-host.ts'; import { until } from '../reaction-testing/until.ts'; import { runAt, startOf, workflow } from '../testing/host-documents.ts'; -const executionId = '0199a3c4-7d2e-7c1a-9b3f-2f1e0d9c8b7c'; +const runId = '0199a3c4-7d2e-7c1a-9b3f-2f1e0d9c8b7c'; const announcing = workflow(`do: - notify: { call: notify, with: { to: ada } } @@ -16,9 +16,9 @@ describe('a workflow run by the host that emits an event', () => { it('hands the event to the brain as emitted by the run and its workflow, one deeper than the run', async () => { const reacting = await reactingHost(); const start = startOf(announcing); - const attributes = { ...start.attributes, spec: { name: 'close', version: 3 }, caller: { id: 'acme-admin' } }; + const attributes = { ...start.attributes, definition: { name: 'close', version: 3 }, caller: { id: 'acme-admin' } }; - await Effect.runPromise(reacting.host.start(runAt(executionId), { ...start, attributes })); + await Effect.runPromise(reacting.host.start(runAt(runId), { ...start, attributes })); const emissions = await until( () => Promise.resolve(reacting.reactions.emissions()), (found) => found.length > 0, @@ -27,7 +27,7 @@ describe('a workflow run by the host that emits an event', () => { expect(emissions).toMatchObject([ { event: { type: 'com.acme.closed', source: '/acme/ledger', data: { month: 'september' } }, - emitter: { execution_id: executionId, workflow: 'close', version: 3 }, + emitter: { run_id: runId, workflow: 'close', version: 3 }, depth: 1, by: 'acme-admin', }, diff --git a/packages/workflow-host/src/emissions/ledger-emitter.test.ts b/packages/workflow-host/src/emissions/ledger-emitter.test.ts index d1eedf310..9725be5dd 100644 --- a/packages/workflow-host/src/emissions/ledger-emitter.test.ts +++ b/packages/workflow-host/src/emissions/ledger-emitter.test.ts @@ -1,20 +1,20 @@ +import { eventEmitter, publishedEventOf } from '@beonauto/definitions'; import { Conflict, messageIdOf } from '@beonauto/operations'; import { memoryLedger } from '@beonauto/operations/testing'; -import { eventEmitter, publishedEventOf } from '@beonauto/specs'; import type { EmitEvent } from '@beonauto/workflow-engine'; import { emitterProbes } from '@beonauto/workflow-engine/testing'; import { Effect } from 'effect'; import { describe, expect, it } from 'vitest'; -import { streamOfRun } from '../runs/run-address.ts'; +import { runLogStreamOf } from '../runs/run-address.ts'; import { ledgerEmitter } from './ledger-emitter.ts'; -const executionId = '0199a3c4-7d2e-7c1a-9b3f-2f1e0d9c8b7a'; +const runId = '0199a3c4-7d2e-7c1a-9b3f-2f1e0d9c8b7a'; const run = { - executionId: `acme/alpha/${executionId}`, + runId: `acme/alpha/${runId}`, attributes: { - spec: { name: 'close-the-month', version: 2 }, + definition: { name: 'close-the-month', version: 2 }, caller: { id: 'acme-admin' }, depth: 1, lineage: { correlation: 'r-top' }, @@ -47,7 +47,7 @@ const event = { const emission: EmitEvent = { kind: 'emit_event', - key: { executionId: run.executionId, reference: '/do/0/x', run: 1 }, + key: { runId: run.runId, reference: '/do/0/x', run: 1 }, event, }; @@ -67,13 +67,13 @@ describe('an event a run of the host emits', () => { records.map(({ causationId, correlationId, data }) => [causationId, correlationId, publishedEventOf(data)]), ).toEqual([ [ - messageIdOf(streamOfRun(run.executionId), 4), + messageIdOf(runLogStreamOf(run.runId), 4), 'r-top', { type: 'event_published', event, filled: [], - emitted_by: { execution_id: executionId, workflow: 'close-the-month', version: 2 }, + emitted_by: { run_id: runId, workflow: 'close-the-month', version: 2 }, depth: 2, by: 'acme-admin', at: '2026-10-01T09:00:01.000Z', diff --git a/packages/workflow-host/src/emissions/ledger-emitter.ts b/packages/workflow-host/src/emissions/ledger-emitter.ts index 1d6406b5c..9706f41d1 100644 --- a/packages/workflow-host/src/emissions/ledger-emitter.ts +++ b/packages/workflow-host/src/emissions/ledger-emitter.ts @@ -1,25 +1,25 @@ +import type { EmitEvent } from '@beonauto/definitions'; import { messageIdOf } from '@beonauto/operations'; -import type { EmitEvent } from '@beonauto/specs'; import { DispatchFailed, type Emitter } from '@beonauto/workflow-engine'; import { Effect } from 'effect'; import { reactionOfRun } from '../reactions/run-attributes.ts'; -import { addressOfRun, streamOfRun } from '../runs/run-address.ts'; +import { addressOfRun, runLogStreamOf } from '../runs/run-address.ts'; export function ledgerEmitter(emit: EmitEvent, now: () => number): Emitter { return { emit: (emission, run, origin) => { - const address = addressOfRun(run.executionId); + const address = addressOfRun(run.runId); const { workflow, version, caller, depth, correlation } = reactionOfRun(run.attributes); const lineage = { - causationId: messageIdOf(streamOfRun(run.executionId), origin.version), - correlationId: correlation ?? address.executionId, + causationId: messageIdOf(runLogStreamOf(run.runId), origin.version), + correlationId: correlation ?? address.runId, }; return emit( address, { event: emission.event, - emitter: { execution_id: address.executionId, workflow, version }, + emitter: { run_id: address.runId, workflow, version }, depth: depth + 1, by: caller, at: new Date(now()).toISOString(), diff --git a/packages/workflow-host/src/follower/brain-discovery.test.ts b/packages/workflow-host/src/follower/brain-discovery.test.ts index 167387ee6..5b4d7edd5 100644 --- a/packages/workflow-host/src/follower/brain-discovery.test.ts +++ b/packages/workflow-host/src/follower/brain-discovery.test.ts @@ -9,7 +9,7 @@ import { brainRenamed, eventTrigger, published, - specRecorded, + definitionRecorded, } from '../reaction-testing/brain-writes.ts'; import { reactingHost, type ReactingHost } from '../reaction-testing/reacting-host.ts'; import { until } from '../reaction-testing/until.ts'; @@ -33,7 +33,7 @@ describe('a brain its org created before the host started', () => { const settings = await onSQLite(); const { store } = await openedOn(settings); await brainCreated(store, 'alpha'); - await specRecorded(store, { name: 'close', version: 1, triggers: [closed] }); + await definitionRecorded(store, { name: 'close', version: 1, triggers: [closed] }); await published(store, { id: 'before', type: 'com.acme.closed' }); const reacting = await reactingHost({ settings }); @@ -52,7 +52,7 @@ describe('a brain its org creates while the host runs', () => { await brainRenamed(store, 'alpha'); await brainCreated(store, 'beta'); - await specRecorded(store, { name: 'watch', version: 1, triggers: [sentinel] }, beta); + await definitionRecorded(store, { name: 'watch', version: 1, triggers: [sentinel] }, beta); await published(store, { id: 's1', type: 'com.acme.sentinel' }, {}, beta); const starts = await startsReaching(reacting, 1); @@ -72,7 +72,7 @@ describe('a brain its org created while the host was stopped', () => { await first.host.stop(); await brainCreated(first.database.store, 'beta'); - await specRecorded(first.database.store, { name: 'watch', version: 1, triggers: [sentinel] }, beta); + await definitionRecorded(first.database.store, { name: 'watch', version: 1, triggers: [sentinel] }, beta); await published(first.database.store, { id: 's1', type: 'com.acme.sentinel' }, {}, beta); const second = await reactingHost({ settings }); const starts = await startsReaching(second, 1); diff --git a/packages/workflow-host/src/follower/brain-discovery.ts b/packages/workflow-host/src/follower/brain-discovery.ts index f1fc78d93..f3016c0f9 100644 --- a/packages/workflow-host/src/follower/brain-discovery.ts +++ b/packages/workflow-host/src/follower/brain-discovery.ts @@ -3,7 +3,11 @@ import { Effect, Schema } from 'effect'; import { rowsOf, WholeNumber, type HostDatabase } from '../database/host-database.ts'; import { statement } from '../database/statement.ts'; -import { specRecordsIn, type ApplySpecRecord, type SpecRecord } from '../triggers/spec-records.ts'; +import { + definitionRecordsIn, + type ApplyDefinitionRecord, + type DefinitionRecord, +} from '../triggers/definition-records.ts'; import type { BrainRecords } from './brain-records.ts'; import type { FollowedBrains } from './followed-brains.ts'; import { scannedListeners } from './listener-scan.ts'; @@ -18,9 +22,9 @@ export interface DiscoveryParts { readonly database: HostDatabase; readonly brains: FollowedBrains; readonly records: BrainRecords; - readonly applySpecRecord: ApplySpecRecord; - readonly unreadable: (brainKey: string, record: SpecRecord) => Effect.Effect; - readonly primitive: string; + readonly applyDefinitionRecord: ApplyDefinitionRecord; + readonly unreadable: (brainKey: string, record: DefinitionRecord) => Effect.Effect; + readonly definitionType: string; } const OrgStreamRow = Schema.Struct({ stream_id: Schema.String, stream_position: WholeNumber }); @@ -63,17 +67,24 @@ function brainKeyOf(stream: string, brain: string): string { return stream.replace(orgRegistry, (_, org: string) => `brain/${org}/${brain}/`); } -function followedAtTailOn({ database, brains, records, applySpecRecord, unreadable, primitive }: DiscoveryParts) { - const applied = (brainKey: string, record: SpecRecord) => - Effect.flatMap(applySpecRecord(brainKey, record), (outcome) => +function followedAtTailOn({ + database, + brains, + records, + applyDefinitionRecord, + unreadable, + definitionType, +}: DiscoveryParts) { + const applied = (brainKey: string, record: DefinitionRecord) => + Effect.flatMap(applyDefinitionRecord(brainKey, record), (outcome) => outcome === 'unreadable' ? unreadable(brainKey, record) : Effect.void, ); return (brainKey: string) => Effect.gen(function* () { const [, org = '', brain = ''] = brainKey.split('/'); yield* brains.follow(brainKey, yield* records.tail({ org, brain })); - const recorded = yield* Effect.promise(() => database.store.read(`${brainKey}specs/${primitive}`, 0)); - yield* Effect.forEach(specRecordsIn(recorded), (record) => applied(brainKey, record), { discard: true }); + const recorded = yield* Effect.promise(() => database.store.read(`${brainKey}definitions/${definitionType}`, 0)); + yield* Effect.forEach(definitionRecordsIn(recorded), (record) => applied(brainKey, record), { discard: true }); }); } diff --git a/packages/workflow-host/src/follower/brain-pass.test.ts b/packages/workflow-host/src/follower/brain-pass.test.ts index ce09b02b6..b2e300d74 100644 --- a/packages/workflow-host/src/follower/brain-pass.test.ts +++ b/packages/workflow-host/src/follower/brain-pass.test.ts @@ -96,10 +96,10 @@ function deliveringEach(delivered: (id: string) => void): RecordConsumer { function listenerFor(type: string) { return { - runId: 'acme/alpha/r-1', + runKey: 'acme/alpha/r-1', listener: 'wait', brainKey, - streamId: `${brainKey}runs/r-1`, + streamId: `${brainKey}run-logs/r-1`, armedBy: 1, filters: JSON.stringify([{ type }]), workflow: 'wait', @@ -108,7 +108,7 @@ function listenerFor(type: string) { } function undispatchedRun(): CountedRecords { - return countedRecords(() => Effect.succeed([recordAt('runs/r-1', 1), recordAt('notes/n2', 2)])); + return countedRecords(() => Effect.succeed([recordAt('run-logs/r-1', 1), recordAt('notes/n2', 2)])); } function endlessNotes(): CountedRecords { @@ -128,8 +128,8 @@ async function passing( brains, consumers, calls: [], - primitive: 'orchestration', - applySpecRecord: () => Effect.succeed('applied'), + definitionType: 'workflow', + applyDefinitionRecord: () => Effect.succeed('applied'), unreadable: () => Effect.void, passedEarly, registered: [], diff --git a/packages/workflow-host/src/follower/brain-records.ts b/packages/workflow-host/src/follower/brain-records.ts index 25dad0cf8..6f0a41043 100644 --- a/packages/workflow-host/src/follower/brain-records.ts +++ b/packages/workflow-host/src/follower/brain-records.ts @@ -4,14 +4,14 @@ import { Effect } from 'effect'; const recordsInAPage = 100; -const specTypes: readonly string[] = ['spec_created', 'spec_updated', 'spec_retired']; +const definitionTypes: readonly string[] = ['definition_created', 'definition_updated', 'definition_retired']; const factTypes: ReadonlySet = new Set([ - 'execution_started', - 'execution_succeeded', - 'execution_rejected', - 'execution_failed', - ...specTypes, + 'run_started', + 'run_succeeded', + 'run_rejected', + 'run_failed', + ...definitionTypes, ]); export const noRecordTypes: ReadonlySet = new Set(); @@ -45,7 +45,7 @@ export function brainRecordsOf(store: EventStore): BrainRecords { { order: 'asc', limit: recordsInAPage, - dataOf: [...new Set([...specTypes, ...delivers])], + dataOf: [...new Set([...definitionTypes, ...delivers])], ...(cursor === null ? {} : { cursor }), }, ), diff --git a/packages/workflow-host/src/follower/followed-events.test.ts b/packages/workflow-host/src/follower/followed-events.test.ts index fb6abdabf..dbd769691 100644 --- a/packages/workflow-host/src/follower/followed-events.test.ts +++ b/packages/workflow-host/src/follower/followed-events.test.ts @@ -26,23 +26,23 @@ function recordOf(stream: string, data: Data, correlationId: string | null = nul function published(extra: Readonly>, correlationId: string | null = null) { const data = { type: 'event_published', event: told, filled: [], ...extra, by: 'acme-admin', at }; - return followedEventOf(recordOf('events/e1', data, correlationId), 'orchestration'); + return followedEventOf(recordOf('events/e1', data, correlationId), 'workflow'); } -function runFact(executionId: string, [primitive, name]: readonly [string, string], correlationId: string | null) { +function runFact(runId: string, [type, name]: readonly [string, string], correlationId: string | null) { const data = { - type: 'execution_started', - primitive, + type: 'run_started', + definition_type: type, name, - spec_version: 1, + definition_version: 1, input: {}, by: 'acme-admin', at, }; - return followedEventOf(recordOf(`executions/${executionId}`, data, correlationId), 'orchestration'); + return followedEventOf(recordOf(`runs/${runId}`, data, correlationId), 'workflow'); } -const emittedBy = { emitted_by: { execution_id: 'r-nested', workflow: 'close', version: 1 }, depth: 2 }; +const emittedBy = { emitted_by: { run_id: 'r-nested', workflow: 'close', version: 1 }, depth: 2 }; describe('an event published to a brain, as the follower reads it', () => { it('is the event at depth 1 when sent from outside, owned by no workflow', () => { @@ -51,49 +51,48 @@ describe('an event published to a brain, as the follower reads it', () => { it('when a run emitted it, is owned by the workflow of that run, and names the run that began the chain', () => { expect([published(emittedBy, 'r-top'), published(emittedBy, 'r-nested'), published(emittedBy)]).toMatchObject([ - { depth: 2, emitter: { executionId: 'r-nested', workflow: 'close' }, ownedBy: ['close'], topRun: 'r-top' }, + { depth: 2, emitter: { runId: 'r-nested', workflow: 'close' }, ownedBy: ['close'], topRun: 'r-top' }, { topRun: undefined }, { topRun: undefined }, ]); }); it('is unreadable when its record cannot be read as a publication', () => { - expect(followedEventOf(recordOf('events/e1', { type: 'event_published' }), 'orchestration')).toBe('unreadable'); + expect(followedEventOf(recordOf('events/e1', { type: 'event_published' }), 'workflow')).toBe('unreadable'); }); }); describe('a fact of a brain, as the follower reads it', () => { it('about a run, is owned by the workflow it ran and names the run that began the chain, one deeper', () => { - expect([ - runFact('r-1', ['orchestration', 'close'], 'r-top'), - runFact('r-2', ['inference', 'sum'], 'r-2'), - ]).toMatchObject([ - { - depth: 1, - ownedBy: ['close'], - topRun: 'r-top', - event: { type: 'execution_started', subject: 'orchestration/close' }, - }, - { depth: 1, ownedBy: [], topRun: undefined, event: { subject: 'inference/sum' } }, - ]); + expect([runFact('r-1', ['workflow', 'close'], 'r-top'), runFact('r-2', ['reasoning', 'sum'], 'r-2')]).toMatchObject( + [ + { + depth: 1, + ownedBy: ['close'], + topRun: 'r-top', + event: { type: 'run_started', subject: 'workflow/close' }, + }, + { depth: 1, ownedBy: [], topRun: undefined, event: { subject: 'reasoning/sum' } }, + ], + ); }); - it('about a spec, is owned by no workflow', () => { - const retired = { type: 'spec_retired', name: 'close', by: 'acme-admin', at }; + it('about a definition, is owned by no workflow', () => { + const retired = { type: 'definition_retired', name: 'close', by: 'acme-admin', at }; - expect(followedEventOf(recordOf('specs/orchestration', retired), 'orchestration')).toMatchObject({ + expect(followedEventOf(recordOf('definitions/workflow', retired), 'workflow')).toMatchObject({ depth: 1, ownedBy: [], topRun: undefined, - event: { type: 'spec_retired', source: '/specs/orchestration/close' }, + event: { type: 'definition_retired', source: '/definitions/workflow/close' }, }); }); it('is unreadable when its record cannot be read, and no fact at all for a record that is none', () => { expect([ - followedEventOf(recordOf('executions/r-1', { type: 'execution_started' }), 'orchestration'), - followedEventOf(recordOf('executions/r-1', { type: 'call_recorded' }), 'orchestration'), - followedEventOf(recordOf('runs/r-1', { type: 'input_applied' }), 'orchestration'), + followedEventOf(recordOf('runs/r-1', { type: 'run_started' }), 'workflow'), + followedEventOf(recordOf('runs/r-1', { type: 'call_recorded' }), 'workflow'), + followedEventOf(recordOf('run-logs/r-1', { type: 'input_applied' }), 'workflow'), ]).toEqual(['unreadable', 'none', 'none']); }); @@ -101,9 +100,9 @@ describe('a fact of a brain, as the follower reads it', () => { const tested = { test_id: 't-1', server: 'graph', tool: 'search', by: 'acme-builder', at }; expect([ - followedEventOf(recordOf('tool-tests/t-1', { type: 'tool_test_started', ...tested }), 'orchestration'), - followedEventOf(recordOf('tool-tests/t-1', { type: 'tool_test_answered', ...tested }), 'orchestration'), - followedEventOf(recordOf('tool-tests/t-1', { type: 'tool_test_started' }), 'recollection'), + followedEventOf(recordOf('tool-tests/t-1', { type: 'tool_test_started', ...tested }), 'workflow'), + followedEventOf(recordOf('tool-tests/t-1', { type: 'tool_test_answered', ...tested }), 'workflow'), + followedEventOf(recordOf('tool-tests/t-1', { type: 'tool_test_started' }), 'recall'), ]).toEqual(['none', 'none', 'none']); }); }); @@ -113,8 +112,8 @@ describe('the reads and tellings of a conversation, as the follower meets them', const read = { call_id: 'c-1', server: 'chat', tool: 'thread_replies', by: 'brain:alpha', at }; expect([ - followedEventOf(recordOf('conversation-calls/c-1', { type: 'replies_read', ...read }), 'orchestration'), - followedEventOf(recordOf('conversation-calls/c-1', { type: 'telling_started', ...read }), 'recollection'), + followedEventOf(recordOf('conversation-calls/c-1', { type: 'replies_read', ...read }), 'workflow'), + followedEventOf(recordOf('conversation-calls/c-1', { type: 'telling_started', ...read }), 'recall'), ]).toEqual(['none', 'none']); }); }); diff --git a/packages/workflow-host/src/follower/followed-events.ts b/packages/workflow-host/src/follower/followed-events.ts index 46500afdb..7255dd51a 100644 --- a/packages/workflow-host/src/follower/followed-events.ts +++ b/packages/workflow-host/src/follower/followed-events.ts @@ -1,9 +1,9 @@ +import { brainFactOf, publishedEventOf, type CloudEvent, type EventPublished } from '@beonauto/definitions'; import { streamKindOf, type RecordedEvent } from '@beonauto/operations'; -import { brainFactOf, publishedEventOf, type CloudEvent, type EventPublished } from '@beonauto/specs'; import { Option, Schema } from 'effect'; interface Emitter { - readonly executionId: string; + readonly runId: string; readonly workflow: string; } @@ -17,24 +17,23 @@ export interface FollowedEvent { export type EventOfRecord = FollowedEvent | 'none' | 'unreadable'; -const runFacts: ReadonlySet = new Set([ - 'execution_started', - 'execution_succeeded', - 'execution_rejected', - 'execution_failed', -]); +const runFacts: ReadonlySet = new Set(['run_started', 'run_succeeded', 'run_rejected', 'run_failed']); -const specFacts: ReadonlySet = new Set(['spec_created', 'spec_updated', 'spec_retired']); +const definitionFacts: ReadonlySet = new Set([ + 'definition_created', + 'definition_updated', + 'definition_retired', +]); const decodeDepth = Schema.decodeUnknownOption(Schema.Struct({ depth: Schema.Int })); -function otherRun(correlation: string | null, executionId: string): string | undefined { - return correlation === null || correlation === executionId ? undefined : correlation; +function otherRun(correlation: string | null, runId: string): string | undefined { + return correlation === null || correlation === runId ? undefined : correlation; } function emitterOf(publication: EventPublished): Emitter | undefined { const emitted = publication.emitted_by; - return emitted === undefined ? undefined : { executionId: emitted.execution_id, workflow: emitted.workflow }; + return emitted === undefined ? undefined : { runId: emitted.run_id, workflow: emitted.workflow }; } function published(record: RecordedEvent): EventOfRecord { @@ -48,12 +47,12 @@ function published(record: RecordedEvent): EventOfRecord { depth: publication.depth ?? 1, emitter, ownedBy: emitter === undefined ? [] : [emitter.workflow], - topRun: emitter === undefined ? undefined : otherRun(record.correlationId, emitter.executionId), + topRun: emitter === undefined ? undefined : otherRun(record.correlationId, emitter.runId), }; } -function workflowsOfSubject(subject: string | undefined, primitive: string): readonly string[] { - return subject?.startsWith(`${primitive}/`) === true ? [subject.slice(primitive.length + 1)] : []; +function workflowsOfSubject(subject: string | undefined, type: string): readonly string[] { + return subject?.startsWith(`${type}/`) === true ? [subject.slice(type.length + 1)] : []; } function depthOf(data: unknown): number { @@ -63,7 +62,7 @@ function depthOf(data: unknown): number { ); } -function fact(record: RecordedEvent, primitive: string): EventOfRecord { +function fact(record: RecordedEvent, type: string): EventOfRecord { const event = brainFactOf(record); if (event === undefined) { return 'unreadable'; @@ -72,22 +71,22 @@ function fact(record: RecordedEvent, primitive: string): EventOfRecord { if (!runFacts.has(record.type)) { return { event, depth, emitter: undefined, ownedBy: [], topRun: undefined }; } - const executionId = record.stream.slice(record.stream.indexOf('/') + 1); + const runId = record.stream.slice(record.stream.indexOf('/') + 1); return { event, depth, emitter: undefined, - ownedBy: workflowsOfSubject(event.subject, primitive), - topRun: otherRun(record.correlationId, executionId), + ownedBy: workflowsOfSubject(event.subject, type), + topRun: otherRun(record.correlationId, runId), }; } -export function followedEventOf(record: RecordedEvent, primitive: string): EventOfRecord { +export function followedEventOf(record: RecordedEvent, type: string): EventOfRecord { const kind = streamKindOf(record.stream); if (kind === 'events' && record.type === 'event_published') { return published(record); } const isFact = - (kind === 'executions' && runFacts.has(record.type)) || (kind === 'specs' && specFacts.has(record.type)); - return isFact ? fact(record, primitive) : 'none'; + (kind === 'runs' && runFacts.has(record.type)) || (kind === 'definitions' && definitionFacts.has(record.type)); + return isFact ? fact(record, type) : 'none'; } diff --git a/packages/workflow-host/src/follower/follower-assembly.ts b/packages/workflow-host/src/follower/follower-assembly.ts index 22381678d..cd32909ce 100644 --- a/packages/workflow-host/src/follower/follower-assembly.ts +++ b/packages/workflow-host/src/follower/follower-assembly.ts @@ -30,13 +30,13 @@ export function followerOn(host: FollowerHost, assembly: FollowerAssembly): Foll consumers: reacting.consumers, registered: assembly.consumers, calls: assembly.calls, - primitive: assembly.options.primitive, - applySpecRecord: reacting.applySpecRecord, + definitionType: assembly.options.definitionType, + applyDefinitionRecord: reacting.applyDefinitionRecord, unreadable, passedEarly: (brainKey, { stream, version }, sweeps) => reports.note({ kind: 'run_record_passed', - run: { ...brainOfKey(brainKey), executionId: stream.slice(stream.lastIndexOf('/') + 1) }, + run: { ...brainOfKey(brainKey), runId: stream.slice(stream.lastIndexOf('/') + 1) }, version, sweeps, }), @@ -45,9 +45,9 @@ export function followerOn(host: FollowerHost, assembly: FollowerAssembly): Foll database, brains, records: assembly.records, - applySpecRecord: reacting.applySpecRecord, + applyDefinitionRecord: reacting.applyDefinitionRecord, unreadable, - primitive: assembly.options.primitive, + definitionType: assembly.options.definitionType, }); return startFollower({ pass, diff --git a/packages/workflow-host/src/follower/follower-guards.test.ts b/packages/workflow-host/src/follower/follower-guards.test.ts index 17e66f554..721aa6d0a 100644 --- a/packages/workflow-host/src/follower/follower-guards.test.ts +++ b/packages/workflow-host/src/follower/follower-guards.test.ts @@ -9,12 +9,12 @@ import { published, recorded, runRecorded, - specRecorded, + definitionRecorded, } from '../reaction-testing/brain-writes.ts'; import { reactingHost, type ReactingHost } from '../reaction-testing/reacting-host.ts'; import { until } from '../reaction-testing/until.ts'; -const succeeded = { type: 'execution_succeeded' }; +const succeeded = { type: 'run_succeeded' }; const told = { type: 'com.acme.told' }; @@ -22,7 +22,7 @@ const RefusalRow = Schema.Struct({ workflow: Schema.String, reason: Schema.Strin async function startsOnceTheSentinelPassed(reacting: ReactingHost) { const sentinel = eventTrigger({ type: 'com.acme.sentinel' }); - await specRecorded(reacting.database.store, { name: 'watch', version: 1, triggers: [sentinel] }); + await definitionRecorded(reacting.database.store, { name: 'watch', version: 1, triggers: [sentinel] }); await published(reacting.database.store, { id: 'sentinel', type: 'com.acme.sentinel' }); return until( () => Promise.resolve(reacting.reactions.starts()), @@ -30,21 +30,17 @@ async function startsOnceTheSentinelPassed(reacting: ReactingHost) { ); } -function emittedBy(executionId: string, workflow: string, depth: number) { - return { emitted_by: { execution_id: executionId, workflow, version: 1 }, depth }; +function emittedBy(runId: string, workflow: string, depth: number) { + return { emitted_by: { run_id: runId, workflow, version: 1 }, depth }; } describe('a workflow that reacts to the facts of the brain', () => { it('starts on the success of another run, with the fact as its input and one more reaction depth', async () => { const reacting = await reactingHost(); const { store } = reacting.database; - await specRecorded(store, { name: 'follow', version: 1, triggers: [eventTrigger(succeeded)] }); + await definitionRecorded(store, { name: 'follow', version: 1, triggers: [eventTrigger(succeeded)] }); - await runRecorded( - store, - { executionId: 'r-sum', primitive: 'inference', name: 'sum', depth: 2 }, - 'execution_succeeded', - ); + await runRecorded(store, { runId: 'r-sum', type: 'reasoning', name: 'sum', depth: 2 }, 'run_succeeded'); const starts = await until( () => Promise.resolve(reacting.reactions.starts()), (found) => found.length > 0, @@ -56,9 +52,9 @@ describe('a workflow that reacts to the facts of the brain', () => { depth: 3, input: [ { - type: 'execution_succeeded', - source: '/executions/r-sum', - subject: 'inference/sum', + type: 'run_succeeded', + source: '/runs/r-sum', + subject: 'reasoning/sum', data: { output: 'done', depth: 2 }, }, ], @@ -71,16 +67,12 @@ describe('a workflow and its own runs', () => { it('never reacts to facts about its runs, nor about runs one of its runs started, nor to events its runs emitted', async () => { const reacting = await reactingHost(); const { store } = reacting.database; - await specRecorded(store, { name: 'follow', version: 1, triggers: [eventTrigger(succeeded, told)] }); + await definitionRecorded(store, { name: 'follow', version: 1, triggers: [eventTrigger(succeeded, told)] }); - await runRecorded( - store, - { executionId: 'r-own', primitive: 'orchestration', name: 'follow' }, - 'execution_succeeded', - ); - await runRecorded(store, { executionId: 'r-top', primitive: 'orchestration', name: 'follow' }); - const nested = { executionId: 'r-nested', primitive: 'inference', name: 'sum', correlation: 'r-top' }; - await runRecorded(store, nested, 'execution_succeeded'); + await runRecorded(store, { runId: 'r-own', type: 'workflow', name: 'follow' }, 'run_succeeded'); + await runRecorded(store, { runId: 'r-top', type: 'workflow', name: 'follow' }); + const nested = { runId: 'r-nested', type: 'reasoning', name: 'sum', correlation: 'r-top' }; + await runRecorded(store, nested, 'run_succeeded'); await published(store, { id: 'told', type: 'com.acme.told' }, emittedBy('r-top', 'follow', 1)); const starts = await startsOnceTheSentinelPassed(reacting); @@ -92,7 +84,7 @@ describe('a chain of reactions', () => { it('stops at a reaction depth of 8: a match past it starts nothing and is refused', async () => { const reacting = await reactingHost(); const { store } = reacting.database; - await specRecorded(store, { name: 'deep', version: 1, triggers: [eventTrigger(told)] }); + await definitionRecorded(store, { name: 'deep', version: 1, triggers: [eventTrigger(told)] }); await published(store, { id: 'eighth', type: 'com.acme.told' }, emittedBy('r1', 'other', 8)); await published(store, { id: 'ninth', type: 'com.acme.told' }, emittedBy('r2', 'other', 9)); @@ -122,8 +114,12 @@ describe('a brain whose run log holds a record its dispatch never covers', () => async () => { const reacting = await reactingHost({ sweepEveryMs: 10 }); const { store } = reacting.database; - await specRecorded(store, { name: 'close', version: 1, triggers: [eventTrigger({ type: 'com.acme.closed' })] }); - await recorded(store, `${alpha}runs/r-stuck`, { + await definitionRecorded(store, { + name: 'close', + version: 1, + triggers: [eventTrigger({ type: 'com.acme.closed' })], + }); + await recorded(store, `${alpha}run-logs/r-stuck`, { type: 'input_applied', input: {}, at: '2026-10-01T09:00:00.000Z', @@ -140,7 +136,7 @@ describe('a brain whose run log holds a record its dispatch never covers', () => expect(reacting.notes()).toMatchObject([ { kind: 'run_record_passed', - run: { org: 'acme', brain: 'alpha', executionId: 'r-stuck' }, + run: { org: 'acme', brain: 'alpha', runId: 'r-stuck' }, version: 1, sweeps: 20, }, diff --git a/packages/workflow-host/src/follower/follower-listeners.test.ts b/packages/workflow-host/src/follower/follower-listeners.test.ts index 1e6d6f7f8..8684b9003 100644 --- a/packages/workflow-host/src/follower/follower-listeners.test.ts +++ b/packages/workflow-host/src/follower/follower-listeners.test.ts @@ -13,13 +13,13 @@ function listening(filter: string, input: Readonly> = {}) return startOf(workflow(`do:\n - await: { listen: { to: { one: { with: ${filter} } } } }`), input); } -function stateOf({ host }: ReactingHost, executionId = waiting) { - return Effect.runPromise(host.stateOf(runAt(executionId))); +function stateOf({ host }: ReactingHost, runId = waiting) { + return Effect.runPromise(host.stateOf(runAt(runId))); } -function ended(reacting: ReactingHost, executionId = waiting) { +function ended(reacting: ReactingHost, runId = waiting) { return until( - () => stateOf(reacting, executionId), + () => stateOf(reacting, runId), (state) => state.status === 'ended', ); } @@ -72,7 +72,7 @@ describe('an offer to a run that waits for an event', () => { await published( reacting.database.store, { id: 'own', type: 'com.acme.decided' }, - { emitted_by: { execution_id: waiting, workflow: 'test', version: 1 }, depth: 1 }, + { emitted_by: { run_id: waiting, workflow: 'test', version: 1 }, depth: 1 }, ); await published(reacting.database.store, { id: 'theirs', type: 'com.acme.decided', data: 'theirs' }); const state = await ended(reacting); @@ -86,7 +86,7 @@ describe('a run that listened before the host kept its listeners', () => { const first = await reactingHost(); await Effect.runPromise(first.host.start(runAt(waiting), listening('{ type: com.acme.decided }'))); await until( - () => Effect.runPromise(first.database.read(statement`SELECT run_id FROM workflow_listeners`)), + () => Effect.runPromise(first.database.read(statement`SELECT run_key FROM workflow_listeners`)), (rows) => rows.length > 0, ); await first.host.stop(); @@ -96,7 +96,7 @@ describe('a run that listened before the host kept its listeners', () => { first.database.write(statement`DELETE FROM workflow_listeners`), first.database.write(statement`DELETE FROM workflow_followed_scans`), first.database.write( - statement`INSERT INTO workflow_runs (run_id, stream_id) VALUES (${'acme/alpha/bare'}, ${'s'})`, + statement`INSERT INTO workflow_runs (run_key, stream_id) VALUES (${'acme/alpha/bare'}, ${'s'})`, ), ]), ); diff --git a/packages/workflow-host/src/follower/follower-loop.test.ts b/packages/workflow-host/src/follower/follower-loop.test.ts index 687df40cd..b23e7fc8d 100644 --- a/packages/workflow-host/src/follower/follower-loop.test.ts +++ b/packages/workflow-host/src/follower/follower-loop.test.ts @@ -117,7 +117,7 @@ describe('the follower of the brains', () => { const watched = followerWith({ passEnds: ['more', 'more', 'caught_up'] }); await logReaching(watched, 3); - watched.raise('brain/acme/beta/runs/r-1'); + watched.raise('brain/acme/beta/run-logs/r-1'); watched.raise('brain/acme/alpha/events/e1'); watched.raise('workflow/elsewhere'); const log = await logReaching(watched, 6); diff --git a/packages/workflow-host/src/follower/follower-records.test.ts b/packages/workflow-host/src/follower/follower-records.test.ts index 189cd5e77..16cc1cce2 100644 --- a/packages/workflow-host/src/follower/follower-records.test.ts +++ b/packages/workflow-host/src/follower/follower-records.test.ts @@ -2,7 +2,7 @@ import { messageIdOf } from '@beonauto/operations'; import { Effect } from 'effect'; import { describe, expect, it } from 'vitest'; -import { alpha, eventTrigger, published, recorded, specRecorded } from '../reaction-testing/brain-writes.ts'; +import { alpha, eventTrigger, published, recorded, definitionRecorded } from '../reaction-testing/brain-writes.ts'; import { reactingHost } from '../reaction-testing/reacting-host.ts'; import { until } from '../reaction-testing/until.ts'; import type { Consumer } from './consumers.ts'; @@ -34,18 +34,18 @@ function recordingIds(name: string, types: readonly string[], received: (id: str } describe('a record of a brain the follower cannot read', () => { - it('is said and passed over, as an event, a fact of a run or a spec, while a workflow of the brain reacts to its type', async () => { + it('is said and passed over, as an event, a fact of a run or a definition, while a workflow of the brain reacts to its type', async () => { const reacting = await reactingHost(); const { store } = reacting.database; const trigger = eventTrigger( { type: 'com.acme.sentinel' }, - { type: 'execution_started' }, - { type: 'spec_created' }, + { type: 'run_started' }, + { type: 'definition_created' }, ); - await specRecorded(store, { name: 'watch', version: 1, triggers: [trigger] }); + await definitionRecorded(store, { name: 'watch', version: 1, triggers: [trigger] }); await recorded(store, `${alpha}events/bad`, { type: 'event_published', event: 'not an event' }); - await recorded(store, `${alpha}executions/r-bad`, { type: 'execution_started', name: 7 }); - await recorded(store, `${alpha}specs/orchestration`, { type: 'spec_created', name: 7 }); + await recorded(store, `${alpha}runs/r-bad`, { type: 'run_started', name: 7 }); + await recorded(store, `${alpha}definitions/workflow`, { type: 'definition_created', name: 7 }); await published(store, { id: 's1', type: 'com.acme.sentinel' }); await until( @@ -55,8 +55,8 @@ describe('a record of a brain the follower cannot read', () => { expect(reacting.notes()).toMatchObject([ { kind: 'record_unreadable', org: 'acme', brain: 'alpha', type: 'event_published' }, - { kind: 'record_unreadable', org: 'acme', brain: 'alpha', type: 'execution_started' }, - { kind: 'record_unreadable', org: 'acme', brain: 'alpha', type: 'spec_created' }, + { kind: 'record_unreadable', org: 'acme', brain: 'alpha', type: 'run_started' }, + { kind: 'record_unreadable', org: 'acme', brain: 'alpha', type: 'definition_created' }, ]); }); }); diff --git a/packages/workflow-host/src/follower/follower-subscriptions.test.ts b/packages/workflow-host/src/follower/follower-subscriptions.test.ts index 60bd30ba6..e00707a62 100644 --- a/packages/workflow-host/src/follower/follower-subscriptions.test.ts +++ b/packages/workflow-host/src/follower/follower-subscriptions.test.ts @@ -4,11 +4,18 @@ import { describe, expect, it } from 'vitest'; import { rowsOf } from '../database/host-database.ts'; import { statement } from '../database/statement.ts'; -import { alpha, at, eventTrigger, published, specRecorded, specRetired } from '../reaction-testing/brain-writes.ts'; +import { + alpha, + at, + eventTrigger, + published, + definitionRecorded, + definitionRetired, +} from '../reaction-testing/brain-writes.ts'; import { reactingHost, type ReactingHost } from '../reaction-testing/reacting-host.ts'; import { refusedWhile, rejectedFor } from '../reaction-testing/recorded-reactions.ts'; import { until } from '../reaction-testing/until.ts'; -import { reactionExecutionIdOf } from '../reactions/reaction-ids.ts'; +import { reactionRunIdOf } from '../reactions/reaction-ids.ts'; const closed = eventTrigger({ type: 'com.acme.closed' }); @@ -40,7 +47,7 @@ describe('a workflow whose trigger is an event', () => { const reacting = await reactingHost(); const { store } = reacting.database; const trigger = eventTrigger({ type: 'com.acme.closed', data: { region: 'eu' } }); - await specRecorded(store, { name: 'close', version: 1, triggers: [trigger] }); + await definitionRecorded(store, { name: 'close', version: 1, triggers: [trigger] }); await published(store, { id: 'e1', type: 'com.acme.closed', data: { region: 'eu' } }); await published(store, { id: 'e2', type: 'com.acme.closed', data: { region: 'us' } }); @@ -54,7 +61,7 @@ describe('a workflow whose trigger is an event', () => { brain: 'alpha', workflow: 'close', version: 1, - executionId: reactionExecutionIdOf('close', 1, '/schedule/on', cause), + runId: reactionRunIdOf('close', 1, '/schedule/on', cause), input: [ { specversion: '1.0', source: '/acme', time: at, id: 'e1', type: 'com.acme.closed', data: { region: 'eu' } }, ], @@ -70,11 +77,11 @@ describe('the records a workflow whose trigger is an event reacts to', () => { it('are none before the record that activated it, and none after the one that retired it', async () => { const reacting = await reactingHost(); const { store } = reacting.database; - await specRecorded(store, { name: 'watch', version: 1, triggers: [sentinel] }); + await definitionRecorded(store, { name: 'watch', version: 1, triggers: [sentinel] }); await published(store, { id: 'early', type: 'com.acme.closed' }); - await specRecorded(store, { name: 'close', version: 1, triggers: [closed] }); + await definitionRecorded(store, { name: 'close', version: 1, triggers: [closed] }); await published(store, { id: 'between', type: 'com.acme.closed' }); - await specRetired(store, 'close'); + await definitionRetired(store, 'close'); await published(store, { id: 'late', type: 'com.acme.closed' }); await sentinelPassed(reacting, 's1'); @@ -87,12 +94,16 @@ describe('a new version of a workflow whose trigger is an event', () => { it('replaces the trigger of the version before, and a version without a trigger reacts to nothing', async () => { const reacting = await reactingHost(); const { store } = reacting.database; - await specRecorded(store, { name: 'watch', version: 1, triggers: [sentinel] }); - await specRecorded(store, { name: 'close', version: 1, triggers: [closed] }); - await specRecorded(store, { name: 'close', version: 2, triggers: [eventTrigger({ type: 'com.acme.opened' })] }); + await definitionRecorded(store, { name: 'watch', version: 1, triggers: [sentinel] }); + await definitionRecorded(store, { name: 'close', version: 1, triggers: [closed] }); + await definitionRecorded(store, { + name: 'close', + version: 2, + triggers: [eventTrigger({ type: 'com.acme.opened' })], + }); await published(store, { id: 'closed', type: 'com.acme.closed' }); await published(store, { id: 'opened', type: 'com.acme.opened' }); - await specRecorded(store, { name: 'close', version: 3, triggers: [] }); + await definitionRecorded(store, { name: 'close', version: 3, triggers: [] }); await published(store, { id: 'opened-again', type: 'com.acme.opened' }); await sentinelPassed(reacting, 's1'); @@ -108,8 +119,8 @@ describe('the follower of a brain', () => { it('starts nothing again for what it delivered before the host stopped, and goes on after it', async () => { const first = await reactingHost(); const { store } = first.database; - await specRecorded(store, { name: 'close', version: 1, triggers: [closed] }); - await specRecorded(store, { name: 'watch', version: 1, triggers: [sentinel] }); + await definitionRecorded(store, { name: 'close', version: 1, triggers: [closed] }); + await definitionRecorded(store, { name: 'watch', version: 1, triggers: [sentinel] }); await published(store, { id: 'e1', type: 'com.acme.closed' }); await startsReaching(first, 1); await first.host.stop(); @@ -130,7 +141,7 @@ describe('a start the brain keeps refusing', () => { const refusing = { now: true }; const reacting = await reactingHost({ failure: refusedWhile(refusing) }); const { store } = reacting.database; - await specRecorded(store, { name: 'close', version: 1, triggers: [closed] }); + await definitionRecorded(store, { name: 'close', version: 1, triggers: [closed] }); await published(store, { id: 'e1', type: 'com.acme.closed' }); const refusals = await until( @@ -158,9 +169,9 @@ describe('a start the brain rejects for good', () => { failure: rejectedFor('close', 'The input is not what the workflow takes'), }); const { store } = reacting.database; - await specRecorded(store, { name: 'close', version: 1, triggers: [closed] }); + await definitionRecorded(store, { name: 'close', version: 1, triggers: [closed] }); await published(store, { id: 'e1', type: 'com.acme.closed' }); - await specRecorded(store, { name: 'watch', version: 1, triggers: [sentinel] }); + await definitionRecorded(store, { name: 'watch', version: 1, triggers: [sentinel] }); await sentinelPassed(reacting, 's1'); const refusals = await Effect.runPromise( diff --git a/packages/workflow-host/src/follower/listener-scan.ts b/packages/workflow-host/src/follower/listener-scan.ts index caebaa8b6..f841dc3a7 100644 --- a/packages/workflow-host/src/follower/listener-scan.ts +++ b/packages/workflow-host/src/follower/listener-scan.ts @@ -6,26 +6,26 @@ import { rowsOf, type HostDatabase } from '../database/host-database.ts'; import { statement } from '../database/statement.ts'; import { insertedListener } from '../listeners/listener-rows.ts'; import { reactionOfRun } from '../reactions/run-attributes.ts'; -import { ledgerRunStore } from '../runs/ledger-run-store.ts'; -import { addressOfRun, streamOfRun } from '../runs/run-address.ts'; +import { ledgerRunLogStore } from '../runs/ledger-run-store.ts'; +import { addressOfRun, runLogStreamOf } from '../runs/run-address.ts'; -const RunRow = Schema.Struct({ run_id: Schema.String }); +const RunRow = Schema.Struct({ run_key: Schema.String }); const scanOfListeners = 'listeners'; -function listenersOfRun(database: HostDatabase, runId: string) { +function listenersOfRun(database: HostDatabase, runKey: string) { return Effect.gen(function* () { - const { state, version } = loadedRunOf(yield* ledgerRunStore(database).load(runId)); + const { state, version } = loadedRunOf(yield* ledgerRunLogStore(database).load(runKey)); const document = state.workflow?.document ?? {}; const { workflow } = reactionOfRun(state.attributes); yield* Effect.forEach( Object.entries(state.listeners), ([listener, key]: readonly [string, CallKey]) => insertedListener(database, { - runId, + runKey, listener, - brainKey: streamPrefixOfBrain(addressOfRun(runId)), - streamId: streamOfRun(runId), + brainKey: streamPrefixOfBrain(addressOfRun(runKey)), + streamId: runLogStreamOf(runKey), armedBy: version, filters: JSON.stringify(listenFiltersOf(valueAtPointer(document, key.reference))), workflow, @@ -47,9 +47,9 @@ export function scannedListeners(database: HostDatabase): Effect.Effect } const live = yield* rowsOf( RunRow, - database.read(statement`SELECT run_id FROM workflow_runs WHERE ended_at IS NULL`), + database.read(statement`SELECT run_key FROM workflow_runs WHERE ended_at IS NULL`), ); - yield* Effect.forEach(live, ({ run_id: runId }) => listenersOfRun(database, runId), { discard: true }); + yield* Effect.forEach(live, ({ run_key: runKey }) => listenersOfRun(database, runKey), { discard: true }); yield* database.write(statement`INSERT INTO workflow_followed_scans (name) VALUES (${scanOfListeners})`); return live.length; }), diff --git a/packages/workflow-host/src/follower/pass-data.test.ts b/packages/workflow-host/src/follower/pass-data.test.ts index 817ba5d6d..3750f5dc2 100644 --- a/packages/workflow-host/src/follower/pass-data.test.ts +++ b/packages/workflow-host/src/follower/pass-data.test.ts @@ -54,10 +54,10 @@ function endlessNotes(): CountedRecords { function listening(database: HostDatabase, armedBy: number) { return insertedListener(database, { - runId: 'acme/alpha/r-1', + runKey: 'acme/alpha/r-1', listener: 'listener', brainKey, - streamId: `${brainKey}runs/r-1`, + streamId: `${brainKey}run-logs/r-1`, armedBy, filters: '[{"type":"com.acme.noted"}]', workflow: 'wait', @@ -74,8 +74,8 @@ async function passing(counted: CountedRecords) { brains, consumers: [], calls: [], - primitive: 'orchestration', - applySpecRecord: () => Effect.succeed('applied'), + definitionType: 'workflow', + applyDefinitionRecord: () => Effect.succeed('applied'), unreadable: () => Effect.void, passedEarly: () => Effect.void, registered: [], diff --git a/packages/workflow-host/src/follower/record-steps.test.ts b/packages/workflow-host/src/follower/record-steps.test.ts index f9930b8ef..e0982fd80 100644 --- a/packages/workflow-host/src/follower/record-steps.test.ts +++ b/packages/workflow-host/src/follower/record-steps.test.ts @@ -9,8 +9,8 @@ const brainKey = 'brain/acme/alpha/'; const parts: StepParts = { consumers: [], - primitive: 'orchestration', - applySpecRecord: () => Effect.succeed('applied'), + definitionType: 'workflow', + applyDefinitionRecord: () => Effect.succeed('applied'), unreadable: () => Effect.void, passedEarly: () => Effect.void, registered: [], @@ -22,7 +22,7 @@ const record: RecordedEvent = { cursor: 'cursor-1', causationId: null, correlationId: null, - stream: `${brainKey}runs/r-1`, + stream: `${brainKey}run-logs/r-1`, version: 1, type: 'input_applied', data: null, diff --git a/packages/workflow-host/src/follower/record-steps.ts b/packages/workflow-host/src/follower/record-steps.ts index a0f6e9964..c4bd95380 100644 --- a/packages/workflow-host/src/follower/record-steps.ts +++ b/packages/workflow-host/src/follower/record-steps.ts @@ -1,7 +1,7 @@ import { streamKindOf, type RecordedEvent } from '@beonauto/operations'; import { Effect } from 'effect'; -import type { ApplySpecRecord } from '../triggers/spec-records.ts'; +import type { ApplyDefinitionRecord } from '../triggers/definition-records.ts'; import { relativeRecord } from './brain-records.ts'; import { boundTo, @@ -28,8 +28,8 @@ export interface StepParts { readonly consumers: readonly RecordConsumer[]; readonly registered: readonly Consumer[]; readonly calls: readonly CallConsumer[]; - readonly primitive: string; - readonly applySpecRecord: ApplySpecRecord; + readonly definitionType: string; + readonly applyDefinitionRecord: ApplyDefinitionRecord; readonly unreadable: (brainKey: string, record: Pick) => Effect.Effect; readonly passedEarly: (brainKey: string, record: RecordedEvent, sweeps: number) => Effect.Effect; } @@ -52,7 +52,7 @@ function passedOver(record: RecordedEvent): Progress { } function followedOf(parts: StepParts, brainKey: string, relative: RecordedEvent): Effect.Effect { - const event = followedEventOf(relative, parts.primitive); + const event = followedEventOf(relative, parts.definitionType); if (event === 'unreadable') { return Effect.as(parts.unreadable(brainKey, relative), null); } @@ -109,15 +109,15 @@ export function stepOf( record: RecordedEvent, ): Effect.Effect { const { brainKey, delivers } = stepping; - if (streamKindOf(record.stream.slice(brainKey.length)) === 'runs') { + if (streamKindOf(record.stream.slice(brainKey.length)) === 'run-logs') { return runRecordStep(parts, stepping, progress, record); } const step: Effect.Effect = delivers.has(record.type) ? deliveredStep(parts, stepping, progress, record) : Effect.succeed({ progress: passedOver(record) }); - if (record.stream === `${brainKey}specs/${parts.primitive}`) { + if (record.stream === `${brainKey}definitions/${parts.definitionType}`) { const readAgain: Effect.Effect = Effect.succeed({ progress, end: 'more' }); - return Effect.flatMap(parts.applySpecRecord(brainKey, record), (applied) => + return Effect.flatMap(parts.applyDefinitionRecord(brainKey, record), (applied) => applied === 'unreadable' ? Effect.as(parts.unreadable(brainKey, record), { progress: passedOver(record) }) : Effect.flatMap(stepping.wantsMore(), (more) => (more ? readAgain : step)), diff --git a/packages/workflow-host/src/follower/run-gate.test.ts b/packages/workflow-host/src/follower/run-gate.test.ts index 016ea039b..fe58c6321 100644 --- a/packages/workflow-host/src/follower/run-gate.test.ts +++ b/packages/workflow-host/src/follower/run-gate.test.ts @@ -11,15 +11,15 @@ import { runGateOf } from './run-gate.ts'; const brainKey = 'brain/acme/alpha/'; -const stream = `${brainKey}runs/r-1`; +const stream = `${brainKey}run-logs/r-1`; const PassedRow = Schema.Struct({ listener: Schema.String, passed: WholeNumber }); -const PassedRunRow = Schema.Struct({ run_id: Schema.String, passed_through: WholeNumber }); +const PassedRunRow = Schema.Struct({ run_key: Schema.String, passed_through: WholeNumber }); function passedRuns(database: HostDatabase) { return Effect.runPromise( - rowsOf(PassedRunRow, database.read(statement`SELECT run_id, passed_through FROM workflow_passed_runs`)), + rowsOf(PassedRunRow, database.read(statement`SELECT run_key, passed_through FROM workflow_passed_runs`)), ); } @@ -66,8 +66,8 @@ function runRecord(version: number): RecordedEvent { function dispatchedThrough(database: HostDatabase, through: number) { return Effect.runPromise( database.write( - statement`INSERT INTO workflow_runs (run_id, stream_id, dispatched_through) VALUES (${'acme/alpha/r-1'}, ${stream}, ${through}) - ON CONFLICT (run_id) DO UPDATE SET dispatched_through = excluded.dispatched_through`, + statement`INSERT INTO workflow_runs (run_key, stream_id, dispatched_through) VALUES (${'acme/alpha/r-1'}, ${stream}, ${through}) + ON CONFLICT (run_key) DO UPDATE SET dispatched_through = excluded.dispatched_through`, ), ); } @@ -75,7 +75,7 @@ function dispatchedThrough(database: HostDatabase, through: number) { function armedAt(database: HostDatabase, listener: string, armedBy: number) { return Effect.runPromise( insertedListener(database, { - runId: 'acme/alpha/r-1', + runKey: 'acme/alpha/r-1', listener, brainKey, streamId: stream, @@ -158,8 +158,8 @@ describe('the gate passing a record of a run held too long', () => { await Effect.runPromise(watermark.advance('acme/alpha/r-1', 2)); expect([kept, behind]).toEqual([ - [{ run_id: 'acme/alpha/r-1', passed_through: 2 }], - [{ run_id: 'acme/alpha/r-1', passed_through: 2 }], + [{ run_key: 'acme/alpha/r-1', passed_through: 2 }], + [{ run_key: 'acme/alpha/r-1', passed_through: 2 }], ]); expect(await passedRuns(database)).toEqual([]); }); @@ -171,7 +171,7 @@ describe('the gate noting how far it passed a run', () => { const ended = await openedOn(await onSQLite()); const caughtUp = sqlWatermark(reached).advance('acme/alpha/r-1', 2); const finished = ended.write( - statement`UPDATE workflow_runs SET ended_at = ${2} WHERE run_id = ${'acme/alpha/r-1'}`, + statement`UPDATE workflow_runs SET ended_at = ${2} WHERE run_key = ${'acme/alpha/r-1'}`, ); await dispatchedThrough(reached, 0); await dispatchedThrough(ended, 0); diff --git a/packages/workflow-host/src/follower/run-gate.ts b/packages/workflow-host/src/follower/run-gate.ts index 04ca16e90..9b33aa3f4 100644 --- a/packages/workflow-host/src/follower/run-gate.ts +++ b/packages/workflow-host/src/follower/run-gate.ts @@ -20,14 +20,14 @@ export function runGateOf(database: HostDatabase, brainKey: string): RunGate { const watermark = sqlWatermark(database); const [, org = '', brain = ''] = brainKey.split('/'); const known = new Map(); - const knownOf = (runId: string, record: RecordedEvent): Effect.Effect => { - const cached = known.get(runId); + const knownOf = (runKey: string, record: RecordedEvent): Effect.Effect => { + const cached = known.get(runKey); return cached !== undefined && cached.through >= record.version ? Effect.succeed(cached) : Effect.map( - Effect.zip(watermark.read(runId), pendingArmings(database, record.stream)), + Effect.zip(watermark.read(runKey), pendingArmings(database, record.stream)), ([through, pending]: readonly [number, readonly number[]]) => { - known.set(runId, { through, pending }); + known.set(runKey, { through, pending }); return { through, pending }; }, ); @@ -35,18 +35,18 @@ export function runGateOf(database: HostDatabase, brainKey: string): RunGate { return { verdictOn: (record, overdue) => Effect.gen(function* () { - const runId = `${org}/${brain}/${record.stream.slice(record.stream.lastIndexOf('/') + 1)}`; - const { through, pending } = yield* knownOf(runId, record); + const runKey = `${org}/${brain}/${record.stream.slice(record.stream.lastIndexOf('/') + 1)}`; + const { through, pending } = yield* knownOf(runKey, record); const behind = through < record.version; if (behind && !overdue) { return 'held'; } const armed = pending.some((armedBy) => armedBy <= record.version); if (behind) { - yield* runPassedThrough(database, runId, record.version); + yield* runPassedThrough(database, runKey, record.version); } if (behind || armed) { - known.set(runId, { through, pending: pending.filter((armedBy) => armedBy > record.version) }); + known.set(runKey, { through, pending: pending.filter((armedBy) => armedBy > record.version) }); yield* passedListeners(database, record.stream, record.version); } if (behind) { diff --git a/packages/workflow-host/src/host/host-answers.test.ts b/packages/workflow-host/src/host/host-answers.test.ts index 31c3ccdcb..ec0bc4ba6 100644 --- a/packages/workflow-host/src/host/host-answers.test.ts +++ b/packages/workflow-host/src/host/host-answers.test.ts @@ -7,9 +7,9 @@ import { aSQLiteFile } from '../testing/host-files.ts'; import { hostedOn } from '../testing/host-runs.ts'; import { HostStopped } from './host-gate.ts'; -const executionId = '0199a3c4-7d2e-7c1a-9b3f-2f1e0d9c8b7a'; +const runId = '0199a3c4-7d2e-7c1a-9b3f-2f1e0d9c8b7a'; -const run = runAt(executionId); +const run = runAt(runId); const listening = workflow('do:\n - approval: { listen: { to: { one: { with: { type: com.acme.approved } } } } }'); @@ -55,14 +55,14 @@ describe('the host given an event', () => { }); describe('the host reporting to its operator', () => { - it('reports a run whose execution the brain does not know, once it ends', async () => { + it('reports a run whose run the brain does not know, once it ends', async () => { const hosted = await hostedOn({ store: 'sqlite', file: aSQLiteFile() }); await Effect.runPromise(hosted.host.start(run, startOf(listening))); await Effect.runPromise(hosted.host.deliver(run, approved)); const troubles = await eventually(hosted.troubles, (reported) => reported.length > 0); - expect(troubles).toEqual([`${executionId} unknown_execution`]); + expect(troubles).toEqual([`${runId} unknown_run`]); }); it('reports a call that broke down before it could answer', async () => { diff --git a/packages/workflow-host/src/host/host-engine.ts b/packages/workflow-host/src/host/host-engine.ts index c8310a442..f85381ce4 100644 --- a/packages/workflow-host/src/host/host-engine.ts +++ b/packages/workflow-host/src/host/host-engine.ts @@ -6,7 +6,7 @@ import { type MachineOptions, type RunCacheBounds, type RunInput, - type RunStore, + type RunLogStore, type Submission, type WorkflowEngine, } from '@beonauto/workflow-engine'; @@ -31,7 +31,7 @@ export interface EngineOptions extends Pick { export interface HostEngine { readonly engine: WorkflowEngine; - readonly runStore: RunStore; + readonly runStore: RunLogStore; readonly timers: TimerTable; readonly executor: HostExecutor; readonly reacting: ReactionPorts; @@ -53,8 +53,7 @@ export function hostEngineOn( const executor = hostExecutor({ database, perform: options.perform, - deliver: (key, result) => - submitted({ kind: 'call_answered', executionId: key.executionId, at: clock.now(), key, result }), + deliver: (key, result) => submitted({ kind: 'call_answered', runId: key.runId, at: clock.now(), key, result }), trouble: reports.trouble, mostAtOnce: options.mostCallsAtOnce, ...waiting, diff --git a/packages/workflow-host/src/host/host-ports.ts b/packages/workflow-host/src/host/host-ports.ts index 7b712f1ec..1dc21bb2c 100644 --- a/packages/workflow-host/src/host/host-ports.ts +++ b/packages/workflow-host/src/host/host-ports.ts @@ -1,16 +1,16 @@ -import type { SettleExecution } from '@beonauto/specs'; +import type { SettleRun } from '@beonauto/definitions'; import type { Emitter, EnginePorts, Executor, Listeners, Timers } from '@beonauto/workflow-engine'; import type { HostDatabase } from '../database/host-database.ts'; import { runSerialiser } from '../dispatch/run-serialiser.ts'; import { sqlWatermark } from '../dispatch/sql-watermark.ts'; -import { ledgerRunStore } from '../runs/ledger-run-store.ts'; +import { ledgerRunLogStore } from '../runs/ledger-run-store.ts'; import { addressOfRun } from '../runs/run-address.ts'; import { ledgerRecordStore } from '../settlement/ledger-record-store.ts'; import type { HostReports } from './host-reports.ts'; export interface PortParts { - readonly settle: SettleExecution; + readonly settle: SettleRun; readonly reports: HostReports; readonly timers: Timers; readonly executor: Executor; @@ -24,14 +24,14 @@ export function hostPortsOn( { settle, reports, timers, executor, listeners, emitter, now }: PortParts, ): EnginePorts { return { - runStore: ledgerRunStore(database), + runStore: ledgerRunLogStore(database), watermark: sqlWatermark(database), timers, executor, listeners, emitter, recordStore: ledgerRecordStore(database, { settle, note: reports.note, now }), - reporter: { unsettled: ({ run, receipt }) => reports.unsettled({ ...addressOfRun(run.executionId), receipt }) }, + reporter: { unsettled: ({ run, receipt }) => reports.unsettled({ ...addressOfRun(run.runId), receipt }) }, serialiser: runSerialiser(), }; } diff --git a/packages/workflow-host/src/host/host-serving.ts b/packages/workflow-host/src/host/host-serving.ts index 383aed6dd..7049f90af 100644 --- a/packages/workflow-host/src/host/host-serving.ts +++ b/packages/workflow-host/src/host/host-serving.ts @@ -46,7 +46,7 @@ function servedOf(database: HostDatabase, options: ServingOptions, engine: HostE submitted: engine.submitted, resultOf: options.waiting.resultOf, cancelDeferred: options.waiting.cancelDeferred, - workflows: options.reactions.primitive, + workflows: options.reactions.definitionType, now: options.clock.now, trouble: options.reports.trouble, }); @@ -64,10 +64,10 @@ export function startServing(database: HostDatabase, options: ServingOptions): S timers: engine.timers, dueWork: options.dueWork ?? [], engine: engine.engine, - fire: ({ runId, timerId }, at) => + fire: ({ runKey, timerId }, at) => Effect.andThen( - served.endedChildren(runId), - engine.submitted({ kind: 'timer_fired', executionId: runId, at, timerId }), + served.endedChildren(runKey), + engine.submitted({ kind: 'timer_fired', runId: runKey, at, timerId }), ), resume: () => Effect.andThen(served.cancelsAsked, engine.executor.resume()), trouble: options.reports.trouble, diff --git a/packages/workflow-host/src/host/host-settling.test.ts b/packages/workflow-host/src/host/host-settling.test.ts index e6f047263..e460267b4 100644 --- a/packages/workflow-host/src/host/host-settling.test.ts +++ b/packages/workflow-host/src/host/host-settling.test.ts @@ -8,9 +8,9 @@ import { runAt, startOf, workflow } from '../testing/host-documents.ts'; import { aSQLiteFile } from '../testing/host-files.ts'; import { hostedOn } from '../testing/host-runs.ts'; -const executionId = '0199a3c4-7d2e-7c1a-9b3f-2f1e0d9c8b7a'; +const runId = '0199a3c4-7d2e-7c1a-9b3f-2f1e0d9c8b7a'; -const run = runAt(executionId); +const run = runAt(runId); const ending = workflow('do:\n - done: { set: { done: true } }'); @@ -26,7 +26,7 @@ describe('the host asked to start a run whose settlement is pending', () => { ledgerDown: () => ledger.down, }, ); - hosted.know(executionId); + hosted.know(runId); const first = await Effect.runPromise(hosted.host.start(run, startOf(ending))); const whileDown = await Effect.runPromise(hosted.host.start(run, startOf(ending))); @@ -35,7 +35,7 @@ describe('the host asked to start a run whose settlement is pending', () => { expect([first, whileDown, once]).toEqual(['started', 'going', 'settled']); expect(hosted.settleAttempts()).toBe(3); - expect([...hosted.settlements().keys()]).toEqual([executionId]); + expect([...hosted.settlements().keys()]).toEqual([runId]); }); it('tries it again at the next sweep after a start found it backing off, rather than a minute later', async () => { @@ -51,7 +51,7 @@ describe('the host asked to start a run whose settlement is pending', () => { }, }, ); - hosted.know(executionId); + hosted.know(runId); await Effect.runPromise(hosted.host.start(run, startOf(ending))); await eventually(hosted.notes, (notes) => notes.length > 0, 2000); @@ -59,7 +59,7 @@ describe('the host asked to start a run whose settlement is pending', () => { const settled = await eventually(hosted.settlements, (settlements) => settlements.size > 0, 2000); expect(startedAgain).toBe('going'); - expect([...settled.keys()]).toEqual([executionId]); + expect([...settled.keys()]).toEqual([runId]); expect(hosted.settleAttempts()).toBe(attemptOfTheStart + 1); }, 30_000); }); @@ -76,14 +76,14 @@ describe('the host whose ledger cannot be reached for two minutes', () => { ledgerDown: () => clock.now() < outage.endsAt, }, ); - hosted.know(executionId); + hosted.know(runId); outage.endsAt = clock.now() + outageMs; await Effect.runPromise(hosted.host.start(run, startOf(ending))); const notes = await eventually(hosted.notes, (noted) => noted.length > 1, 1500); expect(notes.map(({ kind }) => kind)).toEqual(['settle_backing_off', 'settled_after_back_off']); - expect([...hosted.settlements().keys()]).toEqual([executionId]); + expect([...hosted.settlements().keys()]).toEqual([runId]); expect(hosted.settleAttempts()).toBe(settleAttemptsBeforeBackingOff + Math.ceil(outageMs / settleBackOffMs)); expect(hosted.troubles()).toEqual([]); }, 30_000); diff --git a/packages/workflow-host/src/host/host-stopping-reactions.test.ts b/packages/workflow-host/src/host/host-stopping-reactions.test.ts index 9746bd790..e2d962ac0 100644 --- a/packages/workflow-host/src/host/host-stopping-reactions.test.ts +++ b/packages/workflow-host/src/host/host-stopping-reactions.test.ts @@ -4,7 +4,7 @@ import { Effect, Exit } from 'effect'; import { describe, expect, it } from 'vitest'; import type { DatabaseSettings } from '../database/host-databases.ts'; -import { eventTrigger, published, specRecorded } from '../reaction-testing/brain-writes.ts'; +import { eventTrigger, published, definitionRecorded } from '../reaction-testing/brain-writes.ts'; import { heldStart, type HeldStart } from '../reaction-testing/held-start.ts'; import { reactingHost, type ReactingHost } from '../reaction-testing/reacting-host.ts'; import { until } from '../reaction-testing/until.ts'; @@ -18,7 +18,7 @@ const sweepEveryMs = 20; async function startingAReaction(held: HeldStart, settings: DatabaseSettings): Promise { const reacting = await reactingHost({ settings, start: held.start, sweepEveryMs }); const trigger = eventTrigger({ type: 'com.acme.closed' }); - await specRecorded(reacting.database.store, { name: 'close', version: 1, triggers: [trigger] }); + await definitionRecorded(reacting.database.store, { name: 'close', version: 1, triggers: [trigger] }); await published(reacting.database.store, { id: 'e1', type: 'com.acme.closed' }); await held.begun; return reacting; diff --git a/packages/workflow-host/src/host/host-views.test.ts b/packages/workflow-host/src/host/host-views.test.ts index 2c28b7a1b..21b2f94ca 100644 --- a/packages/workflow-host/src/host/host-views.test.ts +++ b/packages/workflow-host/src/host/host-views.test.ts @@ -14,13 +14,13 @@ describe('a host given a store and the settings of views', { timeout: viewTestTi const settings = await onSQLite(); const views = await viewHarness(settings); await views.saved('runs', counting); - await views.ran('inference/runs', 1); + await views.ran('reasoning/runs', 1); const store = await openWorkflowStore(settings, Function.constVoid); const hosted = await hostedOn(store, { views: views.settingsOf() }); const kept = await views.until('runs', ({ folded }) => folded === 1); await hosted.host.stop(); - await views.ran('inference/runs', 2); + await views.ran('reasoning/runs', 2); await setTimeout(300); const afterStopping = await views.viewOf('runs'); diff --git a/packages/workflow-host/src/host/run-requests.ts b/packages/workflow-host/src/host/run-requests.ts index 59116e7a1..f7c258864 100644 --- a/packages/workflow-host/src/host/run-requests.ts +++ b/packages/workflow-host/src/host/run-requests.ts @@ -4,13 +4,13 @@ import { Data, Effect, Schema } from 'effect'; import { rowsOf, type HostDatabase } from '../database/host-database.ts'; import { statement } from '../database/statement.ts'; -import { ledgerRunStore } from '../runs/ledger-run-store.ts'; -import { runIdOf, type RunAddress } from '../runs/run-address.ts'; +import { ledgerRunLogStore } from '../runs/ledger-run-store.ts'; +import { runKeyOf, type RunAddress } from '../runs/run-address.ts'; import { backOffLifted } from '../settlement/settle-attempts.ts'; import { cancelledIfAsked } from '../waiting/pending-cancels.ts'; import type { HostEngine } from './host-engine.ts'; -export type RunStart = Omit; +export type RunStart = Omit; export type StartAnswer = 'started' | 'going' | 'settled'; @@ -21,7 +21,7 @@ export class HostElsewhere extends Data.TaggedError('host_elsewhere')<{ readonly export interface RunRequests { readonly start: (run: RunAddress, start: RunStart) => Effect.Effect; readonly deliver: (run: RunAddress, event: ReceivedEvent) => Effect.Effect; - readonly stateOf: (runId: string) => Effect.Effect; + readonly stateOf: (runKey: string) => Effect.Effect; } export interface RequestParts { @@ -37,11 +37,11 @@ const elsewhere = new HostElsewhere({ const SettlementRow = Schema.Struct({ settlement: Schema.NullOr(Schema.String) }); -function settledOf(database: HostDatabase, runId: string): Effect.Effect { +function settledOf(database: HostDatabase, runKey: string): Effect.Effect { return Effect.orDie( rowsOf( SettlementRow, - database.read(statement`SELECT settlement FROM workflow_settlements WHERE run_id = ${runId}`), + database.read(statement`SELECT settlement FROM workflow_settlements WHERE run_key = ${runKey}`), ), ).pipe(Effect.map((rows) => rows.some(({ settlement }) => settlement !== null))); } @@ -53,40 +53,40 @@ function servingIn({ serving }: RequestParts): Effect.Effect { +function settledAgain(parts: RequestParts, engine: HostEngine, runKey: string): Effect.Effect { return Effect.gen(function* () { - yield* Effect.orDie(backOffLifted(parts.database, runId)); - yield* engine.engine.wake(runId); - if (yield* settledOf(parts.database, runId)) { + yield* Effect.orDie(backOffLifted(parts.database, runKey)); + yield* engine.engine.wake(runKey); + if (yield* settledOf(parts.database, runKey)) { return 'settled'; } - yield* Effect.orDie(backOffLifted(parts.database, runId)); + yield* Effect.orDie(backOffLifted(parts.database, runKey)); return 'going'; }); } export function runRequests(parts: RequestParts): RunRequests { const { database, clock } = parts; - const runStore = ledgerRunStore(database); - const stateOf = (runId: string): Effect.Effect => - Effect.map(runStore.load(runId), (stored) => loadedRunOf(stored).state); + const runStore = ledgerRunLogStore(database); + const stateOf = (runKey: string): Effect.Effect => + Effect.map(runStore.load(runKey), (stored) => loadedRunOf(stored).state); return { stateOf, start: (run, start) => Effect.gen(function* () { const engine = yield* servingIn(parts); - const runId = runIdOf(run); - if (yield* settledOf(database, runId)) { + const runKey = runKeyOf(run); + if (yield* settledOf(database, runKey)) { return 'settled'; } - const { status } = yield* stateOf(runId); + const { status } = yield* stateOf(runKey); if (status === 'ended') { - return yield* settledAgain(parts, engine, runId); + return yield* settledAgain(parts, engine, runKey); } if (status !== 'new') { return 'going'; } - const { outcome } = yield* engine.submitted({ ...start, kind: 'started', executionId: runId, at: clock.now() }); + const { outcome } = yield* engine.submitted({ ...start, kind: 'started', runId: runKey, at: clock.now() }); if (outcome !== 'applied') { return 'going'; } @@ -96,17 +96,17 @@ export function runRequests(parts: RequestParts): RunRequests { deliver: (run, event) => Effect.gen(function* () { const engine = yield* servingIn(parts); - const runId = runIdOf(run); + const runKey = runKeyOf(run); const { outcome } = yield* engine.submitted({ kind: 'event_received', - executionId: runId, + runId: runKey, at: clock.now(), event, }); if (outcome !== 'stale') { return outcome === 'applied' ? 'delivered' : 'not_started'; } - return (yield* stateOf(runId)).status === 'ended' ? 'ended' : 'delivered'; + return (yield* stateOf(runKey)).status === 'ended' ? 'ended' : 'delivered'; }), }; } diff --git a/packages/workflow-host/src/host/workflow-host.ts b/packages/workflow-host/src/host/workflow-host.ts index d317c334c..838280e96 100644 --- a/packages/workflow-host/src/host/workflow-host.ts +++ b/packages/workflow-host/src/host/workflow-host.ts @@ -3,7 +3,7 @@ import type { ReceivedEvent, RunState } from '@beonauto/workflow-engine'; import type { Effect } from 'effect'; import { systemClock, type HostClock } from '../loop/host-clock.ts'; -import { runIdOf, type RunAddress } from '../runs/run-address.ts'; +import { runKeyOf, type RunAddress } from '../runs/run-address.ts'; import { gate, type HostStopped } from './host-gate.ts'; import type { ServingOptions } from './host-serving.ts'; import { standingOn } from './host-standing.ts'; @@ -47,7 +47,7 @@ export async function openWorkflowHost(options: HostOptions): Promise guarded(runs.start(run, start)), deliver: (run, event) => guarded(runs.deliver(run, event)), - stateOf: (run) => runs.stateOf(runIdOf(run)), + stateOf: (run) => runs.stateOf(runKeyOf(run)), stop: () => { stopping.done ??= stopped(); return stopping.done; diff --git a/packages/workflow-host/src/listeners/listener-gate-on-postgresql.test.ts b/packages/workflow-host/src/listeners/listener-gate-on-postgresql.test.ts index cec3cca14..81e554137 100644 --- a/packages/workflow-host/src/listeners/listener-gate-on-postgresql.test.ts +++ b/packages/workflow-host/src/listeners/listener-gate-on-postgresql.test.ts @@ -15,9 +15,9 @@ const server = process.env['LEDGER_TEST_POSTGRESQL_URL'] ?? ''; const brainKey = 'brain/acme/alpha/'; -const runId = 'acme/alpha/r-1'; +const runKey = 'acme/alpha/r-1'; -const stream = `${brainKey}runs/r-1`; +const stream = `${brainKey}run-logs/r-1`; type RecordedEvent = Parameters['verdictOn']>[0]; @@ -89,14 +89,14 @@ describe.skipIf(server === '')('a listener kept while the gate passes its run ea const held = await connected(connectionString); await Effect.runPromise( database.write( - statement`INSERT INTO workflow_runs (run_id, stream_id, dispatched_through) VALUES (${runId}, ${stream}, ${0})`, + statement`INSERT INTO workflow_runs (run_key, stream_id, dispatched_through) VALUES (${runKey}, ${stream}, ${0})`, ), ); const passing = () => Effect.runPromise(runGateOf(database, brainKey).verdictOn(record, true)); await Effect.runPromise( insertedListener(holdingTheInsert(database, { held, insertInto: 'workflow_listeners', meanwhile: passing }), { - runId, + runKey, listener: 'held', brainKey, streamId: stream, @@ -125,14 +125,14 @@ describe.skipIf(server === '')('the note of a run passed early while its dispatc const held = await connected(connectionString); await Effect.runPromise( database.write( - statement`INSERT INTO workflow_runs (run_id, stream_id, dispatched_through) VALUES (${runId}, ${stream}, ${0})`, + statement`INSERT INTO workflow_runs (run_key, stream_id, dispatched_through) VALUES (${runKey}, ${stream}, ${0})`, ), ); - const catchingUp = () => Effect.runPromise(sqlWatermark(database).advance(runId, 2)); + const catchingUp = () => Effect.runPromise(sqlWatermark(database).advance(runKey, 2)); const holding = holdingTheInsert(database, { held, insertInto: 'workflow_passed_runs', meanwhile: catchingUp }); const verdict = await Effect.runPromise(runGateOf(holding, brainKey).verdictOn(record, true)); - const left = await Effect.runPromise(database.read(statement`SELECT run_id FROM workflow_passed_runs`)); + const left = await Effect.runPromise(database.read(statement`SELECT run_key FROM workflow_passed_runs`)); expect([verdict, left]).toEqual(['overdue', []]); }, diff --git a/packages/workflow-host/src/listeners/listener-rows.ts b/packages/workflow-host/src/listeners/listener-rows.ts index 9d36a42f1..ff2e4abb9 100644 --- a/packages/workflow-host/src/listeners/listener-rows.ts +++ b/packages/workflow-host/src/listeners/listener-rows.ts @@ -4,7 +4,7 @@ import { rowsOf, WholeNumber, type HostDatabase } from '../database/host-databas import { statement } from '../database/statement.ts'; export interface ListenerRow { - readonly runId: string; + readonly runKey: string; readonly listener: string; readonly brainKey: string; readonly streamId: string; @@ -15,12 +15,12 @@ export interface ListenerRow { } export interface ListenerPlace { - readonly runId: string; + readonly runKey: string; readonly listener: string; } const MatchedRow = Schema.Struct({ - run_id: Schema.String, + run_key: Schema.String, listener: Schema.String, filters: Schema.String, workflow: Schema.String, @@ -39,17 +39,17 @@ function typesOf(filters: string): readonly string[] { export function insertedListener(database: HostDatabase, row: ListenerRow) { return Effect.gen(function* () { - const { runId, listener, brainKey, streamId, armedBy, filters, workflow, passed } = row; + const { runKey, listener, brainKey, streamId, armedBy, filters, workflow, passed } = row; yield* database.write( - statement`INSERT INTO workflow_listeners (run_id, listener, brain_key, stream_id, armed_by, filters, workflow, passed) - VALUES (${runId}, ${listener}, ${brainKey}, ${streamId}, ${armedBy}, ${filters}, ${workflow}, ${passed ? 1 : 0}) - ON CONFLICT (run_id, listener) DO NOTHING`, + statement`INSERT INTO workflow_listeners (run_key, listener, brain_key, stream_id, armed_by, filters, workflow, passed) + VALUES (${runKey}, ${listener}, ${brainKey}, ${streamId}, ${armedBy}, ${filters}, ${workflow}, ${passed ? 1 : 0}) + ON CONFLICT (run_key, listener) DO NOTHING`, ); yield* database.write( statement`UPDATE workflow_listeners SET passed = 1 - WHERE run_id = ${runId} AND listener = ${listener} AND passed = 0 AND EXISTS ( + WHERE run_key = ${runKey} AND listener = ${listener} AND passed = 0 AND EXISTS ( SELECT 1 FROM workflow_passed_runs - WHERE workflow_passed_runs.run_id = workflow_listeners.run_id + WHERE workflow_passed_runs.run_key = workflow_listeners.run_key AND workflow_passed_runs.passed_through >= workflow_listeners.armed_by )`, ); @@ -57,29 +57,31 @@ export function insertedListener(database: HostDatabase, row: ListenerRow) { typesOf(filters), (type) => database.write( - statement`INSERT INTO workflow_listener_types (brain_key, type, run_id, listener) - VALUES (${brainKey}, ${type}, ${runId}, ${listener}) ON CONFLICT DO NOTHING`, + statement`INSERT INTO workflow_listener_types (brain_key, type, run_key, listener) + VALUES (${brainKey}, ${type}, ${runKey}, ${listener}) ON CONFLICT DO NOTHING`, ), { discard: true }, ); }); } -export function removedListener(database: HostDatabase, { runId, listener }: ListenerPlace) { +export function removedListener(database: HostDatabase, { runKey, listener }: ListenerPlace) { return Effect.gen(function* () { yield* database.write( - statement`DELETE FROM workflow_listener_types WHERE run_id = ${runId} AND listener = ${listener}`, + statement`DELETE FROM workflow_listener_types WHERE run_key = ${runKey} AND listener = ${listener}`, ); const removed = yield* database.write( - statement`DELETE FROM workflow_listeners WHERE run_id = ${runId} AND listener = ${listener} RETURNING run_id`, + statement`DELETE FROM workflow_listeners WHERE run_key = ${runKey} AND listener = ${listener} RETURNING run_key`, ); return removed.length > 0; }); } -export function isListening(database: HostDatabase, { runId, listener }: ListenerPlace) { +export function isListening(database: HostDatabase, { runKey, listener }: ListenerPlace) { return Effect.map( - database.read(statement`SELECT run_id FROM workflow_listeners WHERE run_id = ${runId} AND listener = ${listener}`), + database.read( + statement`SELECT run_key FROM workflow_listeners WHERE run_key = ${runKey} AND listener = ${listener}`, + ), (rows) => rows.length > 0, ); } @@ -106,25 +108,25 @@ export function pendingArmings(database: HostDatabase, streamId: string): Effect ); } -export function runPassedThrough(database: HostDatabase, runId: string, version: number) { +export function runPassedThrough(database: HostDatabase, runKey: string, version: number) { return Effect.asVoid( Effect.orDie( database .write( - statement`INSERT INTO workflow_passed_runs (run_id, passed_through) - SELECT ${runId}, CAST(${version} AS BIGINT) + statement`INSERT INTO workflow_passed_runs (run_key, passed_through) + SELECT ${runKey}, CAST(${version} AS BIGINT) WHERE NOT EXISTS ( SELECT 1 FROM workflow_runs - WHERE run_id = ${runId} AND (ended_at IS NOT NULL OR dispatched_through >= ${version}) + WHERE run_key = ${runKey} AND (ended_at IS NOT NULL OR dispatched_through >= ${version}) ) - ON CONFLICT (run_id) DO UPDATE SET passed_through = excluded.passed_through`, + ON CONFLICT (run_key) DO UPDATE SET passed_through = excluded.passed_through`, ) .pipe( Effect.andThen( database.write( - statement`DELETE FROM workflow_passed_runs WHERE run_id = ${runId} AND EXISTS ( + statement`DELETE FROM workflow_passed_runs WHERE run_key = ${runKey} AND EXISTS ( SELECT 1 FROM workflow_runs - WHERE workflow_runs.run_id = workflow_passed_runs.run_id + WHERE workflow_runs.run_key = workflow_passed_runs.run_key AND (workflow_runs.ended_at IS NOT NULL OR workflow_runs.dispatched_through >= workflow_passed_runs.passed_through) )`, @@ -135,19 +137,19 @@ export function runPassedThrough(database: HostDatabase, runId: string, version: ); } -export function runCaughtUp(database: HostDatabase, runId: string, through: number) { +export function runCaughtUp(database: HostDatabase, runKey: string, through: number) { return Effect.asVoid( Effect.orDie( database.write( - statement`DELETE FROM workflow_passed_runs WHERE run_id = ${runId} AND passed_through <= ${through}`, + statement`DELETE FROM workflow_passed_runs WHERE run_key = ${runKey} AND passed_through <= ${through}`, ), ), ); } -export function runForgotten(database: HostDatabase, runId: string) { +export function runForgotten(database: HostDatabase, runKey: string) { return Effect.asVoid( - Effect.orDie(database.write(statement`DELETE FROM workflow_passed_runs WHERE run_id = ${runId}`)), + Effect.orDie(database.write(statement`DELETE FROM workflow_passed_runs WHERE run_key = ${runKey}`)), ); } @@ -177,12 +179,12 @@ export function listenersOfType( rowsOf( MatchedRow, database.read( - statement`SELECT l.run_id, l.listener, l.filters, l.workflow + statement`SELECT l.run_key, l.listener, l.filters, l.workflow FROM workflow_listener_types AS t - JOIN workflow_listeners AS l ON l.run_id = t.run_id AND l.listener = t.listener + JOIN workflow_listeners AS l ON l.run_key = t.run_key AND l.listener = t.listener WHERE t.brain_key = ${brainKey} AND t.type = ${type} AND l.passed = 1 - AND (t.run_id > ${after.runId} OR (t.run_id = ${after.runId} AND t.listener > ${after.listener})) - ORDER BY t.run_id, t.listener + AND (t.run_key > ${after.runKey} OR (t.run_key = ${after.runKey} AND t.listener > ${after.listener})) + ORDER BY t.run_key, t.listener LIMIT ${limit}`, ), ), diff --git a/packages/workflow-host/src/listeners/sql-listeners.test.ts b/packages/workflow-host/src/listeners/sql-listeners.test.ts index 806d0ca70..2d66ca1b1 100644 --- a/packages/workflow-host/src/listeners/sql-listeners.test.ts +++ b/packages/workflow-host/src/listeners/sql-listeners.test.ts @@ -9,14 +9,14 @@ import { faultyDatabase } from '../testing/faulty-database.ts'; import { onSQLite, openedOn } from '../testing/host-files.ts'; import { mostListenersInABrain, sqlListeners } from './sql-listeners.ts'; -const attributes = { spec: { name: 'await-approval', version: 1 }, caller: { id: 'acme-admin' } }; +const attributes = { definition: { name: 'await-approval', version: 1 }, caller: { id: 'acme-admin' } }; -const run = { executionId: 'acme/alpha/0199a3c4-7d2e-7c1a-9b3f-2f1e0d9c8b7a', attributes }; +const run = { runId: 'acme/alpha/0199a3c4-7d2e-7c1a-9b3f-2f1e0d9c8b7a', attributes }; const armedBy = { version: 3, lastStep: null }; function listenerAt(reference: string): ArmListener { - return { kind: 'arm_listener', key: { executionId: run.executionId, reference, run: 1 }, filters: [{ type: 'go' }] }; + return { kind: 'arm_listener', key: { runId: run.runId, reference, run: 1 }, filters: [{ type: 'go' }] }; } const RefusalRow = Schema.Struct({ workflow: Schema.String, reason: Schema.String }); @@ -30,8 +30,8 @@ describe('the listeners of a brain', () => { statement`WITH RECURSIVE listening (at) AS ( SELECT 1 UNION ALL SELECT at + 1 FROM listening WHERE at < ${mostListenersInABrain - 1} ) - INSERT INTO workflow_listeners (run_id, listener, brain_key, stream_id, armed_by, filters, workflow, passed) - SELECT 'acme/alpha/r' || at, 'listener', 'brain/acme/alpha/', 'brain/acme/alpha/runs/r' || at, 1, '[]', + INSERT INTO workflow_listeners (run_key, listener, brain_key, stream_id, armed_by, filters, workflow, passed) + SELECT 'acme/alpha/r' || at, 'listener', 'brain/acme/alpha/', 'brain/acme/alpha/run-logs/r' || at, 1, '[]', 'other', 1 FROM listening`, ), diff --git a/packages/workflow-host/src/listeners/sql-listeners.ts b/packages/workflow-host/src/listeners/sql-listeners.ts index 1836c3222..29cdb12b1 100644 --- a/packages/workflow-host/src/listeners/sql-listeners.ts +++ b/packages/workflow-host/src/listeners/sql-listeners.ts @@ -5,7 +5,7 @@ import { Effect } from 'effect'; import type { HostDatabase } from '../database/host-database.ts'; import type { Refusals } from '../reactions/refusals.ts'; import { reactionOfRun } from '../reactions/run-attributes.ts'; -import { addressOfRun, streamOfRun } from '../runs/run-address.ts'; +import { addressOfRun, runLogStreamOf } from '../runs/run-address.ts'; import { insertedListener, isListening, listenersInBrain, removedListener } from './listener-rows.ts'; export const mostListenersInABrain = 4096; @@ -18,12 +18,12 @@ export function sqlListeners(database: HostDatabase, refusals: Refusals): Listen return { arm: (output, run, origin) => Effect.gen(function* () { - const runId = run.executionId; - const place = { runId, listener: callKeyText(output.key) }; + const runKey = run.runId; + const place = { runKey, listener: callKeyText(output.key) }; if (yield* isListening(database, place)) { return 'already_armed'; } - const brainKey = streamPrefixOfBrain(addressOfRun(runId)); + const brainKey = streamPrefixOfBrain(addressOfRun(runKey)); const { workflow } = reactionOfRun(run.attributes); if ((yield* listenersInBrain(database, brainKey)) >= mostListenersInABrain) { yield* refusals.refuse( @@ -36,7 +36,7 @@ export function sqlListeners(database: HostDatabase, refusals: Refusals): Listen yield* insertedListener(database, { ...place, brainKey, - streamId: streamOfRun(runId), + streamId: runLogStreamOf(runKey), armedBy: origin.version, filters: JSON.stringify(output.filters), workflow, @@ -48,7 +48,7 @@ export function sqlListeners(database: HostDatabase, refusals: Refusals): Listen Effect.mapError(failedTo('arm_listener')), ), cancel: (output, run) => - removedListener(database, { runId: run.executionId, listener: callKeyText(output.key) }).pipe( + removedListener(database, { runKey: run.runId, listener: callKeyText(output.key) }).pipe( Effect.map((removed) => (removed ? 'cancelled' : 'not_armed')), Effect.mapError(failedTo('cancel_listener')), ), diff --git a/packages/workflow-host/src/loop/host-loop.test.ts b/packages/workflow-host/src/loop/host-loop.test.ts index cfa4eb269..cdb37b280 100644 --- a/packages/workflow-host/src/loop/host-loop.test.ts +++ b/packages/workflow-host/src/loop/host-loop.test.ts @@ -10,9 +10,9 @@ import { sqlTimers, type DueTimer, type TimerTable } from '../timers/sql-timers. import { systemClock } from './host-clock.ts'; import { startLoop, type HostLoop } from './host-loop.ts'; -const runId = 'acme/alpha/0199a3c4-7d2e-7c1a-9b3f-2f1e0d9c8b7a'; +const runKey = 'acme/alpha/0199a3c4-7d2e-7c1a-9b3f-2f1e0d9c8b7a'; -const run = { executionId: runId, attributes: {} }; +const run = { runId: runKey, attributes: {} }; interface Looping { readonly loop: HostLoop; @@ -98,7 +98,7 @@ async function looping(options: LoopingOptions): Promise { loop, arm: async (timerId, inMs) => { const dueAt = Date.now() + inMs; - const timer: ArmTimer = { kind: 'arm_timer', executionId: runId, timerId, dueAt, purpose: 'wait' }; + const timer: ArmTimer = { kind: 'arm_timer', runId: runKey, timerId, dueAt, purpose: 'wait' }; await Effect.runPromise(timers.timers.arm(timer, run, { version: 1, lastStep: null })); return dueAt; }, diff --git a/packages/workflow-host/src/pages/lost-pages.test.ts b/packages/workflow-host/src/pages/lost-pages.test.ts index d308b9f2d..44a815e10 100644 --- a/packages/workflow-host/src/pages/lost-pages.test.ts +++ b/packages/workflow-host/src/pages/lost-pages.test.ts @@ -76,7 +76,7 @@ describe('a page of folds the pool cannot finish', { timeout: viewTestTimeoutMs async (_way, source, kind, message) => { const views = await viewHarness(await onSQLite(), { foldWorker: workerOf(source), heapMegabytes: 16 }); await views.saved('runs', counting); - await views.ran('inference/runs', 1); + await views.ran('reasoning/runs', 1); views.start(quick); const kept = await views.until('runs', isStalled); @@ -90,7 +90,7 @@ describe('a page of folds the pool cannot finish', { timeout: viewTestTimeoutMs async (_way, source, trouble) => { const views = await viewHarness(await onSQLite(), { foldWorker: workerOf(source) }); await views.saved('runs', counting); - await views.ran('inference/runs', 1); + await views.ran('reasoning/runs', 1); views.start(quick); const troubles = await eventually(views.reports.troubles, (reported) => reported.length > 1, 3000); @@ -104,7 +104,7 @@ describe('a page of folds the pool cannot finish', { timeout: viewTestTimeoutMs it('is tried again, with nothing reported, when the pool has no worker free for it', async () => { const views = await viewHarness(await onSQLite()); await views.saved('runs', counting); - await views.ran('inference/runs', 1); + await views.ran('reasoning/runs', 1); const busy = busyOnce(views.pool); views.start({ pool: busy.pool }); @@ -119,7 +119,7 @@ describe('a page of folds lost twice', { timeout: viewTestTimeoutMs }, () => { const atTheSecondRun = markingThen(2, 'throw new Error("broken on purpose");'); const views = await viewHarness(await onSQLite(), { foldWorker: workerOf(atTheSecondRun) }); await views.saved('runs', counting); - await views.ranEach('inference/runs', [1, 2]); + await views.ranEach('reasoning/runs', [1, 2]); views.start(quick); const kept = await views.until('runs', isStalled); @@ -140,7 +140,7 @@ describe('a projector stopped while it folds', { timeout: viewTestTimeoutMs }, ( const lifted = { folding: { ...foldingOf(), limits: liftedLimits(400_000_000) } }; const first = await viewHarness(settings); await first.saved('outputs', slow); - await first.ranEach('inference/runs', [1, 2, 3]); + await first.ranEach('reasoning/runs', [1, 2, 3]); const stopping = first.start(lifted); await setTimeout(150); await stopping.stop(); diff --git a/packages/workflow-host/src/pages/page-events.ts b/packages/workflow-host/src/pages/page-events.ts index 91fc213b1..029c6fa5f 100644 --- a/packages/workflow-host/src/pages/page-events.ts +++ b/packages/workflow-host/src/pages/page-events.ts @@ -1,5 +1,5 @@ +import { brainEventOf, definitionTypeStreamOf, type CloudEvent } from '@beonauto/definitions'; import type { EventStore, StoredPage, StoredPlace } from '@beonauto/ledger'; -import { brainEventOf, specsStreamOf, type CloudEvent } from '@beonauto/specs'; import { recordsInAPage } from '../projector/projector-settings.ts'; import type { Point } from '../views/view-points.ts'; @@ -33,13 +33,13 @@ interface PageRead { type StoredRecord = StoredPage['records'][number]; -const definitionTypes = ['spec_created', 'spec_updated', 'spec_retired']; +const definitionTypes = ['definition_created', 'definition_updated', 'definition_retired']; const factTypes: ReadonlySet = new Set([ - 'execution_started', - 'execution_succeeded', - 'execution_rejected', - 'execution_failed', + 'run_started', + 'run_succeeded', + 'run_rejected', + 'run_failed', ...definitionTypes, ]); @@ -80,7 +80,7 @@ export async function readPage( { order: 'asc', limit: recordsInAPage, types, ...(after === undefined ? {} : { after }) }, ); const placed = page.records.map((record) => placedOf(brainKey, record)); - const definitions = `${brainKey}${specsStreamOf(definitionType)}`; + const definitions = `${brainKey}${definitionTypeStreamOf(definitionType)}`; return { events: placed.filter((each) => isPageEvent(each)), definitionsSeen: page.records.some(({ stream }) => stream === definitions), diff --git a/packages/workflow-host/src/pages/page-folding.ts b/packages/workflow-host/src/pages/page-folding.ts index dce5dd4b3..e5bd5f845 100644 --- a/packages/workflow-host/src/pages/page-folding.ts +++ b/packages/workflow-host/src/pages/page-folding.ts @@ -40,7 +40,7 @@ type Lost = Extract; type Folded = Extract; -const runSourcePrefix = '/executions/'; +const runSourcePrefix = '/runs/'; const stoppedBy = { deadline: 'time', memory: 'memory' } as const satisfies Readonly>; diff --git a/packages/workflow-host/src/projector/brain-definitions.test.ts b/packages/workflow-host/src/projector/brain-definitions.test.ts index 5ba97ac56..3eeacea94 100644 --- a/packages/workflow-host/src/projector/brain-definitions.test.ts +++ b/packages/workflow-host/src/projector/brain-definitions.test.ts @@ -3,7 +3,7 @@ import { describe, expect, it } from 'vitest'; import { definitionsAfter, noDefinitions } from './brain-definitions.ts'; const saved = { - type: 'spec_created', + type: 'definition_created', name: 'runs', version: 1, content: { source: 'the runs document', details: { fold: '. + 1' } }, @@ -13,7 +13,7 @@ const saved = { describe('the recall functions a brain keeps', () => { it('counts an event it cannot read as a version of the stream, and keeps the functions as they were', () => { - const definitions = definitionsAfter(noDefinitions, [saved, { type: 'spec_renamed', name: 'runs' }]); + const definitions = definitionsAfter(noDefinitions, [saved, { type: 'definition_renamed', name: 'runs' }]); expect(definitions.version).toBe(2); expect([...definitions.functions]).toEqual([['runs', { version: 1, saved: 1, details: { fold: '. + 1' } }]]); diff --git a/packages/workflow-host/src/projector/brain-definitions.ts b/packages/workflow-host/src/projector/brain-definitions.ts index 665de1eb8..625d65eac 100644 --- a/packages/workflow-host/src/projector/brain-definitions.ts +++ b/packages/workflow-host/src/projector/brain-definitions.ts @@ -1,4 +1,4 @@ -import { SpecEventSchema, specsStreamOf, type SpecEvent } from '@beonauto/specs'; +import { DefinitionEventSchema, definitionTypeStreamOf, type DefinitionEvent } from '@beonauto/definitions'; import { Option, Schema } from 'effect'; export interface KeptFunction { @@ -14,19 +14,19 @@ export interface BrainDefinitions { export const noDefinitions: BrainDefinitions = { version: 0, functions: new Map() }; -const decodeSpecEvent = Schema.decodeUnknownOption(Schema.toCodecJson(SpecEventSchema)); +const decodeDefinitionEvent = Schema.decodeUnknownOption(Schema.toCodecJson(DefinitionEventSchema)); export function definitionStreamOf(brainKey: string, definitionType: string): string { - return `${brainKey}${specsStreamOf(definitionType)}`; + return `${brainKey}${definitionTypeStreamOf(definitionType)}`; } export function brainKeyOfDefinitions(stream: string, definitionType: string): string { - return stream.slice(0, stream.length - specsStreamOf(definitionType).length); + return stream.slice(0, stream.length - definitionTypeStreamOf(definitionType).length); } -function evolved(functions: ReadonlyMap, event: SpecEvent, saved: number) { +function evolved(functions: ReadonlyMap, event: DefinitionEvent, saved: number) { const kept = new Map(functions); - if (event.type === 'spec_retired') { + if (event.type === 'definition_retired') { kept.delete(event.name); } else { kept.set(event.name, { version: event.version, saved, details: event.content.details }); @@ -37,7 +37,7 @@ function evolved(functions: ReadonlyMap, event: SpecEvent, export function definitionsAfter(previous: BrainDefinitions, events: readonly unknown[]): BrainDefinitions { const functions = events.reduce>( (kept, stored, index) => - Option.match(decodeSpecEvent(stored), { + Option.match(decodeDefinitionEvent(stored), { onNone: () => kept, onSome: (event) => evolved(kept, event, previous.version + index + 1), }), diff --git a/packages/workflow-host/src/projector/projector-bounds.test.ts b/packages/workflow-host/src/projector/projector-bounds.test.ts index 8264bc86c..ea32a2610 100644 --- a/packages/workflow-host/src/projector/projector-bounds.test.ts +++ b/packages/workflow-host/src/projector/projector-bounds.test.ts @@ -25,7 +25,7 @@ async function viewsInEveryBrain(views: ViewHarness): Promise { await Promise.all( brains.map(async (brain) => { await views.saved('runs', counting, brain); - await views.ran('inference/runs', 1, { brain }); + await views.ran('reasoning/runs', 1, { brain }); }), ); } @@ -36,7 +36,7 @@ function partsOf(views: ViewHarness, database: HostDatabase, pagesPerWake: numbe note: () => Effect.void, settings: views.settingsOf({ pagesPerWake, folding: { ...foldingOf(), pageBudgetMs: 60_000 } }), share: Semaphore.makeUnsafe(2), - reconciling: { database, definitionType: 'recollection', rebuildsAtOnce: 4, definitions: new Map() }, + reconciling: { database, definitionType: 'recall', rebuildsAtOnce: 4, definitions: new Map() }, resting: { isResting: () => false, rest: Function.constVoid }, firstSeen: () => true, trouble: () => Effect.void, @@ -51,10 +51,10 @@ describe('the pages of a pass', { timeout: viewTestTimeoutMs }, () => { it('reads at most the pages a pass may for a live view, and asks for another pass', async () => { const views = await viewHarness(await onSQLite()); await views.saved('runs', counting); - await views.ran('inference/runs', 1); + await views.ran('reasoning/runs', 1); await Effect.runPromise(brainPass(partsOf(views, views.store.database, 10), alphaKey)); const caughtUp = await views.viewOf('runs'); - await views.ranInOneStream('inference/runs', runsOf(2500)); + await views.ranInOneStream('reasoning/runs', runsOf(2500)); const reads = heldReads(views.store.database); reads.open(); @@ -68,7 +68,7 @@ describe('the pages of a pass', { timeout: viewTestTimeoutMs }, () => { it('reads at most the pages a pass may for a rebuilding view, and asks for another pass', async () => { const views = await viewHarness(await onSQLite()); await views.saved('runs', counting); - await views.ranInOneStream('inference/runs', runsOf(2500)); + await views.ranInOneStream('reasoning/runs', runsOf(2500)); const reads = heldReads(views.store.database); reads.open(); @@ -132,14 +132,14 @@ describe('a fold that ran past its deadline', { timeout: viewTestTimeoutMs }, () views.start({ folding: { ...folding, limits, foldDeadlineMs: 500 }, sweepEveryMs: 10_000 }); await Promise.all([views.until('slow', isLive), views.until('quick', isLive)]); - await views.ran('inference/runs', 'slow'); + await views.ran('reasoning/runs', 'slow'); await vi.waitFor( async () => { expect(await overtimesOf(views, 'slow')).toBe(1); }, { timeout: 8000 }, ); - await views.ran('inference/runs', 'quick'); + await views.ran('reasoning/runs', 'quick'); await views.until('quick', foldedAll(2)); const beforeTheSweep = await overtimesOf(views, 'slow'); await vi.waitFor( diff --git a/packages/workflow-host/src/projector/projector.test.ts b/packages/workflow-host/src/projector/projector.test.ts index 6a873fb54..b1c2d6588 100644 --- a/packages/workflow-host/src/projector/projector.test.ts +++ b/packages/workflow-host/src/projector/projector.test.ts @@ -26,10 +26,8 @@ describe('the records a projector reports', { timeout: viewTestTimeoutMs }, () = it('include a record passed over once, though a view rebuilt later reads it again', async () => { const views = await viewHarness(await onSQLite()); await views.saved('first', counting); - await views.ran('inference/runs', 1); - await views.append('brain/acme/alpha/executions/broken', [ - { type: 'execution_succeeded', data: { type: 'execution_succeeded' } }, - ]); + await views.ran('reasoning/runs', 1); + await views.append('brain/acme/alpha/runs/broken', [{ type: 'run_succeeded', data: { type: 'run_succeeded' } }]); views.start(); await views.until('first', liveWith(1)); @@ -46,7 +44,7 @@ describe('a projector that fails', { timeout: viewTestTimeoutMs }, () => { it('reports a sweep that failed, and sweeps again', async () => { const views = await viewHarness(await onSQLite()); await views.saved('runs', counting); - await views.ran('inference/runs', 1); + await views.ran('reasoning/runs', 1); views.failingDiscovery(true); views.start({ sweepEveryMs: 20 }); @@ -61,7 +59,7 @@ describe('a projector that fails', { timeout: viewTestTimeoutMs }, () => { it('reports a pass of a brain that failed, and passes again', async () => { const views = await viewHarness(await onSQLite()); await views.saved('runs', counting); - await views.ran('inference/runs', 1); + await views.ran('reasoning/runs', 1); views.start({ sweepEveryMs: 20, pool: failingOnce(views.pool).pool }); const kept = await views.until('runs', foldedAll(1)); diff --git a/packages/workflow-host/src/projector/views-on-postgresql.test.ts b/packages/workflow-host/src/projector/views-on-postgresql.test.ts index bf7aaf170..2f9a524de 100644 --- a/packages/workflow-host/src/projector/views-on-postgresql.test.ts +++ b/packages/workflow-host/src/projector/views-on-postgresql.test.ts @@ -44,20 +44,20 @@ async function aRunLeftOpen(connectionString: string, output: string): Promise client.end()); - const stream = `brain/acme/alpha/executions/${randomUUID()}`; + const stream = `brain/acme/alpha/runs/${randomUUID()}`; const definition = { - primitive: 'inference', + definition_type: 'reasoning', name: 'late', - spec_version: 1, + definition_version: 1, by: 'acme-admin', at: '2026-10-06T10:00:00.000Z', }; - const event = { type: 'execution_succeeded', ...definition, output, record: {} }; + const event = { type: 'run_succeeded', ...definition, output, record: {} }; const metadata = { messageId: messageIdOf(stream, 1), causationId: null, correlationId: null }; await client.query('BEGIN'); await client.query( `SELECT success FROM emt_append_to_stream( - ARRAY[$4], ARRAY[$1::jsonb], ARRAY[$5::jsonb], ARRAY['1'], ARRAY['execution_succeeded'], ARRAY['E'], $2, 'brain', $3, 'emt:default')`, + ARRAY[$4], ARRAY[$1::jsonb], ARRAY[$5::jsonb], ARRAY['1'], ARRAY['run_succeeded'], ARRAY['E'], $2, 'brain', $3, 'emt:default')`, [{ json: JSON.stringify(event) }, stream, 0, metadata.messageId, metadata], ); return client; @@ -75,7 +75,7 @@ describe.skipIf(skipped)(`the views of recall functions on PostgreSQL${notice}`, await views.until('outputs', isLive); const open = await aRunLeftOpen(connectionString, 'late'); - await views.ran('inference/after', 'after'); + await views.ran('reasoning/after', 'after'); await setTimeout(500); const held = await views.viewOf('outputs'); await open.query('COMMIT'); diff --git a/packages/workflow-host/src/reaction-testing/brain-writes.ts b/packages/workflow-host/src/reaction-testing/brain-writes.ts index e8e84532e..3be5d94dc 100644 --- a/packages/workflow-host/src/reaction-testing/brain-writes.ts +++ b/packages/workflow-host/src/reaction-testing/brain-writes.ts @@ -1,7 +1,7 @@ import { brainsStreamOfOrg } from '@beonauto/brains'; +import type { Trigger } from '@beonauto/definitions'; import { eventAppenderOf, type EventStore } from '@beonauto/ledger'; import { messageIdOf, noLineage, type Lineage } from '@beonauto/operations'; -import type { Trigger } from '@beonauto/specs'; import type { Json } from '@beonauto/workflow-engine'; import { Effect, Schema } from 'effect'; @@ -21,7 +21,7 @@ export interface PublishedEvent { readonly data?: Json; } -export interface SpecVersion { +export interface DefinitionVersion { readonly name: string; readonly version: number; readonly triggers: readonly Trigger[]; @@ -75,10 +75,14 @@ export function everyTrigger(milliseconds: number): Trigger { return { kind: 'every', reference: '/schedule/every', milliseconds }; } -export function specRecorded(store: EventStore, { name, version, triggers, when = at }: SpecVersion, brainKey = alpha) { +export function definitionRecorded( + store: EventStore, + { name, version, triggers, when = at }: DefinitionVersion, + brainKey = alpha, +) { const content = triggers.length === 0 ? { source: 'do: []' } : { source: 'schedule: {}', triggers }; - return recorded(store, `${brainKey}specs/orchestration`, { - type: version === 1 ? 'spec_created' : 'spec_updated', + return recorded(store, `${brainKey}definitions/workflow`, { + type: version === 1 ? 'definition_created' : 'definition_updated', name, version, content, @@ -87,16 +91,16 @@ export function specRecorded(store: EventStore, { name, version, triggers, when }); } -export function specRecordAt(position: number, brainKey = alpha): string { - return messageIdOf(`${brainKey}specs/orchestration`, position); +export function definitionRecordAt(position: number, brainKey = alpha): string { + return messageIdOf(`${brainKey}definitions/workflow`, position); } export function eventRecordOf(id: string, brainKey = alpha): string { return messageIdOf(`${brainKey}events/${id}`, 1); } -export function specRetired(store: EventStore, name: string) { - return recorded(store, `${alpha}specs/orchestration`, { type: 'spec_retired', name, by: 'acme-admin', at }); +export function definitionRetired(store: EventStore, name: string) { + return recorded(store, `${alpha}definitions/workflow`, { type: 'definition_retired', name, by: 'acme-admin', at }); } export function brainCreated(store: EventStore, brain: string) { @@ -115,19 +119,19 @@ export function brainRenamed(store: EventStore, brain: string) { } export interface RunOf { - readonly executionId: string; - readonly primitive: string; + readonly runId: string; + readonly type: string; readonly name: string; readonly depth?: number; readonly correlation?: string; } -export async function runRecorded(store: EventStore, run: RunOf, ending?: 'execution_succeeded') { - const { executionId, primitive, name, depth, correlation = executionId } = run; - const stream = `${alpha}executions/${executionId}`; - const ofTheRun = { primitive, name, spec_version: 1, ...(depth === undefined ? {} : { depth }) }; +export async function runRecorded(store: EventStore, run: RunOf, ending?: 'run_succeeded') { + const { runId, type, name, depth, correlation = runId } = run; + const stream = `${alpha}runs/${runId}`; + const ofTheRun = { definition_type: type, name, definition_version: 1, ...(depth === undefined ? {} : { depth }) }; const lineage = { causationId: null, correlationId: correlation }; - await recorded(store, stream, { type: 'execution_started', ...ofTheRun, input: {}, by: 'acme-admin', at }, lineage); + await recorded(store, stream, { type: 'run_started', ...ofTheRun, input: {}, by: 'acme-admin', at }, lineage); if (ending !== undefined) { await recorded( store, diff --git a/packages/workflow-host/src/reaction-testing/held-start.ts b/packages/workflow-host/src/reaction-testing/held-start.ts index 153b7fa13..ac69e7e3b 100644 --- a/packages/workflow-host/src/reaction-testing/held-start.ts +++ b/packages/workflow-host/src/reaction-testing/held-start.ts @@ -19,9 +19,9 @@ export function heldStart(): HeldStart { const begun = Promise.withResolvers(); const released = Promise.withResolvers(); const exits: StartExit[] = []; - const startedOn = (host: WorkflowHost, { executionId }: ReactionStart) => + const startedOn = (host: WorkflowHost, { runId }: ReactionStart) => Effect.gen(function* () { - exits.push(yield* Effect.exit(host.start(runAt(executionId), startOf(listening)))); + exits.push(yield* Effect.exit(host.start(runAt(runId), startOf(listening)))); }); return { start: (start) => diff --git a/packages/workflow-host/src/reaction-testing/kept-triggers.ts b/packages/workflow-host/src/reaction-testing/kept-triggers.ts index e5de557f0..cba3ef2bd 100644 --- a/packages/workflow-host/src/reaction-testing/kept-triggers.ts +++ b/packages/workflow-host/src/reaction-testing/kept-triggers.ts @@ -65,16 +65,14 @@ export function startsReaching( ); } -export function runStillGoing(database: HostDatabase, executionId: string) { +export function runStillGoing(database: HostDatabase, runId: string) { return Effect.runPromise( - database.write( - statement`INSERT INTO workflow_runs (run_id, stream_id) VALUES (${`acme/alpha/${executionId}`}, ${'s'})`, - ), + database.write(statement`INSERT INTO workflow_runs (run_key, stream_id) VALUES (${`acme/alpha/${runId}`}, ${'s'})`), ); } -export function runEnded(database: HostDatabase, executionId: string) { +export function runEnded(database: HostDatabase, runId: string) { return Effect.runPromise( - database.write(statement`UPDATE workflow_runs SET ended_at = 1 WHERE run_id = ${`acme/alpha/${executionId}`}`), + database.write(statement`UPDATE workflow_runs SET ended_at = 1 WHERE run_key = ${`acme/alpha/${runId}`}`), ); } diff --git a/packages/workflow-host/src/reaction-testing/reaction-suite.ts b/packages/workflow-host/src/reaction-testing/reaction-suite.ts index 447de5d78..2ef1e15d3 100644 --- a/packages/workflow-host/src/reaction-testing/reaction-suite.ts +++ b/packages/workflow-host/src/reaction-testing/reaction-suite.ts @@ -3,7 +3,7 @@ import { expect, it } from 'vitest'; import { runAt, startOf, workflow } from '../testing/host-documents.ts'; import type { SettingsOf } from '../testing/host-files.ts'; -import { at, cronTrigger, eventTrigger, everyTrigger, published, specRecorded } from './brain-writes.ts'; +import { at, cronTrigger, eventTrigger, everyTrigger, published, definitionRecorded } from './brain-writes.ts'; import { movedClock } from './moved-clock.ts'; import { reactingHost } from './reacting-host.ts'; import { until } from './until.ts'; @@ -20,7 +20,7 @@ export function reactionSuite(settings: SettingsOf): void { it('starts a workflow whose trigger an event matches', { timeout: aWhile }, async () => { const reacting = await reactingHost({ settings: await settings() }); const trigger = eventTrigger({ type: 'com.acme.closed' }); - await specRecorded(reacting.database.store, { name: 'close', version: 1, triggers: [trigger] }); + await definitionRecorded(reacting.database.store, { name: 'close', version: 1, triggers: [trigger] }); await published(reacting.database.store, { id: 'e1', type: 'com.acme.closed' }); const starts = await until( @@ -54,7 +54,7 @@ function scheduleSuite(settings: SettingsOf): void { const activatedAt = Date.parse(at); const clock = movedClock(activatedAt + 1000); const reacting = await reactingHost({ settings: await settings(), clock }); - await specRecorded(reacting.database.store, { name: 'tick', version: 1, triggers: [everyTrigger(60_000)] }); + await definitionRecorded(reacting.database.store, { name: 'tick', version: 1, triggers: [everyTrigger(60_000)] }); clock.moveTo(activatedAt + 60_000); const starts = await until( @@ -74,7 +74,7 @@ function scheduleSuite(settings: SettingsOf): void { const clock = movedClock(activatedAt + 1000); const reacting = await reactingHost({ settings: await settings(), clock }); const triggers = [eventTrigger({ type: 'com.acme.closed' }), cronTrigger('0 10 * * *'), everyTrigger(3_600_000)]; - await specRecorded(reacting.database.store, { name: 'close', version: 1, triggers }); + await definitionRecorded(reacting.database.store, { name: 'close', version: 1, triggers }); await published(reacting.database.store, { id: 'e1', type: 'com.acme.closed' }); await until( @@ -94,7 +94,7 @@ function scheduleSuite(settings: SettingsOf): void { ['/schedule/cron', true], ['/schedule/every', true], ]); - expect(new Set(starts.map(({ executionId }) => executionId)).size).toBe(3); + expect(new Set(starts.map(({ runId }) => runId)).size).toBe(3); }, ); } diff --git a/packages/workflow-host/src/reaction-testing/recorded-reactions.ts b/packages/workflow-host/src/reaction-testing/recorded-reactions.ts index e6fbd8d3a..0a132d478 100644 --- a/packages/workflow-host/src/reaction-testing/recorded-reactions.ts +++ b/packages/workflow-host/src/reaction-testing/recorded-reactions.ts @@ -1,4 +1,4 @@ -import type { Emission } from '@beonauto/specs'; +import type { Emission } from '@beonauto/definitions'; import { Effect } from 'effect'; import { StartRefused, type ReactionOptions, type ReactionStart } from '../reactions/reaction-options.ts'; @@ -31,7 +31,7 @@ export function recordedReactions(behaviour: ReactionBehaviour = {}): RecordedRe const emissions: Emission[] = []; return { options: { - primitive: 'orchestration', + definitionType: 'workflow', start: (start) => Effect.suspend(() => { const failure = behaviour.failure?.(start) ?? null; diff --git a/packages/workflow-host/src/reactions/listener-offers.test.ts b/packages/workflow-host/src/reactions/listener-offers.test.ts index 4d705a5d1..1429a2585 100644 --- a/packages/workflow-host/src/reactions/listener-offers.test.ts +++ b/packages/workflow-host/src/reactions/listener-offers.test.ts @@ -22,13 +22,13 @@ interface Offering { } function listening(database: HostDatabase, run: string, filters: readonly Json[]) { - const runId = `acme/alpha/${run}`; + const runKey = `acme/alpha/${run}`; return Effect.runPromise( insertedListener(database, { - runId, - listener: callKeyText({ executionId: runId, reference: '/do/0/wait', run: 1 }), + runKey, + listener: callKeyText({ runId: runKey, reference: '/do/0/wait', run: 1 }), brainKey, - streamId: `${brainKey}runs/${run}`, + streamId: `${brainKey}run-logs/${run}`, armedBy: 1, filters: JSON.stringify(filters), workflow: `wf-${run}`, @@ -37,27 +37,27 @@ function listening(database: HostDatabase, run: string, filters: readonly Json[] ); } -function offering(database: HostDatabase, answer: (runId: string) => Effect.Effect): Offering { +function offering(database: HostDatabase, answer: (runKey: string) => Effect.Effect): Offering { const offers: string[] = []; const { refusals, said, say } = saidRefusals(); const consumer = listenerOffers({ database, refusals, - offer: ({ runId, key, listener }) => + offer: ({ runKey, key, listener }) => Effect.andThen( Effect.sync(() => { - offers.push(`${runId} ${key} ${listener.reference}`); + offers.push(`${runKey} ${key} ${listener.reference}`); }), - answer(runId), + answer(runKey), ), - declined: (runId, detail) => say(`${runId} declined: ${detail}`), + declined: (runKey, detail) => say(`${runKey} declined: ${detail}`), now: () => 0, }); return { offers: () => offers, said, consumer }; } -function declinedByTheFirst(runId: string): Effect.Effect { - return runId.endsWith('run-a') +function declinedByTheFirst(runKey: string): Effect.Effect { + return runKey.endsWith('run-a') ? Effect.succeed(declinedOffer) : Effect.fail(new Conflict({ detail: 'The ledger cannot be reached' })); } @@ -84,7 +84,7 @@ describe('the offers of an event to the runs that listen for its type', () => { const batch = await deliveredAll( consumer, - followedRecordOf({ region: 'eu' }, { emitter: { executionId: 'run-c', workflow: 'wf-run-c' } }), + followedRecordOf({ region: 'eu' }, { emitter: { runId: 'run-c', workflow: 'wf-run-c' } }), 100, ); diff --git a/packages/workflow-host/src/reactions/listener-offers.ts b/packages/workflow-host/src/reactions/listener-offers.ts index 13be1d3ce..575132955 100644 --- a/packages/workflow-host/src/reactions/listener-offers.ts +++ b/packages/workflow-host/src/reactions/listener-offers.ts @@ -15,7 +15,7 @@ import { addressOfRun } from '../runs/run-address.ts'; import type { RefuseReaction } from './refusals.ts'; interface Offer { - readonly runId: string; + readonly runKey: string; readonly key: string; readonly listener: CallKey; readonly event: FollowedRecord['event']['event']; @@ -25,7 +25,7 @@ export interface OfferParts { readonly database: HostDatabase; readonly refusals: RefuseReaction; readonly offer: (offer: Offer) => Effect.Effect>; - readonly declined: (runId: string, detail: string) => Effect.Effect; + readonly declined: (runKey: string, detail: string) => Effect.Effect; readonly now: () => number; } @@ -35,23 +35,23 @@ const ListenerKeySchema = Schema.fromJsonString(Schema.Tuple([Schema.String, Sch const FiltersSchema = Schema.fromJsonString(Schema.Array(Schema.JsonObject)); -const nowhere: ListenerPlace = { runId: '', listener: '' }; +const nowhere: ListenerPlace = { runKey: '', listener: '' }; function placeOf(after: string | undefined): ListenerPlace { if (after === undefined) { return nowhere; } - const [runId, listener] = Schema.decodeUnknownSync(PlaceSchema)(after); - return { runId, listener }; + const [runKey, listener] = Schema.decodeUnknownSync(PlaceSchema)(after); + return { runKey, listener }; } function keyOf(row: MatchedListener): string { - return JSON.stringify([row.run_id, row.listener]); + return JSON.stringify([row.run_key, row.listener]); } function listenerKeyOf(text: string): CallKey { - const [executionId, reference, run] = Schema.decodeUnknownSync(ListenerKeySchema)(text); - return Schema.decodeUnknownSync(CallKeySchema)({ executionId, reference, run }); + const [runId, reference, run] = Schema.decodeUnknownSync(ListenerKeySchema)(text); + return Schema.decodeUnknownSync(CallKeySchema)({ runId, reference, run }); } function accepts(row: MatchedListener, { event }: FollowedRecord, now: number): boolean { @@ -62,7 +62,7 @@ function accepts(row: MatchedListener, { event }: FollowedRecord, now: number): } function emittedByTheRun(row: MatchedListener, { event }: FollowedRecord): boolean { - return event.emitter?.executionId === addressOfRun(row.run_id).executionId; + return event.emitter?.runId === addressOfRun(row.run_key).runId; } function offerOf(parts: OfferParts, row: MatchedListener, followed: FollowedRecord): Delivery { @@ -71,13 +71,15 @@ function offerOf(parts: OfferParts, row: MatchedListener, followed: FollowedReco workflow: row.workflow, deliver: parts .offer({ - runId: row.run_id, + runKey: row.run_key, key: followed.record.id, listener: listenerKeyOf(row.listener), event: followed.event.event, }) .pipe( - Effect.flatMap(({ declined }) => (declined === undefined ? Effect.void : parts.declined(row.run_id, declined))), + Effect.flatMap(({ declined }) => + declined === undefined ? Effect.void : parts.declined(row.run_key, declined), + ), Effect.mapError(({ detail }: Readonly<{ detail: string }>) => new DeliveryFailed({ detail })), ), }; diff --git a/packages/workflow-host/src/reactions/reacting.ts b/packages/workflow-host/src/reactions/reacting.ts index 74d4e4a91..c64ffa738 100644 --- a/packages/workflow-host/src/reactions/reacting.ts +++ b/packages/workflow-host/src/reactions/reacting.ts @@ -4,13 +4,13 @@ import type { RecordConsumer } from '../follower/consumers.ts'; import type { FollowerHost } from '../follower/follower-host.ts'; import type { Upkeep } from '../follower/follower-loop.ts'; import { scheduleFiringOn } from '../schedules/schedule-firing.ts'; -import { specRecordsOn, type ApplySpecRecord } from '../triggers/spec-records.ts'; +import { definitionRecordsOn, type ApplyDefinitionRecord } from '../triggers/definition-records.ts'; import { reactionConsumersOf, type ReactionUse } from './reaction-consumers.ts'; import { startingOn } from './start-rates.ts'; export interface Reacting { readonly consumers: readonly RecordConsumer[]; - readonly applySpecRecord: ApplySpecRecord; + readonly applyDefinitionRecord: ApplyDefinitionRecord; readonly upkeep: Upkeep; } @@ -21,7 +21,7 @@ export function reactingOn(host: FollowerHost, use: ReactionUse): Reacting { const schedules = scheduleFiringOn(database, options.start, refusals, clock.now); return { consumers: reactionConsumersOf(host, use, starting), - applySpecRecord: specRecordsOn(database), + applyDefinitionRecord: definitionRecordsOn(database), upkeep: { sweep: () => Effect.asVoid(Effect.andThen(starting.startDeferred(), refusals.flush())), fireSchedules: () => Effect.asVoid(schedules.fireDue()), diff --git a/packages/workflow-host/src/reactions/reaction-consumers.ts b/packages/workflow-host/src/reactions/reaction-consumers.ts index 31648931d..820dc3fbc 100644 --- a/packages/workflow-host/src/reactions/reaction-consumers.ts +++ b/packages/workflow-host/src/reactions/reaction-consumers.ts @@ -22,16 +22,16 @@ export function reactionConsumersOf( const offers = listenerOffers({ database, refusals, - offer: ({ runId, key, listener, event }) => - host.submitted({ kind: 'event_offered', executionId: runId, at: clock.now(), key, listener, event }), - declined: (runId, detail) => reports.note({ kind: 'offer_declined', run: addressOfRun(runId), detail }), + offer: ({ runKey, key, listener, event }) => + host.submitted({ kind: 'event_offered', runId: runKey, at: clock.now(), key, listener, event }), + declined: (runKey, detail) => reports.note({ kind: 'offer_declined', run: addressOfRun(runKey), detail }), now: clock.now, }); const starts = subscriptionStarts({ database, starting, refusals, - workflowOfRun: workflowsOfRuns((stream) => database.store.read(stream, 0), options.primitive), + workflowOfRun: workflowsOfRuns((stream) => database.store.read(stream, 0), options.definitionType), now: clock.now, }); return [offers, starts]; diff --git a/packages/workflow-host/src/reactions/reaction-ids.ts b/packages/workflow-host/src/reactions/reaction-ids.ts index 3edec0e2c..797776f4c 100644 --- a/packages/workflow-host/src/reactions/reaction-ids.ts +++ b/packages/workflow-host/src/reactions/reaction-ids.ts @@ -2,6 +2,6 @@ import { uuidV5 } from '@beonauto/operations'; const reactionStarts = '3c9e1f04-7b2a-5d68-8e41-0a6f5c2d9b73'; -export function reactionExecutionIdOf(workflow: string, version: number, reference: string, cause: string): string { +export function reactionRunIdOf(workflow: string, version: number, reference: string, cause: string): string { return uuidV5(reactionStarts, JSON.stringify([workflow, version, reference, cause])); } diff --git a/packages/workflow-host/src/reactions/reaction-options.ts b/packages/workflow-host/src/reactions/reaction-options.ts index c3fcd0d9d..9f852a73c 100644 --- a/packages/workflow-host/src/reactions/reaction-options.ts +++ b/packages/workflow-host/src/reactions/reaction-options.ts @@ -1,5 +1,5 @@ +import type { EmitEvent, StartingTrigger } from '@beonauto/definitions'; import type { StreamSignal } from '@beonauto/ledger'; -import type { EmitEvent, StartingTrigger } from '@beonauto/specs'; import { Data, type Effect, type Schema } from 'effect'; import type { StartRejected } from './start-rejected.ts'; @@ -9,7 +9,7 @@ export interface ReactionStart { readonly brain: string; readonly workflow: string; readonly version: number; - readonly executionId: string; + readonly runId: string; readonly input: Schema.Json; readonly depth: number; readonly cause: string; @@ -21,7 +21,7 @@ export class StartRefused extends Data.TaggedError('start_refused')<{ readonly d export type StartReaction = (start: ReactionStart) => Effect.Effect; export interface ReactionOptions { - readonly primitive: string; + readonly definitionType: string; readonly start: StartReaction; readonly emit: EmitEvent; readonly appended?: StreamSignal; diff --git a/packages/workflow-host/src/reactions/refusals.ts b/packages/workflow-host/src/reactions/refusals.ts index 2b97412cc..c466f844e 100644 --- a/packages/workflow-host/src/reactions/refusals.ts +++ b/packages/workflow-host/src/reactions/refusals.ts @@ -1,5 +1,5 @@ +import { ReactionRefusedSchema, reactionsStreamKind, type ReactionRefused } from '@beonauto/definitions'; import { eventAppenderOf } from '@beonauto/ledger'; -import { ReactionRefusedSchema, reactionsStreamKind, type ReactionRefused } from '@beonauto/specs'; import { Effect, Schema } from 'effect'; import { rowsOf, WholeNumber, type HostDatabase } from '../database/host-database.ts'; diff --git a/packages/workflow-host/src/reactions/run-attributes.ts b/packages/workflow-host/src/reactions/run-attributes.ts index 2d34b50bb..f85ec5eb3 100644 --- a/packages/workflow-host/src/reactions/run-attributes.ts +++ b/packages/workflow-host/src/reactions/run-attributes.ts @@ -1,7 +1,7 @@ import { Option, Schema } from 'effect'; const ReactionAttributesSchema = Schema.Struct({ - spec: Schema.Struct({ name: Schema.String, version: Schema.Int }), + definition: Schema.Struct({ name: Schema.String, version: Schema.Int }), caller: Schema.Struct({ id: Schema.String }), depth: Schema.optionalKey(Schema.Int), lineage: Schema.optionalKey(Schema.Struct({ correlation: Schema.String })), @@ -22,9 +22,9 @@ const unnamed: RunReaction = { workflow: 'workflow', version: 0, caller: 'unknow export function reactionOfRun(attributes: Schema.JsonObject): RunReaction { return Option.match(decodeAttributes(attributes), { onNone: () => unnamed, - onSome: ({ spec, caller, depth = 0, lineage }) => ({ - workflow: spec.name, - version: spec.version, + onSome: ({ definition, caller, depth = 0, lineage }) => ({ + workflow: definition.name, + version: definition.version, caller: caller.id, depth, correlation: lineage?.correlation, diff --git a/packages/workflow-host/src/reactions/run-workflows.test.ts b/packages/workflow-host/src/reactions/run-workflows.test.ts index 36fd008c1..b5df7f95f 100644 --- a/packages/workflow-host/src/reactions/run-workflows.test.ts +++ b/packages/workflow-host/src/reactions/run-workflows.test.ts @@ -5,13 +5,21 @@ import { workflowsOfRuns } from './run-workflows.ts'; const brainKey = 'brain/acme/alpha/'; -function startedRecord(primitive: string, name: string) { - return { type: 'execution_started', primitive, name, spec_version: 1, input: {}, by: 'acme-admin', at: 'now' }; +function startedRecord(type: string, name: string) { + return { + type: 'run_started', + definition_type: type, + name, + definition_version: 1, + input: {}, + by: 'acme-admin', + at: 'now', + }; } const streams: ReadonlyMap = new Map([ - [`${brainKey}executions/r-close`, [startedRecord('orchestration', 'close')]], - [`${brainKey}executions/r-sum`, [startedRecord('inference', 'sum')]], + [`${brainKey}runs/r-close`, [startedRecord('workflow', 'close')]], + [`${brainKey}runs/r-sum`, [startedRecord('reasoning', 'sum')]], ]); function counting() { @@ -26,21 +34,15 @@ function counting() { describe('the workflow of a run', () => { it('is the name the run started, when it is a workflow, and is read once while it is remembered', async () => { const { read, reads } = counting(); - const workflowOf = workflowsOfRuns(read, 'orchestration', 3); + const workflowOf = workflowsOfRuns(read, 'workflow', 3); const workflows = await Effect.runPromise( - Effect.forEach(['r-close', 'r-sum', 'r-unknown', 'r-close', 'r-other', 'r-close'], (executionId) => - workflowOf(brainKey, executionId), + Effect.forEach(['r-close', 'r-sum', 'r-unknown', 'r-close', 'r-other', 'r-close'], (runId) => + workflowOf(brainKey, runId), ), ); expect(workflows).toEqual(['close', undefined, undefined, 'close', undefined, 'close']); - expect(reads()).toEqual([ - 'executions/r-close', - 'executions/r-sum', - 'executions/r-unknown', - 'executions/r-other', - 'executions/r-close', - ]); + expect(reads()).toEqual(['runs/r-close', 'runs/r-sum', 'runs/r-unknown', 'runs/r-other', 'runs/r-close']); }); }); diff --git a/packages/workflow-host/src/reactions/run-workflows.ts b/packages/workflow-host/src/reactions/run-workflows.ts index 524cd0264..3569da747 100644 --- a/packages/workflow-host/src/reactions/run-workflows.ts +++ b/packages/workflow-host/src/reactions/run-workflows.ts @@ -1,24 +1,20 @@ -import { runStartedOf } from '@beonauto/specs'; +import { runStartedOf } from '@beonauto/definitions'; import { Effect } from 'effect'; -export type WorkflowOfRun = (brainKey: string, executionId: string) => Effect.Effect; +export type WorkflowOfRun = (brainKey: string, runId: string) => Effect.Effect; export type ReadStream = (stream: string) => Promise<{ readonly events: readonly unknown[] }>; const mostRunsRemembered = 4096; -export function workflowsOfRuns( - read: ReadStream, - primitive: string, - mostRemembered = mostRunsRemembered, -): WorkflowOfRun { +export function workflowsOfRuns(read: ReadStream, type: string, mostRemembered = mostRunsRemembered): WorkflowOfRun { const remembered = new Map(); const workflowOf = (events: readonly unknown[]): string | undefined => { const started = runStartedOf(events[0]); - return started?.primitive === primitive ? started.name : undefined; + return started?.definitionType === type ? started.name : undefined; }; - return (brainKey, executionId) => { - const stream = `${brainKey}executions/${executionId}`; + return (brainKey, runId) => { + const stream = `${brainKey}runs/${runId}`; if (remembered.has(stream)) { return Effect.succeed(remembered.get(stream)); } diff --git a/packages/workflow-host/src/reactions/start-rates.test.ts b/packages/workflow-host/src/reactions/start-rates.test.ts index 6f07b3900..03d35a8db 100644 --- a/packages/workflow-host/src/reactions/start-rates.test.ts +++ b/packages/workflow-host/src/reactions/start-rates.test.ts @@ -24,7 +24,7 @@ function startNumber(index: number): ReactionStart { brain: 'alpha', workflow: 'close', version: 1, - executionId: `run-${index}`, + runId: `run-${index}`, input: [], depth: 1, cause: `record-${index}`, @@ -42,7 +42,7 @@ async function startingAt(time: Readonly<{ now: number }>, refuses = () => false refuses() ? Effect.fail(new StartRefused({ detail: 'refused' })) : Effect.sync(() => { - started.push(start.executionId); + started.push(start.runId); }), refusals, () => time.now, @@ -113,7 +113,7 @@ describe('the starts that wait for a later minute', () => { statement`WITH RECURSIVE waiting (at) AS ( SELECT 1 UNION ALL SELECT at + 1 FROM waiting WHERE at < ${mostDeferredStarts - 1} ) - INSERT INTO workflow_reaction_backlog (brain_key, workflow, execution_id, start, due) + INSERT INTO workflow_reaction_backlog (brain_key, workflow, run_id, start, due) SELECT ${brainKey}, 'close', 'waiting-' || at, '{}', ${minute + aMinute} FROM waiting`, ), ); diff --git a/packages/workflow-host/src/reactions/start-rates.ts b/packages/workflow-host/src/reactions/start-rates.ts index 48635aa1e..43e573c08 100644 --- a/packages/workflow-host/src/reactions/start-rates.ts +++ b/packages/workflow-host/src/reactions/start-rates.ts @@ -1,4 +1,4 @@ -import { StartingTriggerSchema, triggerNamed } from '@beonauto/specs'; +import { StartingTriggerSchema, triggerNamed } from '@beonauto/definitions'; import { Effect, Schema } from 'effect'; import { rowsOf, WholeNumber, type HostDatabase } from '../database/host-database.ts'; @@ -33,7 +33,7 @@ const ReactionStartSchema = Schema.Struct({ brain: Schema.String, workflow: Schema.String, version: Schema.Int, - executionId: Schema.String, + runId: Schema.String, input: Schema.Json, depth: Schema.Int, cause: Schema.String, @@ -105,9 +105,9 @@ function deferredStart({ database, refusals }: StartingParts, brainKey: string, : Effect.asVoid( Effect.orDie( database.write( - statement`INSERT INTO workflow_reaction_backlog (brain_key, workflow, execution_id, start, due) - VALUES (${brainKey}, ${start.workflow}, ${start.executionId}, ${JSON.stringify(start)}, ${minute + aMinute}) - ON CONFLICT (brain_key, execution_id) DO NOTHING`, + statement`INSERT INTO workflow_reaction_backlog (brain_key, workflow, run_id, start, due) + VALUES (${brainKey}, ${start.workflow}, ${start.runId}, ${JSON.stringify(start)}, ${minute + aMinute}) + ON CONFLICT (brain_key, run_id) DO NOTHING`, ), ), ), @@ -119,7 +119,7 @@ function withoutDeferred(database: HostDatabase, { brain_key: brainKey, start }: Effect.orDie( database.write( statement`DELETE FROM workflow_reaction_backlog - WHERE brain_key = ${brainKey} AND execution_id = ${start.executionId}`, + WHERE brain_key = ${brainKey} AND run_id = ${start.runId}`, ), ), ); @@ -130,7 +130,7 @@ function deferredAgain({ database }: StartingParts, deferred: Deferred, minute: Effect.orDie( database.write( statement`UPDATE workflow_reaction_backlog SET due = ${minute + aMinute}, attempts = ${attempts} - WHERE brain_key = ${deferred.brain_key} AND execution_id = ${deferred.start.executionId}`, + WHERE brain_key = ${deferred.brain_key} AND run_id = ${deferred.start.runId}`, ), ), ); @@ -180,7 +180,7 @@ export function startingOn( DeferredRow, database.read( statement`SELECT brain_key, start, attempts FROM workflow_reaction_backlog WHERE due <= ${now()} - ORDER BY due, execution_id LIMIT ${mostStartsAMinute}`, + ORDER BY due, run_id LIMIT ${mostStartsAMinute}`, ), ), ); diff --git a/packages/workflow-host/src/reactions/subscription-starts.test.ts b/packages/workflow-host/src/reactions/subscription-starts.test.ts index 5265ff7d4..e5c35427c 100644 --- a/packages/workflow-host/src/reactions/subscription-starts.test.ts +++ b/packages/workflow-host/src/reactions/subscription-starts.test.ts @@ -6,7 +6,7 @@ import { eventTrigger, type TriggerFilter } from '../reaction-testing/brain-writ import { followedRecordOf, saidRefusals } from '../reaction-testing/followed-records.ts'; import { onSQLite, openedOn } from '../testing/host-files.ts'; import { triggersActivated } from '../triggers/trigger-rows.ts'; -import { reactionExecutionIdOf } from './reaction-ids.ts'; +import { reactionRunIdOf } from './reaction-ids.ts'; import type { ReactionStart } from './reaction-options.ts'; import { mostReactionDepth, subscriptionStarts } from './subscription-starts.ts'; @@ -25,7 +25,7 @@ async function subscribedAt( name: string, ...filters: readonly TriggerFilter[] ) { - const activation = { workflow: name, version, activatedBy: `spec-${version}`, activatedAt: Date.parse(at) }; + const activation = { workflow: name, version, activatedBy: `definition-${version}`, activatedAt: Date.parse(at) }; await Effect.runPromise( triggersActivated(database, brainKey, { ...activation, triggers: [eventTrigger(...filters)] }), ); @@ -49,7 +49,7 @@ async function starting() { startDeferred: () => Effect.succeed(0), }, refusals, - workflowOfRun: (_brainKey, executionId) => Effect.succeed(topRuns.get(executionId)), + workflowOfRun: (_brainKey, runId) => Effect.succeed(topRuns.get(runId)), now: () => 0, }); const delivered = (followed: ReturnType) => @@ -75,7 +75,7 @@ describe('the starts of the workflows whose trigger an event matches', () => { brain: 'alpha', workflow: 'close', version: 1, - executionId: reactionExecutionIdOf('close', 1, '/schedule/on', 'record-1'), + runId: reactionRunIdOf('close', 1, '/schedule/on', 'record-1'), input: [{ specversion: '1.0', id: 'e1', source: '/acme', type: 'go', time: at, data: { region: 'eu' } }], depth: 1, cause: 'record-1', diff --git a/packages/workflow-host/src/reactions/subscription-starts.ts b/packages/workflow-host/src/reactions/subscription-starts.ts index 1009458f8..97fe5a593 100644 --- a/packages/workflow-host/src/reactions/subscription-starts.ts +++ b/packages/workflow-host/src/reactions/subscription-starts.ts @@ -4,7 +4,7 @@ import { Effect } from 'effect'; import type { HostDatabase } from '../database/host-database.ts'; import { deliverySweeps, type RecordConsumer, type Delivery, type FollowedRecord } from '../follower/consumers.ts'; import { eventSubscriptionsOf, type EventSubscription } from '../triggers/trigger-rows.ts'; -import { reactionExecutionIdOf } from './reaction-ids.ts'; +import { reactionRunIdOf } from './reaction-ids.ts'; import type { RefuseReaction } from './refusals.ts'; import type { WorkflowOfRun } from './run-workflows.ts'; import type { Starting } from './start-rates.ts'; @@ -56,7 +56,7 @@ function startOf(parts: StartParts, subscription: EventSubscription, followed: F ...brain, workflow, version, - executionId: reactionExecutionIdOf(workflow, version, reference, record.id), + runId: reactionRunIdOf(workflow, version, reference, record.id), input: [event.event], depth: event.depth, cause: record.id, diff --git a/packages/workflow-host/src/runs/ledger-run-store.test.ts b/packages/workflow-host/src/runs/ledger-run-store.test.ts index db94f5b98..7a51cb71f 100644 --- a/packages/workflow-host/src/runs/ledger-run-store.test.ts +++ b/packages/workflow-host/src/runs/ledger-run-store.test.ts @@ -7,12 +7,12 @@ import { statement } from '../database/statement.ts'; import { runAt, startOf, workflow } from '../testing/host-documents.ts'; import { aSQLiteFile, openedOn } from '../testing/host-files.ts'; import { hostedOn } from '../testing/host-runs.ts'; -import { ledgerRunStore } from './ledger-run-store.ts'; -import { runIdOf } from './run-address.ts'; +import { ledgerRunLogStore } from './ledger-run-store.ts'; +import { runKeyOf } from './run-address.ts'; -const executionId = '0199a3c4-7d2e-7c1a-9b3f-2f1e0d9c8b7a'; +const runId = '0199a3c4-7d2e-7c1a-9b3f-2f1e0d9c8b7a'; -const run = runAt(executionId); +const run = runAt(runId); const waiting = workflow('do:\n - pause: { wait: PT1H }'); @@ -30,13 +30,13 @@ describe('the run store of the host, ending a run', () => { const database = await openedOn({ store: 'sqlite', file }); await Effect.runPromise( database.write( - statement`INSERT INTO workflow_passed_runs (run_id, passed_through) VALUES (${runIdOf(run)}, ${1000})`, + statement`INSERT INTO workflow_passed_runs (run_key, passed_through) VALUES (${runKeyOf(run)}, ${1000})`, ), ); const hosted = await hostedOn({ store: 'sqlite', file }); await Effect.runPromise(hosted.host.start(run, startOf(workflow('do:\n - done: { set: { done: true } }')))); - const left = await Effect.runPromise(database.read(statement`SELECT run_id FROM workflow_passed_runs`)); + const left = await Effect.runPromise(database.read(statement`SELECT run_key FROM workflow_passed_runs`)); expect([(await Effect.runPromise(hosted.host.stateOf(run))).status, left]).toEqual(['ended', []]); }); @@ -50,15 +50,11 @@ describe('the run store of the host, keeping a run', () => { const ledger = await ledgerOn(file); const history = await Effect.runPromise( - ledger.readRecorded( - { org: 'acme', brain: 'alpha' }, - { kind: 'run', execution: executionId }, - { order: 'asc', limit: 10 }, - ), + ledger.readRecorded({ org: 'acme', brain: 'alpha' }, { kind: 'run', run: runId }, { order: 'asc', limit: 10 }), ); expect(history.records.map(({ stream, type }) => [stream, type])).toEqual([ - [`brain/acme/alpha/runs/${executionId}`, 'input_applied'], + [`brain/acme/alpha/run-logs/${runId}`, 'input_applied'], ]); }); @@ -72,7 +68,7 @@ describe('the run store of the host, keeping a run', () => { ); const messages = await Effect.runPromise(database.read(statement`SELECT COUNT(*) AS messages FROM emt_messages`)); - const stored = await Effect.runPromise(ledgerRunStore(database).load(runIdOf(run))); + const stored = await Effect.runPromise(ledgerRunLogStore(database).load(runKeyOf(run))); expect(chunks).toEqual([ { version: 1, chunk: 0, chunks: 2 }, diff --git a/packages/workflow-host/src/runs/ledger-run-store.ts b/packages/workflow-host/src/runs/ledger-run-store.ts index 75658e15e..93c7b497f 100644 --- a/packages/workflow-host/src/runs/ledger-run-store.ts +++ b/packages/workflow-host/src/runs/ledger-run-store.ts @@ -1,68 +1,73 @@ import { eventAppenderOf, eventCodecOf } from '@beonauto/ledger'; -import { RunEventSchema, type PositionedEvent, type RunEvent, type RunStore } from '@beonauto/workflow-engine'; +import { RunLogEventSchema, type PositionedEvent, type RunLogEvent, type RunLogStore } from '@beonauto/workflow-engine'; import { Effect } from 'effect'; import type { HostDatabase } from '../database/host-database.ts'; import { statement } from '../database/statement.ts'; import { runForgotten } from '../listeners/listener-rows.ts'; -import { streamOfRun } from './run-address.ts'; +import { runLogStreamOf } from './run-address.ts'; import { lineageOfRecord } from './run-lineage.ts'; import { latestSnapshotOf, savedSnapshot } from './snapshot-chunks.ts'; -const codec = eventCodecOf(RunEventSchema); +const codec = eventCodecOf(RunLogEventSchema); -function endsTheRun(event: RunEvent): boolean { +function endsTheRun(event: RunLogEvent): boolean { return event.outputs.some(({ kind }) => kind === 'settle'); } -function positioned(after: number, events: readonly RunEvent[]): readonly PositionedEvent[] { +function positioned(after: number, events: readonly RunLogEvent[]): readonly PositionedEvent[] { return events.map((event, index) => ({ version: after + index + 1, event })); } -function knownRun(database: HostDatabase, runId: string): Effect.Effect { +function knownRun(database: HostDatabase, runKey: string): Effect.Effect { return Effect.orDie( database.write( - statement`INSERT INTO workflow_runs (run_id, stream_id) VALUES (${runId}, ${streamOfRun(runId)}) - ON CONFLICT (run_id) DO NOTHING`, + statement`INSERT INTO workflow_runs (run_key, stream_id) VALUES (${runKey}, ${runLogStreamOf(runKey)}) + ON CONFLICT (run_key) DO NOTHING`, ), ); } -function endedAt(database: HostDatabase, runId: string, version: number): Effect.Effect { +function endedAt(database: HostDatabase, runKey: string, version: number): Effect.Effect { return Effect.orDie( - database.write(statement`UPDATE workflow_runs SET ended_at = ${version} WHERE run_id = ${runId}`), - ).pipe(Effect.andThen(runForgotten(database, runId))); + database.write(statement`UPDATE workflow_runs SET ended_at = ${version} WHERE run_key = ${runKey}`), + ).pipe(Effect.andThen(runForgotten(database, runKey))); } -export function ledgerRunStore(database: HostDatabase): RunStore { - const append = eventAppenderOf(database.store, RunEventSchema); - const streamAfter = (runId: string, version: number) => - Effect.promise(() => database.store.read(streamOfRun(runId), version)); - const eventsAfter = (runId: string, version: number): Effect.Effect => - streamAfter(runId, version).pipe( +export function ledgerRunLogStore(database: HostDatabase): RunLogStore { + const append = eventAppenderOf(database.store, RunLogEventSchema); + const streamAfter = (runKey: string, version: number) => + Effect.promise(() => database.store.read(runLogStreamOf(runKey), version)); + const eventsAfter = (runKey: string, version: number): Effect.Effect => + streamAfter(runKey, version).pipe( Effect.flatMap(({ events }) => Effect.forEach(events, codec.decode)), - Effect.map((events: readonly RunEvent[]) => positioned(version, events)), + Effect.map((events: readonly RunLogEvent[]) => positioned(version, events)), ); return { - load: (runId) => + load: (runKey) => Effect.gen(function* () { - const snapshot = yield* latestSnapshotOf(database, runId); - const tail = yield* eventsAfter(runId, snapshot?.snapshot.version ?? 0); + const snapshot = yield* latestSnapshotOf(database, runKey); + const tail = yield* eventsAfter(runKey, snapshot?.snapshot.version ?? 0); return { snapshot, tail }; }), - append: (runId, event, expectedVersion, lineage) => + append: (runKey, event, expectedVersion, lineage) => Effect.gen(function* () { if (expectedVersion === 0) { - yield* knownRun(database, runId); + yield* knownRun(database, runKey); } - yield* append(streamOfRun(runId), [event], expectedVersion, yield* lineageOfRecord(database, runId, lineage)); + yield* append( + runLogStreamOf(runKey), + [event], + expectedVersion, + yield* lineageOfRecord(database, runKey, lineage), + ); if (endsTheRun(event)) { - yield* endedAt(database, runId, expectedVersion + 1); + yield* endedAt(database, runKey, expectedVersion + 1); } }), eventsAfter, saveSnapshot: (snapshot) => - Effect.flatMap(streamAfter(snapshot.executionId, snapshot.version - 1), ({ events, version }) => + Effect.flatMap(streamAfter(snapshot.runId, snapshot.version - 1), ({ events, version }) => events.length > 0 ? savedSnapshot(database, snapshot) : Effect.die( diff --git a/packages/workflow-host/src/runs/run-address.ts b/packages/workflow-host/src/runs/run-address.ts index bff64886a..0af5e0ce7 100644 --- a/packages/workflow-host/src/runs/run-address.ts +++ b/packages/workflow-host/src/runs/run-address.ts @@ -3,24 +3,24 @@ import { streamPrefixOfBrain } from '@beonauto/operations'; export interface RunAddress { readonly org: string; readonly brain: string; - readonly executionId: string; + readonly runId: string; } -export function runIdOf({ org, brain, executionId }: RunAddress): string { - return `${org}/${brain}/${executionId}`; +export function runKeyOf({ org, brain, runId }: RunAddress): string { + return `${org}/${brain}/${runId}`; } -export function addressOfRun(runId: string): RunAddress { - const afterOrg = runId.indexOf('/') + 1; - const afterBrain = runId.indexOf('/', afterOrg) + 1; +export function addressOfRun(runKey: string): RunAddress { + const afterOrg = runKey.indexOf('/') + 1; + const afterBrain = runKey.indexOf('/', afterOrg) + 1; return { - org: runId.slice(0, afterOrg - 1), - brain: runId.slice(afterOrg, afterBrain - 1), - executionId: runId.slice(afterBrain), + org: runKey.slice(0, afterOrg - 1), + brain: runKey.slice(afterOrg, afterBrain - 1), + runId: runKey.slice(afterBrain), }; } -export function streamOfRun(runId: string): string { - const address = addressOfRun(runId); - return `${streamPrefixOfBrain(address)}runs/${address.executionId}`; +export function runLogStreamOf(runKey: string): string { + const address = addressOfRun(runKey); + return `${streamPrefixOfBrain(address)}run-logs/${address.runId}`; } diff --git a/packages/workflow-host/src/runs/run-lineage.test.ts b/packages/workflow-host/src/runs/run-lineage.test.ts index ad1550551..3db32da18 100644 --- a/packages/workflow-host/src/runs/run-lineage.test.ts +++ b/packages/workflow-host/src/runs/run-lineage.test.ts @@ -7,14 +7,14 @@ import { eventually } from '../testing/eventually.ts'; import { runAt, startOf, workflow } from '../testing/host-documents.ts'; import { aSQLiteFile, openedOn } from '../testing/host-files.ts'; import { hostedOn } from '../testing/host-runs.ts'; -import { streamOfRun, runIdOf } from './run-address.ts'; +import { runLogStreamOf, runKeyOf } from './run-address.ts'; import { lineageOfRecord } from './run-lineage.ts'; -const executionId = '0199a3c4-7d2e-7c1a-9b3f-2f1e0d9c8b7a'; +const runId = '0199a3c4-7d2e-7c1a-9b3f-2f1e0d9c8b7a'; -const run = runAt(executionId); +const run = runAt(runId); -const stream = streamOfRun(runIdOf(run)); +const stream = runLogStreamOf(runKeyOf(run)); const start = '5d0e9f6a-1b2c-5d3e-8f4a-6b7c8d9e0f1a'; @@ -30,7 +30,7 @@ describe('the lineage the host writes with each record of a run', () => { it('is the start it was given for the first, the waiting step for a resumption, and the root as correlation', async () => { const file = aSQLiteFile(); const hosted = await hostedOn({ store: 'sqlite', file }); - hosted.know(executionId); + hosted.know(runId); const document = workflow('do:\n - pause: { wait: PT0.05S }'); await Effect.runPromise(hosted.host.start(run, { ...startOf(document), attributes: given })); await eventually(hosted.settlements, (settled) => settled.size > 0, 400); @@ -39,12 +39,12 @@ describe('the lineage the host writes with each record of a run', () => { { id: messageIdOf(stream, 1), causationId: start, correlationId: 'root' }, { id: messageIdOf(stream, 2), - causationId: stepEventIdOf(executionId, { reference: '/do/0/pause', run: 1, outcome: 'waiting', times: 1 }), + causationId: stepEventIdOf(runId, { reference: '/do/0/pause', run: 1, outcome: 'waiting', times: 1 }), correlationId: 'root', }, ]); - expect(hosted.settledWith().get(executionId)).toEqual({ - causationId: stepEventIdOf(executionId, { reference: '/do/0/pause', run: 1, outcome: 'completed', times: 1 }), + expect(hosted.settledWith().get(runId)).toEqual({ + causationId: stepEventIdOf(runId, { reference: '/do/0/pause', run: 1, outcome: 'completed', times: 1 }), correlationId: 'root', }); }); @@ -58,16 +58,16 @@ describe('the lineage of the fire of a timer', () => { await Effect.runPromise(first.host.start(run, startOf(document))); await first.host.stop(); const second = await hostedOn({ store: 'sqlite', file }); - second.know(executionId); + second.know(runId); await eventually(second.settlements, (settled) => settled.size > 0, 400); expect(await lineagesOf(file)).toEqual([ - { id: messageIdOf(stream, 1), causationId: null, correlationId: executionId }, - { id: messageIdOf(stream, 2), causationId: messageIdOf(stream, 1), correlationId: executionId }, + { id: messageIdOf(stream, 1), causationId: null, correlationId: runId }, + { id: messageIdOf(stream, 2), causationId: messageIdOf(stream, 1), correlationId: runId }, ]); - expect(second.settledWith().get(executionId)).toEqual({ - causationId: stepEventIdOf(executionId, { reference: '/do/0/slow', run: 1, outcome: 'timed_out', times: 1 }), - correlationId: executionId, + expect(second.settledWith().get(runId)).toEqual({ + causationId: stepEventIdOf(runId, { reference: '/do/0/slow', run: 1, outcome: 'timed_out', times: 1 }), + correlationId: runId, }); }); }); @@ -76,7 +76,7 @@ describe('the lineage of a record nothing waited for', () => { it('is nothing for an event no step took, and the record that ended a run with no step settles it', async () => { const file = aSQLiteFile(); const hosted = await hostedOn({ store: 'sqlite', file }); - hosted.know(executionId); + hosted.know(runId); const waiting = { ...startOf(workflow('do:\n - pause: { wait: PT1H }')), attributes: given }; await Effect.runPromise(hosted.host.start(run, waiting)); await Effect.runPromise( @@ -92,7 +92,7 @@ describe('the lineage of a record nothing waited for', () => { { id: messageIdOf(stream, 2), causationId: null, correlationId: 'root' }, 66, ]); - expect(hosted.settledWith().get(executionId)).toEqual({ + expect(hosted.settledWith().get(runId)).toEqual({ causationId: messageIdOf(stream, 66), correlationId: 'root', }); @@ -103,7 +103,7 @@ describe('the lineage of a record nothing waited for', () => { expect( await Effect.runPromise( - lineageOfRecord(database, runIdOf(run), { cause: { kind: 'timer', timerId: '9' }, attributes: given }), + lineageOfRecord(database, runKeyOf(run), { cause: { kind: 'timer', timerId: '9' }, attributes: given }), ), ).toEqual({ causationId: null, correlationId: 'root' }); }); diff --git a/packages/workflow-host/src/runs/run-lineage.ts b/packages/workflow-host/src/runs/run-lineage.ts index 26289639f..1d5082973 100644 --- a/packages/workflow-host/src/runs/run-lineage.ts +++ b/packages/workflow-host/src/runs/run-lineage.ts @@ -4,7 +4,7 @@ import { Effect, Option, Schema } from 'effect'; import type { HostDatabase } from '../database/host-database.ts'; import { armedByOf } from '../timers/sql-timers.ts'; -import { addressOfRun, streamOfRun } from './run-address.ts'; +import { addressOfRun, runLogStreamOf } from './run-address.ts'; const GivenLineageSchema = Schema.Struct({ lineage: Schema.Struct({ start: Schema.String, correlation: Schema.String }), @@ -17,20 +17,20 @@ interface GivenLineage { const decodeGiven = Schema.decodeUnknownOption(GivenLineageSchema); -function givenOf(runId: string, attributes: Schema.JsonObject): GivenLineage { +function givenOf(runKey: string, attributes: Schema.JsonObject): GivenLineage { return Option.match(decodeGiven(attributes), { - onNone: () => ({ start: null, correlation: addressOfRun(runId).executionId }), + onNone: () => ({ start: null, correlation: addressOfRun(runKey).runId }), onSome: ({ lineage }) => lineage, }); } -export function correlationOfRun(runId: string, attributes: Schema.JsonObject): string { - return givenOf(runId, attributes).correlation; +export function correlationOfRun(runKey: string, attributes: Schema.JsonObject): string { + return givenOf(runKey, attributes).correlation; } function causeOfRecord( database: HostDatabase, - runId: string, + runKey: string, { cause }: RecordLineage, given: GivenLineage, ): Effect.Effect { @@ -38,33 +38,37 @@ function causeOfRecord( return Effect.succeed(given.start); } if (cause.kind === 'resumed') { - return Effect.succeed(stepEventIdOf(addressOfRun(runId).executionId, cause.step)); + return Effect.succeed(stepEventIdOf(addressOfRun(runKey).runId, cause.step)); } if (cause.kind === 'given') { return Effect.succeed(cause.id); } return cause.kind === 'timer' - ? Effect.map(armedByOf(database, runId, cause.timerId), (armedBy) => - armedBy === null ? null : messageIdOf(streamOfRun(runId), armedBy), + ? Effect.map(armedByOf(database, runKey, cause.timerId), (armedBy) => + armedBy === null ? null : messageIdOf(runLogStreamOf(runKey), armedBy), ) : Effect.succeed(null); } -export function lineageOfRecord(database: HostDatabase, runId: string, lineage: RecordLineage): Effect.Effect { - const given = givenOf(runId, lineage.attributes); - return Effect.map(causeOfRecord(database, runId, lineage, given), (causationId) => ({ +export function lineageOfRecord( + database: HostDatabase, + runKey: string, + lineage: RecordLineage, +): Effect.Effect { + const given = givenOf(runKey, lineage.attributes); + return Effect.map(causeOfRecord(database, runKey, lineage, given), (causationId) => ({ causationId, correlationId: given.correlation, })); } -export function lineageOfSettlement({ executionId: runId, attributes }: RunContext, origin: OutputOrigin): Lineage { +export function lineageOfSettlement({ runId: runKey, attributes }: RunContext, origin: OutputOrigin): Lineage { const { lastStep, version } = origin; return { causationId: lastStep === null - ? messageIdOf(streamOfRun(runId), version) - : stepEventIdOf(addressOfRun(runId).executionId, lastStep), - correlationId: givenOf(runId, attributes).correlation, + ? messageIdOf(runLogStreamOf(runKey), version) + : stepEventIdOf(addressOfRun(runKey).runId, lastStep), + correlationId: givenOf(runKey, attributes).correlation, }; } diff --git a/packages/workflow-host/src/runs/snapshot-chunks.test.ts b/packages/workflow-host/src/runs/snapshot-chunks.test.ts index 78ccda743..ae4303ff8 100644 --- a/packages/workflow-host/src/runs/snapshot-chunks.test.ts +++ b/packages/workflow-host/src/runs/snapshot-chunks.test.ts @@ -6,8 +6,8 @@ import { statement } from '../database/statement.ts'; import { runAt, startOf, workflow } from '../testing/host-documents.ts'; import { aSQLiteFile, openedOn } from '../testing/host-files.ts'; import { hostedOn } from '../testing/host-runs.ts'; -import { ledgerRunStore } from './ledger-run-store.ts'; -import { runIdOf } from './run-address.ts'; +import { ledgerRunLogStore } from './ledger-run-store.ts'; +import { runKeyOf } from './run-address.ts'; const run = runAt('0199a3c4-7d2e-7c1a-9b3f-2f1e0d9c8b7a'); @@ -27,12 +27,12 @@ describe('the run store of the host, refusing', () => { const database = await openedOn({ store: 'sqlite', file }); await Effect.runPromise( database.write( - statement`INSERT INTO workflow_snapshot_chunks (run_id, version, chunk, chunks, bytes, text) - VALUES (${runIdOf(run)}, ${1}, ${0}, ${1}, ${2}, ${'{}'})`, + statement`INSERT INTO workflow_snapshot_chunks (run_key, version, chunk, chunks, bytes, text) + VALUES (${runKeyOf(run)}, ${1}, ${0}, ${1}, ${2}, ${'{}'})`, ), ); - const defect = await defectOf(ledgerRunStore(database).load(runIdOf(run))); + const defect = await defectOf(ledgerRunLogStore(database).load(runKeyOf(run))); expect(detailOf(defect).detail).toContain('A snapshot of the run does not decode'); }); @@ -42,7 +42,7 @@ describe('the run store of the host, refusing', () => { const hosted = await hostedOn({ store: 'sqlite', file }); await Effect.runPromise(hosted.host.start(run, startOf(waiting))); const state = await Effect.runPromise(hosted.host.stateOf(run)); - const runStore = ledgerRunStore(await openedOn({ store: 'sqlite', file })); + const runStore = ledgerRunLogStore(await openedOn({ store: 'sqlite', file })); const defect = await defectOf(runStore.saveSnapshot(snapshotOf(state, 2))); diff --git a/packages/workflow-host/src/runs/snapshot-chunks.ts b/packages/workflow-host/src/runs/snapshot-chunks.ts index 89e1af13e..ee1502711 100644 --- a/packages/workflow-host/src/runs/snapshot-chunks.ts +++ b/packages/workflow-host/src/runs/snapshot-chunks.ts @@ -22,12 +22,12 @@ function completeVersions(rows: readonly (typeof VersionRow.Type)[]): readonly n return rows.filter(({ present, chunks }) => present === chunks).map(({ version }) => version); } -function latestCompleteVersion(database: HostDatabase, runId: string): Effect.Effect { +function latestCompleteVersion(database: HostDatabase, runKey: string): Effect.Effect { return rowsOf( VersionRow, database.read( statement`SELECT version, COUNT(*) AS present, MAX(chunks) AS chunks - FROM workflow_snapshot_chunks WHERE run_id = ${runId} GROUP BY version`, + FROM workflow_snapshot_chunks WHERE run_key = ${runKey} GROUP BY version`, ), ).pipe(Effect.map((rows) => Math.max(0, ...completeVersions(rows)))); } @@ -40,10 +40,10 @@ function storedSnapshotOf(chunks: readonly Chunk[]): StoredSnapshot { return { snapshot: decoded.success, bytes: chunks.reduce((sum, { bytes }) => sum + bytes, 0) }; } -export function latestSnapshotOf(database: HostDatabase, runId: string): Effect.Effect { +export function latestSnapshotOf(database: HostDatabase, runKey: string): Effect.Effect { return Effect.orDie( Effect.gen(function* () { - const version = yield* latestCompleteVersion(database, runId); + const version = yield* latestCompleteVersion(database, runKey); if (version === 0) { return null; } @@ -51,7 +51,7 @@ export function latestSnapshotOf(database: HostDatabase, runId: string): Effect. ChunkRow, database.read( statement`SELECT version, chunks, bytes, text FROM workflow_snapshot_chunks - WHERE run_id = ${runId} AND version = ${version} ORDER BY chunk`, + WHERE run_key = ${runKey} AND version = ${version} ORDER BY chunk`, ), ); return storedSnapshotOf(chunks); @@ -60,10 +60,10 @@ export function latestSnapshotOf(database: HostDatabase, runId: string): Effect. } export function savedSnapshot(database: HostDatabase, snapshot: Snapshot): Effect.Effect { - const { executionId: runId, version } = snapshot; + const { runId: runKey, version } = snapshot; return Effect.orDie( Effect.gen(function* () { - if ((yield* latestCompleteVersion(database, runId)) >= version) { + if ((yield* latestCompleteVersion(database, runKey)) >= version) { return; } const chunks = snapshotChunks(snapshot); @@ -71,14 +71,14 @@ export function savedSnapshot(database: HostDatabase, snapshot: Snapshot): Effec chunks, (text, chunk) => database.write( - statement`INSERT INTO workflow_snapshot_chunks (run_id, version, chunk, chunks, bytes, text) - VALUES (${runId}, ${version}, ${chunk}, ${chunks.length}, ${utf8.encode(text).byteLength}, ${text}) - ON CONFLICT (run_id, version, chunk) DO NOTHING`, + statement`INSERT INTO workflow_snapshot_chunks (run_key, version, chunk, chunks, bytes, text) + VALUES (${runKey}, ${version}, ${chunk}, ${chunks.length}, ${utf8.encode(text).byteLength}, ${text}) + ON CONFLICT (run_key, version, chunk) DO NOTHING`, ), { discard: true }, ); yield* database.write( - statement`DELETE FROM workflow_snapshot_chunks WHERE run_id = ${runId} AND version <> ${version}`, + statement`DELETE FROM workflow_snapshot_chunks WHERE run_key = ${runKey} AND version <> ${version}`, ); }), ); diff --git a/packages/workflow-host/src/schedules/schedule-firing.test.ts b/packages/workflow-host/src/schedules/schedule-firing.test.ts index 826700e94..edce19c33 100644 --- a/packages/workflow-host/src/schedules/schedule-firing.test.ts +++ b/packages/workflow-host/src/schedules/schedule-firing.test.ts @@ -3,12 +3,18 @@ import { describe, expect, it } from 'vitest'; import { rowsOf } from '../database/host-database.ts'; import { statement } from '../database/statement.ts'; -import { at, cronTrigger, everyTrigger, specRecordAt, specRecorded } from '../reaction-testing/brain-writes.ts'; +import { + at, + cronTrigger, + everyTrigger, + definitionRecordAt, + definitionRecorded, +} from '../reaction-testing/brain-writes.ts'; import { movedClock, type MovedClock } from '../reaction-testing/moved-clock.ts'; import { reactingHost, type ReactingHost } from '../reaction-testing/reacting-host.ts'; import { refusedWhile, type FailingStart } from '../reaction-testing/recorded-reactions.ts'; import { until } from '../reaction-testing/until.ts'; -import { reactionExecutionIdOf } from '../reactions/reaction-ids.ts'; +import { reactionRunIdOf } from '../reactions/reaction-ids.ts'; const activatedAt = Date.parse(at); @@ -28,7 +34,7 @@ function isoAt(minutes: number): string { async function ticking(failure?: FailingStart): Promise { const clock = movedClock(activatedAt + 1000); const reacting = await reactingHost({ clock, ...(failure === undefined ? {} : { failure }) }); - await specRecorded(reacting.database.store, { + await definitionRecorded(reacting.database.store, { name: 'tick', version: 1, triggers: [everyTrigger(aMinute)], @@ -69,10 +75,10 @@ describe('a workflow whose every schedule is due every minute', () => { brain: 'alpha', workflow: 'tick', version: 1, - executionId: reactionExecutionIdOf('tick', 1, '/schedule/every', isoAt(1)), + runId: reactionRunIdOf('tick', 1, '/schedule/every', isoAt(1)), input: { schedule: { due: isoAt(1) } }, depth: 1, - cause: specRecordAt(1), + cause: definitionRecordAt(1), trigger: { kind: 'every', reference: '/schedule/every' }, }, ]); @@ -85,16 +91,16 @@ describe('the due times of a schedule', () => { const { reacting, clock } = await ticking(); clock.moveTo(activatedAt + aMinute); await startsReaching(reacting, 1); - const running = `acme/alpha/${reactionExecutionIdOf('tick', 1, '/schedule/every', isoAt(1))}`; + const running = `acme/alpha/${reactionRunIdOf('tick', 1, '/schedule/every', isoAt(1))}`; await Effect.runPromise( - reacting.database.write(statement`INSERT INTO workflow_runs (run_id, stream_id) VALUES (${running}, ${'s'})`), + reacting.database.write(statement`INSERT INTO workflow_runs (run_key, stream_id) VALUES (${running}, ${'s'})`), ); clock.moveTo(activatedAt + 2 * aMinute); const refusals = await refusalsOf(reacting); await Effect.runPromise( - reacting.database.write(statement`UPDATE workflow_runs SET ended_at = 3 WHERE run_id = ${running}`), + reacting.database.write(statement`UPDATE workflow_runs SET ended_at = 3 WHERE run_key = ${running}`), ); clock.moveTo(activatedAt + 3 * aMinute); const starts = await startsReaching(reacting, 2); @@ -153,7 +159,7 @@ describe('a workflow with a cron schedule', () => { it('is started at the times of its five fields, in UTC', async () => { const clock = movedClock(activatedAt + 1000); const reacting = await reactingHost({ clock }); - await specRecorded(reacting.database.store, { + await definitionRecorded(reacting.database.store, { name: 'nightly', version: 1, triggers: [cronTrigger('30 2 * * *')], diff --git a/packages/workflow-host/src/schedules/schedule-firing.ts b/packages/workflow-host/src/schedules/schedule-firing.ts index cd5e081be..25beba77e 100644 --- a/packages/workflow-host/src/schedules/schedule-firing.ts +++ b/packages/workflow-host/src/schedules/schedule-firing.ts @@ -1,9 +1,9 @@ -import { triggerNamed } from '@beonauto/specs'; +import { triggerNamed } from '@beonauto/definitions'; import { Effect, Schema } from 'effect'; import { rowsOf, WholeNumber, type HostDatabase } from '../database/host-database.ts'; import { statement } from '../database/statement.ts'; -import { reactionExecutionIdOf } from '../reactions/reaction-ids.ts'; +import { reactionRunIdOf } from '../reactions/reaction-ids.ts'; import type { ReactionStart, StartReaction } from '../reactions/reaction-options.ts'; import type { Refusals } from '../reactions/refusals.ts'; import { dueSchedules, nextScheduleDue, scheduleMovedOn, type Schedule } from './schedule-rows.ts'; @@ -52,7 +52,7 @@ function stillRuns(database: HostDatabase, brainKey: string, running: string | n return Effect.orDie( rowsOf( EndedRow, - database.read(statement`SELECT ended_at FROM workflow_runs WHERE run_id = ${`${org}/${brain}/${running}`}`), + database.read(statement`SELECT ended_at FROM workflow_runs WHERE run_key = ${`${org}/${brain}/${running}`}`), ), ).pipe(Effect.map(([row]) => row !== undefined && row.ended_at === null)); } @@ -87,7 +87,7 @@ function startOf(firing: Firing): ReactionStart { ...brainOf(brainKey), workflow, version, - executionId: reactionExecutionIdOf(workflow, version, trigger.reference, due), + runId: reactionRunIdOf(workflow, version, trigger.reference, due), input: { schedule: { due } }, depth: 1, cause: activatedBy, @@ -101,7 +101,7 @@ function ran(parts: FiringParts, firing: Firing) { return parts.start(start).pipe( Effect.andThen( Effect.andThen( - scheduleMovedOn(parts.database, firing.subscription, firing.next, start.executionId), + scheduleMovedOn(parts.database, firing.subscription, firing.next, start.runId), missedSaid(parts, firing), ), ), diff --git a/packages/workflow-host/src/schedules/schedule-rows.ts b/packages/workflow-host/src/schedules/schedule-rows.ts index 5aafaf2c4..d375e9627 100644 --- a/packages/workflow-host/src/schedules/schedule-rows.ts +++ b/packages/workflow-host/src/schedules/schedule-rows.ts @@ -1,4 +1,4 @@ -import { ScheduleTriggerSchema, type ScheduleTrigger } from '@beonauto/specs'; +import { ScheduleTriggerSchema, type ScheduleTrigger } from '@beonauto/definitions'; import { Effect, Schema } from 'effect'; import { rowsOf, WholeNumber, type HostDatabase } from '../database/host-database.ts'; diff --git a/packages/workflow-host/src/schedules/schedule-times.test.ts b/packages/workflow-host/src/schedules/schedule-times.test.ts index de3c6d52b..696956b27 100644 --- a/packages/workflow-host/src/schedules/schedule-times.test.ts +++ b/packages/workflow-host/src/schedules/schedule-times.test.ts @@ -1,4 +1,4 @@ -import type { ScheduleTrigger } from '@beonauto/specs'; +import type { ScheduleTrigger } from '@beonauto/definitions'; import { describe, expect, it } from 'vitest'; import { cronRejectionOf, latestDue, nextAfter } from './schedule-times.ts'; diff --git a/packages/workflow-host/src/schedules/schedule-times.ts b/packages/workflow-host/src/schedules/schedule-times.ts index 831326b28..fcf604500 100644 --- a/packages/workflow-host/src/schedules/schedule-times.ts +++ b/packages/workflow-host/src/schedules/schedule-times.ts @@ -1,4 +1,4 @@ -import type { ScheduleTrigger } from '@beonauto/specs'; +import type { ScheduleTrigger } from '@beonauto/definitions'; import { Cron } from 'croner'; const fieldsOfACron = 5; diff --git a/packages/workflow-host/src/settlement/ledger-record-store.test.ts b/packages/workflow-host/src/settlement/ledger-record-store.test.ts index 5cc9e27e1..2590cbc08 100644 --- a/packages/workflow-host/src/settlement/ledger-record-store.test.ts +++ b/packages/workflow-host/src/settlement/ledger-record-store.test.ts @@ -1,20 +1,20 @@ +import type { SettleRun } from '@beonauto/definitions'; import type { Settlement } from '@beonauto/operations'; -import type { SettleExecution } from '@beonauto/specs'; import { Effect } from 'effect'; import { describe, expect, it } from 'vitest'; import { faultyDatabase } from '../testing/faulty-database.ts'; import { aSQLiteFile, openedOn } from '../testing/host-files.ts'; -import { runId } from '../testing/probe-subjects.ts'; +import { runKey } from '../testing/probe-subjects.ts'; import { ledgerRecordStore } from './ledger-record-store.ts'; const settledBy = { version: 2, lastStep: null }; -const run = { executionId: runId, attributes: {} }; +const run = { runId: runKey, attributes: {} }; const succeeded: Settlement = { status: 'succeeded', output: 'done' }; -const brokeDown: SettleExecution = () => Effect.die(new Error('The ledger broke down')); +const brokeDown: SettleRun = () => Effect.die(new Error('The ledger broke down')); describe('the record store of the host, failing', () => { it('counts a settlement that broke down as an attempt, to be dispatched again', async () => { @@ -25,7 +25,7 @@ describe('the record store of the host, failing', () => { }); const failure = await Effect.runPromise( - Effect.flip(recordStore.settle({ executionId: runId, settlement: { status: 'failed' } }, run, settledBy)), + Effect.flip(recordStore.settle({ runId: runKey, settlement: { status: 'failed' } }, run, settledBy)), ); expect(failure.detail).toContain('The ledger broke down'); @@ -38,8 +38,8 @@ describe('the record store of the host, failing', () => { const failures = await Effect.runPromise( Effect.all([ - Effect.flip(recordStore.settle({ executionId: runId, settlement: succeeded }, run, settledBy)), - Effect.flip(recordStore.noteDue({ executionId: runId, version: 1, nextDueAt: null }, run)), + Effect.flip(recordStore.settle({ runId: runKey, settlement: succeeded }, run, settledBy)), + Effect.flip(recordStore.noteDue({ runId: runKey, version: 1, nextDueAt: null }, run)), ]), ); diff --git a/packages/workflow-host/src/settlement/ledger-record-store.ts b/packages/workflow-host/src/settlement/ledger-record-store.ts index b1298a20f..2762f2550 100644 --- a/packages/workflow-host/src/settlement/ledger-record-store.ts +++ b/packages/workflow-host/src/settlement/ledger-record-store.ts @@ -1,5 +1,5 @@ +import type { SettleRun } from '@beonauto/definitions'; import { NotFound, SettlementSchema, type Lineage, type Settlement } from '@beonauto/operations'; -import type { SettleExecution } from '@beonauto/specs'; import { DispatchFailed, type RecordStore, type SettleReceipt } from '@beonauto/workflow-engine'; import { Cause, Effect, Equal, Option, Predicate, Schema } from 'effect'; @@ -11,7 +11,7 @@ import { lineageOfSettlement } from '../runs/run-lineage.ts'; import { attempted, isBackingOff, settleAttemptsBeforeBackingOff, settleBackOffMs } from './settle-attempts.ts'; export interface RecordStoreParts { - readonly settle: SettleExecution; + readonly settle: SettleRun; readonly note: (note: HostNote) => Effect.Effect; readonly now: () => number; } @@ -30,28 +30,28 @@ const SettlementRow = Schema.Struct({ last_attempt_at: Schema.NullOr(WholeNumber), }); -const RunRow = Schema.Struct({ run_id: Schema.String }); +const RunRow = Schema.Struct({ run_key: Schema.String }); type Receipt = Effect.Effect; interface Settling { readonly database: HostDatabase; readonly parts: RecordStoreParts; - readonly runId: string; + readonly runKey: string; readonly settlement: Settlement; readonly lineage: Lineage; } -function recorded(database: HostDatabase, runId: string, settlement: Settlement): Effect.Effect { +function recorded(database: HostDatabase, runKey: string, settlement: Settlement): Effect.Effect { return Effect.asVoid( Effect.all([ database.write( - statement`INSERT INTO workflow_settlements (run_id, settlement) VALUES (${runId}, ${encodeSettlement(settlement)}) - ON CONFLICT (run_id) DO UPDATE SET settlement = excluded.settlement`, + statement`INSERT INTO workflow_settlements (run_key, settlement) VALUES (${runKey}, ${encodeSettlement(settlement)}) + ON CONFLICT (run_key) DO UPDATE SET settlement = excluded.settlement`, ), database.write( - statement`INSERT INTO workflow_due (run_id, version, next_due_at) VALUES (${runId}, ${settledVersion}, NULL) - ON CONFLICT (run_id) DO UPDATE SET version = excluded.version, next_due_at = NULL`, + statement`INSERT INTO workflow_due (run_key, version, next_due_at) VALUES (${runKey}, ${settledVersion}, NULL) + ON CONFLICT (run_key) DO UPDATE SET version = excluded.version, next_due_at = NULL`, ), ]), ); @@ -63,35 +63,35 @@ function detailOf(failure: unknown): string { : String(failure); } -function failedAttempt({ database, parts, runId }: Settling, detail: string): Receipt { +function failedAttempt({ database, parts, runKey }: Settling, detail: string): Receipt { return Effect.gen(function* () { - const attempts = yield* attempted(database, runId, parts.now()); + const attempts = yield* attempted(database, runKey, parts.now()); if (attempts === settleAttemptsBeforeBackingOff) { - yield* parts.note({ kind: 'settle_backing_off', run: addressOfRun(runId), attempts, detail }); + yield* parts.note({ kind: 'settle_backing_off', run: addressOfRun(runKey), attempts, detail }); } return yield* new DispatchFailed({ output: 'settle', detail }); }); } -function succeeded({ database, parts, runId, settlement }: Settling, attemptsBefore: number): Receipt { +function succeeded({ database, parts, runKey, settlement }: Settling, attemptsBefore: number): Receipt { return Effect.gen(function* () { - yield* recorded(database, runId, settlement); + yield* recorded(database, runKey, settlement); if (attemptsBefore >= settleAttemptsBeforeBackingOff) { - yield* parts.note({ kind: 'settled_after_back_off', run: addressOfRun(runId), attempts: attemptsBefore + 1 }); + yield* parts.note({ kind: 'settled_after_back_off', run: addressOfRun(runKey), attempts: attemptsBefore + 1 }); } return 'recorded' as const; }); } function settledFor(settling: Settling, attemptsBefore: number): Receipt { - const { org, brain, executionId } = addressOfRun(settling.runId); - const execution = { org, brain, id: executionId }; - return settling.parts.settle(execution, settling.settlement, settling.lineage).pipe( + const { org, brain, runId } = addressOfRun(settling.runKey); + const run = { org, brain, id: runId }; + return settling.parts.settle(run, settling.settlement, settling.lineage).pipe( Effect.matchCauseEffect({ onSuccess: () => succeeded(settling, attemptsBefore), onFailure: (cause: Cause.Cause) => Option.exists(Cause.findErrorOption(cause), (error) => error instanceof NotFound) - ? Effect.succeed('unknown_execution' as const) + ? Effect.succeed('unknown_run' as const) : failedAttempt(settling, detailOf(Cause.squash(cause))), }), ); @@ -104,12 +104,12 @@ function asDispatchFailure(failure: unknown): DispatchFailed { } function settledOnce(settling: Settling): Receipt { - const { database, parts, runId, settlement } = settling; + const { database, parts, runKey, settlement } = settling; return Effect.gen(function* () { const [earlier] = yield* rowsOf( SettlementRow, database.read( - statement`SELECT settlement, attempts, last_attempt_at FROM workflow_settlements WHERE run_id = ${runId}`, + statement`SELECT settlement, attempts, last_attempt_at FROM workflow_settlements WHERE run_key = ${runKey}`, ), ); if (earlier !== undefined && earlier.settlement !== null) { @@ -128,15 +128,15 @@ function settledOnce(settling: Settling): Receipt { export function ledgerRecordStore(database: HostDatabase, parts: RecordStoreParts): RecordStore { return { - settle: ({ executionId: runId, settlement }, run, origin) => - settledOnce({ database, parts, runId, settlement, lineage: lineageOfSettlement(run, origin) }).pipe( + settle: ({ runId: runKey, settlement }, run, origin) => + settledOnce({ database, parts, runKey, settlement, lineage: lineageOfSettlement(run, origin) }).pipe( Effect.mapError(asDispatchFailure), ), - noteDue: ({ executionId: runId, version, nextDueAt }) => + noteDue: ({ runId: runKey, version, nextDueAt }) => Effect.asVoid( database.write( - statement`INSERT INTO workflow_due (run_id, version, next_due_at) VALUES (${runId}, ${version}, ${nextDueAt}) - ON CONFLICT (run_id) DO UPDATE SET version = excluded.version, next_due_at = excluded.next_due_at + statement`INSERT INTO workflow_due (run_key, version, next_due_at) VALUES (${runKey}, ${version}, ${nextDueAt}) + ON CONFLICT (run_key) DO UPDATE SET version = excluded.version, next_due_at = excluded.next_due_at WHERE workflow_due.version <= excluded.version`, ), ).pipe( @@ -149,10 +149,10 @@ export function ledgerRecordStore(database: HostDatabase, parts: RecordStorePart rowsOf( RunRow, database.read( - statement`SELECT run_id FROM workflow_due WHERE next_due_at IS NOT NULL AND next_due_at < ${before} + statement`SELECT run_key FROM workflow_due WHERE next_due_at IS NOT NULL AND next_due_at < ${before} ORDER BY next_due_at LIMIT ${mostDueRunsInOneSweep}`, ), ), - ).pipe(Effect.map((rows) => rows.map(({ run_id: runId }) => runId))), + ).pipe(Effect.map((rows) => rows.map(({ run_key: runKey }) => runKey))), }; } diff --git a/packages/workflow-host/src/settlement/settle-attempts.ts b/packages/workflow-host/src/settlement/settle-attempts.ts index 5a779b495..2711f0901 100644 --- a/packages/workflow-host/src/settlement/settle-attempts.ts +++ b/packages/workflow-host/src/settlement/settle-attempts.ts @@ -9,12 +9,12 @@ export const settleBackOffMs = 60_000; const AttemptsRow = Schema.Struct({ attempts: WholeNumber }); -export function attempted(database: HostDatabase, runId: string, at: number): Effect.Effect { +export function attempted(database: HostDatabase, runKey: string, at: number): Effect.Effect { return oneRowOf( AttemptsRow, database.write( - statement`INSERT INTO workflow_settlements (run_id, attempts, last_attempt_at) VALUES (${runId}, 1, ${at}) - ON CONFLICT (run_id) DO UPDATE SET attempts = workflow_settlements.attempts + 1, + statement`INSERT INTO workflow_settlements (run_key, attempts, last_attempt_at) VALUES (${runKey}, 1, ${at}) + ON CONFLICT (run_key) DO UPDATE SET attempts = workflow_settlements.attempts + 1, last_attempt_at = excluded.last_attempt_at RETURNING attempts`, ), @@ -25,11 +25,11 @@ export function isBackingOff(attempts: number, lastAttemptAt: number | null, now return attempts >= settleAttemptsBeforeBackingOff && lastAttemptAt !== null && now - lastAttemptAt < settleBackOffMs; } -export function backOffLifted(database: HostDatabase, runId: string): Effect.Effect { +export function backOffLifted(database: HostDatabase, runKey: string): Effect.Effect { return Effect.asVoid( database.write( statement`UPDATE workflow_settlements SET last_attempt_at = NULL - WHERE run_id = ${runId} AND settlement IS NULL`, + WHERE run_key = ${runKey} AND settlement IS NULL`, ), ); } diff --git a/packages/workflow-host/src/settlement/settle-back-off.test.ts b/packages/workflow-host/src/settlement/settle-back-off.test.ts index 0c394c995..6737cd302 100644 --- a/packages/workflow-host/src/settlement/settle-back-off.test.ts +++ b/packages/workflow-host/src/settlement/settle-back-off.test.ts @@ -1,18 +1,18 @@ +import type { SettleRun } from '@beonauto/definitions'; import { Conflict, type Settlement } from '@beonauto/operations'; -import type { SettleExecution } from '@beonauto/specs'; import { Effect, Result } from 'effect'; import { describe, expect, it } from 'vitest'; import type { HostNote } from '../host/host-reports.ts'; import { addressOfRun } from '../runs/run-address.ts'; import { aSQLiteFile, openedOn } from '../testing/host-files.ts'; -import { runId } from '../testing/probe-subjects.ts'; +import { runKey } from '../testing/probe-subjects.ts'; import { ledgerRecordStore } from './ledger-record-store.ts'; import { settleAttemptsBeforeBackingOff, settleBackOffMs } from './settle-attempts.ts'; const settledBy = { version: 2, lastStep: null }; -const run = { executionId: runId, attributes: {} }; +const run = { runId: runKey, attributes: {} }; const succeeded: Settlement = { status: 'succeeded', output: 'done' }; @@ -20,11 +20,11 @@ const stillRunning = new Conflict({ detail: 'The run executes within the call that started it, so it cannot be settled', }); -const execution = { - execution_id: '0199a3c4-7d2e-7c1a-9b3f-2f1e0d9c8b7a', - primitive: 'orchestration', +const recordedRun = { + run_id: '0199a3c4-7d2e-7c1a-9b3f-2f1e0d9c8b7a', + type: 'workflow', name: 'flow', - spec_version: 1, + definition_version: 1, status: 'succeeded', started_at: '2026-10-05T09:00:00.000Z', started_by: 'acme-admin', @@ -41,10 +41,10 @@ interface Recording { async function recording(): Promise { const state = { now: 0, refuses: true, attempts: 0 }; const notes: HostNote[] = []; - const settle: SettleExecution = () => + const settle: SettleRun = () => Effect.suspend(() => { state.attempts += 1; - return state.refuses ? Effect.fail(stillRunning) : Effect.succeed(execution); + return state.refuses ? Effect.fail(stillRunning) : Effect.succeed(recordedRun); }); const recordStore = ledgerRecordStore(await openedOn({ store: 'sqlite', file: aSQLiteFile() }), { settle, @@ -69,18 +69,16 @@ async function recording(): Promise { function settledOnce(recorded: Recording): Promise> { return Effect.runPromise( - Effect.result(recorded.recordStore.settle({ executionId: runId, settlement: succeeded }, run, settledBy)), + Effect.result(recorded.recordStore.settle({ runId: runKey, settlement: succeeded }, run, settledBy)), ); } function refusedEveryAttemptBeforeBackingOff(recorded: Recording): Promise { - const refused = Effect.flip( - recorded.recordStore.settle({ executionId: runId, settlement: succeeded }, run, settledBy), - ); + const refused = Effect.flip(recorded.recordStore.settle({ runId: runKey, settlement: succeeded }, run, settledBy)); return Effect.runPromise(Effect.forEach(Array.from({ length: settleAttemptsBeforeBackingOff }), () => refused)); } -const address = addressOfRun(runId); +const address = addressOfRun(runKey); describe('the record store of the host, refused', () => { it(`tries again every dispatch, then after ${settleAttemptsBeforeBackingOff} attempts once a minute, saying so once`, async () => { diff --git a/packages/workflow-host/src/sweeps/brain-sweeps.test.ts b/packages/workflow-host/src/sweeps/brain-sweeps.test.ts index 765347117..c485eda4b 100644 --- a/packages/workflow-host/src/sweeps/brain-sweeps.test.ts +++ b/packages/workflow-host/src/sweeps/brain-sweeps.test.ts @@ -74,8 +74,8 @@ describe('the sweeps of the follower', () => { const appended: AppendedStreams = { streams: [ 'brain/acme/alpha/events/', - 'brain/acme/alpha/specs/', - 'brain/acme/beta/runs/', + 'brain/acme/alpha/definitions/', + 'brain/acme/beta/run-logs/', 'org/acme/brains', 'org/acme/keys/k1', 'workflow/elsewhere', diff --git a/packages/workflow-host/src/sweeps/registry-retries.test.ts b/packages/workflow-host/src/sweeps/registry-retries.test.ts index ef4bd194c..934cb25bc 100644 --- a/packages/workflow-host/src/sweeps/registry-retries.test.ts +++ b/packages/workflow-host/src/sweeps/registry-retries.test.ts @@ -2,7 +2,7 @@ import { Effect } from 'effect'; import { describe, expect, it } from 'vitest'; import { DatabaseFailed, type HostDatabase } from '../database/host-database.ts'; -import { brainCreated, eventTrigger, published, specRecorded } from '../reaction-testing/brain-writes.ts'; +import { brainCreated, eventTrigger, published, definitionRecorded } from '../reaction-testing/brain-writes.ts'; import { followerOver } from '../reaction-testing/follower-over.ts'; import { until } from '../reaction-testing/until.ts'; import { onSQLite, openedOn } from '../testing/host-files.ts'; @@ -54,7 +54,7 @@ describe('a brain another process creates, swept before its registry could be re const trigger = eventTrigger({ type: 'com.acme.closed' }); await brainCreated(database.store, 'beta'); - await specRecorded(database.store, { name: 'close', version: 1, triggers: [trigger] }, beta); + await definitionRecorded(database.store, { name: 'close', version: 1, triggers: [trigger] }, beta); await published(database.store, { id: 'first', type: 'com.acme.closed' }, {}, beta); held.release(); const starts = await until( diff --git a/packages/workflow-host/src/sweeps/sweeps-on-a-host.test.ts b/packages/workflow-host/src/sweeps/sweeps-on-a-host.test.ts index 2d1a0d92e..416ddcacb 100644 --- a/packages/workflow-host/src/sweeps/sweeps-on-a-host.test.ts +++ b/packages/workflow-host/src/sweeps/sweeps-on-a-host.test.ts @@ -1,7 +1,7 @@ import { streamSignalOf } from '@beonauto/ledger'; import { describe, expect, it } from 'vitest'; -import { brainCreated, eventTrigger, published, specRecorded } from '../reaction-testing/brain-writes.ts'; +import { brainCreated, eventTrigger, published, definitionRecorded } from '../reaction-testing/brain-writes.ts'; import { reactingHost, type ReactingHost } from '../reaction-testing/reacting-host.ts'; import { until } from '../reaction-testing/until.ts'; import { onSQLite, openedOn } from '../testing/host-files.ts'; @@ -26,7 +26,7 @@ describe('the brains another process writes to, which raises no signal here', () const settings = await onSQLite(); const { store } = await openedOn(settings); await brainCreated(store, 'alpha'); - await specRecorded(store, { name: 'close', version: 1, triggers: [closed] }); + await definitionRecorded(store, { name: 'close', version: 1, triggers: [closed] }); const reacting = await reactingHost({ settings, appended: streamSignalOf(), sweepEveryMs: 20 }); await published(store, { id: 'after', type: 'com.acme.closed' }); @@ -44,7 +44,7 @@ describe('the brains another process writes to, which raises no signal here', () const { store } = reacting.database; await brainCreated(store, 'beta'); - await specRecorded(store, { name: 'close', version: 1, triggers: [closed] }, beta); + await definitionRecorded(store, { name: 'close', version: 1, triggers: [closed] }, beta); await published(store, { id: 'first', type: 'com.acme.closed' }, {}, beta); const starts = await startsReaching(reacting, 1); diff --git a/packages/workflow-host/src/sweeps/wakes.ts b/packages/workflow-host/src/sweeps/wakes.ts index 50b44c8d6..445dcd5fe 100644 --- a/packages/workflow-host/src/sweeps/wakes.ts +++ b/packages/workflow-host/src/sweeps/wakes.ts @@ -16,7 +16,7 @@ export interface Wakes { export function followedBrainOf(stream: string): string | undefined { const brainKey = brainKeyOfStream(stream); - return brainKey === undefined || !brainKey.startsWith('brain/') || stream.startsWith(`${brainKey}runs/`) + return brainKey === undefined || !brainKey.startsWith('brain/') || stream.startsWith(`${brainKey}run-logs/`) ? undefined : brainKey; } diff --git a/packages/workflow-host/src/testing/executor-subject.ts b/packages/workflow-host/src/testing/executor-subject.ts index d49aec4cd..5a8e6279d 100644 --- a/packages/workflow-host/src/testing/executor-subject.ts +++ b/packages/workflow-host/src/testing/executor-subject.ts @@ -5,7 +5,7 @@ import { Deferred, Effect, Function } from 'effect'; import { hostExecutor, type HostExecutor } from '../calls/host-executor.ts'; import type { HostDatabase } from '../database/host-database.ts'; -import { runId } from './probe-subjects.ts'; +import { runKey } from './probe-subjects.ts'; export function executorSubjectOn(database: HostDatabase): ExecutorSubject { const finishers = new Map>(); @@ -36,7 +36,7 @@ export function executorSubjectOn(database: HostDatabase): ExecutorSubject { start: (call, run) => host.current.executor.start(call, run), cancel: (call, run, origin) => host.current.executor.cancel(call, run, origin), }, - run: { executionId: runId, attributes: {} }, + run: { runId: runKey, attributes: {} }, finish: (call, result) => Deferred.succeed(finisherOf(call.key), result), loseHost: () => Effect.andThen( diff --git a/packages/workflow-host/src/testing/host-documents.ts b/packages/workflow-host/src/testing/host-documents.ts index e8ecefabc..c42b65c4b 100644 --- a/packages/workflow-host/src/testing/host-documents.ts +++ b/packages/workflow-host/src/testing/host-documents.ts @@ -15,8 +15,8 @@ export function workflow(source: string): JsonObject { return { document: header, ...decodeObject(parse(source)) }; } -export function runAt(executionId: string): RunAddress { - return { org: 'acme', brain: 'alpha', executionId }; +export function runAt(runId: string): RunAddress { + return { org: 'acme', brain: 'alpha', runId }; } export function startOf(document: JsonObject, input: JsonObject = {}): RunStart { diff --git a/packages/workflow-host/src/testing/host-runs.ts b/packages/workflow-host/src/testing/host-runs.ts index b9fb80eee..cad9ed86c 100644 --- a/packages/workflow-host/src/testing/host-runs.ts +++ b/packages/workflow-host/src/testing/host-runs.ts @@ -17,7 +17,7 @@ export interface HostedRuns { readonly settleAttempts: () => number; readonly troubles: () => readonly string[]; readonly notes: RecordingReports['notes']; - readonly know: (executionId: string) => void; + readonly know: (runId: string) => void; } export interface HostedOptions { diff --git a/packages/workflow-host/src/testing/known-executions.ts b/packages/workflow-host/src/testing/known-runs.ts similarity index 58% rename from packages/workflow-host/src/testing/known-executions.ts rename to packages/workflow-host/src/testing/known-runs.ts index abed7f88b..8b9736e74 100644 --- a/packages/workflow-host/src/testing/known-executions.ts +++ b/packages/workflow-host/src/testing/known-runs.ts @@ -1,12 +1,12 @@ +import type { Run, SettleRun } from '@beonauto/definitions'; import { NotFound } from '@beonauto/operations'; -import type { Run, SettleExecution } from '@beonauto/specs'; import { Effect } from 'effect'; -const settledExecution: Run = { - execution_id: '0199a3c4-7d2e-7c1a-9b3f-2f1e0d9c8b7a', - primitive: 'orchestration', +const settledRun: Run = { + run_id: '0199a3c4-7d2e-7c1a-9b3f-2f1e0d9c8b7a', + type: 'workflow', name: 'flow', - spec_version: 1, + definition_version: 1, status: 'succeeded', output: 'done', started_at: '2026-10-01T09:00:00.000Z', @@ -14,12 +14,12 @@ const settledExecution: Run = { finished_at: '2026-10-01T09:00:01.000Z', }; -export function knownExecutions(): { readonly settle: SettleExecution; readonly know: (id: string) => void } { +export function knownRuns(): { readonly settle: SettleRun; readonly know: (id: string) => void } { const known = new Set(); return { settle: ({ id }) => known.has(id) - ? Effect.succeed(settledExecution) + ? Effect.succeed(settledRun) : Effect.fail(new NotFound({ detail: 'There is no such run in this brain' })), know: (id) => { known.add(id); diff --git a/packages/workflow-host/src/testing/lease-suite.ts b/packages/workflow-host/src/testing/lease-suite.ts index e19c386e3..bdddadcac 100644 --- a/packages/workflow-host/src/testing/lease-suite.ts +++ b/packages/workflow-host/src/testing/lease-suite.ts @@ -11,7 +11,7 @@ import { aSQLiteFile, type SettingsOf } from './host-files.ts'; import { hostIn } from './host-processes.ts'; import { hostedOn } from './host-runs.ts'; -const executionId = '0199a3c4-7d2e-7c1a-9b3f-2f1e0d9c8b7a'; +const runId = '0199a3c4-7d2e-7c1a-9b3f-2f1e0d9c8b7a'; const sweepEveryMs = 400; @@ -34,9 +34,9 @@ export function leaseSuite(settings: SettingsOf): void { const first = hostIn(database, 'hang-on-call', join(aSQLiteFile(), '..', 'settlements.jsonl'), sweepEveryMs); await first.said('calling'); const second = await hostedOn(database, { sweepEveryMs }); - second.know(executionId); + second.know(runId); - const refused = await Effect.runPromise(Effect.flip(second.host.start(runAt(executionId), startOf(listening)))); + const refused = await Effect.runPromise(Effect.flip(second.host.start(runAt(runId), startOf(listening)))); await setTimeout(3 * sweepEveryMs); const performedWhileBothRan = second.calls().length; await first.killed(); @@ -46,7 +46,7 @@ export function leaseSuite(settings: SettingsOf): void { expect(performedWhileBothRan).toBe(0); expect(second.notes().map(({ kind }) => kind)).toEqual(['standing_by', 'took_over']); expect(second.calls()).toHaveLength(1); - expect([...settled.keys()]).toEqual([executionId]); + expect([...settled.keys()]).toEqual([runId]); }, 60_000); it('hands the workflows over at the next sweep when the host serving them stops', async () => { @@ -73,7 +73,7 @@ export function claimSuite(settings: SettingsOf, claimClock: ClaimClock): void { await first.paused(1000); await setTimeout(10 * shortestSweepMs); - const refused = await Effect.runPromise(Effect.flip(second.host.start(runAt(executionId), startOf(listening)))); + const refused = await Effect.runPromise(Effect.flip(second.host.start(runAt(runId), startOf(listening)))); expect(refused).toBeInstanceOf(HostElsewhere); expect(second.notes().map(({ kind }) => kind)).toEqual(['standing_by']); diff --git a/packages/workflow-host/src/testing/long-run-suite.ts b/packages/workflow-host/src/testing/long-run-suite.ts index fc04c2880..afbbe6403 100644 --- a/packages/workflow-host/src/testing/long-run-suite.ts +++ b/packages/workflow-host/src/testing/long-run-suite.ts @@ -3,13 +3,13 @@ import { Effect } from 'effect'; import { expect, it } from 'vitest'; import { skippingClock } from '../loop/host-clock.ts'; -import { ledgerRunStore } from '../runs/ledger-run-store.ts'; +import { ledgerRunLogStore } from '../runs/ledger-run-store.ts'; import { eventually } from './eventually.ts'; import { runAt, startOf, workflow } from './host-documents.ts'; import { openedOn, type SettingsOf } from './host-files.ts'; import { hostedOn } from './host-runs.ts'; -const executionId = '0199a3c4-7d2e-7c1a-9b3f-000000003000'; +const runId = '0199a3c4-7d2e-7c1a-9b3f-000000003000'; const longRunInputs = 130; @@ -26,14 +26,14 @@ export function longRunSuite(settings: SettingsOf): void { it(`runs a loop of ${longRunInputs} inputs of ${paddingCharacters} characters each, and loads it again from its last snapshot and the events after it`, async () => { const database = await settings(); const hosted = await hostedOn(database, { clock: skippingClock(1_790_845_200_000), sweepEveryMs: 3_600_000 }); - hosted.know(executionId); + hosted.know(runId); - await Effect.runPromise(hosted.host.start(runAt(executionId), startOf(ticking))); + await Effect.runPromise(hosted.host.start(runAt(runId), startOf(ticking))); await eventually(hosted.settlements, (settled) => settled.size > 0, 5000); await hosted.host.stop(); - const runStore = ledgerRunStore(await openedOn(database)); - const stored = await Effect.runPromise(runStore.load(`acme/alpha/${executionId}`)); - const events = await Effect.runPromise(runStore.eventsAfter(`acme/alpha/${executionId}`, 0)); + const runStore = ledgerRunLogStore(await openedOn(database)); + const stored = await Effect.runPromise(runStore.load(`acme/alpha/${runId}`)); + const events = await Effect.runPromise(runStore.eventsAfter(`acme/alpha/${runId}`, 0)); const resumed = loadedRunOf(stored); expect(events).toHaveLength(longRunInputs); diff --git a/packages/workflow-host/src/testing/probe-subjects.ts b/packages/workflow-host/src/testing/probe-subjects.ts index e051968d0..ecbb03b28 100644 --- a/packages/workflow-host/src/testing/probe-subjects.ts +++ b/packages/workflow-host/src/testing/probe-subjects.ts @@ -11,14 +11,14 @@ import type { HostDatabase } from '../database/host-database.ts'; import { sqlWatermark } from '../dispatch/sql-watermark.ts'; import { sqlListeners } from '../listeners/sql-listeners.ts'; import { refusalsOn } from '../reactions/refusals.ts'; -import { ledgerRunStore } from '../runs/ledger-run-store.ts'; +import { ledgerRunLogStore } from '../runs/ledger-run-store.ts'; import { ledgerRecordStore } from '../settlement/ledger-record-store.ts'; import { sqlTimers } from '../timers/sql-timers.ts'; -import { knownExecutions } from './known-executions.ts'; +import { knownRuns } from './known-runs.ts'; -export const runId = 'acme/alpha/0199a3c4-7d2e-7c1a-9b3f-2f1e0d9c8b7a'; +export const runKey = 'acme/alpha/0199a3c4-7d2e-7c1a-9b3f-2f1e0d9c8b7a'; -const otherRunId = 'acme/alpha/0199a3c4-7d2e-7c1a-9b3f-2f1e0d9c8b7b'; +const otherRunKey = 'acme/alpha/0199a3c4-7d2e-7c1a-9b3f-2f1e0d9c8b7b'; export const startedAt = 1_790_845_200_000; @@ -27,8 +27,8 @@ export function timerSubjectOn(database: HostDatabase): TimerSubject { const table = sqlTimers(database, Function.constVoid); return { timers: table.timers, - run: { executionId: runId, attributes: {} }, - otherRun: { executionId: otherRunId, attributes: {} }, + run: { runId: runKey, attributes: {} }, + otherRun: { runId: otherRunKey, attributes: {} }, now: () => clock.now, settle: () => Effect.gen(function* () { @@ -41,30 +41,30 @@ export function timerSubjectOn(database: HostDatabase): TimerSubject { } export function recordStoreSubjectOn(database: HostDatabase): RecordStoreSubject { - const { settle, know } = knownExecutions(); + const { settle, know } = knownRuns(); return { recordStore: ledgerRecordStore(database, { settle, note: Effect.logWarning, now: Date.now }), - run: { executionId: runId, attributes: {} }, - know: (executionId) => + run: { runId: runKey, attributes: {} }, + know: (runId) => Effect.sync(() => { - know(executionId.slice(executionId.lastIndexOf('/') + 1)); + know(runId.slice(runId.lastIndexOf('/') + 1)); }), }; } export function watermarkSubjectOn(database: HostDatabase): WatermarkSubject { - return { watermark: sqlWatermark(database), runStore: ledgerRunStore(database), executionId: runId }; + return { watermark: sqlWatermark(database), runStore: ledgerRunLogStore(database), runId: runKey }; } export function runStoreSubjectOn(database: HostDatabase): RunStoreSubject { - return { runStore: ledgerRunStore(database), executionId: runId }; + return { runStore: ledgerRunLogStore(database), runId: runKey }; } export function listenerSubjectOn(database: HostDatabase): ListenerSubject { - const attributes = { spec: { name: 'await-approval', version: 1 }, caller: { id: 'acme-admin' } }; + const attributes = { definition: { name: 'await-approval', version: 1 }, caller: { id: 'acme-admin' } }; return { listeners: sqlListeners(database, refusalsOn(database, Date.now)), - run: { executionId: runId, attributes }, - otherRun: { executionId: otherRunId, attributes }, + run: { runId: runKey, attributes }, + otherRun: { runId: otherRunKey, attributes }, }; } diff --git a/packages/workflow-host/src/testing/recording-reports.ts b/packages/workflow-host/src/testing/recording-reports.ts index 463c110dc..d43d9864f 100644 --- a/packages/workflow-host/src/testing/recording-reports.ts +++ b/packages/workflow-host/src/testing/recording-reports.ts @@ -1,9 +1,9 @@ +import type { SettleRun } from '@beonauto/definitions'; import { Conflict, type Lineage, type Settlement } from '@beonauto/operations'; -import type { SettleExecution } from '@beonauto/specs'; import { Effect } from 'effect'; import type { HostNote, HostReports } from '../host/host-reports.ts'; -import { knownExecutions } from './known-executions.ts'; +import { knownRuns } from './known-runs.ts'; export interface RecordingReports { readonly reports: HostReports; @@ -12,11 +12,11 @@ export interface RecordingReports { } export interface RecordingSettlements { - readonly settle: SettleExecution; + readonly settle: SettleRun; readonly settlements: () => ReadonlyMap; readonly lineages: () => ReadonlyMap; readonly attempts: () => number; - readonly know: (executionId: string) => void; + readonly know: (runId: string) => void; } const ledgerUnreachable = new Conflict({ detail: 'The ledger cannot be reached' }); @@ -29,9 +29,9 @@ export function recordingReports(): RecordingReports { }; return { reports: { - unsettled: ({ executionId, receipt }) => + unsettled: ({ runId, receipt }) => Effect.sync(() => { - noted(`${executionId} ${receipt}`); + noted(`${runId} ${receipt}`); }), trouble: (what) => Effect.sync(() => { @@ -52,7 +52,7 @@ export function recordingSettlements(ledgerDown: () => boolean): RecordingSettle const settled = new Map(); const lineages = new Map(); const counts = { attempts: 0 }; - const { settle, know } = knownExecutions(); + const { settle, know } = knownRuns(); return { settle: (address, settlement, lineage) => Effect.suspend(() => { diff --git a/packages/workflow-host/src/testing/run-suite.ts b/packages/workflow-host/src/testing/run-suite.ts index 7ad60b999..f15a12813 100644 --- a/packages/workflow-host/src/testing/run-suite.ts +++ b/packages/workflow-host/src/testing/run-suite.ts @@ -6,7 +6,7 @@ import { runAt, startOf, workflow } from './host-documents.ts'; import type { SettingsOf } from './host-files.ts'; import { hostedOn } from './host-runs.ts'; -const executionId = '0199a3c4-7d2e-7c1a-9b3f-2f1e0d9c8b7a'; +const runId = '0199a3c4-7d2e-7c1a-9b3f-2f1e0d9c8b7a'; const approval = workflow(` do: @@ -18,10 +18,10 @@ do: const approved = { id: 'approved-1', type: 'com.acme.approved', data: { by: 'grace' } }; export function runSuite(settings: SettingsOf): void { - it('waits, calls a function, takes an event and ends, settling its execution once', async () => { + it('waits, calls a function, takes an event and ends, settling its run once', async () => { const hosted = await hostedOn(await settings()); - hosted.know(executionId); - const run = runAt(executionId); + hosted.know(runId); + const run = runAt(runId); const started = await Effect.runPromise(hosted.host.start(run, startOf(approval))); await eventually(hosted.calls, (calls) => calls.length > 0); @@ -32,7 +32,7 @@ export function runSuite(settings: SettingsOf): void { expect([started, delivered, again, startedAgain]).toEqual(['started', 'delivered', 'ended', 'settled']); expect(hosted.calls()).toEqual([expect.objectContaining({ function: 'notify', arguments: { to: 'ada' } })]); - expect([...settlements]).toEqual([[executionId, { status: 'succeeded', output: [{ by: 'grace' }] }]]); + expect([...settlements]).toEqual([[runId, { status: 'succeeded', output: [{ by: 'grace' }] }]]); expect(hosted.troubles()).toEqual([]); }); } diff --git a/packages/workflow-host/src/timers/sql-timers.test.ts b/packages/workflow-host/src/timers/sql-timers.test.ts index a0e978329..f8187f6c7 100644 --- a/packages/workflow-host/src/timers/sql-timers.test.ts +++ b/packages/workflow-host/src/timers/sql-timers.test.ts @@ -4,15 +4,15 @@ import { describe, expect, it } from 'vitest'; import { faultyDatabase } from '../testing/faulty-database.ts'; import { aSQLiteFile, openedOn } from '../testing/host-files.ts'; -import { runId, startedAt } from '../testing/probe-subjects.ts'; +import { runKey, startedAt } from '../testing/probe-subjects.ts'; import { armedByOf, sqlTimers } from './sql-timers.ts'; -const run = { executionId: runId, attributes: {} }; +const run = { runId: runKey, attributes: {} }; const armedBy = { version: 3, lastStep: null }; function timerDue(timerId: string, dueAt: number): ArmTimer { - return { kind: 'arm_timer', executionId: runId, timerId, dueAt, purpose: 'wait' }; + return { kind: 'arm_timer', runId: runKey, timerId, dueAt, purpose: 'wait' }; } describe('the timers of the host', () => { @@ -40,7 +40,11 @@ describe('the timers of the host, once armed', () => { expect( await Effect.runPromise( - Effect.all([armedByOf(database, runId, '1'), armedByOf(database, runId, '2'), armedByOf(database, runId, '3')]), + Effect.all([ + armedByOf(database, runKey, '1'), + armedByOf(database, runKey, '2'), + armedByOf(database, runKey, '3'), + ]), ), ).toEqual([3, null, null]); }); @@ -49,10 +53,10 @@ describe('the timers of the host, once armed', () => { const table = sqlTimers(await openedOn({ store: 'sqlite', file: aSQLiteFile() }), Function.constVoid); await Effect.runPromise(table.timers.arm(timerDue('1', startedAt), run, armedBy)); - await Effect.runPromise(table.postponed({ runId, timerId: '1' }, startedAt + 50)); + await Effect.runPromise(table.postponed({ runKey, timerId: '1' }, startedAt + 50)); expect(await Effect.runPromise(table.due(startedAt, 10))).toEqual([]); - expect(await Effect.runPromise(table.due(startedAt + 50, 10))).toEqual([{ runId, timerId: '1' }]); + expect(await Effect.runPromise(table.due(startedAt + 50, 10))).toEqual([{ runKey, timerId: '1' }]); }); it('fail an arm, a cancel or a sweep they cannot record, to be dispatched again', async () => { @@ -63,7 +67,7 @@ describe('the timers of the host, once armed', () => { const failures = await Effect.runPromise( Effect.all([ Effect.flip(timers.arm(timerDue('1', startedAt), run, armedBy)), - Effect.flip(timers.cancel({ kind: 'cancel_timer', executionId: runId, timerId: '1' }, run)), + Effect.flip(timers.cancel({ kind: 'cancel_timer', runId: runKey, timerId: '1' }, run)), Effect.flip(timers.sweep(run, [timerDue('1', startedAt)])), ]), ); diff --git a/packages/workflow-host/src/timers/sql-timers.ts b/packages/workflow-host/src/timers/sql-timers.ts index b70952e59..fe378f3fb 100644 --- a/packages/workflow-host/src/timers/sql-timers.ts +++ b/packages/workflow-host/src/timers/sql-timers.ts @@ -12,7 +12,7 @@ import { oneRowOf, rowsOf, WholeNumber, type DatabaseFailed, type HostDatabase } import { statement } from '../database/statement.ts'; export interface DueTimer { - readonly runId: string; + readonly runKey: string; readonly timerId: string; } @@ -28,7 +28,7 @@ type TimerState = 'armed' | 'fired' | 'cancelled'; const StateRow = Schema.Struct({ state: Schema.Literals(['armed', 'fired', 'cancelled']) }); -const DueRow = Schema.Struct({ run_id: Schema.String, timer_id: Schema.String }); +const DueRow = Schema.Struct({ run_key: Schema.String, timer_id: Schema.String }); const NextRow = Schema.Struct({ due: Schema.NullOr(WholeNumber) }); @@ -56,45 +56,47 @@ function changed(rows: Effect.Effect): Effec function inserted( database: HostDatabase, - runId: string, + runKey: string, timer: ArmTimer, armedBy: number | null, ): Effect.Effect { return changed( database.write( - statement`INSERT INTO workflow_timers (run_id, timer_id, state, due_at, armed_by) - VALUES (${runId}, ${timer.timerId}, 'armed', ${timer.dueAt}, ${armedBy}) - ON CONFLICT (run_id, timer_id) DO NOTHING RETURNING state`, + statement`INSERT INTO workflow_timers (run_key, timer_id, state, due_at, armed_by) + VALUES (${runKey}, ${timer.timerId}, 'armed', ${timer.dueAt}, ${armedBy}) + ON CONFLICT (run_key, timer_id) DO NOTHING RETURNING state`, ), ); } -export function armedByOf(database: HostDatabase, runId: string, timerId: string): Effect.Effect { +export function armedByOf(database: HostDatabase, runKey: string, timerId: string): Effect.Effect { return Effect.orDie( rowsOf( ArmedByRows, - database.read(statement`SELECT armed_by FROM workflow_timers WHERE run_id = ${runId} AND timer_id = ${timerId}`), + database.read( + statement`SELECT armed_by FROM workflow_timers WHERE run_key = ${runKey} AND timer_id = ${timerId}`, + ), ), ).pipe(Effect.map((rows) => rows[0]?.armed_by ?? null)); } -function stateOf(database: HostDatabase, runId: string, timerId: string): Effect.Effect { +function stateOf(database: HostDatabase, runKey: string, timerId: string): Effect.Effect { return oneRowOf( StateRow, - database.read(statement`SELECT state FROM workflow_timers WHERE run_id = ${runId} AND timer_id = ${timerId}`), + database.read(statement`SELECT state FROM workflow_timers WHERE run_key = ${runKey} AND timer_id = ${timerId}`), ).pipe(Effect.map(({ state }) => state)); } function cancelledFor( database: HostDatabase, - runId: string, + runKey: string, timerId: string, ): Effect.Effect { return Effect.gen(function* () { const disarmed = yield* changed( database.write( statement`UPDATE workflow_timers SET state = 'cancelled' - WHERE run_id = ${runId} AND timer_id = ${timerId} AND state = 'armed' RETURNING state`, + WHERE run_key = ${runKey} AND timer_id = ${timerId} AND state = 'armed' RETURNING state`, ), ); if (disarmed) { @@ -102,17 +104,17 @@ function cancelledFor( } const tombstoned = yield* changed( database.write( - statement`INSERT INTO workflow_timers (run_id, timer_id, state) VALUES (${runId}, ${timerId}, 'cancelled') - ON CONFLICT (run_id, timer_id) DO NOTHING RETURNING state`, + statement`INSERT INTO workflow_timers (run_key, timer_id, state) VALUES (${runKey}, ${timerId}, 'cancelled') + ON CONFLICT (run_key, timer_id) DO NOTHING RETURNING state`, ), ); - return tombstoned ? 'tombstoned' : cancelReceipts[yield* stateOf(database, runId, timerId)]; + return tombstoned ? 'tombstoned' : cancelReceipts[yield* stateOf(database, runKey, timerId)]; }); } function timerPort(database: HostDatabase, armed: (dueAt: number) => void): Timers { - const armedFor = (runId: string, timer: ArmTimer, armedBy: number | null): Effect.Effect => - Effect.tap(inserted(database, runId, timer, armedBy), (fresh) => + const armedFor = (runKey: string, timer: ArmTimer, armedBy: number | null): Effect.Effect => + Effect.tap(inserted(database, runKey, timer, armedBy), (fresh) => Effect.sync(() => { if (fresh) { armed(timer.dueAt); @@ -122,15 +124,15 @@ function timerPort(database: HostDatabase, armed: (dueAt: number) => void): Time return { arm: (timer, run, origin: OutputOrigin) => Effect.gen(function* () { - if (yield* armedFor(run.executionId, timer, origin.version)) { + if (yield* armedFor(run.runId, timer, origin.version)) { return 'armed'; } - return armReceipts[yield* stateOf(database, run.executionId, timer.timerId)]; + return armReceipts[yield* stateOf(database, run.runId, timer.timerId)]; }).pipe(Effect.mapError(failedTo('arm_timer'))), cancel: (timer, run) => - cancelledFor(database, run.executionId, timer.timerId).pipe(Effect.mapError(failedTo('cancel_timer'))), + cancelledFor(database, run.runId, timer.timerId).pipe(Effect.mapError(failedTo('cancel_timer'))), sweep: (run, timers) => - Effect.forEach(timers, (timer) => armedFor(run.executionId, timer, null)).pipe( + Effect.forEach(timers, (timer) => armedFor(run.runId, timer, null)).pipe( Effect.map((armedAgain: readonly boolean[]) => armedAgain.filter(Boolean).length), Effect.mapError(failedTo('arm_timer')), ), @@ -145,11 +147,11 @@ export function sqlTimers(database: HostDatabase, armed: (dueAt: number) => void rowsOf( DueRow, database.read( - statement`SELECT run_id, timer_id FROM workflow_timers + statement`SELECT run_key, timer_id FROM workflow_timers WHERE state = 'armed' AND due_at <= ${now} ORDER BY due_at LIMIT ${limit}`, ), ), - ).pipe(Effect.map((rows) => rows.map(({ run_id: runId, timer_id: timerId }) => ({ runId, timerId })))), + ).pipe(Effect.map((rows) => rows.map(({ run_key: runKey, timer_id: timerId }) => ({ runKey, timerId })))), nextDueAt: () => Effect.orDie( oneRowOf( @@ -157,21 +159,21 @@ export function sqlTimers(database: HostDatabase, armed: (dueAt: number) => void database.read(statement`SELECT MIN(due_at) AS due FROM workflow_timers WHERE state = 'armed'`), ), ).pipe(Effect.map(({ due }) => due)), - fired: ({ runId, timerId }) => + fired: ({ runKey, timerId }) => Effect.asVoid( Effect.orDie( database.write( statement`UPDATE workflow_timers SET state = 'fired' - WHERE run_id = ${runId} AND timer_id = ${timerId} AND state = 'armed'`, + WHERE run_key = ${runKey} AND timer_id = ${timerId} AND state = 'armed'`, ), ), ), - postponed: ({ runId, timerId }, until) => + postponed: ({ runKey, timerId }, until) => Effect.asVoid( Effect.orDie( database.write( statement`UPDATE workflow_timers SET due_at = ${until} - WHERE run_id = ${runId} AND timer_id = ${timerId} AND state = 'armed'`, + WHERE run_key = ${runKey} AND timer_id = ${timerId} AND state = 'armed'`, ), ), ), diff --git a/packages/workflow-host/src/triggers/spec-records.test.ts b/packages/workflow-host/src/triggers/definition-records.test.ts similarity index 87% rename from packages/workflow-host/src/triggers/spec-records.test.ts rename to packages/workflow-host/src/triggers/definition-records.test.ts index 44df09311..615e8e94a 100644 --- a/packages/workflow-host/src/triggers/spec-records.test.ts +++ b/packages/workflow-host/src/triggers/definition-records.test.ts @@ -1,4 +1,4 @@ -import type { Trigger } from '@beonauto/specs'; +import type { Trigger } from '@beonauto/definitions'; import { Effect, Schema } from 'effect'; import { describe, expect, it } from 'vitest'; @@ -6,7 +6,7 @@ import { rowsOf, WholeNumber, type HostDatabase } from '../database/host-databas import { statement } from '../database/statement.ts'; import { cronTrigger, eventTrigger, everyTrigger } from '../reaction-testing/brain-writes.ts'; import { onSQLite, openedOn } from '../testing/host-files.ts'; -import { specRecordsIn, specRecordsOn, type SpecRecord } from './spec-records.ts'; +import { definitionRecordsIn, definitionRecordsOn, type DefinitionRecord } from './definition-records.ts'; const brainKey = 'brain/acme/alpha/'; @@ -37,13 +37,13 @@ interface Saved { readonly at?: number; } -function saved({ id, name, version, triggers, at = first }: Saved): SpecRecord { +function saved({ id, name, version, triggers, at = first }: Saved): DefinitionRecord { const content = triggers.length === 0 ? { source: 'do: []' } : { source: 'schedule: {}', triggers }; return { id, - type: version === 1 ? 'spec_created' : 'spec_updated', + type: version === 1 ? 'definition_created' : 'definition_updated', data: { - type: version === 1 ? 'spec_created' : 'spec_updated', + type: version === 1 ? 'definition_created' : 'definition_updated', name, version, content, @@ -53,20 +53,20 @@ function saved({ id, name, version, triggers, at = first }: Saved): SpecRecord { }; } -function retired(id: string, name: string): SpecRecord { +function retired(id: string, name: string): DefinitionRecord { return { id, - type: 'spec_retired', - data: { type: 'spec_retired', name, by: 'acme-admin', at: '2026-10-02T00:00:00Z' }, + type: 'definition_retired', + data: { type: 'definition_retired', name, by: 'acme-admin', at: '2026-10-02T00:00:00Z' }, }; } async function applying() { const database = await openedOn(await onSQLite()); - const apply = specRecordsOn(database); + const apply = definitionRecordsOn(database); return { database, - applied: (...records: readonly SpecRecord[]) => + applied: (...records: readonly DefinitionRecord[]) => Effect.runPromise(Effect.forEach(records, (record) => apply(brainKey, record))), }; } @@ -98,7 +98,7 @@ function runningSet(database: HostDatabase, reference: string, running: string) ); } -describe('the records of the specs of a brain, applied to its triggers', () => { +describe('the records of the definitions of a brain, applied to its triggers', () => { it('give a version with triggers a row for each, and take them away at a version without, at its retirement', async () => { const { database, applied } = await applying(); @@ -114,7 +114,7 @@ describe('the records of the specs of a brain, applied to its triggers', () => { saved({ id: 's4', name: 'close', version: 2, triggers: [] }), saved({ id: 's5', name: 'gone', version: 1, triggers: [everyTrigger(aMinute)] }), retired('s6', 'gone'), - { id: 's7', type: 'spec_created', data: { type: 'spec_created', name: 7 } }, + { id: 's7', type: 'definition_created', data: { type: 'definition_created', name: 7 } }, ); const rows = await rowsIn(database); @@ -245,7 +245,7 @@ describe('a new version of a workflow that changes its triggers', () => { }); }); -describe('a record of a spec applied again, as a pass that reads it again does', () => { +describe('a record of a definition applied again, as a pass that reads it again does', () => { it('changes nothing', async () => { const { database, applied } = await applying(); const record = saved({ @@ -264,19 +264,19 @@ describe('a record of a spec applied again, as a pass that reads it again does', }); }); -describe('the records of the specs read back from their stream', () => { +describe('the records of the definitions read back from their stream', () => { it('are each given the id of its record and the type its data names', () => { expect( - specRecordsIn({ + definitionRecordsIn({ version: 2, - events: [{ type: 'spec_created', name: 'close' }, { name: 7 }], + events: [{ type: 'definition_created', name: 'close' }, { name: 7 }], lineages: [ { id: 'm1', causationId: null, correlationId: null }, { id: 'm2', causationId: null, correlationId: null }, ], }), ).toEqual([ - { id: 'm1', type: 'spec_created', data: { type: 'spec_created', name: 'close' } }, + { id: 'm1', type: 'definition_created', data: { type: 'definition_created', name: 'close' } }, { id: 'm2', type: 'unknown', data: { name: 7 } }, ]); }); diff --git a/packages/workflow-host/src/triggers/spec-records.ts b/packages/workflow-host/src/triggers/definition-records.ts similarity index 68% rename from packages/workflow-host/src/triggers/spec-records.ts rename to packages/workflow-host/src/triggers/definition-records.ts index 035a01afe..5775d2a40 100644 --- a/packages/workflow-host/src/triggers/spec-records.ts +++ b/packages/workflow-host/src/triggers/definition-records.ts @@ -1,22 +1,22 @@ +import { definitionChangeOf } from '@beonauto/definitions'; import type { RecordedStream } from '@beonauto/ledger'; import type { RecordedEvent } from '@beonauto/operations'; -import { specChangeOf } from '@beonauto/specs'; import { Effect, Option, Schema } from 'effect'; import type { HostDatabase } from '../database/host-database.ts'; import { triggersActivated, triggersRemoved } from './trigger-rows.ts'; -export type SpecRecord = Pick; +export type DefinitionRecord = Pick; -type SpecApplied = 'applied' | 'unreadable'; +type DefinitionApplied = 'applied' | 'unreadable'; -export type ApplySpecRecord = (brainKey: string, record: SpecRecord) => Effect.Effect; +export type ApplyDefinitionRecord = (brainKey: string, record: DefinitionRecord) => Effect.Effect; const decodeType = Schema.decodeUnknownOption(Schema.Struct({ type: Schema.String })); -export function specRecordsOn(database: HostDatabase): ApplySpecRecord { +export function definitionRecordsOn(database: HostDatabase): ApplyDefinitionRecord { return (brainKey, { id, data }) => { - const change = specChangeOf(data); + const change = definitionChangeOf(data); if (change.kind === 'activated') { const { name: workflow, version, triggers, at } = change; const activation = { workflow, version, triggers, activatedBy: id, activatedAt: Date.parse(at) }; @@ -29,7 +29,7 @@ export function specRecordsOn(database: HostDatabase): ApplySpecRecord { }; } -export function specRecordsIn({ events, lineages }: RecordedStream): readonly SpecRecord[] { +export function definitionRecordsIn({ events, lineages }: RecordedStream): readonly DefinitionRecord[] { return lineages.map(({ id }, index) => { const data = events[index]; const type = Option.getOrElse( diff --git a/packages/workflow-host/src/triggers/several-triggers.test.ts b/packages/workflow-host/src/triggers/several-triggers.test.ts index 340339f47..0d5387b01 100644 --- a/packages/workflow-host/src/triggers/several-triggers.test.ts +++ b/packages/workflow-host/src/triggers/several-triggers.test.ts @@ -12,14 +12,14 @@ import { published, publishedInTurn, runRecorded, - specRecordAt, - specRecorded, + definitionRecordAt, + definitionRecorded, } from '../reaction-testing/brain-writes.ts'; import { startsReaching, untilScheduleRuns } from '../reaction-testing/kept-triggers.ts'; import { movedClock } from '../reaction-testing/moved-clock.ts'; import { reactingHost, type ReactingHost } from '../reaction-testing/reacting-host.ts'; import { until } from '../reaction-testing/until.ts'; -import { reactionExecutionIdOf } from '../reactions/reaction-ids.ts'; +import { reactionRunIdOf } from '../reactions/reaction-ids.ts'; import { mostStartsAMinute } from '../reactions/start-rates.ts'; const activatedAt = Date.parse(at); @@ -39,7 +39,7 @@ async function openedAt(start: number) { } function closingSaved({ database }: ReactingHost, onEvents = closed) { - return specRecorded(database.store, { + return definitionRecorded(database.store, { name: 'close', version: 1, triggers: [onEvents, cronTrigger('30 9 * * *'), everyTrigger(15 * aMinute)], @@ -61,14 +61,14 @@ function closingStartedIn({ database }: ReactingHost, minute: number, starts: nu ); } -function executionIdOf(start: { readonly executionId: string } | undefined): string { - return start?.executionId ?? ''; +function runIdOf(start: { readonly runId: string } | undefined): string { + return start?.runId ?? ''; } describe('a workflow with an event trigger, a cron schedule and an every schedule', () => { it('starts one run for an event and one at each time each schedule is due, each naming its trigger and cause', async () => { const { reacting, clock } = await closingAt(activatedAt + 1000); - const activation = specRecordAt(1); + const activation = definitionRecordAt(1); const event = eventRecordOf('e1'); await published(reacting.database.store, { id: 'e1', type: 'com.acme.closed' }); @@ -78,27 +78,22 @@ describe('a workflow with an event trigger, a cron schedule and an every schedul clock.moveTo(activatedAt + 30 * aMinute); const starts = await startsReaching(reacting.reactions.starts, 4); - expect(starts.map(({ executionId, trigger, cause, version }) => [executionId, trigger, cause, version])).toEqual([ + expect(starts.map(({ runId, trigger, cause, version }) => [runId, trigger, cause, version])).toEqual([ + [reactionRunIdOf('close', 1, '/schedule/on', event), { kind: 'event', reference: '/schedule/on' }, event, 1], [ - reactionExecutionIdOf('close', 1, '/schedule/on', event), - { kind: 'event', reference: '/schedule/on' }, - event, - 1, - ], - [ - reactionExecutionIdOf('close', 1, '/schedule/every', isoAt(15)), + reactionRunIdOf('close', 1, '/schedule/every', isoAt(15)), { kind: 'every', reference: '/schedule/every' }, activation, 1, ], [ - reactionExecutionIdOf('close', 1, '/schedule/cron', isoAt(30)), + reactionRunIdOf('close', 1, '/schedule/cron', isoAt(30)), { kind: 'cron', reference: '/schedule/cron' }, activation, 1, ], [ - reactionExecutionIdOf('close', 1, '/schedule/every', isoAt(30)), + reactionRunIdOf('close', 1, '/schedule/every', isoAt(30)), { kind: 'every', reference: '/schedule/every' }, activation, 1, @@ -127,7 +122,7 @@ describe('a due time of a schedule asked for again', () => { ); const [first, again] = await startsReaching(reacting.reactions.starts, 2); - expect([again?.executionId, again?.input]).toEqual([first?.executionId, first?.input]); + expect([again?.runId, again?.input]).toEqual([first?.runId, first?.input]); }); }); @@ -145,7 +140,7 @@ describe('the start rate of a workflow with several triggers', () => { ]); const starts = await startsReaching(reacting.reactions.starts, 2); const waiting = await until( - () => Effect.runPromise(reacting.database.read(statement`SELECT execution_id FROM workflow_reaction_backlog`)), + () => Effect.runPromise(reacting.database.read(statement`SELECT run_id FROM workflow_reaction_backlog`)), (rows) => rows.length > 0, ); @@ -155,24 +150,24 @@ describe('the start rate of a workflow with several triggers', () => { describe('a workflow whose schedule started a run', () => { it('is not started by its own event trigger for that run, its facts, nor the events it emits', async () => { - const told = eventTrigger({ type: 'com.acme.told' }, { type: 'execution_succeeded' }); + const told = eventTrigger({ type: 'com.acme.told' }, { type: 'run_succeeded' }); const { reacting, clock, starts: started } = await closingAt(activatedAt + 1000, told); - await specRecorded(reacting.database.store, { + await definitionRecorded(reacting.database.store, { name: 'watch', version: 1, triggers: [eventTrigger({ type: 'go' })], }); clock.moveTo(activatedAt + 15 * aMinute); const [scheduled] = await startsReaching(started, 1); - const run = executionIdOf(scheduled); + const run = runIdOf(scheduled); const { store } = reacting.database; - await runRecorded(store, { executionId: run, primitive: 'orchestration', name: 'close' }, 'execution_succeeded'); + await runRecorded(store, { runId: run, type: 'workflow', name: 'close' }, 'run_succeeded'); await published( store, { id: 'told', type: 'com.acme.told' }, { - emitted_by: { execution_id: run, workflow: 'close', version: 1 }, + emitted_by: { run_id: run, workflow: 'close', version: 1 }, depth: 1, }, ); diff --git a/packages/workflow-host/src/triggers/trigger-changes.ts b/packages/workflow-host/src/triggers/trigger-changes.ts index f4e00b4c2..8900f36ac 100644 --- a/packages/workflow-host/src/triggers/trigger-changes.ts +++ b/packages/workflow-host/src/triggers/trigger-changes.ts @@ -1,4 +1,4 @@ -import { TriggerSchema, type Trigger } from '@beonauto/specs'; +import { TriggerSchema, type Trigger } from '@beonauto/definitions'; import { Schema } from 'effect'; export type TriggerChange = diff --git a/packages/workflow-host/src/triggers/trigger-rebuild.test.ts b/packages/workflow-host/src/triggers/trigger-rebuild.test.ts index a5b8d7e8a..23a4facf7 100644 --- a/packages/workflow-host/src/triggers/trigger-rebuild.test.ts +++ b/packages/workflow-host/src/triggers/trigger-rebuild.test.ts @@ -9,8 +9,8 @@ import { eventTrigger, everyTrigger, recorded, - specRecordAt, - specRecorded, + definitionRecordAt, + definitionRecorded, } from '../reaction-testing/brain-writes.ts'; import { triggerRowsOf, untilTriggersAt } from '../reaction-testing/kept-triggers.ts'; import { movedClock } from '../reaction-testing/moved-clock.ts'; @@ -26,33 +26,33 @@ function isoAt(minutes: number): string { return new Date(activatedAt + minutes * aMinute).toISOString(); } -async function specsOfTheBrain(store: EventStore): Promise { +async function definitionsOfTheBrain(store: EventStore): Promise { const closed = eventTrigger({ type: 'com.acme.closed' }); await brainCreated(store, 'alpha'); - await specRecorded(store, { name: 'close', version: 1, triggers: [closed, everyTrigger(15 * aMinute)] }); - await specRecorded(store, { + await definitionRecorded(store, { name: 'close', version: 1, triggers: [closed, everyTrigger(15 * aMinute)] }); + await definitionRecorded(store, { name: 'close', version: 2, triggers: [closed, everyTrigger(15 * aMinute), cronTrigger('0 18 * * *')], when: isoAt(1), }); - await specRecorded(store, { + await definitionRecorded(store, { name: 'close', version: 3, triggers: [eventTrigger({ type: 'com.acme.opened' }), everyTrigger(15 * aMinute)], when: isoAt(2), }); - await specRecorded(store, { name: 'tick', version: 1, triggers: [everyTrigger(aMinute)] }); - await specRecorded(store, { name: 'tick', version: 2, triggers: [everyTrigger(2 * aMinute)], when: isoAt(3) }); + await definitionRecorded(store, { name: 'tick', version: 1, triggers: [everyTrigger(aMinute)] }); + await definitionRecorded(store, { name: 'tick', version: 2, triggers: [everyTrigger(2 * aMinute)], when: isoAt(3) }); } -describe('the triggers of a brain made again from the records of its specs', () => { +describe('the triggers of a brain made again from the records of its definitions', () => { it('are the rows the follower kept as it passed the same records one by one', async () => { const live = await reactingHost({ clock: movedClock(activatedAt + 1000) }); - await specsOfTheBrain(live.database.store); + await definitionsOfTheBrain(live.database.store); await untilTriggersAt(live.database, 'tick', 2); const settings = await onSQLite(); - await specsOfTheBrain((await openedOn(settings)).store); + await definitionsOfTheBrain((await openedOn(settings)).store); const rebuilt = await reactingHost({ settings, clock: movedClock(activatedAt + 1000) }); await untilTriggersAt(rebuilt.database, 'tick', 2); @@ -62,9 +62,9 @@ describe('the triggers of a brain made again from the records of its specs', () expect( rows.map(({ workflow, reference, version, activated_by: by }) => [workflow, reference, version, by]), ).toEqual([ - ['close', '/schedule/every', 3, specRecordAt(1)], - ['close', '/schedule/on', 3, specRecordAt(3)], - ['tick', '/schedule/every', 2, specRecordAt(5)], + ['close', '/schedule/every', 3, definitionRecordAt(1)], + ['close', '/schedule/on', 3, definitionRecordAt(3)], + ['tick', '/schedule/every', 2, definitionRecordAt(5)], ]); }); @@ -72,10 +72,10 @@ describe('the triggers of a brain made again from the records of its specs', () const settings = await onSQLite(); const { store } = await openedOn(settings); await brainCreated(store, 'alpha'); - await recorded(store, `${alpha}specs/orchestration`, { type: 'spec_created', name: 7 }); - const { version } = await store.read(`${alpha}specs/orchestration`); - await store.append(`${alpha}specs/orchestration`, [{ type: 'spec_created', data: { name: 8 } }], version); - await specRecorded(store, { name: 'tick', version: 1, triggers: [everyTrigger(aMinute)] }); + await recorded(store, `${alpha}definitions/workflow`, { type: 'definition_created', name: 7 }); + const { version } = await store.read(`${alpha}definitions/workflow`); + await store.append(`${alpha}definitions/workflow`, [{ type: 'definition_created', data: { name: 8 } }], version); + await definitionRecorded(store, { name: 'tick', version: 1, triggers: [everyTrigger(aMinute)] }); const reacting = await reactingHost({ settings, clock: movedClock(activatedAt + 1000) }); await untilTriggersAt(reacting.database, 'tick', 1); @@ -85,8 +85,14 @@ describe('the triggers of a brain made again from the records of its specs', () ); expect(notes).toEqual([ - { kind: 'record_unreadable', org: 'acme', brain: 'alpha', recordId: specRecordAt(1), type: 'spec_created' }, - { kind: 'record_unreadable', org: 'acme', brain: 'alpha', recordId: specRecordAt(2), type: 'unknown' }, + { + kind: 'record_unreadable', + org: 'acme', + brain: 'alpha', + recordId: definitionRecordAt(1), + type: 'definition_created', + }, + { kind: 'record_unreadable', org: 'acme', brain: 'alpha', recordId: definitionRecordAt(2), type: 'unknown' }, ]); }); }); diff --git a/packages/workflow-host/src/triggers/trigger-rows.ts b/packages/workflow-host/src/triggers/trigger-rows.ts index 905d9e935..f8f5d8074 100644 --- a/packages/workflow-host/src/triggers/trigger-rows.ts +++ b/packages/workflow-host/src/triggers/trigger-rows.ts @@ -1,4 +1,4 @@ -import { EventTriggerSchema, TriggerSchema, type Trigger, type TriggerFilter } from '@beonauto/specs'; +import { EventTriggerSchema, TriggerSchema, type Trigger, type TriggerFilter } from '@beonauto/definitions'; import { Effect, Schema } from 'effect'; import { rowsOf, WholeNumber, type HostDatabase } from '../database/host-database.ts'; diff --git a/packages/workflow-host/src/triggers/trigger-versions.test.ts b/packages/workflow-host/src/triggers/trigger-versions.test.ts index 3d6f71318..7ec653ce9 100644 --- a/packages/workflow-host/src/triggers/trigger-versions.test.ts +++ b/packages/workflow-host/src/triggers/trigger-versions.test.ts @@ -8,9 +8,9 @@ import { eventTrigger, everyTrigger, published, - specRecordAt, - specRecorded, - specRetired, + definitionRecordAt, + definitionRecorded, + definitionRetired, } from '../reaction-testing/brain-writes.ts'; import { refusalsSaid, @@ -36,11 +36,11 @@ function isoAt(minutes: number): string { async function tickingEveryMinute() { const clock = movedClock(activatedAt + 1000); const reacting = await reactingHost({ clock }); - await specRecorded(reacting.database.store, { name: 'tick', version: 1, triggers: [everyTrigger(aMinute)] }); + await definitionRecorded(reacting.database.store, { name: 'tick', version: 1, triggers: [everyTrigger(aMinute)] }); clock.moveTo(activatedAt + aMinute); const [first] = await startsReaching(reacting.reactions.starts, 1); - await runStillGoing(reacting.database, first?.executionId ?? ''); - return { reacting, clock, running: first?.executionId ?? '' }; + await runStillGoing(reacting.database, first?.runId ?? ''); + return { reacting, clock, running: first?.runId ?? '' }; } async function sentinelPassed(reacting: ReactingHost, count: number) { @@ -52,7 +52,7 @@ describe('a version that leaves the triggers of the one before unchanged', () => it('keeps their anchor, their next due time and their running run, and the next run is of the new version', async () => { const { reacting, clock, running } = await tickingEveryMinute(); const { database } = reacting; - await specRecorded(database.store, { + await definitionRecorded(database.store, { name: 'tick', version: 2, triggers: [everyTrigger(aMinute)], @@ -69,7 +69,11 @@ describe('a version that leaves the triggers of the one before unchanged', () => expect(refusals.map(({ reason }) => reason)).toEqual([ `The run its every schedule had due at ${isoAt(2)} was skipped: the run its every schedule started before still runs`, ]); - expect([next?.version, next?.input, next?.cause]).toEqual([2, { schedule: { due: isoAt(3) } }, specRecordAt(1)]); + expect([next?.version, next?.input, next?.cause]).toEqual([ + 2, + { schedule: { due: isoAt(3) } }, + definitionRecordAt(1), + ]); }); }); @@ -77,7 +81,7 @@ describe('a version that changes an every schedule', () => { it('counts it from its own record, and skips while a run the version before started still runs', async () => { const { reacting, clock, running } = await tickingEveryMinute(); const { database } = reacting; - await specRecorded(database.store, { + await definitionRecorded(database.store, { name: 'tick', version: 2, triggers: [everyTrigger(2 * aMinute)], @@ -94,7 +98,11 @@ describe('a version that changes an every schedule', () => { expect(refusals.map(({ reason }) => reason)).toEqual([ `The run its every schedule had due at ${isoAt(3.5)} was skipped: the run its every schedule started before still runs`, ]); - expect([next?.version, next?.input, next?.cause]).toEqual([2, { schedule: { due: isoAt(5.5) } }, specRecordAt(2)]); + expect([next?.version, next?.input, next?.cause]).toEqual([ + 2, + { schedule: { due: isoAt(5.5) } }, + definitionRecordAt(2), + ]); }); }); @@ -102,11 +110,19 @@ describe('a version that changes the event trigger', () => { it('matches nothing recorded before it, while what matched before it starts the version before', async () => { const reacting = await reactingHost(); const { store } = reacting.database; - await specRecorded(store, { name: 'watch', version: 1, triggers: [watching] }); - await specRecorded(store, { name: 'close', version: 1, triggers: [eventTrigger({ type: 'com.acme.closed' })] }); + await definitionRecorded(store, { name: 'watch', version: 1, triggers: [watching] }); + await definitionRecorded(store, { + name: 'close', + version: 1, + triggers: [eventTrigger({ type: 'com.acme.closed' })], + }); await published(store, { id: 'closed-before', type: 'com.acme.closed' }); await published(store, { id: 'opened-before', type: 'com.acme.opened' }); - await specRecorded(store, { name: 'close', version: 2, triggers: [eventTrigger({ type: 'com.acme.opened' })] }); + await definitionRecorded(store, { + name: 'close', + version: 2, + triggers: [eventTrigger({ type: 'com.acme.opened' })], + }); await published(store, { id: 'closed-after', type: 'com.acme.closed' }); await published(store, { id: 'opened-after', type: 'com.acme.opened' }); @@ -125,13 +141,18 @@ describe('a version that removes a cron schedule', () => { const clock = movedClock(activatedAt + 1000); const reacting = await reactingHost({ clock }); const { store } = reacting.database; - await specRecorded(store, { name: 'watch', version: 1, triggers: [watching] }); - await specRecorded(store, { + await definitionRecorded(store, { name: 'watch', version: 1, triggers: [watching] }); + await definitionRecorded(store, { name: 'tick', version: 1, triggers: [cronTrigger('5 9 * * *'), everyTrigger(10 * aMinute)], }); - await specRecorded(store, { name: 'tick', version: 2, triggers: [everyTrigger(10 * aMinute)], when: isoAt(1) }); + await definitionRecorded(store, { + name: 'tick', + version: 2, + triggers: [everyTrigger(10 * aMinute)], + when: isoAt(1), + }); await untilTriggersAt(reacting.database, 'tick', 2); clock.moveTo(activatedAt + 10 * aMinute); @@ -151,11 +172,11 @@ describe('a version without a schedule, and a retirement', () => { const reacting = await reactingHost({ clock }); const { store } = reacting.database; const closed = eventTrigger({ type: 'com.acme.closed' }); - await specRecorded(store, { name: 'watch', version: 1, triggers: [watching] }); - await specRecorded(store, { name: 'close', version: 1, triggers: [closed, everyTrigger(aMinute)] }); - await specRecorded(store, { name: 'open', version: 1, triggers: [closed, cronTrigger('5 9 * * *')] }); - await specRecorded(store, { name: 'close', version: 2, triggers: [] }); - await specRetired(store, 'open'); + await definitionRecorded(store, { name: 'watch', version: 1, triggers: [watching] }); + await definitionRecorded(store, { name: 'close', version: 1, triggers: [closed, everyTrigger(aMinute)] }); + await definitionRecorded(store, { name: 'open', version: 1, triggers: [closed, cronTrigger('5 9 * * *')] }); + await definitionRecorded(store, { name: 'close', version: 2, triggers: [] }); + await definitionRetired(store, 'open'); clock.moveTo(activatedAt + 10 * aMinute); await published(store, { id: 'closed', type: 'com.acme.closed' }); diff --git a/packages/workflow-host/src/views-testing/brain-appends.ts b/packages/workflow-host/src/views-testing/brain-appends.ts index af1935da4..91c8ae010 100644 --- a/packages/workflow-host/src/views-testing/brain-appends.ts +++ b/packages/workflow-host/src/views-testing/brain-appends.ts @@ -41,26 +41,26 @@ function appendOn(store: EventStore, appends: AppendSignal): Append { function ranBy(append: Append): Ran { return async (subject, output, { at = ranAt, brain = alpha } = {}) => { - const [primitive = '', name = ''] = subject.split('/'); + const [type = '', name = ''] = subject.split('/'); const id = randomUUID(); - const definition = { primitive, name, spec_version: 1, by: 'acme-admin', at }; - await append(`${brainKeyOf(brain)}executions/${id}`, [ - { type: 'execution_started', data: { type: 'execution_started', ...definition, input: {} } }, - { type: 'execution_succeeded', data: { type: 'execution_succeeded', ...definition, output, record: {} } }, + const definition = { definition_type: type, name, definition_version: 1, by: 'acme-admin', at }; + await append(`${brainKeyOf(brain)}runs/${id}`, [ + { type: 'run_started', data: { type: 'run_started', ...definition, input: {} } }, + { type: 'run_succeeded', data: { type: 'run_succeeded', ...definition, output, record: {} } }, ]); return id; }; } function savedBy(append: Append): BrainAppends['saved'] { - const specs = new Map(); + const definitions = new Map(); return async (name, details, brain = alpha) => { const key = `${brainKeyOf(brain)}${name}`; - const version = (specs.get(key) ?? 0) + 1; - specs.set(key, version); - const type = version === 1 ? 'spec_created' : 'spec_updated'; + const version = (definitions.get(key) ?? 0) + 1; + definitions.set(key, version); + const type = version === 1 ? 'definition_created' : 'definition_updated'; const content = { source: `the ${name} document, version ${version}`, details }; - await append(`${brainKeyOf(brain)}specs/recollection`, [ + await append(`${brainKeyOf(brain)}definitions/recall`, [ { type, data: { type, name, version, content, by: 'acme-admin', at: savedAt } }, ]); }; @@ -73,18 +73,18 @@ export function brainAppends(store: EventStore, appends: AppendSignal): BrainApp append, saved: savedBy(append), retired: async (name) => { - const event = { type: 'spec_retired', name, by: 'acme-admin', at: savedAt }; - await append(`${brainKeyOf(alpha)}specs/recollection`, [{ type: 'spec_retired', data: event }]); + const event = { type: 'definition_retired', name, by: 'acme-admin', at: savedAt }; + await append(`${brainKeyOf(alpha)}definitions/recall`, [{ type: 'definition_retired', data: event }]); }, ran, ranInOneStream: async (subject, outputs) => { - const [primitive = '', name = ''] = subject.split('/'); - const definition = { primitive, name, spec_version: 1, by: 'acme-admin', at: ranAt }; + const [type = '', name = ''] = subject.split('/'); + const definition = { definition_type: type, name, definition_version: 1, by: 'acme-admin', at: ranAt }; await append( - `${brainKeyOf(alpha)}executions/${randomUUID()}`, + `${brainKeyOf(alpha)}runs/${randomUUID()}`, outputs.map((output) => ({ - type: 'execution_succeeded', - data: { type: 'execution_succeeded', ...definition, output, record: {} }, + type: 'run_succeeded', + data: { type: 'run_succeeded', ...definition, output, record: {} }, })), ); }, diff --git a/packages/workflow-host/src/views-testing/campaign-reviews.ts b/packages/workflow-host/src/views-testing/campaign-reviews.ts index d0c5a13f3..6216c13e5 100644 --- a/packages/workflow-host/src/views-testing/campaign-reviews.ts +++ b/packages/workflow-host/src/views-testing/campaign-reviews.ts @@ -7,7 +7,7 @@ const reviewsFold = [ '| to_entries | sort_by(.value[-1].at) | .[-50:] | from_entries', ].join('\n'); -const reviewFilters = [{ type: 'execution_succeeded', subject: 'inference/review-brief' }]; +const reviewFilters = [{ type: 'run_succeeded', subject: 'reasoning/review-brief' }]; const reviewsSchema = { type: 'object', diff --git a/packages/workflow-host/src/views-testing/folding-suite.ts b/packages/workflow-host/src/views-testing/folding-suite.ts index 93cf7ac4a..a0a8e0118 100644 --- a/packages/workflow-host/src/views-testing/folding-suite.ts +++ b/packages/workflow-host/src/views-testing/folding-suite.ts @@ -14,7 +14,7 @@ import { } from './view-documents.ts'; import { viewHarness } from './view-harness.ts'; -const reviewBrief = 'inference/review-brief'; +const reviewBrief = 'reasoning/review-brief'; const anyText: unknown = expect.any(String); @@ -44,7 +44,7 @@ function reviewsTests(settingsOf: SettingsOf): void { const numbers = await views.ran(reviewBrief, { campaign: 7, verdict: 3 }, { at: '2026-10-06T10:00:03.000Z' }); const oversized = { campaign: 'spring', verdict: 'x'.repeat(300_000) }; const over = await views.ran(reviewBrief, oversized, { at: '2026-10-06T10:00:04.000Z' }); - await views.ran('inference/other', { campaign: 'spring', verdict: 'reject' }, { at: '2026-10-06T10:00:05.000Z' }); + await views.ran('reasoning/other', { campaign: 'spring', verdict: 'reject' }, { at: '2026-10-06T10:00:05.000Z' }); views.start(); const kept = await views.until('reviews', foldedAll(5)); @@ -52,12 +52,12 @@ function reviewsTests(settingsOf: SettingsOf): void { expect(kept).toMatchObject({ name: 'reviews', version: 1, phase: 'live', folded: 5 }); expect(kept.view).toEqual({ unknown: [ - { at: '2026-10-06T10:00:01.000Z', verdict: 'none', run: `/executions/${text}` }, - { at: '2026-10-06T10:00:02.000Z', verdict: 'none', run: `/executions/${array}` }, - { at: '2026-10-06T10:00:03.000Z', verdict: '3', run: `/executions/${numbers}` }, - { at: '2026-10-06T10:00:04.000Z', verdict: 'none', run: `/executions/${over}` }, + { at: '2026-10-06T10:00:01.000Z', verdict: 'none', run: `/runs/${text}` }, + { at: '2026-10-06T10:00:02.000Z', verdict: 'none', run: `/runs/${array}` }, + { at: '2026-10-06T10:00:03.000Z', verdict: '3', run: `/runs/${numbers}` }, + { at: '2026-10-06T10:00:04.000Z', verdict: 'none', run: `/runs/${over}` }, ], - spring: [{ at: '2026-10-06T10:00:00.000Z', verdict: 'approve', run: `/executions/${spring}` }], + spring: [{ at: '2026-10-06T10:00:00.000Z', verdict: 'approve', run: `/runs/${spring}` }], }); expect(JSON.stringify(kept.view)).toMatch(/^\{"spring":.*"unknown":/u); expect(kept.lastEvent?.time).toBe('2026-10-06T10:00:04.000Z'); @@ -68,11 +68,11 @@ function checkpointTests(settingsOf: SettingsOf): void { it('moves its checkpoint to the last record examined, though no record matched its filters', async () => { const views = await viewHarness(await settingsOf()); await views.saved('quiet', detailsOf('. + 1', [{ type: 'com.acme.never' }], { initial: 0 })); - await views.ran('inference/other', 'nothing to fold'); + await views.ran('reasoning/other', 'nothing to fold'); views.start(); const caught = await views.until('quiet', liveFromSomewhere); - await views.ran('inference/other', 'still nothing'); + await views.ran('reasoning/other', 'still nothing'); const moved = await views.until('quiet', ({ checkpoint }) => checkpoint !== caught.checkpoint); expect([caught.view, caught.folded, moved.view, moved.folded]).toEqual([0, 0, 0, 0]); @@ -82,7 +82,7 @@ function checkpointTests(settingsOf: SettingsOf): void { const views = await viewHarness(await settingsOf()); await views.saved('count', collecting); const outputs = Array.from({ length: 2300 }, (_, index) => index); - await views.ranInOneStream('inference/count', outputs); + await views.ranInOneStream('reasoning/count', outputs); views.start(); const kept = await views.until('count', foldedAll(outputs.length)); @@ -93,7 +93,7 @@ function checkpointTests(settingsOf: SettingsOf): void { it('ends a page early once its time is spent, and goes on from there with nothing stalled', async () => { const views = await viewHarness(await settingsOf()); await views.saved('slow', counting); - await views.ranEach('inference/slow', [1, 2, 3, 4]); + await views.ranEach('reasoning/slow', [1, 2, 3, 4]); views.start({ folding: { ...foldingOf(), pageBudgetMs: 0 } }); const kept = await views.until('slow', foldedAll(4)); @@ -108,13 +108,13 @@ function sourceTests(settingsOf: SettingsOf): void { it('never folds the runs of its own recall function, and folds those of another', async () => { const views = await viewHarness(await settingsOf()); const runs = [ - { type: 'execution_succeeded', subject: 'recollection/self' }, - { type: 'execution_succeeded', subject: 'recollection/other' }, + { type: 'run_succeeded', subject: 'recall/self' }, + { type: 'run_succeeded', subject: 'recall/other' }, ]; await views.saved('self', detailsOf('. + 1', runs, { initial: 0 })); - await views.ran('recollection/self', 'its own answer'); - await views.ran('recollection/other', 'an answer of another'); - await views.ran('recollection/self', 'its own answer again'); + await views.ran('recall/self', 'its own answer'); + await views.ran('recall/other', 'an answer of another'); + await views.ran('recall/self', 'its own answer again'); views.start(); const kept = await views.until('self', liveFromSomewhere); @@ -146,8 +146,8 @@ function recordTests(settingsOf: SettingsOf): void { it('gives a fact its message id, its cause and its run', async () => { const views = await viewHarness(await settingsOf()); const fold = '. + [$event | {id, causationid, correlationid}]'; - await views.saved('ids', detailsOf(fold, [{ type: 'execution_succeeded' }], { initial: [] })); - const run = await views.ran('inference/ids', 'answered'); + await views.saved('ids', detailsOf(fold, [{ type: 'run_succeeded' }], { initial: [] })); + const run = await views.ran('reasoning/ids', 'answered'); views.start(); const kept = await views.until('ids', foldedAll(1)); @@ -160,12 +160,10 @@ function recordTests(settingsOf: SettingsOf): void { const views = await viewHarness(await settingsOf()); await views.saved( 'count', - detailsOf('. + 1', [{ type: 'execution_succeeded' }, { type: 'com.acme.deep' }], { initial: 0 }), + detailsOf('. + 1', [{ type: 'run_succeeded' }, { type: 'com.acme.deep' }], { initial: 0 }), ); - await views.ran('inference/count', 'one'); - await views.append('brain/acme/alpha/executions/broken', [ - { type: 'execution_succeeded', data: { type: 'execution_succeeded' } }, - ]); + await views.ran('reasoning/count', 'one'); + await views.append('brain/acme/alpha/runs/broken', [{ type: 'run_succeeded', data: { type: 'run_succeeded' } }]); const deep = Array.from({ length: 600 }).reduce((inner) => [inner], null); await views.published({ specversion: '1.0', @@ -175,7 +173,7 @@ function recordTests(settingsOf: SettingsOf): void { time: '2026-10-01T09:00:00Z', data: deep, }); - await views.ran('inference/count', 'two'); + await views.ran('reasoning/count', 'two'); views.start(); const kept = await views.until('count', foldedAll(2)); diff --git a/packages/workflow-host/src/views-testing/rows-suite.ts b/packages/workflow-host/src/views-testing/rows-suite.ts index f1d3a579c..f95d58429 100644 --- a/packages/workflow-host/src/views-testing/rows-suite.ts +++ b/packages/workflow-host/src/views-testing/rows-suite.ts @@ -25,7 +25,7 @@ function reconciling( ): () => Promise { const parts: Reconciling = { database, - definitionType: 'recollection', + definitionType: 'recall', rebuildsAtOnce, definitions: new Map(), }; @@ -99,14 +99,14 @@ function slotTests(settingsOf: SettingsOf): void { const views = await viewHarness(await settingsOf()); const content = { source: 'a document saved before views were kept' }; const event = { - type: 'spec_created', + type: 'definition_created', name: 'older', version: 1, content, by: 'acme-admin', at: '2026-10-06T09:00:00.000Z', }; - await views.append(`${alphaKey}specs/recollection`, [{ type: 'spec_created', data: event }]); + await views.append(`${alphaKey}definitions/recall`, [{ type: 'definition_created', data: event }]); await views.saved('runs', counting); const rows = await reconciling(views, 4)(); @@ -230,7 +230,7 @@ function racingTests(settingsOf: SettingsOf): void { const views = await viewHarness(await settingsOf()); const elsewhere = reconciling(views, 4); await views.saved('runs', detailsOf('. + 100', succeeded, { initial: 0 })); - await views.ranEach('inference/runs', [1, 2]); + await views.ranEach('reasoning/runs', [1, 2]); const renewedElsewhere = async (): Promise => { await views.saved('runs', counting); await elsewhere(); diff --git a/packages/workflow-host/src/views-testing/sharing-suite.ts b/packages/workflow-host/src/views-testing/sharing-suite.ts index 5b19d2330..f7bfec61d 100644 --- a/packages/workflow-host/src/views-testing/sharing-suite.ts +++ b/packages/workflow-host/src/views-testing/sharing-suite.ts @@ -7,7 +7,7 @@ import { counting, detailsOf, foldedAll, isLive, liveWith, viewTestTimeoutMs } f import { viewHarness, type ViewHarness } from './view-harness.ts'; function countingRunsOf(subject: string) { - return detailsOf('. + 1', [{ type: 'execution_succeeded', subject }], { initial: 0 }); + return detailsOf('. + 1', [{ type: 'run_succeeded', subject }], { initial: 0 }); } async function readsWhile(views: ViewHarness, work: () => Promise): Promise { @@ -21,22 +21,22 @@ async function readsWhile(views: ViewHarness, work: () => Promise): Pro function joiningTests(settingsOf: SettingsOf): void { it('share one read a wake once caught up, and a rebuilding view joins them without folding an event twice', async () => { const views = await viewHarness(await settingsOf()); - await views.saved('a', countingRunsOf('inference/a')); - await views.saved('b', countingRunsOf('inference/b')); - await views.ran('inference/a', 1); - await views.ran('inference/b', 1); + await views.saved('a', countingRunsOf('reasoning/a')); + await views.saved('b', countingRunsOf('reasoning/b')); + await views.ran('reasoning/a', 1); + await views.ran('reasoning/b', 1); views.start({ sweepEveryMs: 60_000 }); await views.until('a', liveWith(1)); await views.until('b', liveWith(1)); const sharedRead = await readsWhile(views, async () => { - await views.ran('inference/a', 2); + await views.ran('reasoning/a', 2); await views.until('a', foldedAll(2)); }); - await views.saved('c', countingRunsOf('inference/a')); + await views.saved('c', countingRunsOf('reasoning/a')); await views.until('c', isLive); const afterJoining = await readsWhile(views, async () => { - await views.ran('inference/b', 2); + await views.ran('reasoning/b', 2); await views.until('b', foldedAll(2)); }); const folded = await Promise.all(['a', 'b', 'c'].map(async (name) => (await views.viewOf(name))?.view)); @@ -51,7 +51,7 @@ function slotTests(settingsOf: SettingsOf): void { const views = await viewHarness(await settingsOf()); await views.saved('live', counting); await views.ranInOneStream( - 'inference/runs', + 'reasoning/runs', Array.from({ length: 1500 }, (_, run) => run), ); views.start({ pagesPerWake: 2, rebuildsAtOnce: 2 }); diff --git a/packages/workflow-host/src/views-testing/stall-suite.ts b/packages/workflow-host/src/views-testing/stall-suite.ts index 0ecd34958..ce180189d 100644 --- a/packages/workflow-host/src/views-testing/stall-suite.ts +++ b/packages/workflow-host/src/views-testing/stall-suite.ts @@ -49,7 +49,7 @@ const stallingFolds: readonly (readonly [string, string, Readonly { - await views.ranEach('inference/runs', ['good', 'bad', 'later']); + await views.ranEach('reasoning/runs', ['good', 'bad', 'later']); } function stoppingTests(settingsOf: SettingsOf): void { @@ -63,7 +63,7 @@ function stoppingTests(settingsOf: SettingsOf): void { const kept = await views.until('runs', isStalled); - expect(kept).toMatchObject({ view: 1, folded: 1, stall: { ...stall, event: { type: 'execution_succeeded' } } }); + expect(kept).toMatchObject({ view: 1, folded: 1, stall: { ...stall, event: { type: 'run_succeeded' } } }); expect(kept.stall?.event.id).toEqual(anyText); expect(kept.stall?.event.time).toBe('2026-10-06T10:00:00.000Z'); }, @@ -92,7 +92,7 @@ function afterTheStallTests(settingsOf: SettingsOf): void { views.start(); await views.until('stalling', isStalled); - await views.ran('inference/runs', 'after the stall'); + await views.ran('reasoning/runs', 'after the stall'); const kept = await views.until('counting', foldedAll(4)); const stalling = await views.viewOf('stalling'); @@ -117,7 +117,7 @@ function lostPageTests(settingsOf: SettingsOf): void { it('keeps what it folded before the event its worker broke on, counting each try there, and then stalls', async () => { const views = await viewHarness(await settingsOf(), { foldWorker: breakingFoldWorker }); await views.saved('outputs', collecting); - await views.ranEach('inference/runs', [1, 2, breaksTheWorker]); + await views.ranEach('reasoning/runs', [1, 2, breaksTheWorker]); views.start({ overtimesBeforeStall: 2, sweepEveryMs: 20 }); const kept = await views.until('outputs', isStalled); @@ -128,7 +128,7 @@ function lostPageTests(settingsOf: SettingsOf): void { stall: { kind: 'crash', message: 'The fold was stopped by its crash 2 times', - event: { type: 'execution_succeeded' }, + event: { type: 'run_succeeded' }, }, }); }); @@ -136,7 +136,7 @@ function lostPageTests(settingsOf: SettingsOf): void { it('keeps every event it folded before the one its worker broke on, when folding up to it ends early', async () => { const views = await viewHarness(await settingsOf(), { foldWorker: breakingFoldWorker }); await views.saved('outputs', collecting); - await views.ranEach('inference/runs', [1, 2, breaksTheWorker]); + await views.ranEach('reasoning/runs', [1, 2, breaksTheWorker]); const pool = secondFoldWithBudget(views.pool, 0); views.start({ pool, overtimesBeforeStall: 2, sweepEveryMs: 20 }); @@ -161,7 +161,7 @@ function neighbourTests(settingsOf: SettingsOf): void { views.start({ folding: { ...foldingOf(), foldDeadlineMs: 1000, pageBudgetMs: 1 }, overtimesBeforeStall: 1 }); await Promise.all(names.map((name) => views.until(name, isLive))); - await views.ran('inference/runs', sleepsBeforeItIsFolded); + await views.ran('reasoning/runs', sleepsBeforeItIsFolded); const kept = await Promise.all(names.map((name) => views.until(name, liveWith(1)))); expect(kept.map(({ phase, view }) => [phase, view])).toEqual(names.map(() => ['live', 1])); diff --git a/packages/workflow-host/src/views-testing/test-fold-workers.ts b/packages/workflow-host/src/views-testing/test-fold-workers.ts index af722c288..e56328621 100644 --- a/packages/workflow-host/src/views-testing/test-fold-workers.ts +++ b/packages/workflow-host/src/views-testing/test-fold-workers.ts @@ -10,7 +10,7 @@ const jobLoop = import.meta.resolve('@beonauto/workflow-engine/job-loop'); const answerers = import.meta.resolve('@beonauto/workflow-engine/worker'); -const schemaChecks = import.meta.resolve('@beonauto/specs/json-schema'); +const schemaChecks = import.meta.resolve('@beonauto/definitions/json-schema'); interface Folding { readonly prelude: readonly string[]; diff --git a/packages/workflow-host/src/views-testing/versions-suite.ts b/packages/workflow-host/src/views-testing/versions-suite.ts index 5bae1d163..21784c6a7 100644 --- a/packages/workflow-host/src/views-testing/versions-suite.ts +++ b/packages/workflow-host/src/views-testing/versions-suite.ts @@ -17,7 +17,7 @@ function rebuildTests(settingsOf: SettingsOf): void { it('rebuild the view from the start of the history when a new version is saved, and drop it when it is retired', async () => { const views = await viewHarness(await settingsOf()); await views.saved('runs', counting); - await views.ranEach('inference/runs', [1, 2]); + await views.ranEach('reasoning/runs', [1, 2]); views.start(); const first = await views.until('runs', foldedAll(2)); @@ -32,7 +32,7 @@ function rebuildTests(settingsOf: SettingsOf): void { it('replace an older version that waits, rebuilds or stalled with the newer one', async () => { const views = await viewHarness(await settingsOf()); await views.saved('runs', detailsOf('error("version one")', succeeded, { initial: 0 })); - await views.ran('inference/runs', 1); + await views.ran('reasoning/runs', 1); views.start(); await views.until('runs', isStalled); @@ -51,9 +51,9 @@ function discoveryTests(settingsOf: SettingsOf): void { const beta = { org: 'acme', brain: 'beta' }; await views.saved('runs', counting); - await views.ran('inference/runs', 1); + await views.ran('reasoning/runs', 1); await views.saved('runs', counting, beta); - await views.ran('inference/runs', 1, { brain: beta }); + await views.ran('reasoning/runs', 1, { brain: beta }); const [inAlpha, inBeta] = await Promise.all([ views.until('runs', foldedAll(1), alpha), views.until('runs', foldedAll(1), beta), diff --git a/packages/workflow-host/src/views-testing/view-documents.ts b/packages/workflow-host/src/views-testing/view-documents.ts index 1b9faafb2..7591f9e98 100644 --- a/packages/workflow-host/src/views-testing/view-documents.ts +++ b/packages/workflow-host/src/views-testing/view-documents.ts @@ -29,7 +29,7 @@ const foldDialect = { variables: ['event'], }; -export const succeeded = [{ type: 'execution_succeeded' }]; +export const succeeded = [{ type: 'run_succeeded' }]; export function detailsOf(fold: string, filters: ViewDetails['filters'], more: Partial = {}): ViewDetails { return { language: 'jq', fold, foldLine: 30, filters, initial: {}, ...more }; @@ -61,7 +61,7 @@ export type ViewSettingsOf = (more?: Partial) => ProjectorSet export function settingsOver(pool: ProgramPool, appends: AppendSignal): ViewSettingsOf { return (more = {}) => ({ - definitionType: 'recollection', + definitionType: 'recall', pool, folding: foldingOf(), brainsAtOnce: 4, diff --git a/packages/workflow-host/src/views/views-port.test.ts b/packages/workflow-host/src/views/views-port.test.ts index 05fd462f5..ab2031c7a 100644 --- a/packages/workflow-host/src/views/views-port.test.ts +++ b/packages/workflow-host/src/views/views-port.test.ts @@ -11,7 +11,7 @@ describe('the port to the views a host keeps', () => { const newestAt = () => Effect.runPromise(views.store.views.newestRecordAt(alpha)); const before = await newestAt(); - await views.ran('inference/runs', 1); + await views.ran('reasoning/runs', 1); const after = await newestAt(); expect(before).toBeUndefined(); diff --git a/packages/workflow-host/src/waiting-testing/crashed-host.ts b/packages/workflow-host/src/waiting-testing/crashed-host.ts index 7cb9f9a6f..66566ba4d 100644 --- a/packages/workflow-host/src/waiting-testing/crashed-host.ts +++ b/packages/workflow-host/src/waiting-testing/crashed-host.ts @@ -27,8 +27,8 @@ export function bareEngineOn(database: HostDatabase): HostEngine { ); } -export async function startedByAHostThatDied(database: HostDatabase, runId: string, start: RunStart): Promise { +export async function startedByAHostThatDied(database: HostDatabase, runKey: string, start: RunStart): Promise { await Effect.runPromise( - bareEngineOn(database).submitted({ ...start, kind: 'started', executionId: runId, at: Date.now() }), + bareEngineOn(database).submitted({ ...start, kind: 'started', runId: runKey, at: Date.now() }), ); } diff --git a/packages/workflow-host/src/waiting-testing/followed-host.ts b/packages/workflow-host/src/waiting-testing/followed-host.ts index 8f078a5ac..dff62b578 100644 --- a/packages/workflow-host/src/waiting-testing/followed-host.ts +++ b/packages/workflow-host/src/waiting-testing/followed-host.ts @@ -12,16 +12,13 @@ import { hostedOn, type HostedOptions, type HostedRuns } from '../testing/host-r export interface FollowedHost { readonly database: HostDatabase; readonly hosted: HostedRuns; - readonly settled: (executionId: string) => Promise; + readonly settled: (runId: string) => Promise; } -export function settledIn( - hosted: HostedRuns, - attempts?: number, -): (executionId: string) => Promise { - return (executionId) => +export function settledIn(hosted: HostedRuns, attempts?: number): (runId: string) => Promise { + return (runId) => until( - () => Promise.resolve(hosted.settlements().get(executionId)), + () => Promise.resolve(hosted.settlements().get(runId)), (settlement) => settlement !== undefined, attempts, ); diff --git a/packages/workflow-host/src/waiting-testing/lost-cancel-suite.ts b/packages/workflow-host/src/waiting-testing/lost-cancel-suite.ts index f5d6cfa2b..15e9a9b27 100644 --- a/packages/workflow-host/src/waiting-testing/lost-cancel-suite.ts +++ b/packages/workflow-host/src/waiting-testing/lost-cancel-suite.ts @@ -11,8 +11,8 @@ import { followedThroughTheLatest, settledIn, untilFollowed } from './followed-h import { askedCancel, lostCancelId, - lostExecutionId, lostRunId, + lostRunKey, lostStart, lostStream, startedThenCancelled, @@ -22,17 +22,17 @@ const aWhile = 30_000; const attempts = 2000; -const ofTheRun = { primitive: 'orchestration', name: 'pause', spec_version: 1, by: 'brain:alpha', at }; +const ofTheRun = { definition_type: 'workflow', name: 'pause', definition_version: 1, by: 'brain:alpha', at }; const rejectedUnstarted = { - type: 'execution_rejected', + type: 'run_rejected', rejection: { reason: 'unavailable', detail: 'The workflow could not be started' }, ...ofTheRun, }; const strandedId = '0199a3c4-7d2e-7c1a-9b3f-0000000000e1'; -const strandedCancel = { type: 'execution_cancel_requested', kind: 'requested', reason: 'Gone', ...ofTheRun }; +const strandedCancel = { type: 'run_cancel_requested', kind: 'requested', reason: 'Gone', ...ofTheRun }; function diedAfterTheStart(settingsOf: SettingsOf): void { describe('a host that died after it started a run and before it read the cancel its follower had passed over', () => { @@ -48,11 +48,11 @@ function diedAfterTheStart(settingsOf: SettingsOf): void { await startedThenCancelled(database); await followedThroughTheLatest(database, attempts); await first.host.stop(); - await startedByAHostThatDied(database, lostRunId, lostStart); + await startedByAHostThatDied(database, lostRunKey, lostStart); const next = await hostedOn(settings); - next.know(lostExecutionId); - const settlement = await settledIn(next, attempts)(lostExecutionId); + next.know(lostRunId); + const settlement = await settledIn(next, attempts)(lostRunId); expect(settlement).toEqual({ status: 'rejected', @@ -79,7 +79,7 @@ function endedWithoutStarting(settingsOf: SettingsOf): void { await untilFollowed(database, attempts); const pending = () => Effect.runPromise(pendingCancelRowsAfter(database, '', 10)); await startedThenCancelled(database); - await recorded(database.store, `${alpha}executions/${strandedId}`, strandedCancel); + await recorded(database.store, `${alpha}runs/${strandedId}`, strandedCancel); const kept = await until(pending, (rows) => rows.length === 2, attempts); await recorded(database.store, lostStream, rejectedUnstarted); await followedThroughTheLatest(database, attempts); @@ -89,13 +89,13 @@ function endedWithoutStarting(settingsOf: SettingsOf): void { await hostedOn(settings); const left = await until(pending, (rows) => rows.length === 1, attempts); - expect(kept.find(({ runId }) => runId === lostRunId)).toEqual({ - runId: lostRunId, + expect(kept.find(({ runKey }) => runKey === lostRunKey)).toEqual({ + runKey: lostRunKey, cause: lostCancelId, cancel: askedCancel, }); expect(keptAfterTheEnding).toHaveLength(2); - expect(left.map(({ runId }) => runId)).toEqual([`acme/alpha/${strandedId}`]); + expect(left.map(({ runKey }) => runKey)).toEqual([`acme/alpha/${strandedId}`]); }, ); }); diff --git a/packages/workflow-host/src/waiting-testing/lost-run.ts b/packages/workflow-host/src/waiting-testing/lost-run.ts index abff257f5..d118f59ee 100644 --- a/packages/workflow-host/src/waiting-testing/lost-run.ts +++ b/packages/workflow-host/src/waiting-testing/lost-run.ts @@ -4,11 +4,11 @@ import type { HostDatabase } from '../database/host-database.ts'; import { alpha, at, recorded } from '../reaction-testing/brain-writes.ts'; import { startOf, workflow } from '../testing/host-documents.ts'; -export const lostExecutionId = '0199a3c4-7d2e-7c1a-9b3f-2f1e0d9c8b7a'; +export const lostRunId = '0199a3c4-7d2e-7c1a-9b3f-2f1e0d9c8b7a'; -export const lostRunId = `acme/alpha/${lostExecutionId}`; +export const lostRunKey = `acme/alpha/${lostRunId}`; -export const lostStream = `${alpha}executions/${lostExecutionId}`; +export const lostStream = `${alpha}runs/${lostRunId}`; export const lostCancelId = messageIdOf(lostStream, 2); @@ -16,17 +16,17 @@ export const lostStart = startOf(workflow('do:\n - pause: { wait: PT1H }')); export const askedCancel = { by: 'acme-admin', kind: 'requested', reason: 'Not needed any more' } as const; -const ofTheRun = { primitive: 'orchestration', name: 'pause', spec_version: 1, by: askedCancel.by, at }; +const ofTheRun = { definition_type: 'workflow', name: 'pause', definition_version: 1, by: askedCancel.by, at }; export async function startedThenCancelled(database: HostDatabase): Promise { await recorded(database.store, lostStream, { - type: 'execution_started', + type: 'run_started', input: {}, finishes_later: true, ...ofTheRun, }); await recorded(database.store, lostStream, { - type: 'execution_cancel_requested', + type: 'run_cancel_requested', kind: askedCancel.kind, reason: askedCancel.reason, ...ofTheRun, diff --git a/packages/workflow-host/src/waiting-testing/recorded-waiting.ts b/packages/workflow-host/src/waiting-testing/recorded-waiting.ts index 74a0c9e44..3eb3e0549 100644 --- a/packages/workflow-host/src/waiting-testing/recorded-waiting.ts +++ b/packages/workflow-host/src/waiting-testing/recorded-waiting.ts @@ -1,11 +1,11 @@ +import type { CancelReceipt, CancelRequest, RunStreamAddress, RunEnding } from '@beonauto/definitions'; import type { CallResult, Lineage } from '@beonauto/operations'; -import type { CancelReceipt, CancelRequest, ExecutionAddress, RunEnding } from '@beonauto/specs'; import { Effect } from 'effect'; import type { WaitingOptions } from '../waiting/waiting-options.ts'; interface RecordedCancel { - readonly execution: ExecutionAddress; + readonly run: RunStreamAddress; readonly request: CancelRequest; readonly lineage: Lineage; } @@ -17,10 +17,10 @@ export interface RecordedWaiting { } export function resultOfEnding(ending: RunEnding): CallResult { - if (ending.type === 'execution_succeeded') { + if (ending.type === 'run_succeeded') { return { status: 'succeeded', output: ending.output }; } - return ending.type === 'execution_rejected' + return ending.type === 'run_rejected' ? { status: 'rejected', reason: ending.rejection.reason, detail: ending.rejection.detail } : { status: 'failed', detail: 'The run broke down' }; } @@ -31,14 +31,14 @@ export function recordedWaiting(receipt: CancelReceipt = 'requested'): RecordedW return { options: { resultOf: resultOfEnding, - cancel: (execution, request, lineage) => + cancel: (run, request, lineage) => Effect.sync(() => { - cancels.push({ execution, request, lineage }); + cancels.push({ run, request, lineage }); return receipt; }), - cancelDeferred: (execution, request, lineage) => + cancelDeferred: (run, request, lineage) => Effect.sync(() => { - deferredCancels.push({ execution, request, lineage }); + deferredCancels.push({ run, request, lineage }); }), }, cancels: () => cancels, diff --git a/packages/workflow-host/src/waiting-testing/resumed-cancels.ts b/packages/workflow-host/src/waiting-testing/resumed-cancels.ts index 083748ab2..1e6d7f3a2 100644 --- a/packages/workflow-host/src/waiting-testing/resumed-cancels.ts +++ b/packages/workflow-host/src/waiting-testing/resumed-cancels.ts @@ -29,22 +29,22 @@ const outcomes: Readonly> = { 'acme/alpha/stranded': 'not_started', }; -async function pendingFor(runIds: readonly string[], database: HostDatabase): Promise { +async function pendingFor(runKeys: readonly string[], database: HostDatabase): Promise { await Effect.runPromise( - Effect.forEach(runIds, (runId) => passedOverRow(database, { runId, cause: `${runId}-asked`, cancel })), + Effect.forEach(runKeys, (runKey) => passedOverRow(database, { runKey, cause: `${runKey}-asked`, cancel })), ); } -export async function resumedWith(settingsOf: SettingsOf, runIds: readonly string[]): Promise { +export async function resumedWith(settingsOf: SettingsOf, runKeys: readonly string[]): Promise { const database = faultyDatabase(await openedOn(await settingsOf())); - await pendingFor(runIds, database); + await pendingFor(runKeys, database); const given: string[] = []; const troubles: string[] = []; const failing = { still: true }; const submitted = (input: RunInput) => Effect.suspend(() => { - given.push(input.executionId); - const outcome = outcomes[input.executionId] ?? 'applied'; + given.push(input.runId); + const outcome = outcomes[input.runId] ?? 'applied'; return outcome === 'conflict' && failing.still ? Effect.fail(new Conflict({ detail: 'The log of the run kept changing' })) : Effect.succeed({ outcome: outcome === 'conflict' ? 'applied' : outcome, version: 2 }); @@ -60,19 +60,19 @@ export async function resumedWith(settingsOf: SettingsOf, runIds: readonly strin troubles: () => troubles, resume: () => Effect.runPromise(cancelsAsked), pending: async () => - (await Effect.runPromise(pendingCancelRowsAfter(database, '', 1000))).map(({ runId }) => runId), + (await Effect.runPromise(pendingCancelRowsAfter(database, '', 1000))).map(({ runKey }) => runKey), failingNoMore: () => { failing.still = false; }, }; } -const ofTheRun = { primitive: 'orchestration', name: 'pause', spec_version: 1, by: 'acme-admin', at }; +const ofTheRun = { definition_type: 'workflow', name: 'pause', definition_version: 1, by: 'acme-admin', at }; -const asked = { type: 'execution_cancel_requested', kind: 'requested', reason: 'Not needed any more', ...ofTheRun }; +const asked = { type: 'run_cancel_requested', kind: 'requested', reason: 'Not needed any more', ...ofTheRun }; const rejectedUnstarted = { - type: 'execution_rejected', + type: 'run_rejected', rejection: { reason: 'unavailable', detail: 'The workflow could not be started' }, ...ofTheRun, }; @@ -81,9 +81,9 @@ export function endedRunSuite(settingsOf: SettingsOf): void { describe('a cancel kept for a run that finished without ever reaching the host', () => { it('is cleared by the newest head of the run, read first, and given to no run', async () => { const resumed = await resumedWith(settingsOf, ['acme/alpha/finished-unstarted', 'acme/alpha/stranded']); - await recorded(resumed.database.store, `${alpha}executions/finished-unstarted`, asked); - await recorded(resumed.database.store, `${alpha}executions/finished-unstarted`, rejectedUnstarted); - await recorded(resumed.database.store, `${alpha}executions/stranded`, asked); + await recorded(resumed.database.store, `${alpha}runs/finished-unstarted`, asked); + await recorded(resumed.database.store, `${alpha}runs/finished-unstarted`, rejectedUnstarted); + await recorded(resumed.database.store, `${alpha}runs/stranded`, asked); await resumed.resume(); diff --git a/packages/workflow-host/src/waiting/cancel-requests.test.ts b/packages/workflow-host/src/waiting/cancel-requests.test.ts index bb7f21e69..3bbf0f271 100644 --- a/packages/workflow-host/src/waiting/cancel-requests.test.ts +++ b/packages/workflow-host/src/waiting/cancel-requests.test.ts @@ -10,32 +10,32 @@ import { followedHost } from '../waiting-testing/followed-host.ts'; import { recordedWaiting } from '../waiting-testing/recorded-waiting.ts'; import { cancelRequests } from './cancel-requests.ts'; -const executionId = '0199a3c4-7d2e-7c1a-9b3f-2f1e0d9c8b7a'; +const runId = '0199a3c4-7d2e-7c1a-9b3f-2f1e0d9c8b7a'; const pausing = workflow('do:\n - pause: { wait: PT1H }'); -function askedOf(primitive: string) { +function askedOf(type: string) { return { - type: 'execution_cancel_requested', + type: 'run_cancel_requested', kind: 'requested', reason: 'Not needed any more', - primitive, + definition_type: type, name: 'pause', - spec_version: 1, + definition_version: 1, by: 'acme-admin', at, }; } -const requestStream = `${alpha}executions/${executionId}`; +const requestStream = `${alpha}runs/${runId}`; const requestId = messageIdOf(requestStream, 1); const started = { - type: 'execution_started', - primitive: 'orchestration', + type: 'run_started', + definition_type: 'workflow', name: 'pause', - spec_version: 1, + definition_version: 1, input: {}, by: 'acme-admin', at, @@ -52,24 +52,24 @@ const cancelled = { describe('a cancel request on a workflow run', () => { it('is given to the run, which ends cancelled by who asked, its record caused by the request', async () => { const { database, hosted, settled } = await followedHost(); - hosted.know(executionId); - await Effect.runPromise(hosted.host.start(runAt(executionId), startOf(pausing))); + hosted.know(runId); + await Effect.runPromise(hosted.host.start(runAt(runId), startOf(pausing))); - await recorded(database.store, requestStream, askedOf('orchestration'), { + await recorded(database.store, requestStream, askedOf('workflow'), { causationId: null, - correlationId: executionId, + correlationId: runId, }); - const settlement = await settled(executionId); + const settlement = await settled(runId); const { records } = await Effect.runPromise( recordedReaderOf(database.store)( { org: 'acme', brain: 'alpha' }, - { kind: 'run', execution: executionId }, + { kind: 'run', run: runId }, { order: 'desc', limit: 1, dataOf: [] }, ), ); expect(settlement).toEqual(cancelled); - expect(records[0]).toMatchObject({ stream: `${alpha}runs/${executionId}`, causationId: requestId }); + expect(records[0]).toMatchObject({ stream: `${alpha}run-logs/${runId}`, causationId: requestId }); }); }); @@ -86,7 +86,7 @@ describe('a cancel request on a run of another capability', () => { expect(handed).toEqual([ { - execution: { org: 'acme', brain: 'alpha', id: executionId }, + run: { org: 'acme', brain: 'alpha', id: runId }, request: { kind: 'requested', reason: 'Not needed any more', by: 'acme-admin' }, lineage: { causationId: requestId, correlationId: 'root-1' }, }, @@ -97,24 +97,24 @@ describe('a cancel request on a run of another capability', () => { describe('a cancel request recorded before the host started the workflow', () => { it('is passed over by the follower, and the start of the run finds it and ends the run cancelled at once', async () => { const { database, hosted, settled } = await followedHost(); - hosted.know(executionId); + hosted.know(runId); await recorded(database.store, requestStream, started); - await recorded(database.store, requestStream, { ...askedOf('orchestration'), at }); + await recorded(database.store, requestStream, { ...askedOf('workflow'), at }); const other = '0199a3c4-7d2e-7c1a-9b3f-2f1e0d9c8b7b'; hosted.know(other); await Effect.runPromise(hosted.host.start(runAt(other), startOf(pausing))); - await recorded(database.store, `${alpha}executions/${other}`, askedOf('orchestration')); + await recorded(database.store, `${alpha}runs/${other}`, askedOf('workflow')); const otherSettled = await settled(other); - const answer = await Effect.runPromise(hosted.host.start(runAt(executionId), startOf(pausing))); - const settlement = await settled(executionId); + const answer = await Effect.runPromise(hosted.host.start(runAt(runId), startOf(pausing))); + const settlement = await settled(runId); expect([otherSettled, answer, settlement]).toEqual([cancelled, 'started', cancelled]); expect(hosted.troubles()).toEqual([]); }); }); -function asked(data: unknown, type = 'execution_cancel_requested') { +function asked(data: unknown, type = 'run_cancel_requested') { return { brain: { org: 'acme', brain: 'alpha' }, brainKey: alpha, @@ -123,7 +123,7 @@ function asked(data: unknown, type = 'execution_cancel_requested') { cursor: 'c', causationId: null, correlationId: null, - stream: `executions/${executionId}`, + stream: `runs/${runId}`, version: 1, type, data, @@ -138,18 +138,18 @@ async function failingConsumer() { database, submitted: () => Effect.fail(new Conflict({ detail: 'The log of the run kept changing' })), cancelDeferred: () => Effect.void, - workflows: 'orchestration', + workflows: 'workflow', now: Date.now, }); } const endingOfAnotherCapability = { - type: 'execution_succeeded', + type: 'run_succeeded', output: null, record: {}, - primitive: 'interaction', + definition_type: 'interaction', name: 'ask', - spec_version: 1, + definition_version: 1, by: 'brain:alpha', at, }; @@ -157,7 +157,7 @@ const endingOfAnotherCapability = { describe('the consumer of cancel requests', () => { it('never skips a request, and fails a delivery the run cannot take now, to be made again', async () => { const consumer = await failingConsumer(); - const { deliveries } = await Effect.runPromise(consumer.batchOf(asked(askedOf('orchestration')), undefined, 100)); + const { deliveries } = await Effect.runPromise(consumer.batchOf(asked(askedOf('workflow')), undefined, 100)); const failure = await Effect.runPromise(Effect.flip(Effect.forEach(deliveries, ({ deliver }) => deliver))); const skipped = consumer.skipped(asked({}), { key: 'cancel', workflow: 'pause', deliver: Effect.void }, ''); @@ -168,7 +168,7 @@ describe('the consumer of cancel requests', () => { it('makes no delivery of a cancel before a start, nor of a record that is no request or ends another capability’s run', async () => { const consumer = await failingConsumer(); const beforeTheStart = { - type: 'execution_cancel_requested', + type: 'run_cancel_requested', kind: 'requested', reason: 'Gone', by: 'brain:alpha', @@ -180,9 +180,9 @@ describe('the consumer of cancel requests', () => { ), ); const ending = await Effect.runPromise( - consumer.batchOf(asked(endingOfAnotherCapability, 'execution_succeeded'), undefined, 100), + consumer.batchOf(asked(endingOfAnotherCapability, 'run_succeeded'), undefined, 100), ); - const resumed = await Effect.runPromise(consumer.batchOf(asked(askedOf('orchestration')), 'cancel', 100)); + const resumed = await Effect.runPromise(consumer.batchOf(asked(askedOf('workflow')), 'cancel', 100)); expect([...batches, ending, resumed].map(({ deliveries }) => deliveries)).toEqual([[], [], [], []]); }); diff --git a/packages/workflow-host/src/waiting/cancel-requests.ts b/packages/workflow-host/src/waiting/cancel-requests.ts index f87289046..dde4ac2f9 100644 --- a/packages/workflow-host/src/waiting/cancel-requests.ts +++ b/packages/workflow-host/src/waiting/cancel-requests.ts @@ -1,11 +1,11 @@ +import { cancelRequestOf, type CancelRequested } from '@beonauto/definitions'; import type { BrainAddress, Conflict, Lineage, RecordedEvent } from '@beonauto/operations'; -import { cancelRequestOf, type CancelRequested } from '@beonauto/specs'; import type { RunInput, Submission } from '@beonauto/workflow-engine'; import { Effect } from 'effect'; import type { HostDatabase } from '../database/host-database.ts'; import { DeliveryFailed, type CallConsumer, type CallRecord, type Delivery } from '../follower/consumers.ts'; -import { runIdOf } from '../runs/run-address.ts'; +import { runKeyOf } from '../runs/run-address.ts'; import { passedOverRow } from './pending-cancel-rows.ts'; import type { WaitingOptions } from './waiting-options.ts'; @@ -21,8 +21,8 @@ interface Asked { readonly brain: BrainAddress; readonly record: RecordedEvent; readonly request: CancelRequested; - readonly primitive: string; - readonly executionId: string; + readonly type: string; + readonly runId: string; } function failedWith({ detail }: Readonly<{ detail: string }>): DeliveryFailed { @@ -33,38 +33,38 @@ function lineageOf({ id, correlationId }: RecordedEvent): Lineage { return { causationId: id, correlationId }; } -function cancelledWorkflow(parts: CancelParts, { brain, record, request, executionId }: Asked) { +function cancelledWorkflow(parts: CancelParts, { brain, record, request, runId }: Asked) { const { kind, reason, by } = request; - const runId = runIdOf({ ...brain, executionId }); - const pending = { runId, cause: record.id, cancel: { by, kind, reason } }; - return parts.submitted({ kind: 'cancel_requested', executionId: runId, at: parts.now(), ...pending }).pipe( + const runKey = runKeyOf({ ...brain, runId }); + const pending = { runKey, cause: record.id, cancel: { by, kind, reason } }; + return parts.submitted({ kind: 'cancel_requested', runId: runKey, at: parts.now(), ...pending }).pipe( Effect.flatMap(({ outcome }) => (outcome === 'not_started' ? passedOverRow(parts.database, pending) : Effect.void)), Effect.mapError(failedWith), ); } function cancelled(parts: CancelParts, asked: Asked): Effect.Effect { - const { brain, record, request, primitive, executionId } = asked; - if (primitive === parts.workflows) { + const { brain, record, request, type, runId } = asked; + if (type === parts.workflows) { return cancelledWorkflow(parts, asked); } const { kind, reason, by } = request; return parts - .cancelDeferred({ ...brain, id: executionId }, { kind, reason, by }, lineageOf(record)) + .cancelDeferred({ ...brain, id: runId }, { kind, reason, by }, lineageOf(record)) .pipe(Effect.mapError(failedWith)); } function deliveriesOf(parts: CancelParts, { brain, record }: CallRecord): readonly Delivery[] { const request = cancelRequestOf(record.data); - const primitive = request?.primitive; - if (request === undefined || primitive === undefined) { + const type = request?.definition_type; + if (request === undefined || type === undefined) { return []; } - const executionId = record.stream.slice(record.stream.lastIndexOf('/') + 1); + const runId = record.stream.slice(record.stream.lastIndexOf('/') + 1); const delivery: Delivery = { key: 'cancel', - workflow: executionId, - deliver: cancelled(parts, { brain, record, request, primitive, executionId }), + workflow: runId, + deliver: cancelled(parts, { brain, record, request, type, runId }), }; return [delivery]; } @@ -72,7 +72,7 @@ function deliveriesOf(parts: CancelParts, { brain, record }: CallRecord): readon export function cancelRequests(parts: CancelParts): CallConsumer { return { name: 'cancel_requests', - types: ['execution_cancel_requested'], + types: ['run_cancel_requested'], skippedAfterSweeps: Number.POSITIVE_INFINITY, batchOf: (followed, after) => Effect.sync(() => ({ diff --git a/packages/workflow-host/src/waiting/child-cancels.test.ts b/packages/workflow-host/src/waiting/child-cancels.test.ts index 4003786f6..12cf2a7e0 100644 --- a/packages/workflow-host/src/waiting/child-cancels.test.ts +++ b/packages/workflow-host/src/waiting/child-cancels.test.ts @@ -9,7 +9,7 @@ import { childCancelsOn } from './child-cancels.ts'; const lineage = { causationId: 'step-1', correlationId: 'root-1' }; -const child = { org: 'acme', brain: 'alpha', executionId: '0199a3c4-7d2e-7c1a-9b3f-0000000000c1' }; +const child = { org: 'acme', brain: 'alpha', runId: '0199a3c4-7d2e-7c1a-9b3f-0000000000c1' }; describe('a cancel of the run a call waits for', () => { it('asks for the run to be cancelled with the kind of the cancel and words that say why', async () => { @@ -21,7 +21,7 @@ describe('a cancel of the run a call waits for', () => { expect(waiting.cancels()).toEqual([ { - execution: { org: 'acme', brain: 'alpha', id: child.executionId }, + run: { org: 'acme', brain: 'alpha', id: child.runId }, request: { kind: 'deadline', reason: 'The step that waited for this run ran out of time, so it no longer needs it', @@ -29,7 +29,7 @@ describe('a cancel of the run a call waits for', () => { lineage, }, { - execution: { org: 'acme', brain: 'alpha', id: child.executionId }, + run: { org: 'acme', brain: 'alpha', id: child.runId }, request: { kind: 'parent_ended', reason: 'The run that waited for this run ended first, or the branch that waited for it lost a race', @@ -44,7 +44,7 @@ describe('the deadline of a call that waits for a run', () => { it('ends the call as a timeout and cancels the run it waited for, as past its deadline', async () => { const waiting = recordedWaiting(); const clock = movedClock(Date.now()); - const { settled, callStates } = await waitingParent(child.executionId, { clock, waiting: waiting.options }); + const { settled, callStates } = await waitingParent(child.runId, { clock, waiting: waiting.options }); clock.moveTo(Date.now() + defaultLimits.longestCallMs + 1); const settlement = await settled(parentId); @@ -54,8 +54,8 @@ describe('the deadline of a call that waits for a run', () => { reason: 'unavailable', detail: 'The function notify did not finish within 600000 ms, the most it may take (at /do/0/ask)', }); - expect(waiting.cancels().map(({ execution, request }) => [execution, request.kind])).toEqual([ - [{ org: 'acme', brain: 'alpha', id: child.executionId }, 'deadline'], + expect(waiting.cancels().map(({ run, request }) => [run, request.kind])).toEqual([ + [{ org: 'acme', brain: 'alpha', id: child.runId }, 'deadline'], ]); expect(await callStates()).toEqual([{ state: 'cancelled', delivered: 0 }]); }); diff --git a/packages/workflow-host/src/waiting/child-cancels.ts b/packages/workflow-host/src/waiting/child-cancels.ts index c16347a27..8afcfb0ce 100644 --- a/packages/workflow-host/src/waiting/child-cancels.ts +++ b/packages/workflow-host/src/waiting/child-cancels.ts @@ -1,4 +1,4 @@ -import type { CancelExecution } from '@beonauto/specs'; +import type { CancelRun } from '@beonauto/definitions'; import type { CancelReason } from '@beonauto/workflow-engine'; import type { CancelChild } from '../calls/call-cancels.ts'; @@ -8,7 +8,7 @@ const reasons: Readonly> = { parent_ended: 'The run that waited for this run ended first, or the branch that waited for it lost a race', }; -export function childCancelsOn(cancel: CancelExecution): CancelChild { - return ({ child: { org, brain, executionId }, reason, lineage }) => - cancel({ org, brain, id: executionId }, { kind: reason, reason: reasons[reason] }, lineage); +export function childCancelsOn(cancel: CancelRun): CancelChild { + return ({ child: { org, brain, runId }, reason, lineage }) => + cancel({ org, brain, id: runId }, { kind: reason, reason: reasons[reason] }, lineage); } diff --git a/packages/workflow-host/src/waiting/child-endings.test.ts b/packages/workflow-host/src/waiting/child-endings.test.ts index bc13998b1..0469ebce3 100644 --- a/packages/workflow-host/src/waiting/child-endings.test.ts +++ b/packages/workflow-host/src/waiting/child-endings.test.ts @@ -11,11 +11,11 @@ import { childEndings, endedChildrenOn } from './child-endings.ts'; const child = '0199a3c4-7d2e-7c1a-9b3f-0000000000c1'; -const calledBy = { execution_id: parentId, reference: '/do/0/ask', run: 1 }; +const calledBy = { run_id: parentId, reference: '/do/0/ask', run: 1 }; -const ofTheChild = { primitive: 'orchestration', name: 'check', spec_version: 1, by: 'brain:alpha', at }; +const ofTheChild = { definition_type: 'workflow', name: 'check', definition_version: 1, by: 'brain:alpha', at }; -const succeeded = { type: 'execution_succeeded', output: 'checked', record: {}, ...ofTheChild, called_by: calledBy }; +const succeeded = { type: 'run_succeeded', output: 'checked', record: {}, ...ofTheChild, called_by: calledBy }; const endingRecord = { brain: { org: 'acme', brain: 'alpha' }, @@ -25,7 +25,7 @@ const endingRecord = { cursor: 'record-1', causationId: null, correlationId: null, - stream: `executions/${child}`, + stream: `runs/${child}`, version: 2, type: succeeded.type, data: succeeded, @@ -37,7 +37,7 @@ describe('the ending of a run that answers a call', () => { it('is delivered by the follower to the step that waits for it, as its answer, and the call is answered', async () => { const { database, settled, answeredCalls } = await waitingParent(child); - await recorded(database.store, `${alpha}executions/${child}`, succeeded); + await recorded(database.store, `${alpha}runs/${child}`, succeeded); expect(await settled(parentId)).toEqual({ status: 'succeeded', output: 'checked' }); expect(await answeredCalls()).toEqual([{ state: 'answered', delivered: 1 }]); @@ -56,20 +56,20 @@ describe('the ending of a run that answers a call', () => { }, }); - await recorded(database.store, `${alpha}executions/${child}`, succeeded); - await recorded(database.store, `${alpha}executions/${child}`, succeeded); - await recorded(database.store, `${alpha}executions/${child}x`, { - type: 'execution_failed', + await recorded(database.store, `${alpha}runs/${child}`, succeeded); + await recorded(database.store, `${alpha}runs/${child}`, succeeded); + await recorded(database.store, `${alpha}runs/${child}x`, { + type: 'run_failed', ...ofTheChild, called_by: calledBy, }); await settled(parentId); await eventually( (): readonly string[] => mapped, - (types) => types.includes('execution_failed'), + (types) => types.includes('run_failed'), ); - expect(mapped).toContain('execution_failed'); + expect(mapped).toContain('run_failed'); expect(hosted.troubles()).toEqual([]); }); }); @@ -98,8 +98,8 @@ describe('a delivery of an ending that the run cannot take now', () => { describe('the endings of the runs a run waits for, before a timer of that run fires', () => { it('are delivered first, so a child that ended while the server was down answers before the deadline', async () => { const { database, callStates } = await waitingParent(child); - await recorded(database.store, `${alpha}executions/${child}`, { - type: 'execution_started', + await recorded(database.store, `${alpha}runs/${child}`, { + type: 'run_started', ...ofTheChild, input: {}, }); @@ -117,8 +117,8 @@ describe('the endings of the runs a run waits for, before a timer of that run fi await Effect.runPromise(endedChildren(parentRun)); const whileGoing = submitted.length; - await recorded(database.store, `${alpha}executions/${child}`, { - type: 'execution_rejected', + await recorded(database.store, `${alpha}runs/${child}`, { + type: 'run_rejected', rejection: { reason: 'cancelled', detail: 'Expired', kind: 'deadline' }, ...ofTheChild, called_by: calledBy, @@ -129,9 +129,9 @@ describe('the endings of the runs a run waits for, before a timer of that run fi expect(submitted).toEqual([ { kind: 'call_answered', - executionId: parentRun, + runId: parentRun, at: 1, - key: { executionId: parentRun, reference: '/do/0/ask', run: 1 }, + key: { runId: parentRun, reference: '/do/0/ask', run: 1 }, result: { status: 'rejected', reason: 'cancelled', detail: 'Expired' }, }, ]); diff --git a/packages/workflow-host/src/waiting/child-endings.ts b/packages/workflow-host/src/waiting/child-endings.ts index d99257f77..cbc8fc800 100644 --- a/packages/workflow-host/src/waiting/child-endings.ts +++ b/packages/workflow-host/src/waiting/child-endings.ts @@ -1,12 +1,12 @@ +import { lastEndingOf, runEndingOf, type CalledBy, type RunEnding } from '@beonauto/definitions'; import { streamPrefixOfBrain, type BrainAddress, type CallResult, type Conflict } from '@beonauto/operations'; -import { lastEndingOf, runEndingOf, type CalledBy, type RunEnding } from '@beonauto/specs'; import { callKeyText, type RunInput, type Submission } from '@beonauto/workflow-engine'; import { Effect } from 'effect'; import { answeredRow, callRowOf, deliveredRow, waitingCallsOf } from '../calls/call-rows.ts'; import type { HostDatabase } from '../database/host-database.ts'; import { DeliveryFailed, type CallConsumer } from '../follower/consumers.ts'; -import { addressOfRun, runIdOf } from '../runs/run-address.ts'; +import { addressOfRun, runKeyOf } from '../runs/run-address.ts'; import type { WaitingOptions } from './waiting-options.ts'; export interface EndingParts { @@ -31,11 +31,11 @@ function failedWith({ detail }: Readonly<{ detail: string }>): DeliveryFailed { } function answeredWith(parts: EndingParts, brain: BrainAddress, { ending, calledBy }: CalledEnding) { - const executionId = runIdOf({ ...brain, executionId: calledBy.execution_id }); - const key = { executionId, reference: calledBy.reference, run: calledBy.run }; + const runId = runKeyOf({ ...brain, runId: calledBy.run_id }); + const key = { runId, reference: calledBy.reference, run: calledBy.run }; const result = parts.resultOf(ending); const answered = parts - .submitted({ kind: 'call_answered', executionId, at: parts.now(), key, result }) + .submitted({ kind: 'call_answered', runId, at: parts.now(), key, result }) .pipe( Effect.andThen(answeredRow(parts.database, callKeyText(key), result)), Effect.andThen(deliveredRow(parts.database, callKeyText(key))), @@ -47,7 +47,7 @@ function answeredWith(parts: EndingParts, brain: BrainAddress, { ending, calledB } function childEndingOf(database: HostDatabase, brain: BrainAddress, child: string) { - const stream = `${streamPrefixOfBrain(brain)}executions/${child}`; + const stream = `${streamPrefixOfBrain(brain)}runs/${child}`; return Effect.map( Effect.promise(() => database.store.read(stream, 0)), ({ events }) => calledEndingOf(lastEndingOf(events)), @@ -57,9 +57,9 @@ function childEndingOf(database: HostDatabase, brain: BrainAddress, child: strin export function childAnswersOn( database: HostDatabase, resultOf: WaitingOptions['resultOf'], -): (runId: string, child: string) => Effect.Effect { - return (runId, child) => { - const { org, brain } = addressOfRun(runId); +): (runKey: string, child: string) => Effect.Effect { + return (runKey, child) => { + const { org, brain } = addressOfRun(runKey); return Effect.map(childEndingOf(database, { org, brain }, child), (called) => called === undefined ? undefined : resultOf(called.ending), ); @@ -69,7 +69,7 @@ export function childAnswersOn( export function childEndings(parts: EndingParts): CallConsumer { return { name: 'child_endings', - types: ['execution_succeeded', 'execution_rejected', 'execution_failed'], + types: ['run_succeeded', 'run_rejected', 'run_failed'], skippedAfterSweeps: Number.POSITIVE_INFINITY, batchOf: ({ brain, record }, after) => Effect.sync(() => { @@ -78,7 +78,7 @@ export function childEndings(parts: EndingParts): CallConsumer { deliveries: called === undefined ? [] - : [{ key: 'call', workflow: called.calledBy.execution_id, deliver: answeredWith(parts, brain, called) }], + : [{ key: 'call', workflow: called.calledBy.run_id, deliver: answeredWith(parts, brain, called) }], through: undefined, more: false, }; @@ -87,12 +87,12 @@ export function childEndings(parts: EndingParts): CallConsumer { }; } -export function endedChildrenOn(parts: EndingParts): (runId: string) => Effect.Effect { +export function endedChildrenOn(parts: EndingParts): (runKey: string) => Effect.Effect { const { database } = parts; - return (runId) => + return (runKey) => Effect.gen(function* () { - const { org, brain } = addressOfRun(runId); - const waiting = yield* Effect.mapError(waitingCallsOf(database, runId), failedWith); + const { org, brain } = addressOfRun(runKey); + const waiting = yield* Effect.mapError(waitingCallsOf(database, runKey), failedWith); for (const { child } of waiting) { const called = yield* childEndingOf(database, { org, brain }, child); if (called !== undefined) { diff --git a/packages/workflow-host/src/waiting/pending-cancel-rows.ts b/packages/workflow-host/src/waiting/pending-cancel-rows.ts index fb04ad760..d3d1ca010 100644 --- a/packages/workflow-host/src/waiting/pending-cancel-rows.ts +++ b/packages/workflow-host/src/waiting/pending-cancel-rows.ts @@ -5,32 +5,38 @@ import { rowsOf, type DatabaseFailed, type HostDatabase } from '../database/host import { statement } from '../database/statement.ts'; export interface PendingCancelRow { - readonly runId: string; + readonly runKey: string; readonly cause: string; readonly cancel: CancelOrder; } const PendingRow = Schema.Struct({ - run_id: Schema.String, + run_key: Schema.String, cause: Schema.String, cancelled_by: CancelOrderSchema.fields.by, kind: CancelOrderSchema.fields.kind, reason: CancelOrderSchema.fields.reason, }); -function pendingOf({ run_id: runId, cause, cancelled_by: by, kind, reason }: typeof PendingRow.Type): PendingCancelRow { - return { runId, cause, cancel: { by, kind, reason } }; +function pendingOf({ + run_key: runKey, + cause, + cancelled_by: by, + kind, + reason, +}: typeof PendingRow.Type): PendingCancelRow { + return { runKey, cause, cancel: { by, kind, reason } }; } export function passedOverRow( database: HostDatabase, - { runId, cause, cancel }: PendingCancelRow, + { runKey, cause, cancel }: PendingCancelRow, ): Effect.Effect { return Effect.asVoid( database.write( - statement`INSERT INTO workflow_pending_cancels (run_id, cause, cancelled_by, kind, reason) - VALUES (${runId}, ${cause}, ${cancel.by}, ${cancel.kind}, ${cancel.reason}) - ON CONFLICT (run_id) DO NOTHING`, + statement`INSERT INTO workflow_pending_cancels (run_key, cause, cancelled_by, kind, reason) + VALUES (${runKey}, ${cause}, ${cancel.by}, ${cancel.kind}, ${cancel.reason}) + ON CONFLICT (run_key) DO NOTHING`, ), ); } @@ -43,12 +49,12 @@ export function pendingCancelRowsAfter( return rowsOf( PendingRow, database.read( - statement`SELECT run_id, cause, cancelled_by, kind, reason FROM workflow_pending_cancels - WHERE run_id > ${after} ORDER BY run_id LIMIT ${most}`, + statement`SELECT run_key, cause, cancelled_by, kind, reason FROM workflow_pending_cancels + WHERE run_key > ${after} ORDER BY run_key LIMIT ${most}`, ), ).pipe(Effect.map((rows) => rows.map((row) => pendingOf(row)))); } -export function clearedPendingRow(database: HostDatabase, runId: string): Effect.Effect { - return Effect.asVoid(database.write(statement`DELETE FROM workflow_pending_cancels WHERE run_id = ${runId}`)); +export function clearedPendingRow(database: HostDatabase, runKey: string): Effect.Effect { + return Effect.asVoid(database.write(statement`DELETE FROM workflow_pending_cancels WHERE run_key = ${runKey}`)); } diff --git a/packages/workflow-host/src/waiting/pending-cancels.test.ts b/packages/workflow-host/src/waiting/pending-cancels.test.ts index 2f6317252..9e70c2d2d 100644 --- a/packages/workflow-host/src/waiting/pending-cancels.test.ts +++ b/packages/workflow-host/src/waiting/pending-cancels.test.ts @@ -39,8 +39,8 @@ describe('the cancels the follower passed over, given at the first resume after describe('the read of the cancels the follower passed over', () => { it('goes page after page, a hundred at a time, and again at the next resume when it failed', async () => { - const runIds = Array.from({ length: 205 }, (_, index) => `acme/alpha/run-${String(index).padStart(3, '0')}`); - const resumed = await resumedWith(onSQLite, runIds); + const runKeys = Array.from({ length: 205 }, (_, index) => `acme/alpha/run-${String(index).padStart(3, '0')}`); + const resumed = await resumedWith(onSQLite, runKeys); resumed.database.failing(true); await resumed.resume(); @@ -53,7 +53,7 @@ describe('the read of the cancels the follower passed over', () => { expect(resumed.troubles()).toEqual([ 'The cancels the follower passed over could not be read; the next sweep reads them again', ]); - expect([...resumed.given()].toSorted()).toEqual(runIds); + expect([...resumed.given()].toSorted()).toEqual(runKeys); expect(await resumed.pending()).toEqual([]); }); }); diff --git a/packages/workflow-host/src/waiting/pending-cancels.ts b/packages/workflow-host/src/waiting/pending-cancels.ts index a1830b5fa..8fb11ebc7 100644 --- a/packages/workflow-host/src/waiting/pending-cancels.ts +++ b/packages/workflow-host/src/waiting/pending-cancels.ts @@ -1,11 +1,11 @@ +import { cancelRequestOf } from '@beonauto/definitions'; import { recordedReaderOf } from '@beonauto/ledger'; import { messageIdOf, streamPrefixOfBrain, type Conflict } from '@beonauto/operations'; -import { cancelRequestOf } from '@beonauto/specs'; import type { CancelOrder, RunInput, Submission } from '@beonauto/workflow-engine'; import { Effect, Schema, type Cause } from 'effect'; import type { DatabaseFailed, HostDatabase } from '../database/host-database.ts'; -import { addressOfRun, runIdOf, type RunAddress } from '../runs/run-address.ts'; +import { addressOfRun, runKeyOf, type RunAddress } from '../runs/run-address.ts'; import { clearedPendingRow, pendingCancelRowsAfter, type PendingCancelRow } from './pending-cancel-rows.ts'; interface PendingCancel { @@ -13,14 +13,14 @@ interface PendingCancel { readonly cause: string; } -const isStart = Schema.is(Schema.Struct({ type: Schema.Literal('execution_started') })); +const isStart = Schema.is(Schema.Struct({ type: Schema.Literal('run_started') })); function isCancelRequest(data: unknown): boolean { return cancelRequestOf(data) !== undefined; } function pendingCancelOf(database: HostDatabase, run: RunAddress): Effect.Effect { - const stream = `${streamPrefixOfBrain(run)}executions/${run.executionId}`; + const stream = `${streamPrefixOfBrain(run)}runs/${run.runId}`; return Effect.map( Effect.promise(() => database.store.read(stream, 0)), ({ events }): PendingCancel | undefined => { @@ -47,9 +47,7 @@ export function cancelledIfAsked(parts: PendingParts, run: RunAddress): Effect.E return Effect.flatMap(pendingCancelOf(parts.database, run), (pending) => pending === undefined ? Effect.void - : Effect.asVoid( - parts.submitted({ kind: 'cancel_requested', executionId: runIdOf(run), at: parts.now(), ...pending }), - ), + : Effect.asVoid(parts.submitted({ kind: 'cancel_requested', runId: runKeyOf(run), at: parts.now(), ...pending })), ); } @@ -59,13 +57,13 @@ const pendingRowsInAPage = 100; const cancelsGivenAtOnce = 4; -const finishTypes: ReadonlySet = new Set(['execution_succeeded', 'execution_rejected', 'execution_failed']); +const finishTypes: ReadonlySet = new Set(['run_succeeded', 'run_rejected', 'run_failed']); -function hasEnded(database: HostDatabase, runId: string): Effect.Effect { - const { org, brain, executionId } = addressOfRun(runId); +function hasEnded(database: HostDatabase, runKey: string): Effect.Effect { + const { org, brain, runId } = addressOfRun(runKey); return recordedReaderOf(database.store)( { org, brain }, - { kind: 'run', execution: executionId }, + { kind: 'run', run: runId }, { order: 'desc', limit: 1, dataOf: [] }, ).pipe( Effect.orDie, @@ -73,21 +71,21 @@ function hasEnded(database: HostDatabase, runId: string): Effect.Effect ); } -function givenToItsRun(parts: PendingParts, { runId, cause, cancel }: PendingCancelRow) { +function givenToItsRun(parts: PendingParts, { runKey, cause, cancel }: PendingCancelRow) { return Effect.flatMap( - parts.submitted({ kind: 'cancel_requested', executionId: runId, at: parts.now(), cause, cancel }), - ({ outcome }) => (outcome === 'not_started' ? Effect.void : clearedPendingRow(parts.database, runId)), + parts.submitted({ kind: 'cancel_requested', runId: runKey, at: parts.now(), cause, cancel }), + ({ outcome }) => (outcome === 'not_started' ? Effect.void : clearedPendingRow(parts.database, runKey)), ); } function givenOrKept(parts: PendingParts, trouble: Trouble, row: PendingCancelRow) { - return Effect.flatMap(hasEnded(parts.database, row.runId), (ended) => - ended ? clearedPendingRow(parts.database, row.runId) : givenToItsRun(parts, row), + return Effect.flatMap(hasEnded(parts.database, row.runKey), (ended) => + ended ? clearedPendingRow(parts.database, row.runKey) : givenToItsRun(parts, row), ).pipe( Effect.as(true), Effect.catchCause((failure: Cause.Cause) => Effect.as( - trouble(`The cancel asked of ${row.runId} could not be given; the next sweep gives it again`, failure), + trouble(`The cancel asked of ${row.runKey} could not be given; the next sweep gives it again`, failure), false, ), ), @@ -103,7 +101,7 @@ function pagesGiven(parts: PendingParts, trouble: Trouble, after: string): Effec const last = rows.at(-1); return last === undefined || rows.length < pendingRowsInAPage ? Effect.succeed(allGiven) - : Effect.map(pagesGiven(parts, trouble, last.runId), (rest) => allGiven && rest); + : Effect.map(pagesGiven(parts, trouble, last.runKey), (rest) => allGiven && rest); }, ), ); diff --git a/packages/workflow-host/src/waiting/stranded-answers.test.ts b/packages/workflow-host/src/waiting/stranded-answers.test.ts index 4e94259f3..0ff3b3d15 100644 --- a/packages/workflow-host/src/waiting/stranded-answers.test.ts +++ b/packages/workflow-host/src/waiting/stranded-answers.test.ts @@ -14,18 +14,18 @@ const child = '0199a3c4-7d2e-7c1a-9b3f-0000000000c1'; const asking = workflow('do:\n - ask: { call: notify, with: { to: ada } }'); -const key = callKeyText({ executionId: parentRun, reference: '/do/0/ask', run: 1 }); +const key = callKeyText({ runId: parentRun, reference: '/do/0/ask', run: 1 }); const ending = { - type: 'execution_succeeded', + type: 'run_succeeded', output: 'checked', record: {}, - primitive: 'orchestration', + definition_type: 'workflow', name: 'check', - spec_version: 1, + definition_version: 1, by: 'brain:alpha', at, - called_by: { execution_id: parentId, reference: '/do/0/ask', run: 1 }, + called_by: { run_id: parentId, reference: '/do/0/ask', run: 1 }, }; describe('a host that died after it marked a call waiting and before it read the ending of the call’s run', () => { @@ -36,7 +36,7 @@ describe('a host that died after it marked a call waiting and before it read the const first = await hostedOn(settings, { answer: () => Effect.never }); first.know(parentId); await Effect.runPromise(first.host.start(runAt(parentId), startOf(asking))); - await recorded(database.store, `${alpha}executions/${child}`, ending); + await recorded(database.store, `${alpha}runs/${child}`, ending); await followedThroughTheLatest(database); await first.host.stop(); await Effect.runPromise(waitingRow(database, key, child)); diff --git a/packages/workflow-host/src/waiting/waiting-options.ts b/packages/workflow-host/src/waiting/waiting-options.ts index fe63ba155..0b29d607c 100644 --- a/packages/workflow-host/src/waiting/waiting-options.ts +++ b/packages/workflow-host/src/waiting/waiting-options.ts @@ -1,9 +1,9 @@ +import type { CancelRun, RunEnding, SettleCancelled } from '@beonauto/definitions'; import type { CallResult } from '@beonauto/operations'; -import type { CancelExecution, RunEnding, SettleCancelled } from '@beonauto/specs'; export interface WaitingOptions { readonly resultOf: (ending: RunEnding) => CallResult; - readonly cancel: CancelExecution; + readonly cancel: CancelRun; readonly cancelDeferred: SettleCancelled; } diff --git a/packages/workflow-host/src/waiting/waiting-parts.test.ts b/packages/workflow-host/src/waiting/waiting-parts.test.ts index 07c231587..c82f5dfed 100644 --- a/packages/workflow-host/src/waiting/waiting-parts.test.ts +++ b/packages/workflow-host/src/waiting/waiting-parts.test.ts @@ -9,12 +9,12 @@ import { recordedWaiting } from '../waiting-testing/recorded-waiting.ts'; import { mostOpenCallsOfATree } from './waiting-options.ts'; import { executorWaitingOf } from './waiting-parts.ts'; -const run = { executionId: 'acme/alpha/0199a3c4-7d2e-7c1a-9b3f-2f1e0d9c8b7a', attributes: {} }; +const run = { runId: 'acme/alpha/0199a3c4-7d2e-7c1a-9b3f-2f1e0d9c8b7a', attributes: {} }; function callWith(arguments_: StartCall['arguments']): StartCall { return { kind: 'start_call', - key: { executionId: run.executionId, reference: '/do/0/ask', run: 1 }, + key: { runId: run.runId, reference: '/do/0/ask', run: 1 }, function: 'notify', arguments: arguments_, longestMs: 60_000, @@ -52,24 +52,24 @@ describe('the answer of a run a call waits for', () => { const { options } = recordedWaiting(); const database = await openedOn({ store: 'sqlite', file: aSQLiteFile() }); const { childAnswerOf } = executorWaitingOf(database, testMachine, options); - const ofTheChild = { primitive: 'orchestration', name: 'check', spec_version: 1, by: 'brain:alpha', at }; - const calledBy = { execution_id: '0199a3c4-7d2e-7c1a-9b3f-2f1e0d9c8b7a', reference: '/do/0/ask', run: 1 }; - await recorded(database.store, `${alpha}executions/answered`, { - type: 'execution_succeeded', + const ofTheChild = { definition_type: 'workflow', name: 'check', definition_version: 1, by: 'brain:alpha', at }; + const calledBy = { run_id: '0199a3c4-7d2e-7c1a-9b3f-2f1e0d9c8b7a', reference: '/do/0/ask', run: 1 }; + await recorded(database.store, `${alpha}runs/answered`, { + type: 'run_succeeded', output: 'checked', record: {}, ...ofTheChild, called_by: calledBy, }); - await recorded(database.store, `${alpha}executions/uncalled`, { - type: 'execution_succeeded', + await recorded(database.store, `${alpha}runs/uncalled`, { + type: 'run_succeeded', output: 'checked', record: {}, ...ofTheChild, }); const answers = await Effect.runPromise( - Effect.all(['answered', 'uncalled', 'running'].map((child) => childAnswerOf(run.executionId, child))), + Effect.all(['answered', 'uncalled', 'running'].map((child) => childAnswerOf(run.runId, child))), ); expect(answers).toEqual([{ status: 'succeeded', output: 'checked' }, undefined, undefined]); diff --git a/packages/workflow-host/src/waiting/waiting-parts.ts b/packages/workflow-host/src/waiting/waiting-parts.ts index f57f1dab2..7f9851840 100644 --- a/packages/workflow-host/src/waiting/waiting-parts.ts +++ b/packages/workflow-host/src/waiting/waiting-parts.ts @@ -39,7 +39,7 @@ export interface CallConsumerParts extends EndingParts, CancelParts { export interface ServedWaiting { readonly calls: readonly CallConsumer[]; - readonly endedChildren: (runId: string) => Effect.Effect; + readonly endedChildren: (runKey: string) => Effect.Effect; readonly cancelsAsked: Effect.Effect; } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9871b3b6e..c106aae1d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -209,9 +209,9 @@ importers: .: devDependencies: - '@beonauto/specs': + '@beonauto/definitions': specifier: workspace:* - version: link:packages/specs + version: link:packages/definitions '@commitlint/cli': specifier: 21.2.3 version: 21.2.3(@types/node@26.6.3)(conventional-commits-parser@7.1.2)(typescript@7.0.2) @@ -267,6 +267,189 @@ importers: specifier: 'catalog:' version: 5.0.2(@opentelemetry/api@1.9.1)(@types/node@26.6.3)(@vitest/coverage-v8@5.0.2)(vite@8.3.1(@types/node@26.6.3)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.51.2)(yaml@2.9.1)) + capabilities/computation: + dependencies: + '@beonauto/definitions': + specifier: workspace:* + version: link:../../packages/definitions + '@beonauto/operations': + specifier: workspace:* + version: link:../../packages/operations + '@beonauto/workflow-engine': + specifier: workspace:* + version: link:../../packages/workflow-engine + effect: + specifier: 'catalog:' + version: 4.0.0 + devDependencies: + '@vitest/coverage-v8': + specifier: 'catalog:' + version: 5.0.2(vitest@5.0.2) + vitest: + specifier: 'catalog:' + version: 5.0.2(@opentelemetry/api@1.9.1)(@types/node@26.6.3)(@vitest/coverage-v8@5.0.2)(vite@8.3.1(@types/node@26.6.3)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.51.2)(yaml@2.9.1)) + + capabilities/coordination: + dependencies: + '@beonauto/config': + specifier: workspace:* + version: link:../../packages/config + '@beonauto/definitions': + specifier: workspace:* + version: link:../../packages/definitions + '@beonauto/operations': + specifier: workspace:* + version: link:../../packages/operations + '@beonauto/workflow-engine': + specifier: workspace:* + version: link:../../packages/workflow-engine + '@beonauto/workflow-host': + specifier: workspace:* + version: link:../../packages/workflow-host + '@openworkflowspec/sdk': + specifier: 1.0.3-alpha8 + version: 1.0.3-alpha8 + effect: + specifier: 'catalog:' + version: 4.0.0 + yaml: + specifier: 2.9.1 + version: 2.9.1 + devDependencies: + '@beonauto/api': + specifier: workspace:* + version: link:../../packages/api + '@vitest/coverage-v8': + specifier: 'catalog:' + version: 5.0.2(vitest@5.0.2) + vitest: + specifier: 'catalog:' + version: 5.0.2(@opentelemetry/api@1.9.1)(@types/node@26.6.3)(@vitest/coverage-v8@5.0.2)(vite@8.3.1(@types/node@26.6.3)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.51.2)(yaml@2.9.1)) + + capabilities/interaction: + dependencies: + '@beonauto/definitions': + specifier: workspace:* + version: link:../../packages/definitions + '@beonauto/mcp': + specifier: workspace:* + version: link:../../packages/mcp + '@beonauto/operations': + specifier: workspace:* + version: link:../../packages/operations + '@beonauto/workflow-engine': + specifier: workspace:* + version: link:../../packages/workflow-engine + effect: + specifier: 'catalog:' + version: 4.0.0 + liquidjs: + specifier: 10.29.0 + version: 10.29.0 + devDependencies: + '@vitest/coverage-v8': + specifier: 'catalog:' + version: 5.0.2(vitest@5.0.2) + vitest: + specifier: 'catalog:' + version: 5.0.2(@opentelemetry/api@1.9.1)(@types/node@26.6.3)(@vitest/coverage-v8@5.0.2)(vite@8.3.1(@types/node@26.6.3)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.51.2)(yaml@2.9.1)) + + capabilities/reasoning: + dependencies: + '@ai-sdk/amazon-bedrock': + specifier: 5.0.103 + version: 5.0.103(zod@4.6.5) + '@ai-sdk/anthropic': + specifier: 4.0.70 + version: 4.0.70(zod@4.6.5) + '@ai-sdk/azure': + specifier: 4.0.87 + version: 4.0.87(zod@4.6.5) + '@ai-sdk/google': + specifier: 4.0.87 + version: 4.0.87(zod@4.6.5) + '@ai-sdk/google-vertex': + specifier: 5.0.100 + version: 5.0.100(supports-color@8.1.1)(zod@4.6.5) + '@ai-sdk/openai': + specifier: 4.0.83 + version: 4.0.83(zod@4.6.5) + '@ai-sdk/openai-compatible': + specifier: 3.0.61 + version: 3.0.61(zod@4.6.5) + '@aws-sdk/credential-providers': + specifier: 3.1144.0 + version: 3.1144.0 + '@beonauto/config': + specifier: workspace:* + version: link:../../packages/config + '@beonauto/definitions': + specifier: workspace:* + version: link:../../packages/definitions + '@beonauto/mcp': + specifier: workspace:* + version: link:../../packages/mcp + '@beonauto/operations': + specifier: workspace:* + version: link:../../packages/operations + '@beonauto/outbound': + specifier: workspace:* + version: link:../../packages/outbound + ai: + specifier: 7.0.124 + version: 7.0.124(zod@4.6.5) + effect: + specifier: 'catalog:' + version: 4.0.0 + google-auth-library: + specifier: 10.9.1 + version: 10.9.1(supports-color@8.1.1) + liquidjs: + specifier: 10.29.0 + version: 10.29.0 + zod: + specifier: 4.6.5 + version: 4.6.5 + devDependencies: + '@types/json-schema': + specifier: 7.0.15 + version: 7.0.15 + '@vitest/coverage-v8': + specifier: 'catalog:' + version: 5.0.2(vitest@5.0.2) + vitest: + specifier: 'catalog:' + version: 5.0.2(@opentelemetry/api@1.9.1)(@types/node@26.6.3)(@vitest/coverage-v8@5.0.2)(vite@8.3.1(@types/node@26.6.3)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.51.2)(yaml@2.9.1)) + optionalDependencies: + '@azure/identity': + specifier: 4.13.3 + version: 4.13.3(supports-color@8.1.1) + + capabilities/recall: + dependencies: + '@beonauto/definitions': + specifier: workspace:* + version: link:../../packages/definitions + '@beonauto/operations': + specifier: workspace:* + version: link:../../packages/operations + '@beonauto/workflow-engine': + specifier: workspace:* + version: link:../../packages/workflow-engine + '@beonauto/workflow-host': + specifier: workspace:* + version: link:../../packages/workflow-host + effect: + specifier: 'catalog:' + version: 4.0.0 + devDependencies: + '@vitest/coverage-v8': + specifier: 'catalog:' + version: 5.0.2(vitest@5.0.2) + vitest: + specifier: 'catalog:' + version: 5.0.2(@opentelemetry/api@1.9.1)(@types/node@26.6.3)(@vitest/coverage-v8@5.0.2)(vite@8.3.1(@types/node@26.6.3)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.51.2)(yaml@2.9.1)) + packages/api: dependencies: '@beonauto/identity': @@ -288,9 +471,9 @@ importers: specifier: 'catalog:' version: 4.13.12 devDependencies: - '@beonauto/specs': + '@beonauto/definitions': specifier: workspace:* - version: link:../specs + version: link:../definitions '@modelcontextprotocol/client': specifier: 2.2.0 version: 2.2.0 @@ -339,6 +522,46 @@ importers: specifier: 'catalog:' version: 5.0.2(@opentelemetry/api@1.9.1)(@types/node@26.6.3)(@vitest/coverage-v8@5.0.2)(vite@8.3.1(@types/node@26.6.3)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.51.2)(yaml@2.9.1)) + packages/definitions: + dependencies: + '@beonauto/mcp': + specifier: workspace:* + version: link:../mcp + '@beonauto/operations': + specifier: workspace:* + version: link:../operations + '@beonauto/workflow-engine': + specifier: workspace:* + version: link:../workflow-engine + effect: + specifier: 'catalog:' + version: 4.0.0 + liquidjs: + specifier: 10.29.0 + version: 10.29.0 + yaml: + specifier: 2.9.1 + version: 2.9.1 + devDependencies: + '@beonauto/brains': + specifier: workspace:* + version: link:../brains + '@beonauto/ledger': + specifier: workspace:* + version: link:../ledger + '@types/pg': + specifier: 8.23.1 + version: 8.23.1 + '@vitest/coverage-v8': + specifier: 'catalog:' + version: 5.0.2(vitest@5.0.2) + pg: + specifier: 8.23.1 + version: 8.23.1 + vitest: + specifier: 'catalog:' + version: 5.0.2(@opentelemetry/api@1.9.1)(@types/node@26.6.3)(@vitest/coverage-v8@5.0.2)(vite@8.3.1(@types/node@26.6.3)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.51.2)(yaml@2.9.1)) + packages/identity: dependencies: '@beonauto/operations': @@ -456,19 +679,22 @@ importers: version: link:../brains '@beonauto/computation': specifier: workspace:* - version: link:../../primitives/computation + version: link:../../capabilities/computation '@beonauto/config': specifier: workspace:* version: link:../config + '@beonauto/coordination': + specifier: workspace:* + version: link:../../capabilities/coordination + '@beonauto/definitions': + specifier: workspace:* + version: link:../definitions '@beonauto/identity': specifier: workspace:* version: link:../identity - '@beonauto/inference': - specifier: workspace:* - version: link:../../primitives/inference '@beonauto/interaction': specifier: workspace:* - version: link:../../primitives/interaction + version: link:../../capabilities/interaction '@beonauto/ledger': specifier: workspace:* version: link:../ledger @@ -478,15 +704,12 @@ importers: '@beonauto/operations': specifier: workspace:* version: link:../operations - '@beonauto/orchestration': + '@beonauto/reasoning': specifier: workspace:* - version: link:../../primitives/orchestration - '@beonauto/recollection': + version: link:../../capabilities/reasoning + '@beonauto/recall': specifier: workspace:* - version: link:../../primitives/recollection - '@beonauto/specs': - specifier: workspace:* - version: link:../specs + version: link:../../capabilities/recall '@beonauto/workflow-engine': specifier: workspace:* version: link:../workflow-engine @@ -510,46 +733,6 @@ importers: specifier: 'catalog:' version: 5.0.2(@opentelemetry/api@1.9.1)(@types/node@26.6.3)(@vitest/coverage-v8@5.0.2)(vite@8.3.1(@types/node@26.6.3)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.51.2)(yaml@2.9.1)) - packages/specs: - dependencies: - '@beonauto/mcp': - specifier: workspace:* - version: link:../mcp - '@beonauto/operations': - specifier: workspace:* - version: link:../operations - '@beonauto/workflow-engine': - specifier: workspace:* - version: link:../workflow-engine - effect: - specifier: 'catalog:' - version: 4.0.0 - liquidjs: - specifier: 10.29.0 - version: 10.29.0 - yaml: - specifier: 2.9.1 - version: 2.9.1 - devDependencies: - '@beonauto/brains': - specifier: workspace:* - version: link:../brains - '@beonauto/ledger': - specifier: workspace:* - version: link:../ledger - '@types/pg': - specifier: 8.23.1 - version: 8.23.1 - '@vitest/coverage-v8': - specifier: 'catalog:' - version: 5.0.2(vitest@5.0.2) - pg: - specifier: 8.23.1 - version: 8.23.1 - vitest: - specifier: 'catalog:' - version: 5.0.2(@opentelemetry/api@1.9.1)(@types/node@26.6.3)(@vitest/coverage-v8@5.0.2)(vite@8.3.1(@types/node@26.6.3)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.51.2)(yaml@2.9.1)) - packages/workflow-engine: dependencies: '@beonauto/ledger': @@ -580,15 +763,15 @@ importers: '@beonauto/brains': specifier: workspace:* version: link:../brains + '@beonauto/definitions': + specifier: workspace:* + version: link:../definitions '@beonauto/ledger': specifier: workspace:* version: link:../ledger '@beonauto/operations': specifier: workspace:* version: link:../operations - '@beonauto/specs': - specifier: workspace:* - version: link:../specs '@beonauto/workflow-engine': specifier: workspace:* version: link:../workflow-engine @@ -624,189 +807,6 @@ importers: specifier: 2.9.1 version: 2.9.1 - primitives/computation: - dependencies: - '@beonauto/operations': - specifier: workspace:* - version: link:../../packages/operations - '@beonauto/specs': - specifier: workspace:* - version: link:../../packages/specs - '@beonauto/workflow-engine': - specifier: workspace:* - version: link:../../packages/workflow-engine - effect: - specifier: 'catalog:' - version: 4.0.0 - devDependencies: - '@vitest/coverage-v8': - specifier: 'catalog:' - version: 5.0.2(vitest@5.0.2) - vitest: - specifier: 'catalog:' - version: 5.0.2(@opentelemetry/api@1.9.1)(@types/node@26.6.3)(@vitest/coverage-v8@5.0.2)(vite@8.3.1(@types/node@26.6.3)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.51.2)(yaml@2.9.1)) - - primitives/inference: - dependencies: - '@ai-sdk/amazon-bedrock': - specifier: 5.0.103 - version: 5.0.103(zod@4.6.5) - '@ai-sdk/anthropic': - specifier: 4.0.70 - version: 4.0.70(zod@4.6.5) - '@ai-sdk/azure': - specifier: 4.0.87 - version: 4.0.87(zod@4.6.5) - '@ai-sdk/google': - specifier: 4.0.87 - version: 4.0.87(zod@4.6.5) - '@ai-sdk/google-vertex': - specifier: 5.0.100 - version: 5.0.100(supports-color@8.1.1)(zod@4.6.5) - '@ai-sdk/openai': - specifier: 4.0.83 - version: 4.0.83(zod@4.6.5) - '@ai-sdk/openai-compatible': - specifier: 3.0.61 - version: 3.0.61(zod@4.6.5) - '@aws-sdk/credential-providers': - specifier: 3.1144.0 - version: 3.1144.0 - '@beonauto/config': - specifier: workspace:* - version: link:../../packages/config - '@beonauto/mcp': - specifier: workspace:* - version: link:../../packages/mcp - '@beonauto/operations': - specifier: workspace:* - version: link:../../packages/operations - '@beonauto/outbound': - specifier: workspace:* - version: link:../../packages/outbound - '@beonauto/specs': - specifier: workspace:* - version: link:../../packages/specs - ai: - specifier: 7.0.124 - version: 7.0.124(zod@4.6.5) - effect: - specifier: 'catalog:' - version: 4.0.0 - google-auth-library: - specifier: 10.9.1 - version: 10.9.1(supports-color@8.1.1) - liquidjs: - specifier: 10.29.0 - version: 10.29.0 - zod: - specifier: 4.6.5 - version: 4.6.5 - devDependencies: - '@types/json-schema': - specifier: 7.0.15 - version: 7.0.15 - '@vitest/coverage-v8': - specifier: 'catalog:' - version: 5.0.2(vitest@5.0.2) - vitest: - specifier: 'catalog:' - version: 5.0.2(@opentelemetry/api@1.9.1)(@types/node@26.6.3)(@vitest/coverage-v8@5.0.2)(vite@8.3.1(@types/node@26.6.3)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.51.2)(yaml@2.9.1)) - optionalDependencies: - '@azure/identity': - specifier: 4.13.3 - version: 4.13.3(supports-color@8.1.1) - - primitives/interaction: - dependencies: - '@beonauto/mcp': - specifier: workspace:* - version: link:../../packages/mcp - '@beonauto/operations': - specifier: workspace:* - version: link:../../packages/operations - '@beonauto/specs': - specifier: workspace:* - version: link:../../packages/specs - '@beonauto/workflow-engine': - specifier: workspace:* - version: link:../../packages/workflow-engine - effect: - specifier: 'catalog:' - version: 4.0.0 - liquidjs: - specifier: 10.29.0 - version: 10.29.0 - devDependencies: - '@vitest/coverage-v8': - specifier: 'catalog:' - version: 5.0.2(vitest@5.0.2) - vitest: - specifier: 'catalog:' - version: 5.0.2(@opentelemetry/api@1.9.1)(@types/node@26.6.3)(@vitest/coverage-v8@5.0.2)(vite@8.3.1(@types/node@26.6.3)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.51.2)(yaml@2.9.1)) - - primitives/orchestration: - dependencies: - '@beonauto/config': - specifier: workspace:* - version: link:../../packages/config - '@beonauto/operations': - specifier: workspace:* - version: link:../../packages/operations - '@beonauto/specs': - specifier: workspace:* - version: link:../../packages/specs - '@beonauto/workflow-engine': - specifier: workspace:* - version: link:../../packages/workflow-engine - '@beonauto/workflow-host': - specifier: workspace:* - version: link:../../packages/workflow-host - '@openworkflowspec/sdk': - specifier: 1.0.3-alpha8 - version: 1.0.3-alpha8 - effect: - specifier: 'catalog:' - version: 4.0.0 - yaml: - specifier: 2.9.1 - version: 2.9.1 - devDependencies: - '@beonauto/api': - specifier: workspace:* - version: link:../../packages/api - '@vitest/coverage-v8': - specifier: 'catalog:' - version: 5.0.2(vitest@5.0.2) - vitest: - specifier: 'catalog:' - version: 5.0.2(@opentelemetry/api@1.9.1)(@types/node@26.6.3)(@vitest/coverage-v8@5.0.2)(vite@8.3.1(@types/node@26.6.3)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.51.2)(yaml@2.9.1)) - - primitives/recollection: - dependencies: - '@beonauto/operations': - specifier: workspace:* - version: link:../../packages/operations - '@beonauto/specs': - specifier: workspace:* - version: link:../../packages/specs - '@beonauto/workflow-engine': - specifier: workspace:* - version: link:../../packages/workflow-engine - '@beonauto/workflow-host': - specifier: workspace:* - version: link:../../packages/workflow-host - effect: - specifier: 'catalog:' - version: 4.0.0 - devDependencies: - '@vitest/coverage-v8': - specifier: 'catalog:' - version: 5.0.2(vitest@5.0.2) - vitest: - specifier: 'catalog:' - version: 5.0.2(@opentelemetry/api@1.9.1)(@types/node@26.6.3)(@vitest/coverage-v8@5.0.2)(vite@8.3.1(@types/node@26.6.3)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.51.2)(yaml@2.9.1)) - packages: '@ai-sdk/amazon-bedrock@5.0.103': diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 9354c9d9d..7813b2140 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,6 +1,6 @@ packages: - packages/* - - primitives/* + - capabilities/* catalog: vitest: 5.0.2 diff --git a/primitives/dream/README.md b/primitives/dream/README.md deleted file mode 100644 index b2aa985ff..000000000 --- a/primitives/dream/README.md +++ /dev/null @@ -1,5 +0,0 @@ -# Dream - -Dream is a planned optional process that revisits recorded experience and explores associations. Given a subject or goal, it may suggest ideas or changes to a method and iterate on those suggestions. Its findings, called Inspirations, can be considered by a reasoning function and evaluated for the current task. - -Dream uses functions and workflows; it is not a sixth function type. This directory contains a design note only. It does not implement learning, automatic changes or guaranteed improvements. See [History and Dream](../../docs/concepts/history-and-dream.md). diff --git a/primitives/inference/src/primitive/spec-execution.ts b/primitives/inference/src/primitive/spec-execution.ts deleted file mode 100644 index 38e47ff97..000000000 --- a/primitives/inference/src/primitive/spec-execution.ts +++ /dev/null @@ -1,49 +0,0 @@ -import type { ToolAccess } from '@beonauto/mcp'; -import type { RunContext, Finished } from '@beonauto/specs'; -import { Clock, Effect, type Schema } from 'effect'; - -import type { LanguageModel } from '../model/language-model.ts'; -import type { ReasoningFunctionDefinitionDocument } from '../spec/reasoning-function-definition.ts'; -import { finishedWith } from './execution-record.ts'; -import type { SpecRejection } from './model-rejection.ts'; -import { preparedInput } from './prepared-input.ts'; -import { renderedPrompt } from './rendered-prompt.ts'; -import { answerOf } from './spec-answer.ts'; - -export interface ExecutionServices { - readonly languageModel: LanguageModel['Service']; - readonly clock?: Clock.Clock; - readonly tools?: ToolAccess; -} - -function outputOf({ text, json }: { readonly text: string; readonly json?: Schema.Json }): Schema.Json { - return json === undefined ? text : json; -} - -export function specExecution({ - languageModel, - clock, - tools: access, -}: ExecutionServices): ( - spec: ReasoningFunctionDefinitionDocument, - input: Schema.Json, - execution: RunContext, -) => Effect.Effect { - const currentTime = clock === undefined ? Clock.currentTimeMillis : clock.currentTimeMillis; - return Effect.fnUntraced(function* ( - spec: ReasoningFunctionDefinitionDocument, - input: Schema.Json, - execution: RunContext, - ) { - const fields = yield* preparedInput(input, spec.input); - const now = new Date(yield* currentTime).toISOString(); - const prompt = yield* renderedPrompt(spec.template, { input: fields, today: now.slice(0, 10), now }); - const result = yield* answerOf({ languageModel, access, spec, prompt, execution }); - return yield* finishedWith(outputOf(result), { - prompt, - settings: spec.settings, - format: spec.output.type, - result, - }); - }); -} diff --git a/primitives/interaction/src/primitive/primitive-name.ts b/primitives/interaction/src/primitive/primitive-name.ts deleted file mode 100644 index a05c5f04a..000000000 --- a/primitives/interaction/src/primitive/primitive-name.ts +++ /dev/null @@ -1 +0,0 @@ -export const interactionPrimitive = 'interaction'; diff --git a/primitives/orchestration/README.md b/primitives/orchestration/README.md deleted file mode 100644 index 814e104e2..000000000 --- a/primitives/orchestration/README.md +++ /dev/null @@ -1,54 +0,0 @@ -# @beonauto/orchestration - -The workflow adapter parses a `WorkflowDefinitionDocument` from YAML in the Open Workflow Specification DSL. This document type is an alias for `JsonObject`; the parser applies the existing DSL checks. It is separate from the named, versioned definition stored in the registry. The adapter runs the workflow machine of [`@beonauto/workflow-engine`](../../packages/workflow-engine), hosted in Node by [`@beonauto/workflow-host`](../../packages/workflow-host). Workflows coordinate functions and control steps; they are not another function type. Its API identifier and package name are `orchestration`. - -Public documentation explains [workflows and their availability](../../docs/concepts/workflows.md) and [the workflow format](../../docs/reference/workflow-format.md), published at [on.auto/docs](https://on.auto/docs/). The repository-only [workflow execution reference](../../docs/engineering/reference/workflow-format.md) and [workflow operations guide](../../docs/engineering/self-host/workflows.md) hold the implementation details, and [decision 0001](../../docs/decisions/0001-workflow-engine-on-the-ledger.md) why workflows run on an engine on the ledger. - -## Entry - -`src/index.ts` exports: - -- `makeWorkflowAdapter({ runs, mostDurationMs, longestCallMs })`: the workflow adapter, with `WorkflowAdapterDependencies` as its options type. `runs` is the host that starts runs; `mostDurationMs` is the most a run may last, which `create_spec` checks every duration of a document against and a run is stopped at exactly; `longestCallMs` the most a call of a run may take before its `call_deadline` timer fails the task, when the call's definition is not named as written. The adapter states that its runs finish later and may take `mostDurationMs`. -- `defineSendExecutionEvent(runs)`: `send_execution_event`, which gives a running workflow an event. -- `orchestrationMachine`: the machine's options, the functions a workflow may call (`execute_spec`) and the runtime its expressions see as `$runtime`. -- `definitionCalls(runDefinition)`: calls a saved definition from a workflow. This adapter accepts reasoning functions, workflows and custom definition types; it does not classify every extension as a brain function. -- `callResultOfEnding(ending)`: the answer of a call for the ending of the run it waits for, which the workflow host is given as its result mapping. -- `callMarginMs`: the minute a call is given beyond the longest its definition may run. -- `runPresenter`: the presenter of the run log, for the history of a run and the events of a brain. -- `definitionRunResultOf`, `RunDefinition`, `DefinitionRunRequest` and `DefinitionRunResult`, for the server, which runs a definition called by a workflow through its operations. - -## A run of a workflow - -`execute_spec` of a workflow starts its run with the `started` input: the document, the input, the run's limits, a random seed for the draws of the run, and as attributes the org, the brain, the execution id, the spec's name and version, the caller who started it, its reaction depth, `depth`, its call depth, `call_depth`, and `lineage`: the id of the `execution_started` that began the run and the run at the top of its tree, which the workflow host writes with each record of the run (`@beonauto/workflow-host`). The run's log is `runs/` under the brain, so a run belongs to one execution, and the execution records that it finishes later, with an empty record, and stays `started` until the run settles it; a run that ends in its first input settles it before the deferral, and `execute_spec` answers its ending. The limits name, for each task that calls `execute_spec` with a `primitive` and a `name` written out, the longest a run of that definition may take, plus `callMarginMs` (`src/runs/call-limits.ts`), read through the run context's `longestRunOf` when the run starts; every other call has `longestCallMs`. A retry with the execution id of a run that is going answers the execution as it stands. A run never starts twice in one log, so a retry with the execution id of a run that ended and settled its execution without a final result, `unavailable` or `failed`, is rejected with `conflict`: run the workflow again under a new execution id. While the server is stopping, `execute_spec` of a workflow is rejected with `unavailable`. - -`send_execution_event` gives the run an `event_received` input, with an id made when the event has none, the time it was sent, and, when it has no source, `/callers/` of the caller who sent it, a source under a prefix the brain keeps for itself, so that a sent event always says where it came from and no event from outside poses as one. An event the run took before is answered as delivered, since an event is taken once by its id; a run that has not started yet, or has ended, is `not_found`; and a run that cannot take the event at that moment, because its log kept changing or the server is stopping, is `unavailable`. Since a `listen` filter matches on the event's attributes, an event may not take a type or a source of what the brain records itself: `refusingTheBrainsOwnAttributes` of `@beonauto/specs` refuses them with `invalid_input` at `/event/type` and `/event/source`, as `publish_event` does. Its `source` is a URI reference that is not empty, `EventSourceSchema`, and its text follows the rules of a published event too, through `refusingForbiddenCharacters` and `refusingBlankText` of `@beonauto/specs`: no control character, lone surrogate or noncharacter, and a type, id and subject that hold a character that is not a space. - -A call of a workflow, `call: execute_spec`, executes the spec it names through the operations for the caller who started the run, under an execution id derived from the workflow's execution id, the task and the run of the task (`src/calls/nested-execution-id.ts`), so a call started again is the same execution. The machine names that execution on the call's `waiting` entry through `childOf` (`src/calls/child-run.ts`), which derives it the same way and names nothing for arguments the call would refuse. The call starts it with a lineage, the `step_waiting` event of its step as the cause and the workflow's tree as the correlation, with the reaction depth of the run, and with a call depth one more than the run's and the call it answers, which the server passes in the brain request of the in-process start, never in its input. A call of a run that finishes later, such as one of another workflow, answers the host that it waits for that run, by its execution id; the host gives the call the ending of that run, mapped by `callResultOfEnding`. Arguments that name no spec are rejected as `invalid_arguments`, a `validation` error of the task. A rejection keeps its issues in its detail and its `kind` and `because`, which the error of the task carries for a `catch` to read, and an output of more than 1 MiB fails the call. - -## Triggers - -A document's `schedule` names its triggers ([decision 0015](../../docs/decisions/0015-several-triggers.md)), `on`, `cron` and `every`, any one, two or three of them, each a trigger of its own, checked on its own when the spec is saved by `scheduleRejections` (`src/document/workflow-schedule.ts`), which the policy of the engine calls: - -- `on`: `one` filter, or `any` of a list of at least one and at most 64, `mostTriggerFilters`, each a literal event filter of the engine (`literalFilterOf`): its `with` names the type of the events it takes as text, and its source and subject as text too when it names them; a `data` expression that uses a variable such as `$workflow` is refused, since no run exists when the trigger is matched, and so is a filter of `any` whose type and attributes an earlier one has, in any order of keys. `all` and `until` are refused. -- `cron`: five fields, minute, hour, day of month and month, and day of week, read in UTC by `cronRejectionOf` of `@beonauto/workflow-host`, which refuses an expression that cannot be read or names no time that comes. -- `every`: a duration of the DSL of at least a minute. - -`after` is refused, and so is a schedule that names none; two keys of one name cannot be written, since the YAML reader refuses them. `triggersOfDocument` reads the triggers once, at save, in the order the schedule names them, each identified by its kind, `event`, `cron` or `every`, and its reference in the document, `/schedule/on`, `/schedule/cron` or `/schedule/every`: an event trigger with each filter's reference, type and attributes, a cron with its expression and an every with its period in milliseconds. The summary carries them as `triggers`, the `TriggerSchema` of `@beonauto/specs`, so they travel on the definition's record to the workflow host, which keeps one row a trigger and starts the runs (see `@beonauto/workflow-host`); nothing reads a saved source again for them. `cronRejectionOf` stays in the host, which owns the time arithmetic the check must agree with. - -## Emitting an event - -An `emit` task publishes an event to the brain. Its `event.with` takes a `type` and a `source`, and no `id`, which the engine gives it from its call key; a type or a source written out that the brain records itself is refused when the spec is saved (`emitRejections`), and an event computed at run time that the brain would not record, `emittedEventRefusal` of `@beonauto/specs`, fails the task with a `validation` error (`emitRefusal`). - -## Histories - -`runPresenter` presents each event of a run log, `input_applied`, first as `workflow_input_applied`: the kind and key of the input (the execution id for a start or a cancel, the timer id, the call key or the event id), the status of an answer, with the kind and because of a rejection that names them as `rejection`, which the summary also says in words, and the type of an event, the count of the steps the input moved and the first five, each with its task, run and outcome, and the kinds of output it made. Its `cursor` is the record's cursor with the index 0, the event's own place in the record, so a read on from it goes on with the record's step events, oldest first, and with the records before it, newest first. The engine records one entry for each run of a task the input moved, with the outcome it had when the input ended, so the steps are counted once for each run. The patch is never shown. Keys, types and task references are cut at 256 bytes, so the data stays within 4 KiB at the largest event a run stores (`src/presenting/run-presenter.test.ts`). - -It then presents each step entry of the record as an event of its own, in the entry's order (`src/presenting/step-events.ts`): `step_started`, `step_waiting`, `step_finished` for `completed`, `step_failed` for `raised`, `timed_out` and `cancelled`, and `step_skipped`. Each has the record's `at`, its `data` the step's `name`, `reference` cut at 256 bytes, `run` and `times`, with `waits_for` on a wait, the child's `execution_id` on the wait of a call, and on a failure its `outcome` and its error's `type` and `title`, cut at 256 bytes and 1 KiB; its summary names the step by its name in words. Its `id` is `stepEventIdOf` of the run's execution id and the entry's reference, run, outcome and times, so it is the same on every read; its `causation_id` is the id of the step event its `caused_by` names, or the record's id for `input`; its `cursor` is the record's cursor with the index of the event, so a read on from it goes on with the next step. Records of earlier formats show no step events. - -## Testing - -`src/workflows` holds the tests of how a workflow runs, each a document run through the memory driver of the engine (`src/testing/workflows.ts`, `interpret`), with the commands a run gave its ports and the settlement it ended with. `src/input-logs` holds fifteen recorded paths, whose input logs in `input-logs/` are the replay corpus: each replays through the machine to the events it recorded, and each runs today as it was recorded (`RECORD_INPUT_LOGS=1` records them again). The primitive and `send_execution_event` are tested on a brain whose workflows run on a host over a private SQLite database in memory (`src/testing/orchestrated-brain.ts`). - -## Source - -`src/document` parses a spec document: YAML, the DSL schema and graph, the policy, the schedule and its triggers, the issues and the summary. `src/primitive` holds the primitive, whose guide is the public reference page, served to agents as `workflow`, `src/events` the event operation, `src/calls` what a call of a workflow does, `src/runs` the machine's options and a run's attributes, `src/presenting` the presenter of a run's log, `src/workflows` the tests of how a workflow runs, `src/input-logs` the recorded paths and their corpus, and `src/testing` what the tests share. diff --git a/primitives/orchestration/src/calls/child-run.ts b/primitives/orchestration/src/calls/child-run.ts deleted file mode 100644 index 72212567f..000000000 --- a/primitives/orchestration/src/calls/child-run.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { textField, type ChildCall } from '@beonauto/workflow-engine'; - -import { isArgumentsProblem, specArgumentsOf } from '../document/spec-arguments.ts'; -import { executeSpecFunction } from '../document/workflow-functions.ts'; -import { nestedExecutionId } from './nested-execution-id.ts'; - -export function childRunOf({ - function: name, - reference, - run, - arguments: given, - attributes, -}: ChildCall): string | undefined { - const workflow = textField(attributes, 'execution_id'); - if (name !== executeSpecFunction || workflow === undefined || isArgumentsProblem(specArgumentsOf(given))) { - return undefined; - } - return nestedExecutionId(workflow, reference, run); -} diff --git a/primitives/orchestration/src/calls/nested-execution-id.ts b/primitives/orchestration/src/calls/nested-execution-id.ts deleted file mode 100644 index c050168ff..000000000 --- a/primitives/orchestration/src/calls/nested-execution-id.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { uuidV5 } from '@beonauto/operations'; - -const nestedExecutions = '9b1f3a52-6c0d-4b8e-9f27-3e5d1c7a2b40'; - -export function nestedExecutionId(runId: string, reference: string, run: number): string { - return uuidV5(nestedExecutions, `${runId}${reference}#${run}`); -} diff --git a/primitives/orchestration/src/document/spec-arguments.ts b/primitives/orchestration/src/document/spec-arguments.ts deleted file mode 100644 index ea7c11d38..000000000 --- a/primitives/orchestration/src/document/spec-arguments.ts +++ /dev/null @@ -1,45 +0,0 @@ -import { type ErrorKind, field, isObject, type Json, jsonBytesOf, textField } from '@beonauto/workflow-engine'; - -import { executeSpecFunction } from './workflow-functions.ts'; - -export interface SpecArguments { - readonly primitive: string; - readonly name: string; - readonly input: Json; -} - -export interface ArgumentsProblem { - readonly kind: ErrorKind; - readonly title: string; -} - -const mostSpecInputBytes = 262_144; - -export function specArgumentsOf(arguments_: Json): SpecArguments | ArgumentsProblem { - if (!isObject(arguments_)) { - return { kind: 'validation', title: `${executeSpecFunction} takes with: { primitive, name, input }` }; - } - const primitive = textField(arguments_, 'primitive'); - const name = textField(arguments_, 'name'); - if (primitive === undefined || name === undefined) { - return { kind: 'validation', title: `${executeSpecFunction} needs a string primitive and a string name` }; - } - const input = field(arguments_, 'input') ?? {}; - const bytes = jsonBytesOf(input); - return bytes > mostSpecInputBytes - ? { - kind: 'validation', - title: `The input of ${executeSpecFunction} takes ${bytes} bytes as JSON, more than the ${mostSpecInputBytes} a run takes`, - } - : { primitive, name, input }; -} - -export function isArgumentsProblem(value: SpecArguments | ArgumentsProblem): value is ArgumentsProblem { - return 'title' in value; -} - -export function failureChain(error: unknown): string { - return error instanceof Error && error.cause !== undefined - ? `${String(error)}: ${failureChain(error.cause)}` - : String(error); -} diff --git a/scripts/docs-function-taxonomy.test.ts b/scripts/docs-function-taxonomy.test.ts index af0790ad2..8b10d17fa 100644 --- a/scripts/docs-function-taxonomy.test.ts +++ b/scripts/docs-function-taxonomy.test.ts @@ -8,9 +8,9 @@ import { test } from 'node:test'; import { functionCategoryLabels, functionDescriptions, - functionKindOrder, + functionTypeOrder, functionResourceLabels, -} from '@beonauto/specs'; +} from '@beonauto/definitions'; import { checkFunctionTaxonomy, @@ -31,17 +31,17 @@ function withTemporaryAsset(verify: (path: string) => void): void { await test('the published taxonomy comes from the runtime metadata in canonical order', () => { const document: unknown = JSON.parse(functionTaxonomyDocument); assert.deepEqual(document, { - schema: 1, - source: '@beonauto/specs', - functions: functionKindOrder.map((kind) => ({ - kind, - label: functionCategoryLabels[kind], - singular: functionResourceLabels[kind].singular, - plural: functionResourceLabels[kind].plural, - description: functionDescriptions[kind], + schema: 2, + source: '@beonauto/definitions', + functions: functionTypeOrder.map((type) => ({ + type, + label: functionCategoryLabels[type], + singular: functionResourceLabels[type].singular, + plural: functionResourceLabels[type].plural, + description: functionDescriptions[type], })), }); - assert.deepEqual(functionKindOrder, ['reason', 'interact', 'predict', 'recall', 'compute']); + assert.deepEqual(functionTypeOrder, ['reasoning', 'interaction', 'prediction', 'recall', 'computation']); assert.ok(functionTaxonomyDocument.endsWith('\n')); }); diff --git a/scripts/docs-function-taxonomy.ts b/scripts/docs-function-taxonomy.ts index b7790647d..3a14a10d4 100644 --- a/scripts/docs-function-taxonomy.ts +++ b/scripts/docs-function-taxonomy.ts @@ -4,20 +4,20 @@ import { dirname, resolve } from 'node:path'; import { functionCategoryLabels, functionDescriptions, - functionKindOrder, + functionTypeOrder, functionResourceLabels, -} from '@beonauto/specs'; +} from '@beonauto/definitions'; export const functionTaxonomyDocument = `${JSON.stringify( { - schema: 1, - source: '@beonauto/specs', - functions: functionKindOrder.map((kind) => ({ - kind, - label: functionCategoryLabels[kind], - singular: functionResourceLabels[kind].singular, - plural: functionResourceLabels[kind].plural, - description: functionDescriptions[kind], + schema: 2, + source: '@beonauto/definitions', + functions: functionTypeOrder.map((type) => ({ + type, + label: functionCategoryLabels[type], + singular: functionResourceLabels[type].singular, + plural: functionResourceLabels[type].plural, + description: functionDescriptions[type], })), }, null, diff --git a/scripts/docs-quick-start.test.ts b/scripts/docs-quick-start.test.ts index 021632418..acfbaf2c3 100644 --- a/scripts/docs-quick-start.test.ts +++ b/scripts/docs-quick-start.test.ts @@ -22,7 +22,7 @@ await test('the local quick start connects an agent and runs a first function wi 'reasoning function called check-brief', 'ask it to run the saved function on:', 'Promote our reporting tool to finance teams with a USD 5,000 budget.', - 'the recorded run, including its execution id', + 'the recorded run, including its run id', 'missing measurable goal', '## 6. Give your brain tools', 'Create `auto-brain.yaml` at the root of the repository, which Git ignores, holding exactly this', diff --git a/scripts/docs-vocabulary.test.ts b/scripts/docs-vocabulary.test.ts new file mode 100644 index 000000000..4e7192bf7 --- /dev/null +++ b/scripts/docs-vocabulary.test.ts @@ -0,0 +1,189 @@ +import assert from 'node:assert/strict'; +import { execFileSync } from 'node:child_process'; +import { existsSync, lstatSync, readFileSync } from 'node:fs'; +import { join, resolve } from 'node:path'; +import { test } from 'node:test'; + +const root = resolve(import.meta.dirname, '..'); + +const oldWords: readonly string[] = [ + '[pP]rimitive|PRIMITIVE', + '(? `packages/workflow-engine/corpus/format-${format}.json`), + ...['one', 'two', 'three', 'four', 'five', 'six'].map( + (format) => `packages/workflow-engine/src/run-log/formats/format-${format}.ts`, + ), + 'packages/workflow-engine/src/run-log/formats/format-six-records.ts', + ...['one', 'two', 'four', 'six', 'six-records'].map( + (format) => `packages/workflow-engine/src/run-log/formats/format-${format}.test.ts`, + ), +]; + +const keptFiles: readonly string[] = ['pnpm-lock.yaml', 'scripts/docs-vocabulary.test.ts', ...frozenFormats]; + +interface Allowance { + readonly text: string; + readonly in: readonly string[]; +} + +const internalTerms = 'packages/api/src/testing/internal-terms.ts'; + +const internalTermsTest = 'packages/api/src/testing/internal-terms.test.ts'; + +const reasoningReference = 'docs/engineering/reference/reasoning-format.md'; + +const allowances: readonly Allowance[] = [ + { + text: 'https://open-workflow-specification.org/spec/1.0.0/errors', + in: [ + 'capabilities/coordination/input-logs/retry-with-backoff.json', + 'capabilities/coordination/input-logs/timeout-fires.json', + 'capabilities/coordination/src/workflows/call-results.test.ts', + 'capabilities/coordination/src/workflows/error-tasks.test.ts', + 'docs/reference/workflow-format.md', + 'packages/server/src/computation/computation-workflows.test.ts', + 'packages/workflow-engine/corpus/format-7.json', + 'packages/workflow-engine/src/decider/open-calls.test.ts', + 'packages/workflow-engine/src/dsl/raised-error.test.ts', + 'packages/workflow-engine/src/dsl/raised-error.ts', + 'packages/workflow-engine/src/filters/event-filter.test.ts', + 'packages/workflow-engine/src/steps/step-causes.test.ts', + ], + }, + { + text: 'https://github.com/cloudevents/spec/blob/v1.0.2/cloudevents/spec.md', + in: ['docs/reference/http.md', 'packages/definitions/README.md'], + }, + { + text: 'inferenceConfig', + in: [ + 'capabilities/reasoning/src/adapter/cloud-providers.test.ts', + 'capabilities/reasoning/src/definition/definition-settings.test.ts', + reasoningReference, + ], + }, + { text: 'inferenceGeo', in: ['capabilities/reasoning/src/model/offered-provider-options.ts', reasoningReference] }, + { + text: 'inference-profile', + in: [ + 'capabilities/reasoning/src/catalog/catalog-leaks.test.ts', + 'capabilities/reasoning/src/catalog/catalog-listing.test.ts', + 'capabilities/reasoning/src/definition/definition-settings.test.ts', + 'capabilities/reasoning/src/settings/catalog-settings.test.ts', + ], + }, + { text: 'application inference profile', in: ['docs/engineering/self-host/models.md'] }, + { text: 'toolSpec', in: ['capabilities/reasoning/src/adapter/cloud-providers.test.ts'] }, + { text: 'code_execution', in: ['capabilities/reasoning/src/testing/model-lists.ts'] }, + { text: 'execution-denied', in: ['capabilities/reasoning/src/tools/final-step.test.ts'] }, + ...['primitives?', 'executions?', 'inference', 'orchestration', 'recollection'].map((term) => ({ + text: String.raw`/\b${term}\b/iu`, + in: [internalTerms], + })), + ...[ + "['Created the inference spec.', 'spec']", + "['The specs of the brain.', 'specs']", + "['A primitive of the brain.', 'primitive']", + "['The execution started.', 'execution']", + "['It ran an inference.', 'inference']", + "['An orchestration started.', 'orchestration']", + "['It folds a recollection.', 'recollection']", + ].map((leak) => ({ text: leak, in: [internalTermsTest] })), + { text: "**inference** names a model call and its provider's terms", in: ['CLAUDE.md'] }, +]; + +const textOnly = new TextDecoder('utf-8', { fatal: true }); + +function textOf(path: string): string | undefined { + try { + return textOnly.decode(readFileSync(join(root, path))); + } catch { + return undefined; + } +} + +const tracked = execFileSync('git', ['ls-files', '-z'], { cwd: root, encoding: 'utf8' }) + .split('\0') + .filter((path) => path !== ''); + +const searched = tracked.filter( + (path) => + !keptFolders.some((folder) => path.startsWith(folder)) && + !keptFiles.includes(path) && + existsSync(join(root, path)) && + !lstatSync(join(root, path)).isSymbolicLink(), +); + +function placeOf(allowance: Allowance, path: string): string { + return `${path}: ${allowance.text}`; +} + +const allowedOccurrences = new Map(); + +function maskedAllowances(path: string, text: string): string { + return allowances + .filter((allowance) => allowance.in.includes(path)) + .reduce((masked, allowance) => { + const pieces = masked.split(allowance.text); + const place = placeOf(allowance, path); + allowedOccurrences.set(place, (allowedOccurrences.get(place) ?? 0) + pieces.length - 1); + return pieces.join(' '.repeat(allowance.text.length)); + }, text); +} + +function lineAt(text: string, index: number): number { + return text.slice(0, index).split('\n').length; +} + +interface Found { + readonly index: number; + readonly word: string; +} + +function foundIn(masked: string, word: string): readonly Found[] { + const found: Found[] = []; + for (const match of masked.matchAll(new RegExp(word, 'gu'))) { + found.push({ index: match.index, word: match[0] }); + } + return found; +} + +function oldWordsIn(path: string, text: string): readonly string[] { + const masked = maskedAllowances(path, text); + const found = oldWords + .flatMap((word) => foundIn(masked, word)) + .toSorted((one, other) => one.index - other.index) + .map(({ index, word }) => `${path}:${lineAt(masked, index)}: ${word}`); + return [...new Set(found)]; +} + +const findings = searched.flatMap((path) => { + const text = textOf(path); + return text === undefined ? [] : oldWordsIn(path, text); +}); + +const allowedButAbsent = allowances + .flatMap((allowance) => allowance.in.map((path) => placeOf(allowance, path))) + .filter((place) => (allowedOccurrences.get(place) ?? 0) === 0); + +const leftOutButUntracked = keptFiles.filter((path) => !tracked.includes(path)); + +await test('no tracked file says an old word, outside the records, the lockfile, the patches and the frozen formats, but the texts it allows', () => { + assert.deepEqual(findings, []); +}); + +await test('every text the search allows still occurs in each file it is allowed in, and every file it leaves out is still tracked', () => { + assert.deepEqual([...allowedButAbsent, ...leftOutButUntracked], []); +}); diff --git a/scripts/docs.test.ts b/scripts/docs.test.ts index 38f4e5995..2f1039c9f 100644 --- a/scripts/docs.test.ts +++ b/scripts/docs.test.ts @@ -178,7 +178,7 @@ await test('the first-brain tutorial supplies inputs and observable checks for t assert.ok(tutorial.includes('review-campaign-brief')); assert.ok(tutorial.includes('USD 10,000')); assert.ok(tutorial.includes('status: succeeded')); - assert.ok(tutorial.includes('different execution ids')); + assert.ok(tutorial.includes('different run ids')); assert.ok(tutorial.includes('same definition version')); assert.doesNotMatch(tutorial, /localhost|127\.0\.0\.1|claude-|gpt-/u); }); @@ -253,7 +253,7 @@ await test('public workflows are available and link their format and tutorial, w assert.ok(markdownDestinations(workflows).includes('../tutorials/first-workflow.md')); assert.ok(routes.includes('/reference/workflow-format')); assert.ok(routes.includes('/tutorials/first-workflow')); - assert.ok(tutorial.includes('send_execution_event')); + assert.ok(tutorial.includes('send_run_event')); assert.ok(tutorial.includes('status: succeeded')); assert.doesNotMatch(tutorial, /localhost|127\.0\.0\.1|claude-|gpt-/u); const removedPages = ['get-started/self-hosted', 'reference/http-tutorial']; diff --git a/scripts/try-inference.sh b/scripts/try-reasoning.sh similarity index 75% rename from scripts/try-inference.sh rename to scripts/try-reasoning.sh index b9d7de0f5..fb9622b10 100755 --- a/scripts/try-inference.sh +++ b/scripts/try-reasoning.sh @@ -1,7 +1,7 @@ #!/usr/bin/env bash set -euo pipefail -usage='Usage: scripts/try-inference.sh ' +usage='Usage: scripts/try-reasoning.sh ' base_url="${1:?$usage}" model="${2:?$usage}" org="${AUTO_BRAIN_ORG:-local}" @@ -25,6 +25,6 @@ call --data "{\"brain\": \"$brain\", \"name\": \"Trying reasoning functions\"}" source="$(printf '%s\n' '---' "model: $model" 'config: {max_output_tokens: 200}' '---' \ '{% system %}Answer in one sentence.{% endsystem %}Greet {{ input.name }} and name today, {{ today }}.')" call --data "$(jq --null-input --arg source "$source" '{name: "greeting", source: $source}')" \ - "$brains/$brain/specs/inference" > /dev/null -execution="$(call --data '{"input": {"name": "Ada"}}' "$brains/$brain/specs/inference/greeting/execute")" -call "$brains/$brain/executions/$(jq --raw-output .execution_id <<< "$execution")" | jq . + "$brains/$brain/definitions/reasoning" > /dev/null +run="$(call --data '{"input": {"name": "Ada"}}' "$brains/$brain/definitions/reasoning/greeting/run")" +call "$brains/$brain/runs/$(jq --raw-output .run_id <<< "$run")" | jq . diff --git a/scripts/try-workflows.sh b/scripts/try-workflows.sh index 551701afb..bf8fab1b2 100755 --- a/scripts/try-workflows.sh +++ b/scripts/try-workflows.sh @@ -21,11 +21,11 @@ call() { } settled() { - local execution + local run for _ in $(seq 1 60); do - execution="$(call "$brains/$brain/executions/$1")" - if [ "$(jq --raw-output .status <<< "$execution")" != started ]; then - printf '%s\n' "$execution" + run="$(call "$brains/$brain/runs/$1")" + if [ "$(jq --raw-output .status <<< "$run")" != started ]; then + printf '%s\n' "$run" return 0 fi sleep 1 @@ -39,7 +39,7 @@ call --data "{\"brain\": \"$brain\", \"name\": \"Trying workflows\"}" "$brains" greeting="$(printf '%s\n' '---' "model: $model" 'config: {max_output_tokens: 200}' '---' \ '{% system %}Answer in one sentence.{% endsystem %}Greet {{ input.name }} and name today, {{ today }}.')" call --data "$(jq --null-input --arg source "$greeting" '{name: "greeting", source: $source}')" \ - "$brains/$brain/specs/inference" > /dev/null + "$brains/$brain/definitions/reasoning" > /dev/null welcome="$(cat << 'YAML' document: dsl: '1.0.3' @@ -49,9 +49,9 @@ document: summary: Greets a customer, then waits for their reply. do: - greet: - call: execute_spec + call: run_definition with: - primitive: inference + type: reasoning name: greeting input: name: ${ .name } @@ -67,13 +67,13 @@ do: YAML )" call --data "$(jq --null-input --arg source "$welcome" '{name: "welcome", source: $source}')" \ - "$brains/$brain/specs/orchestration" > /dev/null -started="$(call --data '{"input": {"name": "Ada"}}' "$brains/$brain/specs/orchestration/welcome/execute")" -execution_id="$(jq --raw-output .execution_id <<< "$started")" + "$brains/$brain/definitions/workflow" > /dev/null +started="$(call --data '{"input": {"name": "Ada"}}' "$brains/$brain/definitions/workflow/welcome/run")" +run_id="$(jq --raw-output .run_id <<< "$started")" if ! call --data '{"event": {"type": "com.example.customer.replied", "data": "Thank you!"}}' \ - "$brains/$brain/executions/$execution_id/events" > /dev/null; then + "$brains/$brain/runs/$run_id/events" > /dev/null; then printf 'The workflow ended before it could take the reply; it ended so:\n' >&2 fi -execution="$(settled "$execution_id")" -jq . <<< "$execution" -test "$(jq --raw-output .status <<< "$execution")" = succeeded +run="$(settled "$run_id")" +jq . <<< "$run" +test "$(jq --raw-output .status <<< "$run")" = succeeded diff --git a/tsconfig.json b/tsconfig.json index 71377aed9..5a6711f99 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -4,9 +4,9 @@ "*.ts", "packages/*/*.ts", "packages/*/measure", - "primitives/*/measure", + "capabilities/*/measure", "packages/*/src", - "primitives/*/*.ts", - "primitives/*/src" + "capabilities/*/*.ts", + "capabilities/*/src" ] } diff --git a/vitest.config.ts b/vitest.config.ts index 7149c3534..5ff658ef6 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -2,6 +2,6 @@ import { defineConfig } from 'vitest/config'; export default defineConfig({ test: { - projects: ['packages/*', 'primitives/*'], + projects: ['packages/*', 'capabilities/*'], }, });