@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,674 @@
|
|
|
1
|
+
// The Effector's wire, from the Orchestrator's side (ADR-0068): the request it encodes, the
|
|
2
|
+
// answers it decodes, the signatures on both, and the client that speaks it.
|
|
3
|
+
//
|
|
4
|
+
// The types are written by hand, here and in Go (`effector/internal/wire`). Here each zod shape is
|
|
5
|
+
// the one definition, and each type is inferred from it.
|
|
6
|
+
// The golden fixtures in `effector/testdata/wire/` keep the two equal: these tests encode each
|
|
7
|
+
// request fixture's shape and decode each answer; the Go tests do the reverse. An answer that does
|
|
8
|
+
// not decode strictly is a jr2 defect, never an outcome.
|
|
9
|
+
//
|
|
10
|
+
// No bearer: every request is signed with the placement's key (`effectorKey`), and every answer
|
|
11
|
+
// must carry the Effector's signature over its status, the request's method, path and nonce, and
|
|
12
|
+
// its body — an answer bound to this request. An unsigned or mis-signed answer is not the Effector's.
|
|
13
|
+
|
|
14
|
+
import { createHash, createHmac, randomBytes, timingSafeEqual } from "node:crypto";
|
|
15
|
+
import { lookup } from "node:dns/promises";
|
|
16
|
+
import { z } from "zod";
|
|
17
|
+
|
|
18
|
+
// --- The request (POST /effects/<id>) ------------------------------------------------------------
|
|
19
|
+
//
|
|
20
|
+
// Each zod shape is the one definition of its part of the wire; each type is inferred from it.
|
|
21
|
+
|
|
22
|
+
export const HTTP_METHODS = ["GET", "HEAD", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"] as const;
|
|
23
|
+
export const HttpMethodShape = z.enum(HTTP_METHODS);
|
|
24
|
+
/** The method of an `http` Effect: `GET`, `HEAD`, `POST`, `PUT`, `PATCH`, `DELETE` or `OPTIONS`. */
|
|
25
|
+
export type HttpMethod = z.infer<typeof HttpMethodShape>;
|
|
26
|
+
|
|
27
|
+
/** One part of a string the Effector builds: literal text, or a secret it holds, by name. A
|
|
28
|
+
* secret's value is never on the wire. */
|
|
29
|
+
export const TextPartShape = z.union([z.strictObject({ text: z.string() }), z.strictObject({ secret: z.string() })]);
|
|
30
|
+
/** One part of a {@link Text}: `{ text }`, literal text, or `{ secret }`, the name of an Effect
|
|
31
|
+
* secret. The `text` template makes the parts. */
|
|
32
|
+
export type TextPart = z.infer<typeof TextPartShape>;
|
|
33
|
+
|
|
34
|
+
export const WireAuthShape = z.union([
|
|
35
|
+
z.strictObject({ bearer: z.strictObject({ secret: z.string() }) }),
|
|
36
|
+
z.strictObject({ basic: z.strictObject({ user: z.array(TextPartShape), password: z.array(TextPartShape) }) }),
|
|
37
|
+
z.strictObject({ text: z.array(TextPartShape) }),
|
|
38
|
+
]);
|
|
39
|
+
export type WireAuth = z.infer<typeof WireAuthShape>;
|
|
40
|
+
|
|
41
|
+
/** A Worktree file (a file-body `http`, ADR-0067): the Worktree, relative to the work
|
|
42
|
+
* root, and the file, relative to the Worktree. The Sandbox Effector opens both beneath, with no
|
|
43
|
+
* symlink, and refuses a FIFO and a file with more than one link — output, with nothing sent. */
|
|
44
|
+
export const FileRefShape = z.strictObject({
|
|
45
|
+
worktree: z.string(),
|
|
46
|
+
path: z.string(),
|
|
47
|
+
contentType: z.string().optional(),
|
|
48
|
+
});
|
|
49
|
+
export type FileRef = z.infer<typeof FileRefShape>;
|
|
50
|
+
|
|
51
|
+
export const WireBodyShape = z.union([
|
|
52
|
+
z.strictObject({ json: z.unknown() }),
|
|
53
|
+
z.strictObject({ text: z.string() }),
|
|
54
|
+
z.strictObject({ file: FileRefShape }),
|
|
55
|
+
]);
|
|
56
|
+
export type WireBody = z.infer<typeof WireBodyShape>;
|
|
57
|
+
|
|
58
|
+
/** The headers the Effector sets itself, which a definition may not: they frame or route the
|
|
59
|
+
* request. A `proxy-*` name is one too. The Go side's `framing` is the same list; the reject
|
|
60
|
+
* fixtures hold one start per name, which each side refuses. */
|
|
61
|
+
export const FRAMING_HEADERS: ReadonlySet<string> = new Set([
|
|
62
|
+
"host",
|
|
63
|
+
"content-length",
|
|
64
|
+
"transfer-encoding",
|
|
65
|
+
"connection",
|
|
66
|
+
"upgrade",
|
|
67
|
+
"te",
|
|
68
|
+
"trailer",
|
|
69
|
+
"keep-alive",
|
|
70
|
+
]);
|
|
71
|
+
const HEADER_NAME = /^[a-z0-9!#$%&'*+.^_`|~-]+$/;
|
|
72
|
+
|
|
73
|
+
/** A header an Effect may set: a lowercase name that does not frame or route the request. */
|
|
74
|
+
export const isEffectHeader = (name: string): boolean =>
|
|
75
|
+
HEADER_NAME.test(name) && !FRAMING_HEADERS.has(name) && !name.startsWith("proxy-");
|
|
76
|
+
|
|
77
|
+
/** An `http` start; its body may be a Worktree file (ADR-0067), which only a Sandbox Effector
|
|
78
|
+
* serves. */
|
|
79
|
+
export const HttpStartShape = z.strictObject({
|
|
80
|
+
kind: z.literal("http"),
|
|
81
|
+
method: HttpMethodShape,
|
|
82
|
+
url: z.array(TextPartShape),
|
|
83
|
+
path: z.array(z.string()).optional(),
|
|
84
|
+
headers: z.record(z.string().refine(isEffectHeader), z.array(TextPartShape)).optional(),
|
|
85
|
+
auth: WireAuthShape.optional(),
|
|
86
|
+
body: WireBodyShape.optional(),
|
|
87
|
+
timeoutMs: z.number().int(),
|
|
88
|
+
maxBody: z.number().int(),
|
|
89
|
+
});
|
|
90
|
+
export type HttpStartRequest = z.infer<typeof HttpStartShape>;
|
|
91
|
+
|
|
92
|
+
/** Where one Repo Slot's repositories are in a Sandbox pod (ADR-0004, ADR-0051): the Agent's
|
|
93
|
+
* common git dir and Worktree, relative to `/work`, and the node cache's key under `/repos`. */
|
|
94
|
+
export const RepoRefShape = z.strictObject({ gitDir: z.string(), worktree: z.string(), cache: z.string() });
|
|
95
|
+
export type RepoRef = z.infer<typeof RepoRefShape>;
|
|
96
|
+
|
|
97
|
+
/** One push's published commit and the Agent's commit it was made from: a ledger push row's pair,
|
|
98
|
+
* with that push's own `squashed` — what its published commit is, which a later push that maps the
|
|
99
|
+
* same commit names (ADR-0067). */
|
|
100
|
+
export const PairShape = z.strictObject({
|
|
101
|
+
published: z.string(),
|
|
102
|
+
source: z.string(),
|
|
103
|
+
squashed: z.enum(["signed", "unsigned"]).optional(),
|
|
104
|
+
});
|
|
105
|
+
export type Pair = z.infer<typeof PairShape>;
|
|
106
|
+
|
|
107
|
+
/** A `push` start: the Workspace's branch to the Repo Slot's Binding, with the push credential of
|
|
108
|
+
* the `git.credentials` entry the url falls under — the Effector's config holds the entries, and
|
|
109
|
+
* matches the url as the fence does (`credential` names one by its `match` instead). `pin` is the
|
|
110
|
+
* commit; absent, the Effector reads the Agent's branch tip and its answer carries it (`pinned`).
|
|
111
|
+
*/
|
|
112
|
+
export const PushStartShape = z.strictObject({
|
|
113
|
+
kind: z.literal("push"),
|
|
114
|
+
push: z.strictObject({
|
|
115
|
+
repo: RepoRefShape,
|
|
116
|
+
url: z.string(),
|
|
117
|
+
branch: z.string(),
|
|
118
|
+
credential: z.string().optional(),
|
|
119
|
+
pin: z.string().optional(),
|
|
120
|
+
sign: z.boolean().optional(),
|
|
121
|
+
squash: z.string().optional(),
|
|
122
|
+
force: z.boolean().optional(),
|
|
123
|
+
/** Every pair the ledger holds for the Workspace's Repo Slot, oldest push first (ADR-0067):
|
|
124
|
+
* omitted when there is none, never empty. */
|
|
125
|
+
pairs: z.array(PairShape).min(1).optional(),
|
|
126
|
+
}),
|
|
127
|
+
timeoutMs: z.number().int(),
|
|
128
|
+
});
|
|
129
|
+
export type PushStartRequest = z.infer<typeof PushStartShape>;
|
|
130
|
+
|
|
131
|
+
/** One variable of a credentialed command's env: a secret's value, or (`path`) its file's path. */
|
|
132
|
+
export const EnvRefShape = z.strictObject({ secret: z.string(), path: z.boolean().optional() });
|
|
133
|
+
export type EnvRef = z.infer<typeof EnvRefShape>;
|
|
134
|
+
|
|
135
|
+
/** A credentialed `command` start: argv against the pinned commit, with only the env named. */
|
|
136
|
+
export const CommandStartShape = z.strictObject({
|
|
137
|
+
kind: z.literal("command"),
|
|
138
|
+
command: z.strictObject({
|
|
139
|
+
repo: RepoRefShape,
|
|
140
|
+
branch: z.string(),
|
|
141
|
+
pin: z.string().optional(),
|
|
142
|
+
argv: z.array(z.string()).min(1),
|
|
143
|
+
env: z.record(z.string(), EnvRefShape),
|
|
144
|
+
tail: z.number().int(),
|
|
145
|
+
}),
|
|
146
|
+
timeoutMs: z.number().int(),
|
|
147
|
+
});
|
|
148
|
+
export type CommandStartRequest = z.infer<typeof CommandStartShape>;
|
|
149
|
+
|
|
150
|
+
/** A string the Effector builds, as parts (the `text` template's wire form). */
|
|
151
|
+
export const TextShape = z.array(TextPartShape);
|
|
152
|
+
|
|
153
|
+
/** A secret the Effector holds, by name. */
|
|
154
|
+
export const SecretRefShape = z.strictObject({ secret: z.string() });
|
|
155
|
+
|
|
156
|
+
/** A Workflow's name as `jr2 run` names it (ADR-0071): a Machine's filename stem, or
|
|
157
|
+
* `<wrapper>/<name>` for a named Chart. The Go side's `Validate` holds the same rule. */
|
|
158
|
+
export const WORKFLOW_NAME = /^[a-z0-9][a-z0-9-]*(\/[a-z0-9][a-z0-9-]*)?$/;
|
|
159
|
+
|
|
160
|
+
/** A dispatch target's origin: a scheme and a host — a DNS name or IPv4 address, or a bracketed
|
|
161
|
+
* IPv6 address — a port when it is not the scheme's, and no path, query, fragment, userinfo or any
|
|
162
|
+
* other character no origin has — the Effector appends the door's path (ADR-0071). The Go side's
|
|
163
|
+
* `origin` is the same rule. */
|
|
164
|
+
export const ORIGIN = /^https?:\/\/(\[[0-9A-Fa-f:.]+\]|[A-Za-z0-9.-]+)(:[0-9]{1,5})?$/;
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Which run started a run, and its place in the chain (ADR-0071): the caller's run and slot, the
|
|
168
|
+
* caller's ledger row id (`effect`, the door's idempotency key), the dispatched run's depth — one
|
|
169
|
+
* below its caller, stated by the caller and recorded as sent, so at least 1 — and, for a caller
|
|
170
|
+
* on another Orchestrator, that Instance as opaque text.
|
|
171
|
+
*/
|
|
172
|
+
export const DispatchedByShape = z.strictObject({
|
|
173
|
+
run: z.string(),
|
|
174
|
+
slot: z.string(),
|
|
175
|
+
effect: z.string(),
|
|
176
|
+
depth: z.number().int().positive(),
|
|
177
|
+
instance: z.string().optional(),
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* A `dispatch` start (ADR-0071), which only the Instance Effector serves: a run of `workflow` on the
|
|
182
|
+
* Orchestrator at `url` (its origin, one text part), the body `input` whole, the Instance token as a
|
|
183
|
+
* bearer by name, `dispatchedBy` as the `jr2-dispatched-by` header — `instance` set, since the
|
|
184
|
+
* caller is foreign by construction — and, with `wait`, a poll of `GET /runs/<id>` until the run
|
|
185
|
+
* ends or `timeoutMs` passes.
|
|
186
|
+
*/
|
|
187
|
+
export const DispatchStartShape = z.strictObject({
|
|
188
|
+
kind: z.literal("dispatch"),
|
|
189
|
+
url: TextShape.refine(
|
|
190
|
+
(parts) => parts.length === 1 && "text" in parts[0]! && ORIGIN.test(parts[0].text),
|
|
191
|
+
"a dispatch url is one text part, an origin: http(s)://host[:port]",
|
|
192
|
+
),
|
|
193
|
+
workflow: z.string().regex(WORKFLOW_NAME),
|
|
194
|
+
input: z.record(z.string(), z.unknown()),
|
|
195
|
+
auth: z.strictObject({ bearer: SecretRefShape }),
|
|
196
|
+
// Every id non-empty, as the door's header shape requires (http.ts): an empty one would be the
|
|
197
|
+
// door's 400, which reads as a defect of the input. The effect is a ledger row id, the key. The Go
|
|
198
|
+
// side's `validateDispatch` holds the same rule.
|
|
199
|
+
dispatchedBy: DispatchedByShape.extend({
|
|
200
|
+
run: z.string().min(1),
|
|
201
|
+
slot: z.string().min(1),
|
|
202
|
+
effect: z
|
|
203
|
+
.string()
|
|
204
|
+
.regex(/^[A-Za-z0-9._-]{1,128}$/)
|
|
205
|
+
.refine((id) => id !== "." && id !== ".."),
|
|
206
|
+
instance: z.string().min(1),
|
|
207
|
+
}),
|
|
208
|
+
wait: z.boolean(),
|
|
209
|
+
timeoutMs: z.number().int().positive(),
|
|
210
|
+
});
|
|
211
|
+
export type DispatchStartRequest = z.infer<typeof DispatchStartShape>;
|
|
212
|
+
|
|
213
|
+
/** Every start: the http kinds, a push, a credentialed command, a dispatch. */
|
|
214
|
+
export const StartRequestShape = z.union([HttpStartShape, PushStartShape, CommandStartShape, DispatchStartShape]);
|
|
215
|
+
export type StartRequest = z.infer<typeof StartRequestShape>;
|
|
216
|
+
|
|
217
|
+
/** The most bytes of a start's body the Effector reads (its `maxRequestBody`). A longer body
|
|
218
|
+
* cannot be verified, so the Effector refuses it as unsigned; the Orchestrator never sends one. */
|
|
219
|
+
export const MAX_START_BYTES = 4 << 20;
|
|
220
|
+
|
|
221
|
+
// --- The answers ---------------------------------------------------------------------------------
|
|
222
|
+
|
|
223
|
+
/** The `response` outcome: a status came back. The body is there when it is text, bounded and
|
|
224
|
+
* scrubbed; a body that is not text is its size and content type only. */
|
|
225
|
+
export const HttpResponseShape = z.strictObject({
|
|
226
|
+
outcome: z.literal("response"),
|
|
227
|
+
status: z.number().int(),
|
|
228
|
+
headers: z.record(z.string(), z.string()),
|
|
229
|
+
contentType: z.string(),
|
|
230
|
+
size: z.number().int().nonnegative(),
|
|
231
|
+
body: z.string().optional(),
|
|
232
|
+
truncated: z.boolean().optional(),
|
|
233
|
+
});
|
|
234
|
+
export type HttpResponse = z.infer<typeof HttpResponseShape>;
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* An `http` Effect's output. Every outcome is output (ADR-0067): `response`; `unreachable` (nothing
|
|
238
|
+
* was sent); `refused` (the Effector refused it and sent nothing — a path segment that is `.`, `..`
|
|
239
|
+
* or empty, a host no Effect placed there names); `unknown` (it may have been sent, and no answer
|
|
240
|
+
* came — or jr2 cannot tell, after a restart or a state exit).
|
|
241
|
+
*/
|
|
242
|
+
export const HttpOutputShape = z.union([
|
|
243
|
+
HttpResponseShape,
|
|
244
|
+
z.strictObject({ outcome: z.enum(["unreachable", "refused", "unknown"]), reason: z.string() }),
|
|
245
|
+
]);
|
|
246
|
+
/**
|
|
247
|
+
* The output of an `http` Effect: what the `onDone` of its state gets as `event.output`. Every
|
|
248
|
+
* outcome is output. A 4xx or 5xx status is output too. Route on `outcome`:
|
|
249
|
+
*
|
|
250
|
+
* - `response`: an answer came back. `status` is its HTTP status. `headers`, `contentType` and
|
|
251
|
+
* `size` tell about the body. `body` is there when the body is text: at most `maxBody` bytes
|
|
252
|
+
* (`truncated` is `true` when jr2 cut it). A body that is not text gives only its size and
|
|
253
|
+
* content type. jr2 replaces each value of an Effect secret that the Effector holds, and its
|
|
254
|
+
* usual encodings, with `[secret:<name>]`, in the body and the headers.
|
|
255
|
+
* - `unreachable`: the call did not go out, for example because the name did not resolve, or the
|
|
256
|
+
* connection or TLS failed. `reason` tells why.
|
|
257
|
+
* - `refused`: jr2 or the Effector refused the call, and nothing was sent. For example, a path
|
|
258
|
+
* segment that is `.`, `..` or empty, or a request that is too large. `reason` tells why.
|
|
259
|
+
* - `unknown`: the call can have gone out, but no answer came, or jr2 cannot tell after a restart.
|
|
260
|
+
* `reason` tells why. jr2 does not send it again unless it is marked `idempotent`.
|
|
261
|
+
*
|
|
262
|
+
* @example
|
|
263
|
+
* ```ts
|
|
264
|
+
* import { fail, http, jr2Setup, type HttpOutput } from "@jr2/orchestrator";
|
|
265
|
+
*
|
|
266
|
+
* const ok = (output: HttpOutput): boolean => output.outcome === "response" && output.status < 300;
|
|
267
|
+
*
|
|
268
|
+
* export const machine = jr2Setup({
|
|
269
|
+
* actors: { health: http({ method: "GET", url: "https://app.example.com/healthz" }), fail: fail() },
|
|
270
|
+
* }).createMachine({
|
|
271
|
+
* id: "health",
|
|
272
|
+
* initial: "checking",
|
|
273
|
+
* states: {
|
|
274
|
+
* checking: {
|
|
275
|
+
* invoke: {
|
|
276
|
+
* src: "health",
|
|
277
|
+
* onDone: [
|
|
278
|
+
* { guard: ({ event }) => ok(event.output), target: "healthy" },
|
|
279
|
+
* { target: "unhealthy" },
|
|
280
|
+
* ],
|
|
281
|
+
* },
|
|
282
|
+
* },
|
|
283
|
+
* healthy: { type: "final" },
|
|
284
|
+
* // Not a final state: the run ends `failed`, and `jr2 run` exits 1.
|
|
285
|
+
* unhealthy: { invoke: { src: "fail", input: { reason: "the app is not healthy" } } },
|
|
286
|
+
* },
|
|
287
|
+
* });
|
|
288
|
+
* ```
|
|
289
|
+
*
|
|
290
|
+
* @see docs/concepts/effects.md
|
|
291
|
+
* @see docs/guides/run-a-command-or-call-a-url.md
|
|
292
|
+
* @see docs/reference/decisions/0068-an-effector-holds-an-effects-secrets-and-signs-its-wire.md
|
|
293
|
+
*/
|
|
294
|
+
export type HttpOutput = z.infer<typeof HttpOutputShape>;
|
|
295
|
+
|
|
296
|
+
/**
|
|
297
|
+
* A `push` Effect's output (ADR-0067). `pushed`: the published commit, its source (the Agent's
|
|
298
|
+
* commit), what the remote held before, how the new commits were found after a signed push
|
|
299
|
+
* (`ancestry | pair | cherry | force`), `unchanged` when the remote held it already, `newCommits`
|
|
300
|
+
* (how many commits the published commit adds to what jr2 found held, the remote's branch and the
|
|
301
|
+
* Repo cache; 0 when jr2 found the published commit already held, which the Agent reads in words,
|
|
302
|
+
* menu.ts), `squashed` when the published
|
|
303
|
+
* commit is one squashed commit (`signed | unsigned`; absent for signed copies; what the published
|
|
304
|
+
* commit is, an earlier push's included, never only this push's options), and — when the published
|
|
305
|
+
* commit is not the Agent's own — the published commits as a bundle for the sync. `rejected`: a
|
|
306
|
+
* stage and git's words. `diverged`: the published commit is not in the Agent's history and
|
|
307
|
+
* nothing paired it; nothing was pushed.
|
|
308
|
+
*/
|
|
309
|
+
export const PushOutputShape = z.union([
|
|
310
|
+
z.strictObject({
|
|
311
|
+
outcome: z.literal("pushed"),
|
|
312
|
+
published: z.string(),
|
|
313
|
+
source: z.string(),
|
|
314
|
+
previous: z.string().optional(),
|
|
315
|
+
via: z.string().optional(),
|
|
316
|
+
unchanged: z.boolean().optional(),
|
|
317
|
+
// Optional: a live Sandbox keeps the runtime it started with (ADR-0037), so its older Effector
|
|
318
|
+
// sends no count after `jr2 up`. With no count the Harness makes no claim (menu.ts).
|
|
319
|
+
newCommits: z.number().int().nonnegative().optional(),
|
|
320
|
+
squashed: z.enum(["signed", "unsigned"]).optional(),
|
|
321
|
+
bundle: z.string().optional(),
|
|
322
|
+
}),
|
|
323
|
+
z.strictObject({
|
|
324
|
+
outcome: z.literal("rejected"),
|
|
325
|
+
stage: z.string(),
|
|
326
|
+
reason: z.string(),
|
|
327
|
+
detail: z.string().optional(),
|
|
328
|
+
}),
|
|
329
|
+
z.strictObject({ outcome: z.literal("diverged"), reason: z.string(), published: z.string(), source: z.string() }),
|
|
330
|
+
z.strictObject({ outcome: z.literal("unknown"), reason: z.string() }),
|
|
331
|
+
]);
|
|
332
|
+
/** @internal */
|
|
333
|
+
export type PushWireOutput = z.infer<typeof PushOutputShape>;
|
|
334
|
+
|
|
335
|
+
/** A credentialed command's output, as the command kind's (ADR-0067), and `dirty` when the
|
|
336
|
+
* Worktree held uncommitted edits it did not see. A stopped command's (`DELETE`, ADR-0067) is
|
|
337
|
+
* `exited` with the signal the stop sent last, and the code when the program caught SIGTERM and
|
|
338
|
+
* exited with one. */
|
|
339
|
+
export const CommandOutputShape = z.union([
|
|
340
|
+
z.strictObject({
|
|
341
|
+
outcome: z.literal("exited"),
|
|
342
|
+
code: z.number().int(),
|
|
343
|
+
tail: z.string(),
|
|
344
|
+
dirty: z.boolean().optional(),
|
|
345
|
+
}),
|
|
346
|
+
z.strictObject({
|
|
347
|
+
outcome: z.literal("exited"),
|
|
348
|
+
signal: z.string(),
|
|
349
|
+
code: z.number().int().optional(),
|
|
350
|
+
tail: z.string(),
|
|
351
|
+
dirty: z.boolean().optional(),
|
|
352
|
+
}),
|
|
353
|
+
z.strictObject({ outcome: z.literal("timeout"), tail: z.string(), dirty: z.boolean().optional() }),
|
|
354
|
+
z.strictObject({ outcome: z.literal("killed"), signal: z.string(), tail: z.string(), dirty: z.boolean().optional() }),
|
|
355
|
+
z.strictObject({ outcome: z.literal("unknown"), reason: z.string() }),
|
|
356
|
+
]);
|
|
357
|
+
export type CommandWireOutput = z.infer<typeof CommandOutputShape>;
|
|
358
|
+
|
|
359
|
+
/**
|
|
360
|
+
* A `dispatch` Effect's output on the wire (ADR-0071): its own outcome set. `settled.status` is the
|
|
361
|
+
* callee's status as the door served it, in the store's words, and `output` the callee Machine's,
|
|
362
|
+
* passed through scrubbed; `refused` is the door's status and its report, told from an http
|
|
363
|
+
* `refused` (which has a `reason`) by its fields.
|
|
364
|
+
*/
|
|
365
|
+
export const DispatchOutputShape = z.discriminatedUnion("outcome", [
|
|
366
|
+
z.strictObject({ outcome: z.literal("started"), run: z.string() }),
|
|
367
|
+
z.strictObject({
|
|
368
|
+
outcome: z.literal("settled"),
|
|
369
|
+
run: z.string(),
|
|
370
|
+
// The store's words (ADR-0025) and the timeout's `live`, as `DispatchOutput` types them for the
|
|
371
|
+
// Machine: a door that serves another word (another Kit version) is a malformed answer, the
|
|
372
|
+
// invoke's error, never a status the Machine's type does not name (ADR-0071).
|
|
373
|
+
status: z.enum(["done", "failed", "error", "cancelled", "lost", "drifted", "live"]),
|
|
374
|
+
output: z.unknown().optional(),
|
|
375
|
+
}),
|
|
376
|
+
z.strictObject({ outcome: z.literal("refused"), status: z.number().int(), report: z.array(z.string()) }),
|
|
377
|
+
z.strictObject({ outcome: z.literal("unreachable"), reason: z.string() }),
|
|
378
|
+
z.strictObject({ outcome: z.literal("unknown"), reason: z.string() }),
|
|
379
|
+
]);
|
|
380
|
+
export type DispatchWireOutput = z.infer<typeof DispatchOutputShape>;
|
|
381
|
+
|
|
382
|
+
/** The replica that holds an Effect, and where to poll it. */
|
|
383
|
+
export const ReplicaShape = z.strictObject({ name: z.string(), address: z.string() });
|
|
384
|
+
export type Replica = z.infer<typeof ReplicaShape>;
|
|
385
|
+
|
|
386
|
+
/** Every answer of `POST` and `GET /effects/<id>`. */
|
|
387
|
+
export const EffectStateShape = z.strictObject({
|
|
388
|
+
effect: z.string(),
|
|
389
|
+
replica: ReplicaShape,
|
|
390
|
+
state: z.enum(["running", "settled", "unknown"]),
|
|
391
|
+
/** The commit a push or a credentialed command runs against, read when it started: the
|
|
392
|
+
* Orchestrator writes it to the ledger's intent row. */
|
|
393
|
+
pinned: z.string().optional(),
|
|
394
|
+
/** The run a dispatch started, once the door answered and while its wait runs: the Orchestrator
|
|
395
|
+
* writes it to the ledger's intent row, so the caller names its callee during the wait (ADR-0071). */
|
|
396
|
+
run: z.string().optional(),
|
|
397
|
+
output: z.union([HttpOutputShape, PushOutputShape, CommandOutputShape, DispatchOutputShape]).optional(),
|
|
398
|
+
});
|
|
399
|
+
export type EffectState = z.infer<typeof EffectStateShape>;
|
|
400
|
+
|
|
401
|
+
/** A refusal's body: the reason, and the replica when it names one. */
|
|
402
|
+
export const RefusalShape = z.strictObject({ error: z.string(), replica: ReplicaShape.optional() });
|
|
403
|
+
|
|
404
|
+
// --- Signatures ----------------------------------------------------------------------------------
|
|
405
|
+
|
|
406
|
+
export const HEADER_TIME = "x-jr2-time";
|
|
407
|
+
export const HEADER_NONCE = "x-jr2-nonce";
|
|
408
|
+
export const HEADER_SIGNATURE = "x-jr2-signature";
|
|
409
|
+
|
|
410
|
+
const digest = (body: string): string => createHash("sha256").update(body, "utf8").digest("hex");
|
|
411
|
+
const mac = (key: Buffer | string, parts: string[]): string =>
|
|
412
|
+
createHmac("sha256", key).update(parts.join("\n"), "utf8").digest("base64url");
|
|
413
|
+
|
|
414
|
+
/** A request's signature: over its method, the address it is sent to (the replica's `host:port`),
|
|
415
|
+
* its path, time, nonce and the sha-256 of its body. */
|
|
416
|
+
export function requestMac(
|
|
417
|
+
key: Buffer | string,
|
|
418
|
+
method: string,
|
|
419
|
+
address: string,
|
|
420
|
+
path: string,
|
|
421
|
+
time: string,
|
|
422
|
+
nonce: string,
|
|
423
|
+
body: string,
|
|
424
|
+
): string {
|
|
425
|
+
return mac(key, ["jr2-effector-request", method, address, path, time, nonce, digest(body)]);
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
/** An answer's signature: over its status, the request's method, path and nonce, and its body. */
|
|
429
|
+
export function responseMac(
|
|
430
|
+
key: Buffer | string,
|
|
431
|
+
status: number,
|
|
432
|
+
method: string,
|
|
433
|
+
path: string,
|
|
434
|
+
nonce: string,
|
|
435
|
+
body: string,
|
|
436
|
+
): string {
|
|
437
|
+
return mac(key, ["jr2-effector-response", String(status), method, path, nonce, digest(body)]);
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
// --- The transport -------------------------------------------------------------------------------
|
|
441
|
+
|
|
442
|
+
/** One raw exchange with one replica. */
|
|
443
|
+
export type RawAnswer = { status: number; headers: Record<string, string>; body: string };
|
|
444
|
+
|
|
445
|
+
/**
|
|
446
|
+
* How the client reaches a placement's replicas — the seam the unit tests inject (ADR-0010): the
|
|
447
|
+
* real one dials the headless Service's addresses; a test's answers in-process.
|
|
448
|
+
*/
|
|
449
|
+
export interface EffectorTransport {
|
|
450
|
+
/** Every replica's address now (`host:port`). */
|
|
451
|
+
replicas(signal?: AbortSignal): Promise<string[]>;
|
|
452
|
+
/** One request to one replica. Rejects on a transport failure; a status is an answer. */
|
|
453
|
+
send(
|
|
454
|
+
address: string,
|
|
455
|
+
method: "POST" | "GET" | "DELETE",
|
|
456
|
+
path: string,
|
|
457
|
+
headers: Record<string, string>,
|
|
458
|
+
body: string,
|
|
459
|
+
signal?: AbortSignal,
|
|
460
|
+
): Promise<RawAnswer>;
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
/** The request certainly never reached a replica (no address, the connection was refused, the name
|
|
464
|
+
* did not resolve), or the replica that got it refused to start it because it is stopping: sending
|
|
465
|
+
* it again starts it at most once. */
|
|
466
|
+
export class EffectorNotSent extends Error {}
|
|
467
|
+
|
|
468
|
+
/** The Effector refused the request as malformed (400): a jr2 or config defect, never retried. */
|
|
469
|
+
export class EffectorRefused extends Error {}
|
|
470
|
+
|
|
471
|
+
/** An answer that is not the Effector's: unsigned, or signed with another key. No retry changes the
|
|
472
|
+
* key it is checked with, so the Effect that gets one settles `unknown` (ADR-0068). */
|
|
473
|
+
export class EffectorForged extends Error {}
|
|
474
|
+
|
|
475
|
+
/** An answer the placement's key verifies that is not the wire's shape: a jr2 defect, never an
|
|
476
|
+
* outcome, never retried. */
|
|
477
|
+
export class EffectorMalformed extends Error {}
|
|
478
|
+
|
|
479
|
+
/** The Effector's wire, as an Effect drives it. */
|
|
480
|
+
export interface EffectorPort {
|
|
481
|
+
/** POST: start the Effect on some replica, or attach to it. Rejects with `EffectorNotSent` only
|
|
482
|
+
* when no replica can have started it; any other rejection may follow a start. */
|
|
483
|
+
start(id: string, req: StartRequest, signal: AbortSignal): Promise<EffectState>;
|
|
484
|
+
/** GET on the replica that holds it: settled, still running after the hold, or `unknown`. */
|
|
485
|
+
poll(replica: Replica, id: string, signal: AbortSignal): Promise<EffectState>;
|
|
486
|
+
/** Ask every replica: the state of the one that holds it, or undefined when every replica
|
|
487
|
+
* answered that it holds no such Effect. Rejects when a replica could not answer. */
|
|
488
|
+
find(id: string, signal: AbortSignal): Promise<EffectState | undefined>;
|
|
489
|
+
/** DELETE: stop a credentialed command whose state exited (ADR-0067), on the replica that holds
|
|
490
|
+
* it — its state once stopped, or undefined when no replica holds it. Sent once to each replica,
|
|
491
|
+
* signed like every request. A push and an http Effect have no stop. */
|
|
492
|
+
stop(id: string, signal: AbortSignal): Promise<EffectState | undefined>;
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
/** The signed client over a transport. `key` is the placement's (`effectorKey`). */
|
|
496
|
+
export function createEffectorClient(opts: {
|
|
497
|
+
key: Buffer | string;
|
|
498
|
+
transport: EffectorTransport;
|
|
499
|
+
now?: () => number;
|
|
500
|
+
}): EffectorPort {
|
|
501
|
+
const { key, transport } = opts;
|
|
502
|
+
const now = opts.now ?? Date.now;
|
|
503
|
+
|
|
504
|
+
const exchange = async (
|
|
505
|
+
address: string,
|
|
506
|
+
method: "POST" | "GET" | "DELETE",
|
|
507
|
+
id: string,
|
|
508
|
+
body: string,
|
|
509
|
+
signal: AbortSignal,
|
|
510
|
+
): Promise<RawAnswer> => {
|
|
511
|
+
const path = `/effects/${encodeURIComponent(id)}`;
|
|
512
|
+
const time = String(now());
|
|
513
|
+
const nonce = randomBytes(16).toString("hex");
|
|
514
|
+
const headers = {
|
|
515
|
+
"content-type": "application/json",
|
|
516
|
+
[HEADER_TIME]: time,
|
|
517
|
+
[HEADER_NONCE]: nonce,
|
|
518
|
+
// Signed for the replica it is sent to: another replica, or this one after a restart, refuses
|
|
519
|
+
// it sent again (ADR-0068).
|
|
520
|
+
[HEADER_SIGNATURE]: requestMac(key, method, address, path, time, nonce, body),
|
|
521
|
+
};
|
|
522
|
+
const answer = await transport.send(address, method, path, headers, body, signal);
|
|
523
|
+
const signature = answer.headers[HEADER_SIGNATURE] ?? "";
|
|
524
|
+
const want = responseMac(key, answer.status, method, path, nonce, answer.body);
|
|
525
|
+
if (!sameString(signature, want)) {
|
|
526
|
+
throw new EffectorForged(
|
|
527
|
+
`the answer to ${method} ${path} from ${address} (${answer.status}) does not carry the Effector's signature`,
|
|
528
|
+
);
|
|
529
|
+
}
|
|
530
|
+
return answer;
|
|
531
|
+
};
|
|
532
|
+
const state = (answer: RawAnswer, what: string): EffectState => {
|
|
533
|
+
const parsed = EffectStateShape.safeParse(parseJson(answer.body));
|
|
534
|
+
if (!parsed.success) throw new EffectorMalformed(`the Effector's answer to ${what} is not the wire's shape`);
|
|
535
|
+
return parsed.data;
|
|
536
|
+
};
|
|
537
|
+
const refusal = (answer: RawAnswer): string => {
|
|
538
|
+
const parsed = RefusalShape.safeParse(parseJson(answer.body));
|
|
539
|
+
return parsed.success ? parsed.data.error : `status ${answer.status}`;
|
|
540
|
+
};
|
|
541
|
+
|
|
542
|
+
const ask = async (address: string, id: string, signal: AbortSignal): Promise<EffectState> => {
|
|
543
|
+
const answer = await exchange(address, "GET", id, "", signal);
|
|
544
|
+
if (answer.status === 200 || answer.status === 404) return state(answer, "a poll");
|
|
545
|
+
throw new Error(`the Effector at ${address} answered a poll with ${answer.status}: ${refusal(answer)}`);
|
|
546
|
+
};
|
|
547
|
+
|
|
548
|
+
return {
|
|
549
|
+
start: async (id, req, signal) => {
|
|
550
|
+
const body = JSON.stringify(req);
|
|
551
|
+
const addresses = shuffle(await transport.replicas(signal).catch(notSent));
|
|
552
|
+
if (addresses.length === 0) throw new EffectorNotSent("no Effector replica is serving");
|
|
553
|
+
let last: unknown;
|
|
554
|
+
for (const address of addresses) {
|
|
555
|
+
let answer: RawAnswer;
|
|
556
|
+
try {
|
|
557
|
+
answer = await exchange(address, "POST", id, body, signal);
|
|
558
|
+
} catch (err) {
|
|
559
|
+
// A replica that was never reached is the one failure the next replica may follow.
|
|
560
|
+
if (err instanceof EffectorNotSent || (!signal.aborted && neverReached(err))) {
|
|
561
|
+
last = err;
|
|
562
|
+
continue;
|
|
563
|
+
}
|
|
564
|
+
throw err;
|
|
565
|
+
}
|
|
566
|
+
if (answer.status === 202 || answer.status === 200) return state(answer, "a start");
|
|
567
|
+
if (answer.status === 503) {
|
|
568
|
+
// A stopping replica started nothing: another may take it.
|
|
569
|
+
last = new EffectorNotSent(`the Effector replica at ${address} is stopping`);
|
|
570
|
+
continue;
|
|
571
|
+
}
|
|
572
|
+
if (answer.status === 400) throw new EffectorRefused(`the Effector refused the request: ${refusal(answer)}`);
|
|
573
|
+
throw new Error(`the Effector answered a start with ${answer.status}: ${refusal(answer)}`);
|
|
574
|
+
}
|
|
575
|
+
throw last instanceof EffectorNotSent
|
|
576
|
+
? last
|
|
577
|
+
: new EffectorNotSent(`no Effector replica took it: ${(last as Error | undefined)?.message ?? "none"}`);
|
|
578
|
+
},
|
|
579
|
+
poll: (replica, id, signal) => ask(replica.address, id, signal),
|
|
580
|
+
find: async (id, signal) => {
|
|
581
|
+
const addresses = await transport.replicas(signal);
|
|
582
|
+
const states = await Promise.all(addresses.map((address) => ask(address, id, signal)));
|
|
583
|
+
return states.find((s) => s.state !== "unknown");
|
|
584
|
+
},
|
|
585
|
+
stop: async (id, signal) => {
|
|
586
|
+
for (const address of await transport.replicas(signal)) {
|
|
587
|
+
const answer = await exchange(address, "DELETE", id, "", signal);
|
|
588
|
+
if (answer.status === 404) continue;
|
|
589
|
+
if (answer.status === 200) return state(answer, "a stop");
|
|
590
|
+
throw new Error(`the Effector at ${address} answered a stop with ${answer.status}: ${refusal(answer)}`);
|
|
591
|
+
}
|
|
592
|
+
return undefined;
|
|
593
|
+
},
|
|
594
|
+
};
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
/** A transport rejection that certainly never reached the replica becomes `EffectorNotSent`. */
|
|
598
|
+
function notSent(err: unknown): never {
|
|
599
|
+
throw new EffectorNotSent(`the Effector's replicas could not be listed: ${(err as Error)?.message ?? err}`, {
|
|
600
|
+
cause: err,
|
|
601
|
+
});
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
/** Errnos that mean no connection was ever made (the same reading as the Harness client's). */
|
|
605
|
+
const NEVER_REACHED_CODES = new Set(["ECONNREFUSED", "ENOTFOUND", "EAI_AGAIN", "EHOSTUNREACH", "ENETUNREACH"]);
|
|
606
|
+
|
|
607
|
+
export function neverReached(err: unknown, depth = 0): boolean {
|
|
608
|
+
if (depth > 4 || typeof err !== "object" || err === null) return false;
|
|
609
|
+
const { code, syscall, cause, errors } = err as {
|
|
610
|
+
code?: unknown;
|
|
611
|
+
syscall?: unknown;
|
|
612
|
+
cause?: unknown;
|
|
613
|
+
errors?: unknown;
|
|
614
|
+
};
|
|
615
|
+
if (syscall === "connect" || syscall === "getaddrinfo") return true;
|
|
616
|
+
if (typeof code === "string" && (code === "UND_ERR_CONNECT_TIMEOUT" || NEVER_REACHED_CODES.has(code))) return true;
|
|
617
|
+
if (Array.isArray(errors) && errors.some((nested) => neverReached(nested, depth + 1))) return true;
|
|
618
|
+
return neverReached(cause, depth + 1);
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
function parseJson(body: string): unknown {
|
|
622
|
+
try {
|
|
623
|
+
return JSON.parse(body);
|
|
624
|
+
} catch {
|
|
625
|
+
return undefined;
|
|
626
|
+
}
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
function sameString(a: string, b: string): boolean {
|
|
630
|
+
const left = Buffer.from(a);
|
|
631
|
+
const right = Buffer.from(b);
|
|
632
|
+
return left.length === right.length && timingSafeEqual(left, right);
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
function shuffle<T>(items: T[]): T[] {
|
|
636
|
+
const out = [...items];
|
|
637
|
+
for (let i = out.length - 1; i > 0; i--) {
|
|
638
|
+
const j = Math.floor(Math.random() * (i + 1));
|
|
639
|
+
[out[i], out[j]] = [out[j]!, out[i]!];
|
|
640
|
+
}
|
|
641
|
+
return out;
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
/**
|
|
645
|
+
* The real transport: the placement's headless Service, `host:port`. Its replicas are the
|
|
646
|
+
* addresses its name resolves to — every pod, a stopping one included, because the Service
|
|
647
|
+
* publishes addresses that are not ready (a stopping replica still answers for the Effects
|
|
648
|
+
* it finishes). A request goes to one pod's address over plain HTTP: the signature, not TLS, is
|
|
649
|
+
* what an answer is trusted by.
|
|
650
|
+
*/
|
|
651
|
+
export function nodeEffectorTransport(service: string, fetchImpl: typeof fetch = fetch): EffectorTransport {
|
|
652
|
+
const cut = service.lastIndexOf(":");
|
|
653
|
+
const host = service.slice(0, cut);
|
|
654
|
+
const port = service.slice(cut + 1);
|
|
655
|
+
return {
|
|
656
|
+
// `lookup` (getaddrinfo), not `resolve4`: it reads the pod's resolv.conf search list, so the
|
|
657
|
+
// namespace-relative name resolves as every other in-cluster address the Orchestrator dials.
|
|
658
|
+
replicas: async () => (await lookup(host, { all: true, family: 4 })).map((a) => `${a.address}:${port}`),
|
|
659
|
+
send: async (address, method, path, headers, body, signal) => {
|
|
660
|
+
const res = await fetchImpl(`http://${address}${path}`, {
|
|
661
|
+
method,
|
|
662
|
+
headers,
|
|
663
|
+
...(method === "POST" ? { body } : {}),
|
|
664
|
+
...(signal ? { signal } : {}),
|
|
665
|
+
});
|
|
666
|
+
const text = await res.text();
|
|
667
|
+
const out: Record<string, string> = {};
|
|
668
|
+
res.headers.forEach((value, name) => {
|
|
669
|
+
out[name.toLowerCase()] = value;
|
|
670
|
+
});
|
|
671
|
+
return { status: res.status, headers: out, body: text };
|
|
672
|
+
},
|
|
673
|
+
};
|
|
674
|
+
}
|