@jr2/orchestrator 0.3.0 → 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/bin/server.ts +7 -4
- package/console/canvas.ts +27 -14
- package/console/components/app.ts +12 -1
- package/console/components/fleet.ts +52 -6
- package/console/components/machine-pane.ts +128 -2
- package/console/components/nav.ts +1 -1
- package/console/components/schema-form.ts +12 -4
- package/console/json-field.ts +23 -0
- package/console/main.ts +122 -39
- package/console/store.ts +321 -21
- package/console/style.css +111 -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 +6 -4
- package/src/actor.ts +976 -61
- package/src/agent.ts +114 -38
- package/src/ambient.ts +4 -1
- package/src/assembly-checks.ts +347 -0
- package/src/assembly-tar.ts +494 -0
- package/src/assembly.ts +721 -0
- package/src/cause.ts +234 -0
- package/src/chart.ts +2182 -0
- package/src/charts.ts +748 -0
- package/src/config.ts +520 -162
- package/src/content-hash.ts +105 -0
- package/src/custodian.lua +6 -5
- package/src/custodian.ts +143 -39
- package/src/customize.ts +388 -55
- package/src/dispatch-effect.ts +945 -0
- package/src/door-compat.ts +217 -0
- package/src/durability.ts +11 -4
- package/src/effect-checks.ts +557 -0
- package/src/effect.ts +1202 -0
- package/src/effector-wire.ts +674 -0
- package/src/fail.ts +209 -0
- package/src/fingerprint.ts +4 -3
- package/src/gate.ts +8 -0
- package/src/git-clone.ts +65 -0
- package/src/git-push.ts +336 -0
- package/src/harness-client.ts +764 -48
- package/src/held-secrets.ts +82 -26
- package/src/holds.ts +156 -0
- package/src/http-effect.ts +1423 -0
- package/src/http.ts +916 -113
- package/src/images.ts +47 -21
- package/src/index.ts +62 -43
- package/src/instance.ts +505 -59
- package/src/internal.ts +84 -0
- package/src/kube-client.ts +333 -0
- package/src/machine-doc.ts +106 -6
- package/src/mechanism-routes.ts +168 -0
- package/src/model-keys.generated.ts +53 -0
- package/src/model-keys.ts +18 -58
- package/src/mount-digest.ts +53 -0
- package/src/names.ts +66 -8
- package/src/notices.ts +141 -0
- package/src/open-body.ts +1580 -0
- package/src/open-effect.ts +217 -0
- package/src/open.ts +59 -6
- package/src/parts.ts +709 -41
- package/src/party.ts +534 -0
- package/src/pick.ts +761 -0
- package/src/pool.ts +248 -37
- package/src/registration.ts +382 -19
- package/src/repo-fetch.ts +60 -85
- package/src/repos.ts +65 -75
- package/src/rest.ts +133 -0
- package/src/run-host.ts +2383 -115
- package/src/run-output.ts +38 -0
- package/src/sandbox-effect.ts +1126 -0
- package/src/sandbox-effector.ts +111 -0
- package/src/sandbox-kube.ts +1721 -0
- package/src/sandbox-watch.ts +260 -0
- package/src/secret-placement.ts +529 -0
- package/src/server.ts +284 -84
- package/src/setup.ts +819 -117
- package/src/size.ts +454 -0
- package/src/snapshot-store.ts +115 -20
- package/src/stub-harness.ts +49 -16
- package/src/sync-client.ts +54 -0
- package/src/timers.ts +191 -0
- package/src/tokens.ts +35 -4
- package/src/vocabulary.ts +135 -15
- package/src/wire.ts +238 -10
- package/src/workspace.ts +1195 -338
- package/src/sandbox-kubectl.ts +0 -1108
|
@@ -0,0 +1,1423 @@
|
|
|
1
|
+
// The `http` Effect (ADR-0068): call a URL from the Instance Effector, with a secret the Agent
|
|
2
|
+
// never reads. An actor slot beside `command()` (ADR-0067):
|
|
3
|
+
//
|
|
4
|
+
// comment: http({
|
|
5
|
+
// method: "POST",
|
|
6
|
+
// url: "https://jira.example.com/rest/api/2/issue",
|
|
7
|
+
// auth: bearer(secret("jira")),
|
|
8
|
+
// input: z.object({ key: z.string(), body: z.string() }),
|
|
9
|
+
// request: ({ key, body }) => ({ path: [key, "comment"], json: { body } }),
|
|
10
|
+
// })
|
|
11
|
+
//
|
|
12
|
+
// Its body may be a Worktree file instead (ADR-0067), and then it runs in the Sandbox Effector of the
|
|
13
|
+
// Workspace whose body invokes it, which opens the file beneath the Worktree with no symlink:
|
|
14
|
+
//
|
|
15
|
+
// report: http({
|
|
16
|
+
// method: "PUT",
|
|
17
|
+
// url: "https://files.example.com/reports",
|
|
18
|
+
// auth: bearer(secret("files")),
|
|
19
|
+
// input: z.object({ marker: z.string() }),
|
|
20
|
+
// body: file("dist/report.pdf", { segments: ({ marker }) => [marker], contentType: "application/pdf" }),
|
|
21
|
+
// })
|
|
22
|
+
//
|
|
23
|
+
// The definition is plain data `jr2 up` reads without running anything (`HttpParts`): the kind,
|
|
24
|
+
// the placement (`instance`, or `sandbox` for a file body), the fixed host, the secret names. `request` is the one function: it
|
|
25
|
+
// maps the Effect's typed input to the run's part of the request — `path` segments and a body —
|
|
26
|
+
// and nothing else, so an Agent's param reaches a path segment or a body and never the host, a
|
|
27
|
+
// header or a secret. The Effector builds the request; the Orchestrator holds no secret.
|
|
28
|
+
//
|
|
29
|
+
// What this module adds to the Effect's restart safety (effect.ts) for this kind:
|
|
30
|
+
//
|
|
31
|
+
// - THE RESTORE RULE. The intent row names the placement and no replica. After an
|
|
32
|
+
// Orchestrator restart, or a start whose answer never came, the Effect asks EVERY replica for
|
|
33
|
+
// its id: one that holds it is followed; when none does, an Effect the author marked
|
|
34
|
+
// `idempotent` is sent again, and any other settles `unknown`. A start that certainly never
|
|
35
|
+
// reached a replica (no connection, a stopping replica) is sent again either way.
|
|
36
|
+
// - A FORGED ANSWER. An answer the placement's key does not verify is not the Effector's: a key
|
|
37
|
+
// that differs from the Effector's, or another process on its address. A poll's is read as a
|
|
38
|
+
// replica gone (its address may be stale), so every replica is asked; when asking every
|
|
39
|
+
// replica gets one, the Effect settles `unknown` and is not sent again — no retry changes the
|
|
40
|
+
// key the answer is checked with, and such a process can make the call fail, never answer it.
|
|
41
|
+
// - D1. When its state exits, the Orchestrator stops polling and records the abort, "outcome
|
|
42
|
+
// unknown", on the ledger and the feed. An http Effect has no stop route: the Effector finishes the call
|
|
43
|
+
// and drops an outcome nobody reads.
|
|
44
|
+
|
|
45
|
+
import { randomUUID } from "node:crypto";
|
|
46
|
+
import type { ActorLogic, AnyActorRef, EventObject } from "xstate";
|
|
47
|
+
import type { z } from "zod";
|
|
48
|
+
import type {
|
|
49
|
+
Duration,
|
|
50
|
+
EffectLogic,
|
|
51
|
+
EffectMarker,
|
|
52
|
+
EffectRow,
|
|
53
|
+
EffectSnapshot,
|
|
54
|
+
HttpParts,
|
|
55
|
+
OpenHttpParts,
|
|
56
|
+
Unknown,
|
|
57
|
+
} from "./effect.ts";
|
|
58
|
+
import {
|
|
59
|
+
createEffectorClient,
|
|
60
|
+
EffectorForged,
|
|
61
|
+
EffectorMalformed,
|
|
62
|
+
EffectorNotSent,
|
|
63
|
+
EffectorRefused,
|
|
64
|
+
HTTP_METHODS,
|
|
65
|
+
isEffectHeader,
|
|
66
|
+
MAX_START_BYTES,
|
|
67
|
+
nodeEffectorTransport,
|
|
68
|
+
type EffectorPort,
|
|
69
|
+
type EffectState,
|
|
70
|
+
type HttpMethod,
|
|
71
|
+
type HttpOutput,
|
|
72
|
+
type Replica,
|
|
73
|
+
type HttpStartRequest,
|
|
74
|
+
type StartRequest,
|
|
75
|
+
type TextPart,
|
|
76
|
+
type WireAuth,
|
|
77
|
+
type WireBody,
|
|
78
|
+
} from "./effector-wire.ts";
|
|
79
|
+
import { boundRun, type AnyActorSystem } from "./registration.ts";
|
|
80
|
+
import { open } from "./open.ts";
|
|
81
|
+
import { effectBindingShape, openEffectLogic, openPartsOf, type OpenOf } from "./open-effect.ts";
|
|
82
|
+
import { httpFileWith, sandboxEffectorPort } from "./sandbox-effect.ts";
|
|
83
|
+
|
|
84
|
+
// --- Naming a secret -----------------------------------------------------------------------------
|
|
85
|
+
|
|
86
|
+
// Naming it in an Effect is what places it in that Effect's Effector (ADR-0068). On the wire it is
|
|
87
|
+
// a name.
|
|
88
|
+
/** What `secret("name")` gives: an Effect secret, by its name in the `secrets` list of
|
|
89
|
+
* `jr2.config.ts`. Put it in an Effect's `auth`, a header, a `text` or a command's `env`. Its
|
|
90
|
+
* `.path` is the path of a file that holds the value. The value stays in the Effector, so the
|
|
91
|
+
* Machine and the Agent never see it. */
|
|
92
|
+
export type Secret = { readonly secret: string; readonly path: { readonly secret: string; readonly path: true } };
|
|
93
|
+
|
|
94
|
+
/** What the `text` template gives: a string of literal text and Effect secrets, in order. The
|
|
95
|
+
* Effector builds the string, so the Machine never sees the value of a secret. */
|
|
96
|
+
export type Text = { readonly text: readonly TextPart[] };
|
|
97
|
+
|
|
98
|
+
declare const openSecretMark: unique symbol;
|
|
99
|
+
/** The TYPE half of an Open secret (ADR-0054): whatever carries `secret(open)` — the secret, its
|
|
100
|
+
* `.path`, a `text` or a `basic` pair holding it, a `bearer` of it — carries this mark, so the
|
|
101
|
+
* Effect's constructor states the Open part "secret" and `customize()` offers `{ secret }`.
|
|
102
|
+
* Phantom.
|
|
103
|
+
*
|
|
104
|
+
* @internal
|
|
105
|
+
*/
|
|
106
|
+
export type OpenSecretMark = { readonly [openSecretMark]: true };
|
|
107
|
+
|
|
108
|
+
/** A named secret, never `secret(open)`: where a definition's Open part is a whole that holds a
|
|
109
|
+
* secret — a dispatch's target (ADR-0071) — the secret is bound with it, to a name, so the mark
|
|
110
|
+
* `secret(open)` carries may not be present. One type for the definition and the binding. */
|
|
111
|
+
export type NamedSecret = Secret & { readonly [K in keyof OpenSecretMark]?: never };
|
|
112
|
+
|
|
113
|
+
/** `secret(open)`: a secret a packaged Machine names in its auth, a header or a command's env, and
|
|
114
|
+
* leaves for its composer to name (ADR-0054). */
|
|
115
|
+
export type OpenSecret = {
|
|
116
|
+
readonly secret: string;
|
|
117
|
+
readonly path: { readonly secret: string; readonly path: true } & OpenSecretMark;
|
|
118
|
+
} & OpenSecretMark;
|
|
119
|
+
|
|
120
|
+
/** The name an Open secret stands under in a definition's plain data until `customize()` binds it.
|
|
121
|
+
* `@` is no character of a secret's name, so it can never be one; an Effect that still holds it
|
|
122
|
+
* runs nothing (open-effect.ts). */
|
|
123
|
+
export const OPEN_SECRET = "@open";
|
|
124
|
+
|
|
125
|
+
const SECRET_NAME = /^[A-Za-z0-9._-]{1,253}$/;
|
|
126
|
+
|
|
127
|
+
/** Each `secret(open)` a definition names, by every object that stands for it — the secret, its
|
|
128
|
+
* `.path`, a `text` part built from it — so a definition that names one Open secret in two places
|
|
129
|
+
* (a value and its `.path`) is told from one that names two. */
|
|
130
|
+
const openSecretOf = new WeakMap<object, object>();
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Name an Effect secret: `secret("jira")`. The name is the `name` of an entry in the `secrets`
|
|
134
|
+
* list of `jr2.config.ts`. The Effector reads the value and puts it into the call, or into the env
|
|
135
|
+
* of a `command`. The Agent never reads it, and the Orchestrator never holds it.
|
|
136
|
+
*
|
|
137
|
+
* Use it in `bearer`, `basic` or `text`, or in the `env` of a `command`. In an `env`,
|
|
138
|
+
* `secret("x")` gives the value, and `secret("x").path` gives the path of a file that holds the
|
|
139
|
+
* value.
|
|
140
|
+
*
|
|
141
|
+
* `secret(open)` leaves the name to the composer of a packaged Machine, who binds it one time:
|
|
142
|
+
* `customize(machine, { effects: { <slot>: { secret: "<name>" } } })`. An Effect can have one Open
|
|
143
|
+
* secret, in its `auth`, a header or its `env`. A url cannot have one: leave the whole url Open
|
|
144
|
+
* (`url: open`) instead.
|
|
145
|
+
*
|
|
146
|
+
* It refuses, when you call it:
|
|
147
|
+
* - a name that is not letters, digits, `.`, `_` and `-`, that starts with `.`, or that is longer
|
|
148
|
+
* than 253 characters: `secret("<name>"): a secret's name is letters, digits, ".", "_" and "-",
|
|
149
|
+
* and does not start with "." — it is the file the Effector reads it from (ADR-0068)`
|
|
150
|
+
* - an Effect that names `secret(open)` in two places that are two secrets: `an Effect takes one
|
|
151
|
+
* Open secret — it names secret(open) in two places that are two secrets; name one secret(open)
|
|
152
|
+
* and use it in each place, or name the others with secret("<name>") (ADR-0054)`
|
|
153
|
+
* - `secret(open)` in a url: ``secret(open) in a url: a url's secret is not an Open part — leave
|
|
154
|
+
* the whole url Open (`url: open`) and let the composer bind it with its secret in its path
|
|
155
|
+
* (ADR-0054)``
|
|
156
|
+
*
|
|
157
|
+
* And at `jr2 up`:
|
|
158
|
+
* - a name that the `secrets` list does not have: ``workflow "<workflow>": the <kind> Effect
|
|
159
|
+
* "<slot>" names secret("<name>"), which the `secrets` list does not hold — add it: secrets: [{
|
|
160
|
+
* name: "<name>", value: process.env.<NAME> }] (ADR-0068)``
|
|
161
|
+
* - a value of fewer than 8 characters: `secrets[<i>] (<name>) is an Effect secret shorter than 8
|
|
162
|
+
* characters — the Effector replaces every match of a secret in what it returns, and a value
|
|
163
|
+
* this short would match ordinary text. Use the whole credential; the value itself is not shown
|
|
164
|
+
* (ADR-0068)`
|
|
165
|
+
*
|
|
166
|
+
* @param name - The name of an entry in the `secrets` list.
|
|
167
|
+
* @returns The Effect secret, for an Effect. Its `.path` names the file of the secret, for the
|
|
168
|
+
* `env` of a `command`.
|
|
169
|
+
*
|
|
170
|
+
* @example
|
|
171
|
+
* ```ts
|
|
172
|
+
* import { command, fail, jr2Setup, secret, workspace, type Workspaced } from "@jr2/orchestrator";
|
|
173
|
+
*
|
|
174
|
+
* type Input = Workspaced<{}, "app">;
|
|
175
|
+
*
|
|
176
|
+
* const body = jr2Setup({
|
|
177
|
+
* types: {} as { context: Input; input: Input },
|
|
178
|
+
* actors: {
|
|
179
|
+
* // The command reads the file at $KUBECONFIG. The Agent never reads the secret.
|
|
180
|
+
* deploy: command(["kubectl", "apply", "-f", "k8s/"], {
|
|
181
|
+
* env: { KUBECONFIG: secret("kube").path },
|
|
182
|
+
* }),
|
|
183
|
+
* fail: fail(),
|
|
184
|
+
* },
|
|
185
|
+
* }).createMachine({
|
|
186
|
+
* id: "deploy",
|
|
187
|
+
* context: ({ input }) => input,
|
|
188
|
+
* on: { "workspace.lost": ".lost" },
|
|
189
|
+
* initial: "deploying",
|
|
190
|
+
* states: {
|
|
191
|
+
* deploying: { invoke: { src: "deploy", onDone: "done" } },
|
|
192
|
+
* done: { type: "final" },
|
|
193
|
+
* lost: { invoke: { src: "fail", input: { reason: "the Sandbox is gone" } } },
|
|
194
|
+
* },
|
|
195
|
+
* });
|
|
196
|
+
*
|
|
197
|
+
* export const machine = workspace(body, {
|
|
198
|
+
* repos: { app: { url: "https://github.com/you/app.git" } },
|
|
199
|
+
* spec: () => ({ branch: "deploy" }),
|
|
200
|
+
* });
|
|
201
|
+
* ```
|
|
202
|
+
*
|
|
203
|
+
* @see docs/concepts/secrets-and-trust.md
|
|
204
|
+
* @see docs/guides/run-a-command-or-call-a-url.md
|
|
205
|
+
* @see docs/reference/decisions/0068-an-effector-holds-an-effects-secrets-and-signs-its-wire.md
|
|
206
|
+
* @see docs/reference/decisions/0054-the-kit-ships-machines-and-a-shipped-machine-leaves-its-parts-open.md
|
|
207
|
+
*/
|
|
208
|
+
export function secret(name: string): Secret;
|
|
209
|
+
/**
|
|
210
|
+
* Leave an Effect secret Open: `secret(open)`, in a Machine that you publish as a package. The
|
|
211
|
+
* composer binds its name one time: `customize(machine, { effects: { <slot>: { secret: "<name>"
|
|
212
|
+
* } } })`. {@link secret} tells the rest, and what it refuses.
|
|
213
|
+
*
|
|
214
|
+
* @param name - `open`.
|
|
215
|
+
* @returns The Open secret, for an Effect. Its `.path` names the file of the secret, for the `env`
|
|
216
|
+
* of a `command`.
|
|
217
|
+
*
|
|
218
|
+
* @see docs/concepts/secrets-and-trust.md
|
|
219
|
+
* @see docs/guides/bind-open-parts.md
|
|
220
|
+
* @see docs/reference/decisions/0054-the-kit-ships-machines-and-a-shipped-machine-leaves-its-parts-open.md
|
|
221
|
+
*/
|
|
222
|
+
export function secret(name: typeof open): OpenSecret;
|
|
223
|
+
export function secret(name: string | typeof open): Secret | OpenSecret {
|
|
224
|
+
if ((name as unknown) === open) {
|
|
225
|
+
// An Open secret (ADR-0054): the composer binds its name once, with `customize()`'s
|
|
226
|
+
// `{ effects: { <slot>: { secret: "<name>" } } }`. Its plain data names `OPEN_SECRET` until then.
|
|
227
|
+
const s = { secret: OPEN_SECRET } as OpenSecret;
|
|
228
|
+
const path = Object.freeze({ secret: OPEN_SECRET, path: true as const });
|
|
229
|
+
Object.defineProperty(s, "path", { value: path, enumerable: false });
|
|
230
|
+
openSecretOf.set(s, s);
|
|
231
|
+
openSecretOf.set(path, s);
|
|
232
|
+
return s;
|
|
233
|
+
}
|
|
234
|
+
if (typeof name !== "string" || !SECRET_NAME.test(name) || name.startsWith(".")) {
|
|
235
|
+
throw new Error(
|
|
236
|
+
`secret("${String(name)}"): a secret's name is letters, digits, ".", "_" and "-", and does not start with "." — ` +
|
|
237
|
+
"it is the file the Effector reads it from (ADR-0068)",
|
|
238
|
+
);
|
|
239
|
+
}
|
|
240
|
+
// `.path` names the secret's FILE in the Effector's own mount, for a credentialed command's env
|
|
241
|
+
// (ADR-0067): `env: { KUBECONFIG: secret("kube").path }`. Not enumerable, so a definition's plain
|
|
242
|
+
// data never carries it.
|
|
243
|
+
const s = { secret: name } as Secret;
|
|
244
|
+
Object.defineProperty(s, "path", { value: Object.freeze({ secret: name, path: true as const }), enumerable: false });
|
|
245
|
+
return s;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* What a value that may hold `secret(open)` gives: marked when it does.
|
|
250
|
+
*
|
|
251
|
+
* @internal
|
|
252
|
+
*/
|
|
253
|
+
type MarkedIf<V, T> = [Extract<V, OpenSecretMark>] extends [never] ? T : T & OpenSecretMark;
|
|
254
|
+
|
|
255
|
+
/** The values of a record type, or never. */
|
|
256
|
+
type ValuesOf<R> = R extends Readonly<Record<string, infer V>> ? V : never;
|
|
257
|
+
|
|
258
|
+
/** "secret" when one of these values holds `secret(open)`, else never: the Open part it states. */
|
|
259
|
+
export type OpenSecretIn<V> = [Extract<V, OpenSecretMark>] extends [never] ? never : "secret";
|
|
260
|
+
|
|
261
|
+
/** The Open parts an http definition's types state (ADR-0054): its url, when it is `open`, and its
|
|
262
|
+
* secret, when its auth or a header holds `secret(open)`.
|
|
263
|
+
*
|
|
264
|
+
* @internal
|
|
265
|
+
*/
|
|
266
|
+
export type HttpOpenParts<U, A, H> = (U extends typeof open ? "url" : never) | OpenSecretIn<A | ValuesOf<H>>;
|
|
267
|
+
|
|
268
|
+
/** A secret's wire part: `{ secret: name }` — for an Open secret, a part that stands for it. */
|
|
269
|
+
function partOf(s: Secret): TextPart {
|
|
270
|
+
const part = { secret: s.secret };
|
|
271
|
+
const owner = openSecretOf.get(s);
|
|
272
|
+
if (owner) openSecretOf.set(part, owner);
|
|
273
|
+
return part;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* Make a string from literal text and Effect secrets, as a tagged template:
|
|
278
|
+
* `` text`Token ${secret("x")}` ``. The Effector puts in the value of each secret when it makes
|
|
279
|
+
* the call. Use it for a url with a secret in its path, or for a header value. Use it also for
|
|
280
|
+
* `auth`, where it is the whole `Authorization` header, or for a part of `basic`.
|
|
281
|
+
*
|
|
282
|
+
* It refuses a `${…}` that is not a `secret(...)` or a string: `` text`…`: a value is a
|
|
283
|
+
* secret("name") or a string, got <value> ``.
|
|
284
|
+
*
|
|
285
|
+
* @param strings - The literal text of the template.
|
|
286
|
+
* @param values - The `${…}` values, each a `secret(...)` or a string.
|
|
287
|
+
* @returns The string, which the Effector makes.
|
|
288
|
+
*
|
|
289
|
+
* @example
|
|
290
|
+
* ```ts
|
|
291
|
+
* import { http, jr2Setup, secret, text } from "@jr2/orchestrator";
|
|
292
|
+
*
|
|
293
|
+
* export const machine = jr2Setup({
|
|
294
|
+
* actors: {
|
|
295
|
+
* notify: http({
|
|
296
|
+
* method: "POST",
|
|
297
|
+
* url: text`https://api.telegram.org/bot${secret("telegram")}/sendMessage`,
|
|
298
|
+
* headers: { "x-run-source": text`jr2` },
|
|
299
|
+
* request: () => ({ json: { chat_id: "12345", text: "The run is done." } }),
|
|
300
|
+
* }),
|
|
301
|
+
* },
|
|
302
|
+
* }).createMachine({
|
|
303
|
+
* id: "notify",
|
|
304
|
+
* initial: "notifying",
|
|
305
|
+
* states: {
|
|
306
|
+
* notifying: { invoke: { src: "notify", onDone: "done" } },
|
|
307
|
+
* done: { type: "final" },
|
|
308
|
+
* },
|
|
309
|
+
* });
|
|
310
|
+
* ```
|
|
311
|
+
*
|
|
312
|
+
* @see docs/guides/run-a-command-or-call-a-url.md
|
|
313
|
+
* @see docs/concepts/secrets-and-trust.md
|
|
314
|
+
* @see docs/reference/decisions/0068-an-effector-holds-an-effects-secrets-and-signs-its-wire.md
|
|
315
|
+
*/
|
|
316
|
+
export function text<const V extends Array<Secret | OpenSecret | string>>(
|
|
317
|
+
strings: TemplateStringsArray,
|
|
318
|
+
...values: V
|
|
319
|
+
): MarkedIf<V[number], Text>;
|
|
320
|
+
export function text(strings: TemplateStringsArray, ...values: Array<Secret | string>): Text {
|
|
321
|
+
const parts: TextPart[] = [];
|
|
322
|
+
const push = (s: string): void => {
|
|
323
|
+
if (s === "") return;
|
|
324
|
+
const last = parts.at(-1);
|
|
325
|
+
if (last && "text" in last) parts[parts.length - 1] = { text: last.text + s };
|
|
326
|
+
else parts.push({ text: s });
|
|
327
|
+
};
|
|
328
|
+
strings.forEach((s, i) => {
|
|
329
|
+
push(s);
|
|
330
|
+
if (i >= values.length) return;
|
|
331
|
+
const v = values[i];
|
|
332
|
+
if (typeof v === "string") push(v);
|
|
333
|
+
else if (isSecret(v)) parts.push(partOf(v));
|
|
334
|
+
else throw new Error(`text\`…\`: a value is a secret("name") or a string, got ${JSON.stringify(v)}`);
|
|
335
|
+
});
|
|
336
|
+
return { text: parts };
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
/**
|
|
340
|
+
* Make the `auth` of an `http` Effect that sends `Authorization: Bearer <value>`, with the value
|
|
341
|
+
* of an Effect secret. In a packaged Machine, the secret can be `secret(open)`.
|
|
342
|
+
*
|
|
343
|
+
* It refuses a value that is not a secret: `bearer(…) takes a secret("name")`.
|
|
344
|
+
*
|
|
345
|
+
* @param s - The secret: `secret("name")`, or `secret(open)`.
|
|
346
|
+
* @returns The `auth` of an `http` Effect.
|
|
347
|
+
*
|
|
348
|
+
* @example
|
|
349
|
+
* ```ts
|
|
350
|
+
* import { bearer, http, jr2Setup, secret } from "@jr2/orchestrator";
|
|
351
|
+
*
|
|
352
|
+
* export const machine = jr2Setup({
|
|
353
|
+
* actors: {
|
|
354
|
+
* status: http({
|
|
355
|
+
* method: "GET",
|
|
356
|
+
* url: "https://api.github.com/repos/you/app",
|
|
357
|
+
* auth: bearer(secret("github")),
|
|
358
|
+
* }),
|
|
359
|
+
* },
|
|
360
|
+
* }).createMachine({
|
|
361
|
+
* id: "status",
|
|
362
|
+
* initial: "reading",
|
|
363
|
+
* states: {
|
|
364
|
+
* reading: { invoke: { src: "status", onDone: "done" } },
|
|
365
|
+
* done: { type: "final" },
|
|
366
|
+
* },
|
|
367
|
+
* });
|
|
368
|
+
* ```
|
|
369
|
+
*
|
|
370
|
+
* @see docs/guides/run-a-command-or-call-a-url.md
|
|
371
|
+
* @see docs/concepts/secrets-and-trust.md
|
|
372
|
+
* @see docs/reference/decisions/0068-an-effector-holds-an-effects-secrets-and-signs-its-wire.md
|
|
373
|
+
* @see docs/reference/decisions/0054-the-kit-ships-machines-and-a-shipped-machine-leaves-its-parts-open.md
|
|
374
|
+
*/
|
|
375
|
+
export function bearer<const S extends Secret>(s: S): MarkedIf<S, { readonly bearer: Secret }>;
|
|
376
|
+
export function bearer(s: Secret): { readonly bearer: Secret } {
|
|
377
|
+
if (!isSecret(s)) throw new Error('bearer(…) takes a secret("name")');
|
|
378
|
+
return { bearer: s };
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
/**
|
|
382
|
+
* Make the `auth` of an `http` Effect that sends `Authorization: Basic <value>`, where the value
|
|
383
|
+
* is the base64 of `user:password`. The Effector puts in the value of each secret.
|
|
384
|
+
*
|
|
385
|
+
* Each of `user` and `password` is a string, a `secret(...)` or a `text`. In a packaged Machine,
|
|
386
|
+
* one of them can hold `secret(open)`. It refuses another value: `` expected a string, a
|
|
387
|
+
* secret("name") or a text`…`, got <value> ``.
|
|
388
|
+
*
|
|
389
|
+
* @param user - The user name: a string, a `secret(...)` or a `text`.
|
|
390
|
+
* @param password - The password: a string, a `secret(...)` or a `text`.
|
|
391
|
+
* @returns The `auth` of an `http` Effect.
|
|
392
|
+
*
|
|
393
|
+
* @example
|
|
394
|
+
* ```ts
|
|
395
|
+
* import { basic, http, jr2Setup, secret } from "@jr2/orchestrator";
|
|
396
|
+
*
|
|
397
|
+
* export const machine = jr2Setup({
|
|
398
|
+
* actors: {
|
|
399
|
+
* issue: http({
|
|
400
|
+
* method: "GET",
|
|
401
|
+
* url: "https://jira.example.com/rest/api/2/issue/APP-1",
|
|
402
|
+
* auth: basic("jr2-bot@example.com", secret("jira-token")),
|
|
403
|
+
* }),
|
|
404
|
+
* },
|
|
405
|
+
* }).createMachine({
|
|
406
|
+
* id: "issue",
|
|
407
|
+
* initial: "reading",
|
|
408
|
+
* states: {
|
|
409
|
+
* reading: { invoke: { src: "issue", onDone: "done" } },
|
|
410
|
+
* done: { type: "final" },
|
|
411
|
+
* },
|
|
412
|
+
* });
|
|
413
|
+
* ```
|
|
414
|
+
*
|
|
415
|
+
* @see docs/guides/run-a-command-or-call-a-url.md
|
|
416
|
+
* @see docs/concepts/secrets-and-trust.md
|
|
417
|
+
* @see docs/reference/decisions/0068-an-effector-holds-an-effects-secrets-and-signs-its-wire.md
|
|
418
|
+
* @see docs/reference/decisions/0054-the-kit-ships-machines-and-a-shipped-machine-leaves-its-parts-open.md
|
|
419
|
+
*/
|
|
420
|
+
export function basic<const U extends string | Secret | Text, const P extends string | Secret | Text>(
|
|
421
|
+
user: U,
|
|
422
|
+
password: P,
|
|
423
|
+
): MarkedIf<U | P, { readonly basic: { user: Text; password: Text } }>;
|
|
424
|
+
export function basic(
|
|
425
|
+
user: string | Secret | Text,
|
|
426
|
+
password: string | Secret | Text,
|
|
427
|
+
): { readonly basic: { user: Text; password: Text } } {
|
|
428
|
+
return { basic: { user: asText(user), password: asText(password) } };
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
function isSecret(v: unknown): v is Secret {
|
|
432
|
+
return typeof v === "object" && v !== null && typeof (v as Secret).secret === "string" && !("text" in v);
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
/**
|
|
436
|
+
* Whether these places of one definition — wire parts, secrets, `.path`s — name `secret(open)`
|
|
437
|
+
* (ADR-0054). They may name one Open secret, in as many places as the author likes (a value and its
|
|
438
|
+
* `.path`); a second `secret(open)` is refused: one binding names one secret.
|
|
439
|
+
*/
|
|
440
|
+
export function openSecretsIn(items: readonly unknown[]): boolean {
|
|
441
|
+
const owners = new Set<object>();
|
|
442
|
+
for (const item of items) {
|
|
443
|
+
if (typeof item !== "object" || item === null || (item as { secret?: unknown }).secret !== OPEN_SECRET) continue;
|
|
444
|
+
owners.add(openSecretOf.get(item) ?? item);
|
|
445
|
+
}
|
|
446
|
+
if (owners.size > 1) {
|
|
447
|
+
throw new Error(
|
|
448
|
+
"an Effect takes one Open secret — it names secret(open) in two places that are two secrets; name one " +
|
|
449
|
+
'secret(open) and use it in each place, or name the others with secret("<name>") (ADR-0054)',
|
|
450
|
+
);
|
|
451
|
+
}
|
|
452
|
+
return owners.size === 1;
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
/**
|
|
456
|
+
* One value of a definition with its Open secret named (ADR-0054): `secret(open)` becomes
|
|
457
|
+
* `secret(name)`, its `.path` that secret's `.path`, and a `text` part `{ secret: name }` inside a
|
|
458
|
+
* `text`. A string, a named secret and a `text` with no Open part are as written. `secret(name)`
|
|
459
|
+
* checks the name, as it checks one the author writes.
|
|
460
|
+
*/
|
|
461
|
+
function bindOpenValue(v: unknown, name: string): unknown {
|
|
462
|
+
if (typeof v !== "object" || v === null) return v;
|
|
463
|
+
const o = v as { secret?: unknown; path?: unknown; text?: unknown };
|
|
464
|
+
if (o.secret === OPEN_SECRET) return o.path === true ? secret(name).path : secret(name);
|
|
465
|
+
if (Array.isArray(o.text)) return { text: o.text.map((part) => bindOpenValue(part, name)) };
|
|
466
|
+
return v;
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
/**
|
|
470
|
+
* A header or env record with its Open secret named (ADR-0054): each VALUE is bound, by its place —
|
|
471
|
+
* a key is the author's header or variable name, whatever it is (`bearer`, `secret`, `basic`), and
|
|
472
|
+
* never read as a shape.
|
|
473
|
+
*/
|
|
474
|
+
export function bindOpenSecretIn<R extends Readonly<Record<string, unknown>> | undefined>(record: R, name: string): R {
|
|
475
|
+
if (record === undefined) return record;
|
|
476
|
+
return Object.fromEntries(Object.entries(record).map(([k, v]) => [k, bindOpenValue(v, name)])) as R;
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
/** An http definition's auth with its Open secret named (ADR-0054): a `bearer` of it, a `basic`
|
|
480
|
+
* pair holding it, or a `text` of it. */
|
|
481
|
+
export function bindOpenAuth<A extends HttpDefinition<z.ZodType | undefined>["auth"]>(auth: A, name: string): A {
|
|
482
|
+
if (auth === undefined) return auth;
|
|
483
|
+
if ("bearer" in auth) return { bearer: bindOpenValue(auth.bearer, name) } as A;
|
|
484
|
+
if ("basic" in auth) {
|
|
485
|
+
return {
|
|
486
|
+
basic: { user: bindOpenValue(auth.basic.user, name), password: bindOpenValue(auth.basic.password, name) },
|
|
487
|
+
} as A;
|
|
488
|
+
}
|
|
489
|
+
return bindOpenValue(auth, name) as A;
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
function asText(v: string | Secret | Text): Text {
|
|
493
|
+
if (typeof v === "string") return { text: v === "" ? [] : [{ text: v }] };
|
|
494
|
+
if (isSecret(v)) return { text: [partOf(v)] };
|
|
495
|
+
if (typeof v === "object" && v !== null && Array.isArray((v as Text).text)) return v;
|
|
496
|
+
throw new Error(`expected a string, a secret("name") or a text\`…\`, got ${JSON.stringify(v)}`);
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
// --- A Worktree file as the body (ADR-0067) ------------------------------------------------------------
|
|
500
|
+
|
|
501
|
+
/** A value of a file body's part: as written, or from the Effect's input. */
|
|
502
|
+
export type FromInput<S, T> = T | ((input: S extends z.ZodType ? z.infer<S> : void) => T);
|
|
503
|
+
|
|
504
|
+
/** `file(path, { slot?, segments?, contentType? })`: a Worktree file as an `http` body. */
|
|
505
|
+
export type FileBody<S extends z.ZodType | undefined = undefined> = {
|
|
506
|
+
readonly file: {
|
|
507
|
+
/** The file, relative to the Repo Slot's Worktree, or to `/work` in a Workspace with no Repo
|
|
508
|
+
* Slot. */
|
|
509
|
+
readonly path: FromInput<S, string>;
|
|
510
|
+
/** The Repo Slot whose Worktree holds it. Write it only when the Workspace has more than one.
|
|
511
|
+
*/
|
|
512
|
+
readonly slot?: string;
|
|
513
|
+
/** Path segments that jr2 adds to the end of the url, as `path` of `request` does for a json
|
|
514
|
+
* or text body. */
|
|
515
|
+
readonly segments?: FromInput<S, readonly string[]>;
|
|
516
|
+
/** The content type of the body. Write it or a `content-type` header, not both. With neither,
|
|
517
|
+
* it is `application/octet-stream`. */
|
|
518
|
+
readonly contentType?: string;
|
|
519
|
+
};
|
|
520
|
+
};
|
|
521
|
+
|
|
522
|
+
/**
|
|
523
|
+
* Make a Worktree file the body of an `http` Effect: `body: file("dist/report.pdf")`. The Effect
|
|
524
|
+
* then runs in the Sandbox Effector of the Workspace whose body invokes it. That Effector opens the
|
|
525
|
+
* file in the Worktree and sends it. In a Workspace with no Repo Slot, the path is relative to
|
|
526
|
+
* `/work`, the folder that its steps share. Use it with `PUT`, `POST` or `PATCH`. `path` and
|
|
527
|
+
* `segments` can be functions of the input of the Effect.
|
|
528
|
+
*
|
|
529
|
+
* Some paths and files give the output `refused`, and then nothing is sent. The path has a symlink
|
|
530
|
+
* in it, has `..`, or is absolute. Or the file is a FIFO, a directory, or a file with more than one
|
|
531
|
+
* link.
|
|
532
|
+
*
|
|
533
|
+
* It refuses, when you call it:
|
|
534
|
+
* - a path that is not a string or a function: `file(path): the path is a string, or a function of
|
|
535
|
+
* the Effect's input that returns one`
|
|
536
|
+
*
|
|
537
|
+
* And when `http` takes the definition:
|
|
538
|
+
* - a method other than `PUT`, `POST` and `PATCH`: `http({ method: "<method>", body: file(...) }):
|
|
539
|
+
* a file body is sent with PUT, POST, PATCH (ADR-0068)`
|
|
540
|
+
* - a `request` beside it: ``http({ body: file(...), request }): with a file body the run's part
|
|
541
|
+
* is file()'s — its path, from the input if it must be, and its `segments` — so write no
|
|
542
|
+
* `request` (ADR-0068)``
|
|
543
|
+
* - a `content-type` header and `contentType`: `http({ headers, body: file(path, { contentType })
|
|
544
|
+
* }): "content-type" is set by file()'s contentType — write one of them (ADR-0067)`
|
|
545
|
+
*
|
|
546
|
+
* And at `jr2 up`, an Effect that no `workspace()` invokes: `workflow "<workflow>": the http Effect
|
|
547
|
+
* "<slot>" sends a Worktree file as its body, and no workspace() invokes it — outside a Workspace
|
|
548
|
+
* there is no Worktree to read; invoke it from a workspace()'s body, or send a json or text body
|
|
549
|
+
* (ADR-0068)`.
|
|
550
|
+
*
|
|
551
|
+
* @param path - The file, relative to the Worktree of the Repo Slot, or to `/work` in a Workspace
|
|
552
|
+
* with no Repo Slot: a string, or a function of the input of the Effect that gives one.
|
|
553
|
+
* @param options - Optional. Each field says what it does.
|
|
554
|
+
* @returns The `body` of an `http` Effect.
|
|
555
|
+
*
|
|
556
|
+
* @example
|
|
557
|
+
* ```ts
|
|
558
|
+
* import {
|
|
559
|
+
* bearer,
|
|
560
|
+
* command,
|
|
561
|
+
* fail,
|
|
562
|
+
* file,
|
|
563
|
+
* http,
|
|
564
|
+
* jr2Setup,
|
|
565
|
+
* secret,
|
|
566
|
+
* workspace,
|
|
567
|
+
* type Workspaced,
|
|
568
|
+
* } from "@jr2/orchestrator";
|
|
569
|
+
*
|
|
570
|
+
* type Input = Workspaced<{}, "app">;
|
|
571
|
+
*
|
|
572
|
+
* const body = jr2Setup({
|
|
573
|
+
* types: {} as { context: Input; input: Input },
|
|
574
|
+
* actors: {
|
|
575
|
+
* build: command(["npm", "run", "report"]),
|
|
576
|
+
* upload: http({
|
|
577
|
+
* method: "PUT",
|
|
578
|
+
* url: "https://files.example.com/reports",
|
|
579
|
+
* auth: bearer(secret("files")),
|
|
580
|
+
* body: file("dist/report.pdf", {
|
|
581
|
+
* segments: ["report.pdf"],
|
|
582
|
+
* contentType: "application/pdf",
|
|
583
|
+
* }),
|
|
584
|
+
* }),
|
|
585
|
+
* fail: fail(),
|
|
586
|
+
* },
|
|
587
|
+
* }).createMachine({
|
|
588
|
+
* id: "report",
|
|
589
|
+
* context: ({ input }) => input,
|
|
590
|
+
* on: { "workspace.lost": ".lost" },
|
|
591
|
+
* initial: "building",
|
|
592
|
+
* states: {
|
|
593
|
+
* building: { invoke: { src: "build", onDone: "uploading" } },
|
|
594
|
+
* uploading: { invoke: { src: "upload", onDone: "done" } },
|
|
595
|
+
* done: { type: "final" },
|
|
596
|
+
* lost: { invoke: { src: "fail", input: { reason: "the Sandbox is gone" } } },
|
|
597
|
+
* },
|
|
598
|
+
* });
|
|
599
|
+
*
|
|
600
|
+
* export const machine = workspace(body, {
|
|
601
|
+
* repos: { app: { url: "https://github.com/you/app.git" } },
|
|
602
|
+
* spec: () => ({ branch: "report" }),
|
|
603
|
+
* });
|
|
604
|
+
* ```
|
|
605
|
+
*
|
|
606
|
+
* @see docs/guides/run-a-command-or-call-a-url.md
|
|
607
|
+
* @see docs/concepts/effects.md
|
|
608
|
+
* @see docs/reference/decisions/0067-an-effect-is-an-actor-slot-and-runs-at-most-once-unless-marked-idempotent.md
|
|
609
|
+
*/
|
|
610
|
+
export function file<S extends z.ZodType | undefined = undefined>(
|
|
611
|
+
path: FromInput<S, string>,
|
|
612
|
+
options: {
|
|
613
|
+
/** The Repo Slot whose Worktree holds the file. Write it only when the Workspace has more than
|
|
614
|
+
* one. */
|
|
615
|
+
slot?: string;
|
|
616
|
+
/**
|
|
617
|
+
* Path segments that jr2 adds to the end of the url, as `path` does in `request` for a json or
|
|
618
|
+
* text body. A list of strings, or a function of the input of the Effect that gives one.
|
|
619
|
+
*/
|
|
620
|
+
segments?: FromInput<S, readonly string[]>;
|
|
621
|
+
/**
|
|
622
|
+
* The content type of the body. Default `application/octet-stream`. Do not write it beside a
|
|
623
|
+
* `content-type` header.
|
|
624
|
+
*/
|
|
625
|
+
contentType?: string;
|
|
626
|
+
} = {},
|
|
627
|
+
): FileBody<S> {
|
|
628
|
+
// The Sandbox Effector opens the file beneath the Worktree with no symlink at any component
|
|
629
|
+
// (ADR-0067, ADR-0068); the refusals it makes are output, never a fault.
|
|
630
|
+
if (typeof path !== "string" && typeof path !== "function") {
|
|
631
|
+
throw new Error("file(path): the path is a string, or a function of the Effect's input that returns one");
|
|
632
|
+
}
|
|
633
|
+
return {
|
|
634
|
+
file: {
|
|
635
|
+
path,
|
|
636
|
+
...(options.slot !== undefined ? { slot: options.slot } : {}),
|
|
637
|
+
...(options.segments !== undefined ? { segments: options.segments } : {}),
|
|
638
|
+
...(options.contentType !== undefined ? { contentType: options.contentType } : {}),
|
|
639
|
+
},
|
|
640
|
+
};
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
/** The methods that send a file body. */
|
|
644
|
+
const FILE_METHODS: readonly HttpMethod[] = ["PUT", "POST", "PATCH"];
|
|
645
|
+
|
|
646
|
+
// --- The definition ------------------------------------------------------------------------------
|
|
647
|
+
|
|
648
|
+
/** What the `request` of an `http` definition returns: the part of the request that changes for
|
|
649
|
+
* each run. The definition fixes the rest. */
|
|
650
|
+
export type HttpRequestParts = {
|
|
651
|
+
/**
|
|
652
|
+
* Path segments that jr2 adds to the end of the url. jr2 encodes each segment. A segment that
|
|
653
|
+
* is `.`, `..` or empty gives the output `refused`, and nothing is sent.
|
|
654
|
+
*/
|
|
655
|
+
path?: readonly string[];
|
|
656
|
+
/** A JSON body. Do not give `text` too. When it starts, the Effect refuses both: ``http:
|
|
657
|
+
* `request` returned both json and text``. */
|
|
658
|
+
json?: unknown;
|
|
659
|
+
/** A text body. */
|
|
660
|
+
text?: string;
|
|
661
|
+
};
|
|
662
|
+
|
|
663
|
+
/** An `http` Effect's definition. */
|
|
664
|
+
export type HttpDefinition<S extends z.ZodType | undefined = undefined> = {
|
|
665
|
+
/**
|
|
666
|
+
* The HTTP method: `GET`, `HEAD`, `POST`, `PUT`, `PATCH`, `DELETE` or `OPTIONS`. It refuses
|
|
667
|
+
* another: `http({ method: "<method>" }): the method is one of GET, HEAD, POST, PUT, PATCH,
|
|
668
|
+
* DELETE, OPTIONS`.
|
|
669
|
+
*/
|
|
670
|
+
method: HttpMethod;
|
|
671
|
+
// The scheme and host are literal so that no param reaches the host (ADR-0068); an Open url is
|
|
672
|
+
// bound once by the composer, never per run (ADR-0054).
|
|
673
|
+
/**
|
|
674
|
+
* The url: `http://` or `https://`, then a literal host. No input, param or Effect secret can
|
|
675
|
+
* change where the call goes. An Effect secret can be only in the path:
|
|
676
|
+
* `` text`https://api.telegram.org/bot${secret("tg")}/sendMessage` ``.
|
|
677
|
+
*
|
|
678
|
+
* Or `open`: a packaged Machine that cannot know the host leaves the url Open, and the composer
|
|
679
|
+
* binds it one time with `customize(machine, { effects: { <slot>: { url } } })`.
|
|
680
|
+
*
|
|
681
|
+
* It refuses:
|
|
682
|
+
* - a scheme or host that is not literal: `` http({ url }): the url is an http:// or https:// url
|
|
683
|
+
* whose scheme and host are literal — a secret may sit only in its path, after a "/":
|
|
684
|
+
* text`https://api.example.com/bot${secret("tg")}/send` (ADR-0068) ``
|
|
685
|
+
* - an Effect secret after `?` or `#`: `` http({ url }): secret("<name>") sits in the url's query
|
|
686
|
+
* or fragment — a secret may sit only in its path; send it in `auth` or a header instead:
|
|
687
|
+
* headers: { "x-api-key": text`${secret("...")}` } (ADR-0068) ``
|
|
688
|
+
* - a host that is not a host: `http({ url }): "<scheme>://<host>" is not a host`
|
|
689
|
+
* - a user name or password in the url: ``http({ url }): the url carries userinfo — use `auth` ``
|
|
690
|
+
*/
|
|
691
|
+
url: string | Text | typeof open;
|
|
692
|
+
/**
|
|
693
|
+
* Headers that are the same for each run. Each name is lowercase. A value is a string, or a
|
|
694
|
+
* `text` with an Effect secret.
|
|
695
|
+
*
|
|
696
|
+
* It refuses:
|
|
697
|
+
* - a name that is not lowercase, or that frames or routes the request (`host`,
|
|
698
|
+
* `content-length`, `transfer-encoding`, `connection`, `upgrade`, `te`, `trailer`,
|
|
699
|
+
* `keep-alive`, `proxy-*`): `http({ headers }): "<name>" is not a header an Effect sets — a
|
|
700
|
+
* lowercase name that does not frame or route the request (ADR-0068)`
|
|
701
|
+
* - an `authorization` header beside `auth`: ``http({ headers }): "authorization" is set by
|
|
702
|
+
* `auth` — write one of them``
|
|
703
|
+
*/
|
|
704
|
+
headers?: Readonly<Record<string, string | Text>>;
|
|
705
|
+
/**
|
|
706
|
+
* The `Authorization` header: `bearer(secret("x"))`, `basic(user, password)`, or a `text` that
|
|
707
|
+
* is the whole value.
|
|
708
|
+
*/
|
|
709
|
+
auth?: { readonly bearer: Secret } | { readonly basic: { user: Text; password: Text } } | Text;
|
|
710
|
+
/**
|
|
711
|
+
* The zod schema of the input of the Effect: what the `input` of the `invoke`, or `fromPick`,
|
|
712
|
+
* gives it. jr2 parses the input with it before the call. When it starts, input that does not
|
|
713
|
+
* agree with it makes the Effect fail (its `onError` route): `http "<slot>": its input does not
|
|
714
|
+
* match the definition's input schema — <path>: <message>`, one `<path>: <message>` for each
|
|
715
|
+
* problem, joined by `; `.
|
|
716
|
+
*/
|
|
717
|
+
input?: S;
|
|
718
|
+
/**
|
|
719
|
+
* A function that makes the part of the request that changes for each run, from the input:
|
|
720
|
+
* `path` segments, and a `json` or `text` body. It cannot change the host, a header or an
|
|
721
|
+
* Effect secret. Do not write it beside `body`.
|
|
722
|
+
*/
|
|
723
|
+
request?: (input: S extends z.ZodType ? z.infer<S> : void) => HttpRequestParts;
|
|
724
|
+
// A file body is an ADR-0067 Effect: the Sandbox Effector of the Workspace sends it.
|
|
725
|
+
/**
|
|
726
|
+
* A Worktree file as the body: `file(path, { slot?, segments?, contentType? })`. The Effect then
|
|
727
|
+
* runs in the Sandbox Effector of its Workspace. Do not write `request` beside it: the `segments`
|
|
728
|
+
* of `file` are the path. It refuses another body: ``http({ body }): a body is file(path, {
|
|
729
|
+
* slot?, segments?, contentType? }) — json and text come from `request` ``.
|
|
730
|
+
*/
|
|
731
|
+
body?: FileBody<S>;
|
|
732
|
+
/**
|
|
733
|
+
* How long jr2 waits for the answer: a number and a unit, such as `"30s"` or `"2m"`. Default
|
|
734
|
+
* `"30s"`. With no answer in that time, the output is `unknown`. It refuses a value that is not
|
|
735
|
+
* a positive duration: `http: timeout "<timeout>" is not a positive duration (like "30s",
|
|
736
|
+
* "2m")`.
|
|
737
|
+
*/
|
|
738
|
+
timeout?: Duration;
|
|
739
|
+
/**
|
|
740
|
+
* The largest number of bytes of a text response body that the output keeps. Default 65536.
|
|
741
|
+
* When jr2 cuts the body, `truncated` is `true`. It refuses a value that is not a positive whole
|
|
742
|
+
* number: `http({ maxBody }): a positive number of bytes`.
|
|
743
|
+
*/
|
|
744
|
+
maxBody?: number;
|
|
745
|
+
// The restore rule (ADR-0067): a restore that finds no replica holding the call sends it again
|
|
746
|
+
// only when it is marked; unmarked, it settles `unknown`.
|
|
747
|
+
/**
|
|
748
|
+
* `true` when it is safe to send the call two times: it has its own idempotency key, or it only
|
|
749
|
+
* reads. Default `false`. After an Orchestrator restart, jr2 cannot always tell if the call went
|
|
750
|
+
* out. Then it sends an idempotent call again, and gives any other call the output `unknown`.
|
|
751
|
+
*/
|
|
752
|
+
idempotent?: boolean;
|
|
753
|
+
};
|
|
754
|
+
|
|
755
|
+
/** @internal */
|
|
756
|
+
export type HttpInput<S> = S extends z.ZodType ? z.infer<S> : void;
|
|
757
|
+
|
|
758
|
+
const DEFAULT_TIMEOUT_MS = 30_000;
|
|
759
|
+
const DEFAULT_MAX_BODY = 65_536;
|
|
760
|
+
/** How long a re-asked request waits, at most, after a transport failure. */
|
|
761
|
+
const RETRY_MAX_MS = 10_000;
|
|
762
|
+
|
|
763
|
+
/** The definition's plain data, checked: every refusal names what to write instead. */
|
|
764
|
+
export function httpParts<S extends z.ZodType | undefined>(def: HttpDefinition<S>): HttpParts {
|
|
765
|
+
if (!(HTTP_METHODS as readonly string[]).includes(def.method)) {
|
|
766
|
+
throw new Error(`http({ method: "${def.method}" }): the method is one of ${HTTP_METHODS.join(", ")}`);
|
|
767
|
+
}
|
|
768
|
+
if (def.url === open) throw new Error("httpParts: an Open url has no target — read it with openHttpParts");
|
|
769
|
+
const url = asText(def.url).text;
|
|
770
|
+
if (url.some((p) => "secret" in p && p.secret === OPEN_SECRET)) {
|
|
771
|
+
// A url that carries a secret is left Open whole (ADR-0054): the composer binds it with its
|
|
772
|
+
// secret in its path, so two copies of one Machine spend two teams' secrets.
|
|
773
|
+
throw new Error(
|
|
774
|
+
"secret(open) in a url: a url's secret is not an Open part — leave the whole url Open " +
|
|
775
|
+
"(`url: open`) and let the composer bind it with its secret in its path (ADR-0054)",
|
|
776
|
+
);
|
|
777
|
+
}
|
|
778
|
+
const target = targetOf(url);
|
|
779
|
+
const headers: Record<string, TextPart[]> = {};
|
|
780
|
+
for (const [name, value] of Object.entries(def.headers ?? {})) {
|
|
781
|
+
// The wire's own rule, so a definition the Machine loads is a start the Effector takes.
|
|
782
|
+
if (!isEffectHeader(name)) {
|
|
783
|
+
throw new Error(
|
|
784
|
+
`http({ headers }): "${name}" is not a header an Effect sets — a lowercase name that does not frame or ` +
|
|
785
|
+
"route the request (ADR-0068)",
|
|
786
|
+
);
|
|
787
|
+
}
|
|
788
|
+
if (name === "authorization" && def.auth !== undefined) {
|
|
789
|
+
throw new Error('http({ headers }): "authorization" is set by `auth` — write one of them');
|
|
790
|
+
}
|
|
791
|
+
headers[name] = [...asText(value).text];
|
|
792
|
+
}
|
|
793
|
+
const auth: WireAuth | undefined =
|
|
794
|
+
def.auth === undefined
|
|
795
|
+
? undefined
|
|
796
|
+
: "bearer" in def.auth
|
|
797
|
+
? { bearer: { secret: def.auth.bearer.secret } }
|
|
798
|
+
: "basic" in def.auth
|
|
799
|
+
? { basic: { user: [...def.auth.basic.user.text], password: [...def.auth.basic.password.text] } }
|
|
800
|
+
: { text: [...asText(def.auth).text] };
|
|
801
|
+
const secrets: string[] = [];
|
|
802
|
+
const note = (parts: readonly TextPart[]): void => {
|
|
803
|
+
for (const p of parts) if ("secret" in p && !secrets.includes(p.secret)) secrets.push(p.secret);
|
|
804
|
+
};
|
|
805
|
+
note(url);
|
|
806
|
+
for (const name of Object.keys(headers).sort()) note(headers[name]!);
|
|
807
|
+
if (auth && "bearer" in auth) note([{ secret: auth.bearer.secret }]);
|
|
808
|
+
if (auth && "basic" in auth) {
|
|
809
|
+
note(auth.basic.user);
|
|
810
|
+
note(auth.basic.password);
|
|
811
|
+
}
|
|
812
|
+
if (auth && "text" in auth) note(auth.text);
|
|
813
|
+
// An Open secret (ADR-0054) is the composer's to name: its places are the auth and the headers,
|
|
814
|
+
// one per Effect, and it names no secret until `customize()` binds it.
|
|
815
|
+
const opened = openSecretsIn([
|
|
816
|
+
...Object.values(headers).flat(),
|
|
817
|
+
...(def.auth === undefined
|
|
818
|
+
? []
|
|
819
|
+
: "bearer" in def.auth
|
|
820
|
+
? [def.auth.bearer]
|
|
821
|
+
: "basic" in def.auth
|
|
822
|
+
? [...def.auth.basic.user.text, ...def.auth.basic.password.text]
|
|
823
|
+
: asText(def.auth).text),
|
|
824
|
+
]);
|
|
825
|
+
const named = secrets.filter((name) => name !== OPEN_SECRET);
|
|
826
|
+
secrets.splice(0, secrets.length, ...named);
|
|
827
|
+
for (const name of secrets) secret(name);
|
|
828
|
+
const maxBody = def.maxBody ?? DEFAULT_MAX_BODY;
|
|
829
|
+
if (!Number.isInteger(maxBody) || maxBody <= 0) throw new Error("http({ maxBody }): a positive number of bytes");
|
|
830
|
+
const body = def.body?.file;
|
|
831
|
+
if (def.body !== undefined && (typeof body !== "object" || body === null)) {
|
|
832
|
+
throw new Error(
|
|
833
|
+
"http({ body }): a body is file(path, { slot?, segments?, contentType? }) — json and text come from `request`",
|
|
834
|
+
);
|
|
835
|
+
}
|
|
836
|
+
if (body && !FILE_METHODS.includes(def.method)) {
|
|
837
|
+
throw new Error(
|
|
838
|
+
`http({ method: "${def.method}", body: file(...) }): a file body is sent with ${FILE_METHODS.join(", ")} (ADR-0068)`,
|
|
839
|
+
);
|
|
840
|
+
}
|
|
841
|
+
if (body?.contentType !== undefined && headers["content-type"] !== undefined) {
|
|
842
|
+
throw new Error(
|
|
843
|
+
'http({ headers, body: file(path, { contentType }) }): "content-type" is set by file()\'s contentType — write ' +
|
|
844
|
+
"one of them (ADR-0067)",
|
|
845
|
+
);
|
|
846
|
+
}
|
|
847
|
+
if (body && def.request !== undefined) {
|
|
848
|
+
throw new Error(
|
|
849
|
+
"http({ body: file(...), request }): with a file body the run's part is file()'s — its path, from the input " +
|
|
850
|
+
"if it must be, and its `segments` — so write no `request` (ADR-0068)",
|
|
851
|
+
);
|
|
852
|
+
}
|
|
853
|
+
return {
|
|
854
|
+
kind: "http",
|
|
855
|
+
method: def.method,
|
|
856
|
+
target,
|
|
857
|
+
url,
|
|
858
|
+
headers,
|
|
859
|
+
...(auth ? { auth } : {}),
|
|
860
|
+
secrets,
|
|
861
|
+
inputKeys: inputKeysOfSchema(def.input),
|
|
862
|
+
timeoutMs: def.timeout === undefined ? DEFAULT_TIMEOUT_MS : parseDuration(def.timeout),
|
|
863
|
+
maxBody,
|
|
864
|
+
idempotent: def.idempotent === true,
|
|
865
|
+
...(opened ? { openSecret: true as const } : {}),
|
|
866
|
+
...(body
|
|
867
|
+
? {
|
|
868
|
+
file: {
|
|
869
|
+
...(body.slot !== undefined ? { slot: body.slot } : {}),
|
|
870
|
+
...(body.contentType !== undefined ? { contentType: body.contentType } : {}),
|
|
871
|
+
},
|
|
872
|
+
}
|
|
873
|
+
: {}),
|
|
874
|
+
};
|
|
875
|
+
}
|
|
876
|
+
|
|
877
|
+
/** The url an Open definition's other parts are checked against: it names no secret, and nothing
|
|
878
|
+
* is ever sent to it — the unbound logic sends nothing (open-effect.ts). */
|
|
879
|
+
const OPEN_PROBE_URL = "https://open.invalid";
|
|
880
|
+
|
|
881
|
+
/**
|
|
882
|
+
* An Open url's plain data (ADR-0054): every other part checked as `httpParts` checks it, the
|
|
883
|
+
* secrets its headers and auth name, and the sentinel where the url and its target go. The
|
|
884
|
+
* secrets the bound url names join when `customize()` binds it.
|
|
885
|
+
*/
|
|
886
|
+
export function openHttpParts<S extends z.ZodType | undefined>(def: HttpDefinition<S>): OpenHttpParts {
|
|
887
|
+
const { url: _url, target: _target, ...rest } = httpParts({ ...def, url: OPEN_PROBE_URL });
|
|
888
|
+
return { ...rest, url: open, target: open };
|
|
889
|
+
}
|
|
890
|
+
|
|
891
|
+
/**
|
|
892
|
+
* The unbound logic of an http definition with Open parts (ADR-0054) — its url, an Open secret in
|
|
893
|
+
* its auth or a header, or both — or undefined when every part is fixed. Binding builds the Effect
|
|
894
|
+
* again, whole, with `rebuild` (its own constructor) over the definition with the bound parts, so
|
|
895
|
+
* the bound one is checked like any definition; a binding that names one of two Open parts gives an
|
|
896
|
+
* Effect still Open in the other.
|
|
897
|
+
*/
|
|
898
|
+
export function openHttpOf<S extends z.ZodType | undefined>(
|
|
899
|
+
def: HttpDefinition<S>,
|
|
900
|
+
rebuild: (def: HttpDefinition<S>) => unknown,
|
|
901
|
+
): EffectLogic<unknown, unknown> | undefined {
|
|
902
|
+
const parts = def.url === open ? openHttpParts(def) : httpParts(def);
|
|
903
|
+
const opened = openPartsOf(parts);
|
|
904
|
+
if (opened.length === 0) return undefined;
|
|
905
|
+
return openEffectLogic(parts, opened, (binding) => {
|
|
906
|
+
if (binding.argv !== undefined) {
|
|
907
|
+
throw new Error(
|
|
908
|
+
`an http Effect's Open parts are its url and its secret — bind it with ${effectBindingShape(opened)}, not an argv`,
|
|
909
|
+
);
|
|
910
|
+
}
|
|
911
|
+
let bound: HttpDefinition<S> = def;
|
|
912
|
+
if (binding.url !== undefined) bound = { ...bound, url: binding.url };
|
|
913
|
+
if (binding.secret !== undefined) {
|
|
914
|
+
const headers = bindOpenSecretIn(bound.headers, binding.secret);
|
|
915
|
+
const auth = bindOpenAuth(bound.auth, binding.secret);
|
|
916
|
+
bound = { ...bound, ...(headers !== undefined ? { headers } : {}), ...(auth !== undefined ? { auth } : {}) };
|
|
917
|
+
}
|
|
918
|
+
return rebuild(bound) as EffectLogic<unknown, unknown>;
|
|
919
|
+
});
|
|
920
|
+
}
|
|
921
|
+
|
|
922
|
+
/** The scheme, host and port, from the url's literal head: a secret may sit only after the host,
|
|
923
|
+
* in the path, so the host is fixed by the definition and never a value. */
|
|
924
|
+
function targetOf(url: readonly TextPart[]): HttpParts["target"] {
|
|
925
|
+
const first = url[0];
|
|
926
|
+
const head = first && "text" in first ? first.text : "";
|
|
927
|
+
const m = /^(https?):\/\/([^/?#]*)(.*)$/i.exec(head);
|
|
928
|
+
const literalPathFollows = m !== null && (m[3] !== "" || url.length === 1);
|
|
929
|
+
if (!m || !literalPathFollows) {
|
|
930
|
+
throw new Error(
|
|
931
|
+
"http({ url }): the url is an http:// or https:// url whose scheme and host are literal — a secret may sit " +
|
|
932
|
+
'only in its path, after a "/": text`https://api.example.com/bot${secret("tg")}/send` (ADR-0068)',
|
|
933
|
+
);
|
|
934
|
+
}
|
|
935
|
+
// The Effector encodes a secret as a path segment, which a query or a fragment would read as a
|
|
936
|
+
// different value: a secret after a `?` or a `#` is refused here, as the Effector refuses it.
|
|
937
|
+
let before = "";
|
|
938
|
+
for (const part of url) {
|
|
939
|
+
if ("text" in part) {
|
|
940
|
+
before += part.text;
|
|
941
|
+
} else if (/[?#]/.test(before)) {
|
|
942
|
+
throw new Error(
|
|
943
|
+
`http({ url }): secret("${part.secret}") sits in the url's query or fragment — a secret may sit only in ` +
|
|
944
|
+
'its path; send it in `auth` or a header instead: headers: { "x-api-key": text`${secret("...")}` } (ADR-0068)',
|
|
945
|
+
);
|
|
946
|
+
}
|
|
947
|
+
}
|
|
948
|
+
let parsed: URL;
|
|
949
|
+
try {
|
|
950
|
+
parsed = new URL(`${m[1]}://${m[2]}/`);
|
|
951
|
+
} catch {
|
|
952
|
+
throw new Error(`http({ url }): "${m[1]}://${m[2]}" is not a host`);
|
|
953
|
+
}
|
|
954
|
+
if (parsed.username || parsed.password) throw new Error("http({ url }): the url carries userinfo — use `auth`");
|
|
955
|
+
const scheme = parsed.protocol === "https:" ? "https" : "http";
|
|
956
|
+
const port = parsed.port ? Number(parsed.port) : scheme === "https" ? 443 : 80;
|
|
957
|
+
return { scheme, host: parsed.hostname.replace(/^\[|\]$/g, "").toLowerCase(), port };
|
|
958
|
+
}
|
|
959
|
+
|
|
960
|
+
function inputKeysOfSchema(schema: z.ZodType | undefined): string[] {
|
|
961
|
+
const shape = (schema as { shape?: unknown } | undefined)?.shape;
|
|
962
|
+
return shape && typeof shape === "object" ? Object.keys(shape) : [];
|
|
963
|
+
}
|
|
964
|
+
|
|
965
|
+
/** The wire request for one run: the definition's parts, and `request`'s from the input. */
|
|
966
|
+
export function startRequestOf(parts: HttpParts, run: HttpRequestParts): HttpStartRequest {
|
|
967
|
+
const path = run.path === undefined ? undefined : [...run.path];
|
|
968
|
+
if (path !== undefined && !path.every((seg) => typeof seg === "string")) {
|
|
969
|
+
throw new Error("http: `request` returned a path that is not a list of strings");
|
|
970
|
+
}
|
|
971
|
+
if (run.json !== undefined && run.text !== undefined) throw new Error("http: `request` returned both json and text");
|
|
972
|
+
const body: WireBody | undefined =
|
|
973
|
+
run.json !== undefined ? { json: run.json } : run.text !== undefined ? { text: run.text } : undefined;
|
|
974
|
+
return {
|
|
975
|
+
kind: "http",
|
|
976
|
+
method: parts.method,
|
|
977
|
+
url: [...parts.url],
|
|
978
|
+
...(path?.length ? { path } : {}),
|
|
979
|
+
...(Object.keys(parts.headers).length ? { headers: { ...parts.headers } as Record<string, TextPart[]> } : {}),
|
|
980
|
+
...(parts.auth ? { auth: parts.auth } : {}),
|
|
981
|
+
...(body ? { body } : {}),
|
|
982
|
+
timeoutMs: parts.timeoutMs,
|
|
983
|
+
maxBody: parts.maxBody,
|
|
984
|
+
};
|
|
985
|
+
}
|
|
986
|
+
|
|
987
|
+
// --- The actor logic -----------------------------------------------------------------------------
|
|
988
|
+
|
|
989
|
+
/** Which Effector an Effect asks for: the Instance Effector, or the Sandbox Effector of one
|
|
990
|
+
* Workspace's Sandbox (ADR-0067). */
|
|
991
|
+
export type EffectorPlacement = "instance" | { sandbox: string };
|
|
992
|
+
|
|
993
|
+
/** Where a placement's Effector is, as the host knows it (ADR-0068): its Service and its key. */
|
|
994
|
+
export type EffectorTarget = { placement: "instance" | "sandbox"; service: string; key: Buffer | string };
|
|
995
|
+
export type EffectorPortFactory = (target: EffectorTarget) => EffectorPort;
|
|
996
|
+
|
|
997
|
+
type Settle = { type: "jr2.effect.settle"; output: unknown };
|
|
998
|
+
type Fault = { type: "jr2.effect.error"; error: unknown };
|
|
999
|
+
|
|
1000
|
+
type Running = { controller: AbortController };
|
|
1001
|
+
const running = new WeakMap<AnyActorRef, Running>();
|
|
1002
|
+
|
|
1003
|
+
/**
|
|
1004
|
+
* An `http` Effect's slot logic over an injected port factory — the seam `http()` and the unit
|
|
1005
|
+
* tests share, as `commandWith` is for a command.
|
|
1006
|
+
*/
|
|
1007
|
+
export function httpWith<
|
|
1008
|
+
S extends z.ZodType | undefined = undefined,
|
|
1009
|
+
const U extends HttpDefinition<S>["url"] = string,
|
|
1010
|
+
const A extends HttpDefinition<S>["auth"] = undefined,
|
|
1011
|
+
const H extends HttpDefinition<S>["headers"] = undefined,
|
|
1012
|
+
>(
|
|
1013
|
+
portFactory: EffectorPortFactory,
|
|
1014
|
+
def: HttpDefinition<S> & { url: U; auth?: A; headers?: H },
|
|
1015
|
+
): EffectLogic<HttpInput<S>, HttpOutput> & OpenOf<HttpOpenParts<U, A, H>>;
|
|
1016
|
+
export function httpWith<S extends z.ZodType | undefined = undefined>(
|
|
1017
|
+
portFactory: EffectorPortFactory,
|
|
1018
|
+
def: HttpDefinition<S>,
|
|
1019
|
+
): EffectLogic<HttpInput<S>, HttpOutput> {
|
|
1020
|
+
const unbound = openHttpOf(def, (bound) => httpWith(portFactory, bound));
|
|
1021
|
+
if (unbound) return unbound as EffectLogic<HttpInput<S>, HttpOutput>;
|
|
1022
|
+
const parts = httpParts(def);
|
|
1023
|
+
type I = HttpInput<S>;
|
|
1024
|
+
type O = HttpOutput;
|
|
1025
|
+
const logic: ActorLogic<EffectSnapshot<O, I>, EventObject, I> = {
|
|
1026
|
+
getInitialSnapshot: (_scope, input) => ({
|
|
1027
|
+
status: "active",
|
|
1028
|
+
output: undefined,
|
|
1029
|
+
error: undefined,
|
|
1030
|
+
input,
|
|
1031
|
+
effectId: randomUUID(),
|
|
1032
|
+
}),
|
|
1033
|
+
transition: (snapshot, event, { self, system }) => {
|
|
1034
|
+
if (snapshot.status !== "active") return snapshot;
|
|
1035
|
+
if (event.type === "jr2.effect.settle") {
|
|
1036
|
+
return { ...snapshot, status: "done", output: (event as Settle).output as O, input: undefined };
|
|
1037
|
+
}
|
|
1038
|
+
if (event.type === "jr2.effect.error") {
|
|
1039
|
+
return { ...snapshot, status: "error", error: (event as Fault).error, input: undefined };
|
|
1040
|
+
}
|
|
1041
|
+
if (event.type === "xstate.stop") {
|
|
1042
|
+
stopped(self, system, snapshot.effectId);
|
|
1043
|
+
return { ...snapshot, status: "stopped", input: undefined };
|
|
1044
|
+
}
|
|
1045
|
+
return snapshot;
|
|
1046
|
+
},
|
|
1047
|
+
start: (snapshot, { self, system }) => {
|
|
1048
|
+
if (snapshot.status !== "active") return;
|
|
1049
|
+
const run: Running = { controller: new AbortController() };
|
|
1050
|
+
running.set(self, run);
|
|
1051
|
+
void drive(self, system, snapshot, parts, def, portFactory, run).catch((error: unknown) => {
|
|
1052
|
+
if (run.controller.signal.aborted) return;
|
|
1053
|
+
if (self.getSnapshot().status === "active") self.send({ type: "jr2.effect.error", error } as Fault);
|
|
1054
|
+
});
|
|
1055
|
+
},
|
|
1056
|
+
getPersistedSnapshot: (snapshot) => snapshot,
|
|
1057
|
+
};
|
|
1058
|
+
return Object.assign(logic, { effect: parts });
|
|
1059
|
+
}
|
|
1060
|
+
|
|
1061
|
+
const slotOf = (self: AnyActorRef): string => (typeof self.src === "string" ? self.src : self.id);
|
|
1062
|
+
|
|
1063
|
+
async function drive<I, O>(
|
|
1064
|
+
self: AnyActorRef,
|
|
1065
|
+
system: AnyActorSystem,
|
|
1066
|
+
snapshot: EffectSnapshot<O, I>,
|
|
1067
|
+
parts: HttpParts,
|
|
1068
|
+
def: HttpDefinition<z.ZodType | undefined>,
|
|
1069
|
+
portFactory: EffectorPortFactory,
|
|
1070
|
+
run: Running,
|
|
1071
|
+
): Promise<void> {
|
|
1072
|
+
const binding = boundRun(system);
|
|
1073
|
+
const id = snapshot.effectId;
|
|
1074
|
+
const slot = slotOf(self);
|
|
1075
|
+
const signal = run.controller.signal;
|
|
1076
|
+
const now = (): string => new Date().toISOString();
|
|
1077
|
+
const mark = (marker: Omit<EffectMarker, "kind" | "slot" | "effect">): void =>
|
|
1078
|
+
binding?.marker?.({ kind: "effect", slot, effect: id, ...marker });
|
|
1079
|
+
const settle = (output: HttpOutput): void => {
|
|
1080
|
+
if (signal.aborted || self.getSnapshot().status !== "active") return;
|
|
1081
|
+
// The settled row first, saved at the write: a restart before the transition is saved delivers
|
|
1082
|
+
// this output, and sends nothing.
|
|
1083
|
+
binding?.recordEffect?.(id, { state: "settled", kind: "http", slot, output, at: now() });
|
|
1084
|
+
mark({ stage: "settled", output });
|
|
1085
|
+
self.send({ type: "jr2.effect.settle", output } satisfies Settle);
|
|
1086
|
+
};
|
|
1087
|
+
const unknown = (reason: string): Unknown => ({ outcome: "unknown", reason });
|
|
1088
|
+
|
|
1089
|
+
const row = binding?.effectRow?.(id);
|
|
1090
|
+
if (row?.state === "settled") {
|
|
1091
|
+
await Promise.resolve();
|
|
1092
|
+
if (!signal.aborted && self.getSnapshot().status === "active") {
|
|
1093
|
+
self.send({ type: "jr2.effect.settle", output: row.output } satisfies Settle);
|
|
1094
|
+
}
|
|
1095
|
+
return;
|
|
1096
|
+
}
|
|
1097
|
+
if (row?.state === "aborted") {
|
|
1098
|
+
settle(unknown("its state exited while it ran; not sent again"));
|
|
1099
|
+
return;
|
|
1100
|
+
}
|
|
1101
|
+
|
|
1102
|
+
const target = binding?.effector?.("instance");
|
|
1103
|
+
if (!target) {
|
|
1104
|
+
throw new Error(
|
|
1105
|
+
`http "${slot}" runs in the Instance Effector, and this Orchestrator knows none — \`jr2 up\` converges it ` +
|
|
1106
|
+
"when a registered Machine carries an http Effect (ADR-0068)",
|
|
1107
|
+
);
|
|
1108
|
+
}
|
|
1109
|
+
const port = portFactory(target);
|
|
1110
|
+
/** Ask every replica. Undefined when an answer was forged: the Effect is settled then. */
|
|
1111
|
+
const findAll = async (): Promise<{ found: EffectState | undefined } | undefined> => {
|
|
1112
|
+
try {
|
|
1113
|
+
return { found: await retrying(() => port.find(id, signal), signal) };
|
|
1114
|
+
} catch (err) {
|
|
1115
|
+
if (signal.aborted || !(err instanceof EffectorForged)) throw err;
|
|
1116
|
+
settle(
|
|
1117
|
+
unknown(
|
|
1118
|
+
`${err.message}: the Orchestrator's key for this Effector is not the Effector's, or another process ` +
|
|
1119
|
+
"answered; not sent again",
|
|
1120
|
+
),
|
|
1121
|
+
);
|
|
1122
|
+
return undefined;
|
|
1123
|
+
}
|
|
1124
|
+
};
|
|
1125
|
+
|
|
1126
|
+
if (row?.state === "intent") {
|
|
1127
|
+
// In flight at an Orchestrator restart: ask every replica, follow the one that holds it, and
|
|
1128
|
+
// when none does, send it again only if the author marked it idempotent.
|
|
1129
|
+
const asked = await findAll();
|
|
1130
|
+
if (!asked || signal.aborted) return;
|
|
1131
|
+
const { found } = asked;
|
|
1132
|
+
if (found) {
|
|
1133
|
+
await follow(found);
|
|
1134
|
+
return;
|
|
1135
|
+
}
|
|
1136
|
+
if (!parts.idempotent) {
|
|
1137
|
+
settle(
|
|
1138
|
+
unknown(
|
|
1139
|
+
"it was in flight when the Orchestrator restarted, and no Effector replica holds it; not sent again " +
|
|
1140
|
+
"(it is not marked idempotent)",
|
|
1141
|
+
),
|
|
1142
|
+
);
|
|
1143
|
+
return;
|
|
1144
|
+
}
|
|
1145
|
+
mark({ stage: "started", text: `${parts.method} ${hostText(parts)} (sent again: it is idempotent)` });
|
|
1146
|
+
await send(requestFor());
|
|
1147
|
+
return;
|
|
1148
|
+
}
|
|
1149
|
+
|
|
1150
|
+
// Fresh: nothing was sent. The run's part of the request is built before the intent row, so a
|
|
1151
|
+
// defect in `request` is the Machine's error and sends nothing.
|
|
1152
|
+
const req = requestFor();
|
|
1153
|
+
// A start the Effector would not read is refused here, before the intent row: nothing is sent.
|
|
1154
|
+
const big = tooLarge(req);
|
|
1155
|
+
if (big) {
|
|
1156
|
+
settle(big);
|
|
1157
|
+
return;
|
|
1158
|
+
}
|
|
1159
|
+
// Write-ahead: the intent row is saved, with the snapshot that holds this child, before the
|
|
1160
|
+
// request leaves.
|
|
1161
|
+
binding?.recordEffect?.(id, {
|
|
1162
|
+
state: "intent",
|
|
1163
|
+
kind: "http",
|
|
1164
|
+
slot,
|
|
1165
|
+
placement: "instance",
|
|
1166
|
+
idempotent: parts.idempotent,
|
|
1167
|
+
at: now(),
|
|
1168
|
+
});
|
|
1169
|
+
mark({ stage: "started", text: `${parts.method} ${hostText(parts)}` });
|
|
1170
|
+
await send(req);
|
|
1171
|
+
|
|
1172
|
+
function requestFor(): StartRequest {
|
|
1173
|
+
let input: unknown = snapshot.input;
|
|
1174
|
+
if (def.input !== undefined) {
|
|
1175
|
+
const parsed = def.input.safeParse(input);
|
|
1176
|
+
if (!parsed.success) {
|
|
1177
|
+
throw new Error(
|
|
1178
|
+
`http "${slot}": its input does not match the definition's input schema — ` +
|
|
1179
|
+
parsed.error.issues.map((i) => `${i.path.join(".") || "(root)"}: ${i.message}`).join("; "),
|
|
1180
|
+
);
|
|
1181
|
+
}
|
|
1182
|
+
input = parsed.data;
|
|
1183
|
+
}
|
|
1184
|
+
const runParts = def.request ? (def.request as (i: unknown) => HttpRequestParts)(input) : {};
|
|
1185
|
+
return startRequestOf(parts, runParts ?? {});
|
|
1186
|
+
}
|
|
1187
|
+
|
|
1188
|
+
/** Send the start, and follow it. A start that never reached a replica is sent again; one whose
|
|
1189
|
+
* answer did not come is looked for on every replica, never sent blind. */
|
|
1190
|
+
async function send(req: StartRequest): Promise<void> {
|
|
1191
|
+
const big = tooLarge(req);
|
|
1192
|
+
if (big) {
|
|
1193
|
+
settle(big);
|
|
1194
|
+
return;
|
|
1195
|
+
}
|
|
1196
|
+
for (let wait = 250; ; wait = Math.min(wait * 2, RETRY_MAX_MS)) {
|
|
1197
|
+
let state: EffectState;
|
|
1198
|
+
try {
|
|
1199
|
+
state = await port.start(id, req, signal);
|
|
1200
|
+
} catch (err) {
|
|
1201
|
+
if (signal.aborted) return;
|
|
1202
|
+
if (err instanceof EffectorRefused || err instanceof EffectorMalformed) throw err;
|
|
1203
|
+
if (!(err instanceof EffectorNotSent)) {
|
|
1204
|
+
// It may have started — a lost answer, or a forged one: ask every replica first.
|
|
1205
|
+
const asked = await findAll();
|
|
1206
|
+
if (!asked || signal.aborted) return;
|
|
1207
|
+
const { found } = asked;
|
|
1208
|
+
if (found) {
|
|
1209
|
+
await follow(found);
|
|
1210
|
+
return;
|
|
1211
|
+
}
|
|
1212
|
+
if (!parts.idempotent) {
|
|
1213
|
+
settle(unknown("its start may have reached the Effector, and no replica holds it now; not sent again"));
|
|
1214
|
+
return;
|
|
1215
|
+
}
|
|
1216
|
+
}
|
|
1217
|
+
await sleep(wait, signal);
|
|
1218
|
+
if (signal.aborted) return;
|
|
1219
|
+
continue;
|
|
1220
|
+
}
|
|
1221
|
+
await follow(state);
|
|
1222
|
+
return;
|
|
1223
|
+
}
|
|
1224
|
+
}
|
|
1225
|
+
|
|
1226
|
+
/** Poll the replica that holds it to its outcome. A replica that stops answering for it — gone,
|
|
1227
|
+
* or restarted — sends the Effect to every replica again; none holding it is the restore rule. */
|
|
1228
|
+
async function follow(first: EffectState): Promise<void> {
|
|
1229
|
+
let state = first;
|
|
1230
|
+
let replica: Replica = first.replica;
|
|
1231
|
+
for (;;) {
|
|
1232
|
+
if (state.state === "settled" && state.output) {
|
|
1233
|
+
settle(state.output as HttpOutput);
|
|
1234
|
+
return;
|
|
1235
|
+
}
|
|
1236
|
+
if (state.state === "unknown") {
|
|
1237
|
+
const asked = await findAll();
|
|
1238
|
+
if (!asked || signal.aborted) return;
|
|
1239
|
+
const { found } = asked;
|
|
1240
|
+
if (!found) {
|
|
1241
|
+
if (parts.idempotent) {
|
|
1242
|
+
await send(requestFor());
|
|
1243
|
+
return;
|
|
1244
|
+
}
|
|
1245
|
+
settle(unknown("the Effector replica that held it no longer knows it (it restarted); not sent again"));
|
|
1246
|
+
return;
|
|
1247
|
+
}
|
|
1248
|
+
state = found;
|
|
1249
|
+
replica = found.replica;
|
|
1250
|
+
continue;
|
|
1251
|
+
}
|
|
1252
|
+
try {
|
|
1253
|
+
state = await port.poll(replica, id, signal);
|
|
1254
|
+
} catch (err) {
|
|
1255
|
+
if (signal.aborted) return;
|
|
1256
|
+
if (err instanceof EffectorRefused || err instanceof EffectorMalformed) throw err;
|
|
1257
|
+
// The replica did not answer, or not with the Effector's signature (its address may be
|
|
1258
|
+
// stale): the next pass asks every replica.
|
|
1259
|
+
state = { effect: id, replica, state: "unknown" };
|
|
1260
|
+
}
|
|
1261
|
+
if (signal.aborted) return;
|
|
1262
|
+
}
|
|
1263
|
+
}
|
|
1264
|
+
}
|
|
1265
|
+
|
|
1266
|
+
/**
|
|
1267
|
+
* The state that invoked this Effect exited (D1) — or the host is parking the run. Parking keeps
|
|
1268
|
+
* the call for the restore to find. An exit stops the polling and records the abort, "outcome
|
|
1269
|
+
* unknown": an http Effect has no stop route, so the Effector finishes the call and drops an
|
|
1270
|
+
* outcome nobody reads. The Machine never hears it.
|
|
1271
|
+
*/
|
|
1272
|
+
function stopped(self: AnyActorRef, system: AnyActorSystem, effectId: string): void {
|
|
1273
|
+
const run = running.get(self);
|
|
1274
|
+
running.delete(self);
|
|
1275
|
+
run?.controller.abort();
|
|
1276
|
+
const binding = boundRun(system);
|
|
1277
|
+
if (binding?.hostStopping) return;
|
|
1278
|
+
const row = binding?.effectRow?.(effectId);
|
|
1279
|
+
if (row?.state !== "intent" || row.kind !== "http") return;
|
|
1280
|
+
const aborted: EffectRow = { state: "aborted", kind: "http", slot: row.slot, at: new Date().toISOString() };
|
|
1281
|
+
binding?.recordEffect?.(effectId, aborted, "with-transition");
|
|
1282
|
+
binding?.marker?.({
|
|
1283
|
+
kind: "effect",
|
|
1284
|
+
slot: row.slot,
|
|
1285
|
+
effect: effectId,
|
|
1286
|
+
stage: "aborted",
|
|
1287
|
+
text: "its state exited while it ran: no longer followed; the Effector may still send it; outcome unknown",
|
|
1288
|
+
});
|
|
1289
|
+
}
|
|
1290
|
+
|
|
1291
|
+
/** A start over the bytes the Effector reads is output `refused` (ADR-0068): the Effector could not
|
|
1292
|
+
* verify it, and a body built from context or a pick's param can reach the bound. */
|
|
1293
|
+
function tooLarge(req: StartRequest): HttpOutput | undefined {
|
|
1294
|
+
const bytes = Buffer.byteLength(JSON.stringify(req), "utf8");
|
|
1295
|
+
if (bytes <= MAX_START_BYTES) return undefined;
|
|
1296
|
+
return {
|
|
1297
|
+
outcome: "refused",
|
|
1298
|
+
reason: `the request is ${bytes} bytes, over the ${MAX_START_BYTES} bytes an Effector reads; nothing was sent`,
|
|
1299
|
+
};
|
|
1300
|
+
}
|
|
1301
|
+
|
|
1302
|
+
function hostText(parts: HttpParts): string {
|
|
1303
|
+
const { scheme, host, port } = parts.target;
|
|
1304
|
+
const standard = (scheme === "https" && port === 443) || (scheme === "http" && port === 80);
|
|
1305
|
+
return `${scheme}://${host}${standard ? "" : `:${port}`}`;
|
|
1306
|
+
}
|
|
1307
|
+
|
|
1308
|
+
async function retrying<T>(call: () => Promise<T>, signal: AbortSignal): Promise<T> {
|
|
1309
|
+
for (let wait = 250; ; wait = Math.min(wait * 2, RETRY_MAX_MS)) {
|
|
1310
|
+
try {
|
|
1311
|
+
return await call();
|
|
1312
|
+
} catch (err) {
|
|
1313
|
+
// A refusal and a malformed answer are defects; a forged one would be forged again.
|
|
1314
|
+
if (
|
|
1315
|
+
signal.aborted ||
|
|
1316
|
+
err instanceof EffectorRefused ||
|
|
1317
|
+
err instanceof EffectorMalformed ||
|
|
1318
|
+
err instanceof EffectorForged
|
|
1319
|
+
) {
|
|
1320
|
+
throw err;
|
|
1321
|
+
}
|
|
1322
|
+
await sleep(wait, signal);
|
|
1323
|
+
if (signal.aborted) throw err;
|
|
1324
|
+
}
|
|
1325
|
+
}
|
|
1326
|
+
}
|
|
1327
|
+
|
|
1328
|
+
function sleep(ms: number, signal: AbortSignal): Promise<void> {
|
|
1329
|
+
return new Promise((resolve) => {
|
|
1330
|
+
const timer = setTimeout(resolve, ms / 2 + Math.random() * (ms / 2));
|
|
1331
|
+
signal.addEventListener("abort", () => {
|
|
1332
|
+
clearTimeout(timer);
|
|
1333
|
+
resolve();
|
|
1334
|
+
});
|
|
1335
|
+
});
|
|
1336
|
+
}
|
|
1337
|
+
|
|
1338
|
+
function parseDuration(d: Duration): number {
|
|
1339
|
+
const n = Number.parseFloat(d);
|
|
1340
|
+
if (!(n > 0)) throw new Error(`http: timeout "${d}" is not a positive duration (like "30s", "2m")`);
|
|
1341
|
+
return d.endsWith("h") ? n * 3_600_000 : d.endsWith("m") ? n * 60_000 : n * 1000;
|
|
1342
|
+
}
|
|
1343
|
+
|
|
1344
|
+
/**
|
|
1345
|
+
* Make an `http` Effect: a call to a URL, with Effect secrets that the Agent never reads. Put it
|
|
1346
|
+
* in `actors`, and invoke it by its key: `actors: { comment: http({ ... }) }`, then `invoke: {
|
|
1347
|
+
* src: "comment" }`.
|
|
1348
|
+
*
|
|
1349
|
+
* It runs in the Instance Effector, which `jr2 up` deploys when a Machine has an `http` Effect.
|
|
1350
|
+
* With `body: file(...)`, it runs in the Sandbox Effector of the Workspace whose body invokes it.
|
|
1351
|
+
* The Effector makes the call. The Orchestrator holds no secret.
|
|
1352
|
+
*
|
|
1353
|
+
* Every outcome is output (`HttpOutput`): `response`, `unreachable`, `refused` or `unknown`. A 4xx
|
|
1354
|
+
* or 5xx status is output too. jr2 sends the call at most one time, unless you mark it
|
|
1355
|
+
* `idempotent`. When its state exits while the call runs, jr2 stops waiting for it. The Effector
|
|
1356
|
+
* can still send it.
|
|
1357
|
+
*
|
|
1358
|
+
* Each field of {@link HttpDefinition} tells what it refuses. `createMachine` also refuses an
|
|
1359
|
+
* Effect written directly in an `invoke`: `machine "<id>": state "<state>" invokes an Effect
|
|
1360
|
+
* inline — a Part is a key of actors, so it has a name that the ledger, the report and
|
|
1361
|
+
* customize() share: actors: { <name>: http(...) }, then invoke: { src: "<name>" }`.
|
|
1362
|
+
*
|
|
1363
|
+
* @param def - The definition. Each field tells what it does.
|
|
1364
|
+
* @returns An Effect, for `actors`.
|
|
1365
|
+
*
|
|
1366
|
+
* @example
|
|
1367
|
+
* ```ts
|
|
1368
|
+
* import { z } from "zod";
|
|
1369
|
+
* import { bearer, http, jr2Setup, secret } from "@jr2/orchestrator";
|
|
1370
|
+
*
|
|
1371
|
+
* const comment = http({
|
|
1372
|
+
* method: "POST",
|
|
1373
|
+
* url: "https://jira.example.com/rest/api/2/issue",
|
|
1374
|
+
* auth: bearer(secret("jira")),
|
|
1375
|
+
* input: z.object({ key: z.string(), body: z.string() }),
|
|
1376
|
+
* request: ({ key, body }) => ({ path: [key, "comment"], json: { body } }),
|
|
1377
|
+
* });
|
|
1378
|
+
*
|
|
1379
|
+
* export const machine = jr2Setup({
|
|
1380
|
+
* types: {} as { context: { key: string }; input: { key: string } },
|
|
1381
|
+
* actors: { comment },
|
|
1382
|
+
* }).createMachine({
|
|
1383
|
+
* id: "comment",
|
|
1384
|
+
* context: ({ input }) => input,
|
|
1385
|
+
* initial: "commenting",
|
|
1386
|
+
* states: {
|
|
1387
|
+
* commenting: {
|
|
1388
|
+
* invoke: {
|
|
1389
|
+
* src: "comment",
|
|
1390
|
+
* input: ({ context }) => ({ key: context.key, body: "The build passed." }),
|
|
1391
|
+
* onDone: "done",
|
|
1392
|
+
* },
|
|
1393
|
+
* },
|
|
1394
|
+
* done: { type: "final" },
|
|
1395
|
+
* },
|
|
1396
|
+
* });
|
|
1397
|
+
* ```
|
|
1398
|
+
*
|
|
1399
|
+
* @see docs/concepts/effects.md
|
|
1400
|
+
* @see docs/guides/run-a-command-or-call-a-url.md
|
|
1401
|
+
* @see docs/concepts/secrets-and-trust.md
|
|
1402
|
+
* @see docs/reference/decisions/0068-an-effector-holds-an-effects-secrets-and-signs-its-wire.md
|
|
1403
|
+
* @see docs/reference/decisions/0067-an-effect-is-an-actor-slot-and-runs-at-most-once-unless-marked-idempotent.md
|
|
1404
|
+
*/
|
|
1405
|
+
export function http<
|
|
1406
|
+
S extends z.ZodType | undefined = undefined,
|
|
1407
|
+
const U extends HttpDefinition<S>["url"] = string,
|
|
1408
|
+
const A extends HttpDefinition<S>["auth"] = undefined,
|
|
1409
|
+
const H extends HttpDefinition<S>["headers"] = undefined,
|
|
1410
|
+
>(
|
|
1411
|
+
def: HttpDefinition<S> & { url: U; auth?: A; headers?: H },
|
|
1412
|
+
): EffectLogic<HttpInput<S>, HttpOutput> & OpenOf<HttpOpenParts<U, A, H>>;
|
|
1413
|
+
export function http<S extends z.ZodType | undefined = undefined>(
|
|
1414
|
+
def: HttpDefinition<S>,
|
|
1415
|
+
): EffectLogic<HttpInput<S>, HttpOutput> {
|
|
1416
|
+
// The Orchestrator signs the start, and the Effector builds the request: the definition's parts,
|
|
1417
|
+
// and `request`'s from the input (ADR-0068).
|
|
1418
|
+
if (def.body !== undefined) return httpFileWith(sandboxEffectorPort, def);
|
|
1419
|
+
return httpWith(
|
|
1420
|
+
(target) => createEffectorClient({ key: target.key, transport: nodeEffectorTransport(target.service) }),
|
|
1421
|
+
def,
|
|
1422
|
+
);
|
|
1423
|
+
}
|