@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,1126 @@
|
|
|
1
|
+
// The Sandbox Effector's kinds (ADR-0067, ADR-0068): an `http` whose body is a Worktree file, a
|
|
2
|
+
// `push`, and a `command` that names a secret. Each is an actor slot; each runs in the Sandbox
|
|
3
|
+
// Effector of the Workspace whose body invokes it — a container beside the Agent in its Sandbox pod,
|
|
4
|
+
// with the Worktree mounted read-only, that holds the Effect secrets and the push credential the
|
|
5
|
+
// Agent never reads.
|
|
6
|
+
//
|
|
7
|
+
// publish: push({ sign: true }),
|
|
8
|
+
// report: http({ method: "PUT", url: "https://files.example.com/r", auth: bearer(secret("files")),
|
|
9
|
+
// input: z.object({ path: z.string() }), body: file(({ path }) => path) }),
|
|
10
|
+
// deploy: command(["terraform", "apply", "-auto-approve"], { env: { AWS_PROFILE_FILE: secret("aws").path } }),
|
|
11
|
+
//
|
|
12
|
+
// What this module adds to the Effector's wire (http-effect.ts has the http kind's):
|
|
13
|
+
//
|
|
14
|
+
// - THE PIN. A push and a credentialed command run against one commit: the Agent's branch tip
|
|
15
|
+
// when the Effect starts — which the Effector reads as data and its answer names — or the
|
|
16
|
+
// commit `pin` names (an approved one, from the Effect's input). It is written to the intent
|
|
17
|
+
// row, so a restore repeats a push of only that commit.
|
|
18
|
+
// - THE RESTORE RULE PER KIND. After an Orchestrator restart, or a start whose answer never came,
|
|
19
|
+
// the Effect asks its Effector for its id: found, it is followed. Not found: a push and a
|
|
20
|
+
// credentialed command are sent again with only the pinned commit the intent row holds — a run
|
|
21
|
+
// is a function of (commit, argv, secrets), never of the Agent's tip now — and with none
|
|
22
|
+
// recorded settle `unknown`; a file-body http is sent again only when marked `idempotent`.
|
|
23
|
+
// - THE PAIRS AND THE SYNC. A push sends every push of its Workspace and Repo Slot that published
|
|
24
|
+
// (each one's published commit, source and `squashed`), oldest first, as `pairs`: the input of
|
|
25
|
+
// the Effector's `pair` fallback and of its map before signing.
|
|
26
|
+
// After every push whose published commit is not the Agent's own — signed copies, or one
|
|
27
|
+
// squashed commit, signed or not — the Orchestrator relays the bundle of the published commits
|
|
28
|
+
// to the Workspace's Harness, which moves the Agent's branch to them (`POST /sync`); its
|
|
29
|
+
// answer rides the output as `sync`, a
|
|
30
|
+
// field beside the push's outcome. When the newest push's sync did not finish, it is run again
|
|
31
|
+
// before the next push. The push never trusts it.
|
|
32
|
+
// - D1. When its state exits, the Orchestrator stops polling and records the abort, "outcome
|
|
33
|
+
// unknown". A credentialed command is stopped: one signed DELETE on its own path, and the
|
|
34
|
+
// Effector ends its process group (SIGTERM, then SIGKILL past a grace); a stop that reaches it
|
|
35
|
+
// before the command's start is kept there, and that start runs nothing. A push and a file-body
|
|
36
|
+
// http are one request each and have no stop: the Effector runs them to their end. A park is
|
|
37
|
+
// not an exit, and stops nothing.
|
|
38
|
+
|
|
39
|
+
import { randomUUID } from "node:crypto";
|
|
40
|
+
import { posix } from "node:path";
|
|
41
|
+
import type { ActorLogic, AnyActorRef, EventObject } from "xstate";
|
|
42
|
+
import type { z } from "zod";
|
|
43
|
+
import { ambientHandlesFor, type AmbientHandles } from "./ambient.ts";
|
|
44
|
+
import type {
|
|
45
|
+
Arg,
|
|
46
|
+
CommandParts,
|
|
47
|
+
Duration,
|
|
48
|
+
EffectLogic,
|
|
49
|
+
EffectMarker,
|
|
50
|
+
EffectRow,
|
|
51
|
+
EffectSnapshot,
|
|
52
|
+
HttpParts,
|
|
53
|
+
PushParts,
|
|
54
|
+
Unknown,
|
|
55
|
+
} from "./effect.ts";
|
|
56
|
+
import { lastPushOf, needsASlot, pushPairsOf } from "./effect.ts";
|
|
57
|
+
import {
|
|
58
|
+
createEffectorClient,
|
|
59
|
+
EffectorForged,
|
|
60
|
+
EffectorMalformed,
|
|
61
|
+
EffectorNotSent,
|
|
62
|
+
EffectorRefused,
|
|
63
|
+
MAX_START_BYTES,
|
|
64
|
+
nodeEffectorTransport,
|
|
65
|
+
type CommandStartRequest,
|
|
66
|
+
type CommandWireOutput,
|
|
67
|
+
type EffectorPort,
|
|
68
|
+
type EffectState,
|
|
69
|
+
type HttpOutput,
|
|
70
|
+
type HttpStartRequest,
|
|
71
|
+
type Pair,
|
|
72
|
+
type PushStartRequest,
|
|
73
|
+
type PushWireOutput,
|
|
74
|
+
type RepoRef,
|
|
75
|
+
type StartRequest,
|
|
76
|
+
} from "./effector-wire.ts";
|
|
77
|
+
import {
|
|
78
|
+
httpParts,
|
|
79
|
+
openHttpOf,
|
|
80
|
+
OPEN_SECRET,
|
|
81
|
+
openSecretsIn,
|
|
82
|
+
secret,
|
|
83
|
+
startRequestOf,
|
|
84
|
+
type EffectorPortFactory,
|
|
85
|
+
type FromInput,
|
|
86
|
+
type HttpDefinition,
|
|
87
|
+
type HttpOpenParts,
|
|
88
|
+
} from "./http-effect.ts";
|
|
89
|
+
import { open } from "./open.ts";
|
|
90
|
+
import type { OpenOf } from "./open-effect.ts";
|
|
91
|
+
import { boundRun, type AnyActorSystem } from "./registration.ts";
|
|
92
|
+
import { repoKey } from "./repo-identity.ts";
|
|
93
|
+
import { harnessSyncPort, type SyncPortFactory } from "./sync-client.ts";
|
|
94
|
+
import type { SyncAnswer } from "./wire.ts";
|
|
95
|
+
|
|
96
|
+
/** The work root a Sandbox pod's Worktrees sit under (ADR-0004), as both containers mount it. */
|
|
97
|
+
const WORK_ROOT = "/work";
|
|
98
|
+
|
|
99
|
+
const DEFAULT_PUSH_TIMEOUT_MS = 5 * 60_000;
|
|
100
|
+
const DEFAULT_COMMAND_TIMEOUT_MS = 10 * 60_000;
|
|
101
|
+
const DEFAULT_TAIL = 4096;
|
|
102
|
+
const RETRY_MAX_MS = 10_000;
|
|
103
|
+
|
|
104
|
+
/** The real port factory: the Sandbox Effector's address, signed with its per-pod key. */
|
|
105
|
+
export function sandboxEffectorPort(target: Parameters<EffectorPortFactory>[0]): ReturnType<EffectorPortFactory> {
|
|
106
|
+
return createEffectorClient({ key: target.key, transport: nodeEffectorTransport(target.service) });
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// --- Naming a secret's file -----------------------------------------------------------------------
|
|
110
|
+
|
|
111
|
+
/** `secret("x").path` in a command's env: the path of the secret's file in the Sandbox Effector's
|
|
112
|
+
* own mount, never its value (ADR-0067). */
|
|
113
|
+
export type SecretPath = { readonly secret: string; readonly path: true };
|
|
114
|
+
|
|
115
|
+
// --- Where a Repo Slot's repositories are in the pod ----------------------------------------------
|
|
116
|
+
|
|
117
|
+
/** The Repo Slot an Effect runs on: the one named, or the Workspace's only one. */
|
|
118
|
+
function slotOf(kind: string, slot: string, named: string | undefined, ambient: AmbientHandles): string {
|
|
119
|
+
const slots = Object.keys(ambient.repos);
|
|
120
|
+
if (named !== undefined) {
|
|
121
|
+
if (ambient.repos[named] === undefined) {
|
|
122
|
+
throw new Error(
|
|
123
|
+
`${kind} "${slot}" names Repo Slot "${named}", which its Workspace does not carry (${slots.join(", ") || "none"})`,
|
|
124
|
+
);
|
|
125
|
+
}
|
|
126
|
+
return named;
|
|
127
|
+
}
|
|
128
|
+
if (slots.length === 1) return slots[0]!;
|
|
129
|
+
const why = slots.length === 0 ? needsASlot(kind) : undefined;
|
|
130
|
+
if (why !== undefined)
|
|
131
|
+
throw new Error(`${kind} "${slot}" runs under a Workspace with no Repo Slot: ${why} (ADR-0051)`);
|
|
132
|
+
throw new Error(
|
|
133
|
+
`${kind} "${slot}" runs under a Workspace carrying ${slots.length === 0 ? "no" : "more than one"} Repo Slot ` +
|
|
134
|
+
`(${slots.join(", ") || "none"}) — say which: { slot: "<slot>" } (ADR-0051)`,
|
|
135
|
+
);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** Where a file body's path is read from, relative to the work root: the Repo Slot's Worktree, or
|
|
139
|
+
* the work root itself (`.`) in a Workspace with no Repo Slot, whose steps share `/work` (ADR-0051).
|
|
140
|
+
* The Effector opens the file beneath it either way (ADR-0067). */
|
|
141
|
+
function fileRootOf(slot: string, named: string | undefined, ambient: AmbientHandles): string {
|
|
142
|
+
if (named === undefined && Object.keys(ambient.repos).length === 0) return ".";
|
|
143
|
+
return posix.relative(WORK_ROOT, ambient.repos[slotOf("http", slot, named, ambient)]!);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** The branch of a Workspace with Repo Slots. A kind that reads it resolved a slot first, and a
|
|
147
|
+
* Workspace with a slot always has a branch, so its absence is a jr2 defect. */
|
|
148
|
+
function branchOf(ambient: AmbientHandles): string {
|
|
149
|
+
if (ambient.branch === undefined) throw new Error("a Workspace with a Repo Slot has no branch — a jr2 defect");
|
|
150
|
+
return ambient.branch;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** The slot's repositories, relative to the work root: the Agent's common git dir is the pod-local
|
|
154
|
+
* clone's, beside the branch Worktree (ADR-0004); the cache is the Binding's key (ADR-0051). */
|
|
155
|
+
export function repoRefOf(ambient: AmbientHandles, repoSlot: string): RepoRef & { url: string } {
|
|
156
|
+
const worktree = ambient.repos[repoSlot]!;
|
|
157
|
+
const url = ambient.bindings?.[repoSlot];
|
|
158
|
+
if (url === undefined) {
|
|
159
|
+
throw new Error(`Repo Slot "${repoSlot}" has no Binding url in its Workspace's handles — a jr2 defect`);
|
|
160
|
+
}
|
|
161
|
+
const rel = posix.relative(WORK_ROOT, worktree);
|
|
162
|
+
return {
|
|
163
|
+
gitDir: posix.join(posix.dirname(rel), "default", ".git"),
|
|
164
|
+
worktree: rel,
|
|
165
|
+
cache: repoKey(url),
|
|
166
|
+
url,
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// --- The definitions -------------------------------------------------------------------------------
|
|
171
|
+
|
|
172
|
+
/** The options of `push()`. */
|
|
173
|
+
export type PushOptions = {
|
|
174
|
+
// A pinned commit the Agent pruned is refused by the remote, never substituted (ADR-0067).
|
|
175
|
+
/**
|
|
176
|
+
* `true`: the input of the Effect names the commit to push (`{ sha }`, a full commit id, for
|
|
177
|
+
* example one that a Gate approved). Default: the tip of the Agent's branch when the Effect
|
|
178
|
+
* starts. When the Agent removed that commit from its branch, the remote refuses the push. jr2
|
|
179
|
+
* never pushes another commit in its place.
|
|
180
|
+
*
|
|
181
|
+
* When it starts, it refuses a `sha` that is not a full commit id: `push({ pin: true }): its
|
|
182
|
+
* input's sha is not a full commit id (got <sha>)`.
|
|
183
|
+
*/
|
|
184
|
+
pin?: boolean;
|
|
185
|
+
/**
|
|
186
|
+
* `true`: push signed copies of the commits. The `sign` field of the `git.credentials` entry that
|
|
187
|
+
* the Binding matches names the key. Default `false`.
|
|
188
|
+
*
|
|
189
|
+
* At `jr2 up`, it refuses an entry with no `sign` field: `<push> signed, and git.credentials
|
|
190
|
+
* entry "<match>" holds no sign field — add sign: { sshKey: "<a Secret with identity>", name:
|
|
191
|
+
* "<committer name>", email: "<committer email>" } to it; the email must belong to the forge
|
|
192
|
+
* account that holds the key (ADR-0051, ADR-0067)`. `<push>` names the push: `workflow
|
|
193
|
+
* "<workflow>": push "<slot>" pushes Repo Slot "<name>" (<url>)`.
|
|
194
|
+
*/
|
|
195
|
+
sign?: boolean;
|
|
196
|
+
/**
|
|
197
|
+
* `true`: push one commit that holds all the changes. The input of the Effect gives its message
|
|
198
|
+
* (`{ message }`). Signed, its committer is the signer. Unsigned, it is the `committer` of the
|
|
199
|
+
* `git.credentials` entry that the Binding matches. Default `false`.
|
|
200
|
+
*
|
|
201
|
+
* When it starts, it refuses an input with no message: `push({ squash: true }): its input
|
|
202
|
+
* carries no message`.
|
|
203
|
+
*
|
|
204
|
+
* At `jr2 up`, it refuses an unsigned squash whose entry has no `sign` and no `committer`:
|
|
205
|
+
* `<push> squashed without sign, and git.credentials entry "<match>" holds neither sign nor
|
|
206
|
+
* committer — the squashed commit is new and needs a committer; add committer: { name:
|
|
207
|
+
* "<committer name>", email: "<committer email>" } to it, or sign the push — push({ squash:
|
|
208
|
+
* true, sign: true }) — and add sign: { sshKey: "<a Secret with identity>", name: "<committer
|
|
209
|
+
* name>", email: "<committer email>" } to it (ADR-0051, ADR-0067)`.
|
|
210
|
+
*/
|
|
211
|
+
squash?: boolean;
|
|
212
|
+
/** `true`: replace what the remote branch holds. Default `false`. */
|
|
213
|
+
force?: boolean;
|
|
214
|
+
/**
|
|
215
|
+
* The Repo Slot to push. Write it only when the Workspace has more than one.
|
|
216
|
+
*
|
|
217
|
+
* When it starts, it refuses:
|
|
218
|
+
* - no `slot` under a Workspace with more than one Repo Slot: `push "<slot>" runs under a
|
|
219
|
+
* Workspace carrying more than one Repo Slot (<slots>) — say which: { slot: "<slot>" }
|
|
220
|
+
* (ADR-0051)`
|
|
221
|
+
* - a `slot` that the Workspace does not have: `push "<slot>" names Repo Slot "<name>", which
|
|
222
|
+
* its Workspace does not carry (<slots>)`
|
|
223
|
+
* - a Workspace with no Repo Slot: `push "<slot>" runs under a Workspace with no Repo Slot: a
|
|
224
|
+
* push publishes a Repo Slot's branch, and there is none — declare one: repos: { app: "<url>" }
|
|
225
|
+
* (ADR-0051)`. `jr2 up` refuses it first, with the same reason.
|
|
226
|
+
*/
|
|
227
|
+
slot?: string;
|
|
228
|
+
/**
|
|
229
|
+
* How long jr2 waits for the push: a number and a unit, such as `"90s"` or `"5m"`. Default
|
|
230
|
+
* `"5m"`. With no answer in that time, the output is `unknown`. It refuses a value that is not a
|
|
231
|
+
* positive duration: `timeout "<timeout>" is not a positive duration (like "30s", "5m")`.
|
|
232
|
+
*/
|
|
233
|
+
timeout?: Duration;
|
|
234
|
+
};
|
|
235
|
+
|
|
236
|
+
/** The input of a `push` Effect: `{ sha }` with `pin`, and `{ message }` with `squash`. */
|
|
237
|
+
export type PushInput<O extends PushOptions> = (O["pin"] extends true ? { sha: string } : unknown) &
|
|
238
|
+
(O["squash"] extends true ? { message: string } : unknown);
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* The sync's answer as a push's output carries it: a field beside the outcome, never the outcome.
|
|
242
|
+
*
|
|
243
|
+
* @internal
|
|
244
|
+
*/
|
|
245
|
+
export type SyncState = SyncAnswer["sync"];
|
|
246
|
+
|
|
247
|
+
// The sync's bundle never reaches the Machine: `deliver` drops it from the output (ADR-0067).
|
|
248
|
+
/**
|
|
249
|
+
* The output of a `push` Effect: what the `onDone` of its state gets as `event.output`. Every
|
|
250
|
+
* outcome is output, so route on `outcome`:
|
|
251
|
+
*
|
|
252
|
+
* - `pushed`: the remote has the commit. `published` is the commit on the remote, and `source` is
|
|
253
|
+
* the Agent's commit that it comes from. They are the same commit, unless the push was signed or
|
|
254
|
+
* squashed. `previous` is what the remote had before, and `unchanged` is `true` when the remote
|
|
255
|
+
* had the commit already. `squashed` is `signed` or `unsigned` when the commit on the remote is
|
|
256
|
+
* one squashed commit. `via` tells how jr2 found the new commits after a signed push:
|
|
257
|
+
* `ancestry`, `pair`, `cherry` or `force`.
|
|
258
|
+
* - `rejected`: git or the remote refused the push. `stage` tells where, `reason` tells why, and
|
|
259
|
+
* `detail` has the words of git.
|
|
260
|
+
* - `diverged`: the commit on the remote is not in the history of the Agent's branch, and jr2
|
|
261
|
+
* could not pair it with a commit there. Nothing was pushed. `reason` tells why, and `published`
|
|
262
|
+
* and `source` name the two commits.
|
|
263
|
+
* - `unknown`: jr2 cannot tell what the push did. `reason` tells why.
|
|
264
|
+
*
|
|
265
|
+
* Beside `pushed`, `sync` tells what jr2 did to the Agent's branch after the push:
|
|
266
|
+
*
|
|
267
|
+
* - `synced`: the commit on the remote is not the Agent's own, and jr2 moved the branch to the
|
|
268
|
+
* published commits.
|
|
269
|
+
* - `already`: the branch has the published commit already.
|
|
270
|
+
* - `skipped`: the remote has the Agent's own commit, so the branch does not move.
|
|
271
|
+
* - `busy`: a rebase, merge, cherry-pick or revert of the branch is in progress. Nothing changed.
|
|
272
|
+
* - `not-synced`: the branch did not move.
|
|
273
|
+
*
|
|
274
|
+
* With `busy` or `not-synced`, `syncReason` tells why, and jr2 tries again before the next push.
|
|
275
|
+
*
|
|
276
|
+
* @example
|
|
277
|
+
* ```ts
|
|
278
|
+
* import { fail, jr2Setup, push, type PushOutput, workspace, type Workspaced } from "@jr2/orchestrator";
|
|
279
|
+
*
|
|
280
|
+
* type Input = Workspaced<{ sha: string }, "app">;
|
|
281
|
+
*
|
|
282
|
+
* // A push that did not happen (`rejected` or `diverged`) is a different problem from one jr2
|
|
283
|
+
* // cannot see (`unknown`).
|
|
284
|
+
* const notPushed = (output: PushOutput): boolean =>
|
|
285
|
+
* output.outcome === "rejected" || output.outcome === "diverged";
|
|
286
|
+
*
|
|
287
|
+
* const body = jr2Setup({
|
|
288
|
+
* types: {} as { context: Input; input: Input },
|
|
289
|
+
* actors: { publish: push({ pin: true }), fail: fail() },
|
|
290
|
+
* }).createMachine({
|
|
291
|
+
* id: "release",
|
|
292
|
+
* context: ({ input }) => input,
|
|
293
|
+
* on: { "workspace.lost": ".lost" },
|
|
294
|
+
* initial: "publishing",
|
|
295
|
+
* states: {
|
|
296
|
+
* publishing: {
|
|
297
|
+
* invoke: {
|
|
298
|
+
* src: "publish",
|
|
299
|
+
* input: ({ context }) => ({ sha: context.sha }),
|
|
300
|
+
* onDone: [
|
|
301
|
+
* { guard: ({ event }) => event.output.outcome === "pushed", target: "published" },
|
|
302
|
+
* { guard: ({ event }) => notPushed(event.output), target: "notPushed" },
|
|
303
|
+
* { target: "unknown" },
|
|
304
|
+
* ],
|
|
305
|
+
* },
|
|
306
|
+
* },
|
|
307
|
+
* published: { type: "final" },
|
|
308
|
+
* // Not final states: each one fails the run, with its own reason.
|
|
309
|
+
* notPushed: { invoke: { src: "fail", input: { reason: "the remote refused the push" } } },
|
|
310
|
+
* unknown: { invoke: { src: "fail", input: { reason: "jr2 cannot tell if the push happened" } } },
|
|
311
|
+
* lost: { invoke: { src: "fail", input: { reason: "the Sandbox is gone" } } },
|
|
312
|
+
* },
|
|
313
|
+
* });
|
|
314
|
+
*
|
|
315
|
+
* export const machine = workspace(body, {
|
|
316
|
+
* repos: { app: { url: "https://github.com/you/app.git" } },
|
|
317
|
+
* spec: () => ({ branch: "release" }),
|
|
318
|
+
* });
|
|
319
|
+
* ```
|
|
320
|
+
*
|
|
321
|
+
* @see docs/guides/push-and-sign-a-branch.md
|
|
322
|
+
* @see docs/concepts/effects.md
|
|
323
|
+
* @see docs/reference/decisions/0067-an-effect-is-an-actor-slot-and-runs-at-most-once-unless-marked-idempotent.md
|
|
324
|
+
*/
|
|
325
|
+
export type PushOutput =
|
|
326
|
+
| Exclude<PushWireOutput, { outcome: "pushed" }>
|
|
327
|
+
| (Omit<Extract<PushWireOutput, { outcome: "pushed" }>, "bundle"> & { sync: SyncState; syncReason?: string });
|
|
328
|
+
|
|
329
|
+
export function pushParts(options: PushOptions = {}): PushParts {
|
|
330
|
+
const inputKeys = [...(options.pin ? ["sha"] : []), ...(options.squash ? ["message"] : [])];
|
|
331
|
+
return {
|
|
332
|
+
kind: "push",
|
|
333
|
+
pin: options.pin === true,
|
|
334
|
+
sign: options.sign === true,
|
|
335
|
+
squash: options.squash === true,
|
|
336
|
+
force: options.force === true,
|
|
337
|
+
timeoutMs: options.timeout === undefined ? DEFAULT_PUSH_TIMEOUT_MS : parseDuration(options.timeout),
|
|
338
|
+
secrets: [],
|
|
339
|
+
inputKeys,
|
|
340
|
+
...(options.slot !== undefined ? { slot: options.slot } : {}),
|
|
341
|
+
};
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
/** The push's start for one run: the Binding, the branch, the pin, the ledger's last pair. */
|
|
345
|
+
export function pushStartOf(
|
|
346
|
+
parts: PushParts,
|
|
347
|
+
ref: RepoRef & { url: string },
|
|
348
|
+
branch: string,
|
|
349
|
+
input: { sha?: string; message?: string } | undefined,
|
|
350
|
+
pairs?: readonly Pair[],
|
|
351
|
+
): PushStartRequest {
|
|
352
|
+
const pin = parts.pin ? input?.sha : undefined;
|
|
353
|
+
if (parts.pin && (typeof pin !== "string" || !/^[0-9a-f]{40}$/.test(pin))) {
|
|
354
|
+
throw new Error(`push({ pin: true }): its input's sha is not a full commit id (got ${JSON.stringify(pin)})`);
|
|
355
|
+
}
|
|
356
|
+
const message = parts.squash ? input?.message : undefined;
|
|
357
|
+
if (parts.squash && (typeof message !== "string" || message.trim() === "")) {
|
|
358
|
+
throw new Error("push({ squash: true }): its input carries no message");
|
|
359
|
+
}
|
|
360
|
+
return {
|
|
361
|
+
kind: "push",
|
|
362
|
+
push: {
|
|
363
|
+
repo: { gitDir: ref.gitDir, worktree: ref.worktree, cache: ref.cache },
|
|
364
|
+
url: ref.url,
|
|
365
|
+
branch,
|
|
366
|
+
...(pin ? { pin } : {}),
|
|
367
|
+
...(parts.sign ? { sign: true } : {}),
|
|
368
|
+
...(message ? { squash: message } : {}),
|
|
369
|
+
...(parts.force ? { force: true } : {}),
|
|
370
|
+
...(pairs?.length ? { pairs: [...pairs] } : {}),
|
|
371
|
+
},
|
|
372
|
+
timeoutMs: parts.timeoutMs,
|
|
373
|
+
};
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
/** A file-body http's start for one run (ADR-0067): the definition's parts, the url's segments, and
|
|
377
|
+
* the Worktree file as the body. */
|
|
378
|
+
export function fileStartOf(
|
|
379
|
+
parts: HttpParts,
|
|
380
|
+
worktree: string,
|
|
381
|
+
run: { path: string; segments?: readonly string[] },
|
|
382
|
+
): HttpStartRequest {
|
|
383
|
+
if (typeof run?.path !== "string") throw new Error("http: `file()` gave no path");
|
|
384
|
+
if (run.segments !== undefined && !run.segments.every((s) => typeof s === "string")) {
|
|
385
|
+
throw new Error("http: `file()`'s segments are not a list of strings");
|
|
386
|
+
}
|
|
387
|
+
const http = startRequestOf(parts, run.segments ? { path: run.segments } : {});
|
|
388
|
+
const contentType = parts.file?.contentType;
|
|
389
|
+
return { ...http, body: { file: { worktree, path: run.path, ...(contentType ? { contentType } : {}) } } };
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
/** The `env` of a `command`: for each variable, `secret("name")` for the value of an Effect
|
|
393
|
+
* secret, or `secret("name").path` for the path of a file that holds it. */
|
|
394
|
+
export type CommandEnv = Readonly<Record<string, { readonly secret: string; readonly path: unknown }>>;
|
|
395
|
+
|
|
396
|
+
export function commandEnvParts(env: CommandEnv): Pick<CommandParts, "env" | "secrets" | "openSecret"> {
|
|
397
|
+
const out: Record<string, { secret: string; path?: true }> = {};
|
|
398
|
+
for (const [name, ref] of Object.entries(env)) {
|
|
399
|
+
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(name) || name === "PATH" || name === "HOME" || name === "TMPDIR") {
|
|
400
|
+
throw new Error(
|
|
401
|
+
`command(argv, { env }): "${name}" is not a variable the author sets — PATH, HOME and TMPDIR are the ` +
|
|
402
|
+
"Effector's (ADR-0067)",
|
|
403
|
+
);
|
|
404
|
+
}
|
|
405
|
+
// `secret(open)` and its `.path` stand under OPEN_SECRET until the composer names it (ADR-0054).
|
|
406
|
+
const s = ref.secret === OPEN_SECRET ? ref : secret(ref.secret);
|
|
407
|
+
out[name] = (ref as { path?: unknown }).path === true ? { secret: s.secret, path: true } : { secret: s.secret };
|
|
408
|
+
}
|
|
409
|
+
const opened = openSecretsIn(Object.values(env));
|
|
410
|
+
const secrets = [...new Set(Object.values(out).map((r) => r.secret))].filter((n) => n !== OPEN_SECRET).sort();
|
|
411
|
+
return { env: out, secrets, ...(opened ? { openSecret: true as const } : {}) };
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
/** The credentialed command's start for one run. */
|
|
415
|
+
export function commandStartOf(
|
|
416
|
+
parts: CommandParts,
|
|
417
|
+
argv: string[],
|
|
418
|
+
ref: RepoRef,
|
|
419
|
+
branch: string,
|
|
420
|
+
pin?: string,
|
|
421
|
+
): CommandStartRequest {
|
|
422
|
+
return {
|
|
423
|
+
kind: "command",
|
|
424
|
+
command: {
|
|
425
|
+
repo: { gitDir: ref.gitDir, worktree: ref.worktree, cache: ref.cache },
|
|
426
|
+
branch,
|
|
427
|
+
...(pin ? { pin } : {}),
|
|
428
|
+
argv,
|
|
429
|
+
env: Object.fromEntries(
|
|
430
|
+
Object.entries(parts.env ?? {}).map(([k, v]) => [
|
|
431
|
+
k,
|
|
432
|
+
v.path ? { secret: v.secret, path: true } : { secret: v.secret },
|
|
433
|
+
]),
|
|
434
|
+
),
|
|
435
|
+
tail: parts.tail,
|
|
436
|
+
},
|
|
437
|
+
timeoutMs: parts.timeoutMs,
|
|
438
|
+
};
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
// --- The actor logic -------------------------------------------------------------------------------
|
|
442
|
+
|
|
443
|
+
type Settle = { type: "jr2.effect.settle"; output: unknown };
|
|
444
|
+
type Fault = { type: "jr2.effect.error"; error: unknown };
|
|
445
|
+
|
|
446
|
+
/** What a kind's hooks get: the run's ledger, the Workspace, and the call's signal. */
|
|
447
|
+
type KindContext = {
|
|
448
|
+
ambient: AmbientHandles;
|
|
449
|
+
binding: ReturnType<typeof boundRun>;
|
|
450
|
+
id: string;
|
|
451
|
+
slot: string;
|
|
452
|
+
signal: AbortSignal;
|
|
453
|
+
mark: (marker: Omit<EffectMarker, "kind" | "slot" | "effect">) => void;
|
|
454
|
+
};
|
|
455
|
+
|
|
456
|
+
/** How one Sandbox kind builds its start and what a restore may send again. */
|
|
457
|
+
type KindSpec = {
|
|
458
|
+
kind: "http" | "push" | "command";
|
|
459
|
+
idempotent: boolean;
|
|
460
|
+
/** A restore that finds no Effect sends it again with only the pinned commit. */
|
|
461
|
+
repeatsPinned: boolean;
|
|
462
|
+
label: (ambient: AmbientHandles) => string;
|
|
463
|
+
/** The Repo Slot the Effect runs on, written to its settled row. */
|
|
464
|
+
repoOf: (ambient: AmbientHandles, slot: string) => string;
|
|
465
|
+
/** The start for this run; `pinned` is the commit the intent row recorded. */
|
|
466
|
+
build: (input: unknown, ambient: AmbientHandles, slot: string, pinned?: string, extra?: StartExtra) => StartRequest;
|
|
467
|
+
/** Run before a start is sent (a push: the last sync again, and the ledger's pair). */
|
|
468
|
+
before?: (ctx: KindContext) => Promise<StartExtra>;
|
|
469
|
+
/** Run on the Effector's settled output before it is ledgered (a push: the sync). */
|
|
470
|
+
finish?: (output: unknown, ctx: KindContext) => Promise<unknown>;
|
|
471
|
+
/** What a settled output becomes for the Machine. */
|
|
472
|
+
deliver?: (output: unknown) => unknown;
|
|
473
|
+
};
|
|
474
|
+
|
|
475
|
+
type StartExtra = { pairs?: readonly Pair[] };
|
|
476
|
+
|
|
477
|
+
/** An Effect's run: its abort, and — for a credentialed command, once its Effector is known — the
|
|
478
|
+
* port its stop goes through (ADR-0067). */
|
|
479
|
+
type Running = { controller: AbortController; port?: EffectorPort };
|
|
480
|
+
const running = new WeakMap<AnyActorRef, Running>();
|
|
481
|
+
|
|
482
|
+
/** How long a stop's DELETE may take: the Effector answers once the command's group is gone,
|
|
483
|
+
* SIGTERM then SIGKILL past its grace, within its poll's hold. */
|
|
484
|
+
const STOP_WAIT_MS = 60_000;
|
|
485
|
+
|
|
486
|
+
function sandboxLogic<I, O>(
|
|
487
|
+
portFactory: EffectorPortFactory,
|
|
488
|
+
parts: HttpParts | PushParts | CommandParts,
|
|
489
|
+
spec: KindSpec,
|
|
490
|
+
): EffectLogic<I, O> {
|
|
491
|
+
const logic: ActorLogic<EffectSnapshot<O, I>, EventObject, I> = {
|
|
492
|
+
getInitialSnapshot: (_scope, input) => ({
|
|
493
|
+
status: "active",
|
|
494
|
+
output: undefined,
|
|
495
|
+
error: undefined,
|
|
496
|
+
input,
|
|
497
|
+
effectId: randomUUID(),
|
|
498
|
+
}),
|
|
499
|
+
transition: (snapshot, event, { self, system }) => {
|
|
500
|
+
if (snapshot.status !== "active") return snapshot;
|
|
501
|
+
if (event.type === "jr2.effect.settle") {
|
|
502
|
+
return { ...snapshot, status: "done", output: (event as Settle).output as O, input: undefined };
|
|
503
|
+
}
|
|
504
|
+
if (event.type === "jr2.effect.error") {
|
|
505
|
+
return { ...snapshot, status: "error", error: (event as Fault).error, input: undefined };
|
|
506
|
+
}
|
|
507
|
+
if (event.type === "xstate.stop") {
|
|
508
|
+
stopped(self, system, snapshot.effectId);
|
|
509
|
+
return { ...snapshot, status: "stopped", input: undefined };
|
|
510
|
+
}
|
|
511
|
+
return snapshot;
|
|
512
|
+
},
|
|
513
|
+
start: (snapshot, { self, system }) => {
|
|
514
|
+
if (snapshot.status !== "active") return;
|
|
515
|
+
const controller = new AbortController();
|
|
516
|
+
running.set(self, { controller });
|
|
517
|
+
void drive(self, system, snapshot, spec, portFactory, controller.signal).catch((error: unknown) => {
|
|
518
|
+
if (controller.signal.aborted) return;
|
|
519
|
+
if (self.getSnapshot().status === "active") self.send({ type: "jr2.effect.error", error } as Fault);
|
|
520
|
+
});
|
|
521
|
+
},
|
|
522
|
+
getPersistedSnapshot: (snapshot) => snapshot,
|
|
523
|
+
};
|
|
524
|
+
return Object.assign(logic, { effect: parts });
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
const slotName = (self: AnyActorRef): string => (typeof self.src === "string" ? self.src : self.id);
|
|
528
|
+
|
|
529
|
+
async function drive<I, O>(
|
|
530
|
+
self: AnyActorRef,
|
|
531
|
+
system: AnyActorSystem,
|
|
532
|
+
snapshot: EffectSnapshot<O, I>,
|
|
533
|
+
spec: KindSpec,
|
|
534
|
+
portFactory: EffectorPortFactory,
|
|
535
|
+
signal: AbortSignal,
|
|
536
|
+
): Promise<void> {
|
|
537
|
+
const binding = boundRun(system);
|
|
538
|
+
const id = snapshot.effectId;
|
|
539
|
+
const slot = slotName(self);
|
|
540
|
+
const ambient = ambientHandlesFor(self);
|
|
541
|
+
const now = (): string => new Date().toISOString();
|
|
542
|
+
const mark = (marker: Omit<EffectMarker, "kind" | "slot" | "effect">): void =>
|
|
543
|
+
binding?.marker?.({ kind: "effect", slot, effect: id, ...marker });
|
|
544
|
+
const settle = (output: unknown): void => {
|
|
545
|
+
if (signal.aborted || self.getSnapshot().status !== "active") return;
|
|
546
|
+
binding?.recordEffect?.(id, {
|
|
547
|
+
state: "settled",
|
|
548
|
+
kind: spec.kind,
|
|
549
|
+
slot,
|
|
550
|
+
...(ambient ? { sandbox: ambient.sandbox, ...repoField(spec, ambient, slot) } : {}),
|
|
551
|
+
output,
|
|
552
|
+
at: now(),
|
|
553
|
+
});
|
|
554
|
+
mark({ stage: "settled", output: output as HttpOutput });
|
|
555
|
+
self.send({ type: "jr2.effect.settle", output: spec.deliver ? spec.deliver(output) : output } satisfies Settle);
|
|
556
|
+
};
|
|
557
|
+
const unknown = (reason: string): Unknown => ({ outcome: "unknown", reason });
|
|
558
|
+
|
|
559
|
+
const row = binding?.effectRow?.(id);
|
|
560
|
+
if (row?.state === "settled") {
|
|
561
|
+
await Promise.resolve();
|
|
562
|
+
if (!signal.aborted && self.getSnapshot().status === "active") {
|
|
563
|
+
self.send({
|
|
564
|
+
type: "jr2.effect.settle",
|
|
565
|
+
output: spec.deliver ? spec.deliver(row.output) : row.output,
|
|
566
|
+
} satisfies Settle);
|
|
567
|
+
}
|
|
568
|
+
return;
|
|
569
|
+
}
|
|
570
|
+
if (row?.state === "aborted") {
|
|
571
|
+
settle(unknown("its state exited while it ran; not run again"));
|
|
572
|
+
return;
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
if (!ambient) {
|
|
576
|
+
throw new Error(
|
|
577
|
+
`${spec.kind} "${slot}" runs in the Sandbox Effector of a Workspace, and its state is not inside a ` +
|
|
578
|
+
"workspace() — invoke it from a Workspace's body (ADR-0067)",
|
|
579
|
+
);
|
|
580
|
+
}
|
|
581
|
+
const target = binding?.effector?.({ sandbox: ambient.sandbox });
|
|
582
|
+
if (!target) {
|
|
583
|
+
throw new Error(
|
|
584
|
+
`${spec.kind} "${slot}" runs in the Sandbox Effector of Sandbox "${ambient.sandbox}", and this Orchestrator ` +
|
|
585
|
+
"knows no address for it (ADR-0067)",
|
|
586
|
+
);
|
|
587
|
+
}
|
|
588
|
+
const port = portFactory(target);
|
|
589
|
+
// A credentialed command's stop goes through this port when its state exits (ADR-0067).
|
|
590
|
+
const run = running.get(self);
|
|
591
|
+
if (spec.kind === "command" && run) run.port = port;
|
|
592
|
+
const ctx: KindContext = { ambient, binding, id, slot, signal, mark };
|
|
593
|
+
|
|
594
|
+
const find = async (): Promise<{ found: EffectState | undefined } | undefined> => {
|
|
595
|
+
try {
|
|
596
|
+
return { found: await retrying(() => port.find(id, signal), signal) };
|
|
597
|
+
} catch (err) {
|
|
598
|
+
if (signal.aborted || !(err instanceof EffectorForged)) throw err;
|
|
599
|
+
settle(unknown(`${err.message}: the answer is not the Sandbox Effector's; not run again`));
|
|
600
|
+
return undefined;
|
|
601
|
+
}
|
|
602
|
+
};
|
|
603
|
+
const intent = (pinned?: string): EffectRow => ({
|
|
604
|
+
state: "intent",
|
|
605
|
+
kind: spec.kind,
|
|
606
|
+
slot,
|
|
607
|
+
placement: "sandbox",
|
|
608
|
+
sandbox: ambient.sandbox,
|
|
609
|
+
idempotent: spec.idempotent,
|
|
610
|
+
...(pinned ? { pinned } : {}),
|
|
611
|
+
at: now(),
|
|
612
|
+
});
|
|
613
|
+
/** Not found where it was: the restore rule of the kind. */
|
|
614
|
+
const notFound = async (pinned: string | undefined, why: string): Promise<void> => {
|
|
615
|
+
if (spec.repeatsPinned && pinned) {
|
|
616
|
+
mark({
|
|
617
|
+
stage: "started",
|
|
618
|
+
text: `${spec.label(ambient)} (sent again: only the pinned commit ${pinned.slice(0, 12)})`,
|
|
619
|
+
});
|
|
620
|
+
const extra = spec.before ? await spec.before(ctx) : {};
|
|
621
|
+
if (signal.aborted) return;
|
|
622
|
+
await send(spec.build(snapshot.input, ambient, slot, pinned, extra), pinned);
|
|
623
|
+
return;
|
|
624
|
+
}
|
|
625
|
+
if (spec.kind === "http" && spec.idempotent) {
|
|
626
|
+
mark({ stage: "started", text: `${spec.label(ambient)} (sent again: it is idempotent)` });
|
|
627
|
+
await send(spec.build(snapshot.input, ambient, slot), undefined);
|
|
628
|
+
return;
|
|
629
|
+
}
|
|
630
|
+
settle(unknown(`${why}; not run again`));
|
|
631
|
+
};
|
|
632
|
+
|
|
633
|
+
if (row?.state === "intent") {
|
|
634
|
+
const pinned = "pinned" in row ? row.pinned : undefined;
|
|
635
|
+
const asked = await find();
|
|
636
|
+
if (!asked || signal.aborted) return;
|
|
637
|
+
if (asked.found) {
|
|
638
|
+
await follow(asked.found, pinned);
|
|
639
|
+
return;
|
|
640
|
+
}
|
|
641
|
+
await notFound(
|
|
642
|
+
pinned,
|
|
643
|
+
"it was in flight when the Orchestrator restarted, and its Sandbox Effector no longer holds it",
|
|
644
|
+
);
|
|
645
|
+
return;
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
const extra = spec.before ? await spec.before(ctx) : {};
|
|
649
|
+
if (signal.aborted) return;
|
|
650
|
+
const req = spec.build(snapshot.input, ambient, slot, undefined, extra);
|
|
651
|
+
const big = tooLarge(req);
|
|
652
|
+
if (big) {
|
|
653
|
+
settle(big);
|
|
654
|
+
return;
|
|
655
|
+
}
|
|
656
|
+
binding?.recordEffect?.(id, intent());
|
|
657
|
+
mark({ stage: "started", text: spec.label(ambient) });
|
|
658
|
+
await send(req, undefined);
|
|
659
|
+
|
|
660
|
+
async function send(req: StartRequest, pinned: string | undefined): Promise<void> {
|
|
661
|
+
for (let wait = 250; ; wait = Math.min(wait * 2, RETRY_MAX_MS)) {
|
|
662
|
+
let state: EffectState;
|
|
663
|
+
try {
|
|
664
|
+
state = await port.start(id, req, signal);
|
|
665
|
+
} catch (err) {
|
|
666
|
+
if (signal.aborted) return;
|
|
667
|
+
if (err instanceof EffectorRefused || err instanceof EffectorMalformed) throw err;
|
|
668
|
+
if (!(err instanceof EffectorNotSent)) {
|
|
669
|
+
const asked = await find();
|
|
670
|
+
if (!asked || signal.aborted) return;
|
|
671
|
+
if (asked.found) {
|
|
672
|
+
await follow(asked.found, pinned);
|
|
673
|
+
return;
|
|
674
|
+
}
|
|
675
|
+
await notFound(pinned, "its start may have reached the Sandbox Effector, which does not hold it now");
|
|
676
|
+
return;
|
|
677
|
+
}
|
|
678
|
+
await sleep(wait, signal);
|
|
679
|
+
if (signal.aborted) return;
|
|
680
|
+
continue;
|
|
681
|
+
}
|
|
682
|
+
await follow(state, pinned);
|
|
683
|
+
return;
|
|
684
|
+
}
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
async function follow(first: EffectState, pinned: string | undefined): Promise<void> {
|
|
688
|
+
let state = first;
|
|
689
|
+
// The commit the Effector pinned goes to the intent row, saved at the write: a restore repeats
|
|
690
|
+
// only it.
|
|
691
|
+
if (state.pinned && state.pinned !== pinned) {
|
|
692
|
+
pinned = state.pinned;
|
|
693
|
+
binding?.recordEffect?.(id, intent(pinned));
|
|
694
|
+
}
|
|
695
|
+
for (;;) {
|
|
696
|
+
if (state.state === "settled" && state.output) {
|
|
697
|
+
const output = spec.finish ? await spec.finish(state.output, ctx) : state.output;
|
|
698
|
+
if (signal.aborted) return;
|
|
699
|
+
settle(output);
|
|
700
|
+
return;
|
|
701
|
+
}
|
|
702
|
+
if (state.state === "unknown") {
|
|
703
|
+
const asked = await find();
|
|
704
|
+
if (!asked || signal.aborted) return;
|
|
705
|
+
if (!asked.found) {
|
|
706
|
+
await notFound(pinned, "its Sandbox Effector no longer holds it (it restarted)");
|
|
707
|
+
return;
|
|
708
|
+
}
|
|
709
|
+
state = asked.found;
|
|
710
|
+
continue;
|
|
711
|
+
}
|
|
712
|
+
try {
|
|
713
|
+
state = await port.poll(state.replica, id, signal);
|
|
714
|
+
} catch (err) {
|
|
715
|
+
if (signal.aborted) return;
|
|
716
|
+
if (err instanceof EffectorRefused || err instanceof EffectorMalformed) throw err;
|
|
717
|
+
state = { effect: id, replica: state.replica, state: "unknown" };
|
|
718
|
+
}
|
|
719
|
+
if (signal.aborted) return;
|
|
720
|
+
}
|
|
721
|
+
}
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
/** The settled row's Repo Slot, or none when the Workspace does not name one the kind can run on.
|
|
725
|
+
*/
|
|
726
|
+
function repoField(spec: KindSpec, ambient: AmbientHandles, slot: string): { repo?: string } {
|
|
727
|
+
try {
|
|
728
|
+
return { repo: spec.repoOf(ambient, slot) };
|
|
729
|
+
} catch {
|
|
730
|
+
return {};
|
|
731
|
+
}
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
function stopped(self: AnyActorRef, system: AnyActorSystem, effectId: string): void {
|
|
735
|
+
const run = running.get(self);
|
|
736
|
+
run?.controller.abort();
|
|
737
|
+
running.delete(self);
|
|
738
|
+
const binding = boundRun(system);
|
|
739
|
+
if (binding?.hostStopping) return;
|
|
740
|
+
const row = binding?.effectRow?.(effectId);
|
|
741
|
+
if (row?.state !== "intent" || !("sandbox" in row)) return;
|
|
742
|
+
// A credentialed command is stopped where it runs: one signed DELETE on its own path, and its
|
|
743
|
+
// Sandbox Effector ends its process group (ADR-0067). A push and a file-body http are one request
|
|
744
|
+
// each, and have no stop.
|
|
745
|
+
// What the stop's answer says goes to the feed after the abort's line: the outcome the Effector
|
|
746
|
+
// settled it with (`exited` with the signal), or that the stop did not reach it. It is sent at once,
|
|
747
|
+
// while a start may still be on the wire: the Effector keeps a stop of an id it does not hold, and
|
|
748
|
+
// the start that arrives after it runs nothing.
|
|
749
|
+
const port = row.kind === "command" ? run?.port : undefined;
|
|
750
|
+
port?.stop(effectId, AbortSignal.timeout(STOP_WAIT_MS)).then(
|
|
751
|
+
(state) => {
|
|
752
|
+
binding?.marker?.({
|
|
753
|
+
kind: "effect",
|
|
754
|
+
slot: row.slot,
|
|
755
|
+
effect: effectId,
|
|
756
|
+
stage: "aborted",
|
|
757
|
+
text: stoppedText(state),
|
|
758
|
+
});
|
|
759
|
+
},
|
|
760
|
+
(err: unknown) => {
|
|
761
|
+
binding?.marker?.({
|
|
762
|
+
kind: "effect",
|
|
763
|
+
slot: row.slot,
|
|
764
|
+
effect: effectId,
|
|
765
|
+
stage: "aborted",
|
|
766
|
+
text: `the stop did not reach its Sandbox Effector (${err instanceof Error ? err.message : String(err)}); it may run until its timeout`,
|
|
767
|
+
});
|
|
768
|
+
},
|
|
769
|
+
);
|
|
770
|
+
binding?.recordEffect?.(
|
|
771
|
+
effectId,
|
|
772
|
+
{ state: "aborted", kind: row.kind, slot: row.slot, at: new Date().toISOString() },
|
|
773
|
+
"with-transition",
|
|
774
|
+
);
|
|
775
|
+
binding?.marker?.({
|
|
776
|
+
kind: "effect",
|
|
777
|
+
slot: row.slot,
|
|
778
|
+
effect: effectId,
|
|
779
|
+
stage: "aborted",
|
|
780
|
+
text: port
|
|
781
|
+
? "its state exited while it ran: its Sandbox Effector was asked to stop it; outcome unknown"
|
|
782
|
+
: "its state exited while it ran: no longer followed; the Sandbox Effector may still run it; outcome unknown",
|
|
783
|
+
});
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
/** The feed's line for a stop's answer (ADR-0067): the outcome a stopped command settled with —
|
|
787
|
+
* `exited` with the signal the stop sent last, and its code when the program caught SIGTERM; for
|
|
788
|
+
* one stopped before its program started, `unknown` with the Effector's reason (it did not run);
|
|
789
|
+
* for one that had ended first, that outcome. When no replica holds it, the start may not have
|
|
790
|
+
* arrived yet: the Effector keeps the stop, and the line says that a start still on the way runs
|
|
791
|
+
* nothing; one not settled within the stop's answer is said as such. */
|
|
792
|
+
function stoppedText(state: EffectState | undefined): string {
|
|
793
|
+
if (state === undefined) {
|
|
794
|
+
return "its Sandbox Effector held no such Effect when asked to stop it; a start still on the way runs nothing";
|
|
795
|
+
}
|
|
796
|
+
const out = state.state === "settled" ? state.output : undefined;
|
|
797
|
+
if (!out) return "its Sandbox Effector had not settled it when the stop answered; outcome unknown";
|
|
798
|
+
if (out.outcome === "exited" && "signal" in out) {
|
|
799
|
+
return `its Sandbox Effector stopped it: exited with ${out.signal}${out.code === undefined ? "" : `, code ${out.code}`}`;
|
|
800
|
+
}
|
|
801
|
+
if (out.outcome === "unknown" && "reason" in out) return `its Sandbox Effector stopped it: ${out.reason}`;
|
|
802
|
+
return `its Sandbox Effector stopped it: it had already ended (${out.outcome})`;
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
/** A start over the bytes an Effector reads is sent nowhere, and settles as its kind's own outcome
|
|
806
|
+
* (ADR-0067): an http `refused`; a push `rejected` at stage `start` — nothing pushed; a
|
|
807
|
+
* credentialed command `exited` 127 with the reason in its tail, the Effector's own form for a
|
|
808
|
+
* command that could not start. An Agent's pick parameter — a squash message, an argv part — can
|
|
809
|
+
* reach the bound. */
|
|
810
|
+
function tooLarge(req: StartRequest): HttpOutput | PushWireOutput | CommandWireOutput | undefined {
|
|
811
|
+
const bytes = Buffer.byteLength(JSON.stringify(req), "utf8");
|
|
812
|
+
if (bytes <= MAX_START_BYTES) return undefined;
|
|
813
|
+
const reason = `the request is ${bytes} bytes, over the ${MAX_START_BYTES} bytes an Effector reads`;
|
|
814
|
+
if (req.kind === "push") return { outcome: "rejected", stage: "start", reason: `${reason}; nothing was pushed` };
|
|
815
|
+
if (req.kind === "command") return { outcome: "exited", code: 127, tail: `${reason}; nothing was run\n` };
|
|
816
|
+
return { outcome: "refused", reason: `${reason}; nothing was sent` };
|
|
817
|
+
}
|
|
818
|
+
|
|
819
|
+
async function retrying<T>(call: () => Promise<T>, signal: AbortSignal): Promise<T> {
|
|
820
|
+
for (let wait = 250; ; wait = Math.min(wait * 2, RETRY_MAX_MS)) {
|
|
821
|
+
try {
|
|
822
|
+
return await call();
|
|
823
|
+
} catch (err) {
|
|
824
|
+
if (
|
|
825
|
+
signal.aborted ||
|
|
826
|
+
err instanceof EffectorRefused ||
|
|
827
|
+
err instanceof EffectorMalformed ||
|
|
828
|
+
err instanceof EffectorForged
|
|
829
|
+
) {
|
|
830
|
+
throw err;
|
|
831
|
+
}
|
|
832
|
+
await sleep(wait, signal);
|
|
833
|
+
if (signal.aborted) throw err;
|
|
834
|
+
}
|
|
835
|
+
}
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
function sleep(ms: number, signal: AbortSignal): Promise<void> {
|
|
839
|
+
return new Promise((resolve) => {
|
|
840
|
+
const timer = setTimeout(resolve, ms / 2 + Math.random() * (ms / 2));
|
|
841
|
+
signal.addEventListener("abort", () => {
|
|
842
|
+
clearTimeout(timer);
|
|
843
|
+
resolve();
|
|
844
|
+
});
|
|
845
|
+
});
|
|
846
|
+
}
|
|
847
|
+
|
|
848
|
+
function parseDuration(d: Duration): number {
|
|
849
|
+
const n = Number.parseFloat(d);
|
|
850
|
+
if (!(n > 0)) throw new Error(`timeout "${d}" is not a positive duration (like "30s", "5m")`);
|
|
851
|
+
return d.endsWith("h") ? n * 3_600_000 : d.endsWith("m") ? n * 60_000 : n * 1000;
|
|
852
|
+
}
|
|
853
|
+
|
|
854
|
+
// --- The constructors, over an injected port factory (the unit tests' seam) ----------------------
|
|
855
|
+
|
|
856
|
+
export function pushWith<const O extends PushOptions>(
|
|
857
|
+
portFactory: EffectorPortFactory,
|
|
858
|
+
options?: O,
|
|
859
|
+
syncPorts: SyncPortFactory = harnessSyncPort,
|
|
860
|
+
): EffectLogic<PushInput<O> | void, PushOutput> {
|
|
861
|
+
const parts = pushParts(options);
|
|
862
|
+
const repoOf = (ambient: AmbientHandles, slot: string) => slotOf("push", slot, parts.slot, ambient);
|
|
863
|
+
const worktreeOf = (ctx: KindContext) => ctx.ambient.repos[repoOf(ctx.ambient, ctx.slot)]!;
|
|
864
|
+
const syncOf = (ctx: KindContext) =>
|
|
865
|
+
syncPorts(ctx.ambient.endpoint, ctx.binding?.harnessBearer?.(ctx.ambient.sandbox));
|
|
866
|
+
return sandboxLogic(portFactory, parts, {
|
|
867
|
+
kind: "push",
|
|
868
|
+
idempotent: false,
|
|
869
|
+
repeatsPinned: true,
|
|
870
|
+
label: (ambient) => `push ${ambient.branch}${parts.sign ? " (signed)" : ""}`,
|
|
871
|
+
repoOf,
|
|
872
|
+
build: (input, ambient, slot, pinned, extra) => {
|
|
873
|
+
const repoSlot = slotOf("push", slot, parts.slot, ambient);
|
|
874
|
+
const ref = repoRefOf(ambient, repoSlot);
|
|
875
|
+
const req = pushStartOf(parts, ref, branchOf(ambient), input as { sha?: string; message?: string }, extra?.pairs);
|
|
876
|
+
if (pinned) req.push.pin = pinned;
|
|
877
|
+
return req;
|
|
878
|
+
},
|
|
879
|
+
// Every push of this Workspace's Repo Slot — one branch, one remote — that published gives the
|
|
880
|
+
// Effector a pair, oldest first (ADR-0067). The newest one's sync, when it did not finish, runs
|
|
881
|
+
// again first, in that slot's Worktree and from the bundle the row keeps while it is the newest
|
|
882
|
+
// push and its sync did not finish (`dropSavedTails`); the push does not depend on it.
|
|
883
|
+
before: async (ctx) => {
|
|
884
|
+
const rows = ctx.binding?.effectRows?.() ?? {};
|
|
885
|
+
const repo = repoOf(ctx.ambient, ctx.slot);
|
|
886
|
+
const last = lastPushOf(rows, ctx.ambient.sandbox, repo, ctx.id);
|
|
887
|
+
if (last === undefined) return {};
|
|
888
|
+
const { row, rowId, output } = last;
|
|
889
|
+
if (output.sync === "busy" || output.sync === "not-synced") {
|
|
890
|
+
const answer = await syncOf(ctx).sync(
|
|
891
|
+
{
|
|
892
|
+
worktree: worktreeOf(ctx),
|
|
893
|
+
branch: branchOf(ctx.ambient),
|
|
894
|
+
published: output.published,
|
|
895
|
+
source: output.source,
|
|
896
|
+
...(typeof output.bundle === "string" ? { bundle: output.bundle } : {}),
|
|
897
|
+
},
|
|
898
|
+
ctx.signal,
|
|
899
|
+
);
|
|
900
|
+
if (ctx.signal.aborted) return {};
|
|
901
|
+
ctx.mark({ stage: "output", text: `the last push's sync, run again before this push: ${answer.sync}` });
|
|
902
|
+
ctx.binding?.recordEffect?.(rowId, { ...row, output: { ...output, ...syncFields(answer) } });
|
|
903
|
+
}
|
|
904
|
+
// Each row's `squashed` rides beside its pair: a push that maps the same commit names what it is.
|
|
905
|
+
return { pairs: pushPairsOf(rows, ctx.ambient.sandbox, repo, ctx.id) };
|
|
906
|
+
},
|
|
907
|
+
// After every pushed outcome whose published commit is not the Agent's own — signed copies, or
|
|
908
|
+
// one squashed commit, signed or not (ADR-0067): the sync, in the Workspace's Harness, from the
|
|
909
|
+
// bundle the Effector wrote, a push that published nothing new (a pair or cherry that mapped
|
|
910
|
+
// the published tip) included. The decision keys on published != source, never on `sign`. A
|
|
911
|
+
// push of the Agent's own commit is `skipped`, nothing to send.
|
|
912
|
+
finish: async (output, ctx) => {
|
|
913
|
+
const o = output as PushWireOutput;
|
|
914
|
+
if (o.outcome !== "pushed") return o;
|
|
915
|
+
if (o.published === o.source) return { ...o, sync: "skipped" satisfies SyncState };
|
|
916
|
+
const answer = await syncOf(ctx).sync(
|
|
917
|
+
{
|
|
918
|
+
worktree: worktreeOf(ctx),
|
|
919
|
+
branch: branchOf(ctx.ambient),
|
|
920
|
+
published: o.published,
|
|
921
|
+
source: o.source,
|
|
922
|
+
...(o.bundle !== undefined ? { bundle: o.bundle } : {}),
|
|
923
|
+
},
|
|
924
|
+
ctx.signal,
|
|
925
|
+
);
|
|
926
|
+
return { ...o, ...syncFields(answer) };
|
|
927
|
+
},
|
|
928
|
+
deliver: (output) => {
|
|
929
|
+
const o = output as PushWireOutput;
|
|
930
|
+
if (o.outcome !== "pushed" || o.bundle === undefined) return o;
|
|
931
|
+
const { bundle: _bundle, ...rest } = o;
|
|
932
|
+
return rest;
|
|
933
|
+
},
|
|
934
|
+
}) as EffectLogic<PushInput<O> | void, PushOutput>;
|
|
935
|
+
}
|
|
936
|
+
|
|
937
|
+
/** The sync's answer as the output's fields: its state, and its reason when it moved nothing. */
|
|
938
|
+
function syncFields(answer: SyncAnswer): { sync: SyncState; syncReason?: string } {
|
|
939
|
+
return {
|
|
940
|
+
sync: answer.sync,
|
|
941
|
+
...("reason" in answer && answer.sync !== "skipped" ? { syncReason: answer.reason } : {}),
|
|
942
|
+
};
|
|
943
|
+
}
|
|
944
|
+
|
|
945
|
+
export { lastPushOf };
|
|
946
|
+
|
|
947
|
+
/** An http whose body is a Worktree file (ADR-0067), over an injected port factory — the seam
|
|
948
|
+
* `http()` and the unit tests share. It runs in the Sandbox Effector of the Workspace whose body
|
|
949
|
+
* invokes it. */
|
|
950
|
+
export function httpFileWith<
|
|
951
|
+
S extends z.ZodType | undefined = undefined,
|
|
952
|
+
const U extends HttpDefinition<S>["url"] = string,
|
|
953
|
+
const A extends HttpDefinition<S>["auth"] = undefined,
|
|
954
|
+
const H extends HttpDefinition<S>["headers"] = undefined,
|
|
955
|
+
>(
|
|
956
|
+
portFactory: EffectorPortFactory,
|
|
957
|
+
def: HttpDefinition<S> & { url: U; auth?: A; headers?: H },
|
|
958
|
+
): EffectLogic<S extends z.ZodType ? z.infer<S> : void, HttpOutput> & OpenOf<HttpOpenParts<U, A, H>>;
|
|
959
|
+
export function httpFileWith<S extends z.ZodType | undefined = undefined>(
|
|
960
|
+
portFactory: EffectorPortFactory,
|
|
961
|
+
def: HttpDefinition<S>,
|
|
962
|
+
): EffectLogic<S extends z.ZodType ? z.infer<S> : void, HttpOutput> {
|
|
963
|
+
// Open parts (ADR-0054), as http's own: the unbound logic binds by building this Effect again,
|
|
964
|
+
// whole, with the composer's url or secret — a file body changes where it runs, not how it binds.
|
|
965
|
+
const unbound = openHttpOf(def, (bound) => httpFileWith(portFactory, bound));
|
|
966
|
+
if (unbound) return unbound as EffectLogic<S extends z.ZodType ? z.infer<S> : void, HttpOutput>;
|
|
967
|
+
const parts = httpParts(def);
|
|
968
|
+
const body = def.body!.file;
|
|
969
|
+
const fromInput = <T>(v: FromInput<S, T> | undefined, input: unknown): T | undefined =>
|
|
970
|
+
typeof v === "function" ? (v as (i: unknown) => T)(input) : v;
|
|
971
|
+
return sandboxLogic(portFactory, parts, {
|
|
972
|
+
kind: "http",
|
|
973
|
+
idempotent: parts.idempotent,
|
|
974
|
+
repeatsPinned: false,
|
|
975
|
+
// With no Repo Slot the file is in `/work`, which is no Worktree (ADR-0051).
|
|
976
|
+
label: (ambient) =>
|
|
977
|
+
`http ${parts.method} ${parts.target.scheme}://${parts.target.host} ` +
|
|
978
|
+
(Object.keys(ambient.repos).length === 0 && body.slot === undefined ? "(a file of /work)" : "(a Worktree file)"),
|
|
979
|
+
repoOf: (ambient, slot) => slotOf("http", slot, body.slot, ambient),
|
|
980
|
+
build: (input, ambient, slot) => {
|
|
981
|
+
let value: unknown = input;
|
|
982
|
+
if (def.input !== undefined) {
|
|
983
|
+
const parsed = def.input.safeParse(input);
|
|
984
|
+
if (!parsed.success) throw new Error(`http "${slot}": its input does not match the definition's input schema`);
|
|
985
|
+
value = parsed.data;
|
|
986
|
+
}
|
|
987
|
+
const worktree = fileRootOf(slot, body.slot, ambient);
|
|
988
|
+
const segments = fromInput<readonly string[]>(body.segments, value);
|
|
989
|
+
return fileStartOf(parts, worktree, {
|
|
990
|
+
path: fromInput<string>(body.path, value)!,
|
|
991
|
+
...(segments !== undefined ? { segments } : {}),
|
|
992
|
+
});
|
|
993
|
+
},
|
|
994
|
+
});
|
|
995
|
+
}
|
|
996
|
+
|
|
997
|
+
/** A command whose env names a secret: it runs in the Sandbox Effector against the pinned commit.
|
|
998
|
+
*/
|
|
999
|
+
export function credentialedCommandWith(
|
|
1000
|
+
portFactory: EffectorPortFactory,
|
|
1001
|
+
parts: CommandParts,
|
|
1002
|
+
argvOf: (input: unknown, slot: string) => string[],
|
|
1003
|
+
pin = false,
|
|
1004
|
+
): EffectLogic<unknown, CommandWireOutput> {
|
|
1005
|
+
return sandboxLogic(portFactory, parts, {
|
|
1006
|
+
kind: "command",
|
|
1007
|
+
idempotent: false,
|
|
1008
|
+
repeatsPinned: true,
|
|
1009
|
+
label: () => parts.argv.map((a) => (typeof a === "string" ? a : `<${(a as Arg).arg}>`)).join(" "),
|
|
1010
|
+
repoOf: (ambient, slot) => slotOf("command", slot, parts.slot, ambient),
|
|
1011
|
+
build: (input, ambient, slot, pinned) => {
|
|
1012
|
+
const repoSlot = slotOf("command", slot, parts.slot, ambient);
|
|
1013
|
+
const ref = repoRefOf(ambient, repoSlot);
|
|
1014
|
+
const approved = pin ? (input as { sha?: unknown } | undefined)?.sha : undefined;
|
|
1015
|
+
if (pin && (typeof approved !== "string" || !/^[0-9a-f]{40}$/.test(approved))) {
|
|
1016
|
+
throw new Error(`command "${slot}" (pin): its input's sha is not a full commit id`);
|
|
1017
|
+
}
|
|
1018
|
+
return commandStartOf(
|
|
1019
|
+
parts,
|
|
1020
|
+
argvOf(input, slot),
|
|
1021
|
+
ref,
|
|
1022
|
+
branchOf(ambient),
|
|
1023
|
+
pinned ?? (approved as string | undefined),
|
|
1024
|
+
);
|
|
1025
|
+
},
|
|
1026
|
+
});
|
|
1027
|
+
}
|
|
1028
|
+
|
|
1029
|
+
/**
|
|
1030
|
+
* Make a `push` Effect: it pushes the branch of the Workspace to the Binding of its Repo Slot.
|
|
1031
|
+
* Put it in `actors`, and invoke it from the body of a `workspace()`: `actors: { publish: push({
|
|
1032
|
+
* sign: true }) }`.
|
|
1033
|
+
*
|
|
1034
|
+
* It runs in the Sandbox Effector of the Workspace, with the push credential of the
|
|
1035
|
+
* `git.credentials` entry that the Binding matches. The Agent never reads that credential. It
|
|
1036
|
+
* pushes one commit: the tip of the Agent's branch when the Effect starts, or, with `pin`, the
|
|
1037
|
+
* commit that the input names. After an Orchestrator restart, jr2 pushes only that same commit
|
|
1038
|
+
* again. With `sign` or `squash`, the commit on the remote is not the Agent's own. Then jr2 moves
|
|
1039
|
+
* the Agent's branch to it.
|
|
1040
|
+
*
|
|
1041
|
+
* Every outcome is output (`PushOutput`): `pushed`, `rejected`, `diverged` or `unknown`. Each
|
|
1042
|
+
* field of {@link PushOptions} tells what it refuses. When it starts, it also refuses a push
|
|
1043
|
+
* outside a Workspace: `push "<slot>" runs in the Sandbox Effector of a Workspace, and its state is
|
|
1044
|
+
* not inside a workspace() — invoke it from a Workspace's body (ADR-0067)`.
|
|
1045
|
+
*
|
|
1046
|
+
* @param options - Optional. Each field tells what it does.
|
|
1047
|
+
* @returns An Effect, for `actors`.
|
|
1048
|
+
*
|
|
1049
|
+
* @example
|
|
1050
|
+
* ```ts
|
|
1051
|
+
* import { z } from "zod";
|
|
1052
|
+
* import {
|
|
1053
|
+
* agent,
|
|
1054
|
+
* defineEvent,
|
|
1055
|
+
* fail,
|
|
1056
|
+
* jr2Setup,
|
|
1057
|
+
* push,
|
|
1058
|
+
* workspace,
|
|
1059
|
+
* type Workspaced,
|
|
1060
|
+
* } from "@jr2/orchestrator";
|
|
1061
|
+
*
|
|
1062
|
+
* const committed = defineEvent({
|
|
1063
|
+
* name: "committed",
|
|
1064
|
+
* description: "Say that the fix is committed.",
|
|
1065
|
+
* input: z.object({}),
|
|
1066
|
+
* });
|
|
1067
|
+
*
|
|
1068
|
+
* type Input = Workspaced<{}, "app">;
|
|
1069
|
+
*
|
|
1070
|
+
* const body = jr2Setup({
|
|
1071
|
+
* types: {} as { context: Input; input: Input },
|
|
1072
|
+
* events: [committed],
|
|
1073
|
+
* actors: {
|
|
1074
|
+
* coder: agent({
|
|
1075
|
+
* model: "anthropic/claude-haiku-5-5",
|
|
1076
|
+
* instructions: "You are a careful engineer.",
|
|
1077
|
+
* }),
|
|
1078
|
+
* publish: push({ sign: true }),
|
|
1079
|
+
* fail: fail(),
|
|
1080
|
+
* },
|
|
1081
|
+
* }).createMachine({
|
|
1082
|
+
* id: "fix-test",
|
|
1083
|
+
* context: ({ input }) => input,
|
|
1084
|
+
* on: { "workspace.lost": ".failed", "agent.fault": ".failed" },
|
|
1085
|
+
* initial: "coding",
|
|
1086
|
+
* states: {
|
|
1087
|
+
* coding: {
|
|
1088
|
+
* invoke: { src: "coder", input: { prompt: "Fix the failing test, and commit the fix." } },
|
|
1089
|
+
* // The Agent moves the Machine on with a pick. An Agent's actor never ends, so `onDone`
|
|
1090
|
+
* // never fires.
|
|
1091
|
+
* on: { committed: "publishing" },
|
|
1092
|
+
* },
|
|
1093
|
+
* publishing: {
|
|
1094
|
+
* invoke: {
|
|
1095
|
+
* src: "publish",
|
|
1096
|
+
* onDone: [
|
|
1097
|
+
* { guard: ({ event }) => event.output.outcome === "pushed", target: "done" },
|
|
1098
|
+
* { target: "failed" },
|
|
1099
|
+
* ],
|
|
1100
|
+
* },
|
|
1101
|
+
* },
|
|
1102
|
+
* done: { type: "final" },
|
|
1103
|
+
* // Not a final state: the run ends `failed`, and the Workspace deletes the Sandbox.
|
|
1104
|
+
* failed: { invoke: { src: "fail", input: { reason: "the fix was not pushed" } } },
|
|
1105
|
+
* },
|
|
1106
|
+
* });
|
|
1107
|
+
*
|
|
1108
|
+
* export const machine = workspace(body, {
|
|
1109
|
+
* repos: { app: { url: "https://github.com/you/app.git" } },
|
|
1110
|
+
* spec: () => ({ branch: "fix-test" }),
|
|
1111
|
+
* });
|
|
1112
|
+
* ```
|
|
1113
|
+
*
|
|
1114
|
+
* @see docs/guides/push-and-sign-a-branch.md
|
|
1115
|
+
* @see docs/concepts/effects.md
|
|
1116
|
+
* @see docs/guides/give-the-cluster-git-credentials.md
|
|
1117
|
+
* @see docs/reference/decisions/0067-an-effect-is-an-actor-slot-and-runs-at-most-once-unless-marked-idempotent.md
|
|
1118
|
+
* @see docs/reference/decisions/0051-a-repo-is-a-slot-on-the-workspace-and-a-cache-on-the-node.md
|
|
1119
|
+
*/
|
|
1120
|
+
export function push<const O extends PushOptions>(options?: O): EffectLogic<PushInput<O> | void, PushOutput> {
|
|
1121
|
+
// After an Orchestrator restart, a push is sent again with only the commit its intent row holds
|
|
1122
|
+
// (ADR-0067): a run is a function of the commit, never of the Agent's tip now.
|
|
1123
|
+
return pushWith(sandboxEffectorPort, options);
|
|
1124
|
+
}
|
|
1125
|
+
|
|
1126
|
+
export { DEFAULT_COMMAND_TIMEOUT_MS, DEFAULT_TAIL };
|