@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
package/src/workspace.ts
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
|
-
// `workspace(body, { input, image, user, repos, spec })` (ADR-0012, ADR-0049, ADR-0051
|
|
2
|
-
// jr2-owned wrapper Machine that owns ONLY Sandbox lifecycle — provision the Sandbox
|
|
3
|
-
// STATIC `image`/`user`/`repos` options it carries) + attach one worktree
|
|
4
|
-
// author's body Machine inside it as the named slot `body`, with
|
|
5
|
-
// appended to its input (the mechanism-facing endpoint/sandbox
|
|
6
|
-
// — ADR-0016, ambient.ts), and destroy the Sandbox when the body reaches a
|
|
1
|
+
// `workspace(body, { input, image, user, repos, resources, spec })` (ADR-0012, ADR-0049, ADR-0051,
|
|
2
|
+
// ADR-0060): the jr2-owned wrapper Machine that owns ONLY Sandbox lifecycle — provision the Sandbox
|
|
3
|
+
// (out of the STATIC `image`/`user`/`repos`/`resources` options it carries) + attach one worktree
|
|
4
|
+
// per Repo Slot, run the author's body Machine inside it as the named slot `body`, with
|
|
5
|
+
// `{ workspace: { repos, branch } }` appended to its input (the mechanism-facing endpoint/sandbox
|
|
6
|
+
// are published ambiently — ADR-0016, ambient.ts), and destroy the Sandbox when the body reaches a
|
|
7
|
+
// final state or fails (ADR-0073). Teardown lives INSIDE the
|
|
7
8
|
// wrapper's own states because an xstate stop is synchronous — multi-step async cleanup must be
|
|
8
9
|
// states the machine transitions through itself, which forces the thing that provisions to also
|
|
9
10
|
// observe the body's completion (the ADR's load-bearing argument). There is no retain policy: a
|
|
@@ -14,14 +15,21 @@
|
|
|
14
15
|
// table): one cluster per orchestrator instance, so the port rides `RunHostOptions.sandbox`,
|
|
15
16
|
// never workflow code — `workspace` stays a plain static import (ADR-0011 doctrine) and unit
|
|
16
17
|
// tests bind a fake port. Every port operation is invoked from a state that RE-RUNS on restore
|
|
17
|
-
// (invoked actors re-execute from persisted input), so
|
|
18
|
+
// (invoked actors re-execute from persisted input), so every operation must be idempotent.
|
|
18
19
|
//
|
|
19
|
-
//
|
|
20
|
-
//
|
|
21
|
-
//
|
|
22
|
-
//
|
|
23
|
-
//
|
|
24
|
-
//
|
|
20
|
+
// The lifecycle (ADR-0012, ADR-0064): `placing` writes the Sandbox and waits, with no deadline,
|
|
21
|
+
// for its pod to be scheduled; `provisioning` waits for Ready under a budget that starts there;
|
|
22
|
+
// `attaching` runs the Harness's attach; `running` runs the body; `teardown` destroys. A Failure from
|
|
23
|
+
// the body (ADR-0073) goes to `failing`, which destroys as `teardown` does and then sends the same
|
|
24
|
+
// Failure up; a defect has no `onError` here, so it goes up at once and its Sandbox stays as evidence.
|
|
25
|
+
//
|
|
26
|
+
// Restore-reconcile (ADR-0012, ADR-0021): every state from `placing` to `running` co-invokes the
|
|
27
|
+
// lease, and in `running` the lease judges Continuity from the Orchestrator's watch for its
|
|
28
|
+
// Sandbox (ADR-0063). Invoked callback actors restart on every (re)entry — including snapshot
|
|
29
|
+
// restore — so after an orchestrator restart the watch's first list is the reconcile: present and
|
|
30
|
+
// not Lost → nothing (agent admissions re-attach — ADR-0016); gone or Lost → the pod-local clone
|
|
31
|
+
// and any unpushed commits are gone, so it delivers `workspace.lost` INTO the restored body (same
|
|
32
|
+
// channel as `agent.fault`) and the body's policy decides. Never silently re-provision.
|
|
25
33
|
|
|
26
34
|
import {
|
|
27
35
|
assign,
|
|
@@ -36,111 +44,295 @@ import {
|
|
|
36
44
|
} from "xstate";
|
|
37
45
|
import type { z } from "zod";
|
|
38
46
|
import { registerAmbientHandles, type AmbientHandles } from "./ambient.ts";
|
|
47
|
+
import { failLogic, failurePayload, isFailure, type Failure, type FailureInput } from "./fail.ts";
|
|
48
|
+
import { refuseUnroutedLost } from "./mechanism-routes.ts";
|
|
39
49
|
import {
|
|
50
|
+
actorRoute,
|
|
40
51
|
actorSlotPath,
|
|
41
52
|
assertRepoSlot,
|
|
42
53
|
attachSandboxParts,
|
|
43
54
|
attachWrapperBody,
|
|
55
|
+
composesSandbox,
|
|
44
56
|
customizeLine,
|
|
45
57
|
open,
|
|
46
58
|
repoSlotState,
|
|
47
59
|
sandboxPartsOf,
|
|
60
|
+
wrapperBodyOf,
|
|
48
61
|
type Binding,
|
|
49
62
|
type JR2Repos,
|
|
50
63
|
type JR2Wrapper,
|
|
51
64
|
type RepoSlot,
|
|
65
|
+
type PlacementRoute,
|
|
52
66
|
type SandboxParts,
|
|
53
67
|
type WrapperActors,
|
|
54
68
|
} from "./parts.ts";
|
|
55
|
-
import { runBindingOf, type AnyActorSystem } from "./registration.ts";
|
|
56
|
-
import {
|
|
69
|
+
import { actorPath, runBindingOf, type AnyActorSystem } from "./registration.ts";
|
|
70
|
+
import { assertSize, assertUserSeat, userSeatOf, type Size, type UserContainer } from "./size.ts";
|
|
71
|
+
import { attachInputSchema, declaresRunInput, invokingMachine, type HostInjectedInput } from "./vocabulary.ts";
|
|
57
72
|
|
|
58
73
|
/** Lease cadence when the backend names none. Well inside the 30m default idle timeout, so a
|
|
59
|
-
* few missed renewals in a row are survivable
|
|
60
|
-
*
|
|
61
|
-
const DEFAULT_LEASE_INTERVAL_MS = 5 * 60_000;
|
|
62
|
-
|
|
63
|
-
/**
|
|
64
|
-
*
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
*
|
|
69
|
-
*
|
|
74
|
+
* few missed renewals in a row are survivable. It bounds only how long an orphan waits to be
|
|
75
|
+
* reaped: loss is the watch's news, within seconds (ADR-0021, ADR-0063). */
|
|
76
|
+
export const DEFAULT_LEASE_INTERVAL_MS = 5 * 60_000;
|
|
77
|
+
|
|
78
|
+
/** Each renewal lands at the interval ±20% (ADR-0021), so Leases restored together after an
|
|
79
|
+
* Orchestrator restart never renew in lockstep. */
|
|
80
|
+
const LEASE_JITTER = 0.2;
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* When the lease renews next (ADR-0021), for a draw `random` in [0, 1]: every renewal at the
|
|
84
|
+
* interval ±20%, and the FIRST within a fifth of it — spread, like the rest, but soon, because a
|
|
85
|
+
* restore may follow an Orchestrator that was away for a while and the idle timeout has been
|
|
86
|
+
* running since its last stamp. Exported for the tests; the lease draws `Math.random()`.
|
|
87
|
+
*/
|
|
88
|
+
export function leaseDelay(interval: number, which: "first" | "next", random: number): number {
|
|
89
|
+
if (which === "first") return interval * LEASE_JITTER * random;
|
|
90
|
+
return interval * (1 - LEASE_JITTER + 2 * LEASE_JITTER * random);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// What to attach, in workspace vocabulary only (ADR-0012 boundary): the one branch the body works
|
|
94
|
+
// on, the pod's work group, and the review sha. Derived PER RUN from the wrapper's input, which is
|
|
95
|
+
// what keeps it out here rather than in the options — and which is exactly why the two IMAGES and
|
|
96
|
+
// the REPOS are NOT here (ADR-0049, ADR-0051): `jr2 up` must find them by walking the Machine, and
|
|
97
|
+
// no walk can evaluate a function of run input. They are static `workspace()` options instead; a
|
|
98
|
+
// Repo that IS a function of run input is a per-run slot, a mapper the walk can see the shape of
|
|
99
|
+
// even though it cannot see the url.
|
|
100
|
+
/** What the `spec` of a `workspace()` returns: the branch that the Workspace works on, and two
|
|
101
|
+
* optional fields. jr2 calls `spec` with the run input when the run enters the Workspace. */
|
|
70
102
|
export type WorkspaceSpec = {
|
|
103
|
+
/** The branch that the Workspace works on: each Repo Slot gets a Worktree for it, at
|
|
104
|
+
* `/work/<slot>/<branch>`, with each `/` in the branch written as `-`. Required, and not
|
|
105
|
+
* `default`, which is the directory of the clone. Your Machine decides its name. */
|
|
71
106
|
branch: string;
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
107
|
+
// The pod's work group (ADR-0005): `fsGroup`. The two writing seats may run different uids —
|
|
108
|
+
// each image's own `USER` decides — and POSIX would then make the other seat's files read-only;
|
|
109
|
+
// fsGroup (group ownership) plus the default ACL the attach stamps on each repo root (group
|
|
110
|
+
// writability, umask-proof) closes that, both inert when the uids already match. The override
|
|
111
|
+
// exists for the image whose sessions already hold a gid of their own — a root sshd's logins
|
|
112
|
+
// rebuild groups from `/etc/group`, so pointing the work group at one they have costs no
|
|
113
|
+
// rebuild. Never a config key — pod composition is the spec's business.
|
|
114
|
+
/** The group id that owns the files in `/work`. With it, the Sandbox Image and the User
|
|
115
|
+
* Container can both write those files when they run as different users. Default: 2000. Set it
|
|
116
|
+
* to a group id that the User Container's sessions already have, for example when a root sshd
|
|
117
|
+
* builds their groups from `/etc/group`. A non-negative integer. */
|
|
79
118
|
workGroup?: number;
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
119
|
+
// `<branchDir>-review`, a sibling of the branch worktree, forced to exactly this sha on every
|
|
120
|
+
// attach (ADR-0028). Creation-time seat only; the per-round refresh verb (the sha moves between
|
|
121
|
+
// review rounds) is a later, workflow-driven change.
|
|
122
|
+
/** A commit to check out in a second, detached Worktree beside each branch Worktree, for a
|
|
123
|
+
* reviewer: `/work/<slot>/<branch>-review`. The body gets its paths as `workspace.review`. */
|
|
84
124
|
reviewSha?: string;
|
|
85
125
|
};
|
|
86
126
|
|
|
127
|
+
// A Workspace with no Repo Slot (ADR-0051) attaches nothing, so a branch and a review sha name
|
|
128
|
+
// nothing: `never` refuses them where the spec is written, and `assertSpec` refuses them at run
|
|
129
|
+
// time for the caller the types do not see.
|
|
130
|
+
/** What the `spec` of a `workspace()` with no Repo Slot returns: only `workGroup`. There is no
|
|
131
|
+
* branch and no review Worktree, because the Workspace has no Repo Slot. */
|
|
132
|
+
export type NoSlotSpec = Pick<WorkspaceSpec, "workGroup"> & { branch?: never; reviewSha?: never };
|
|
133
|
+
|
|
134
|
+
// What the workspace hands the BODY (ADR-0012, ADR-0016): worktree geography only, keyed by Repo
|
|
135
|
+
// Slot (ADR-0051) — so a path a prompt names is right in every Instance that consumes the Machine.
|
|
136
|
+
// `endpoint` and `sandbox` are mechanism-internal — the Agent actor resolves them ambiently from
|
|
137
|
+
// the enclosing wrapper (ambient.ts), so a workflow can no longer forget to thread them (the
|
|
138
|
+
// baba71f incident: `sandbox` omitted, every tool call 403'd, fail-closed but silent).
|
|
139
|
+
//
|
|
140
|
+
// `TSlots` has NO default on purpose (ADR-0050): a body names the slots it reads, and
|
|
141
|
+
// `workspace()` holds those names to the ones the wrapper declares. A default of `string` would
|
|
142
|
+
// type `repos` as `Record<string, string>`, under which a misspelled slot reads as a path — the
|
|
143
|
+
// silent widening the slot key exists to refuse. `string` WRITTEN is a different statement: the
|
|
144
|
+
// body names no slot at all — it enumerates them, in the order the composer wrote — which is the
|
|
145
|
+
// body under a wrapper that declares its map Open (`repos: open`, ADR-0051), where the slots are
|
|
146
|
+
// the composer's words and no body could name them.
|
|
87
147
|
/**
|
|
88
|
-
* What the workspace
|
|
89
|
-
*
|
|
90
|
-
*
|
|
91
|
-
* the
|
|
92
|
-
*
|
|
93
|
-
*
|
|
94
|
-
*
|
|
95
|
-
*
|
|
96
|
-
* type `repos` as `Record<string, string>`, under which a misspelled slot reads as a path — the
|
|
97
|
-
* silent widening the slot key exists to refuse. `string` WRITTEN is a different statement: the
|
|
98
|
-
* body names no slot at all — it enumerates them, in the order the composer wrote — which is the body
|
|
99
|
-
* under a wrapper that declares its map Open (`repos: open`, ADR-0051), where the slots are the
|
|
100
|
-
* composer's words and no body could name them.
|
|
148
|
+
* What the body of a `workspace()` gets as `input.workspace`: the path of each Worktree, and the
|
|
149
|
+
* branch. A path is correct in each Instance that registers the Machine.
|
|
150
|
+
*
|
|
151
|
+
* `TSlots` is the names of the Repo Slots that the body reads. `workspace()` refuses a body that
|
|
152
|
+
* names a slot that the Workspace does not have. Write `string` for a body that names no slot and
|
|
153
|
+
* reads the Worktrees in the order of `repos`, as under `repos: open`. Write `never` for a
|
|
154
|
+
* Workspace with no Repo Slot: its handles are `{ repos: {} }`, with no branch, and its Agents work
|
|
155
|
+
* in `/work`.
|
|
101
156
|
*/
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
157
|
+
// `[TSlots] extends [never]`, not `TSlots extends never`: a bare `never` would distribute to `never`.
|
|
158
|
+
export type WorkspaceHandles<TSlots extends string> = [TSlots] extends [never]
|
|
159
|
+
? { repos: Record<never, string> }
|
|
160
|
+
: SlotHandles<TSlots>;
|
|
161
|
+
|
|
162
|
+
/** The handles of a Workspace that declares Repo Slots. @internal */
|
|
163
|
+
type SlotHandles<TSlots extends string> = {
|
|
164
|
+
// The kit gives no slot a privileged meaning — there is no `workdir` — because which tree an
|
|
165
|
+
// Agent works in is a fact about that Agent's Turn, not about the Workspace: a body that names
|
|
166
|
+
// its slots frames each Agent with its own, and a body that names none may give the order a
|
|
167
|
+
// meaning of its own (`task`: the first is the one the coder edits). The order is the kit's
|
|
168
|
+
// promise; the meaning is the Machine's (ADR-0051).
|
|
169
|
+
/** The absolute path of each Repo Slot's Worktree, `/work/<slot>/<branch>`, in the order that
|
|
170
|
+
* `repos` declares the slots. Give one to an Agent's Turn as its `cwd`. */
|
|
109
171
|
repos: Record<TSlots, string>;
|
|
172
|
+
/** The branch that the Workspace works on: the `branch` of the spec. */
|
|
110
173
|
branch: string;
|
|
111
|
-
|
|
112
|
-
|
|
174
|
+
// The review seat (ADR-0028).
|
|
175
|
+
/** The path of each Repo Slot's review Worktree. Present only when the spec has `reviewSha`.
|
|
176
|
+
* Give one to a reviewer's Turn as its `cwd`. */
|
|
113
177
|
review?: Partial<Record<TSlots, string>>;
|
|
114
178
|
};
|
|
115
179
|
|
|
180
|
+
// The composition is the whole reason the door is declared on the wrapper and not on the body
|
|
181
|
+
// (ADR-0033): `Workspaced<RunInput, Slot>` is what the body receives, `RunInput` is what a caller
|
|
182
|
+
// may send, and no caller can send `workspace` (the handles do not exist until a Sandbox is
|
|
183
|
+
// provisioned and attached). Naming it here keeps the body from hand-copying
|
|
184
|
+
// {@link WorkspaceHandles}, which drifts. The slot argument is required, never defaulted, so
|
|
185
|
+
// `workspace.repos.<slot>` is typed by the same keys the wrapper declares (ADR-0050, ADR-0051).
|
|
186
|
+
//
|
|
187
|
+
// The wrapper passes its input through UNTOUCHED, so a ROOT-placed wrapper's body also receives
|
|
188
|
+
// what the host injected beside the door — `HostInjectedInput` today (the run's `instanceId`).
|
|
189
|
+
// That is outside this type on purpose: it depends on where the wrapper sits, and `Workspaced` is
|
|
190
|
+
// the composition the WRAPPER makes.
|
|
191
|
+
/**
|
|
192
|
+
* The input of a `workspace()` body: the run input, plus `workspace`, the handles that the
|
|
193
|
+
* Workspace adds (`workspace.repos`, `workspace.branch`, `workspace.review`). Use it as the type of
|
|
194
|
+
* the body's input.
|
|
195
|
+
*
|
|
196
|
+
* The first argument is the run input: the `input` schema of the `workspace()`. The second is the
|
|
197
|
+
* Repo Slots that the body reads, by name (`"app"` or `"app" | "docs"`). Under `repos: open`, write
|
|
198
|
+
* `string`: the body cannot know the names, so it reads the slots in order. For a `workspace()`
|
|
199
|
+
* with no `repos`, write `never`. The second argument has no default.
|
|
200
|
+
*
|
|
201
|
+
* A body under a root `workspace()` also gets the fields of `HostInjectedInput`. Add them when the
|
|
202
|
+
* body reads them: `Workspaced<HostInjectedInput & RunInput, "app">`.
|
|
203
|
+
*
|
|
204
|
+
* Its fields are the fields of the run input, and `workspace`. The run input can have no fields:
|
|
205
|
+
* `Workspaced<z.infer<typeof door>, "app">` for the door `z.object({})` holds only `workspace`.
|
|
206
|
+
*
|
|
207
|
+
* @example
|
|
208
|
+
* ```ts
|
|
209
|
+
* import { z } from "zod";
|
|
210
|
+
* import { agent, defineEvent, fail, jr2Setup, workspace, type Workspaced } from "@jr2/orchestrator";
|
|
211
|
+
*
|
|
212
|
+
* const done = defineEvent({
|
|
213
|
+
* name: "done",
|
|
214
|
+
* description: "Call this when REVIEW.md is written.",
|
|
215
|
+
* input: z.object({}),
|
|
216
|
+
* });
|
|
217
|
+
* const runInput = z.object({ branch: z.string(), sha: z.string() });
|
|
218
|
+
* type BodyInput = Workspaced<z.infer<typeof runInput>, "app">;
|
|
219
|
+
*
|
|
220
|
+
* const body = jr2Setup({
|
|
221
|
+
* types: {} as { input: BodyInput; context: BodyInput },
|
|
222
|
+
* events: [done],
|
|
223
|
+
* actors: {
|
|
224
|
+
* reviewer: agent({
|
|
225
|
+
* model: "anthropic/claude-haiku-5-5",
|
|
226
|
+
* instructions: "You review one commit and write REVIEW.md.",
|
|
227
|
+
* workspace: "read",
|
|
228
|
+
* }),
|
|
229
|
+
* fail: fail(),
|
|
230
|
+
* },
|
|
231
|
+
* }).createMachine({
|
|
232
|
+
* id: "review",
|
|
233
|
+
* context: ({ input }) => input,
|
|
234
|
+
* on: { "workspace.lost": ".lost", "agent.fault": ".failed" },
|
|
235
|
+
* initial: "reviewing",
|
|
236
|
+
* states: {
|
|
237
|
+
* reviewing: {
|
|
238
|
+
* invoke: {
|
|
239
|
+
* src: "reviewer",
|
|
240
|
+
* input: ({ context }) => ({
|
|
241
|
+
* prompt: `Review commit ${context.sha} on ${context.workspace.branch}.`,
|
|
242
|
+
* cwd: context.workspace.review?.app ?? context.workspace.repos.app,
|
|
243
|
+
* }),
|
|
244
|
+
* },
|
|
245
|
+
* on: { done: "finished" },
|
|
246
|
+
* },
|
|
247
|
+
* finished: { type: "final" },
|
|
248
|
+
* failed: { invoke: { src: "fail", input: { reason: "the Agent's Turn could not go on" } } },
|
|
249
|
+
* lost: { invoke: { src: "fail", input: { reason: "the Sandbox is gone" } } },
|
|
250
|
+
* },
|
|
251
|
+
* });
|
|
252
|
+
*
|
|
253
|
+
* export const machine = workspace(body, {
|
|
254
|
+
* input: runInput,
|
|
255
|
+
* repos: { app: "https://github.com/you/app.git" },
|
|
256
|
+
* spec: ({ input }) => ({ branch: input.branch, reviewSha: input.sha }),
|
|
257
|
+
* });
|
|
258
|
+
* ```
|
|
259
|
+
*
|
|
260
|
+
* @see docs/concepts/workspaces.md
|
|
261
|
+
* @see docs/guides/write-a-workflow.md
|
|
262
|
+
* @see docs/reference/decisions/0012-workspace-wrapper-machine.md
|
|
263
|
+
* @see docs/reference/decisions/0051-a-repo-is-a-slot-on-the-workspace-and-a-cache-on-the-node.md
|
|
264
|
+
*/
|
|
265
|
+
export type Workspaced<TInput, TSlots extends string> = WithoutEmptyIndex<TInput> & {
|
|
266
|
+
workspace: WorkspaceHandles<TSlots>;
|
|
267
|
+
};
|
|
268
|
+
|
|
269
|
+
// `z.infer` of a door with no fields, `z.object({})`, is `Record<string, never>`: an index signature
|
|
270
|
+
// that says each key holds `never`. Intersected with the handles, it makes each assign of a body
|
|
271
|
+
// context fail (`n: number` is not `never`) with a diagnostic that does not name the cause. This
|
|
272
|
+
// removes that index signature, and only one whose value is `never`, so the named fields stay
|
|
273
|
+
// (`HostInjectedInput & Record<string, never>` keeps `instanceId: string`) and an input that is a
|
|
274
|
+
// real map (`Record<string, string>`) is not changed. The alternative, a separate type for the empty
|
|
275
|
+
// door, makes the author choose between two names for one statement.
|
|
276
|
+
// It is not `@internal`: the Reference then shows this TSDoc, so a reader of `Workspaced` sees that
|
|
277
|
+
// these are the fields of the run input. As plumbing, its page showed only the declaration.
|
|
278
|
+
/**
|
|
279
|
+
* The fields of the run input `T`. A string index signature whose value is `never` is not one of
|
|
280
|
+
* them: `z.infer` gives `Record<string, never>` for `z.object({})`, and this type makes it `{}`. The
|
|
281
|
+
* named fields of `T` stay. A different `T` does not change.
|
|
282
|
+
*/
|
|
283
|
+
type WithoutEmptyIndex<T> = string extends keyof T
|
|
284
|
+
? [T[string & keyof T]] extends [never]
|
|
285
|
+
? { [K in keyof T as string extends K ? never : number extends K ? never : K]: T[K] }
|
|
286
|
+
: T
|
|
287
|
+
: T;
|
|
288
|
+
|
|
116
289
|
/**
|
|
117
|
-
*
|
|
118
|
-
*
|
|
119
|
-
*
|
|
120
|
-
*
|
|
121
|
-
* provisioned and attached). Naming it here keeps the body from hand-copying
|
|
122
|
-
* {@link WorkspaceHandles}, which drifts. The second argument is the body's word for each Repo
|
|
123
|
-
* Slot it reads (`Workspaced<RunInput, "target">`) — required, never defaulted, so
|
|
124
|
-
* `workspace.repos.<slot>` is typed by the same keys the wrapper declares (ADR-0050, ADR-0051).
|
|
125
|
-
*
|
|
126
|
-
* The wrapper passes its input through UNTOUCHED, so a ROOT-placed wrapper's body also receives
|
|
127
|
-
* what the host injected beside the door — `HostInjectedInput` today (the run's `instanceId`).
|
|
128
|
-
* That is outside this type on purpose: it depends on where the wrapper sits, and `Workspaced` is
|
|
129
|
-
* the composition the WRAPPER makes.
|
|
290
|
+
* What the watch says about a workspace (ADR-0021, ADR-0063): gone, or present — and, once the
|
|
291
|
+
* operator has judged its one pod gone or terminal, `lost` with the operator's reason and the pod's
|
|
292
|
+
* own words. A Sandbox has one pod for its life, so a Lost Sandbox never comes back; the operator
|
|
293
|
+
* judges identity because it knows which pod it created, and nothing here compares pod UIDs.
|
|
130
294
|
*/
|
|
131
|
-
export type
|
|
295
|
+
export type Continuity = { present: false } | { present: true; lost?: SandboxLoss };
|
|
296
|
+
|
|
297
|
+
/** Why a Sandbox is Lost (ADR-0021): the pod's own reason (`Evicted`, `NodeShutdown`, ...) or the
|
|
298
|
+
* operator's (`PodDeleted`, `NodeLost`, `PodFailed`, `PodSucceeded`), and its message. */
|
|
299
|
+
export type SandboxLoss = { reason: string; message: string };
|
|
132
300
|
|
|
133
301
|
/**
|
|
134
|
-
*
|
|
135
|
-
*
|
|
136
|
-
*
|
|
137
|
-
* CR name, the Service DNS, the worktree paths are all derived from the run — so every name a
|
|
138
|
-
* live run holds still resolves after an eviction or a node loss, while the pod behind them is
|
|
139
|
-
* a replacement with an empty `work` volume: clones, worktrees, and unpushed commits gone.
|
|
140
|
-
* Presence cannot see that; identity can. A backend with no such notion may omit it, and its
|
|
141
|
-
* workspaces are then reconciled on presence alone.
|
|
302
|
+
* Why a Sandbox with no node waits (ADR-0064): the scheduler found no node for it, or a
|
|
303
|
+
* ResourceQuota refused its pod. `message` is the scheduler's or the API server's own words, which
|
|
304
|
+
* can name nodes and taints — so it rides the authenticated status and the per-run feed only.
|
|
142
305
|
*/
|
|
143
|
-
export type
|
|
306
|
+
export type PlacingWait = { on: "node" | "quota"; message: string };
|
|
307
|
+
|
|
308
|
+
/** The Harness container's restarts on the Sandbox's one pod, and how it last ended — as the
|
|
309
|
+
* operator publishes them (ADR-0021, ADR-0063). The signal that ends the Turns a restart took. */
|
|
310
|
+
export type HarnessRestarts = {
|
|
311
|
+
restartCount: number;
|
|
312
|
+
lastTerminated?: { reason?: string; exitCode?: number; finishedAt?: string };
|
|
313
|
+
};
|
|
314
|
+
|
|
315
|
+
/** What the port writes when a Workspace is placed (ADR-0012, ADR-0064). */
|
|
316
|
+
export type PlaceRequest = {
|
|
317
|
+
name: string;
|
|
318
|
+
runId: string;
|
|
319
|
+
workflow: string;
|
|
320
|
+
/** The placement route from the registered Machine to this `workspace()` (`actorRoute`). With
|
|
321
|
+
* `workflow`, it names the pod whose Held secrets and Sandbox Effector `jr2 up` placed (ADR-0059). */
|
|
322
|
+
route: PlacementRoute;
|
|
323
|
+
image?: string;
|
|
324
|
+
user?: string;
|
|
325
|
+
workGroup?: number;
|
|
326
|
+
repos: ProvisionedRepo[];
|
|
327
|
+
resources?: Size;
|
|
328
|
+
userResources?: Size;
|
|
329
|
+
/** The Sandbox Effector's image as the `workspace()` names it (ADR-0068): the port resolves it,
|
|
330
|
+
* and adds the container only when `jr2 up` placed an Effect there. Absent → the Sandbox Image. */
|
|
331
|
+
effector?: string;
|
|
332
|
+
/** The hash of the Assembly the run holds (ADR-0069). When it is not the current one, an image
|
|
333
|
+
* the map lacks fails naming it. Absent for a run no Assembly started. */
|
|
334
|
+
assembly?: string;
|
|
335
|
+
};
|
|
144
336
|
|
|
145
337
|
/** One Repo Slot as the port receives it at provision (ADR-0051): resolved to a Binding, and
|
|
146
338
|
* flagged when the run — not the Machine — chose the url, because that is what the credentials
|
|
@@ -149,27 +341,34 @@ export type ProvisionedRepo = { slot: string; url: string; ref?: string; perRun:
|
|
|
149
341
|
|
|
150
342
|
/**
|
|
151
343
|
* The Sandbox backend a host supplies (`RunHostOptions.sandbox`) — the seam between the
|
|
152
|
-
* workspace Machine and the cluster.
|
|
344
|
+
* workspace Machine and the cluster. Every operation MUST be idempotent: the invoking
|
|
153
345
|
* states re-run on snapshot restore (create-if-absent, attach-if-absent, delete-if-present).
|
|
154
346
|
*/
|
|
155
347
|
export interface SandboxPort {
|
|
156
|
-
/**
|
|
157
|
-
*
|
|
158
|
-
*
|
|
159
|
-
* (ADR-0037/0005/0049) — a `file:` context or a registry ref,
|
|
160
|
-
* context the last converge did not build fails here rather than
|
|
161
|
-
* wrapper's slots, resolved, in declaration order (ADR-0051): the
|
|
162
|
-
* CR so the cluster mounts its cache, and refuses a per-run url no
|
|
163
|
-
* admits. `workGroup` is the pod's `fsGroup`; the port owns the default.
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
348
|
+
/**
|
|
349
|
+
* Write the Sandbox (its token Secret, then the CR, labeled with its run for `jr2 ls`) and wait,
|
|
350
|
+
* with NO deadline, until the operator says its pod is scheduled (ADR-0064). `image`/`user` are
|
|
351
|
+
* the wrapper's static image options (ADR-0037/0005/0049) — a `file:` context or a registry ref,
|
|
352
|
+
* the port resolves both, and a context the last converge did not build fails here rather than
|
|
353
|
+
* converge-time. `repos` are the wrapper's slots, resolved, in declaration order (ADR-0051): the
|
|
354
|
+
* port names each Repo on the CR so the cluster mounts its cache, and refuses a per-run url no
|
|
355
|
+
* `git.credentials` entry admits. `workGroup` is the pod's `fsGroup`; the port owns the default.
|
|
356
|
+
* `resources` is the Size the Machine states and `userResources` the User Container's split of it
|
|
357
|
+
* (ADR-0060), both as stated — the port resolves the rest of the chain and splits the Size inside
|
|
358
|
+
* the pod.
|
|
359
|
+
*
|
|
360
|
+
* While the pod has no node, `onWait` hears why, each time the reason changes. A Sandbox that is
|
|
361
|
+
* Lost, or deleted, before it is placed rejects. `signal` ends the wait: the promise rejects once
|
|
362
|
+
* any write in flight has settled, so a caller that deletes after it never races the apply.
|
|
363
|
+
*/
|
|
364
|
+
place(req: PlaceRequest, opts: { onWait: (wait: PlacingWait) => void; signal?: AbortSignal }): Promise<void>;
|
|
365
|
+
/**
|
|
366
|
+
* Wait for the placed Sandbox's `phase: Ready` under the port's budget, which starts NOW — the
|
|
367
|
+
* pod is scheduled, so it measures only what jr2 controls: the image pull, the Harness start, the
|
|
368
|
+
* Repos (ADR-0064). Resolves with the Harness endpoint the orchestrator can reach. Lost, or gone,
|
|
369
|
+
* rejects naming why.
|
|
370
|
+
*/
|
|
371
|
+
provision(name: string): Promise<{ endpoint: string }>;
|
|
173
372
|
/** Post-Ready attach (ADR-0004): per slot, `git clone --shared` off the node's read-only
|
|
174
373
|
* cache (the `default/` checkout), then a branch worktree sibling — and, with `spec.reviewSha`, the detached
|
|
175
374
|
* review worktree (ADR-0028). Resolves with the worktree paths by slot; `stale` names the slots
|
|
@@ -185,22 +384,55 @@ export interface SandboxPort {
|
|
|
185
384
|
stale?: Record<string, string>;
|
|
186
385
|
}>;
|
|
187
386
|
/**
|
|
188
|
-
* Renew this workspace's keepalive lease
|
|
189
|
-
*
|
|
190
|
-
*
|
|
191
|
-
*
|
|
192
|
-
*
|
|
193
|
-
|
|
194
|
-
|
|
387
|
+
* Renew this workspace's keepalive lease — a WRITE, and only that (ADR-0021, ADR-0063). Nothing
|
|
388
|
+
* in the cluster represents a run (ADR-0001), so the lease is the Orchestrator's assertion
|
|
389
|
+
* that lets the operator reap an orphan and no one else. It learns nothing: Continuity is the
|
|
390
|
+
* watch's. Idempotent, called on a jittered timer; a failure rejects and the caller shrugs —
|
|
391
|
+
* the next renewal is minutes away and the idle timeout is thirty.
|
|
392
|
+
*/
|
|
393
|
+
renew(name: string): Promise<void>;
|
|
394
|
+
/**
|
|
395
|
+
* Hear this workspace's Continuity from the watch (ADR-0021, ADR-0063): once the watch has
|
|
396
|
+
* listed, what it holds now, then again on every change the cluster reports — a Sandbox gone, or
|
|
397
|
+
* one the operator has made Lost. A dropped watch reports NOTHING: unknown is never loss, and the
|
|
398
|
+
* listener never hears a fabricated `{present: false}`. Returns the unsubscribe.
|
|
195
399
|
*/
|
|
196
|
-
|
|
400
|
+
continuity(name: string, listener: (seen: Continuity) => void): () => void;
|
|
401
|
+
/**
|
|
402
|
+
* Hear the Harness container's restarts on this Sandbox's pod (ADR-0021): once the watch has
|
|
403
|
+
* listed, then on every change. A Turn records the count at its admission, and a count above it
|
|
404
|
+
* means the conversation it waits on is gone. Silent for a name the watch does not hold — the
|
|
405
|
+
* Instance Harness is a StatefulSet, not a Sandbox. Returns the unsubscribe.
|
|
406
|
+
*/
|
|
407
|
+
harnessRestarts(name: string, listener: (seen: HarnessRestarts) => void): () => void;
|
|
408
|
+
/**
|
|
409
|
+
* Name a memory kill (ADR-0061): when the Harness container of this Sandbox last ended
|
|
410
|
+
* `OOMKilled` at or after `since`, the kill — its fault reason, starting with the fixed prefix
|
|
411
|
+
* `memory limit`, and the data the next Turn's notice is made of (ADR-0062) — else undefined.
|
|
412
|
+
* Asked when a Turn faults on a lost conversation; it may wait a moment for the operator's word,
|
|
413
|
+
* which can trail the Harness's restart. Asked too before an admission into the Workspace whose
|
|
414
|
+
* watch shows a last end `OOMKilled`, with `since` that end: a kill while no Turn ran (ADR-0062).
|
|
415
|
+
* A backend that cannot see the container answers undefined, and every lost conversation stays
|
|
416
|
+
* lost — never an optional method (ADR-0021).
|
|
417
|
+
*/
|
|
418
|
+
memoryFault(name: string, since: Date): Promise<MemoryKill | undefined>;
|
|
197
419
|
/** Delete the Sandbox CR. Absent is success. */
|
|
198
420
|
destroy(name: string): Promise<void>;
|
|
199
421
|
/** How often to renew. Must be well inside the backend's idle-timeout, since a lapsed lease is
|
|
200
|
-
* what lets the operator reap.
|
|
422
|
+
* what lets the operator reap. */
|
|
201
423
|
readonly leaseIntervalMs?: number;
|
|
202
424
|
}
|
|
203
425
|
|
|
426
|
+
/** A kernel memory kill of a Sandbox's Harness container (ADR-0061, layer 5). */
|
|
427
|
+
export type MemoryKill = {
|
|
428
|
+
/** The fault reason, starting with the fixed prefix `memory limit`. */
|
|
429
|
+
reason: string;
|
|
430
|
+
/** The limit the kernel enforced, as the Sandbox states it (`1920Mi`). */
|
|
431
|
+
limit?: string;
|
|
432
|
+
/** When the container ended — which kill this was, so every Turn it ended names the same one. */
|
|
433
|
+
at?: string;
|
|
434
|
+
};
|
|
435
|
+
|
|
204
436
|
/** Resolve the host's Sandbox backend, failing with a pointed message on a host without one. */
|
|
205
437
|
export function sandboxOf(system: AnyActorSystem): SandboxPort {
|
|
206
438
|
const port = runBindingOf(system).sandbox;
|
|
@@ -215,7 +447,7 @@ export function sandboxOf(system: AnyActorSystem): SandboxPort {
|
|
|
215
447
|
|
|
216
448
|
/**
|
|
217
449
|
* The Sandbox CR name for one workspace invocation: DNS-1123, deterministic from the run and
|
|
218
|
-
* the wrapper's actor id (both stable across restore — that is what lets the
|
|
450
|
+
* the wrapper's actor id (both stable across restore — that is what lets the lease
|
|
219
451
|
* and a re-run provision find the SAME CR), collision-proofed by a content suffix.
|
|
220
452
|
*/
|
|
221
453
|
export function workspaceName(runId: string, wsId: string): string {
|
|
@@ -239,23 +471,36 @@ type MechanismHandles = AmbientHandles;
|
|
|
239
471
|
* mapper is not re-evaluated against an input that may since have been re-parsed. */
|
|
240
472
|
export type ResolvedBinding = { url: string; ref?: string; perRun: boolean };
|
|
241
473
|
|
|
474
|
+
/**
|
|
475
|
+
* A Workspace's wait for capacity as its context holds it (ADR-0064): the reason as the port last
|
|
476
|
+
* heard it, and when the wait began. `since` survives a changed reason and a restart — it is how
|
|
477
|
+
* long the Workspace has waited, which is what the feed's closing line reports.
|
|
478
|
+
*/
|
|
479
|
+
export type WorkspaceWait = PlacingWait & { since: string };
|
|
480
|
+
|
|
481
|
+
/** The context key the wait rides under — what `RunStatus.waiting` reads off a Workspace's
|
|
482
|
+
* snapshot, live or persisted. */
|
|
483
|
+
export const WORKSPACE_WAIT_KEY = "wait";
|
|
484
|
+
|
|
242
485
|
type WsContext = {
|
|
243
486
|
/** The wrapper's own input, passed through to the body untouched (plus `workspace`). */
|
|
244
487
|
runInput: Record<string, unknown>;
|
|
245
488
|
/** This invocation's stable identity (the actor id the parent invoked/spawned us under). */
|
|
246
489
|
wsId: string;
|
|
247
490
|
/** The resolved spec — computed once from input and persisted, like any other context data. */
|
|
248
|
-
spec:
|
|
491
|
+
spec: AnySpec;
|
|
249
492
|
/** Every slot's resolved Binding, by slot, in declaration order — assigned at provision. */
|
|
250
493
|
bindings?: Record<string, ResolvedBinding>;
|
|
251
494
|
endpoint?: string;
|
|
252
|
-
/**
|
|
253
|
-
*
|
|
254
|
-
|
|
255
|
-
identity?: string;
|
|
495
|
+
/** Why the Sandbox waits for a node, while `placing` (ADR-0064) — persisted, so a restart keeps
|
|
496
|
+
* when the wait began and `RunStatus.waiting` reads it off the snapshot. Cleared once placed. */
|
|
497
|
+
wait?: WorkspaceWait;
|
|
256
498
|
/** Persisted in context so the registrar can re-publish them on restore. */
|
|
257
499
|
handles?: MechanismHandles;
|
|
258
500
|
output?: unknown;
|
|
501
|
+
/** The body's Failure (ADR-0073), as plain data: kept while `failing` destroys, so a restore
|
|
502
|
+
* there sends the same Failure up. */
|
|
503
|
+
failure?: FailureInput;
|
|
259
504
|
};
|
|
260
505
|
|
|
261
506
|
/**
|
|
@@ -276,12 +521,14 @@ type WsContext = {
|
|
|
276
521
|
* Repo Slots it carries, because a default would widen exactly where the phantom exists to refuse
|
|
277
522
|
* — `JR2Repos<string>` offers `customize()` every key, and `AnyStateMachine` as the body offers no
|
|
278
523
|
* Agent at all. `PoolMachine` names its worker the same way.
|
|
524
|
+
*
|
|
525
|
+
* @internal
|
|
279
526
|
*/
|
|
280
527
|
export type WorkspaceMachine<TInput, TOutput, TBody extends AnyStateMachine, TSlots extends string> = StateMachine<
|
|
281
528
|
any,
|
|
282
529
|
any,
|
|
283
530
|
any,
|
|
284
|
-
WrapperActors<"body", TBody, "provision" | "attach" | "registrar" | "lease" | "destroy">,
|
|
531
|
+
WrapperActors<"body", TBody, "place" | "provision" | "attach" | "registrar" | "lease" | "destroy" | "fail">,
|
|
285
532
|
any,
|
|
286
533
|
any,
|
|
287
534
|
any,
|
|
@@ -328,8 +575,15 @@ export type WorkspaceMachine<TInput, TOutput, TBody extends AnyStateMachine, TSl
|
|
|
328
575
|
*/
|
|
329
576
|
// The handles are keyed by the wrapper's DECLARED slots (ADR-0051), so a body demanding a slot the
|
|
330
577
|
// wrapper never declared is refused here too; demanding fewer is fine, as with any other field.
|
|
578
|
+
//
|
|
579
|
+
// The provided side holds the raw door AND its `Workspaced` form. `Workspaced` removes the
|
|
580
|
+
// `Record<string, never>` that `z.infer` gives the empty door, so `Workspaced` alone no longer
|
|
581
|
+
// satisfies a body that writes that type itself (`z.infer<typeof door>`, alone or with the
|
|
582
|
+
// handles). The raw door keeps those bodies legal, and the stripped form keeps a body that
|
|
583
|
+
// declares `Workspaced<…>` legal. A key that the door does not carry is still refused.
|
|
584
|
+
/** @internal */
|
|
331
585
|
type BodyAcceptsDoor<TBody extends AnyStateMachine, TDoor, TSlots extends string> =
|
|
332
|
-
Workspaced<TDoor, TSlots> & HostInjectedInput extends InputFrom<TBody>
|
|
586
|
+
TDoor & Workspaced<TDoor, TSlots> & HostInjectedInput extends InputFrom<TBody>
|
|
333
587
|
? BodyNamesNoSlotUnderOpenMap<TBody, TSlots>
|
|
334
588
|
: { "the body's declared input must accept the door plus the injected handles": Workspaced<TDoor, TSlots> };
|
|
335
589
|
|
|
@@ -360,6 +614,8 @@ type BodyNamesNoSlotUnderOpenMap<TBody extends AnyStateMachine, TSlots extends s
|
|
|
360
614
|
* is the same one-direction assignability, on the `workspace` field alone: the handles the wrapper
|
|
361
615
|
* will inject must satisfy what the body declares for them, so a body that names fewer slots, or
|
|
362
616
|
* no handles at all, is fed a superset and passes, as ever.
|
|
617
|
+
*
|
|
618
|
+
* @internal
|
|
363
619
|
*/
|
|
364
620
|
type BodyAcceptsSlots<TBody extends AnyStateMachine, TSlots extends string> =
|
|
365
621
|
InputFrom<TBody> extends { workspace: infer THandles }
|
|
@@ -368,106 +624,426 @@ type BodyAcceptsSlots<TBody extends AnyStateMachine, TSlots extends string> =
|
|
|
368
624
|
: { "the body's declared handles must accept the Repo Slots the wrapper declares": WorkspaceHandles<TSlots> }
|
|
369
625
|
: unknown;
|
|
370
626
|
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
*/
|
|
627
|
+
// What the Sandbox is MADE OF (ADR-0037, ADR-0005) and which Repos it attaches (ADR-0051), as
|
|
628
|
+
// STATIC options on the wrapper rather than fields of the per-run spec (ADR-0049). Static is the
|
|
629
|
+
// whole point: `jr2 up` walks the registered Machines to find every `file:` context and build it,
|
|
630
|
+
// every bound Repo and warm it, every open slot and refuse it (parts.ts) — and a spec is a function
|
|
631
|
+
// of run input that no walk can evaluate. They are also never persisted — the provisioning state
|
|
632
|
+
// re-reads them off the Machine it was invoked as, so a restore, a `provide()` and a `customize()`
|
|
633
|
+
// all get the image and the slots the Machine carries NOW.
|
|
634
|
+
//
|
|
635
|
+
// Each image is one string in ADR-0037's two shapes: a `file:` URL to a docker context the
|
|
636
|
+
// Machine's module ships (`import.meta.resolve("./image")`), or a registry ref its owner baked and
|
|
637
|
+
// hosts.
|
|
638
|
+
//
|
|
639
|
+
// `TSlots` has no default, here and on the two option types below (ADR-0050): a value annotated
|
|
640
|
+
// with a `string` slot set types `repos` as `Record<string, RepoSlot>`, under which a body naming
|
|
641
|
+
// any slot passes the wrapper's check — the widening WorkspaceHandles refuses for the
|
|
642
|
+
// same reason. The `workspace()` overloads infer it; an annotation names it.
|
|
643
|
+
/** The options of a `workspace()` that say what its Sandbox is made of and which Repos it
|
|
644
|
+
* clones. They are the same for each run. */
|
|
389
645
|
export type SandboxOptions<TSlots extends string, TInput = unknown> = {
|
|
390
|
-
/** The Sandbox Image
|
|
646
|
+
/** The Sandbox Image: the image of the container where the Agent's Working tools and
|
|
647
|
+
* `command` Effects run. A `file:` URL to a docker context that your module ships
|
|
648
|
+
* (`import.meta.resolve("./image")`), which `jr2 up` builds, or a registry ref. Its floor is
|
|
649
|
+
* glibc and git; jr2 adds its own runtime at `/opt/jr2`. Default: the Instance's
|
|
650
|
+
* `images/default`, then jr2's Harness image. */
|
|
391
651
|
image?: string;
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
652
|
+
// The widening ADR-0005 foresaw: `{ image, resources }` states the seat's split of the Size
|
|
653
|
+
// (ADR-0060). There is no default, because the seat's whole identity is "what jr2 does not own"
|
|
654
|
+
// and jr2 has nothing to put there. Env and ports are deliberately not forwarded.
|
|
655
|
+
/** The User Container: a third container in the Sandbox, for a service that must run beside the
|
|
656
|
+
* Agent, such as an sshd. Its image, in the same two forms as `image`, or `{ image, resources }`
|
|
657
|
+
* to give it a part of the Size. It runs its own entrypoint, with `/work` mounted. jr2 adds
|
|
658
|
+
* nothing to it. Default: none. Without `resources`, it has no limit of its own and shares the
|
|
659
|
+
* Size of the Sandbox. */
|
|
660
|
+
user?: string | UserContainer;
|
|
661
|
+
// Agent Substrate's template size (ADR-0060), so the Workspace moves there unchanged. A fact of
|
|
662
|
+
// the Machine, like its image: the author knows the toolchain, the runner, the worker count.
|
|
397
663
|
/**
|
|
398
|
-
* The
|
|
399
|
-
* `
|
|
400
|
-
*
|
|
401
|
-
*
|
|
402
|
-
*
|
|
664
|
+
* The Size: the memory and cpu limit of the whole Sandbox, `{ limits: { memory, cpu } }`, such as
|
|
665
|
+
* `{ limits: { memory: "4Gi", cpu: "2" } }`. jr2 reserves all of it on the node: each request
|
|
666
|
+
* equals its limit. jr2 also enforces the cpu limit. Only `limits`,
|
|
667
|
+
* and in it only `memory` and `cpu`, are accepted. Default: the Instance's `sandbox.resources`,
|
|
668
|
+
* then 2Gi and 1 cpu. Under a Size that you give, a field you leave out takes the kit's value,
|
|
669
|
+
* not the Instance's. The Instance that registers the Machine can change it with
|
|
670
|
+
* `customize(machine, { resources })`.
|
|
671
|
+
*/
|
|
672
|
+
resources?: Size;
|
|
673
|
+
// The handles keep this map's order, and the kit reads nothing into it (ADR-0051). Required
|
|
674
|
+
// here, at least one; a Workspace with no Repo Slot leaves it out, which is the
|
|
675
|
+
// `NoSlotWorkspaceOptions` overload, not an empty map. `repos: open` is the shape a packaged
|
|
676
|
+
// Machine takes when its body enumerates its checkouts rather than naming them
|
|
677
|
+
// (`@jr2/machines`'s `task`, whose first slot is the one the coder edits).
|
|
678
|
+
/**
|
|
679
|
+
* The Repo Slots: the repositories that the Sandbox clones, each under a name of your Machine's
|
|
680
|
+
* choice. The name is the key of `workspace.repos` in the body and the directory under `/work`.
|
|
681
|
+
* At least one slot. Leave `repos` out for a Workspace with no Repo Slot (see
|
|
682
|
+
* {@link NoSlotWorkspaceOptions}). Each value is one of:
|
|
683
|
+
* - a url, or `{ url, ref }`, where `ref` is the branch or commit that the Worktree starts from
|
|
684
|
+
* (default: the Repo's default branch);
|
|
685
|
+
* - `open`, for the Instance that registers the Machine to bind with `customize()`;
|
|
686
|
+
* - a function of the run input, `({ input }) => url` or `{ url, ref }`, when each run chooses.
|
|
403
687
|
*
|
|
404
|
-
* Or
|
|
405
|
-
* one with `customize
|
|
406
|
-
* Machine takes when its body enumerates its checkouts rather than naming them (`@jr2/machines`'s
|
|
407
|
-
* `task`, whose first slot is the one the coder edits). Under it `TSlots` is `string`, and the body's handles must say so.
|
|
688
|
+
* Or `repos: open` for the whole map: the Machine names no slot, and the Instance that registers
|
|
689
|
+
* it names each one with `customize()`. The body's input is then `Workspaced<RunInput, string>`.
|
|
408
690
|
*/
|
|
409
691
|
repos: Record<TSlots, RepoSlot<TInput>> | typeof open;
|
|
692
|
+
// Naming is placement (ADR-0059): a pod gets the Held secrets something in it names — this
|
|
693
|
+
// list, and the model keys of the Agents whose Turns run here, which their providers claim
|
|
694
|
+
// without being listed.
|
|
695
|
+
/**
|
|
696
|
+
* The Held secrets that this Sandbox can use: names from the `secrets` list in `jr2.config.ts`.
|
|
697
|
+
* The Sandbox's Custodian holds each value and puts it into requests to the hosts of that Held
|
|
698
|
+
* secret. The Agent sees only a Stand-in. You need not list the model key of an Agent that runs
|
|
699
|
+
* here: its provider claims it. Each name once.
|
|
700
|
+
*/
|
|
701
|
+
secrets?: readonly string[];
|
|
702
|
+
// The container exists only when an Effect below this `workspace()` is placed there (ADR-0068,
|
|
703
|
+
// ADR-0005). It runs as its own `USER`.
|
|
704
|
+
/**
|
|
705
|
+
* The image of the Sandbox Effector. This container runs three kinds of Effect for this
|
|
706
|
+
* Workspace: a `push`, an `http` Effect with a Worktree file as its body, and a `command` with an
|
|
707
|
+
* Effect secret in its `env`. It gets the Effect secrets that those Effects name. Give an image
|
|
708
|
+
* when those Effects need tools or credentials that you keep out of the Sandbox Image, such as
|
|
709
|
+
* `terraform`. A `file:` URL or a registry ref, as for `image`. Default: the Sandbox Image,
|
|
710
|
+
* which already holds `git`.
|
|
711
|
+
*/
|
|
712
|
+
// The default is the Sandbox Image, whose floor already holds `git` (ADR-0037).
|
|
713
|
+
effector?: { image: string };
|
|
410
714
|
};
|
|
411
715
|
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
*/
|
|
716
|
+
// How a Workspace with a declared door is configured (ADR-0012, ADR-0033) — the wrapper's own
|
|
717
|
+
// run-input schema, what the pod is made of, and the mapping from what comes through the door to
|
|
718
|
+
// workspace vocabulary.
|
|
719
|
+
/** The options of a `workspace()` that declares the input that starts a run. */
|
|
417
720
|
export type WorkspaceOptions<TSchema extends z.ZodObject, TSlots extends string> = SandboxOptions<
|
|
418
721
|
TSlots,
|
|
419
722
|
z.infer<TSchema>
|
|
420
723
|
> & {
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
*
|
|
724
|
+
// Deliberately NOT the body's schema (ADR-0033): the body is fed the run input PLUS the injected
|
|
725
|
+
// `workspace` handles ({@link Workspaced}), which no caller can send, so the body's contract is
|
|
726
|
+
// the door plus something that does not exist yet. Symmetric with `PoolSpec.input`.
|
|
727
|
+
/** The input that starts a run, as a `z.object`. jr2 checks each start against it. It also types
|
|
728
|
+
* the `input` of `spec` and of a per-run Repo Slot, and the body must accept it plus the
|
|
729
|
+
* `workspace` handles. Write it here, not on the body. */
|
|
426
730
|
input: TSchema;
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
731
|
+
// ADR-0012's boundary: workflow configuration never enters the spec.
|
|
732
|
+
/**
|
|
733
|
+
* A function from the run input to the Workspace's {@link WorkspaceSpec}: the `branch` it works
|
|
734
|
+
* on, and optionally `reviewSha` and `workGroup`. jr2 calls it when the run enters the
|
|
735
|
+
* Workspace.
|
|
736
|
+
*
|
|
737
|
+
* The input of `spec` also holds `instanceId`, the seed Instance ID of the run (see
|
|
738
|
+
* {@link HostInjectedInput}). Use it to give each run a branch of its own:
|
|
739
|
+
* ``spec: ({ input }) => ({ branch: `fix/${input.instanceId}` })``. Only a root `workspace()` gets
|
|
740
|
+
* it from jr2. A `workspace()` that a different Machine invokes gets only the input that its
|
|
741
|
+
* parent gives it, so that parent must give `instanceId` when `spec` reads it.
|
|
742
|
+
*/
|
|
743
|
+
// The type says `instanceId` is present also below the root, where only the parent can supply it:
|
|
744
|
+
// the same permissive answer the body guard gives (BodyAcceptsDoor), because no type can see where
|
|
745
|
+
// the wrapper sits. The alternative, `Partial<HostInjectedInput>`, makes each root `spec` that reads
|
|
746
|
+
// it write a fallback for a value that is always there.
|
|
747
|
+
spec: (args: { input: WithoutEmptyIndex<z.infer<TSchema>> & HostInjectedInput }) => WorkspaceSpec;
|
|
430
748
|
};
|
|
431
749
|
|
|
750
|
+
// How a Workspace with NO declared door is configured: absence is permissive (ADR-0033), so jr2
|
|
751
|
+
// has nothing to infer from and says `unknown` rather than `any` — an honest "jr2 does not know",
|
|
752
|
+
// which the mapper must narrow before it reads a field.
|
|
432
753
|
/**
|
|
433
|
-
*
|
|
434
|
-
*
|
|
435
|
-
*
|
|
436
|
-
*
|
|
437
|
-
*
|
|
438
|
-
*
|
|
754
|
+
* The options of a `workspace()` with no `input`: it starts with any input. The `input` of `spec`
|
|
755
|
+
* is then `unknown`, and `spec` must check it before it reads a field.
|
|
756
|
+
*
|
|
757
|
+
* Use it for a Workspace that a Pool or another Machine starts. Give the parameter of `spec` a
|
|
758
|
+
* type to say what that input is: `spec: ({ input }: { input: Item }) => …`. That also types the
|
|
759
|
+
* input of the Workspace. For a Workspace that a caller starts, declare `input`.
|
|
439
760
|
*/
|
|
440
761
|
export type PermissiveWorkspaceOptions<TSlots extends string, TInput = unknown> = SandboxOptions<TSlots, TInput> & {
|
|
441
|
-
|
|
442
|
-
|
|
762
|
+
// Spelled out so a declared schema can never fall through to the permissive overload, where the
|
|
763
|
+
// body's door would go unchecked.
|
|
764
|
+
/** Not present: a `workspace()` without `input` starts with any input. */
|
|
443
765
|
input?: never;
|
|
766
|
+
/** A function from the input to the Workspace's {@link WorkspaceSpec}. Its `input` is
|
|
767
|
+
* `unknown` unless you give the parameter a type: `spec: ({ input }: { input: Item }) => …`. */
|
|
444
768
|
spec: (args: { input: TInput }) => WorkspaceSpec;
|
|
445
769
|
};
|
|
446
770
|
|
|
771
|
+
// A Workspace with no Repo Slot (ADR-0051): a Sandbox for one unit of work whose Agents and
|
|
772
|
+
// Effects share `/work`, and no Worktree is in it — the steps of one job. Its own option types, not a
|
|
773
|
+
// conditional on `TSlots`, because the slot overloads infer `TSlots` from `repos`, and no
|
|
774
|
+
// inference reaches through a conditional type.
|
|
775
|
+
/** The options of a `workspace()` with no Repo Slot that declares the input that starts a run.
|
|
776
|
+
* `repos` is left out, and `spec` is optional. */
|
|
777
|
+
export type NoSlotWorkspaceOptions<TSchema extends z.ZodObject> = Omit<SandboxOptions<never>, "repos"> & {
|
|
778
|
+
/** Not present: the Workspace has no Repo Slot. Its Agents work in `/work`. */
|
|
779
|
+
repos?: never;
|
|
780
|
+
/** The input that starts a run, as a `z.object`, as in {@link WorkspaceOptions}. */
|
|
781
|
+
input: TSchema;
|
|
782
|
+
/** A function from the run input to the Workspace's {@link NoSlotSpec}: only `workGroup`.
|
|
783
|
+
* Default: no `workGroup`. */
|
|
784
|
+
spec?: (args: { input: WithoutEmptyIndex<z.infer<TSchema>> & HostInjectedInput }) => NoSlotSpec;
|
|
785
|
+
};
|
|
786
|
+
|
|
787
|
+
/** The options of a `workspace()` with no Repo Slot and no `input`: it starts with any input. */
|
|
788
|
+
export type PermissiveNoSlotWorkspaceOptions<TInput = unknown> = Omit<SandboxOptions<never>, "repos"> & {
|
|
789
|
+
/** Not present: the Workspace has no Repo Slot. Its Agents work in `/work`. */
|
|
790
|
+
repos?: never;
|
|
791
|
+
/** Not present: a `workspace()` without `input` starts with any input. */
|
|
792
|
+
input?: never;
|
|
793
|
+
/** A function from the input to the Workspace's {@link NoSlotSpec}: only `workGroup`. Default:
|
|
794
|
+
* no `workGroup`. */
|
|
795
|
+
spec?: (args: { input: TInput }) => NoSlotSpec;
|
|
796
|
+
};
|
|
797
|
+
|
|
447
798
|
/**
|
|
448
|
-
*
|
|
449
|
-
*
|
|
450
|
-
*
|
|
451
|
-
*
|
|
452
|
-
*
|
|
453
|
-
*
|
|
799
|
+
* Run a body Machine in a Sandbox: a pod of its own, with a Worktree of each Repo Slot on one
|
|
800
|
+
* branch. The result is a Machine. Register it as a Workflow, or invoke it from another Machine as
|
|
801
|
+
* an actor slot.
|
|
802
|
+
*
|
|
803
|
+
* When a run enters it, the Workspace makes the Sandbox and waits for room on a node, with no
|
|
804
|
+
* deadline. It clones each Repo Slot and makes its Worktree on the `branch` that `spec` gives. Then
|
|
805
|
+
* it runs the body with the run input plus `workspace`, the handles (see {@link Workspaced}). The
|
|
806
|
+
* Agents with Working tools and the `command` Effects in the body run in that Sandbox. An Agent
|
|
807
|
+
* with `workspace: "none"` runs on the Instance Harness. When the body reaches a final state, the
|
|
808
|
+
* Workspace deletes the Sandbox, and its output is the body's output. Push the branch before
|
|
809
|
+
* then: jr2 deletes the Sandbox's commits with it.
|
|
810
|
+
*
|
|
811
|
+
* Leave out `repos` for a Workspace with no Repo Slot: its Agents and `command` Effects share
|
|
812
|
+
* `/work`, as the steps of one job share a folder, and `spec` is optional and gives only
|
|
813
|
+
* `workGroup`. The body's input is then `Workspaced<RunInput, never>`. An `http` Effect sends a
|
|
814
|
+
* file of `/work` with `file(path)`. A `push`, and a `command` with an Effect secret in its `env`,
|
|
815
|
+
* need a Repo Slot, so jr2 refuses them there. jr2 deletes `/work` with the Sandbox: send what
|
|
816
|
+
* must stay before the body ends.
|
|
817
|
+
*
|
|
818
|
+
* Give each run a different branch, so that two runs do not push to one branch. Make it from a
|
|
819
|
+
* field of the run input, or from `instanceId` when the run input has no field that is different
|
|
820
|
+
* for each run: ``spec: ({ input }) => ({ branch: `fix/${input.instanceId}` })``. The input of
|
|
821
|
+
* `spec` holds `instanceId` with no cast, also for the door `z.object({})`. Only a root
|
|
822
|
+
* `workspace()` gets `instanceId` from jr2. A `workspace()` that a different Machine invokes gets
|
|
823
|
+
* it only when its parent gives it.
|
|
824
|
+
*
|
|
825
|
+
* The Sandbox is Lost when its pod is evicted or its node goes. Then the body gets the event
|
|
826
|
+
* `workspace.lost`, and the body decides what to do. jr2 does not make the Lost Sandbox again.
|
|
827
|
+
*
|
|
828
|
+
* When the body fails (it invokes a `fail()` slot), the Workspace deletes the Sandbox, and then the
|
|
829
|
+
* same Failure goes up to the parent. With no parent to catch it, the run ends `failed`. When the
|
|
830
|
+
* body has a defect (an error that is not a Failure), the run faults: it ends `error`, and the
|
|
831
|
+
* Sandbox stays for you to inspect, until jr2's operator removes it after an idle timeout.
|
|
832
|
+
*
|
|
833
|
+
* Give the run input to `input`, not to the body: a body with an `input` schema of its own is
|
|
834
|
+
* refused. The types refuse a body whose input does not accept the run input plus the handles,
|
|
835
|
+
* with the message `the body's declared input must accept the door plus the injected handles`.
|
|
836
|
+
*
|
|
837
|
+
* It refuses, when you call it:
|
|
838
|
+
* - a body with a state that is not final, when that state and each state above it have no
|
|
839
|
+
* `workspace.lost` route: ``workspace(): the body "<id>" has a state "<state>" that is not final,
|
|
840
|
+
* and no route for "workspace.lost" is on it or on a state above — jr2 sends workspace.lost to the
|
|
841
|
+
* body when its Sandbox is gone, and with no route the run faults: route it on the body's root,
|
|
842
|
+
* as on: { "workspace.lost": "<the state that handles it>" } (ADR-0074)``
|
|
843
|
+
* - a body with its own `input`: ``workspace(): the body "<id>" declares its own run input, which
|
|
844
|
+
* nothing will ever serve or enforce — the wrapper feeds the body the run input PLUS the injected
|
|
845
|
+
* `workspace` handles, so the body's schema is not the door. Move it to the wrapper:
|
|
846
|
+
* workspace(body, { input, spec }) (ADR-0033).``
|
|
847
|
+
* - an empty `image`: ``workspace(): `image` must be a non-empty string (got <value>) — either a
|
|
848
|
+
* `file:` URL to a docker context this module ships (`import.meta.resolve("./image")`) or a
|
|
849
|
+
* registry ref (ADR-0037).``
|
|
850
|
+
* - an empty `repos`: ``workspace(): `repos: {}` names no Repo Slot — leave `repos` out for a
|
|
851
|
+
* Workspace with no Repo Slot, or name a slot: `repos: { app: "https://…" }` (ADR-0051).``
|
|
852
|
+
* - a `repos` that is not a map or `open`: ``workspace(): `repos` must be a map of Repo Slots —
|
|
853
|
+
* `repos: { app: "https://…" }` — or `open` whole, for a map the consumer names; leave it out
|
|
854
|
+
* for a Workspace with no Repo Slot (ADR-0051).``
|
|
855
|
+
* - a bad Repo Slot name or value (see {@link RepoSlot})
|
|
856
|
+
* - a `spec` that is not a function, or no `spec` on a Workspace with Repo Slots: ``workspace():
|
|
857
|
+
* `spec` must be a function from the run input to the spec — spec: ({ input }) => ({ branch }) —
|
|
858
|
+
* and only a Workspace with no Repo Slot may leave it out (ADR-0051).``
|
|
859
|
+
* - a bad `secrets` list: ``workspace(): `secrets` must be a list of distinct names from
|
|
860
|
+
* jr2.config.ts's `secrets` list (got <value>) — secrets: ["JIRA_TOKEN"] (ADR-0059).``
|
|
861
|
+
* - a bad `effector`: ``workspace(): `effector` is { image } — a `file:` URL to a docker context
|
|
862
|
+
* or a registry ref (got <value>) — effector: { image: import.meta.resolve("./effector") }
|
|
863
|
+
* (ADR-0068).``
|
|
864
|
+
* - a `user` that is not an image or `{ image, resources? }`: ``workspace(): `user` must be a
|
|
865
|
+
* non-empty string (an image), or { image, resources? } (got <value>) — either a `file:` URL to a
|
|
866
|
+
* docker context this module ships (`import.meta.resolve("./image")`) or a registry ref
|
|
867
|
+
* (ADR-0037, ADR-0060).``, or ``workspace(): user.<key> is not accepted — the User Container is {
|
|
868
|
+
* image, resources? } (ADR-0005, ADR-0060)`` (see {@link UserContainer})
|
|
869
|
+
* - a `resources` key other than `limits` or `requests`: ``workspace(): resources.<key> is not accepted — a
|
|
870
|
+
* Size is { limits: { memory?, cpu? } } and nothing else — Agent Substrate's template size
|
|
871
|
+
* (ADR-0060)``, or the same text for `resources.limits.<key>`. The other texts are in
|
|
872
|
+
* {@link Size}.
|
|
873
|
+
*
|
|
874
|
+
* And when a run enters it, a spec without a `branch` (or with `branch: "default"`), or with a bad
|
|
875
|
+
* `reviewSha` or `workGroup`: ``workspace spec invalid: <fields> — the spec derives from run input;
|
|
876
|
+
* does `jr2 run --input` carry every field this workflow's workspace() mapping reads?``. With no
|
|
877
|
+
* Repo Slot, a spec with a `branch` or a `reviewSha`: ``workspace spec invalid: <field> (the
|
|
878
|
+
* Workspace has no Repo Slot, so it has no branch and no review Worktree; its spec gives only
|
|
879
|
+
* workGroup) …``
|
|
880
|
+
*
|
|
881
|
+
* @param body - The Machine to run in the Sandbox. Its input is `Workspaced<RunInput, Slots>`.
|
|
882
|
+
* @param options - What the Sandbox is made of (`image`, `user`, `resources`, `effector`), the
|
|
883
|
+
* Repo Slots it clones (`repos`), the Held secrets it can use (`secrets`), the run input
|
|
884
|
+
* (`input`), and the function from the run input to the branch (`spec`).
|
|
885
|
+
* @returns A Machine whose input is the run input and whose output is the body's output.
|
|
886
|
+
*
|
|
887
|
+
* @example
|
|
888
|
+
* ```ts
|
|
889
|
+
* import { z } from "zod";
|
|
890
|
+
* import {
|
|
891
|
+
* command,
|
|
892
|
+
* fail,
|
|
893
|
+
* jr2Setup,
|
|
894
|
+
* workspace,
|
|
895
|
+
* type CommandOutput,
|
|
896
|
+
* type Workspaced,
|
|
897
|
+
* } from "@jr2/orchestrator";
|
|
898
|
+
*
|
|
899
|
+
* const runInput = z.object({ ticket: z.string(), repo: z.string() });
|
|
900
|
+
* type BodyInput = Workspaced<z.infer<typeof runInput>, "app" | "docs">;
|
|
901
|
+
*
|
|
902
|
+
* const passed = (output: CommandOutput): boolean =>
|
|
903
|
+
* output.outcome === "exited" && output.code === 0;
|
|
904
|
+
*
|
|
905
|
+
* const body = jr2Setup({
|
|
906
|
+
* types: {} as { input: BodyInput },
|
|
907
|
+
* actors: {
|
|
908
|
+
* build: command(["npm", "run", "build"], { slot: "app" }),
|
|
909
|
+
* fail: fail(),
|
|
910
|
+
* },
|
|
911
|
+
* }).createMachine({
|
|
912
|
+
* id: "build",
|
|
913
|
+
* on: { "workspace.lost": ".lost" },
|
|
914
|
+
* initial: "building",
|
|
915
|
+
* states: {
|
|
916
|
+
* building: {
|
|
917
|
+
* invoke: {
|
|
918
|
+
* src: "build",
|
|
919
|
+
* onDone: [
|
|
920
|
+
* { guard: ({ event }) => passed(event.output), target: "built" },
|
|
921
|
+
* { target: "broken" },
|
|
922
|
+
* ],
|
|
923
|
+
* },
|
|
924
|
+
* },
|
|
925
|
+
* built: { type: "final" },
|
|
926
|
+
* broken: { invoke: { src: "fail", input: { reason: "npm run build did not pass" } } },
|
|
927
|
+
* lost: { invoke: { src: "fail", input: { reason: "the Sandbox is gone" } } },
|
|
928
|
+
* },
|
|
929
|
+
* });
|
|
930
|
+
*
|
|
931
|
+
* export const machine = workspace(body, {
|
|
932
|
+
* input: runInput,
|
|
933
|
+
* image: "docker.io/library/node:24",
|
|
934
|
+
* resources: { limits: { memory: "4Gi", cpu: "2" } },
|
|
935
|
+
* repos: {
|
|
936
|
+
* app: ({ input }) => input.repo,
|
|
937
|
+
* docs: { url: "https://github.com/you/docs.git", ref: "main" },
|
|
938
|
+
* },
|
|
939
|
+
* spec: ({ input }) => ({ branch: `fix/${input.ticket}` }),
|
|
940
|
+
* });
|
|
941
|
+
* ```
|
|
942
|
+
*
|
|
943
|
+
* @example
|
|
944
|
+
* ```ts
|
|
945
|
+
* import { z } from "zod";
|
|
946
|
+
* import {
|
|
947
|
+
* command,
|
|
948
|
+
* fail,
|
|
949
|
+
* jr2Setup,
|
|
950
|
+
* workspace,
|
|
951
|
+
* type CommandOutput,
|
|
952
|
+
* type Workspaced,
|
|
953
|
+
* } from "@jr2/orchestrator";
|
|
954
|
+
*
|
|
955
|
+
* const passed = (output: CommandOutput): boolean =>
|
|
956
|
+
* output.outcome === "exited" && output.code === 0;
|
|
957
|
+
*
|
|
958
|
+
* // The run input has no field that is different for each run, so the branch comes from
|
|
959
|
+
* // `instanceId`.
|
|
960
|
+
* const runInput = z.object({});
|
|
961
|
+
*
|
|
962
|
+
* const body = jr2Setup({
|
|
963
|
+
* types: {} as { input: Workspaced<z.infer<typeof runInput>, "app"> },
|
|
964
|
+
* actors: { test: command(["npm", "test"], { slot: "app" }), fail: fail() },
|
|
965
|
+
* }).createMachine({
|
|
966
|
+
* id: "nightly",
|
|
967
|
+
* on: { "workspace.lost": ".lost" },
|
|
968
|
+
* initial: "testing",
|
|
969
|
+
* states: {
|
|
970
|
+
* testing: {
|
|
971
|
+
* invoke: {
|
|
972
|
+
* src: "test",
|
|
973
|
+
* onDone: [
|
|
974
|
+
* { guard: ({ event }) => passed(event.output), target: "tested" },
|
|
975
|
+
* { target: "red" },
|
|
976
|
+
* ],
|
|
977
|
+
* },
|
|
978
|
+
* },
|
|
979
|
+
* tested: { type: "final" },
|
|
980
|
+
* red: { invoke: { src: "fail", input: { reason: "npm test did not pass" } } },
|
|
981
|
+
* lost: { invoke: { src: "fail", input: { reason: "the Sandbox is gone" } } },
|
|
982
|
+
* },
|
|
983
|
+
* });
|
|
984
|
+
*
|
|
985
|
+
* export const machine = workspace(body, {
|
|
986
|
+
* input: runInput,
|
|
987
|
+
* image: "docker.io/library/node:24",
|
|
988
|
+
* repos: { app: "https://github.com/you/app.git" },
|
|
989
|
+
* spec: ({ input }) => ({ branch: `fix/${input.instanceId}` }),
|
|
990
|
+
* });
|
|
991
|
+
* ```
|
|
992
|
+
*
|
|
993
|
+
* @see docs/concepts/workspaces.md
|
|
994
|
+
* @see docs/guides/write-a-workflow.md
|
|
995
|
+
* @see docs/guides/size-a-workspace.md
|
|
996
|
+
* @see docs/guides/add-tools-to-the-sandbox.md
|
|
997
|
+
* @see docs/reference/decisions/0012-workspace-wrapper-machine.md
|
|
998
|
+
* @see docs/reference/decisions/0051-a-repo-is-a-slot-on-the-workspace-and-a-cache-on-the-node.md
|
|
454
999
|
*/
|
|
455
|
-
|
|
1000
|
+
// `TSlots` defaults to `never`: with `repos` left out there is nothing to infer it from, and the
|
|
1001
|
+
// Workspace has no Repo Slot (ADR-0051). The no-slot options are a union member, not overloads of
|
|
1002
|
+
// their own, so a call with slots reports its error where it always did.
|
|
1003
|
+
export function workspace<TSchema extends z.ZodObject, TBody extends AnyStateMachine, TSlots extends string = never>(
|
|
456
1004
|
body: TBody & BodyAcceptsDoor<TBody, z.infer<TSchema>, TSlots>,
|
|
457
|
-
options: WorkspaceOptions<TSchema, TSlots>,
|
|
458
|
-
|
|
459
|
-
|
|
1005
|
+
options: WorkspaceOptions<TSchema, TSlots> | NoSlotWorkspaceOptions<TSchema>,
|
|
1006
|
+
// The input is the door without the empty door's `Record<string, never>` (`{}` for `z.object({})`), so
|
|
1007
|
+
// a parent can give `instanceId` to a nested `spec` that reads it (ADR-0033).
|
|
1008
|
+
): WorkspaceMachine<WithoutEmptyIndex<z.infer<TSchema>>, OutputFrom<TBody>, TBody, TSlots>;
|
|
1009
|
+
/**
|
|
1010
|
+
* Run a body Machine in a Sandbox, with no `input` schema: the Workspace starts with any input. Use
|
|
1011
|
+
* it for a Workspace that a Pool or another Machine starts. {@link workspace} tells the rest, and
|
|
1012
|
+
* what it refuses.
|
|
1013
|
+
*
|
|
1014
|
+
* @param body - The Machine to run in the Sandbox. Its input is the input of the Workspace plus
|
|
1015
|
+
* `workspace`, the handles.
|
|
1016
|
+
* @param options - The options of the Sandbox, and `spec`. There is no `input`.
|
|
1017
|
+
* @returns A Machine whose input is the run input and whose output is the body's output.
|
|
1018
|
+
*
|
|
1019
|
+
* @see docs/concepts/workspaces.md
|
|
1020
|
+
* @see docs/reference/decisions/0033-a-machine-declares-the-input-that-starts-it.md
|
|
1021
|
+
*/
|
|
1022
|
+
export function workspace<TBody extends AnyStateMachine, TSlots extends string = never, TInput = unknown>(
|
|
460
1023
|
body: TBody & BodyAcceptsSlots<TBody, TSlots>,
|
|
461
|
-
options: PermissiveWorkspaceOptions<TSlots, TInput>,
|
|
1024
|
+
options: PermissiveWorkspaceOptions<TSlots, TInput> | PermissiveNoSlotWorkspaceOptions<TInput>,
|
|
462
1025
|
): WorkspaceMachine<TInput, OutputFrom<TBody>, TBody, TSlots>;
|
|
463
1026
|
export function workspace(
|
|
464
1027
|
body: AnyStateMachine,
|
|
465
|
-
options: SandboxOptions<string, any> & {
|
|
1028
|
+
options: Omit<SandboxOptions<string, any>, "repos"> & {
|
|
1029
|
+
repos?: SandboxOptions<string, any>["repos"];
|
|
1030
|
+
input?: z.ZodObject;
|
|
1031
|
+
spec?: (args: { input: any }) => WorkspaceSpec | NoSlotSpec;
|
|
1032
|
+
},
|
|
466
1033
|
): AnyStateMachine {
|
|
1034
|
+
// `spec` maps the wrapper's input to the workspace-domain spec (ADR-0012); the body receives
|
|
1035
|
+
// the wrapper's input plus the handles — `Workspaced<TInput, TSlots>`, which is also what the
|
|
1036
|
+
// declared door checks the body against. A Failure of the body is caught: the wrapper destroys
|
|
1037
|
+
// the Sandbox and fails again with the same payload (ADR-0073). Every other body error (a defect)
|
|
1038
|
+
// is not caught: it faults the run (RunStatus.fault) and leaves the Sandbox to the operator's
|
|
1039
|
+
// idle-timeout GC, so the trail stays as evidence.
|
|
1040
|
+
//
|
|
467
1041
|
// A body that still declares its own run input is a dead declaration under the door design: the
|
|
468
1042
|
// wrapper never serves it, never validates against it, and feeds the body something it does not
|
|
469
1043
|
// describe. Silence would leave an author believing a contract that nothing enforces (ADR-0033).
|
|
470
|
-
|
|
1044
|
+
// A body that `setup()` made keeps its zod `input` in the config with no door attached, so the
|
|
1045
|
+
// check reads the config too: else the author loses the door with no message.
|
|
1046
|
+
if (declaresRunInput(body)) {
|
|
471
1047
|
throw new Error(
|
|
472
1048
|
`workspace(): the body "${body.id}" declares its own run input, which nothing will ever ` +
|
|
473
1049
|
"serve or enforce — the wrapper feeds the body the run input PLUS the injected `workspace` " +
|
|
@@ -475,34 +1051,82 @@ export function workspace(
|
|
|
475
1051
|
"workspace(body, { input, spec }) (ADR-0033).",
|
|
476
1052
|
);
|
|
477
1053
|
}
|
|
1054
|
+
// A body state that cannot route `workspace.lost` (ADR-0074): with no route, the body would show
|
|
1055
|
+
// healthy on the feed with no Sandbox. Here, because `workspace()` is the one place that knows
|
|
1056
|
+
// that a Machine is a body. A body that is the kit's own wrapper (a `workspace()`, a `pool()`) is
|
|
1057
|
+
// not judged: its states are jr2's, and the walk refuses a Workspace below a Workspace (ADR-0059).
|
|
1058
|
+
if (!composesSandbox(body) && wrapperBodyOf(body) === undefined) refuseUnroutedLost(body);
|
|
478
1059
|
// Static, so checkable NOW rather than at the first provision — an empty or non-string image is
|
|
479
1060
|
// the same derives-from-a-typo bug `assertSpec` catches for the spec, one build earlier.
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
);
|
|
488
|
-
}
|
|
1061
|
+
const image = options.image;
|
|
1062
|
+
if (image !== undefined && (typeof image !== "string" || !image)) {
|
|
1063
|
+
throw new Error(
|
|
1064
|
+
`workspace(): \`image\` must be a non-empty string (got ${JSON.stringify(image)}) — either a \`file:\` ` +
|
|
1065
|
+
'URL to a docker context this module ships (`import.meta.resolve("./image")`) or a registry ref ' +
|
|
1066
|
+
"(ADR-0037).",
|
|
1067
|
+
);
|
|
489
1068
|
}
|
|
1069
|
+
if (options.user !== undefined) assertUserSeat("workspace()", options.user);
|
|
1070
|
+
// The Size, refused by field where it is written (ADR-0060): only the two limits. A request, or
|
|
1071
|
+
// any key Substrate would refuse, is a dependency the move to it would drop.
|
|
1072
|
+
if (options.resources !== undefined) assertSize("workspace(): resources", options.resources);
|
|
490
1073
|
// The slots, checked NOW for the same reason (ADR-0051): every value is one of the three forms,
|
|
491
|
-
// every key is a directory name
|
|
492
|
-
//
|
|
493
|
-
// An Open map defers all of that to the `customize` that
|
|
494
|
-
|
|
1074
|
+
// and every key is a directory name. Left out, the Workspace has no Repo Slot and its steps share
|
|
1075
|
+
// `/work`; written empty, it is refused, so "no slot" has one spelling and an empty map never
|
|
1076
|
+
// reads as a map someone forgot to fill. An Open map defers all of that to the `customize` that
|
|
1077
|
+
// fills it, which still asks for at least one: an Open map says the composer names the slots.
|
|
1078
|
+
const repos = options.repos ?? {};
|
|
495
1079
|
if (repos !== open) {
|
|
496
|
-
if (typeof repos !== "object" || repos === null || Array.isArray(repos)
|
|
1080
|
+
if (typeof repos !== "object" || repos === null || Array.isArray(repos)) {
|
|
1081
|
+
throw new Error(
|
|
1082
|
+
'workspace(): `repos` must be a map of Repo Slots — `repos: { app: "https://…" }` — or `open` whole, ' +
|
|
1083
|
+
"for a map the consumer names; leave it out for a Workspace with no Repo Slot (ADR-0051).",
|
|
1084
|
+
);
|
|
1085
|
+
}
|
|
1086
|
+
if (options.repos !== undefined && Object.keys(repos).length === 0) {
|
|
497
1087
|
throw new Error(
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
"`open` whole, for a map the consumer names (ADR-0051).",
|
|
1088
|
+
"workspace(): `repos: {}` names no Repo Slot — leave `repos` out for a Workspace with no Repo Slot, " +
|
|
1089
|
+
'or name a slot: `repos: { app: "https://…" }` (ADR-0051).',
|
|
501
1090
|
);
|
|
502
1091
|
}
|
|
503
1092
|
for (const [slot, value] of Object.entries(repos)) assertRepoSlot("workspace()", slot, value);
|
|
504
1093
|
}
|
|
505
|
-
|
|
1094
|
+
// The Held secrets it names (ADR-0059), checked NOW: each a name, each once. Whether the list
|
|
1095
|
+
// holds the name is `jr2 up`'s to say — the config is not here.
|
|
1096
|
+
const secrets = options.secrets;
|
|
1097
|
+
if (secrets !== undefined) {
|
|
1098
|
+
const names = Array.isArray(secrets) ? (secrets as unknown[]) : undefined;
|
|
1099
|
+
const bad = names?.find((n) => typeof n !== "string" || !n);
|
|
1100
|
+
if (names === undefined || bad !== undefined || new Set(names).size !== names.length) {
|
|
1101
|
+
throw new Error(
|
|
1102
|
+
`workspace(): \`secrets\` must be a list of distinct names from jr2.config.ts's \`secrets\` list (got ` +
|
|
1103
|
+
`${JSON.stringify(secrets)}) — secrets: ["JIRA_TOKEN"] (ADR-0059).`,
|
|
1104
|
+
);
|
|
1105
|
+
}
|
|
1106
|
+
}
|
|
1107
|
+
const effector = options.effector;
|
|
1108
|
+
if (
|
|
1109
|
+
effector !== undefined &&
|
|
1110
|
+
(typeof effector !== "object" ||
|
|
1111
|
+
effector === null ||
|
|
1112
|
+
typeof effector.image !== "string" ||
|
|
1113
|
+
!effector.image ||
|
|
1114
|
+
Object.keys(effector).some((k) => k !== "image"))
|
|
1115
|
+
) {
|
|
1116
|
+
throw new Error(
|
|
1117
|
+
`workspace(): \`effector\` is { image } — a \`file:\` URL to a docker context or a registry ref (got ` +
|
|
1118
|
+
`${JSON.stringify(effector)}) — effector: { image: import.meta.resolve("./effector") } (ADR-0068).`,
|
|
1119
|
+
);
|
|
1120
|
+
}
|
|
1121
|
+
// A Workspace with slots cuts a branch, so it needs `spec`; one with none may leave it out. The
|
|
1122
|
+
// types say so; this says so to a caller the types do not see, by name, before any run.
|
|
1123
|
+
if (options.spec !== undefined ? typeof options.spec !== "function" : repos === open || Object.keys(repos).length) {
|
|
1124
|
+
throw new Error(
|
|
1125
|
+
"workspace(): `spec` must be a function from the run input to the spec — spec: ({ input }) => ({ branch }) — " +
|
|
1126
|
+
"and only a Workspace with no Repo Slot may leave it out (ADR-0051).",
|
|
1127
|
+
);
|
|
1128
|
+
}
|
|
1129
|
+
const wrapper = buildWorkspaceMachine(body, options.spec ?? (() => ({})));
|
|
506
1130
|
// The wrapper is TRANSPARENT to its body (ADR-0049): `customize(machine, { agents })` on a
|
|
507
1131
|
// Workspace means the Machine inside, so the composer never spells `body` and never has to know
|
|
508
1132
|
// that jr2 wrapped anything.
|
|
@@ -516,6 +1140,9 @@ export function workspace(
|
|
|
516
1140
|
...(options.image !== undefined ? { image: options.image } : {}),
|
|
517
1141
|
...(options.user !== undefined ? { user: options.user } : {}),
|
|
518
1142
|
repos: repos === open ? open : { ...repos },
|
|
1143
|
+
...(options.resources !== undefined ? { resources: options.resources } : {}),
|
|
1144
|
+
...(secrets !== undefined ? { secrets: [...secrets] } : {}),
|
|
1145
|
+
...(effector !== undefined ? { effector: { image: effector.image } } : {}),
|
|
519
1146
|
});
|
|
520
1147
|
// The body's vocabulary stays the BODY's (ADR-0011, ADR-0049): the wrapper declares no events
|
|
521
1148
|
// of its own and merges none, because the actors that use the body's names resolve against the
|
|
@@ -536,16 +1163,29 @@ export function workspace(
|
|
|
536
1163
|
* unchecked, it survives until the attach script's string ops and dies as "Cannot read properties
|
|
537
1164
|
* of undefined", with a Sandbox already provisioned and nothing pointing back at the input.
|
|
538
1165
|
*/
|
|
539
|
-
function assertSpec(spec:
|
|
1166
|
+
function assertSpec(spec: AnySpec, noSlot: boolean): void {
|
|
1167
|
+
if (typeof spec !== "object" || spec === null) {
|
|
1168
|
+
throw new Error(
|
|
1169
|
+
`workspace spec invalid: the spec is ${JSON.stringify(spec)}, not an object — \`spec\` returns one, ` +
|
|
1170
|
+
"such as { branch } (ADR-0051)",
|
|
1171
|
+
);
|
|
1172
|
+
}
|
|
540
1173
|
const bad: string[] = [];
|
|
541
|
-
|
|
1174
|
+
// No Repo Slot, nothing attached (ADR-0051): a branch or a review sha would name nothing, and a
|
|
1175
|
+
// value jr2 silently ignores is the failure the refusals exist for.
|
|
1176
|
+
const nothing =
|
|
1177
|
+
"(the Workspace has no Repo Slot, so it has no branch and no review Worktree; its spec gives only workGroup)";
|
|
1178
|
+
if (noSlot) {
|
|
1179
|
+
if (spec?.branch !== undefined) bad.push(`branch ${nothing}`);
|
|
1180
|
+
if (spec?.reviewSha !== undefined) bad.push(`reviewSha ${nothing}`);
|
|
1181
|
+
} else if (typeof spec?.branch !== "string" || !spec.branch) bad.push(`branch (got ${JSON.stringify(spec?.branch)})`);
|
|
542
1182
|
// The branch Worktree is a SIBLING of the pod-local clone at `<slot>/default/` (ADR-0004), so
|
|
543
1183
|
// the one branch name that is not a worktree directory is `default`.
|
|
544
1184
|
else if (spec.branch === "default")
|
|
545
1185
|
bad.push(
|
|
546
1186
|
'branch "default" (the pod-local clone\'s own directory — a branch Worktree sits beside `default/`, ADR-0004)',
|
|
547
1187
|
);
|
|
548
|
-
if (spec?.reviewSha !== undefined && (typeof spec.reviewSha !== "string" || !spec.reviewSha))
|
|
1188
|
+
if (!noSlot && spec?.reviewSha !== undefined && (typeof spec.reviewSha !== "string" || !spec.reviewSha))
|
|
549
1189
|
bad.push(`reviewSha (got ${JSON.stringify(spec?.reviewSha)})`);
|
|
550
1190
|
// A gid, so an integer — a float or a negative becomes a pod the API server rejects at
|
|
551
1191
|
// admission, which surfaces as "never reached Ready" with nothing pointing back at the spec.
|
|
@@ -614,6 +1254,15 @@ function resolveBindings(
|
|
|
614
1254
|
return bindings;
|
|
615
1255
|
}
|
|
616
1256
|
|
|
1257
|
+
/** A spec as the wrapper holds it: a Workspace's with Repo Slots, or one with none (ADR-0051). */
|
|
1258
|
+
type AnySpec = WorkspaceSpec | NoSlotSpec;
|
|
1259
|
+
|
|
1260
|
+
/** Does this Workspace declare no Repo Slot (ADR-0051)? An Open map is not "none": its slots are
|
|
1261
|
+
* the composer's, and `customize()` names at least one. */
|
|
1262
|
+
function noSlot(slots: SandboxParts["repos"]): boolean {
|
|
1263
|
+
return slots !== open && Object.keys(slots).length === 0;
|
|
1264
|
+
}
|
|
1265
|
+
|
|
617
1266
|
/** The port-facing view of the persisted bindings, in declaration order. */
|
|
618
1267
|
function attachedRepos(bindings: Record<string, ResolvedBinding>): Array<{ slot: string; url: string; ref?: string }> {
|
|
619
1268
|
return Object.entries(bindings).map(([slot, b]) => ({
|
|
@@ -623,12 +1272,34 @@ function attachedRepos(bindings: Record<string, ResolvedBinding>): Array<{ slot:
|
|
|
623
1272
|
}));
|
|
624
1273
|
}
|
|
625
1274
|
|
|
626
|
-
function buildWorkspaceMachine(body: AnyStateMachine, spec: (args: { input: any }) =>
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
1275
|
+
function buildWorkspaceMachine(body: AnyStateMachine, spec: (args: { input: any }) => AnySpec): AnyStateMachine {
|
|
1276
|
+
/**
|
|
1277
|
+
* `placing` (ADR-0064): write the Sandbox and wait, with no deadline, for its pod to be
|
|
1278
|
+
* scheduled. jr2 cannot tell "full now" from "never fits", so a bound is the Machine's own
|
|
1279
|
+
* `after` on the state that holds the Workspace.
|
|
1280
|
+
*
|
|
1281
|
+
* The wait is said, never sent to the body: each reason the port hears is assigned into this
|
|
1282
|
+
* wrapper's context (`workspace.waiting`, where `RunStatus.waiting` reads it), and the feed gets
|
|
1283
|
+
* two lines — the wait starting, with its reason, and the wait ending ("placed after …"). A
|
|
1284
|
+
* changed reason updates the context and adds no line. A restart re-enters here: the apply is
|
|
1285
|
+
* idempotent, the watch's first list answers at once, and `since` is read back off the
|
|
1286
|
+
* wrapper's context, so neither line is said twice.
|
|
1287
|
+
*
|
|
1288
|
+
* Delete what never ran: stopped before its pod has a node — a parent `after`, a cancelled run, a
|
|
1289
|
+
* run faulted elsewhere — this Sandbox is deleted at once, because nothing on it can be inspected
|
|
1290
|
+
* and left alone it could take a node later for nobody. xstate's stop is synchronous, so the
|
|
1291
|
+
* delete rides the abort, after any write in flight has settled. A wait that fails on its own —
|
|
1292
|
+
* the Sandbox Lost before its node, a write that half landed — is the same end: the delete comes
|
|
1293
|
+
* before the fault, and the fault carries the reason. The host stopping the run for a restart is
|
|
1294
|
+
* not an end: the restored run re-enters `placing` and keeps its place. Nor is a name that a live
|
|
1295
|
+
* Workspace of the run holds when the delete would run: a parent that re-enters the state, or
|
|
1296
|
+
* catches a Failure and invokes the same Machine again, gives its new Workspace the same invoke id,
|
|
1297
|
+
* and so the same name, which that Workspace has placed again by then (ADR-0073).
|
|
1298
|
+
*/
|
|
1299
|
+
const place = fromPromise<
|
|
1300
|
+
{ bindings: Record<string, ResolvedBinding> },
|
|
1301
|
+
{ wsId: string; spec: AnySpec; runInput: unknown }
|
|
1302
|
+
>(async ({ input, self, system, signal }) => {
|
|
632
1303
|
const binding = runBindingOf(system);
|
|
633
1304
|
// The images and the slots come off the WRAPPER, at invoke time, not out of context and not
|
|
634
1305
|
// out of a build-time closure (ADR-0049, ADR-0051). This state re-runs on every restore, so
|
|
@@ -637,31 +1308,124 @@ function buildWorkspaceMachine(body: AnyStateMachine, spec: (args: { input: any
|
|
|
637
1308
|
// content-addressed tag, which would outlive the image it names. The RESOLVED bindings are
|
|
638
1309
|
// persisted, because a per-run mapper's answer is this run's fact.
|
|
639
1310
|
const parts = sandboxPartsOf(invokingMachine(self));
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
1311
|
+
assertSpec(input.spec, noSlot(parts.repos)); // before the port: a bad spec must never cost a pod
|
|
1312
|
+
const user = userSeatOf(parts.user);
|
|
1313
|
+
const path = actorSlotPath(self._parent);
|
|
1314
|
+
// Read before the port: a Workspace no walk can see placed nothing, and is refused with the slot form.
|
|
1315
|
+
const route = actorRoute(self._parent);
|
|
1316
|
+
const bindings = resolveBindings({ workflow: binding.workflow, path }, parts.repos, input.runInput);
|
|
1317
|
+
const port = sandboxOf(system);
|
|
1318
|
+
const name = workspaceName(binding.runId, input.wsId);
|
|
1319
|
+
const wrapper = self._parent;
|
|
1320
|
+
const child = wrapper ? actorPath(wrapper).join("/") : "";
|
|
1321
|
+
// A wait opened before a restart is the same wait: its start is in the wrapper's persisted
|
|
1322
|
+
// context, never in this actor's input, which a restore replays as it was first invoked.
|
|
1323
|
+
const opened = (): string | undefined =>
|
|
1324
|
+
((wrapper?.getSnapshot() as { context?: WsContext } | undefined)?.context?.wait as WorkspaceWait | undefined)
|
|
1325
|
+
?.since;
|
|
1326
|
+
let since: string | undefined;
|
|
1327
|
+
/** When THIS actor opened the wait, on the monotonic clock: `after` is a duration, and two
|
|
1328
|
+
* wall-clock reads go negative when the host's clock steps back between them. */
|
|
1329
|
+
let openedAt: number | undefined;
|
|
1330
|
+
let heard: PlacingWait | undefined;
|
|
1331
|
+
const onWait = (wait: PlacingWait) => {
|
|
1332
|
+
if (signal.aborted || (heard?.on === wait.on && heard.message === wait.message)) return;
|
|
1333
|
+
heard = wait;
|
|
1334
|
+
since ??= opened();
|
|
1335
|
+
if (since === undefined) {
|
|
1336
|
+
since = new Date().toISOString();
|
|
1337
|
+
openedAt = performance.now();
|
|
1338
|
+
binding.marker?.({ kind: "placing", child, on: wait.on, message: wait.message, since });
|
|
1339
|
+
}
|
|
1340
|
+
wrapper?.send({ type: "workspace.waiting", wait: { ...wait, since } });
|
|
1341
|
+
};
|
|
1342
|
+
const placed = port.place(
|
|
1343
|
+
{
|
|
1344
|
+
name,
|
|
1345
|
+
runId: binding.runId,
|
|
1346
|
+
workflow: binding.workflow,
|
|
1347
|
+
route,
|
|
1348
|
+
// The image strings straight through (ADR-0037/0005) — the port owns resolution, and the
|
|
1349
|
+
// work group's default (ADR-0005 puts it in pod composition, where the pod is built).
|
|
1350
|
+
...(parts.image !== undefined ? { image: parts.image } : {}),
|
|
1351
|
+
...(user.image !== undefined ? { user: user.image } : {}),
|
|
1352
|
+
...(input.spec.workGroup !== undefined ? { workGroup: input.spec.workGroup } : {}),
|
|
1353
|
+
repos: Object.entries(bindings).map(([slot, b]) => ({ slot, ...b })),
|
|
1354
|
+
// The Size as the Machine states it, re-read here like the images (ADR-0060): a redeploy
|
|
1355
|
+
// that retuned it reaches the next provision, and no snapshot holds it.
|
|
1356
|
+
...(parts.resources !== undefined ? { resources: parts.resources } : {}),
|
|
1357
|
+
...(user.resources !== undefined ? { userResources: user.resources } : {}),
|
|
1358
|
+
...(parts.effector !== undefined ? { effector: parts.effector.image } : {}),
|
|
1359
|
+
...(binding.assembly !== undefined ? { assembly: binding.assembly } : {}),
|
|
1360
|
+
},
|
|
1361
|
+
{ onWait, signal },
|
|
644
1362
|
);
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
1363
|
+
signal.addEventListener(
|
|
1364
|
+
"abort",
|
|
1365
|
+
() => {
|
|
1366
|
+
if (binding.hostStopping) return;
|
|
1367
|
+
void placed
|
|
1368
|
+
.catch(() => {})
|
|
1369
|
+
.then(() => (binding.heldSandboxes?.().has(name) ? undefined : port.destroy(name)))
|
|
1370
|
+
.catch(() => {}); // a delete that fails leaves the CR to the idle GC, as ever
|
|
1371
|
+
},
|
|
1372
|
+
{ once: true },
|
|
1373
|
+
);
|
|
1374
|
+
try {
|
|
1375
|
+
await placed;
|
|
1376
|
+
} catch (err) {
|
|
1377
|
+
// An abort is the listener's to delete; a failure of the wait itself is deleted here.
|
|
1378
|
+
if (!signal.aborted && !binding.hostStopping) await port.destroy(name).catch(() => {});
|
|
1379
|
+
throw err;
|
|
1380
|
+
}
|
|
1381
|
+
since ??= opened();
|
|
1382
|
+
if (since !== undefined) {
|
|
1383
|
+
// A wait opened before a restart has only its stored wall-clock start, so its duration is two
|
|
1384
|
+
// wall-clock reads; it is clamped at zero, as a clock that stepped back since the start would
|
|
1385
|
+
// make it negative.
|
|
1386
|
+
const after =
|
|
1387
|
+
openedAt !== undefined ? Math.round(performance.now() - openedAt) : Math.max(0, Date.now() - Date.parse(since));
|
|
1388
|
+
binding.marker?.({ kind: "placed", child, since, after });
|
|
1389
|
+
}
|
|
1390
|
+
return { bindings };
|
|
657
1391
|
});
|
|
658
1392
|
|
|
1393
|
+
/** `provisioning`: the placed pod's Ready, under the port's budget, which starts here (ADR-0064). */
|
|
1394
|
+
const provision = fromPromise<{ endpoint: string }, { wsId: string }>(async ({ input, system }) =>
|
|
1395
|
+
sandboxOf(system).provision(workspaceName(runBindingOf(system).runId, input.wsId)),
|
|
1396
|
+
);
|
|
1397
|
+
|
|
659
1398
|
const attach = fromPromise<
|
|
660
1399
|
{ repos: Record<string, string>; review?: Record<string, string>; stale?: Record<string, string> },
|
|
661
|
-
{ wsId: string; spec:
|
|
662
|
-
>(async ({ input, system }) => {
|
|
1400
|
+
{ wsId: string; spec: AnySpec; bindings: Record<string, ResolvedBinding> }
|
|
1401
|
+
>(async ({ input, system, signal }) => {
|
|
1402
|
+
// No Repo Slot, nothing to clone (ADR-0051): the Harness's attach would refuse an empty list,
|
|
1403
|
+
// and `/work` needs nothing from it — the pod's `fsGroup` already makes it the work group's.
|
|
1404
|
+
if (Object.keys(input.bindings).length === 0) return { repos: {} };
|
|
1405
|
+
const port = sandboxOf(system);
|
|
663
1406
|
const name = workspaceName(runBindingOf(system).runId, input.wsId);
|
|
664
|
-
|
|
1407
|
+
// The attach is loss-aware (ADR-0021): a Sandbox gone or Lost under it fails the attach at
|
|
1408
|
+
// once and by name, as it fails the placing and the provisioning — never a transport error
|
|
1409
|
+
// one attach window later. Only the cluster's word: a dropped watch says nothing.
|
|
1410
|
+
let unsubscribe = (): void => {};
|
|
1411
|
+
const loss = new Promise<never>((_, reject) => {
|
|
1412
|
+
unsubscribe = port.continuity(name, (seen) => {
|
|
1413
|
+
const lost = !seen.present ? GONE : seen.lost;
|
|
1414
|
+
if (lost === undefined) return;
|
|
1415
|
+
const what = seen.present ? "is Lost" : "is gone";
|
|
1416
|
+
reject(new Error(`Sandbox "${name}" ${what} while it attached: ${lost.reason}: ${lost.message} (ADR-0021)`));
|
|
1417
|
+
});
|
|
1418
|
+
});
|
|
1419
|
+
loss.catch(() => {});
|
|
1420
|
+
signal.addEventListener("abort", () => unsubscribe(), { once: true });
|
|
1421
|
+
let out: Awaited<ReturnType<typeof port.attach>>;
|
|
1422
|
+
try {
|
|
1423
|
+
// A Workspace with slots holds a `WorkspaceSpec`: `place` refused one without a branch.
|
|
1424
|
+
const spec = input.spec as WorkspaceSpec;
|
|
1425
|
+
out = await Promise.race([port.attach({ name, spec, repos: attachedRepos(input.bindings) }), loss]);
|
|
1426
|
+
} finally {
|
|
1427
|
+
unsubscribe();
|
|
1428
|
+
}
|
|
665
1429
|
// Announced, never persisted (ADR-0051): a stale cache is a degraded attach the run proceeds
|
|
666
1430
|
// through on the objects the node holds. The pod cannot heal it — the worktree's `origin`
|
|
667
1431
|
// fetches from the cache, not the remote (ADR-0005: the pod holds no credential) — so stale
|
|
@@ -696,65 +1460,93 @@ function buildWorkspaceMachine(body: AnyStateMachine, spec: (args: { input: any
|
|
|
696
1460
|
});
|
|
697
1461
|
|
|
698
1462
|
/**
|
|
699
|
-
* The lease (ADR-0021). One actor owns the
|
|
700
|
-
* it
|
|
701
|
-
* saying "still mine" or the operator's idle GC reaps — ADR-0001) and
|
|
702
|
-
*
|
|
1463
|
+
* The lease (ADR-0021). One actor owns both halves of the exchange with the cluster for one
|
|
1464
|
+
* workspace: it ASSERTS liveness with a write (nothing in the cluster represents a run, so the
|
|
1465
|
+
* Orchestrator must keep saying "still mine" or the operator's idle GC reaps — ADR-0001), and it
|
|
1466
|
+
* LISTENS to the watch for whether what the body attached to is still there (ADR-0063).
|
|
703
1467
|
*
|
|
704
|
-
* Being an INVOKED actor is the whole design.
|
|
705
|
-
*
|
|
706
|
-
*
|
|
707
|
-
*
|
|
708
|
-
*
|
|
1468
|
+
* Being an INVOKED actor is the whole design. It is invoked beside the work of every state from
|
|
1469
|
+
* `placing` to `running`, so the renewal covers the Sandbox from its write to its teardown — a
|
|
1470
|
+
* Sandbox that waits for capacity is never reaped as abandoned (ADR-0064) — and there is one at
|
|
1471
|
+
* a time, because a state's invocations stop when it exits. It re-invokes on snapshot restore (so
|
|
1472
|
+
* a restart reconciles for free: the watch's first list is the first thing it hears), and it
|
|
1473
|
+
* stops on every exit — body final, run stopped, run faulted. That last one is why there is no
|
|
1474
|
+
* `release()`: a faulted run stops its actors, the lease stops with them, and the abandoned pod
|
|
1475
|
+
* ages out of the idle timeout on its own.
|
|
709
1476
|
*
|
|
710
|
-
*
|
|
711
|
-
*
|
|
712
|
-
*
|
|
1477
|
+
* Continuity is JUDGED only in `running`, once there is an attached pod: before that, a Sandbox
|
|
1478
|
+
* that is Lost or gone fails the placing, the provisioning or the attach, by name, on its own. The two halves
|
|
1479
|
+
* share no call: loss arrives within seconds of the cluster saying so; the renewal is a merge
|
|
1480
|
+
* patch every interval ±20%, whose only job is to keep the Sandbox from being reaped.
|
|
713
1481
|
*/
|
|
714
|
-
const lease = fromCallback<{ type: string }, { wsId: string;
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
let seen: Continuity;
|
|
721
|
-
try {
|
|
722
|
-
seen = await port.renew(name);
|
|
723
|
-
} catch {
|
|
724
|
-
return; // unknown, never lost: an API blip must not settle a run holding real work
|
|
725
|
-
}
|
|
726
|
-
if (stopped) return;
|
|
727
|
-
// Two ways to lose a workspace, one event. Gone: reaped, deleted, namespace cleared.
|
|
728
|
-
// Replaced: the CR survived an eviction or node loss but the pod behind it did not, so
|
|
729
|
-
// every name still resolves over an empty `work` volume. Re-provisioning either silently
|
|
730
|
-
// would resume into an inconsistent world — the body decides (ADR-0012).
|
|
731
|
-
const replaced =
|
|
732
|
-
seen.present && input.identity !== undefined && seen.identity !== undefined
|
|
733
|
-
? seen.identity !== input.identity
|
|
734
|
-
: false;
|
|
735
|
-
if (!seen.present || replaced) sendBack({ type: "workspace.lost" });
|
|
736
|
-
};
|
|
1482
|
+
const lease = fromCallback<{ type: string }, { wsId: string; judging: boolean }>(
|
|
1483
|
+
({ input, self, system, sendBack }) => {
|
|
1484
|
+
const port = sandboxOf(system);
|
|
1485
|
+
const name = workspaceName(runBindingOf(system).runId, input.wsId);
|
|
1486
|
+
let stopped = false;
|
|
1487
|
+
let lost = false;
|
|
737
1488
|
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
1489
|
+
const unsubscribe = input.judging
|
|
1490
|
+
? port.continuity(name, (seen) => {
|
|
1491
|
+
if (stopped || lost) return;
|
|
1492
|
+
// Two ways to lose a workspace, one event. Gone: reaped, deleted, namespace cleared. Lost:
|
|
1493
|
+
// the CR survived but its one pod did not — evicted, its node lost or shut down — so
|
|
1494
|
+
// every name still resolves over an empty `work` volume, and the operator never gives it
|
|
1495
|
+
// a second pod. Re-provisioning either silently would resume into an inconsistent world
|
|
1496
|
+
// — the body decides (ADR-0012).
|
|
1497
|
+
const loss: SandboxLoss | undefined = !seen.present ? GONE : seen.lost;
|
|
1498
|
+
if (loss !== undefined) {
|
|
1499
|
+
lost = true; // once: the body has been told, and the workspace does not come back
|
|
1500
|
+
sendBack({ type: "workspace.lost", reason: loss.reason, message: loss.message });
|
|
1501
|
+
}
|
|
1502
|
+
})
|
|
1503
|
+
: () => {};
|
|
1504
|
+
|
|
1505
|
+
// The assertion, jittered from the first renewal on (see `leaseDelay`).
|
|
1506
|
+
const interval = port.leaseIntervalMs ?? DEFAULT_LEASE_INTERVAL_MS;
|
|
1507
|
+
let timer: ReturnType<typeof setTimeout>;
|
|
1508
|
+
const schedule = (ms: number) => {
|
|
1509
|
+
timer = setTimeout(() => {
|
|
1510
|
+
// A Workspace that ended in error stopped processing without stopping its children (xstate
|
|
1511
|
+
// does not), and a parent that handled the error only stops what still processes: the
|
|
1512
|
+
// lease asks, and asserts nothing for a Workspace that is over.
|
|
1513
|
+
if (stopped || self._parent?.getSnapshot().status !== "active") return;
|
|
1514
|
+
// A failed renewal is unknown, never lost: the next one is an interval away, well inside
|
|
1515
|
+
// the idle timeout, and loss is the watch's to report. Before the CR's write lands it is a
|
|
1516
|
+
// 404, and the same shrug.
|
|
1517
|
+
port.renew(name).catch(() => {});
|
|
1518
|
+
schedule(leaseDelay(interval, "next", Math.random()));
|
|
1519
|
+
}, ms);
|
|
1520
|
+
timer.unref?.(); // a lease never holds the process open; it matters only while the run runs
|
|
1521
|
+
};
|
|
1522
|
+
schedule(leaseDelay(interval, "first", Math.random()));
|
|
1523
|
+
return () => {
|
|
1524
|
+
stopped = true;
|
|
1525
|
+
clearTimeout(timer);
|
|
1526
|
+
unsubscribe();
|
|
1527
|
+
};
|
|
1528
|
+
},
|
|
1529
|
+
);
|
|
746
1530
|
|
|
747
1531
|
const destroy = fromPromise<void, { wsId: string }>(async ({ input, system }) =>
|
|
748
1532
|
sandboxOf(system).destroy(workspaceName(runBindingOf(system).runId, input.wsId)),
|
|
749
1533
|
);
|
|
750
1534
|
|
|
1535
|
+
/** The lease beside a state's own work — renewing everywhere, judging in `running` alone. */
|
|
1536
|
+
const leaseInvoke = (judging: boolean) => ({
|
|
1537
|
+
id: "lease",
|
|
1538
|
+
src: "lease" as const,
|
|
1539
|
+
input: ({ context }: { context: unknown }) => ({ wsId: (context as WsContext).wsId, judging }),
|
|
1540
|
+
});
|
|
1541
|
+
|
|
751
1542
|
// Every actor this wrapper runs is a NAMED SLOT (ADR-0049), the body first among them: a Machine
|
|
752
1543
|
// composes by invoking a declared `src`, and `body` is what `provide()`, `customize()`, Stately,
|
|
753
|
-
// the Console's join key and the `jr2 up` parts walk all reach it by. The mechanism's own
|
|
754
|
-
// provision, attach, registrar, lease, destroy — are named for the same price, and the
|
|
755
|
-
//
|
|
1544
|
+
// the Console's join key and the `jr2 up` parts walk all reach it by. The mechanism's own seven —
|
|
1545
|
+
// place, provision, attach, registrar, lease, destroy, fail — are named for the same price, and the
|
|
1546
|
+
// Console shows what each state is doing instead of "inline". `fail` is the unbranded logic: it
|
|
1547
|
+
// sends the body's Failure up again, and the walk does not list it as a state that fails the run.
|
|
756
1548
|
return setup({
|
|
757
|
-
actors: { body, provision, attach, registrar, lease, destroy },
|
|
1549
|
+
actors: { body, place, provision, attach, registrar, lease, destroy, fail: failLogic },
|
|
758
1550
|
}).createMachine({
|
|
759
1551
|
id: "workspace",
|
|
760
1552
|
context: ({ input, self }: { input: unknown; self: { id: string } }): WsContext => ({
|
|
@@ -762,60 +1554,96 @@ function buildWorkspaceMachine(body: AnyStateMachine, spec: (args: { input: any
|
|
|
762
1554
|
wsId: self.id,
|
|
763
1555
|
spec: spec({ input }),
|
|
764
1556
|
}),
|
|
765
|
-
initial: "
|
|
1557
|
+
initial: "placing",
|
|
766
1558
|
states: {
|
|
767
|
-
|
|
768
|
-
invoke:
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
target: "attaching",
|
|
777
|
-
actions: assign({
|
|
778
|
-
endpoint: ({ event }) => (event as unknown as { output: { endpoint: string } }).output.endpoint,
|
|
779
|
-
identity: ({ event }) => (event as unknown as { output: { identity?: string } }).output.identity,
|
|
780
|
-
bindings: ({ event }) =>
|
|
781
|
-
(event as unknown as { output: { bindings: Record<string, ResolvedBinding> } }).output.bindings,
|
|
1559
|
+
placing: {
|
|
1560
|
+
invoke: [
|
|
1561
|
+
{
|
|
1562
|
+
id: "place",
|
|
1563
|
+
src: "place",
|
|
1564
|
+
input: ({ context }) => ({
|
|
1565
|
+
wsId: (context as unknown as WsContext).wsId,
|
|
1566
|
+
spec: (context as unknown as WsContext).spec,
|
|
1567
|
+
runInput: (context as unknown as WsContext).runInput,
|
|
782
1568
|
}),
|
|
1569
|
+
onDone: {
|
|
1570
|
+
target: "provisioning",
|
|
1571
|
+
actions: assign({
|
|
1572
|
+
bindings: ({ event }) =>
|
|
1573
|
+
(event as unknown as { output: { bindings: Record<string, ResolvedBinding> } }).output.bindings,
|
|
1574
|
+
wait: undefined,
|
|
1575
|
+
}),
|
|
1576
|
+
},
|
|
1577
|
+
},
|
|
1578
|
+
leaseInvoke(false),
|
|
1579
|
+
],
|
|
1580
|
+
// The wait's reason, into context and nowhere else: scheduling detail is not the body's
|
|
1581
|
+
// concern (ADR-0064), and a bound is the parent's `after`.
|
|
1582
|
+
on: {
|
|
1583
|
+
"workspace.waiting": {
|
|
1584
|
+
actions: assign({ wait: ({ event }) => (event as unknown as { wait: WorkspaceWait }).wait }),
|
|
783
1585
|
},
|
|
784
1586
|
},
|
|
785
1587
|
},
|
|
1588
|
+
provisioning: {
|
|
1589
|
+
invoke: [
|
|
1590
|
+
{
|
|
1591
|
+
id: "provision",
|
|
1592
|
+
src: "provision",
|
|
1593
|
+
input: ({ context }) => ({ wsId: (context as unknown as WsContext).wsId }),
|
|
1594
|
+
onDone: {
|
|
1595
|
+
target: "attaching",
|
|
1596
|
+
actions: assign({
|
|
1597
|
+
endpoint: ({ event }) => (event as unknown as { output: { endpoint: string } }).output.endpoint,
|
|
1598
|
+
}),
|
|
1599
|
+
},
|
|
1600
|
+
},
|
|
1601
|
+
leaseInvoke(false),
|
|
1602
|
+
],
|
|
1603
|
+
},
|
|
786
1604
|
attaching: {
|
|
787
|
-
invoke:
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
target: "running",
|
|
796
|
-
actions: assign({
|
|
797
|
-
handles: ({ context, event, system }): MechanismHandles => {
|
|
798
|
-
const ctx = context as WsContext;
|
|
799
|
-
const out = (
|
|
800
|
-
event as unknown as {
|
|
801
|
-
output: { repos: Record<string, string>; review?: Record<string, string> };
|
|
802
|
-
}
|
|
803
|
-
).output;
|
|
804
|
-
return {
|
|
805
|
-
endpoint: ctx.endpoint!,
|
|
806
|
-
// Derived, not remembered: the same function every port operation names the CR
|
|
807
|
-
// with, so the Sandbox the registrar publishes — and the Agent actor records on its
|
|
808
|
-
// registration — is the one the Custodian's token is scoped to, by construction
|
|
809
|
-
// (ADR-0013).
|
|
810
|
-
sandbox: workspaceName(runBindingOf(system as AnyActorSystem).runId, ctx.wsId),
|
|
811
|
-
repos: out.repos,
|
|
812
|
-
branch: ctx.spec.branch,
|
|
813
|
-
...(out.review ? { review: out.review } : {}),
|
|
814
|
-
};
|
|
815
|
-
},
|
|
1605
|
+
invoke: [
|
|
1606
|
+
{
|
|
1607
|
+
id: "attach",
|
|
1608
|
+
src: "attach",
|
|
1609
|
+
input: ({ context }) => ({
|
|
1610
|
+
wsId: (context as unknown as WsContext).wsId,
|
|
1611
|
+
spec: (context as unknown as WsContext).spec,
|
|
1612
|
+
bindings: (context as unknown as WsContext).bindings!,
|
|
816
1613
|
}),
|
|
1614
|
+
onDone: {
|
|
1615
|
+
target: "running",
|
|
1616
|
+
actions: assign({
|
|
1617
|
+
handles: ({ context, event, system }): MechanismHandles => {
|
|
1618
|
+
const ctx = context as WsContext;
|
|
1619
|
+
const out = (
|
|
1620
|
+
event as unknown as {
|
|
1621
|
+
output: { repos: Record<string, string>; review?: Record<string, string> };
|
|
1622
|
+
}
|
|
1623
|
+
).output;
|
|
1624
|
+
return {
|
|
1625
|
+
endpoint: ctx.endpoint!,
|
|
1626
|
+
// Derived, not remembered: the same function every port operation names the CR
|
|
1627
|
+
// with, so the Sandbox the registrar publishes — and the Agent actor records on its
|
|
1628
|
+
// registration — is the one the Custodian's token is scoped to, by construction
|
|
1629
|
+
// (ADR-0013).
|
|
1630
|
+
sandbox: workspaceName(runBindingOf(system as AnyActorSystem).runId, ctx.wsId),
|
|
1631
|
+
repos: out.repos,
|
|
1632
|
+
// Each slot's Binding url: where a push goes, and the Repo cache its import
|
|
1633
|
+
// borrows from (ADR-0051, ADR-0067).
|
|
1634
|
+
...(ctx.bindings
|
|
1635
|
+
? { bindings: Object.fromEntries(Object.entries(ctx.bindings).map(([s, b]) => [s, b.url])) }
|
|
1636
|
+
: {}),
|
|
1637
|
+
// No branch with no Repo Slot (ADR-0051): nothing was cut.
|
|
1638
|
+
...(ctx.spec.branch !== undefined ? { branch: ctx.spec.branch } : {}),
|
|
1639
|
+
...(out.review ? { review: out.review } : {}),
|
|
1640
|
+
};
|
|
1641
|
+
},
|
|
1642
|
+
}),
|
|
1643
|
+
},
|
|
817
1644
|
},
|
|
818
|
-
|
|
1645
|
+
leaseInvoke(false),
|
|
1646
|
+
],
|
|
819
1647
|
},
|
|
820
1648
|
running: {
|
|
821
1649
|
invoke: [
|
|
@@ -833,28 +1661,31 @@ function buildWorkspaceMachine(body: AnyStateMachine, spec: (args: { input: any
|
|
|
833
1661
|
input: ({ context }: { context: WsContext }) => {
|
|
834
1662
|
const ctx = context;
|
|
835
1663
|
const { repos, branch, review } = ctx.handles!;
|
|
836
|
-
// Body-facing subset only (ADR-0016): endpoint/sandbox are mechanism-internal.
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
};
|
|
1664
|
+
// Body-facing subset only (ADR-0016): endpoint/sandbox are mechanism-internal. With
|
|
1665
|
+
// no Repo Slot the handles are `{ repos: {} }` (WorkspaceHandles<never>).
|
|
1666
|
+
const handles: WorkspaceHandles<string> | WorkspaceHandles<never> =
|
|
1667
|
+
branch === undefined ? { repos: {} } : { repos, branch, ...(review ? { review } : {}) };
|
|
1668
|
+
return { ...ctx.runInput, workspace: handles };
|
|
841
1669
|
},
|
|
842
1670
|
onDone: {
|
|
843
1671
|
target: "teardown",
|
|
844
1672
|
actions: assign({ output: ({ event }) => (event as unknown as { output: unknown }).output }),
|
|
845
1673
|
},
|
|
1674
|
+
// A Failure deletes the Sandbox before it goes up (ADR-0073). Guarded, so a defect
|
|
1675
|
+
// takes no transition and goes up at once, as before: its Sandbox stays as evidence.
|
|
1676
|
+
onError: {
|
|
1677
|
+
guard: ({ event }: { event: { error?: unknown } }) => isFailure(event.error),
|
|
1678
|
+
target: "failing",
|
|
1679
|
+
actions: assign({
|
|
1680
|
+
failure: ({ event }) => failurePayload((event as unknown as { error: Failure }).error),
|
|
1681
|
+
}),
|
|
1682
|
+
},
|
|
846
1683
|
},
|
|
847
|
-
|
|
848
|
-
id: "lease",
|
|
849
|
-
src: "lease",
|
|
850
|
-
input: ({ context }) => ({
|
|
851
|
-
wsId: (context as unknown as WsContext).wsId,
|
|
852
|
-
identity: (context as unknown as WsContext).identity,
|
|
853
|
-
}),
|
|
854
|
-
},
|
|
1684
|
+
leaseInvoke(true),
|
|
855
1685
|
],
|
|
856
|
-
// The wrapper emits, the body decides (ADR-0012): forward loss into the
|
|
857
|
-
|
|
1686
|
+
// The wrapper emits, the body decides (ADR-0012): forward loss, with its reason, into the
|
|
1687
|
+
// body's policy.
|
|
1688
|
+
on: { "workspace.lost": { actions: sendTo("body", ({ event }) => event) } },
|
|
858
1689
|
},
|
|
859
1690
|
teardown: {
|
|
860
1691
|
invoke: {
|
|
@@ -866,9 +1697,35 @@ function buildWorkspaceMachine(body: AnyStateMachine, spec: (args: { input: any
|
|
|
866
1697
|
},
|
|
867
1698
|
},
|
|
868
1699
|
done: { type: "final" },
|
|
1700
|
+
// The body failed (ADR-0073): the same delete as `teardown`, then the same Failure goes up to
|
|
1701
|
+
// the parent unchanged. No lease here: nothing judges a Sandbox that is being deleted.
|
|
1702
|
+
failing: {
|
|
1703
|
+
initial: "destroying",
|
|
1704
|
+
states: {
|
|
1705
|
+
destroying: {
|
|
1706
|
+
invoke: {
|
|
1707
|
+
src: "destroy",
|
|
1708
|
+
input: ({ context }) => ({ wsId: (context as unknown as WsContext).wsId }),
|
|
1709
|
+
onDone: "raising",
|
|
1710
|
+
// A failed delete is the operator GC's problem, and the run host's (ADR-0012 backstop).
|
|
1711
|
+
onError: "raising",
|
|
1712
|
+
},
|
|
1713
|
+
},
|
|
1714
|
+
raising: {
|
|
1715
|
+
invoke: { src: "fail", input: ({ context }) => (context as unknown as WsContext).failure! },
|
|
1716
|
+
},
|
|
1717
|
+
},
|
|
1718
|
+
},
|
|
869
1719
|
},
|
|
870
1720
|
// Machine output must be declared at the ROOT in xstate v5 (a final state's own `output`
|
|
871
1721
|
// only rides the done event); the workspace's output is the body's, verbatim (ADR-0012).
|
|
872
1722
|
output: ({ context }) => (context as unknown as WsContext).output,
|
|
873
1723
|
});
|
|
874
1724
|
}
|
|
1725
|
+
|
|
1726
|
+
/** A Sandbox the watch says is gone — reaped by the idle GC, deleted by hand, its namespace
|
|
1727
|
+
* cleared — as the loss `workspace.lost` carries. */
|
|
1728
|
+
const GONE: SandboxLoss = {
|
|
1729
|
+
reason: "Deleted",
|
|
1730
|
+
message: "the Sandbox is gone: deleted, or reaped by the operator's idle GC (ADR-0001)",
|
|
1731
|
+
};
|