@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/size.ts
ADDED
|
@@ -0,0 +1,454 @@
|
|
|
1
|
+
// A Workspace's SIZE (ADR-0060): the ceiling it states for its whole Sandbox, cpu and memory and
|
|
2
|
+
// nothing else, which jr2 reserves in full. The shape is Agent Substrate's `ActorTemplate.resources`
|
|
3
|
+
// — a Kubernetes `ResourceRequirements` of which only `limits.cpu` and `limits.memory` are
|
|
4
|
+
// accepted — so a Workspace moves there unchanged.
|
|
5
|
+
//
|
|
6
|
+
// This module is the arithmetic and the refusals, with no Kubernetes client and no Machine in it:
|
|
7
|
+
//
|
|
8
|
+
// - `assertSize` refuses anything but the two limits WHERE IT IS READ (`workspace()`,
|
|
9
|
+
// `customize()`, `jr2.config.ts`), naming the field — a request below the limit, a missing
|
|
10
|
+
// memory limit, or a key Substrate refuses would make the Machine depend on something the move
|
|
11
|
+
// drops.
|
|
12
|
+
// - `resolveSize` walks the chain, most specific first, one field at a time: the Machine's Size
|
|
13
|
+
// (a `customize()` layered over the `workspace()`), then the Instance's `sandbox.resources` — a
|
|
14
|
+
// default for a Workspace that states none, never an override of one it states — then the kit.
|
|
15
|
+
// - `splitSize` divides the Size inside the pod: the Custodian's fixed slice, the User
|
|
16
|
+
// Container's split if the Machine states one, and the Harness gets the rest as its own limit,
|
|
17
|
+
// so an OOM stays per container where it can. It refuses a split that leaves the Harness below
|
|
18
|
+
// its floor, naming where each number came from, because the fix is an edit of one of them.
|
|
19
|
+
//
|
|
20
|
+
// Both the provision (sandbox-kube.ts) and `jr2 up` call the same two functions, so a Size the
|
|
21
|
+
// converge admits is a Size the provision composes.
|
|
22
|
+
|
|
23
|
+
/** A Kubernetes quantity: `"2Gi"`, `"1500m"`, `"2"`, or a plain number (`2` cpus, bytes of memory). */
|
|
24
|
+
export type Quantity = string | number;
|
|
25
|
+
|
|
26
|
+
// A Size (ADR-0060, GLOSSARY.md "Size"): `limits.memory` and `limits.cpu`, nothing else. A field
|
|
27
|
+
// left out resolves down the chain (`resolveSize`) — the Instance's `sandbox.resources`, then the
|
|
28
|
+
// kit default (`KIT_SIZE`). The same shape is the User Container's split (`userSeatOf`,
|
|
29
|
+
// `splitSize`), where a field left out means the seat has no limit of its own for it and shares
|
|
30
|
+
// the pod's budget.
|
|
31
|
+
/**
|
|
32
|
+
* The Size of a Workspace: the most memory and cpu that its whole Sandbox can use. jr2 reserves
|
|
33
|
+
* all of it on the node. Thus a Sandbox never gets less than its Size and never uses more.
|
|
34
|
+
*
|
|
35
|
+
* Write it as `{ limits: { memory, cpu } }`. You can write it in three places: the `resources`
|
|
36
|
+
* option of `workspace()`, `customize(machine, { resources })`, and `sandbox.resources` in
|
|
37
|
+
* `jr2.config.ts`. The Size is a fact of the Machine. The Instance's `sandbox.resources` applies
|
|
38
|
+
* only to a Workspace that states no Size. A field that neither states comes from the kit default:
|
|
39
|
+
* memory `2Gi`, cpu `1`.
|
|
40
|
+
*
|
|
41
|
+
* The same shape is the User Container's part of the Size, in `user: { image, resources }`. There,
|
|
42
|
+
* a field you leave out gives the User Container no limit of its own: it shares the pod's budget.
|
|
43
|
+
*
|
|
44
|
+
* It refuses any key but `limits.memory` and `limits.cpu`, where you write it. The message names
|
|
45
|
+
* the field: `<where>` is where you wrote the Size, for example `workspace(): resources`.
|
|
46
|
+
*
|
|
47
|
+
* - `<where>.requests is not accepted — jr2 reserves the whole Size, so every request equals its
|
|
48
|
+
* limit — a Size is { limits: { memory?, cpu? } } and nothing else — Agent Substrate's template
|
|
49
|
+
* size (ADR-0060)`
|
|
50
|
+
* - `<where>.<key> is not accepted — a Size is { limits: { memory?, cpu? } } and nothing else —
|
|
51
|
+
* Agent Substrate's template size (ADR-0060)`
|
|
52
|
+
* - `<where>.limits.<key> is not accepted — a Size is { limits: { memory?, cpu? } } and nothing
|
|
53
|
+
* else — Agent Substrate's template size (ADR-0060)`
|
|
54
|
+
* - `<where>.limits.memory: <value> is not a positive quantity (e.g. "2Gi", "512Mi")`, and for
|
|
55
|
+
* cpu, `<where>.limits.cpu: <value> is not a positive quantity (e.g. "2", "1500m")`
|
|
56
|
+
*
|
|
57
|
+
* `jr2 up` also refuses a Size that leaves the Harness container less than its floor. The message
|
|
58
|
+
* starts with `the Size leaves the Harness container <memory> memory, below its 256Mi floor:` or
|
|
59
|
+
* with `the Size leaves the Harness container no cpu:`. The text that follows names each number
|
|
60
|
+
* and where it came from.
|
|
61
|
+
*
|
|
62
|
+
* @example
|
|
63
|
+
* ```ts
|
|
64
|
+
* import { customize, type Size } from "@jr2/orchestrator";
|
|
65
|
+
* import { task } from "@jr2/machines";
|
|
66
|
+
*
|
|
67
|
+
* const size: Size = { limits: { memory: "4Gi", cpu: "2" } };
|
|
68
|
+
*
|
|
69
|
+
* export const machine = customize(task, {
|
|
70
|
+
* repos: { app: { url: "https://github.com/you/app.git" } },
|
|
71
|
+
* agents: { coder: { model: "anthropic/claude-haiku-5-5" } },
|
|
72
|
+
* resources: size,
|
|
73
|
+
* });
|
|
74
|
+
* ```
|
|
75
|
+
*
|
|
76
|
+
* @see docs/guides/size-a-workspace.md
|
|
77
|
+
* @see docs/concepts/workspaces.md
|
|
78
|
+
* @see docs/reference/decisions/0060-a-workspace-states-its-size-and-jr2-reserves-all-of-it.md
|
|
79
|
+
*/
|
|
80
|
+
export type Size = {
|
|
81
|
+
/** The two limits. Write `limits: {}` to state no field: each field then comes from the next
|
|
82
|
+
* source. */
|
|
83
|
+
limits: {
|
|
84
|
+
/** The memory limit, as a Kubernetes quantity: `"4Gi"`, `"512Mi"`, or a number of bytes.
|
|
85
|
+
* It must be more than zero. When you leave it out, it comes from the next source, as above. */
|
|
86
|
+
memory?: Quantity;
|
|
87
|
+
/** The cpu limit, as a Kubernetes quantity: `"2"`, `"1500m"`, or a number of cpus. It must be
|
|
88
|
+
* more than zero. jr2 enforces it. When you leave it out, it comes from the next source, as
|
|
89
|
+
* above. */
|
|
90
|
+
cpu?: Quantity;
|
|
91
|
+
};
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
/** The kit's default Size (ADR-0060). Node gives each process a V8 heap of about 55% of its
|
|
95
|
+
* container limit, and `tsc` on a mid-size repo aborted below about 1.1Gi, so 1Gi fails a common
|
|
96
|
+
* task; 2Gi covers `tsc` + vitest, `cargo build` and Playwright at 2 workers. */
|
|
97
|
+
export const KIT_SIZE = { memory: "2Gi", cpu: "1" } as const;
|
|
98
|
+
|
|
99
|
+
/** The Custodian's fixed slice of every Sandbox's Size (ADR-0060). */
|
|
100
|
+
export const CUSTODIAN_SLICE = { memory: "64Mi", cpu: "50m" } as const;
|
|
101
|
+
|
|
102
|
+
/** The Harness container's floor: a split that leaves it less memory than this is refused. */
|
|
103
|
+
export const HARNESS_FLOOR = "256Mi";
|
|
104
|
+
|
|
105
|
+
/** `/dev/shm` is 25% of the Harness container's share, never below this (ADR-0060). */
|
|
106
|
+
export const SHM_FLOOR = "64Mi";
|
|
107
|
+
|
|
108
|
+
/** The `/work` disk: an ephemeral-storage REQUEST on the Harness container, with no limit and no
|
|
109
|
+
* `sizeLimit` — passing either evicts the pod and loses `/work` (ADR-0060). Kit-fixed. */
|
|
110
|
+
export const WORK_DISK_REQUEST = "10Gi";
|
|
111
|
+
|
|
112
|
+
/** The preflight init step's resources (ADR-0037): small and fixed, and inside the pod's budget —
|
|
113
|
+
* an init container runs before the others start, so it borrows the pod's Size, it adds nothing. */
|
|
114
|
+
export const PREFLIGHT_RESOURCES = {
|
|
115
|
+
requests: { cpu: "10m", memory: "32Mi" },
|
|
116
|
+
limits: { memory: "128Mi" },
|
|
117
|
+
} as const;
|
|
118
|
+
|
|
119
|
+
/** A container's or a pod's resources, in the Kubernetes (corev1.ResourceRequirements) shape. */
|
|
120
|
+
export type ResourceRequirements = {
|
|
121
|
+
requests?: Record<string, string>;
|
|
122
|
+
limits?: Record<string, string>;
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
// --- quantities --------------------------------------------------------------------------------
|
|
126
|
+
|
|
127
|
+
const QUANTITY = /^(\d+(?:\.\d+)?|\.\d+)(m|k|M|G|T|P|E|Ki|Mi|Gi|Ti|Pi|Ei)?$/;
|
|
128
|
+
const SCALE: Record<string, number> = {
|
|
129
|
+
"": 1,
|
|
130
|
+
m: 1e-3,
|
|
131
|
+
k: 1e3,
|
|
132
|
+
M: 1e6,
|
|
133
|
+
G: 1e9,
|
|
134
|
+
T: 1e12,
|
|
135
|
+
P: 1e15,
|
|
136
|
+
E: 1e18,
|
|
137
|
+
Ki: 2 ** 10,
|
|
138
|
+
Mi: 2 ** 20,
|
|
139
|
+
Gi: 2 ** 30,
|
|
140
|
+
Ti: 2 ** 40,
|
|
141
|
+
Pi: 2 ** 50,
|
|
142
|
+
Ei: 2 ** 60,
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
/** The quantity as a plain number of base units, or undefined when it is not one Kubernetes reads.
|
|
146
|
+
* Zero and negatives are not a Size: a zero limit is a container that cannot start. */
|
|
147
|
+
function baseUnits(q: unknown): number | undefined {
|
|
148
|
+
if (typeof q === "number") return Number.isFinite(q) && q > 0 ? q : undefined;
|
|
149
|
+
if (typeof q !== "string") return undefined;
|
|
150
|
+
const m = QUANTITY.exec(q.trim());
|
|
151
|
+
if (!m) return undefined;
|
|
152
|
+
const value = Number(m[1]) * SCALE[m[2] ?? ""]!;
|
|
153
|
+
return value > 0 ? value : undefined;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** Memory in bytes, rounded up as Kubernetes rounds it. */
|
|
157
|
+
export function memoryBytes(q: Quantity): number {
|
|
158
|
+
const v = baseUnits(q);
|
|
159
|
+
if (v === undefined) throw new Error(`${JSON.stringify(q)} is not a memory quantity (e.g. "2Gi", "512Mi")`);
|
|
160
|
+
return Math.ceil(v - 1e-9);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** CPU in millicores, rounded up to 1m as Kubernetes rounds it. */
|
|
164
|
+
export function cpuMillis(q: Quantity): number {
|
|
165
|
+
const v = baseUnits(q);
|
|
166
|
+
if (v === undefined) throw new Error(`${JSON.stringify(q)} is not a cpu quantity (e.g. "2", "1500m")`);
|
|
167
|
+
return Math.ceil(v * 1000 - 1e-9);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** Bytes as the quantity a human would write: `Gi`, `Mi` or `Ki` when exact, else plain bytes. */
|
|
171
|
+
export function formatMemory(bytes: number): string {
|
|
172
|
+
for (const [unit, scale] of [
|
|
173
|
+
["Gi", 2 ** 30],
|
|
174
|
+
["Mi", 2 ** 20],
|
|
175
|
+
["Ki", 2 ** 10],
|
|
176
|
+
] as const) {
|
|
177
|
+
if (bytes % scale === 0) return `${bytes / scale}${unit}`;
|
|
178
|
+
}
|
|
179
|
+
return String(bytes);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** Millicores as the quantity a human would write: whole cpus when exact, else `m`. */
|
|
183
|
+
export function formatCpu(millis: number): string {
|
|
184
|
+
return millis % 1000 === 0 ? String(millis / 1000) : `${millis}m`;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// --- the refusal where a Size is read ----------------------------------------------------------
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Refuse anything but a Size, BY FIELD (ADR-0060): `requests`, any key beside `limits`, any limit
|
|
191
|
+
* beside `memory` and `cpu`, or a value Kubernetes would not read. `where` names the reader and the
|
|
192
|
+
* field's own path (`workspace(): resources`, `jr2.config.ts at sandbox.resources`), so the message
|
|
193
|
+
* points at the text to edit. An empty `limits` is legal and states nothing: every field resolves
|
|
194
|
+
* down the chain.
|
|
195
|
+
*/
|
|
196
|
+
export function assertSize(where: string, value: unknown): asserts value is Size {
|
|
197
|
+
const hint = "a Size is { limits: { memory?, cpu? } } and nothing else — Agent Substrate's template size (ADR-0060)";
|
|
198
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
|
199
|
+
throw new Error(`${where}: expected an object (got ${JSON.stringify(value)}) — ${hint}`);
|
|
200
|
+
}
|
|
201
|
+
for (const key of Object.keys(value)) {
|
|
202
|
+
if (key === "limits") continue;
|
|
203
|
+
const why =
|
|
204
|
+
key === "requests"
|
|
205
|
+
? "is not accepted — jr2 reserves the whole Size, so every request equals its limit"
|
|
206
|
+
: "is not accepted";
|
|
207
|
+
throw new Error(`${where}.${key} ${why} — ${hint}`);
|
|
208
|
+
}
|
|
209
|
+
const limits = (value as { limits?: unknown }).limits;
|
|
210
|
+
if (typeof limits !== "object" || limits === null || Array.isArray(limits)) {
|
|
211
|
+
throw new Error(`${where}.limits: expected an object (got ${JSON.stringify(limits)}) — ${hint}`);
|
|
212
|
+
}
|
|
213
|
+
for (const [key, q] of Object.entries(limits)) {
|
|
214
|
+
if (key !== "memory" && key !== "cpu") {
|
|
215
|
+
throw new Error(`${where}.limits.${key} is not accepted — ${hint}`);
|
|
216
|
+
}
|
|
217
|
+
if (q === undefined) continue;
|
|
218
|
+
if (baseUnits(q) === undefined) {
|
|
219
|
+
const eg = key === "memory" ? '"2Gi", "512Mi"' : '"2", "1500m"';
|
|
220
|
+
throw new Error(`${where}.limits.${key}: ${JSON.stringify(q)} is not a positive quantity (e.g. ${eg})`);
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/** Layer one Size over another, field by field — `customize(machine, { resources })` over the
|
|
226
|
+
* `workspace()`'s own, as an Agent override layers over its stock definition. */
|
|
227
|
+
export function layerSize(base: Size | undefined, over: Size | undefined): Size | undefined {
|
|
228
|
+
if (over === undefined) return base;
|
|
229
|
+
if (base === undefined) return over;
|
|
230
|
+
return { limits: { ...base.limits, ...over.limits } };
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// --- resolution --------------------------------------------------------------------------------
|
|
234
|
+
|
|
235
|
+
/** Where a resolved number came from — what a refusal names, because the fix is an edit THERE. */
|
|
236
|
+
export type SizeSource = "machine" | "instance" | "kit";
|
|
237
|
+
|
|
238
|
+
/** A Size resolved to numbers, each with its source. */
|
|
239
|
+
export type ResolvedSize = {
|
|
240
|
+
memory: { bytes: number; from: SizeSource };
|
|
241
|
+
cpu: { millis: number; from: SizeSource };
|
|
242
|
+
};
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* The chain (ADR-0060): the Machine's Size (its `workspace()`, with any `customize()` already
|
|
246
|
+
* layered over it), else the Instance's `sandbox.resources`, then the kit for a field neither
|
|
247
|
+
* states. The Instance default applies only to a Workspace that states NO Size — never field by
|
|
248
|
+
* field under a stated one — so a Machine's Size resolves the same on every Instance, and each
|
|
249
|
+
* fact has one override path, `customize()`.
|
|
250
|
+
*/
|
|
251
|
+
export function resolveSize(machine: Size | undefined, instance: Size | undefined): ResolvedSize {
|
|
252
|
+
const stated = (size: Size | undefined) =>
|
|
253
|
+
size !== undefined && (size.limits.memory !== undefined || size.limits.cpu !== undefined);
|
|
254
|
+
const [chosen, source]: [Size | undefined, SizeSource] = stated(machine)
|
|
255
|
+
? [machine, "machine"]
|
|
256
|
+
: [instance, "instance"];
|
|
257
|
+
const pick = (field: "memory" | "cpu"): { q: Quantity; from: SizeSource } => {
|
|
258
|
+
const q = chosen?.limits[field];
|
|
259
|
+
return q !== undefined ? { q, from: source } : { q: KIT_SIZE[field], from: "kit" };
|
|
260
|
+
};
|
|
261
|
+
const memory = pick("memory");
|
|
262
|
+
const cpu = pick("cpu");
|
|
263
|
+
return {
|
|
264
|
+
memory: { bytes: memoryBytes(memory.q), from: memory.from },
|
|
265
|
+
cpu: { millis: cpuMillis(cpu.q), from: cpu.from },
|
|
266
|
+
};
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// --- the split inside the pod ------------------------------------------------------------------
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* One Sandbox pod's resources, as the CR carries them (ADR-0060). Every request equals its limit —
|
|
273
|
+
* the pod is Guaranteed, and the CPU limit is enforced — except the Harness container's
|
|
274
|
+
* `ephemeral-storage`, a request with no limit, and the preflight's small fixed step.
|
|
275
|
+
*/
|
|
276
|
+
export type SizeSplit = {
|
|
277
|
+
/** The whole Size, pod-level (KEP-2837): the ceiling the pod never passes. */
|
|
278
|
+
pod: ResourceRequirements;
|
|
279
|
+
/** The rest of the Size, after the Custodian and the User Container. */
|
|
280
|
+
harness: ResourceRequirements;
|
|
281
|
+
custodian: ResourceRequirements;
|
|
282
|
+
/** Only when the Machine states a split for the User Container; otherwise it has no limit of its
|
|
283
|
+
* own and shares the pod's budget. */
|
|
284
|
+
user?: ResourceRequirements;
|
|
285
|
+
preflight: ResourceRequirements;
|
|
286
|
+
/** The `/dev/shm` emptyDir's `sizeLimit`: 25% of the Harness share, minimum 64Mi. */
|
|
287
|
+
shmSizeLimit: string;
|
|
288
|
+
/** The Harness container's memory limit in bytes — what layer 4 of ADR-0061 guards. */
|
|
289
|
+
harnessMemoryBytes: number;
|
|
290
|
+
};
|
|
291
|
+
|
|
292
|
+
const SOURCE_TEXT: Record<SizeSource, (field: "memory" | "cpu") => string> = {
|
|
293
|
+
machine: (f) => `the Machine's Size — its workspace()'s resources.limits.${f}, or a customize() of it`,
|
|
294
|
+
instance: (f) =>
|
|
295
|
+
`sandbox.resources.limits.${f} in jr2.config.ts, the Instance's default for a Workspace that states none`,
|
|
296
|
+
kit: () => "the kit default — the Workspace states no Size",
|
|
297
|
+
};
|
|
298
|
+
|
|
299
|
+
/**
|
|
300
|
+
* Split a resolved Size inside the pod (ADR-0060), or refuse. `user` is the User Container's split
|
|
301
|
+
* as the Machine stated it. The refusal names each number and where it came from, because the fix
|
|
302
|
+
* is one of them: a larger Size, or a smaller split.
|
|
303
|
+
*/
|
|
304
|
+
export function splitSize(size: ResolvedSize, user?: Size): SizeSplit {
|
|
305
|
+
const custodian = { memory: memoryBytes(CUSTODIAN_SLICE.memory), cpu: cpuMillis(CUSTODIAN_SLICE.cpu) };
|
|
306
|
+
const userMemory = user?.limits.memory !== undefined ? memoryBytes(user.limits.memory) : undefined;
|
|
307
|
+
const userCpu = user?.limits.cpu !== undefined ? cpuMillis(user.limits.cpu) : undefined;
|
|
308
|
+
const harnessMemory = size.memory.bytes - custodian.memory - (userMemory ?? 0);
|
|
309
|
+
const harnessCpu = size.cpu.millis - custodian.cpu - (userCpu ?? 0);
|
|
310
|
+
|
|
311
|
+
const floor = memoryBytes(HARNESS_FLOOR);
|
|
312
|
+
if (harnessMemory < floor) {
|
|
313
|
+
throw new Error(
|
|
314
|
+
`the Size leaves the Harness container ${harnessMemory > 0 ? formatMemory(harnessMemory) : "no"} memory, below its ` +
|
|
315
|
+
`${HARNESS_FLOOR} floor: memory ${formatMemory(size.memory.bytes)} (${SOURCE_TEXT[size.memory.from]("memory")}) ` +
|
|
316
|
+
`− the Custodian's ${CUSTODIAN_SLICE.memory}` +
|
|
317
|
+
(userMemory !== undefined
|
|
318
|
+
? ` − the User Container's ${formatMemory(userMemory)} (its user.resources.limits.memory)`
|
|
319
|
+
: "") +
|
|
320
|
+
" — raise the Size's limits.memory, or shrink the User Container's split (ADR-0060)",
|
|
321
|
+
);
|
|
322
|
+
}
|
|
323
|
+
if (harnessCpu <= 0) {
|
|
324
|
+
throw new Error(
|
|
325
|
+
`the Size leaves the Harness container no cpu: cpu ${formatCpu(size.cpu.millis)} ` +
|
|
326
|
+
`(${SOURCE_TEXT[size.cpu.from]("cpu")}) − the Custodian's ${CUSTODIAN_SLICE.cpu}` +
|
|
327
|
+
(userCpu !== undefined ? ` − the User Container's ${formatCpu(userCpu)} (its user.resources.limits.cpu)` : "") +
|
|
328
|
+
" — raise the Size's limits.cpu, or shrink the User Container's split (ADR-0060)",
|
|
329
|
+
);
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
const exact = (memory: number, cpu: number): ResourceRequirements => {
|
|
333
|
+
const limits = { cpu: formatCpu(cpu), memory: formatMemory(memory) };
|
|
334
|
+
return { requests: { ...limits }, limits };
|
|
335
|
+
};
|
|
336
|
+
const harness = exact(harnessMemory, harnessCpu);
|
|
337
|
+
harness.requests!["ephemeral-storage"] = WORK_DISK_REQUEST;
|
|
338
|
+
|
|
339
|
+
let userResources: ResourceRequirements | undefined;
|
|
340
|
+
if (userMemory !== undefined || userCpu !== undefined) {
|
|
341
|
+
const limits: Record<string, string> = {
|
|
342
|
+
...(userCpu !== undefined ? { cpu: formatCpu(userCpu) } : {}),
|
|
343
|
+
...(userMemory !== undefined ? { memory: formatMemory(userMemory) } : {}),
|
|
344
|
+
};
|
|
345
|
+
userResources = { requests: { ...limits }, limits };
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
return {
|
|
349
|
+
pod: exact(size.memory.bytes, size.cpu.millis),
|
|
350
|
+
harness,
|
|
351
|
+
custodian: exact(custodian.memory, custodian.cpu),
|
|
352
|
+
...(userResources ? { user: userResources } : {}),
|
|
353
|
+
preflight: {
|
|
354
|
+
requests: { ...PREFLIGHT_RESOURCES.requests },
|
|
355
|
+
limits: { ...PREFLIGHT_RESOURCES.limits },
|
|
356
|
+
},
|
|
357
|
+
shmSizeLimit: formatMemory(Math.max(memoryBytes(SHM_FLOOR), Math.floor(harnessMemory / 4))),
|
|
358
|
+
harnessMemoryBytes: harnessMemory,
|
|
359
|
+
};
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
// --- the User Container seat -------------------------------------------------------------------
|
|
363
|
+
|
|
364
|
+
// The User Container as a Machine states it (ADR-0005, ADR-0060): an image in ADR-0037's two
|
|
365
|
+
// shapes, and optionally its split of the Size. The object form exists for the split alone.
|
|
366
|
+
/**
|
|
367
|
+
* The User Container in its object form: its image, and its part of the Workspace's Size. The User
|
|
368
|
+
* Container is an optional container of your own in the Sandbox pod. It runs its own entrypoint
|
|
369
|
+
* with `/work` mounted, and jr2 never builds into it, probes it or sends it commands. Use it for a
|
|
370
|
+
* service that must run without an Agent, such as an sshd.
|
|
371
|
+
*
|
|
372
|
+
* Write it in the `user` option of `workspace()` or of `customize()`. That option also takes the
|
|
373
|
+
* image as a string; use this object form only to give the User Container a part of the Size. A
|
|
374
|
+
* `customize()` that names `user` replaces the whole User Container, its part of the Size
|
|
375
|
+
* included.
|
|
376
|
+
*
|
|
377
|
+
* It refuses, where you write it. `<where>` is `workspace()` or `customize()`:
|
|
378
|
+
*
|
|
379
|
+
* - an image that is not a non-empty string. The message starts with
|
|
380
|
+
* `` <where>: `user` must be a non-empty string (an image), or { image, resources? } ``.
|
|
381
|
+
* - `<where>: user.<key> is not accepted — the User Container is { image, resources? } (ADR-0005,
|
|
382
|
+
* ADR-0060)`
|
|
383
|
+
*
|
|
384
|
+
* `resources` is checked as a {@link Size}, with the same refusals.
|
|
385
|
+
*
|
|
386
|
+
* `jr2 up` refuses a part that leaves the Harness container less than its floor. The message starts
|
|
387
|
+
* with `the Size leaves the Harness container <memory> memory, below its 256Mi floor:` or with
|
|
388
|
+
* `the Size leaves the Harness container no cpu:`. The text that follows names each number and
|
|
389
|
+
* where it came from.
|
|
390
|
+
*
|
|
391
|
+
* @example
|
|
392
|
+
* ```ts
|
|
393
|
+
* import { customize, type UserContainer } from "@jr2/orchestrator";
|
|
394
|
+
* import { task } from "@jr2/machines";
|
|
395
|
+
*
|
|
396
|
+
* const sshd: UserContainer = {
|
|
397
|
+
* image: "registry.example.com/sshd:1.0",
|
|
398
|
+
* resources: { limits: { memory: "256Mi", cpu: "250m" } },
|
|
399
|
+
* };
|
|
400
|
+
*
|
|
401
|
+
* export const machine = customize(task, {
|
|
402
|
+
* repos: { app: { url: "https://github.com/you/app.git" } },
|
|
403
|
+
* agents: { coder: { model: "anthropic/claude-haiku-5-5" } },
|
|
404
|
+
* user: sshd,
|
|
405
|
+
* });
|
|
406
|
+
* ```
|
|
407
|
+
*
|
|
408
|
+
* @see docs/concepts/workspaces.md
|
|
409
|
+
* @see docs/guides/size-a-workspace.md
|
|
410
|
+
* @see docs/reference/decisions/0005-sandbox-pod-composition.md
|
|
411
|
+
* @see docs/reference/decisions/0060-a-workspace-states-its-size-and-jr2-reserves-all-of-it.md
|
|
412
|
+
*/
|
|
413
|
+
export type UserContainer = {
|
|
414
|
+
/** The image: a `file:` URL to a docker context that your module ships
|
|
415
|
+
* (`import.meta.resolve("./sshd")`), which `jr2 up` builds, or a registry ref. It must not be
|
|
416
|
+
* empty. */
|
|
417
|
+
image: string;
|
|
418
|
+
/** This container's part of the Workspace's Size, as a Size. jr2 takes it from the Harness
|
|
419
|
+
* container's part. A field you leave out gives this container no limit of its own for it: it
|
|
420
|
+
* shares the pod's budget. Default: no part. */
|
|
421
|
+
resources?: Size;
|
|
422
|
+
};
|
|
423
|
+
|
|
424
|
+
/** The `user` option in either shape, read as its two halves. */
|
|
425
|
+
export function userSeatOf(user: string | UserContainer | undefined): { image?: string; resources?: Size } {
|
|
426
|
+
if (user === undefined) return {};
|
|
427
|
+
if (typeof user === "string") return { image: user };
|
|
428
|
+
return { image: user.image, ...(user.resources !== undefined ? { resources: user.resources } : {}) };
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
/**
|
|
432
|
+
* Refuse a malformed `user` where it is written — a non-empty image string, or `{ image,
|
|
433
|
+
* resources? }` whose split is a Size. `where` names the caller (`workspace()`, `customize()`).
|
|
434
|
+
*/
|
|
435
|
+
export function assertUserSeat(where: string, value: unknown): asserts value is string | UserContainer {
|
|
436
|
+
const image = typeof value === "object" && value !== null ? (value as { image?: unknown }).image : value;
|
|
437
|
+
if (typeof image !== "string" || !image) {
|
|
438
|
+
throw new Error(
|
|
439
|
+
`${where}: \`user\` must be a non-empty string (an image), or { image, resources? } (got ${JSON.stringify(value)}) — ` +
|
|
440
|
+
'either a `file:` URL to a docker context this module ships (`import.meta.resolve("./image")`) or a ' +
|
|
441
|
+
"registry ref (ADR-0037, ADR-0060).",
|
|
442
|
+
);
|
|
443
|
+
}
|
|
444
|
+
if (typeof value !== "object" || value === null) return;
|
|
445
|
+
for (const key of Object.keys(value)) {
|
|
446
|
+
if (key !== "image" && key !== "resources") {
|
|
447
|
+
throw new Error(
|
|
448
|
+
`${where}: user.${key} is not accepted — the User Container is { image, resources? } (ADR-0005, ADR-0060)`,
|
|
449
|
+
);
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
const resources = (value as { resources?: unknown }).resources;
|
|
453
|
+
if (resources !== undefined) assertSize(`${where} user.resources`, resources);
|
|
454
|
+
}
|
package/src/snapshot-store.ts
CHANGED
|
@@ -1,7 +1,11 @@
|
|
|
1
|
-
// Persistence for durable Machine snapshots (ADR-0007). A `SnapshotStore` is keyed by `runId
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
1
|
+
// Persistence for durable Machine snapshots (ADR-0007). A `SnapshotStore` is keyed by `runId`, one
|
|
2
|
+
// blob per run: the snapshot is a tree xstate restores from its root, and one row keeps it atomic
|
|
3
|
+
// (ADR-0065). `SqliteSnapshotStore` on the Instance's volume is the one implementation — no other
|
|
4
|
+
// store is owed — with `:memory:` for tests. A persist survives a process crash, not a power loss
|
|
5
|
+
// (WAL, `synchronous=NORMAL`), and the write stays synchronous so the row is on disk before the
|
|
6
|
+
// Machine's next side effect runs. `markLost` records a run whose live state could not be
|
|
7
|
+
// re-hydrated (e.g. its flue handle is gone), keeping any snapshot the row had; a non-live row
|
|
8
|
+
// lives a week, then `sweep` deletes it.
|
|
5
9
|
|
|
6
10
|
import { DatabaseSync } from "node:sqlite";
|
|
7
11
|
import type { StoredSnapshot } from "./durability.ts";
|
|
@@ -9,8 +13,24 @@ import type { StoredSnapshot } from "./durability.ts";
|
|
|
9
13
|
export interface SnapshotStore {
|
|
10
14
|
init(): Promise<void>;
|
|
11
15
|
load(runId: string): Promise<StoredSnapshot | null>;
|
|
12
|
-
/**
|
|
13
|
-
|
|
16
|
+
/** The `live` runs, in insertion order — what `restore()` reads (ADR-0065). Terminal rows are
|
|
17
|
+
* filtered by the store, not the host, so a boot never parses a week of finished runs. */
|
|
18
|
+
live(): Promise<StoredSnapshot[]>;
|
|
19
|
+
/**
|
|
20
|
+
* The `live` runs by the Assembly hash their blob names (ADR-0069), each list in insertion order:
|
|
21
|
+
* what holds an Assembly. A hold is the ROW, not the process: a run this process failed to
|
|
22
|
+
* restore is still live here, so it still holds its Assembly. Read without parsing a snapshot,
|
|
23
|
+
* because `GET /assembly` and every collection ask. A live row that names no Assembly is not
|
|
24
|
+
* listed.
|
|
25
|
+
*/
|
|
26
|
+
liveAssemblies(): Promise<Map<string, string[]>>;
|
|
27
|
+
/**
|
|
28
|
+
* Delete every non-live row last written before `olderThan`, and answer how many (ADR-0065). A
|
|
29
|
+
* `live` row is never swept, whatever its age: a run parked on a Gate for a month is restored.
|
|
30
|
+
* A swept id is gone as if it never ran — `read()` answers undefined, and its abbreviations are
|
|
31
|
+
* free again (ADR-0009).
|
|
32
|
+
*/
|
|
33
|
+
sweep(olderThan: Date): Promise<number>;
|
|
14
34
|
/**
|
|
15
35
|
* Persisted run ids sharing a prefix, sorted, capped at `limit` — the store half of abbreviated
|
|
16
36
|
* run ids (ADR-0009). Includes `lost` rows: the ambiguity set is the ids that EXIST, not the ones
|
|
@@ -18,16 +38,31 @@ export interface SnapshotStore {
|
|
|
18
38
|
* silently to the live one, which makes resolution unsound.
|
|
19
39
|
*/
|
|
20
40
|
findIdsByPrefix(prefix: string, limit: number): Promise<string[]>;
|
|
41
|
+
/**
|
|
42
|
+
* The run whose blob carries the idempotency key `effect` (`dispatchedBy.effect`, ADR-0071), in
|
|
43
|
+
* any status, or undefined: what answers a re-sent dispatch with the run it already started. A
|
|
44
|
+
* row `markLost` wrote with no save before it has no snapshot, and so no key; a swept row is gone
|
|
45
|
+
* with its key.
|
|
46
|
+
*/
|
|
47
|
+
findByDispatch(effect: string): Promise<string | undefined>;
|
|
21
48
|
save(runId: string, snapshot: unknown, status?: string): Promise<void>;
|
|
49
|
+
/**
|
|
50
|
+
* Record a run whose live state could not be re-hydrated. An upsert: a row that was saved KEEPS
|
|
51
|
+
* its snapshot and its idempotency key, because a lost dispatched run must keep answering its
|
|
52
|
+
* key — a re-send from its caller would otherwise start a second run (ADR-0071). Only a run with
|
|
53
|
+
* no row before gets a row with no snapshot.
|
|
54
|
+
*/
|
|
22
55
|
markLost(runId: string, reason: string): Promise<void>;
|
|
23
56
|
/**
|
|
24
|
-
* Record a run
|
|
57
|
+
* Record a run this Orchestrator can no longer read (ADR-0030, ADR-0069) — refused, not resumed:
|
|
58
|
+
* the Assembly its snapshot names is not on the volume, was published for another Kit version or
|
|
59
|
+
* does not load, or its Machine's shape does not match the snapshot's stamp.
|
|
25
60
|
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
61
|
+
* An UPDATE of a row that is there, and it KEEPS the snapshot: a drifted run is intact and only
|
|
62
|
+
* unreadable by this Orchestrator, and it is the one a human most needs to inspect. Nulling it
|
|
63
|
+
* would also hide the run entirely: `read()` returns undefined for a null blob, so
|
|
64
|
+
* `jr2 status <id>` would answer `no run` — a refusal indistinguishable from a run that never
|
|
65
|
+
* existed. It keeps the row's idempotency key too (ADR-0071).
|
|
31
66
|
*/
|
|
32
67
|
markDrifted(runId: string, reason: string): Promise<void>;
|
|
33
68
|
close(): Promise<void>;
|
|
@@ -41,6 +76,20 @@ type Row = {
|
|
|
41
76
|
updated_at: string;
|
|
42
77
|
};
|
|
43
78
|
|
|
79
|
+
/** The lookup `findByDispatch` runs, exported so a test can ask the planner about THIS statement,
|
|
80
|
+
* not a copy of it: the column is indexed, so the plan is a seek. */
|
|
81
|
+
export const FIND_BY_DISPATCH_SQL = `SELECT run_id FROM machine_snapshots
|
|
82
|
+
WHERE dispatch_effect = ? ORDER BY rowid LIMIT 1`;
|
|
83
|
+
|
|
84
|
+
/** The idempotency key a blob carries at `dispatchedBy.effect` (ADR-0071), or null. */
|
|
85
|
+
function dispatchEffectOf(snapshot: unknown): string | null {
|
|
86
|
+
if (typeof snapshot !== "object" || snapshot === null) return null;
|
|
87
|
+
const by = (snapshot as { dispatchedBy?: unknown }).dispatchedBy;
|
|
88
|
+
if (typeof by !== "object" || by === null) return null;
|
|
89
|
+
const effect = (by as { effect?: unknown }).effect;
|
|
90
|
+
return typeof effect === "string" ? effect : null;
|
|
91
|
+
}
|
|
92
|
+
|
|
44
93
|
export class SqliteSnapshotStore implements SnapshotStore {
|
|
45
94
|
private readonly db: DatabaseSync;
|
|
46
95
|
|
|
@@ -49,6 +98,10 @@ export class SqliteSnapshotStore implements SnapshotStore {
|
|
|
49
98
|
}
|
|
50
99
|
|
|
51
100
|
async init(): Promise<void> {
|
|
101
|
+
// A persist survives a process crash, not a power loss (ADR-0065): a write is durable once the
|
|
102
|
+
// OS holds it, and sqlite fsyncs only at a checkpoint. FULL cost 4.5 ms a persist, all fsync.
|
|
103
|
+
this.db.exec(`PRAGMA journal_mode = WAL`);
|
|
104
|
+
this.db.exec(`PRAGMA synchronous = NORMAL`);
|
|
52
105
|
this.db.exec(`
|
|
53
106
|
CREATE TABLE IF NOT EXISTS machine_snapshots (
|
|
54
107
|
run_id TEXT PRIMARY KEY,
|
|
@@ -58,6 +111,16 @@ export class SqliteSnapshotStore implements SnapshotStore {
|
|
|
58
111
|
updated_at TEXT
|
|
59
112
|
)
|
|
60
113
|
`);
|
|
114
|
+
// The idempotency key of a dispatched run (ADR-0071) is copied out of its blob into a column of
|
|
115
|
+
// its own when `save` writes the row, and the column is indexed. Not an expression index over
|
|
116
|
+
// `json_extract(snapshot, …)`: that runs on every row and THROWS on a blob SQLite's JSON parser
|
|
117
|
+
// refuses (nested about a thousand deep), so one such row would fail its own save and every
|
|
118
|
+
// later `init()` over the volume. A column also costs no JSON parse a save.
|
|
119
|
+
const columns = this.db.prepare(`PRAGMA table_info(machine_snapshots)`).all() as Array<{ name: string }>;
|
|
120
|
+
if (!columns.some((c) => c.name === "dispatch_effect")) {
|
|
121
|
+
this.db.exec(`ALTER TABLE machine_snapshots ADD COLUMN dispatch_effect TEXT`);
|
|
122
|
+
}
|
|
123
|
+
this.db.exec(`CREATE INDEX IF NOT EXISTS machine_snapshots_dispatch_effect ON machine_snapshots(dispatch_effect)`);
|
|
61
124
|
}
|
|
62
125
|
|
|
63
126
|
async load(runId: string): Promise<StoredSnapshot | null> {
|
|
@@ -75,9 +138,12 @@ export class SqliteSnapshotStore implements SnapshotStore {
|
|
|
75
138
|
return stored;
|
|
76
139
|
}
|
|
77
140
|
|
|
78
|
-
async
|
|
141
|
+
async live(): Promise<StoredSnapshot[]> {
|
|
79
142
|
const rows = this.db
|
|
80
|
-
.prepare(
|
|
143
|
+
.prepare(
|
|
144
|
+
`SELECT run_id, status, snapshot, reason, updated_at FROM machine_snapshots
|
|
145
|
+
WHERE status = 'live' ORDER BY rowid`,
|
|
146
|
+
)
|
|
81
147
|
.all() as Row[];
|
|
82
148
|
return rows.map((row) => {
|
|
83
149
|
const stored: StoredSnapshot = {
|
|
@@ -90,13 +156,27 @@ export class SqliteSnapshotStore implements SnapshotStore {
|
|
|
90
156
|
});
|
|
91
157
|
}
|
|
92
158
|
|
|
159
|
+
async liveAssemblies(): Promise<Map<string, string[]>> {
|
|
160
|
+
const rows = this.db
|
|
161
|
+
.prepare(
|
|
162
|
+
`SELECT run_id, json_extract(snapshot, '$.assembly') AS assembly FROM machine_snapshots
|
|
163
|
+
WHERE status = 'live' AND json_valid(snapshot) ORDER BY rowid`,
|
|
164
|
+
)
|
|
165
|
+
.all() as Array<{ run_id: string; assembly: unknown }>;
|
|
166
|
+
const held = new Map<string, string[]>();
|
|
167
|
+
for (const { run_id, assembly } of rows) {
|
|
168
|
+
if (typeof assembly !== "string") continue;
|
|
169
|
+
held.set(assembly, [...(held.get(assembly) ?? []), run_id]);
|
|
170
|
+
}
|
|
171
|
+
return held;
|
|
172
|
+
}
|
|
173
|
+
|
|
93
174
|
/**
|
|
94
175
|
* A range scan rather than `LIKE`/`GLOB`. `run_id` is the BINARY-collated PRIMARY KEY, but
|
|
95
176
|
* SQLite's `LIKE` is ASCII-case-insensitive by default, so the planner declines the index range
|
|
96
177
|
* and falls back to a full scan. `GLOB` would seek correctly but obliges every caller to escape
|
|
97
178
|
* `*?[]` first — a store shouldn't have to trust that. The `CHAR(0x10FFFF)` sentinel as the upper
|
|
98
|
-
* bound also avoids incrementing the prefix's last character, which has edge cases.
|
|
99
|
-
* carries to Postgres unchanged, where `LIKE 'x%'` would need `text_pattern_ops` to use an index.
|
|
179
|
+
* bound also avoids incrementing the prefix's last character, which has edge cases.
|
|
100
180
|
*/
|
|
101
181
|
async findIdsByPrefix(prefix: string, limit: number): Promise<string[]> {
|
|
102
182
|
const rows = this.db
|
|
@@ -109,18 +189,24 @@ export class SqliteSnapshotStore implements SnapshotStore {
|
|
|
109
189
|
return rows.map((row) => row.run_id);
|
|
110
190
|
}
|
|
111
191
|
|
|
192
|
+
async findByDispatch(effect: string): Promise<string | undefined> {
|
|
193
|
+
const row = this.db.prepare(FIND_BY_DISPATCH_SQL).get(effect) as { run_id: string } | undefined;
|
|
194
|
+
return row?.run_id;
|
|
195
|
+
}
|
|
196
|
+
|
|
112
197
|
async save(runId: string, snapshot: unknown, status = "live"): Promise<void> {
|
|
113
198
|
this.db
|
|
114
199
|
.prepare(
|
|
115
|
-
`INSERT INTO machine_snapshots (run_id, status, snapshot, reason, updated_at)
|
|
116
|
-
VALUES (?, ?, ?, NULL, ?)
|
|
200
|
+
`INSERT INTO machine_snapshots (run_id, status, snapshot, reason, updated_at, dispatch_effect)
|
|
201
|
+
VALUES (?, ?, ?, NULL, ?, ?)
|
|
117
202
|
ON CONFLICT(run_id) DO UPDATE SET
|
|
118
203
|
status = excluded.status,
|
|
119
204
|
snapshot = excluded.snapshot,
|
|
120
205
|
reason = NULL,
|
|
121
|
-
updated_at = excluded.updated_at
|
|
206
|
+
updated_at = excluded.updated_at,
|
|
207
|
+
dispatch_effect = excluded.dispatch_effect`,
|
|
122
208
|
)
|
|
123
|
-
.run(runId, status, JSON.stringify(snapshot), new Date().toISOString());
|
|
209
|
+
.run(runId, status, JSON.stringify(snapshot), new Date().toISOString(), dispatchEffectOf(snapshot));
|
|
124
210
|
}
|
|
125
211
|
|
|
126
212
|
async markLost(runId: string, reason: string): Promise<void> {
|
|
@@ -144,6 +230,15 @@ export class SqliteSnapshotStore implements SnapshotStore {
|
|
|
144
230
|
.run(reason, new Date().toISOString(), runId);
|
|
145
231
|
}
|
|
146
232
|
|
|
233
|
+
/** `updated_at` is an ISO-8601 UTC string, so a string comparison is a time comparison. A
|
|
234
|
+
* terminal row's `updated_at` is its terminal write — the age the week counts from. */
|
|
235
|
+
async sweep(olderThan: Date): Promise<number> {
|
|
236
|
+
const result = this.db
|
|
237
|
+
.prepare(`DELETE FROM machine_snapshots WHERE status != 'live' AND updated_at < ?`)
|
|
238
|
+
.run(olderThan.toISOString());
|
|
239
|
+
return Number(result.changes);
|
|
240
|
+
}
|
|
241
|
+
|
|
147
242
|
async close(): Promise<void> {
|
|
148
243
|
this.db.close();
|
|
149
244
|
}
|