@jr2/orchestrator 0.4.1 → 0.5.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +20 -2
- package/console/components/app.ts +2 -0
- package/console/components/fleet.ts +14 -1
- package/console/components/machine-pane.ts +41 -2
- package/console/main.ts +13 -1
- package/console/store.ts +102 -6
- package/console/style.css +21 -0
- package/docs/agents-block.md +5 -0
- package/docs/concepts/agents-and-turns.md +442 -0
- package/docs/concepts/architecture.md +230 -0
- package/docs/concepts/charts.md +143 -0
- package/docs/concepts/composition.md +357 -0
- package/docs/concepts/converge.md +254 -0
- package/docs/concepts/durable-runs.md +189 -0
- package/docs/concepts/effects.md +603 -0
- package/docs/concepts/gates.md +208 -0
- package/docs/concepts/machines-and-workflows.md +289 -0
- package/docs/concepts/pools-and-sources.md +230 -0
- package/docs/concepts/secrets-and-trust.md +222 -0
- package/docs/concepts/the-menu.md +378 -0
- package/docs/concepts/workspaces.md +678 -0
- package/docs/get-started/a-model-decides.md +563 -0
- package/docs/get-started/an-agent-in-a-sandbox.md +381 -0
- package/docs/get-started/connect-a-model.md +248 -0
- package/docs/get-started/create-an-instance.md +194 -0
- package/docs/get-started/have-your-assistant-write-a-machine.md +922 -0
- package/docs/get-started/install.md +126 -0
- package/docs/guides/add-tools-to-the-sandbox.md +295 -0
- package/docs/guides/answer-a-gate.md +304 -0
- package/docs/guides/bind-open-parts.md +223 -0
- package/docs/guides/cancel-a-run.md +146 -0
- package/docs/guides/clean-up.md +206 -0
- package/docs/guides/fix-a-failed-converge.md +361 -0
- package/docs/guides/give-the-cluster-git-credentials.md +222 -0
- package/docs/guides/iterate-on-a-machine-on-kind.md +190 -0
- package/docs/guides/let-an-agent-write-a-chart.md +226 -0
- package/docs/guides/look-inside-a-sandbox.md +174 -0
- package/docs/guides/mirror-the-kit-images.md +176 -0
- package/docs/guides/move-from-kind-to-a-shared-cluster.md +302 -0
- package/docs/guides/open-the-console.md +91 -0
- package/docs/guides/publish-a-machine-as-an-npm-package.md +350 -0
- package/docs/guides/push-and-sign-a-branch.md +486 -0
- package/docs/guides/run-a-chart-by-name.md +270 -0
- package/docs/guides/run-a-command-or-call-a-url.md +544 -0
- package/docs/guides/run-one-worker-per-item.md +321 -0
- package/docs/guides/set-an-agents-access-and-dials.md +375 -0
- package/docs/guides/size-a-workspace.md +204 -0
- package/docs/guides/start-a-run-from-a-run.md +237 -0
- package/docs/guides/start-a-run.md +207 -0
- package/docs/guides/upgrade-the-kit-version.md +240 -0
- package/docs/guides/use-a-hosted-provider.md +208 -0
- package/docs/guides/use-a-local-model.md +313 -0
- package/docs/guides/watch-a-run.md +326 -0
- package/docs/guides/write-a-workflow.md +318 -0
- package/docs/index.md +44 -0
- package/docs/llms.txt +216 -0
- package/docs/reference/authoring-surface/agent.md +128 -0
- package/docs/reference/authoring-surface/answer.md +140 -0
- package/docs/reference/authoring-surface/arg.md +60 -0
- package/docs/reference/authoring-surface/basic.md +62 -0
- package/docs/reference/authoring-surface/bearer.md +53 -0
- package/docs/reference/authoring-surface/chartschema.md +23 -0
- package/docs/reference/authoring-surface/chartschemafor.md +48 -0
- package/docs/reference/authoring-surface/command.md +191 -0
- package/docs/reference/authoring-surface/customize.md +64 -0
- package/docs/reference/authoring-surface/defineconfig.md +41 -0
- package/docs/reference/authoring-surface/defineevent.md +114 -0
- package/docs/reference/authoring-surface/dispatch.md +126 -0
- package/docs/reference/authoring-surface/fail.md +83 -0
- package/docs/reference/authoring-surface/file.md +122 -0
- package/docs/reference/authoring-surface/frompick.md +277 -0
- package/docs/reference/authoring-surface/http.md +159 -0
- package/docs/reference/authoring-surface/isfailure.md +58 -0
- package/docs/reference/authoring-surface/jr2setup.md +237 -0
- package/docs/reference/authoring-surface/open.md +74 -0
- package/docs/reference/authoring-surface/openbody.md +86 -0
- package/docs/reference/authoring-surface/paramsof.md +125 -0
- package/docs/reference/authoring-surface/pool.md +186 -0
- package/docs/reference/authoring-surface/push.md +112 -0
- package/docs/reference/authoring-surface/secret.md +118 -0
- package/docs/reference/authoring-surface/sentby.md +83 -0
- package/docs/reference/authoring-surface/source.md +51 -0
- package/docs/reference/authoring-surface/text.md +62 -0
- package/docs/reference/authoring-surface/type-agentdeclaration.md +27 -0
- package/docs/reference/authoring-surface/type-agentdefinition.md +80 -0
- package/docs/reference/authoring-surface/type-agentlogic.md +15 -0
- package/docs/reference/authoring-surface/type-agentslots.md +12 -0
- package/docs/reference/authoring-surface/type-answeraction.md +12 -0
- package/docs/reference/authoring-surface/type-arg.md +19 -0
- package/docs/reference/authoring-surface/type-argnames.md +12 -0
- package/docs/reference/authoring-surface/type-binding.md +62 -0
- package/docs/reference/authoring-surface/type-bindingof.md +12 -0
- package/docs/reference/authoring-surface/type-bodyacceptsdoor.md +15 -0
- package/docs/reference/authoring-surface/type-bodyacceptsslots.md +17 -0
- package/docs/reference/authoring-surface/type-ceilings.md +41 -0
- package/docs/reference/authoring-surface/type-chainlink.md +19 -0
- package/docs/reference/authoring-surface/type-chartaction.md +46 -0
- package/docs/reference/authoring-surface/type-chartagent.md +28 -0
- package/docs/reference/authoring-surface/type-chartevent.md +20 -0
- package/docs/reference/authoring-surface/type-chartguard.md +20 -0
- package/docs/reference/authoring-surface/type-chartinvoke.md +28 -0
- package/docs/reference/authoring-surface/type-chartjson.md +14 -0
- package/docs/reference/authoring-surface/type-chartstate.md +46 -0
- package/docs/reference/authoring-surface/type-charttext.md +40 -0
- package/docs/reference/authoring-surface/type-charttransition.md +22 -0
- package/docs/reference/authoring-surface/type-charttransitions.md +14 -0
- package/docs/reference/authoring-surface/type-chartvalue.md +15 -0
- package/docs/reference/authoring-surface/type-childat.md +12 -0
- package/docs/reference/authoring-surface/type-childslots.md +12 -0
- package/docs/reference/authoring-surface/type-commandenv.md +15 -0
- package/docs/reference/authoring-surface/type-commandinput.md +19 -0
- package/docs/reference/authoring-surface/type-commandoptions.md +78 -0
- package/docs/reference/authoring-surface/type-commandoutput.md +104 -0
- package/docs/reference/authoring-surface/type-customize.md +109 -0
- package/docs/reference/authoring-surface/type-dispatchdefinition.md +39 -0
- package/docs/reference/authoring-surface/type-dispatchinput.md +14 -0
- package/docs/reference/authoring-surface/type-dispatchopenparts.md +14 -0
- package/docs/reference/authoring-surface/type-dispatchoutput.md +65 -0
- package/docs/reference/authoring-surface/type-duration.md +15 -0
- package/docs/reference/authoring-surface/type-effectbinding.md +93 -0
- package/docs/reference/authoring-surface/type-effectlogic.md +12 -0
- package/docs/reference/authoring-surface/type-envopenparts.md +14 -0
- package/docs/reference/authoring-surface/type-eventaudience.md +27 -0
- package/docs/reference/authoring-surface/type-eventdef.md +45 -0
- package/docs/reference/authoring-surface/type-eventfrom.md +16 -0
- package/docs/reference/authoring-surface/type-eventsemantics.md +26 -0
- package/docs/reference/authoring-surface/type-execoutcome.md +34 -0
- package/docs/reference/authoring-surface/type-failslot.md +14 -0
- package/docs/reference/authoring-surface/type-failure.md +19 -0
- package/docs/reference/authoring-surface/type-failureinput.md +20 -0
- package/docs/reference/authoring-surface/type-filebody.md +41 -0
- package/docs/reference/authoring-surface/type-frominput.md +14 -0
- package/docs/reference/authoring-surface/type-hostinjectedinput.md +58 -0
- package/docs/reference/authoring-surface/type-httpdefinition.md +101 -0
- package/docs/reference/authoring-surface/type-httpinput.md +12 -0
- package/docs/reference/authoring-surface/type-httpmethod.md +14 -0
- package/docs/reference/authoring-surface/type-httpopenparts.md +12 -0
- package/docs/reference/authoring-surface/type-httpoutput.md +89 -0
- package/docs/reference/authoring-surface/type-httprequestparts.md +33 -0
- package/docs/reference/authoring-surface/type-jr2config.md +126 -0
- package/docs/reference/authoring-surface/type-lends.md +38 -0
- package/docs/reference/authoring-surface/type-logicat.md +12 -0
- package/docs/reference/authoring-surface/type-markedif.md +12 -0
- package/docs/reference/authoring-surface/type-mergedactors.md +12 -0
- package/docs/reference/authoring-surface/type-namedsecret.md +25 -0
- package/docs/reference/authoring-surface/type-noslotspec.md +26 -0
- package/docs/reference/authoring-surface/type-noslotworkspaceoptions.md +32 -0
- package/docs/reference/authoring-surface/type-openbodydeclaration.md +40 -0
- package/docs/reference/authoring-surface/type-openbodylogic.md +14 -0
- package/docs/reference/authoring-surface/type-openbodyoutput.md +42 -0
- package/docs/reference/authoring-surface/type-openbodyslot.md +18 -0
- package/docs/reference/authoring-surface/type-opencommandinput.md +17 -0
- package/docs/reference/authoring-surface/type-openeffect.md +12 -0
- package/docs/reference/authoring-surface/type-openeffectslots.md +12 -0
- package/docs/reference/authoring-surface/type-openof.md +12 -0
- package/docs/reference/authoring-surface/type-opensecret.md +23 -0
- package/docs/reference/authoring-surface/type-opensecretmark.md +12 -0
- package/docs/reference/authoring-surface/type-permissivenoslotworkspaceoptions.md +31 -0
- package/docs/reference/authoring-surface/type-permissiveworkspaceoptions.md +32 -0
- package/docs/reference/authoring-surface/type-poolmachine.md +28 -0
- package/docs/reference/authoring-surface/type-poolspec.md +60 -0
- package/docs/reference/authoring-surface/type-pushinput.md +15 -0
- package/docs/reference/authoring-surface/type-pushoptions.md +79 -0
- package/docs/reference/authoring-surface/type-pushoutput.md +124 -0
- package/docs/reference/authoring-surface/type-pushwireoutput.md +12 -0
- package/docs/reference/authoring-surface/type-quantity.md +14 -0
- package/docs/reference/authoring-surface/type-reached.md +12 -0
- package/docs/reference/authoring-surface/type-reposlot.md +70 -0
- package/docs/reference/authoring-surface/type-reposlotsof.md +16 -0
- package/docs/reference/authoring-surface/type-sandboxoptions.md +77 -0
- package/docs/reference/authoring-surface/type-secret.md +25 -0
- package/docs/reference/authoring-surface/type-sender.md +19 -0
- package/docs/reference/authoring-surface/type-sentbyguards.md +12 -0
- package/docs/reference/authoring-surface/type-size.md +85 -0
- package/docs/reference/authoring-surface/type-slothandles.md +32 -0
- package/docs/reference/authoring-surface/type-sourcenextresult.md +17 -0
- package/docs/reference/authoring-surface/type-sourcespec.md +44 -0
- package/docs/reference/authoring-surface/type-syncstate.md +12 -0
- package/docs/reference/authoring-surface/type-text.md +19 -0
- package/docs/reference/authoring-surface/type-textpart.md +24 -0
- package/docs/reference/authoring-surface/type-thinkinglevel.md +39 -0
- package/docs/reference/authoring-surface/type-toparameterizedobject.md +14 -0
- package/docs/reference/authoring-surface/type-toprovidedactor.md +14 -0
- package/docs/reference/authoring-surface/type-unknown.md +21 -0
- package/docs/reference/authoring-surface/type-usercontainer.md +77 -0
- package/docs/reference/authoring-surface/type-withoutemptyindex.md +20 -0
- package/docs/reference/authoring-surface/type-workflowevent.md +46 -0
- package/docs/reference/authoring-surface/type-workspaceaccess.md +16 -0
- package/docs/reference/authoring-surface/type-workspaced.md +94 -0
- package/docs/reference/authoring-surface/type-workspacehandles.md +23 -0
- package/docs/reference/authoring-surface/type-workspacemachine.md +29 -0
- package/docs/reference/authoring-surface/type-workspaceof.md +13 -0
- package/docs/reference/authoring-surface/type-workspaceoptions.md +39 -0
- package/docs/reference/authoring-surface/type-workspacespec.md +37 -0
- package/docs/reference/authoring-surface/workspace.md +259 -0
- package/docs/reference/authoring-surface.md +197 -0
- package/docs/reference/cli.md +433 -0
- package/docs/reference/config.md +756 -0
- package/docs/reference/decisions/0001-operator-owned-generic-sandbox.md +113 -0
- package/docs/reference/decisions/0002-duplex-streaming-actor.md +110 -0
- package/docs/reference/decisions/0004-worktree-provisioning-model.md +124 -0
- package/docs/reference/decisions/0005-sandbox-pod-composition.md +230 -0
- package/docs/reference/decisions/0006-agent-control-surface.md +47 -0
- package/docs/reference/decisions/0007-durable-machine-state.md +109 -0
- package/docs/reference/decisions/0008-orchestrator-as-deployed-app.md +60 -0
- package/docs/reference/decisions/0009-cli-and-instance-interface.md +275 -0
- package/docs/reference/decisions/0010-bdd-acceptance-tests.md +160 -0
- package/docs/reference/decisions/0011-workflow-defined-events.md +141 -0
- package/docs/reference/decisions/0012-workspace-wrapper-machine.md +103 -0
- package/docs/reference/decisions/0013-the-agent-reaches-its-machine-through-a-container-it-cannot-read.md +157 -0
- package/docs/reference/decisions/0014-observation-is-open-run-state-is-not.md +95 -0
- package/docs/reference/decisions/0015-authoring-surface-absorbs-the-mechanism.md +265 -0
- package/docs/reference/decisions/0016-agent-turn-mechanics-are-internal.md +124 -0
- package/docs/reference/decisions/0017-pool-over-a-source-port.md +69 -0
- package/docs/reference/decisions/0018-instance-agents-are-definitions-jr2-assembles-the-harness.md +111 -0
- package/docs/reference/decisions/0019-one-converging-command-against-the-current-context.md +275 -0
- package/docs/reference/decisions/0020-private-ca-trust-is-a-named-concept.md +60 -0
- package/docs/reference/decisions/0021-workspace-continuity-is-a-lease-that-answers-back.md +100 -0
- package/docs/reference/decisions/0022-observation-is-a-level-triggered-feed.md +103 -0
- package/docs/reference/decisions/0023-the-harness-prints-the-conversation.md +176 -0
- package/docs/reference/decisions/0024-an-agents-turn-ends-with-the-state-that-asked-for-it.md +439 -0
- package/docs/reference/decisions/0025-cancel-ends-a-run-stop-parks-it.md +65 -0
- package/docs/reference/decisions/0026-a-turn-that-is-over-is-never-prompted.md +62 -0
- package/docs/reference/decisions/0027-the-harness-is-jr2s-own-server-flue-retires-the-wire-stays.md +237 -0
- package/docs/reference/decisions/0028-what-an-agent-may-do-to-the-workspace-is-part-of-its-definition.md +89 -0
- package/docs/reference/decisions/0029-a-menu-is-fixed-for-a-conversation-and-a-turn-is-told-its-allowed-picks.md +154 -0
- package/docs/reference/decisions/0030-a-snapshot-names-the-machine-it-was-written-under.md +100 -0
- package/docs/reference/decisions/0031-menu-only-agents-run-on-the-instance-harness.md +190 -0
- package/docs/reference/decisions/0032-the-console-unlocks-with-the-instance-token.md +61 -0
- package/docs/reference/decisions/0033-a-machine-declares-the-input-that-starts-it.md +127 -0
- package/docs/reference/decisions/0034-the-console-renders-with-preact-and-the-server-erases-the-types.md +57 -0
- package/docs/reference/decisions/0035-a-runaway-turn-is-ended-rerolled-once-then-a-fault.md +92 -0
- package/docs/reference/decisions/0036-a-turn-compacts-its-own-context-mid-flight.md +138 -0
- package/docs/reference/decisions/0037-an-instance-builds-its-sandbox-images-jr2-injects-the-harness.md +196 -0
- package/docs/reference/decisions/0038-jr2-up-builds-every-image-it-deploys.md +170 -0
- package/docs/reference/decisions/0039-image-garbage-collects-by-reachability.md +115 -0
- package/docs/reference/decisions/0041-a-build-the-host-already-holds-is-not-spent-again.md +64 -0
- package/docs/reference/decisions/0042-ready-is-not-routable-an-admission-retries-its-connection.md +142 -0
- package/docs/reference/decisions/0043-the-kit-is-tested-as-installed-a-local-registry-stands-in-for-npm.md +170 -0
- package/docs/reference/decisions/0044-kit-images-live-at-a-canonical-home-a-self-host-mirrors-it.md +85 -0
- package/docs/reference/decisions/0045-the-platform-joins-the-image-address-and-the-cluster-chooses-it.md +82 -0
- package/docs/reference/decisions/0046-a-converge-failure-carries-its-diagnosis.md +34 -0
- package/docs/reference/decisions/0047-the-git-ssh-key-source-is-the-users-choice.md +47 -0
- package/docs/reference/decisions/0048-the-orchestrator-boots-without-its-repos.md +36 -0
- package/docs/reference/decisions/0049-a-machine-carries-its-parts-and-composes-by-invoke.md +126 -0
- package/docs/reference/decisions/0050-what-code-names-config-declares-what-the-cli-names-discovery-finds.md +73 -0
- package/docs/reference/decisions/0051-a-repo-is-a-slot-on-the-workspace-and-a-cache-on-the-node.md +227 -0
- package/docs/reference/decisions/0052-a-sandbox-lands-where-an-ordinary-pod-lands-and-the-cache-follows-it.md +81 -0
- package/docs/reference/decisions/0053-a-fetch-inside-the-pod-asks-the-node-cache-and-the-cache-asks-the-remote.md +111 -0
- package/docs/reference/decisions/0054-the-kit-ships-machines-and-a-shipped-machine-leaves-its-parts-open.md +126 -0
- package/docs/reference/decisions/0055-a-release-is-a-pushed-tag-and-the-publish-guard-is-the-credential.md +107 -0
- package/docs/reference/decisions/0056-a-global-jr2-hands-off-to-the-instances-own-and-an-instance-has-one-kit-version.md +62 -0
- package/docs/reference/decisions/0057-a-turn-is-its-frame-its-dials-and-whether-it-continues.md +152 -0
- package/docs/reference/decisions/0058-a-harness-answers-the-orchestrator-alone.md +106 -0
- package/docs/reference/decisions/0059-a-harness-holds-stand-ins-and-the-custodian-holds-the-keys.md +384 -0
- package/docs/reference/decisions/0060-a-workspace-states-its-size-and-jr2-reserves-all-of-it.md +111 -0
- package/docs/reference/decisions/0061-a-memory-kill-ends-the-agents-processes-not-the-harness.md +83 -0
- package/docs/reference/decisions/0062-jr2-briefs-the-agent-on-its-seat-never-on-its-task.md +212 -0
- package/docs/reference/decisions/0063-the-orchestrator-watches-the-cluster.md +83 -0
- package/docs/reference/decisions/0064-a-workspace-waits-for-capacity.md +79 -0
- package/docs/reference/decisions/0065-a-persist-survives-a-crash-and-history-is-a-week.md +65 -0
- package/docs/reference/decisions/0066-a-model-tier-runs-a-real-model-and-a-change-to-what-it-reads-runs-the-tier.md +550 -0
- package/docs/reference/decisions/0067-an-effect-is-an-actor-slot-and-runs-at-most-once-unless-marked-idempotent.md +430 -0
- package/docs/reference/decisions/0068-an-effector-holds-an-effects-secrets-and-signs-its-wire.md +464 -0
- package/docs/reference/decisions/0069-the-orchestrator-is-a-kit-image-and-an-instance-publishes-its-assembly.md +180 -0
- package/docs/reference/decisions/0070-a-machine-lends-its-parts-to-a-chart.md +323 -0
- package/docs/reference/decisions/0071-a-run-starts-a-run-through-a-dispatch-effect.md +183 -0
- package/docs/reference/decisions/0072-the-docs-ship-inside-the-kit-and-an-instances-agents-md-indexes-them.md +144 -0
- package/docs/reference/decisions/0073-a-machine-fails-a-run-with-fail-and-an-uncaught-failure-ends-it-failed.md +122 -0
- package/docs/reference/decisions/0074-an-unrouted-mechanism-event-is-refused-at-load-and-faults-the-run.md +76 -0
- package/docs/reference/decisions.md +83 -0
- package/docs/reference/glossary.md +648 -0
- package/docs/reference/http-api.md +1348 -0
- package/docs/reference/machines.md +190 -0
- package/docs/reference/mechanism-events.md +72 -0
- package/docs/why.md +206 -0
- package/package.json +5 -3
- package/src/actor.ts +541 -79
- package/src/agent.ts +114 -38
- package/src/ambient.ts +2 -1
- package/src/assembly-checks.ts +189 -7
- package/src/cause.ts +234 -0
- package/src/chart.ts +169 -37
- package/src/charts.ts +34 -6
- package/src/config.ts +430 -178
- package/src/custodian.ts +74 -22
- package/src/customize.ts +188 -46
- package/src/dispatch-effect.ts +945 -0
- package/src/door-compat.ts +217 -0
- package/src/durability.ts +11 -4
- package/src/effect.ts +341 -44
- package/src/effector-wire.ts +160 -11
- package/src/fail.ts +209 -0
- package/src/harness-client.ts +541 -47
- package/src/http-effect.ts +495 -53
- package/src/http.ts +427 -16
- package/src/index.ts +60 -82
- package/src/instance.ts +68 -8
- package/src/internal.ts +84 -0
- package/src/machine-doc.ts +21 -3
- package/src/mechanism-routes.ts +168 -0
- package/src/model-keys.generated.ts +53 -0
- package/src/model-keys.ts +18 -58
- package/src/notices.ts +66 -13
- package/src/open-body.ts +159 -23
- package/src/open-effect.ts +81 -8
- package/src/open.ts +59 -6
- package/src/parts.ts +184 -13
- package/src/party.ts +68 -2
- package/src/pick.ts +463 -11
- package/src/pool.ts +247 -36
- package/src/registration.ts +83 -4
- package/src/repos.ts +2 -1
- package/src/run-host.ts +565 -31
- package/src/run-output.ts +38 -0
- package/src/sandbox-effect.ts +273 -38
- package/src/sandbox-kube.ts +3 -1
- package/src/secret-placement.ts +52 -17
- package/src/server.ts +3 -0
- package/src/setup.ts +239 -56
- package/src/size.ts +121 -9
- package/src/snapshot-store.ts +54 -10
- package/src/stub-harness.ts +44 -16
- package/src/timers.ts +14 -1
- package/src/vocabulary.ts +69 -14
- package/src/wire.ts +90 -16
- package/src/workspace.ts +697 -175
|
@@ -0,0 +1,945 @@
|
|
|
1
|
+
// The `dispatch` Effect (ADR-0071): a run starts a run of a registered Workflow, as an act of the
|
|
2
|
+
// caller. An actor slot beside `command()` and `http()` (ADR-0067):
|
|
3
|
+
//
|
|
4
|
+
// actors: {
|
|
5
|
+
// review: dispatch("review", { input: reviewDoor.pick({ branch: true }), wait: true }),
|
|
6
|
+
// ops: dispatch("task/review-loop", { target: { url: "https://ops.example.com", token: secret("ops") } }),
|
|
7
|
+
// }
|
|
8
|
+
//
|
|
9
|
+
// The callee is named as `jr2 run` names it — a constant on the slot, never a param: the name is
|
|
10
|
+
// authority, the input is data. The invoke's input is the run's body, whole. Two placements, read
|
|
11
|
+
// from the target: with none the Effect runs in the Orchestrator's own process and starts the run
|
|
12
|
+
// through the run host (`RunBinding.dispatch`), with no wire and no token; with one it runs in the
|
|
13
|
+
// Instance Effector, which holds the token as an Effect secret and calls the other Orchestrator's
|
|
14
|
+
// door. A packaged Machine leaves the target Open (`target: open`) for its composer to bind.
|
|
15
|
+
//
|
|
16
|
+
// What this module adds to the Effect's restart safety (effect.ts) for this kind:
|
|
17
|
+
//
|
|
18
|
+
// - THE KEY. The ledger row's id rides every start as the door's idempotency key (in-process
|
|
19
|
+
// through the binding, foreign in the `jr2-dispatched-by` header), and the door answers a
|
|
20
|
+
// repeat with the run the key already started. So a dispatch is idempotent by construction: a
|
|
21
|
+
// restore that cannot tell whether its start arrived sends it again, and gets the same run.
|
|
22
|
+
// - THE RUN ON THE ROW. Once the start's answer names the run, the intent row is written again
|
|
23
|
+
// with it, saved at the write: a restore with it settles `started` at once, or resumes the
|
|
24
|
+
// wait on that run, and never starts another.
|
|
25
|
+
// - D1. When its state exits, the wait stops and the abort is recorded, "outcome unknown". The
|
|
26
|
+
// run it started is never touched: the two runs are independent.
|
|
27
|
+
// - REACH IS OUTPUT, DEFECTS ARE ERRORS, as for `http`: `started | settled | refused |
|
|
28
|
+
// unreachable | unknown` are output; an absent run host or Effector, input that fails the
|
|
29
|
+
// slot's schema, and a refused or malformed wire are `onError`.
|
|
30
|
+
|
|
31
|
+
import { randomUUID } from "node:crypto";
|
|
32
|
+
import type { ActorLogic, AnyActorRef, EventObject } from "xstate";
|
|
33
|
+
import { z } from "zod";
|
|
34
|
+
import type {
|
|
35
|
+
DispatchOutput,
|
|
36
|
+
DispatchParts,
|
|
37
|
+
Duration,
|
|
38
|
+
EffectLogic,
|
|
39
|
+
EffectMarker,
|
|
40
|
+
EffectRow,
|
|
41
|
+
EffectSnapshot,
|
|
42
|
+
OpenDispatchParts,
|
|
43
|
+
Unknown,
|
|
44
|
+
} from "./effect.ts";
|
|
45
|
+
import {
|
|
46
|
+
createEffectorClient,
|
|
47
|
+
DispatchOutputShape,
|
|
48
|
+
EffectorForged,
|
|
49
|
+
EffectorMalformed,
|
|
50
|
+
EffectorNotSent,
|
|
51
|
+
EffectorRefused,
|
|
52
|
+
MAX_START_BYTES,
|
|
53
|
+
nodeEffectorTransport,
|
|
54
|
+
ORIGIN,
|
|
55
|
+
WORKFLOW_NAME,
|
|
56
|
+
type DispatchStartRequest,
|
|
57
|
+
type EffectorPort,
|
|
58
|
+
type EffectState,
|
|
59
|
+
type Replica,
|
|
60
|
+
} from "./effector-wire.ts";
|
|
61
|
+
import { OPEN_SECRET, secret, type EffectorPortFactory, type NamedSecret } from "./http-effect.ts";
|
|
62
|
+
import { boundRun, type AnyActorSystem, type DispatchedBy } from "./registration.ts";
|
|
63
|
+
import { open } from "./open.ts";
|
|
64
|
+
import { effectBindingShape, openEffectLogic, type OpenOf } from "./open-effect.ts";
|
|
65
|
+
|
|
66
|
+
export { DISPATCHED_BY_HEADER, type DispatchedBy, type DispatchStart } from "./registration.ts";
|
|
67
|
+
|
|
68
|
+
// --- The definition ------------------------------------------------------------------------------
|
|
69
|
+
|
|
70
|
+
/** The definition of a `dispatch` Effect (ADR-0071). Every field is optional. */
|
|
71
|
+
export type DispatchDefinition<S extends z.ZodObject | undefined = undefined> = {
|
|
72
|
+
/** A zod schema of the run input of the Workflow. It types the `input` of the invoke and
|
|
73
|
+
* `fromPick`, and `jr2 up` compares it with the input that the Workflow declares. Without it,
|
|
74
|
+
* only the Workflow checks the input. */
|
|
75
|
+
input?: S;
|
|
76
|
+
/** `true`: end when the new run ends (`settled`). `false`, the default: end when the
|
|
77
|
+
* Orchestrator accepts the run (`started`). */
|
|
78
|
+
wait?: boolean;
|
|
79
|
+
/** How long the Effect waits: by default 30s for the start, and 10m with `wait`. */
|
|
80
|
+
timeout?: Duration;
|
|
81
|
+
/** A different Orchestrator: `url` is its origin, and `token` is the secret that holds its
|
|
82
|
+
* Instance token. Or `open`, which the composer binds with `customize(<import>, { effects: {
|
|
83
|
+
* <slot>: { target: { url, token } } } })`. Without it, the run starts in this Orchestrator. */
|
|
84
|
+
target?: { url: string; token: NamedSecret } | typeof open;
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
/** A dispatch's input: the run's body — the slot's schema's type, or any JSON object. */
|
|
88
|
+
export type DispatchInput<S> = S extends z.ZodObject ? z.infer<S> : Record<string, unknown>;
|
|
89
|
+
|
|
90
|
+
/** The Open part a dispatch definition states: its target, when it is `open`. */
|
|
91
|
+
export type DispatchOpenParts<T> = T extends typeof open ? "target" : never;
|
|
92
|
+
|
|
93
|
+
const DEFAULT_START_TIMEOUT_MS = 30_000;
|
|
94
|
+
const DEFAULT_WAIT_TIMEOUT_MS = 10 * 60_000;
|
|
95
|
+
/** How long a re-asked request waits, at most, after a transport failure. */
|
|
96
|
+
const RETRY_MAX_MS = 10_000;
|
|
97
|
+
/** The longest delay one `setTimeout` holds (2^31-1 ms, about 24.8 days): Node fires a longer one
|
|
98
|
+
* at once, so a longer bound is armed again in steps of this (`atDeadline`). */
|
|
99
|
+
const MAX_TIMER_MS = 2 ** 31 - 1;
|
|
100
|
+
/** A start sent again after its bound passed (a restore of a late row) still gets this long to be
|
|
101
|
+
* answered: the key finds the run it started, if it did (ADR-0071). */
|
|
102
|
+
const START_FLOOR_MS = 1_000;
|
|
103
|
+
/** How long past the slot's bound the Orchestrator follows a foreign dispatch, at most: the
|
|
104
|
+
* Effector settles at the bound, and its answer has to travel back (ADR-0071). */
|
|
105
|
+
const FOREIGN_GRACE_MS = 10_000;
|
|
106
|
+
|
|
107
|
+
/** The definition's plain data, checked: every refusal names what to write instead. */
|
|
108
|
+
export function dispatchParts(workflow: string, def: DispatchDefinition<any> = {}): DispatchParts {
|
|
109
|
+
if (def.target === open) {
|
|
110
|
+
throw new Error("dispatchParts: an Open target has no url — read it with openDispatchParts");
|
|
111
|
+
}
|
|
112
|
+
const { target: _target, ...rest } = openDispatchParts(workflow, { ...def, target: open });
|
|
113
|
+
if (def.target === undefined) return withInputSchema(rest, def.input);
|
|
114
|
+
const target = targetOf(workflow, def.target);
|
|
115
|
+
return withInputSchema({ ...rest, target, secrets: [target.token] }, def.input);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** An Open target's plain data (ADR-0054): every other part checked as `dispatchParts` checks it,
|
|
119
|
+
* the sentinel where the target goes, and no secret until `customize()` binds the token. */
|
|
120
|
+
export function openDispatchParts(workflow: string, def: DispatchDefinition<any> = {}): OpenDispatchParts {
|
|
121
|
+
if (typeof workflow !== "string" || !WORKFLOW_NAME.test(workflow)) {
|
|
122
|
+
throw new Error(
|
|
123
|
+
`dispatch(${JSON.stringify(workflow)}): the Workflow is named as \`jr2 run\` names it — a Machine's filename ` +
|
|
124
|
+
'stem ("review"), or "<wrapper>/<name>" for a named Chart: lowercase letters, digits and "-" (ADR-0071)',
|
|
125
|
+
);
|
|
126
|
+
}
|
|
127
|
+
if (def.wait !== undefined && typeof def.wait !== "boolean") {
|
|
128
|
+
throw new Error(`dispatch("${workflow}", { wait }): wait is true or false — a slot that needs both declares two`);
|
|
129
|
+
}
|
|
130
|
+
const wait = def.wait === true;
|
|
131
|
+
const input = def.input as z.ZodType | undefined;
|
|
132
|
+
if (input !== undefined && !isObjectSchema(input)) {
|
|
133
|
+
throw new Error(
|
|
134
|
+
`dispatch("${workflow}", { input }): the input is a z.object(...) — the run's body is a JSON object (ADR-0071)`,
|
|
135
|
+
);
|
|
136
|
+
}
|
|
137
|
+
return withInputSchema(
|
|
138
|
+
{
|
|
139
|
+
kind: "dispatch",
|
|
140
|
+
workflow,
|
|
141
|
+
wait,
|
|
142
|
+
timeoutMs:
|
|
143
|
+
def.timeout === undefined
|
|
144
|
+
? wait
|
|
145
|
+
? DEFAULT_WAIT_TIMEOUT_MS
|
|
146
|
+
: DEFAULT_START_TIMEOUT_MS
|
|
147
|
+
: parseDuration(workflow, def.timeout),
|
|
148
|
+
target: open,
|
|
149
|
+
inputKeys: input === undefined ? undefined : Object.keys((input as unknown as { shape: object }).shape),
|
|
150
|
+
secrets: [],
|
|
151
|
+
openSecret: false,
|
|
152
|
+
},
|
|
153
|
+
input,
|
|
154
|
+
);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** The key the slot's input schema rides the parts under, as JSON Schema: what `jr2 up` and the
|
|
158
|
+
* publish door compare with the callee's door (ADR-0071, door-compat.ts; parts.ts reads it). A
|
|
159
|
+
* `Symbol.for` key, so a second copy of this module reads it, and not enumerable, so the parts stay
|
|
160
|
+
* the plain data they print as. Written here, by the zod that built the schema. */
|
|
161
|
+
const DISPATCH_INPUT = Symbol.for("jr2.dispatch.input");
|
|
162
|
+
|
|
163
|
+
function withInputSchema<P extends object>(parts: P, input: z.ZodType | undefined): P {
|
|
164
|
+
if (input === undefined) return parts;
|
|
165
|
+
// `io: "output"`: what the driver SENDS, which is the slot schema's parse (`inputOf`), its
|
|
166
|
+
// defaults filled — so a defaulted field is required here, and one left optional is not, which
|
|
167
|
+
// is what the door's `required` is compared with (door-compat.ts). The door is served as
|
|
168
|
+
// `io: "input"`: what it takes. A part zod cannot draw is `{}`, which the comparison reads as
|
|
169
|
+
// "any".
|
|
170
|
+
const schema = z.toJSONSchema(input, { io: "output", unrepresentable: "any" });
|
|
171
|
+
Object.defineProperty(parts, DISPATCH_INPUT, { value: schema, enumerable: false });
|
|
172
|
+
return parts;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** A target, checked (ADR-0071, D-J): an origin — no path, query, fragment or userinfo, and no
|
|
176
|
+
* secret in it — and a token that is a named secret. The whole target is the Open part, so
|
|
177
|
+
* `secret(open)` inside one is refused. */
|
|
178
|
+
function targetOf(workflow: string, target: unknown): { url: string; token: string } {
|
|
179
|
+
const where = `dispatch("${workflow}", { target })`;
|
|
180
|
+
if (typeof target !== "object" || target === null) {
|
|
181
|
+
throw new Error(`${where}: a target is { url: "https://<host>", token: secret("<name>") }, or open (ADR-0071)`);
|
|
182
|
+
}
|
|
183
|
+
const { url, token } = target as { url?: unknown; token?: unknown };
|
|
184
|
+
if (typeof url !== "string") {
|
|
185
|
+
throw new Error(
|
|
186
|
+
`${where}: the url is a string origin, "https://<host>[:<port>]" — a secret never sits in it; the token is ` +
|
|
187
|
+
"`token: secret(...)` (ADR-0071)",
|
|
188
|
+
);
|
|
189
|
+
}
|
|
190
|
+
let parsed: URL | undefined;
|
|
191
|
+
try {
|
|
192
|
+
parsed = new URL(url);
|
|
193
|
+
} catch {
|
|
194
|
+
parsed = undefined;
|
|
195
|
+
}
|
|
196
|
+
const bare = url.replace(/\/$/, "");
|
|
197
|
+
if (
|
|
198
|
+
parsed === undefined ||
|
|
199
|
+
(parsed.protocol !== "https:" && parsed.protocol !== "http:") ||
|
|
200
|
+
parsed.username !== "" ||
|
|
201
|
+
parsed.password !== "" ||
|
|
202
|
+
!ORIGIN.test(bare)
|
|
203
|
+
) {
|
|
204
|
+
throw new Error(
|
|
205
|
+
`${where}: "${url}" is not an origin — write the scheme and the host, with a port if it is not the scheme's, ` +
|
|
206
|
+
'and no path, query or userinfo: "https://ops.example.com" or "http://jr2-orchestrator.ops.svc:4000"; ' +
|
|
207
|
+
"the Effector appends the door's path (ADR-0071)",
|
|
208
|
+
);
|
|
209
|
+
}
|
|
210
|
+
if (typeof token !== "object" || token === null || typeof (token as { secret?: unknown }).secret !== "string") {
|
|
211
|
+
throw new Error(`${where}: the token is a secret("<name>") holding the target's Instance token (ADR-0071)`);
|
|
212
|
+
}
|
|
213
|
+
const name = (token as { secret: string }).secret;
|
|
214
|
+
if (name === OPEN_SECRET) {
|
|
215
|
+
throw new Error(
|
|
216
|
+
`${where}: secret(open) in a target — the target is the Open part, not its secret: leave the whole target ` +
|
|
217
|
+
"Open (`target: open`) and let the composer bind its url and token together (ADR-0071)",
|
|
218
|
+
);
|
|
219
|
+
}
|
|
220
|
+
secret(name);
|
|
221
|
+
return { url: parsed.origin, token: name };
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function isObjectSchema(schema: z.ZodType): boolean {
|
|
225
|
+
const shape = (schema as { shape?: unknown }).shape;
|
|
226
|
+
return (
|
|
227
|
+
typeof shape === "object" && shape !== null && typeof (schema as { safeParse?: unknown }).safeParse === "function"
|
|
228
|
+
);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* The unbound logic of a dispatch whose target is Open (ADR-0054, ADR-0071), or undefined for one
|
|
233
|
+
* whose target is fixed or absent. Binding builds the Effect again, whole, with `rebuild` over the
|
|
234
|
+
* definition with the bound target, so the bound one is checked as any definition is.
|
|
235
|
+
*/
|
|
236
|
+
export function openDispatchOf<S extends z.ZodObject | undefined>(
|
|
237
|
+
workflow: string,
|
|
238
|
+
def: DispatchDefinition<S>,
|
|
239
|
+
rebuild: (def: DispatchDefinition<S>) => unknown,
|
|
240
|
+
): EffectLogic<unknown, unknown> | undefined {
|
|
241
|
+
if (def.target !== open) return undefined;
|
|
242
|
+
const parts = openDispatchParts(workflow, def);
|
|
243
|
+
return openEffectLogic(parts, ["target"], (binding) => {
|
|
244
|
+
if (binding.url !== undefined || binding.argv !== undefined || binding.secret !== undefined) {
|
|
245
|
+
throw new Error(
|
|
246
|
+
`a dispatch's Open part is its target, its url and token together — bind it with ` +
|
|
247
|
+
`${effectBindingShape(["target"])} (ADR-0071)`,
|
|
248
|
+
);
|
|
249
|
+
}
|
|
250
|
+
if (binding.target === undefined) {
|
|
251
|
+
throw new Error(`a dispatch's Open target is bound with ${effectBindingShape(["target"])} (ADR-0071)`);
|
|
252
|
+
}
|
|
253
|
+
return rebuild({ ...def, target: binding.target }) as EffectLogic<unknown, unknown>;
|
|
254
|
+
});
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/** The wire request for one foreign start (ADR-0071): the target's origin, the Workflow, the body,
|
|
258
|
+
* the token by name, and who dispatched it. */
|
|
259
|
+
export function dispatchStartOf(
|
|
260
|
+
parts: DispatchParts,
|
|
261
|
+
input: Record<string, unknown>,
|
|
262
|
+
dispatchedBy: DispatchedBy & { instance: string },
|
|
263
|
+
): DispatchStartRequest {
|
|
264
|
+
if (parts.target === undefined) throw new Error("dispatchStartOf: an in-process dispatch has no wire request");
|
|
265
|
+
return {
|
|
266
|
+
kind: "dispatch",
|
|
267
|
+
url: [{ text: parts.target.url }],
|
|
268
|
+
workflow: parts.workflow,
|
|
269
|
+
input,
|
|
270
|
+
auth: { bearer: { secret: parts.target.token } },
|
|
271
|
+
dispatchedBy,
|
|
272
|
+
wait: parts.wait,
|
|
273
|
+
timeoutMs: parts.timeoutMs,
|
|
274
|
+
};
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
// --- The actor logic -----------------------------------------------------------------------------
|
|
278
|
+
|
|
279
|
+
type Settle = { type: "jr2.effect.settle"; output: unknown };
|
|
280
|
+
type Fault = { type: "jr2.effect.error"; error: unknown };
|
|
281
|
+
|
|
282
|
+
/** A dispatch's run: its abort, and — for a foreign one, once its Effector is known — the port its
|
|
283
|
+
* stop goes through (ADR-0071). */
|
|
284
|
+
type Running = { controller: AbortController; port?: EffectorPort };
|
|
285
|
+
const running = new WeakMap<AnyActorRef, Running>();
|
|
286
|
+
|
|
287
|
+
/** How long a stop's DELETE may take: the Effector answers it with the wait's outcome, within its
|
|
288
|
+
* poll's hold. */
|
|
289
|
+
const STOP_WAIT_MS = 30_000;
|
|
290
|
+
|
|
291
|
+
/**
|
|
292
|
+
* A `dispatch` Effect's slot logic over an injected port factory — the seam `dispatch()` and the
|
|
293
|
+
* unit tests share, as `httpWith` is for an http Effect. The factory is read only by a dispatch
|
|
294
|
+
* with a target.
|
|
295
|
+
*/
|
|
296
|
+
export function dispatchWith<
|
|
297
|
+
S extends z.ZodObject | undefined = undefined,
|
|
298
|
+
const T extends DispatchDefinition<S>["target"] = undefined,
|
|
299
|
+
>(
|
|
300
|
+
portFactory: EffectorPortFactory,
|
|
301
|
+
workflow: string,
|
|
302
|
+
def?: DispatchDefinition<S> & { target?: T },
|
|
303
|
+
): EffectLogic<DispatchInput<S>, DispatchOutput> & OpenOf<DispatchOpenParts<T>>;
|
|
304
|
+
export function dispatchWith<S extends z.ZodObject | undefined = undefined>(
|
|
305
|
+
portFactory: EffectorPortFactory,
|
|
306
|
+
workflow: string,
|
|
307
|
+
def: DispatchDefinition<S> = {},
|
|
308
|
+
): EffectLogic<DispatchInput<S>, DispatchOutput> {
|
|
309
|
+
const unbound = openDispatchOf(workflow, def, (bound) => dispatchWith(portFactory, workflow, bound));
|
|
310
|
+
if (unbound) return unbound as EffectLogic<DispatchInput<S>, DispatchOutput>;
|
|
311
|
+
const parts = dispatchParts(workflow, def);
|
|
312
|
+
type I = DispatchInput<S>;
|
|
313
|
+
type O = DispatchOutput;
|
|
314
|
+
const logic: ActorLogic<EffectSnapshot<O, I>, EventObject, I> = {
|
|
315
|
+
getInitialSnapshot: (_scope, input) => ({
|
|
316
|
+
status: "active",
|
|
317
|
+
output: undefined,
|
|
318
|
+
error: undefined,
|
|
319
|
+
input,
|
|
320
|
+
effectId: randomUUID(),
|
|
321
|
+
}),
|
|
322
|
+
transition: (snapshot, event, { self, system }) => {
|
|
323
|
+
if (snapshot.status !== "active") return snapshot;
|
|
324
|
+
if (event.type === "jr2.effect.settle") {
|
|
325
|
+
return { ...snapshot, status: "done", output: (event as Settle).output as O, input: undefined };
|
|
326
|
+
}
|
|
327
|
+
if (event.type === "jr2.effect.error") {
|
|
328
|
+
return { ...snapshot, status: "error", error: (event as Fault).error, input: undefined };
|
|
329
|
+
}
|
|
330
|
+
if (event.type === "xstate.stop") {
|
|
331
|
+
stopped(self, system, snapshot.effectId);
|
|
332
|
+
return { ...snapshot, status: "stopped", input: undefined };
|
|
333
|
+
}
|
|
334
|
+
return snapshot;
|
|
335
|
+
},
|
|
336
|
+
start: (snapshot, { self, system }) => {
|
|
337
|
+
if (snapshot.status !== "active") return;
|
|
338
|
+
const run: Running = { controller: new AbortController() };
|
|
339
|
+
running.set(self, run);
|
|
340
|
+
void drive(self, system, snapshot, parts, def, portFactory, run).catch((error: unknown) => {
|
|
341
|
+
if (run.controller.signal.aborted) return;
|
|
342
|
+
if (self.getSnapshot().status === "active") self.send({ type: "jr2.effect.error", error } as Fault);
|
|
343
|
+
});
|
|
344
|
+
},
|
|
345
|
+
getPersistedSnapshot: (snapshot) => snapshot,
|
|
346
|
+
};
|
|
347
|
+
return Object.assign(logic, { effect: parts });
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
const slotOf = (self: AnyActorRef): string => (typeof self.src === "string" ? self.src : self.id);
|
|
351
|
+
|
|
352
|
+
/** The statuses a settled dispatch names, in the store's own words (ADR-0025, ADR-0071). */
|
|
353
|
+
type SettledStatus = Extract<DispatchOutput, { outcome: "settled" }>["status"];
|
|
354
|
+
|
|
355
|
+
async function drive<I, O>(
|
|
356
|
+
self: AnyActorRef,
|
|
357
|
+
system: AnyActorSystem,
|
|
358
|
+
snapshot: EffectSnapshot<O, I>,
|
|
359
|
+
parts: DispatchParts,
|
|
360
|
+
def: DispatchDefinition<z.ZodObject | undefined>,
|
|
361
|
+
portFactory: EffectorPortFactory,
|
|
362
|
+
run: Running,
|
|
363
|
+
): Promise<void> {
|
|
364
|
+
const binding = boundRun(system);
|
|
365
|
+
const id = snapshot.effectId;
|
|
366
|
+
const slot = slotOf(self);
|
|
367
|
+
const signal = run.controller.signal;
|
|
368
|
+
const now = (): string => new Date().toISOString();
|
|
369
|
+
const mark = (marker: Omit<EffectMarker, "kind" | "slot" | "effect">): void =>
|
|
370
|
+
binding?.marker?.({ kind: "effect", slot, effect: id, ...marker });
|
|
371
|
+
let settledOnce = false;
|
|
372
|
+
const settle = (output: DispatchOutput): void => {
|
|
373
|
+
if (settledOnce || signal.aborted || self.getSnapshot().status !== "active") return;
|
|
374
|
+
settledOnce = true;
|
|
375
|
+
// The settled row first, saved at the write: a restart before the transition is saved delivers
|
|
376
|
+
// this output, and starts nothing.
|
|
377
|
+
binding?.recordEffect?.(id, { state: "settled", kind: "dispatch", slot, output, at: now() });
|
|
378
|
+
mark({ stage: "settled", output });
|
|
379
|
+
self.send({ type: "jr2.effect.settle", output } satisfies Settle);
|
|
380
|
+
};
|
|
381
|
+
const unknown = (reason: string): Unknown => ({ outcome: "unknown", reason });
|
|
382
|
+
const placement = parts.target === undefined ? "orchestrator" : "instance";
|
|
383
|
+
const named = `${parts.workflow}${parts.target ? ` at ${parts.target.url}` : ""}`;
|
|
384
|
+
|
|
385
|
+
const row = binding?.effectRow?.(id);
|
|
386
|
+
if (row?.state === "settled") {
|
|
387
|
+
await Promise.resolve();
|
|
388
|
+
if (!signal.aborted && self.getSnapshot().status === "active") {
|
|
389
|
+
self.send({ type: "jr2.effect.settle", output: row.output } satisfies Settle);
|
|
390
|
+
}
|
|
391
|
+
return;
|
|
392
|
+
}
|
|
393
|
+
if (row?.state === "aborted") {
|
|
394
|
+
settle(unknown("its state exited while it ran; not started again"));
|
|
395
|
+
return;
|
|
396
|
+
}
|
|
397
|
+
const prior = row?.state === "intent" && row.kind === "dispatch" ? row : undefined;
|
|
398
|
+
// The intent row keeps the time it was first written: the wait's bound runs from the start, across
|
|
399
|
+
// a restore.
|
|
400
|
+
const at = prior?.at ?? now();
|
|
401
|
+
let started: string | undefined = prior?.run;
|
|
402
|
+
const intent = (): EffectRow => ({
|
|
403
|
+
state: "intent",
|
|
404
|
+
kind: "dispatch",
|
|
405
|
+
slot,
|
|
406
|
+
placement,
|
|
407
|
+
idempotent: true,
|
|
408
|
+
wait: parts.wait,
|
|
409
|
+
...(started !== undefined ? { run: started } : {}),
|
|
410
|
+
at,
|
|
411
|
+
});
|
|
412
|
+
/** The run the start's answer names, written to the intent row, saved at the write: a restore
|
|
413
|
+
* reads it and never starts another. */
|
|
414
|
+
const pin = (runId: string, existing: boolean): void => {
|
|
415
|
+
if (signal.aborted || started === runId) return;
|
|
416
|
+
started = runId;
|
|
417
|
+
binding?.recordEffect?.(id, intent());
|
|
418
|
+
mark({ stage: "output", text: `${named}: run ${runId}${existing ? " (the key found it: started before)" : ""}` });
|
|
419
|
+
};
|
|
420
|
+
const deadline = Date.parse(at) + parts.timeoutMs;
|
|
421
|
+
|
|
422
|
+
if (prior?.run !== undefined && !parts.wait) {
|
|
423
|
+
// Started before the restart, and the answer is the run: nothing to send.
|
|
424
|
+
await Promise.resolve();
|
|
425
|
+
settle({ outcome: "started", run: prior.run });
|
|
426
|
+
return;
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
if (parts.target === undefined) {
|
|
430
|
+
const host = binding?.dispatch;
|
|
431
|
+
if (!host || !binding) {
|
|
432
|
+
throw new Error(
|
|
433
|
+
`dispatch "${slot}" starts a run through its Orchestrator's run host, and this run has none — a dispatch ` +
|
|
434
|
+
"with no target runs only under a RunHost (ADR-0071)",
|
|
435
|
+
);
|
|
436
|
+
}
|
|
437
|
+
if (prior?.run !== undefined) {
|
|
438
|
+
// In a wait at an Orchestrator restart: the run is on the row, so the wait resumes on it.
|
|
439
|
+
mark({ stage: "started", text: `${named}: waiting for run ${prior.run} again, after a restart` });
|
|
440
|
+
await waitIn(prior.run);
|
|
441
|
+
return;
|
|
442
|
+
}
|
|
443
|
+
// Fresh, or in flight with no run on the row: the key makes a second start the same run.
|
|
444
|
+
const input = inputOf();
|
|
445
|
+
if (!prior) binding.recordEffect?.(id, intent());
|
|
446
|
+
mark({
|
|
447
|
+
stage: "started",
|
|
448
|
+
text: prior ? `${named} (sent again under its key: a run it started is answered)` : named,
|
|
449
|
+
});
|
|
450
|
+
// The depth is the callee's: one below this run, which the host records as sent (ADR-0071).
|
|
451
|
+
const by: DispatchedBy = { run: binding.runId, slot, effect: id, depth: (binding.depth ?? 0) + 1 };
|
|
452
|
+
// The slot's bound holds the start too (ADR-0071): a run host that does not answer in it is
|
|
453
|
+
// `unknown` — the start may have been taken, and a restore's re-send under the key would find it.
|
|
454
|
+
const answer = await beforeDeadline(
|
|
455
|
+
host.start(parts.workflow, input, by),
|
|
456
|
+
prior ? Math.max(deadline, Date.now() + START_FLOOR_MS) : deadline,
|
|
457
|
+
signal,
|
|
458
|
+
);
|
|
459
|
+
if (signal.aborted) return;
|
|
460
|
+
if (answer === undefined) {
|
|
461
|
+
settle(
|
|
462
|
+
unknown(
|
|
463
|
+
`the run host did not answer the start of ${parts.workflow} within the slot's timeout; a run may have started`,
|
|
464
|
+
),
|
|
465
|
+
);
|
|
466
|
+
return;
|
|
467
|
+
}
|
|
468
|
+
if (!answer.ok) {
|
|
469
|
+
settle({ outcome: "refused", status: answer.status, report: [...answer.report] });
|
|
470
|
+
return;
|
|
471
|
+
}
|
|
472
|
+
pin(answer.runId, answer.existing);
|
|
473
|
+
if (!parts.wait) {
|
|
474
|
+
settle({ outcome: "started", run: answer.runId });
|
|
475
|
+
return;
|
|
476
|
+
}
|
|
477
|
+
await waitIn(answer.runId);
|
|
478
|
+
return;
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
// Foreign: the Instance Effector holds the token and calls the other Orchestrator's door.
|
|
482
|
+
const target = binding?.effector?.("instance");
|
|
483
|
+
if (!target) {
|
|
484
|
+
throw new Error(
|
|
485
|
+
`dispatch "${slot}" calls another Orchestrator from the Instance Effector, and this Orchestrator knows none — ` +
|
|
486
|
+
"`jr2 up` converges it when a registered Machine carries a dispatch with a target (ADR-0071)",
|
|
487
|
+
);
|
|
488
|
+
}
|
|
489
|
+
const port = portFactory(target);
|
|
490
|
+
// The wait's stop goes through this port when its state exits (ADR-0071).
|
|
491
|
+
run.port = port;
|
|
492
|
+
// Fresh: nothing was sent. The body is checked before the intent row, so a defect in the input is
|
|
493
|
+
// the Machine's error and sends nothing.
|
|
494
|
+
const req = prior ? undefined : requestFor(false);
|
|
495
|
+
const big = req && tooLarge(req);
|
|
496
|
+
if (big) {
|
|
497
|
+
settle(big);
|
|
498
|
+
return;
|
|
499
|
+
}
|
|
500
|
+
// The slot's bound holds the foreign path too (ADR-0071): `cut` ends every send, find and poll
|
|
501
|
+
// at the bound — and a little past it, for the Effector's own answer at the bound to come back.
|
|
502
|
+
// The Effect then settles `unreachable` when no replica ever took the start, else `unknown`.
|
|
503
|
+
const cutter = new AbortController();
|
|
504
|
+
const cut = cutter.signal;
|
|
505
|
+
const onAbort = (): void => cutter.abort();
|
|
506
|
+
signal.addEventListener("abort", onAbort);
|
|
507
|
+
const foreignBound = deadline + Math.min(parts.timeoutMs, FOREIGN_GRACE_MS);
|
|
508
|
+
const disarm = atDeadline(prior ? Math.max(foreignBound, Date.now() + START_FLOOR_MS) : foreignBound, () =>
|
|
509
|
+
cutter.abort(),
|
|
510
|
+
);
|
|
511
|
+
/** Whether any send may have reached a replica: a restore's row may have been sent before. */
|
|
512
|
+
let reached = prior !== undefined;
|
|
513
|
+
const expired = (): void => {
|
|
514
|
+
disarm();
|
|
515
|
+
signal.removeEventListener("abort", onAbort);
|
|
516
|
+
if (signal.aborted || !cut.aborted) return;
|
|
517
|
+
settle(
|
|
518
|
+
reached
|
|
519
|
+
? unknown(`no outcome came from the Instance Effector within the slot's timeout; a run may have started`)
|
|
520
|
+
: {
|
|
521
|
+
outcome: "unreachable",
|
|
522
|
+
reason: "no Instance Effector replica took the start within the slot's timeout; nothing was started",
|
|
523
|
+
},
|
|
524
|
+
);
|
|
525
|
+
};
|
|
526
|
+
/** Ask every replica. Undefined when an answer was forged (the Effect is settled then) or the
|
|
527
|
+
* bound cut it. */
|
|
528
|
+
const findAll = async (): Promise<{ found: EffectState | undefined } | undefined> => {
|
|
529
|
+
try {
|
|
530
|
+
return { found: await retrying(() => port.find(id, cut), cut) };
|
|
531
|
+
} catch (err) {
|
|
532
|
+
if (cut.aborted) return undefined;
|
|
533
|
+
if (!(err instanceof EffectorForged)) throw err;
|
|
534
|
+
settle(
|
|
535
|
+
unknown(
|
|
536
|
+
`${err.message}: the Orchestrator's key for this Effector is not the Effector's, or another process ` +
|
|
537
|
+
"answered; not sent again",
|
|
538
|
+
),
|
|
539
|
+
);
|
|
540
|
+
return undefined;
|
|
541
|
+
}
|
|
542
|
+
};
|
|
543
|
+
|
|
544
|
+
if (prior) {
|
|
545
|
+
// In flight at an Orchestrator restart: follow the replica that holds it; when none does, send
|
|
546
|
+
// it again — the key makes the door answer with the run it started, if it did.
|
|
547
|
+
try {
|
|
548
|
+
const asked = await findAll();
|
|
549
|
+
if (!asked || cut.aborted) return;
|
|
550
|
+
if (asked.found) {
|
|
551
|
+
await follow(asked.found);
|
|
552
|
+
return;
|
|
553
|
+
}
|
|
554
|
+
mark({ stage: "started", text: `${named} (sent again under its key: a run it started is answered)` });
|
|
555
|
+
await send(requestFor(true));
|
|
556
|
+
return;
|
|
557
|
+
} finally {
|
|
558
|
+
expired();
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
binding?.recordEffect?.(id, intent());
|
|
563
|
+
mark({ stage: "started", text: named });
|
|
564
|
+
try {
|
|
565
|
+
await send(req!);
|
|
566
|
+
} finally {
|
|
567
|
+
expired();
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
/** The run's body: the invoke's input, parsed by the slot's schema when it has one. */
|
|
571
|
+
function inputOf(): Record<string, unknown> {
|
|
572
|
+
let input: unknown = snapshot.input ?? {};
|
|
573
|
+
if (def.input !== undefined) {
|
|
574
|
+
const parsed = def.input.safeParse(input);
|
|
575
|
+
if (!parsed.success) {
|
|
576
|
+
throw new Error(
|
|
577
|
+
`dispatch "${slot}": its input does not match the slot's input schema — ` +
|
|
578
|
+
parsed.error.issues.map((i) => `${i.path.join(".") || "(root)"}: ${i.message}`).join("; "),
|
|
579
|
+
);
|
|
580
|
+
}
|
|
581
|
+
input = parsed.data;
|
|
582
|
+
}
|
|
583
|
+
if (typeof input !== "object" || input === null || Array.isArray(input)) {
|
|
584
|
+
throw new Error(`dispatch "${slot}": its input is the run's body, a JSON object (ADR-0071)`);
|
|
585
|
+
}
|
|
586
|
+
return input as Record<string, unknown>;
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
/** The start, as the wire carries it. Sent `again`, its bound is what is left of the slot's,
|
|
590
|
+
* counted from the intent row's first write: a re-send never starts the bound over (ADR-0071). At
|
|
591
|
+
* least 1 ms: the key still answers the run, and the Effector settles at once with it. */
|
|
592
|
+
function requestFor(again: boolean): DispatchStartRequest {
|
|
593
|
+
const by = {
|
|
594
|
+
run: binding?.runId ?? "",
|
|
595
|
+
slot,
|
|
596
|
+
effect: id,
|
|
597
|
+
depth: (binding?.depth ?? 0) + 1,
|
|
598
|
+
// The caller is foreign to the door it calls: it names its Orchestrator, as opaque text the
|
|
599
|
+
// callee serves on its open band — never an Instance ID (ADR-0071).
|
|
600
|
+
instance: binding?.orchestratorName ?? "an Orchestrator with no name",
|
|
601
|
+
};
|
|
602
|
+
const timeoutMs = again ? Math.max(1, Math.floor(deadline - Date.now())) : parts.timeoutMs;
|
|
603
|
+
return dispatchStartOf({ ...parts, timeoutMs }, inputOf(), by);
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
/** The in-process wait (ADR-0071): the run host tells when the run leaves `live`; the slot's
|
|
607
|
+
* bound ends the wait with the run still live, never the run. */
|
|
608
|
+
async function waitIn(runId: string): Promise<void> {
|
|
609
|
+
const host = binding!.dispatch!;
|
|
610
|
+
const bounded = new AbortController();
|
|
611
|
+
const abort = (): void => bounded.abort();
|
|
612
|
+
signal.addEventListener("abort", abort);
|
|
613
|
+
let timedOut = false;
|
|
614
|
+
const disarmWait = atDeadline(deadline, () => {
|
|
615
|
+
timedOut = true;
|
|
616
|
+
bounded.abort();
|
|
617
|
+
});
|
|
618
|
+
try {
|
|
619
|
+
const status = await host.settled(runId, bounded.signal);
|
|
620
|
+
if (signal.aborted) return;
|
|
621
|
+
settle({
|
|
622
|
+
outcome: "settled",
|
|
623
|
+
run: runId,
|
|
624
|
+
status: status.status as SettledStatus,
|
|
625
|
+
...(status.output !== undefined ? { output: status.output } : {}),
|
|
626
|
+
});
|
|
627
|
+
} catch (err) {
|
|
628
|
+
if (signal.aborted) return;
|
|
629
|
+
if (!timedOut) throw err;
|
|
630
|
+
settle({ outcome: "settled", run: runId, status: "live" });
|
|
631
|
+
} finally {
|
|
632
|
+
disarmWait();
|
|
633
|
+
signal.removeEventListener("abort", abort);
|
|
634
|
+
}
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
/** Send the start, and follow it. A start that never reached a replica is sent again; one whose
|
|
638
|
+
* answer did not come is looked for on every replica, and sent again when none holds it — the
|
|
639
|
+
* key makes that safe (ADR-0071). */
|
|
640
|
+
async function send(first: DispatchStartRequest): Promise<void> {
|
|
641
|
+
let req = first;
|
|
642
|
+
for (let wait = 250; ; wait = Math.min(wait * 2, RETRY_MAX_MS)) {
|
|
643
|
+
let state: EffectState;
|
|
644
|
+
try {
|
|
645
|
+
state = await port.start(id, req, cut);
|
|
646
|
+
reached = true;
|
|
647
|
+
} catch (err) {
|
|
648
|
+
if (cut.aborted) return;
|
|
649
|
+
if (err instanceof EffectorRefused || err instanceof EffectorMalformed) throw err;
|
|
650
|
+
if (!(err instanceof EffectorNotSent)) {
|
|
651
|
+
reached = true;
|
|
652
|
+
const asked = await findAll();
|
|
653
|
+
if (!asked || cut.aborted) return;
|
|
654
|
+
if (asked.found) {
|
|
655
|
+
await follow(asked.found);
|
|
656
|
+
return;
|
|
657
|
+
}
|
|
658
|
+
}
|
|
659
|
+
await sleep(wait, cut);
|
|
660
|
+
if (cut.aborted) return;
|
|
661
|
+
req = requestFor(true);
|
|
662
|
+
continue;
|
|
663
|
+
}
|
|
664
|
+
await follow(state);
|
|
665
|
+
return;
|
|
666
|
+
}
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
/** Poll the replica that holds it to its outcome — with `wait`, the Effector's own poll of the
|
|
670
|
+
* door runs as long as the slot's bound. A replica that no longer knows it sends it again. */
|
|
671
|
+
async function follow(first: EffectState): Promise<void> {
|
|
672
|
+
let state = first;
|
|
673
|
+
let replica: Replica = first.replica;
|
|
674
|
+
for (;;) {
|
|
675
|
+
// The door answered, and the wait runs: the intent row names the callee from now on.
|
|
676
|
+
if (state.run !== undefined) pin(state.run, false);
|
|
677
|
+
if (state.state === "settled" && state.output) {
|
|
678
|
+
const parsed = DispatchOutputShape.safeParse(state.output);
|
|
679
|
+
if (!parsed.success) throw new EffectorMalformed("the Effector's output for a dispatch is not a dispatch's");
|
|
680
|
+
const output = parsed.data as DispatchOutput;
|
|
681
|
+
if (output.outcome === "started" || output.outcome === "settled") pin(output.run, false);
|
|
682
|
+
settle(output);
|
|
683
|
+
return;
|
|
684
|
+
}
|
|
685
|
+
if (state.state === "unknown") {
|
|
686
|
+
const asked = await findAll();
|
|
687
|
+
if (!asked || cut.aborted) return;
|
|
688
|
+
if (!asked.found) {
|
|
689
|
+
await send(requestFor(true));
|
|
690
|
+
return;
|
|
691
|
+
}
|
|
692
|
+
state = asked.found;
|
|
693
|
+
replica = asked.found.replica;
|
|
694
|
+
continue;
|
|
695
|
+
}
|
|
696
|
+
try {
|
|
697
|
+
state = await port.poll(replica, id, cut);
|
|
698
|
+
} catch (err) {
|
|
699
|
+
if (cut.aborted) return;
|
|
700
|
+
if (err instanceof EffectorRefused || err instanceof EffectorMalformed) throw err;
|
|
701
|
+
state = { effect: id, replica, state: "unknown" };
|
|
702
|
+
}
|
|
703
|
+
if (cut.aborted) return;
|
|
704
|
+
}
|
|
705
|
+
}
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
/**
|
|
709
|
+
* The state that invoked this Effect exited (D1) — or the host is parking the run. Parking keeps
|
|
710
|
+
* the row for the restore. An exit stops the wait and records the abort, "outcome unknown"; the run
|
|
711
|
+
* the Effect started runs on, untouched: the two runs are independent (ADR-0071). A foreign wait
|
|
712
|
+
* runs in the Instance Effector, so it is stopped there too: one signed DELETE, best-effort, and
|
|
713
|
+
* the Effector's poll of the door ends — it never sends anything to the callee.
|
|
714
|
+
*/
|
|
715
|
+
function stopped(self: AnyActorRef, system: AnyActorSystem, effectId: string): void {
|
|
716
|
+
const run = running.get(self);
|
|
717
|
+
running.delete(self);
|
|
718
|
+
run?.controller.abort();
|
|
719
|
+
const binding = boundRun(system);
|
|
720
|
+
if (binding?.hostStopping) return;
|
|
721
|
+
const row = binding?.effectRow?.(effectId);
|
|
722
|
+
if (row?.state !== "intent" || row.kind !== "dispatch") return;
|
|
723
|
+
// Sent at once, while a start may still be on the wire: the Effector keeps a stop of an id it
|
|
724
|
+
// does not hold, and the start that arrives after it goes to the door and is not waited for.
|
|
725
|
+
const port = row.placement === "instance" ? run?.port : undefined;
|
|
726
|
+
port?.stop(effectId, AbortSignal.timeout(STOP_WAIT_MS)).catch((err: unknown) => {
|
|
727
|
+
binding?.marker?.({
|
|
728
|
+
kind: "effect",
|
|
729
|
+
slot: row.slot,
|
|
730
|
+
effect: effectId,
|
|
731
|
+
stage: "aborted",
|
|
732
|
+
text: `the stop did not reach the Instance Effector (${err instanceof Error ? err.message : String(err)}); it may poll the run until its timeout`,
|
|
733
|
+
});
|
|
734
|
+
});
|
|
735
|
+
// The run it started stays on the row: the callee runs on, and the caller still names it (ADR-0071).
|
|
736
|
+
const aborted: EffectRow = {
|
|
737
|
+
state: "aborted",
|
|
738
|
+
kind: "dispatch",
|
|
739
|
+
slot: row.slot,
|
|
740
|
+
...(row.run !== undefined ? { run: row.run } : {}),
|
|
741
|
+
at: new Date().toISOString(),
|
|
742
|
+
};
|
|
743
|
+
binding?.recordEffect?.(effectId, aborted, "with-transition");
|
|
744
|
+
binding?.marker?.({
|
|
745
|
+
kind: "effect",
|
|
746
|
+
slot: row.slot,
|
|
747
|
+
effect: effectId,
|
|
748
|
+
stage: "aborted",
|
|
749
|
+
text:
|
|
750
|
+
row.run !== undefined
|
|
751
|
+
? `its state exited while it ran: no longer waited for; run ${row.run} runs on; outcome unknown`
|
|
752
|
+
: "its state exited while it ran: no longer followed; the run may have started; outcome unknown",
|
|
753
|
+
});
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
/** A start over the bytes the Effector reads is output `refused` (ADR-0068): nothing is sent. */
|
|
757
|
+
function tooLarge(req: DispatchStartRequest): DispatchOutput | undefined {
|
|
758
|
+
const bytes = Buffer.byteLength(JSON.stringify(req), "utf8");
|
|
759
|
+
if (bytes <= MAX_START_BYTES) return undefined;
|
|
760
|
+
return {
|
|
761
|
+
outcome: "refused",
|
|
762
|
+
status: 413,
|
|
763
|
+
report: [`the start is ${bytes} bytes, over the ${MAX_START_BYTES} bytes an Effector reads; nothing was sent`],
|
|
764
|
+
};
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
/**
|
|
768
|
+
* Call `fire` once `deadline` (epoch ms) has passed: a timer armed again in steps of at most
|
|
769
|
+
* `MAX_TIMER_MS`, so a bound past 24.8 days holds — one `setTimeout` that long fires at once.
|
|
770
|
+
* Answers the disarm.
|
|
771
|
+
*/
|
|
772
|
+
function atDeadline(deadline: number, fire: () => void): () => void {
|
|
773
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
774
|
+
let disarmed = false;
|
|
775
|
+
const arm = (): void => {
|
|
776
|
+
if (disarmed) return;
|
|
777
|
+
const left = deadline - Date.now();
|
|
778
|
+
if (left <= 0) {
|
|
779
|
+
fire();
|
|
780
|
+
return;
|
|
781
|
+
}
|
|
782
|
+
timer = setTimeout(arm, Math.min(left, MAX_TIMER_MS));
|
|
783
|
+
};
|
|
784
|
+
timer = setTimeout(arm, Math.min(Math.max(0, deadline - Date.now()), MAX_TIMER_MS));
|
|
785
|
+
return () => {
|
|
786
|
+
disarmed = true;
|
|
787
|
+
clearTimeout(timer);
|
|
788
|
+
};
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
/** `work`'s answer, or undefined when `deadline` passes or `signal` aborts first. */
|
|
792
|
+
async function beforeDeadline<T>(work: Promise<T>, deadline: number, signal: AbortSignal): Promise<T | undefined> {
|
|
793
|
+
let disarm = (): void => {};
|
|
794
|
+
let onAbort = (): void => {};
|
|
795
|
+
const ended = new Promise<undefined>((resolve) => {
|
|
796
|
+
disarm = atDeadline(deadline, () => resolve(undefined));
|
|
797
|
+
onAbort = () => resolve(undefined);
|
|
798
|
+
signal.addEventListener("abort", onAbort);
|
|
799
|
+
});
|
|
800
|
+
try {
|
|
801
|
+
return await Promise.race([work, ended]);
|
|
802
|
+
} finally {
|
|
803
|
+
disarm();
|
|
804
|
+
signal.removeEventListener("abort", onAbort);
|
|
805
|
+
}
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
async function retrying<T>(call: () => Promise<T>, signal: AbortSignal): Promise<T> {
|
|
809
|
+
for (let wait = 250; ; wait = Math.min(wait * 2, RETRY_MAX_MS)) {
|
|
810
|
+
try {
|
|
811
|
+
return await call();
|
|
812
|
+
} catch (err) {
|
|
813
|
+
if (
|
|
814
|
+
signal.aborted ||
|
|
815
|
+
err instanceof EffectorRefused ||
|
|
816
|
+
err instanceof EffectorMalformed ||
|
|
817
|
+
err instanceof EffectorForged
|
|
818
|
+
) {
|
|
819
|
+
throw err;
|
|
820
|
+
}
|
|
821
|
+
await sleep(wait, signal);
|
|
822
|
+
if (signal.aborted) throw err;
|
|
823
|
+
}
|
|
824
|
+
}
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
function sleep(ms: number, signal: AbortSignal): Promise<void> {
|
|
828
|
+
return new Promise((resolve) => {
|
|
829
|
+
const timer = setTimeout(resolve, ms / 2 + Math.random() * (ms / 2));
|
|
830
|
+
signal.addEventListener("abort", () => {
|
|
831
|
+
clearTimeout(timer);
|
|
832
|
+
resolve();
|
|
833
|
+
});
|
|
834
|
+
});
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
function parseDuration(workflow: string, d: Duration): number {
|
|
838
|
+
const n = Number.parseFloat(d);
|
|
839
|
+
if (!(n > 0) || !/^[0-9.]+[smh]$/.test(d)) {
|
|
840
|
+
throw new Error(`dispatch("${workflow}"): timeout "${d}" is not a positive duration (like "30s", "10m", "1h")`);
|
|
841
|
+
}
|
|
842
|
+
return d.endsWith("h") ? n * 3_600_000 : d.endsWith("m") ? n * 60_000 : n * 1000;
|
|
843
|
+
}
|
|
844
|
+
|
|
845
|
+
// THE authoring surface for a `dispatch` Effect (ADR-0071). With no target it starts the run in
|
|
846
|
+
// this Orchestrator, through the run host; with one it runs in the Instance Effector, which
|
|
847
|
+
// `jr2 up` converges because this definition is placed there.
|
|
848
|
+
/**
|
|
849
|
+
* Make a `dispatch` Effect: it starts a run of a Workflow. Put it in `actors`, and invoke it from a
|
|
850
|
+
* state: `actors: { triage: dispatch("triage", { input }) }`. The input of the invoke is the run
|
|
851
|
+
* input of the new run, as `jr2 run --input` gives it.
|
|
852
|
+
*
|
|
853
|
+
* The new run is a run of its own, with its own id, its own Workspaces and its own `CANCEL`. Its
|
|
854
|
+
* record names this run as the run that dispatched it. A `CANCEL` of this run does not end it.
|
|
855
|
+
*
|
|
856
|
+
* With no `target`, the run starts in this Orchestrator, under the current Assembly. With
|
|
857
|
+
* `target: { url, token }`, it starts on the Orchestrator at `url`, and the Effect runs in the
|
|
858
|
+
* Instance Effector. Every outcome is output ({@link DispatchOutput}): `started`, `settled`,
|
|
859
|
+
* `refused`, `unreachable` or `unknown`. Each start carries a key, so a start that jr2 sends again
|
|
860
|
+
* after a restart starts no second run.
|
|
861
|
+
*
|
|
862
|
+
* It refuses:
|
|
863
|
+
* - a name that is not a Workflow name: ``dispatch("<name>"): the Workflow is named as `jr2 run`
|
|
864
|
+
* names it — a Machine's filename stem ("review"), or "<wrapper>/<name>" for a named Chart:
|
|
865
|
+
* lowercase letters, digits and "-" (ADR-0071)``
|
|
866
|
+
* - a `wait` that is not a boolean: `dispatch("<name>", { wait }): wait is true or false — a slot
|
|
867
|
+
* that needs both declares two`
|
|
868
|
+
* - an `input` that is not a `z.object()`: `dispatch("<name>", { input }): the input is a
|
|
869
|
+
* z.object(...) — the run's body is a JSON object (ADR-0071)`
|
|
870
|
+
* - a `timeout` that is not a duration: `dispatch("<name>"): timeout "<timeout>" is not a positive
|
|
871
|
+
* duration (like "30s", "10m", "1h")`
|
|
872
|
+
* - a `target` url that is not an origin: `dispatch("<name>", { target }): "<url>" is not an
|
|
873
|
+
* origin — write the scheme and the host, with a port if it is not the scheme's, and no path,
|
|
874
|
+
* query or userinfo`, then two examples
|
|
875
|
+
* - `secret(open)` in a target: `dispatch("<name>", { target }): secret(open) in a target — the
|
|
876
|
+
* target is the Open part, not its secret`, then the fix
|
|
877
|
+
*
|
|
878
|
+
* `jr2 up` refuses a dispatch with no target whose Workflow the Assembly does not have, and an
|
|
879
|
+
* `input` schema that the input of that Workflow does not take.
|
|
880
|
+
*
|
|
881
|
+
* @param workflow - The Workflow to start, as `jr2 run` names it: `"triage"`, or
|
|
882
|
+
* `"<wrapper>/<name>"` for a named Chart.
|
|
883
|
+
* @param def - Optional. Each field tells what it does.
|
|
884
|
+
* @returns An Effect, for `actors`.
|
|
885
|
+
*
|
|
886
|
+
* @example
|
|
887
|
+
* ```ts
|
|
888
|
+
* import { assign } from "xstate";
|
|
889
|
+
* import { z } from "zod";
|
|
890
|
+
* import { defineEvent, dispatch, jr2Setup } from "@jr2/orchestrator";
|
|
891
|
+
*
|
|
892
|
+
* const triageInput = z.object({ request: z.string() });
|
|
893
|
+
*
|
|
894
|
+
* const report = defineEvent({
|
|
895
|
+
* name: "report",
|
|
896
|
+
* description: "Report a request. A run of triage starts for it.",
|
|
897
|
+
* input: triageInput,
|
|
898
|
+
* });
|
|
899
|
+
*
|
|
900
|
+
* export const machine = jr2Setup({
|
|
901
|
+
* types: {} as { context: { request: string } },
|
|
902
|
+
* events: [report],
|
|
903
|
+
* // `wait: true`: the Effect ends when the run of triage ends.
|
|
904
|
+
* actors: { triage: dispatch("triage", { input: triageInput, wait: true }) },
|
|
905
|
+
* }).createMachine({
|
|
906
|
+
* id: "intake",
|
|
907
|
+
* context: { request: "" },
|
|
908
|
+
* initial: "listening",
|
|
909
|
+
* states: {
|
|
910
|
+
* listening: {
|
|
911
|
+
* invoke: { src: "gate" },
|
|
912
|
+
* on: { report: { target: "starting", actions: assign({ request: ({ event }) => event.request }) } },
|
|
913
|
+
* },
|
|
914
|
+
* starting: {
|
|
915
|
+
* invoke: {
|
|
916
|
+
* src: "triage",
|
|
917
|
+
* input: ({ context }) => ({ request: context.request }),
|
|
918
|
+
* onDone: [
|
|
919
|
+
* { guard: ({ event }) => event.output.outcome === "settled", target: "done" },
|
|
920
|
+
* { target: "listening" },
|
|
921
|
+
* ],
|
|
922
|
+
* },
|
|
923
|
+
* },
|
|
924
|
+
* done: { type: "final" },
|
|
925
|
+
* },
|
|
926
|
+
* });
|
|
927
|
+
* ```
|
|
928
|
+
*
|
|
929
|
+
* @see docs/guides/start-a-run-from-a-run.md
|
|
930
|
+
* @see docs/concepts/effects.md
|
|
931
|
+
* @see docs/reference/decisions/0071-a-run-starts-a-run-through-a-dispatch-effect.md
|
|
932
|
+
*/
|
|
933
|
+
export function dispatch<
|
|
934
|
+
S extends z.ZodObject | undefined = undefined,
|
|
935
|
+
const T extends DispatchDefinition<S>["target"] = undefined,
|
|
936
|
+
>(
|
|
937
|
+
workflow: string,
|
|
938
|
+
def?: DispatchDefinition<S> & { target?: T },
|
|
939
|
+
): EffectLogic<DispatchInput<S>, DispatchOutput> & OpenOf<DispatchOpenParts<T>> {
|
|
940
|
+
return dispatchWith(
|
|
941
|
+
(target) => createEffectorClient({ key: target.key, transport: nodeEffectorTransport(target.service) }),
|
|
942
|
+
workflow,
|
|
943
|
+
def,
|
|
944
|
+
);
|
|
945
|
+
}
|