@jr2/orchestrator 0.5.1 → 0.7.0
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/console/components/app.ts +55 -15
- package/console/components/bottom-drawer.ts +259 -0
- package/console/components/drawer.ts +14 -6
- package/console/components/fleet.ts +36 -13
- package/console/components/machine-pane.ts +61 -5
- package/console/components/nav.ts +54 -8
- package/console/conversation.ts +161 -0
- package/console/login.ts +123 -0
- package/console/main.ts +600 -60
- package/console/store.ts +611 -64
- package/console/style.css +231 -0
- package/docs/agents-block.md +2 -2
- package/docs/concepts/agents-and-turns.md +142 -29
- package/docs/concepts/architecture.md +60 -34
- package/docs/concepts/callers-and-hooks.md +205 -0
- package/docs/concepts/charts.md +13 -8
- package/docs/concepts/composition.md +10 -1
- package/docs/concepts/converge.md +7 -2
- package/docs/concepts/durable-runs.md +43 -16
- package/docs/concepts/effects.md +133 -50
- package/docs/concepts/gates.md +95 -25
- package/docs/concepts/machines-and-workflows.md +14 -4
- package/docs/concepts/pools-and-sources.md +193 -17
- package/docs/concepts/secrets-and-trust.md +186 -62
- package/docs/concepts/the-menu.md +14 -0
- package/docs/concepts/workspaces.md +135 -56
- package/docs/get-started/a-model-decides.md +14 -13
- package/docs/get-started/an-agent-in-a-sandbox.md +46 -35
- package/docs/get-started/connect-a-model.md +1 -1
- package/docs/get-started/create-an-instance.md +17 -14
- package/docs/get-started/have-your-assistant-write-a-machine.md +111 -40
- package/docs/get-started/install.md +4 -0
- package/docs/guides/add-tools-to-the-sandbox.md +72 -15
- package/docs/guides/answer-a-gate-from-a-forge.md +304 -0
- package/docs/guides/answer-a-gate.md +74 -37
- package/docs/guides/bind-open-parts.md +29 -12
- package/docs/guides/call-an-mcp-tool-from-a-machine.md +572 -0
- package/docs/guides/cancel-a-run.md +26 -16
- package/docs/guides/cut-a-worktree.md +560 -0
- package/docs/guides/fix-a-failed-converge.md +19 -7
- package/docs/guides/give-a-caller-a-scope.md +203 -0
- package/docs/guides/give-an-agent-mcp-tools.md +422 -0
- package/docs/guides/iterate-on-a-machine-on-kind.md +10 -9
- package/docs/guides/let-an-agent-write-a-chart.md +14 -7
- package/docs/guides/look-inside-a-sandbox.md +115 -42
- package/docs/guides/move-from-kind-to-a-shared-cluster.md +16 -0
- package/docs/guides/open-the-console.md +63 -14
- package/docs/guides/push-and-sign-a-branch.md +67 -21
- package/docs/guides/read-a-conversation.md +225 -0
- package/docs/guides/run-a-chart-by-name.md +17 -16
- package/docs/guides/run-a-command-or-call-a-url.md +84 -41
- package/docs/guides/run-one-worker-per-item.md +34 -27
- package/docs/guides/set-an-agents-access-and-dials.md +69 -22
- package/docs/guides/size-a-workspace.md +19 -6
- package/docs/guides/start-a-run-for-each-alert.md +290 -0
- package/docs/guides/start-a-run-from-a-run.md +18 -16
- package/docs/guides/start-a-run.md +24 -18
- package/docs/guides/steer-an-agent.md +332 -0
- package/docs/guides/use-a-hosted-provider.md +7 -1
- package/docs/guides/watch-a-run.md +146 -117
- package/docs/guides/write-a-hook-kind.md +238 -0
- package/docs/guides/write-a-tool-as-an-mcp-server.md +351 -0
- package/docs/guides/write-a-workflow.md +18 -8
- package/docs/llms.txt +110 -23
- package/docs/reference/authoring-surface/agent.md +35 -12
- package/docs/reference/authoring-surface/answer.md +20 -4
- package/docs/reference/authoring-surface/arg.md +28 -6
- package/docs/reference/authoring-surface/basic.md +1 -1
- package/docs/reference/authoring-surface/bearer.md +1 -1
- package/docs/reference/authoring-surface/branchname.md +15 -0
- package/docs/reference/authoring-surface/carry.md +23 -0
- package/docs/reference/authoring-surface/chartschema.md +1 -1
- package/docs/reference/authoring-surface/chartschemafor.md +1 -1
- package/docs/reference/authoring-surface/command.md +37 -22
- package/docs/reference/authoring-surface/customize.md +4 -1
- package/docs/reference/authoring-surface/defineconfig.md +1 -1
- package/docs/reference/authoring-surface/defineevent.md +1 -1
- package/docs/reference/authoring-surface/dispatch.md +5 -4
- package/docs/reference/authoring-surface/fail.md +18 -5
- package/docs/reference/authoring-surface/file.md +37 -17
- package/docs/reference/authoring-surface/frompick.md +1 -1
- package/docs/reference/authoring-surface/gate.md +118 -0
- package/docs/reference/authoring-surface/group-agents.md +15 -0
- package/docs/reference/authoring-surface/group-charts.md +16 -0
- package/docs/reference/authoring-surface/group-composition.md +25 -0
- package/docs/reference/authoring-surface/group-config.md +17 -0
- package/docs/reference/authoring-surface/group-effects.md +48 -0
- package/docs/reference/authoring-surface/group-failure.md +13 -0
- package/docs/reference/authoring-surface/group-gates.md +16 -0
- package/docs/reference/authoring-surface/group-machine-and-events.md +15 -0
- package/docs/reference/authoring-surface/group-steering.md +12 -0
- package/docs/reference/authoring-surface/group-types-these-signatures-name.md +137 -0
- package/docs/reference/authoring-surface/group-workspaces-and-repos.md +15 -0
- package/docs/reference/authoring-surface/http.md +109 -9
- package/docs/reference/authoring-surface/isfailure.md +1 -1
- package/docs/reference/authoring-surface/isready.md +14 -0
- package/docs/reference/authoring-surface/jr2setup.md +14 -4
- package/docs/reference/authoring-surface/mcp.md +107 -0
- package/docs/reference/authoring-surface/open.md +41 -8
- package/docs/reference/authoring-surface/openbody.md +1 -1
- package/docs/reference/authoring-surface/paramsof.md +23 -5
- package/docs/reference/authoring-surface/pool.md +79 -11
- package/docs/reference/authoring-surface/push.md +31 -13
- package/docs/reference/authoring-surface/readyof.md +21 -0
- package/docs/reference/authoring-surface/reread.md +19 -0
- package/docs/reference/authoring-surface/secret.md +26 -5
- package/docs/reference/authoring-surface/sentby.md +1 -1
- package/docs/reference/authoring-surface/skip.md +22 -0
- package/docs/reference/authoring-surface/source.md +59 -4
- package/docs/reference/authoring-surface/steer.md +93 -0
- package/docs/reference/authoring-surface/text.md +1 -1
- package/docs/reference/authoring-surface/type-agentdefinition.md +16 -3
- package/docs/reference/authoring-surface/type-agentmcpserver.md +54 -0
- package/docs/reference/authoring-surface/type-agentslots.md +4 -1
- package/docs/reference/authoring-surface/type-anyframepath.md +14 -0
- package/docs/reference/authoring-surface/type-anyworktreepath.md +15 -0
- package/docs/reference/authoring-surface/type-bindactors.md +14 -0
- package/docs/reference/authoring-surface/type-binding.md +6 -7
- package/docs/reference/authoring-surface/type-caller.md +40 -0
- package/docs/reference/authoring-surface/type-callerrecord.md +32 -0
- package/docs/reference/authoring-surface/type-carry.md +19 -0
- package/docs/reference/authoring-surface/type-chainlink.md +3 -3
- package/docs/reference/authoring-surface/type-commandinput.md +4 -3
- package/docs/reference/authoring-surface/type-commandoptions.md +0 -22
- package/docs/reference/authoring-surface/type-commandoutput.md +23 -5
- package/docs/reference/authoring-surface/type-customize.md +20 -3
- package/docs/reference/authoring-surface/type-delivered.md +17 -0
- package/docs/reference/authoring-surface/type-deliveredevent.md +18 -0
- package/docs/reference/authoring-surface/type-dispatchdefinition.md +4 -3
- package/docs/reference/authoring-surface/type-dispatchoutput.md +9 -2
- package/docs/reference/authoring-surface/type-doorlessinput.md +19 -0
- package/docs/reference/authoring-surface/type-effectbinding.md +12 -2
- package/docs/reference/authoring-surface/type-filebody.md +4 -8
- package/docs/reference/authoring-surface/type-framepath.md +14 -0
- package/docs/reference/authoring-surface/type-gatebinding.md +15 -0
- package/docs/reference/authoring-surface/type-gatehooks.md +12 -0
- package/docs/reference/authoring-surface/type-gatelook.md +37 -0
- package/docs/reference/authoring-surface/type-gateoutcome.md +32 -0
- package/docs/reference/authoring-surface/type-gatereply.md +19 -0
- package/docs/reference/authoring-surface/type-gateslot.md +21 -0
- package/docs/reference/authoring-surface/type-gateslotinput.md +13 -0
- package/docs/reference/authoring-surface/type-gateslotparts.md +18 -0
- package/docs/reference/authoring-surface/type-gateslots.md +12 -0
- package/docs/reference/authoring-surface/type-gatetypesat.md +13 -0
- package/docs/reference/authoring-surface/type-hostinjectedinput.md +3 -5
- package/docs/reference/authoring-surface/type-httpdefinition.md +6 -3
- package/docs/reference/authoring-surface/type-httpfileinput.md +20 -0
- package/docs/reference/authoring-surface/type-httpoutput.md +8 -2
- package/docs/reference/authoring-surface/type-jr2config.md +37 -2
- package/docs/reference/authoring-surface/type-lateroutcome.md +21 -0
- package/docs/reference/authoring-surface/type-lends.md +5 -1
- package/docs/reference/authoring-surface/type-lookoutput.md +14 -0
- package/docs/reference/authoring-surface/type-mcpcalloutput.md +78 -0
- package/docs/reference/authoring-surface/type-mcpnotreturned.md +42 -0
- package/docs/reference/authoring-surface/type-mcpoptions.md +47 -0
- package/docs/reference/authoring-surface/type-mcpoutput.md +50 -0
- package/docs/reference/authoring-surface/type-mcpreadoutput.md +53 -0
- package/docs/reference/authoring-surface/type-mcpremoteserver.md +50 -0
- package/docs/reference/authoring-surface/type-mcpserver.md +41 -0
- package/docs/reference/authoring-surface/type-mcpstdioserver.md +42 -0
- package/docs/reference/authoring-surface/type-namedsecret.md +4 -4
- package/docs/reference/authoring-surface/type-namestart.md +77 -0
- package/docs/reference/authoring-surface/type-noslotworkspaceoptions.md +1 -6
- package/docs/reference/authoring-surface/type-notworktreekeys.md +12 -0
- package/docs/reference/authoring-surface/type-openbodydeclaration.md +1 -1
- package/docs/reference/authoring-surface/type-openbodyoutput.md +1 -1
- package/docs/reference/authoring-surface/type-opencommandinput.md +7 -2
- package/docs/reference/authoring-surface/type-permissivenoslotworkspaceoptions.md +1 -6
- package/docs/reference/authoring-surface/type-permissiveworkspaceoptions.md +5 -10
- package/docs/reference/authoring-surface/type-poolnextinput.md +22 -0
- package/docs/reference/authoring-surface/type-poolspec.md +11 -6
- package/docs/reference/authoring-surface/type-pushinput.md +22 -3
- package/docs/reference/authoring-surface/type-pushoutput.md +33 -8
- package/docs/reference/authoring-surface/type-pushsourcespec.md +38 -0
- package/docs/reference/authoring-surface/type-readsourcespec.md +47 -0
- package/docs/reference/authoring-surface/type-reposlot.md +6 -6
- package/docs/reference/authoring-surface/type-reread.md +18 -0
- package/docs/reference/authoring-surface/type-runsconfig.md +41 -0
- package/docs/reference/authoring-surface/type-sandboxoptions.md +11 -4
- package/docs/reference/authoring-surface/type-sender.md +26 -1
- package/docs/reference/authoring-surface/type-sendercallerfield.md +14 -0
- package/docs/reference/authoring-surface/type-size.md +1 -1
- package/docs/reference/authoring-surface/type-skip.md +19 -0
- package/docs/reference/authoring-surface/type-slothandles.md +5 -13
- package/docs/reference/authoring-surface/type-sourcehooks.md +23 -0
- package/docs/reference/authoring-surface/type-sourcein.md +25 -0
- package/docs/reference/authoring-surface/type-sourcereply.md +18 -0
- package/docs/reference/authoring-surface/type-sourcespec.md +26 -17
- package/docs/reference/authoring-surface/type-sourceverdict.md +49 -0
- package/docs/reference/authoring-surface/type-standingpoolmachine.md +12 -0
- package/docs/reference/authoring-surface/type-standingpoolspec.md +51 -0
- package/docs/reference/authoring-surface/type-steerargs.md +20 -0
- package/docs/reference/authoring-surface/type-thinkinglevel.md +1 -1
- package/docs/reference/authoring-surface/type-usercontainer.md +1 -1
- package/docs/reference/authoring-surface/type-workspaced.md +30 -9
- package/docs/reference/authoring-surface/type-workspacehandles.md +5 -6
- package/docs/reference/authoring-surface/type-workspaceoptions.md +1 -13
- package/docs/reference/authoring-surface/type-worktreefound.md +16 -0
- package/docs/reference/authoring-surface/type-worktreeinput.md +56 -0
- package/docs/reference/authoring-surface/type-worktreekey.md +14 -0
- package/docs/reference/authoring-surface/type-worktreeoptions.md +29 -0
- package/docs/reference/authoring-surface/type-worktreeoutput.md +45 -0
- package/docs/reference/authoring-surface/type-worktreepath.md +16 -0
- package/docs/reference/authoring-surface/type-worktreeready.md +37 -0
- package/docs/reference/authoring-surface/type-worktreerefused.md +21 -0
- package/docs/reference/authoring-surface/workspace.md +66 -38
- package/docs/reference/authoring-surface/worktree.md +71 -0
- package/docs/reference/authoring-surface.md +88 -147
- package/docs/reference/cli.md +413 -74
- package/docs/reference/config.md +248 -6
- package/docs/reference/decisions/0001-operator-owned-generic-sandbox.md +10 -9
- package/docs/reference/decisions/0002-duplex-streaming-actor.md +81 -14
- package/docs/reference/decisions/0004-worktree-provisioning-model.md +27 -21
- package/docs/reference/decisions/0005-sandbox-pod-composition.md +39 -25
- package/docs/reference/decisions/0006-agent-control-surface.md +6 -3
- package/docs/reference/decisions/0007-durable-machine-state.md +1 -1
- package/docs/reference/decisions/0009-cli-and-instance-interface.md +150 -49
- package/docs/reference/decisions/0010-bdd-acceptance-tests.md +5 -3
- package/docs/reference/decisions/0011-workflow-defined-events.md +256 -12
- package/docs/reference/decisions/0012-workspace-wrapper-machine.md +39 -32
- package/docs/reference/decisions/0013-the-agent-reaches-its-machine-through-a-container-it-cannot-read.md +54 -30
- package/docs/reference/decisions/0014-observation-is-open-run-state-is-not.md +53 -27
- package/docs/reference/decisions/0015-authoring-surface-absorbs-the-mechanism.md +26 -16
- package/docs/reference/decisions/0016-agent-turn-mechanics-are-internal.md +9 -7
- package/docs/reference/decisions/0017-pool-over-a-source-port.md +226 -10
- package/docs/reference/decisions/0018-instance-agents-are-definitions-jr2-assembles-the-harness.md +15 -7
- package/docs/reference/decisions/0019-one-converging-command-against-the-current-context.md +53 -27
- package/docs/reference/decisions/0021-workspace-continuity-is-a-lease-that-answers-back.md +16 -2
- package/docs/reference/decisions/0022-observation-is-a-level-triggered-feed.md +4 -4
- package/docs/reference/decisions/0023-the-harness-prints-the-conversation.md +124 -43
- package/docs/reference/decisions/0024-an-agents-turn-ends-with-the-state-that-asked-for-it.md +36 -38
- package/docs/reference/decisions/0025-cancel-ends-a-run-stop-parks-it.md +10 -5
- package/docs/reference/decisions/0027-the-harness-is-jr2s-own-server-flue-retires-the-wire-stays.md +16 -7
- package/docs/reference/decisions/0028-what-an-agent-may-do-to-the-workspace-is-part-of-its-definition.md +36 -19
- package/docs/reference/decisions/0029-a-menu-is-fixed-for-a-conversation-and-a-turn-is-told-its-allowed-picks.md +18 -10
- package/docs/reference/decisions/0030-a-snapshot-names-the-machine-it-was-written-under.md +3 -2
- package/docs/reference/decisions/0031-menu-only-agents-run-on-the-instance-harness.md +23 -15
- package/docs/reference/decisions/0032-the-console-unlocks-with-a-callers-credential.md +101 -0
- package/docs/reference/decisions/0033-a-machine-declares-the-input-that-starts-it.md +22 -22
- package/docs/reference/decisions/0034-the-console-renders-with-preact-and-the-server-erases-the-types.md +6 -6
- package/docs/reference/decisions/0037-an-instance-builds-its-sandbox-images-jr2-injects-the-harness.md +13 -12
- package/docs/reference/decisions/0049-a-machine-carries-its-parts-and-composes-by-invoke.md +23 -23
- package/docs/reference/decisions/0050-what-code-names-config-declares-what-the-cli-names-discovery-finds.md +6 -1
- package/docs/reference/decisions/0051-a-repo-is-a-slot-on-the-workspace-and-a-cache-on-the-node.md +57 -46
- package/docs/reference/decisions/0052-a-sandbox-lands-where-an-ordinary-pod-lands-and-the-cache-follows-it.md +5 -0
- package/docs/reference/decisions/0053-a-fetch-inside-the-pod-asks-the-node-cache-and-the-cache-asks-the-remote.md +20 -8
- package/docs/reference/decisions/0054-the-kit-ships-machines-and-a-shipped-machine-leaves-its-parts-open.md +55 -40
- package/docs/reference/decisions/0055-a-release-is-a-pushed-tag-and-the-publish-guard-is-the-credential.md +14 -13
- package/docs/reference/decisions/0057-a-turn-is-its-frame-its-dials-and-whether-it-continues.md +31 -24
- package/docs/reference/decisions/0058-a-harness-answers-the-orchestrator-alone.md +18 -10
- package/docs/reference/decisions/0059-a-harness-holds-stand-ins-and-the-custodian-holds-the-keys.md +137 -86
- package/docs/reference/decisions/0062-jr2-briefs-the-agent-on-its-seat-never-on-its-task.md +165 -91
- package/docs/reference/decisions/0063-the-orchestrator-watches-the-cluster.md +7 -7
- package/docs/reference/decisions/0064-a-workspace-waits-for-capacity.md +2 -2
- package/docs/reference/decisions/0065-a-persist-survives-a-crash-and-history-is-kept-for-a-set-time.md +109 -0
- package/docs/reference/decisions/0066-a-model-tier-runs-a-real-model-and-a-change-to-what-it-reads-runs-the-tier.md +53 -23
- package/docs/reference/decisions/0067-an-effect-is-an-actor-slot-and-runs-at-most-once-unless-marked-idempotent.md +221 -153
- package/docs/reference/decisions/0068-an-effector-holds-an-effects-secrets-and-signs-its-wire.md +161 -96
- package/docs/reference/decisions/0069-the-orchestrator-is-a-kit-image-and-an-instance-publishes-its-assembly.md +32 -9
- package/docs/reference/decisions/0070-a-machine-lends-its-parts-to-a-chart.md +48 -33
- package/docs/reference/decisions/0071-a-run-starts-a-run-through-a-dispatch-effect.md +56 -30
- package/docs/reference/decisions/0072-the-docs-ship-inside-the-kit-and-an-instances-agents-md-indexes-them.md +35 -25
- package/docs/reference/decisions/0073-a-machine-fails-a-run-with-fail-and-an-uncaught-failure-ends-it-failed.md +1 -1
- package/docs/reference/decisions/0075-a-machine-cuts-a-worktree-and-the-workspace-only-clones.md +296 -0
- package/docs/reference/decisions/0076-an-agents-control-credential-lives-only-where-no-workspace-code-runs.md +347 -0
- package/docs/reference/decisions/0077-jr2-requires-landlock-on-every-sandbox-node.md +79 -0
- package/docs/reference/decisions/0078-a-caller-comes-from-one-authenticator-seam-and-acts-within-its-scopes.md +285 -0
- package/docs/reference/decisions/0079-an-agent-holds-mcp-tools-a-machine-calls-them-as-an-effect-and-the-machine-is-the-approval.md +198 -0
- package/docs/reference/decisions.md +9 -4
- package/docs/reference/glossary.md +138 -45
- package/docs/reference/hooks.md +1077 -0
- package/docs/reference/http-api.md +988 -123
- package/docs/reference/machines.md +0 -1
- package/docs/why.md +7 -5
- package/package.json +10 -3
- package/src/actor.ts +194 -42
- package/src/agent.ts +56 -6
- package/src/ambient.ts +3 -5
- package/src/assembly-checks.ts +530 -4
- package/src/callers.ts +245 -0
- package/src/chart.ts +238 -26
- package/src/charts.ts +9 -2
- package/src/config.ts +566 -7
- package/src/conversations.ts +453 -0
- package/src/custodian.lua +17 -5
- package/src/custodian.ts +257 -77
- package/src/customize.ts +154 -7
- package/src/dispatch-effect.ts +142 -26
- package/src/durability.ts +2 -0
- package/src/duration.ts +20 -0
- package/src/effect-checks.ts +52 -4
- package/src/effect.ts +401 -132
- package/src/effector-wire.ts +278 -30
- package/src/fail.ts +17 -4
- package/src/gate-brand.ts +16 -0
- package/src/gate.ts +634 -48
- package/src/harness-client.ts +84 -35
- package/src/harness-env.ts +33 -0
- package/src/held-secrets.ts +197 -39
- package/src/holds.ts +5 -2
- package/src/hook-kinds/discord.ts +92 -0
- package/src/hook-kinds/door.ts +107 -0
- package/src/hook-kinds/github.ts +89 -0
- package/src/hook-kinds/gitlab.ts +72 -0
- package/src/hook-kinds/hook.ts +7 -0
- package/src/hook-kinds/index.ts +465 -0
- package/src/hook-kinds/slack.ts +151 -0
- package/src/hook-kinds/webhook.ts +113 -0
- package/src/hooks.ts +506 -0
- package/src/http-effect.ts +164 -70
- package/src/http.ts +933 -99
- package/src/index.ts +30 -5
- package/src/instance.ts +196 -26
- package/src/internal.ts +23 -3
- package/src/kube-client.ts +6 -3
- package/src/machine-doc.ts +29 -5
- package/src/mcp-effect.ts +424 -0
- package/src/model-keys.ts +24 -0
- package/src/mount-digest.ts +41 -7
- package/src/names.ts +82 -7
- package/src/notices.ts +576 -59
- package/src/open-body.ts +110 -30
- package/src/open-effect.ts +17 -4
- package/src/open.ts +40 -7
- package/src/parts.ts +125 -35
- package/src/party.ts +35 -1
- package/src/pick.ts +43 -9
- package/src/pool.ts +1172 -172
- package/src/registration.ts +249 -17
- package/src/repo-identity.ts +7 -3
- package/src/repos.ts +20 -2
- package/src/run-host.ts +1614 -190
- package/src/run-list.ts +191 -0
- package/src/sandbox-effect.ts +217 -66
- package/src/sandbox-kube.ts +116 -21
- package/src/secret-placement.ts +248 -35
- package/src/server.ts +53 -4
- package/src/setup.ts +158 -38
- package/src/snapshot-store.ts +565 -48
- package/src/steer.ts +111 -0
- package/src/stub-harness.ts +6 -2
- package/src/tokens.ts +362 -46
- package/src/vocabulary.ts +23 -4
- package/src/wire.ts +27 -10
- package/src/workspace.ts +314 -259
- package/src/worktree.ts +249 -0
- package/docs/reference/authoring-surface/type-noslotspec.md +0 -26
- package/docs/reference/authoring-surface/type-workspacespec.md +0 -37
- package/docs/reference/decisions/0032-the-console-unlocks-with-the-instance-token.md +0 -61
- package/docs/reference/decisions/0065-a-persist-survives-a-crash-and-history-is-a-week.md +0 -65
package/src/http.ts
CHANGED
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
// GET /workflows/:wrapper/chart-schema the grammar, narrowed to the Open body [open]
|
|
19
19
|
// PUT /workflows/:wrapper/charts/:name admit + keep a runtime Chart [Instance token]
|
|
20
20
|
// DELETE /workflows/:wrapper/charts/:name remove a runtime Chart [Instance token]
|
|
21
|
-
// POST /workflows/:wrapper/charts/:name/runs start the named Chart [
|
|
21
|
+
// POST /workflows/:wrapper/charts/:name/runs start the named Chart [start]
|
|
22
22
|
// GET /workflows/:name/runs/:runId/machine the run's bound Machine, Charts compiled [open]
|
|
23
23
|
//
|
|
24
24
|
// # `jr2 up`, and nothing else (ADR-0069)
|
|
@@ -30,17 +30,21 @@
|
|
|
30
30
|
// agent surface recorded against its own Sandbox — never to a Gate. That is what stops an Agent
|
|
31
31
|
// from approving its own review.
|
|
32
32
|
//
|
|
33
|
-
//
|
|
33
|
+
// The bands of access (ADR-0014, ADR-0078) — the middleware is what says which:
|
|
34
34
|
//
|
|
35
35
|
// open structure + observation. `/workflows`, a template's Machine, the Console shell,
|
|
36
36
|
// and the run PROJECTIONS below (`GET /workflows/:name/runs*`). No context, no
|
|
37
37
|
// control.
|
|
38
38
|
// authenticated any principal we minted a token for. The Agent's surface lives here, scoped
|
|
39
39
|
// further per-registration by `mayDeliverToAgent`.
|
|
40
|
-
//
|
|
41
|
-
//
|
|
40
|
+
// guarded a CALLER (ADR-0078) with the route's one scope, for the Workflow of the run or
|
|
41
|
+
// the door it addresses: run state, run control, starting a run and Gates. A
|
|
42
|
+
// Sandbox token authenticates but is refused, because it is never a Caller:
|
|
42
43
|
// starting work, reading another feature's context, or cancelling a run is not on
|
|
43
44
|
// the Agent's surface any more than a Gate is.
|
|
45
|
+
// instanceOnly the Instance token's Caller ALONE: the routes no scope names (the runtime Chart
|
|
46
|
+
// routes, `GET /repos`, a Hook's log).
|
|
47
|
+
// hook `POST /hooks/:name` (ADR-0011): no bearer token, and the Hook's own verifier.
|
|
44
48
|
|
|
45
49
|
import { readFile, stat } from "node:fs/promises";
|
|
46
50
|
import { createRequire } from "node:module";
|
|
@@ -49,10 +53,13 @@ import tsBlankSpace from "ts-blank-space";
|
|
|
49
53
|
import { Hono } from "hono";
|
|
50
54
|
import type { Context, MiddlewareHandler } from "hono";
|
|
51
55
|
import { accepts } from "hono/accepts";
|
|
56
|
+
import { cors } from "hono/cors";
|
|
52
57
|
import { streamSSE } from "hono/streaming";
|
|
53
58
|
import { z } from "zod";
|
|
54
59
|
import {
|
|
55
60
|
DISPATCHED_BY_HEADER,
|
|
61
|
+
dispatchKeyOf,
|
|
62
|
+
KEY_ONLY_HEADER,
|
|
56
63
|
EventValidationError,
|
|
57
64
|
NotTakenError,
|
|
58
65
|
OutstandingError,
|
|
@@ -60,13 +67,33 @@ import {
|
|
|
60
67
|
type DispatchedBy,
|
|
61
68
|
} from "./registration.ts";
|
|
62
69
|
import type { RepoStatus } from "./repos.ts";
|
|
70
|
+
import { parseRunListQuery } from "./run-list.ts";
|
|
71
|
+
import { CONVERSATION_SWEPT, type ConversationEntry, type ConversationStore } from "./conversations.ts";
|
|
63
72
|
import { UnmountedRepoError, type FetchAnswer } from "./repo-fetch.ts";
|
|
64
|
-
import { mayAskForSandbox, mayDeliverToAgent, type Authenticator, type Principal } from "./tokens.ts";
|
|
65
73
|
import {
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
74
|
+
callerHasScope,
|
|
75
|
+
callerMay,
|
|
76
|
+
callerMayAnswer,
|
|
77
|
+
callerMayAny,
|
|
78
|
+
instanceCaller,
|
|
79
|
+
isInstancePrincipal,
|
|
80
|
+
mayAskForSandbox,
|
|
81
|
+
mayDeliverToAgent,
|
|
82
|
+
ON_BEHALF_OF_HEADER,
|
|
83
|
+
ON_BEHALF_OF_MAX,
|
|
84
|
+
SLOT_HEADER,
|
|
85
|
+
type Authenticator,
|
|
86
|
+
type Caller,
|
|
87
|
+
type Principal,
|
|
88
|
+
} from "./tokens.ts";
|
|
89
|
+
import type { Scope } from "./config.ts";
|
|
90
|
+
import { callerRecord } from "./callers.ts";
|
|
91
|
+
import { STEERING_TEXT_MAX } from "./steer.ts";
|
|
92
|
+
import {
|
|
93
|
+
GateRefused,
|
|
69
94
|
observe,
|
|
95
|
+
StandingRefused,
|
|
96
|
+
SteeringRefused,
|
|
70
97
|
UnknownWorkflowError,
|
|
71
98
|
type RunFeedEvent,
|
|
72
99
|
type RunHost,
|
|
@@ -76,6 +103,7 @@ import {
|
|
|
76
103
|
import { KIT_VERSION } from "./config.ts";
|
|
77
104
|
import type { AssemblyDoor } from "./assembly.ts";
|
|
78
105
|
import { ChartRefusal } from "./chart.ts";
|
|
106
|
+
import { HOOK_LOG_DEFAULT, HOOK_LOG_MAX, type HookDoor } from "./hooks.ts";
|
|
79
107
|
import { chartName, NamedChartError, splitChartName, type NamedCharts } from "./charts.ts";
|
|
80
108
|
|
|
81
109
|
// ---- The route table (ADR-0072) -------------------------------------------------------------
|
|
@@ -85,9 +113,12 @@ import { chartName, NamedChartError, splitChartName, type NamedCharts } from "./
|
|
|
85
113
|
// the app's own registrations both ways and checks each row's band on the wire, so the table
|
|
86
114
|
// cannot drift from the code by forgetting.
|
|
87
115
|
|
|
88
|
-
/** The access bands of ADR-0014, named for
|
|
89
|
-
* band (ADR-
|
|
90
|
-
|
|
116
|
+
/** The access bands of ADR-0014, named for what each checks, the publish door's band (ADR-0069):
|
|
117
|
+
* the publish token alone, and the Hook's band (ADR-0011, ADR-0078): the Hook's own verifier, and
|
|
118
|
+
* no bearer token. `guarded` takes a Caller with the route's {@link HttpRoute.scope}
|
|
119
|
+
* (ADR-0078); `instanceOnly` takes the Instance token's Caller alone, for a route that no scope
|
|
120
|
+
* names. */
|
|
121
|
+
export type HttpBand = "open" | "authenticated" | "guarded" | "instanceOnly" | "publishOnly" | "hook";
|
|
91
122
|
|
|
92
123
|
/** One route of the Orchestrator's HTTP API (ADR-0072). */
|
|
93
124
|
export type HttpRoute = {
|
|
@@ -95,6 +126,10 @@ export type HttpRoute = {
|
|
|
95
126
|
/** In Hono syntax, exactly as registered: `:param`, and `:param{regex}` for a constrained one. */
|
|
96
127
|
path: string;
|
|
97
128
|
band: HttpBand;
|
|
129
|
+
/** A `guarded` route's scope (ADR-0078): the one a Caller needs, for the Workflow of the run or
|
|
130
|
+
* the door that the route addresses; a list when any one of them opens the route (each opens its
|
|
131
|
+
* own part of the answer); `any` for a route that takes any Caller. */
|
|
132
|
+
scope?: Scope | readonly Scope[] | "any";
|
|
98
133
|
/** One line: what the route does. */
|
|
99
134
|
summary: string;
|
|
100
135
|
/** The request body, as a type name or in short; absent when the route reads none. */
|
|
@@ -127,6 +162,38 @@ export const HTTP_ROUTES: readonly HttpRoute[] = [
|
|
|
127
162
|
stream: false,
|
|
128
163
|
adrs: ["0009"],
|
|
129
164
|
},
|
|
165
|
+
{
|
|
166
|
+
method: "GET",
|
|
167
|
+
path: "/whoami",
|
|
168
|
+
band: "guarded",
|
|
169
|
+
scope: "any",
|
|
170
|
+
summary: "The request's Caller: how jr2 knows it, its name, what it may do, and the party it says it acts for.",
|
|
171
|
+
response: "Caller",
|
|
172
|
+
stream: false,
|
|
173
|
+
adrs: ["0078"],
|
|
174
|
+
},
|
|
175
|
+
{
|
|
176
|
+
method: "POST",
|
|
177
|
+
path: "/hooks/:name",
|
|
178
|
+
band: "hook",
|
|
179
|
+
summary:
|
|
180
|
+
"A delivery of an outside service to the Hook that the current Assembly's config names. The Hook's verifier authenticates it, and it goes to the open Gates whose handlers hear its kind and event key. 413 for a body above 2 MiB, before the verifier and with no more of the body read than that; 401 for a delivery that fails the verifier, 404 for a name that no Hook has, 503 with no current Assembly, and 503 when a standing Pool that hears it has no live run and is not paused, or when the delivery followed the Pool's hand-overs 3 times and the Pool handed its run over again. A 503 claims no delivery id.",
|
|
181
|
+
body: "the service's own request",
|
|
182
|
+
response:
|
|
183
|
+
"the kind's answer to a handshake, or HookOutcome: { delivery, seen, delivered, refused, ignored, sources, payloadRefused? }",
|
|
184
|
+
stream: false,
|
|
185
|
+
adrs: ["0011", "0078"],
|
|
186
|
+
},
|
|
187
|
+
{
|
|
188
|
+
method: "GET",
|
|
189
|
+
path: "/hooks/:name",
|
|
190
|
+
band: "instanceOnly",
|
|
191
|
+
summary:
|
|
192
|
+
"A Hook's log, newest first: each delivery that jr2 keeps (`runs.retain`, 30 days by default), with what the door did with it. `?limit=` (default 50, at most 1000).",
|
|
193
|
+
response: "{ hook, declared, lines: HookLogLine[] }",
|
|
194
|
+
stream: false,
|
|
195
|
+
adrs: ["0011", "0065"],
|
|
196
|
+
},
|
|
130
197
|
{
|
|
131
198
|
method: "GET",
|
|
132
199
|
path: "/workflows",
|
|
@@ -141,10 +208,11 @@ export const HTTP_ROUTES: readonly HttpRoute[] = [
|
|
|
141
208
|
method: "GET",
|
|
142
209
|
path: "/assembly",
|
|
143
210
|
band: "open",
|
|
144
|
-
summary:
|
|
145
|
-
|
|
211
|
+
summary:
|
|
212
|
+
"The current Assembly, the Assemblies that live runs hold, and each standing Workflow: live, paused or waiting, its run, and how many items it holds.",
|
|
213
|
+
response: "AssemblyView & { standing?: StandingView[] }",
|
|
146
214
|
stream: false,
|
|
147
|
-
adrs: ["0069"],
|
|
215
|
+
adrs: ["0017", "0069"],
|
|
148
216
|
},
|
|
149
217
|
{
|
|
150
218
|
method: "PUT",
|
|
@@ -154,7 +222,7 @@ export const HTTP_ROUTES: readonly HttpRoute[] = [
|
|
|
154
222
|
"Publish the next Assembly. `jr2 up` calls it, and nothing else. A refused Assembly leaves the current one current.",
|
|
155
223
|
body: "the Assembly as a tar stream, with the headers `jr2-kit-version` and `jr2-assembly-hash`",
|
|
156
224
|
response:
|
|
157
|
-
"200 with the Assembly, its Workflows, the held Assemblies
|
|
225
|
+
"200 with the Assembly, its Workflows, the held Assemblies, the ones collected and each standing Workflow's state; 400, 409 or 422 with the reason",
|
|
158
226
|
stream: false,
|
|
159
227
|
adrs: ["0069"],
|
|
160
228
|
},
|
|
@@ -189,12 +257,13 @@ export const HTTP_ROUTES: readonly HttpRoute[] = [
|
|
|
189
257
|
{
|
|
190
258
|
method: "POST",
|
|
191
259
|
path: "/workflows/:wrapper/charts/:name/runs",
|
|
192
|
-
band: "
|
|
260
|
+
band: "guarded",
|
|
261
|
+
scope: "start",
|
|
193
262
|
summary: "Start a run of a named Chart, as `POST /workflows/<wrapper>%2F<name>/runs` does.",
|
|
194
263
|
body: "the Chart wrapper's input, without the Chart",
|
|
195
264
|
response: "201 { runId, instanceId }; 409 when the named Chart cannot run now",
|
|
196
265
|
stream: false,
|
|
197
|
-
adrs: ["0070"],
|
|
266
|
+
adrs: ["0070", "0078"],
|
|
198
267
|
},
|
|
199
268
|
{
|
|
200
269
|
method: "GET",
|
|
@@ -214,6 +283,16 @@ export const HTTP_ROUTES: readonly HttpRoute[] = [
|
|
|
214
283
|
stream: false,
|
|
215
284
|
adrs: ["0032"],
|
|
216
285
|
},
|
|
286
|
+
{
|
|
287
|
+
method: "GET",
|
|
288
|
+
path: "/console/config",
|
|
289
|
+
band: "open",
|
|
290
|
+
summary:
|
|
291
|
+
"What the Console needs before a login: each JWT issuer that offers a login, and its client id. Both are public values.",
|
|
292
|
+
response: "{ login: Array<{ issuer, clientId }> }",
|
|
293
|
+
stream: false,
|
|
294
|
+
adrs: ["0032", "0078"],
|
|
295
|
+
},
|
|
217
296
|
{
|
|
218
297
|
method: "GET",
|
|
219
298
|
path: "/workflows/:name",
|
|
@@ -226,14 +305,15 @@ export const HTTP_ROUTES: readonly HttpRoute[] = [
|
|
|
226
305
|
{
|
|
227
306
|
method: "POST",
|
|
228
307
|
path: "/workflows/:name/runs",
|
|
229
|
-
band: "
|
|
308
|
+
band: "guarded",
|
|
309
|
+
scope: "start",
|
|
230
310
|
summary:
|
|
231
|
-
"Start a run of a registered workflow. A `dispatch` from another Orchestrator sends the header `jr2-dispatched-by`, and a repeated start with its key answers the run that it started.",
|
|
311
|
+
"Start a run of a registered workflow. A `dispatch` from another Orchestrator sends the header `jr2-dispatched-by`, and a repeated start with its key answers the run that it started. With the header `jr2-key-only: true` too, the start is a key-only ask: it answers the run under the key, and starts nothing. For a standing Workflow, jr2 starts the run: the start only resumes a paused one, and `?drop-held=true` resumes it with no held item.",
|
|
232
312
|
body: "the workflow's input, checked against its declared input schema",
|
|
233
313
|
response:
|
|
234
|
-
"201 { runId, instanceId }; 200 { runId, instanceId, existing: true } for a repeated dispatch; 422 { error, report } when the door refuses a Chart in the input",
|
|
314
|
+
"201 { runId, instanceId }; 201 { runId, instanceId, resumed: true } for a resumed standing Workflow; 200 { runId, instanceId, existing: true } for a repeated dispatch; 200 { existing: false } for a key-only ask with no run under the key; 409 for a standing Workflow that is not paused, or a dispatch to one; 422 { error, report } when the door refuses a Chart in the input",
|
|
235
315
|
stream: false,
|
|
236
|
-
adrs: ["0033", "0058", "0070", "0071"],
|
|
316
|
+
adrs: ["0017", "0033", "0058", "0070", "0071", "0078"],
|
|
237
317
|
},
|
|
238
318
|
{
|
|
239
319
|
method: "GET",
|
|
@@ -319,11 +399,14 @@ export const HTTP_ROUTES: readonly HttpRoute[] = [
|
|
|
319
399
|
{
|
|
320
400
|
method: "GET",
|
|
321
401
|
path: "/runs",
|
|
322
|
-
band: "
|
|
323
|
-
|
|
324
|
-
|
|
402
|
+
band: "guarded",
|
|
403
|
+
scope: "read",
|
|
404
|
+
summary:
|
|
405
|
+
"A page of the runs of the Workflows that the Caller may read, newest first: live runs by default; " +
|
|
406
|
+
"?status=live|ended|all|<list> ?workflow ?since ?cursor ?limit.",
|
|
407
|
+
response: "{ runs: RunSummary[], next? }",
|
|
325
408
|
stream: false,
|
|
326
|
-
adrs: ["0009", "0014"],
|
|
409
|
+
adrs: ["0009", "0014", "0078"],
|
|
327
410
|
},
|
|
328
411
|
{
|
|
329
412
|
method: "GET",
|
|
@@ -337,58 +420,135 @@ export const HTTP_ROUTES: readonly HttpRoute[] = [
|
|
|
337
420
|
{
|
|
338
421
|
method: "GET",
|
|
339
422
|
path: "/runs/resolve",
|
|
340
|
-
band: "
|
|
341
|
-
|
|
423
|
+
band: "guarded",
|
|
424
|
+
scope: "any",
|
|
425
|
+
summary:
|
|
426
|
+
"The run ids that start with a prefix (`?prefix=`, at least 4 characters), of the Workflows in any scope of the Caller.",
|
|
342
427
|
response: "{ prefix, runIds: string[], truncated }",
|
|
343
428
|
stream: false,
|
|
344
|
-
adrs: ["0009"],
|
|
429
|
+
adrs: ["0009", "0078"],
|
|
430
|
+
},
|
|
431
|
+
{
|
|
432
|
+
method: "GET",
|
|
433
|
+
path: "/gates",
|
|
434
|
+
band: "guarded",
|
|
435
|
+
scope: "answer",
|
|
436
|
+
summary:
|
|
437
|
+
"The Gate inbox: every open Gate of every live run that the Caller may answer, oldest first, never a run's context; ?workflow.",
|
|
438
|
+
response: "{ gates: GateInboxEntry[] }",
|
|
439
|
+
stream: false,
|
|
440
|
+
adrs: ["0011", "0014", "0078"],
|
|
345
441
|
},
|
|
346
442
|
{
|
|
347
443
|
method: "GET",
|
|
348
444
|
path: "/runs/:runId",
|
|
349
|
-
band: "
|
|
350
|
-
|
|
351
|
-
|
|
445
|
+
band: "guarded",
|
|
446
|
+
scope: ["read", "answer"],
|
|
447
|
+
summary:
|
|
448
|
+
"One run's status, live or settled, with its open Gates. `read` opens the status, and `answer` opens the Gates that the Caller may answer: with `answer` alone the answer is the run's observation and those Gates, and never its context.",
|
|
449
|
+
response:
|
|
450
|
+
"RunStatus & { gates: GateView[] }; RunStatus with `read` alone; RunObservation & { gates: GateView[] } with `answer` alone",
|
|
352
451
|
stream: false,
|
|
353
|
-
adrs: ["0009", "0011"],
|
|
452
|
+
adrs: ["0009", "0011", "0078"],
|
|
354
453
|
},
|
|
355
454
|
{
|
|
356
|
-
// `authenticated` in the code, and the handler refuses a Sandbox token itself (ADR-0013): the
|
|
357
|
-
// credential the route requires is the Instance token's, so this is its band.
|
|
358
455
|
method: "POST",
|
|
359
456
|
path: "/runs/:runId/gates/:gate/events",
|
|
360
|
-
band: "
|
|
361
|
-
|
|
457
|
+
band: "guarded",
|
|
458
|
+
scope: "answer",
|
|
459
|
+
summary:
|
|
460
|
+
"Deliver an event to an open Gate of a run. A Caller whose `answer` names single Gates of the Workflow is refused 403 for another Gate.",
|
|
362
461
|
body: "{ type, ...payload }, checked against the Gate's schema for that event",
|
|
363
462
|
response: "{ ok: true }; 409 { error, acceptsNow } when the run does not take it now",
|
|
364
463
|
stream: false,
|
|
365
|
-
adrs: ["0011", "0013"],
|
|
464
|
+
adrs: ["0011", "0013", "0078"],
|
|
366
465
|
},
|
|
367
466
|
{
|
|
368
467
|
method: "GET",
|
|
369
468
|
path: "/runs/:runId/events",
|
|
370
|
-
band: "
|
|
469
|
+
band: "guarded",
|
|
470
|
+
scope: "read",
|
|
371
471
|
summary: "One run's full activity until it settles; a settled run's final status, once.",
|
|
372
|
-
response:
|
|
472
|
+
response:
|
|
473
|
+
"SSE: status, emit, retry, admission, pick, placing, placed, child-error, looked, hook-refused, pool, effect, gone",
|
|
474
|
+
stream: true,
|
|
475
|
+
adrs: ["0011", "0022", "0023", "0064", "0067", "0078"],
|
|
476
|
+
},
|
|
477
|
+
{
|
|
478
|
+
method: "GET",
|
|
479
|
+
path: "/runs/:runId/conversations",
|
|
480
|
+
band: "guarded",
|
|
481
|
+
scope: "read",
|
|
482
|
+
summary:
|
|
483
|
+
"A run's kept conversations, the first first: one row for each conversation of each Agent, Menu-only Agents too, with its Agent slot.",
|
|
484
|
+
response: 'Array<{ agent, slot?, instanceId, open, bytes, cut, swept?: "no space" }>',
|
|
485
|
+
stream: false,
|
|
486
|
+
adrs: ["0023", "0065"],
|
|
487
|
+
},
|
|
488
|
+
{
|
|
489
|
+
method: "GET",
|
|
490
|
+
path: "/runs/:runId/conversations/:instanceId",
|
|
491
|
+
band: "guarded",
|
|
492
|
+
scope: "read",
|
|
493
|
+
summary:
|
|
494
|
+
"The entries of one kept conversation after a sequence number (`?after=<seq>`, `?limit=`, 1000 by default). The Instance ID is URL-encoded.",
|
|
495
|
+
response: "Array<{ id, seq, at, agent, kind, text }>",
|
|
496
|
+
stream: false,
|
|
497
|
+
adrs: ["0023"],
|
|
498
|
+
},
|
|
499
|
+
{
|
|
500
|
+
method: "GET",
|
|
501
|
+
path: "/runs/:runId/conversations/:instanceId/events",
|
|
502
|
+
band: "guarded",
|
|
503
|
+
scope: "read",
|
|
504
|
+
summary:
|
|
505
|
+
"Follow one kept conversation: its entries after `?after=<seq>`, then each new one, until the conversation is not open.",
|
|
506
|
+
response: "SSE: entry, end",
|
|
373
507
|
stream: true,
|
|
374
|
-
adrs: ["
|
|
508
|
+
adrs: ["0023"],
|
|
375
509
|
},
|
|
376
510
|
{
|
|
377
511
|
method: "POST",
|
|
378
512
|
path: "/runs/:runId/events",
|
|
379
|
-
band: "
|
|
513
|
+
band: "guarded",
|
|
514
|
+
scope: "cancel",
|
|
380
515
|
summary: "Cancel a run: it ends, and its Workspaces go unless `keep` is true.",
|
|
381
516
|
body: '{ type: "CANCEL", keep?: boolean }',
|
|
382
517
|
response: "{ ok: true }",
|
|
383
518
|
stream: false,
|
|
384
|
-
adrs: ["0002", "0025"],
|
|
519
|
+
adrs: ["0002", "0025", "0078"],
|
|
520
|
+
},
|
|
521
|
+
{
|
|
522
|
+
method: "GET",
|
|
523
|
+
path: "/runs/:runId/agents",
|
|
524
|
+
band: "guarded",
|
|
525
|
+
scope: "steer",
|
|
526
|
+
summary:
|
|
527
|
+
"The Agent slots of a run that a Steering message can go to: one row for each Agent of each live Machine instance.",
|
|
528
|
+
response: "Array<{ agent, open, steerable }>; [] for a run that is not live",
|
|
529
|
+
stream: false,
|
|
530
|
+
adrs: ["0002", "0078"],
|
|
531
|
+
},
|
|
532
|
+
{
|
|
533
|
+
method: "POST",
|
|
534
|
+
path: "/runs/:runId/steering",
|
|
535
|
+
band: "guarded",
|
|
536
|
+
scope: "steer",
|
|
537
|
+
summary:
|
|
538
|
+
"Send a Steering message to one Agent slot of a live run: its open Turn hears it at a step boundary, or else its next Turn.",
|
|
539
|
+
body: "{ agent, text }, `text` at most 16 KiB",
|
|
540
|
+
response:
|
|
541
|
+
"202 { id, agent, open }; 413 for a longer text; 409 { error, agents? } when no live Machine instance holds the slot; 403 when the Agent is not steerable",
|
|
542
|
+
stream: false,
|
|
543
|
+
adrs: ["0002", "0062", "0078"],
|
|
385
544
|
},
|
|
386
545
|
{
|
|
387
546
|
method: "GET",
|
|
388
547
|
path: "/agents/:instanceId/surface",
|
|
389
548
|
band: "authenticated",
|
|
390
|
-
summary:
|
|
391
|
-
|
|
549
|
+
summary:
|
|
550
|
+
"An Agent's Menu, this Turn's Allowed picks, the conversation's pending notices and its slot's Steering messages. A control token reads only its own pod's, for the slot it states.",
|
|
551
|
+
response: "AgentSurfaceView & { notices: Array<{ id, notice }>, steering?: Array<{ id, message }> }",
|
|
392
552
|
stream: false,
|
|
393
553
|
adrs: ["0013", "0029"],
|
|
394
554
|
},
|
|
@@ -411,11 +571,34 @@ export const HTTP_ROUTES: readonly HttpRoute[] = [
|
|
|
411
571
|
stream: false,
|
|
412
572
|
adrs: ["0013", "0024"],
|
|
413
573
|
},
|
|
574
|
+
{
|
|
575
|
+
method: "POST",
|
|
576
|
+
path: "/agents/:instanceId/notices",
|
|
577
|
+
band: "authenticated",
|
|
578
|
+
summary:
|
|
579
|
+
"Acknowledge the notices and Steering messages a step boundary appended to the Turn: they are heard in its conversation.",
|
|
580
|
+
body: "{ heard: string[] }",
|
|
581
|
+
response: "{ heard: number }",
|
|
582
|
+
stream: false,
|
|
583
|
+
adrs: ["0062", "0076"],
|
|
584
|
+
},
|
|
585
|
+
{
|
|
586
|
+
method: "POST",
|
|
587
|
+
path: "/agents/:instanceId/conversation",
|
|
588
|
+
band: "authenticated",
|
|
589
|
+
summary:
|
|
590
|
+
"Keep a batch of a conversation's entries. Only the control token of the pod that the run's admission ledger names, for the ledger's slot; never the Instance token. An entry kept already adds nothing.",
|
|
591
|
+
body: "{ entries: Array<{ id, seq, at, kind, text }> }, at most 4 MiB",
|
|
592
|
+
response:
|
|
593
|
+
"{ kept, cut }; 409 while the run's ledger does not name the conversation yet (send again); 404 for a run that is not known",
|
|
594
|
+
stream: false,
|
|
595
|
+
adrs: ["0023", "0076"],
|
|
596
|
+
},
|
|
414
597
|
{
|
|
415
598
|
method: "POST",
|
|
416
599
|
path: "/sandboxes/:name/fetch",
|
|
417
600
|
band: "authenticated",
|
|
418
|
-
summary: "Ask the node cache to fetch one Repo that this Sandbox mounts. A
|
|
601
|
+
summary: "Ask the node cache to fetch one Repo that this Sandbox mounts. A fetch token asks only for its own.",
|
|
419
602
|
body: "{ identity }",
|
|
420
603
|
response: "FetchAnswer: { fetched } | { stale, asOf }",
|
|
421
604
|
stream: false,
|
|
@@ -433,6 +616,17 @@ type RunEventBody = { type?: string; keep?: unknown };
|
|
|
433
616
|
* the same floor on the argument; this one guards the scan regardless of who is calling. */
|
|
434
617
|
const MIN_RUN_ID_PREFIX = 4;
|
|
435
618
|
|
|
619
|
+
/** How many candidates `GET /runs/resolve` reads for a Caller whose scopes name Workflows: the ids
|
|
620
|
+
* of other Workflows must not fill its answer. */
|
|
621
|
+
const RESOLVE_SCAN = 200;
|
|
622
|
+
|
|
623
|
+
/** The items of `items` for which `keep` answers true, in order. */
|
|
624
|
+
async function filterAsync<T>(items: readonly T[], keep: (item: T) => Promise<boolean>): Promise<T[]> {
|
|
625
|
+
const out: T[] = [];
|
|
626
|
+
for (const item of items) if (await keep(item)) out.push(item);
|
|
627
|
+
return out;
|
|
628
|
+
}
|
|
629
|
+
|
|
436
630
|
/** How many ambiguous candidates are worth showing. Proving ambiguity takes 2; letting the caller
|
|
437
631
|
* PICK is the point, so the listing goes deeper before it truncates. */
|
|
438
632
|
const RESOLVE_LIMIT = 10;
|
|
@@ -497,6 +691,15 @@ function restorePendingMessage(what: string): string {
|
|
|
497
691
|
* Instance principal, so it reads no run, starts none, and speaks for no Agent. */
|
|
498
692
|
const PUBLISHER_ONLY = "the publish token publishes an Assembly (PUT /assembly) and does nothing else";
|
|
499
693
|
|
|
694
|
+
/** Why a principal that is not a Caller is refused on a Caller's route (ADR-0014, ADR-0078). */
|
|
695
|
+
const NOT_A_CALLER =
|
|
696
|
+
"this route needs a Caller, and a Sandbox token is not one: no Agent reaches run state, run control or a Gate";
|
|
697
|
+
|
|
698
|
+
/** A Caller as a 403 names it: `token/ci`. */
|
|
699
|
+
function callerLabel(caller: Caller): string {
|
|
700
|
+
return `the Caller ${caller.via}/${caller.name}`;
|
|
701
|
+
}
|
|
702
|
+
|
|
500
703
|
/** The app's context: `authenticated` resolves the bearer to a `principal` the routes read back. */
|
|
501
704
|
type JR2Env = { Variables: { principal: Principal } };
|
|
502
705
|
|
|
@@ -643,6 +846,28 @@ function closer(resolve: () => void) {
|
|
|
643
846
|
/** How often an otherwise-silent feed writes a ping. */
|
|
644
847
|
const PING_MS = 15_000;
|
|
645
848
|
|
|
849
|
+
/** How many entries one read of a kept conversation gives when the query names no `limit`, and the
|
|
850
|
+
* most it can name (ADR-0023). */
|
|
851
|
+
const CONVERSATION_PAGE_DEFAULT = 1_000;
|
|
852
|
+
const CONVERSATION_PAGE_MAX = 5_000;
|
|
853
|
+
/** The largest batch of entries the Harness may send (ADR-0023): it sends less than 1 MiB. */
|
|
854
|
+
const CONVERSATION_BATCH_MAX = 4 * 1024 * 1024;
|
|
855
|
+
/** How long a followed conversation that is not open stays followed for a late batch. */
|
|
856
|
+
const FOLLOW_GRACE_MS = 5_000;
|
|
857
|
+
|
|
858
|
+
/** A batch of a conversation's entries, as the Harness sends it (ADR-0023). */
|
|
859
|
+
const conversationBatch = z.object({
|
|
860
|
+
entries: z.array(
|
|
861
|
+
z.object({
|
|
862
|
+
id: z.string().min(1).max(200),
|
|
863
|
+
seq: z.number().int().min(0),
|
|
864
|
+
at: z.string().max(64),
|
|
865
|
+
kind: z.string().min(1).max(200),
|
|
866
|
+
text: z.string(),
|
|
867
|
+
}),
|
|
868
|
+
),
|
|
869
|
+
});
|
|
870
|
+
|
|
646
871
|
/**
|
|
647
872
|
* Keep a quiet feed alive.
|
|
648
873
|
*
|
|
@@ -767,6 +992,9 @@ function subscriber(stream: SseStream): Subscriber {
|
|
|
767
992
|
export type CreateAppOptions = {
|
|
768
993
|
/** Ping interval for the SSE feeds. Tests shorten it; nothing in production sets it. */
|
|
769
994
|
pingMs?: number;
|
|
995
|
+
/** How long a followed conversation that is not open waits for a late batch before its feed
|
|
996
|
+
* ends (ADR-0023). Tests shorten it. */
|
|
997
|
+
followGraceMs?: number;
|
|
770
998
|
/**
|
|
771
999
|
* Whether this instance has a data plane at all (ADR-0051, ADR-0069): the process is deployed in
|
|
772
1000
|
* a cluster, so it wires its kube client whatever the current Assembly composes. `false` is an
|
|
@@ -782,8 +1010,10 @@ export type CreateAppOptions = {
|
|
|
782
1010
|
assembly?: AssemblyDoor;
|
|
783
1011
|
/**
|
|
784
1012
|
* The digests of the ConfigMaps this process reads through a mount (ADR-0069), as
|
|
785
|
-
* `readMountDigests` reads them
|
|
786
|
-
*
|
|
1013
|
+
* `readMountDigests` reads them, and of the digests Secrets as it loaded them (ADR-0078,
|
|
1014
|
+
* `loadedDigests`). `GET /assembly` reports them, so `jr2 up` can wait for the kubelet before it
|
|
1015
|
+
* publishes, and `jr2 up` and `jr2 caller rotate` before a token they wrote works. Absent when
|
|
1016
|
+
* not deployed: nothing is mounted.
|
|
787
1017
|
*/
|
|
788
1018
|
mounts?: () => Promise<Record<string, string>>;
|
|
789
1019
|
/**
|
|
@@ -811,6 +1041,20 @@ export type CreateAppOptions = {
|
|
|
811
1041
|
* that build an app straight over a host: those routes then answer 404.
|
|
812
1042
|
*/
|
|
813
1043
|
charts?: NamedCharts;
|
|
1044
|
+
/** The origins a browser app may call from (ADR-0078), read per request: the current Assembly's
|
|
1045
|
+
* `cors`. Absent: none. */
|
|
1046
|
+
cors?: () => readonly string[];
|
|
1047
|
+
/** The JWT issuers the Console offers a login against (ADR-0032), read per request: the current
|
|
1048
|
+
* Assembly's issuers with `login: true`, each with its `audience` as the client id. Absent: none. */
|
|
1049
|
+
consoleLogin?: () => ReadonlyArray<{ issuer: string; clientId: string }>;
|
|
1050
|
+
/** The Hook's door (ADR-0011, ADR-0078): `POST /hooks/:name` and `GET /hooks/:name`. Absent for
|
|
1051
|
+
* the in-process tests that build an app straight over a host: `POST` then answers 503, as with
|
|
1052
|
+
* no current Assembly, and `GET` answers no line. */
|
|
1053
|
+
hooks?: HookDoor;
|
|
1054
|
+
/** The kept conversations (ADR-0023): `POST /agents/:iid/conversation` writes them, and the
|
|
1055
|
+
* `/runs/:runId/conversations` routes read them. Absent for the in-process tests that build an
|
|
1056
|
+
* app straight over a host: the write answers 404, and a run lists no conversation. */
|
|
1057
|
+
conversations?: ConversationStore;
|
|
814
1058
|
};
|
|
815
1059
|
|
|
816
1060
|
/**
|
|
@@ -822,41 +1066,142 @@ export type CreateAppOptions = {
|
|
|
822
1066
|
*/
|
|
823
1067
|
export function createApp(host: RunHost, auth?: Authenticator, opts: CreateAppOptions = {}): Hono<JR2Env> {
|
|
824
1068
|
const pingMs = opts.pingMs ?? PING_MS;
|
|
1069
|
+
const followGraceMs = opts.followGraceMs ?? FOLLOW_GRACE_MS;
|
|
825
1070
|
const app = new Hono<JR2Env>();
|
|
826
1071
|
|
|
827
1072
|
/** Authenticate, or refuse. There is no anonymous principal (ADR-0013) — an open surface would
|
|
828
1073
|
* hand every Agent in the cluster a delivery API, which is the hole this ADR exists to close. */
|
|
829
1074
|
const authenticated: MiddlewareHandler<JR2Env> = async (c, next) => {
|
|
830
1075
|
if (!auth) return next(); // no authenticator configured: tests only (see the doc comment)
|
|
831
|
-
|
|
1076
|
+
// The slot a control request states (ADR-0076): a control token without one resolves to nothing.
|
|
1077
|
+
const principal = await auth(bearerOf(c), c.req.header(SLOT_HEADER));
|
|
832
1078
|
if (!principal) return c.json({ error: "unauthorized" }, 401);
|
|
833
1079
|
if (principal.kind === "publisher") return c.json({ error: PUBLISHER_ONLY }, 403);
|
|
834
1080
|
c.set("principal", principal);
|
|
835
1081
|
return next();
|
|
836
1082
|
};
|
|
837
1083
|
/** The principal `authenticated` resolved. An unconfigured `auth` means full trust. */
|
|
838
|
-
const principalOf = (c: Context<JR2Env>): Principal =>
|
|
1084
|
+
const principalOf = (c: Context<JR2Env>): Principal =>
|
|
1085
|
+
c.get("principal") ?? { kind: "caller", caller: instanceCaller() };
|
|
1086
|
+
/** The Caller a `guarded` middleware resolved. */
|
|
1087
|
+
const callerOf = (c: Context<JR2Env>): Caller => {
|
|
1088
|
+
const principal = principalOf(c);
|
|
1089
|
+
if (principal.kind !== "caller") throw new Error("callerOf: the route is not in the guarded band");
|
|
1090
|
+
return principal.caller;
|
|
1091
|
+
};
|
|
1092
|
+
|
|
1093
|
+
/**
|
|
1094
|
+
* Authenticate a CALLER, and require one of `scopes` for at least one Workflow (ADR-0014,
|
|
1095
|
+
* ADR-0078). A Sandbox token is a token we minted, but it is never a Caller: on the run surface it
|
|
1096
|
+
* would let a Custodian's credential read every run's context (other features' branches, tickets,
|
|
1097
|
+
* verdicts) and CANCEL any run, and neither is on the Agent's surface (ADR-0013). The Workflow of
|
|
1098
|
+
* the run or the door is the handler's to check, with `mayFor`, because only the handler knows it.
|
|
1099
|
+
* `any` takes every Caller. The Caller's `jr2-on-behalf-of` claim is read here, once.
|
|
1100
|
+
*/
|
|
1101
|
+
const guarded =
|
|
1102
|
+
(scopes: Scope | readonly Scope[] | "any"): MiddlewareHandler<JR2Env> =>
|
|
1103
|
+
async (c, next) => {
|
|
1104
|
+
const onBehalfOf = c.req.header(ON_BEHALF_OF_HEADER)?.trim();
|
|
1105
|
+
if (onBehalfOf !== undefined && onBehalfOf.length > ON_BEHALF_OF_MAX) {
|
|
1106
|
+
return c.json(
|
|
1107
|
+
{ error: `the ${ON_BEHALF_OF_HEADER} header is longer than ${ON_BEHALF_OF_MAX} characters` },
|
|
1108
|
+
400,
|
|
1109
|
+
);
|
|
1110
|
+
}
|
|
1111
|
+
let caller: Caller;
|
|
1112
|
+
if (!auth) {
|
|
1113
|
+
caller = instanceCaller(); // no authenticator configured: tests only (see the doc comment)
|
|
1114
|
+
} else {
|
|
1115
|
+
const principal = await auth(bearerOf(c), c.req.header(SLOT_HEADER));
|
|
1116
|
+
if (!principal) return c.json({ error: "unauthorized" }, 401);
|
|
1117
|
+
if (principal.kind === "publisher") return c.json({ error: PUBLISHER_ONLY }, 403);
|
|
1118
|
+
if (principal.kind !== "caller") return c.json({ error: NOT_A_CALLER }, 403);
|
|
1119
|
+
caller = principal.caller;
|
|
1120
|
+
}
|
|
1121
|
+
// A Hook's `onBehalfOf` comes from its signed payload, never from a header (ADR-0078).
|
|
1122
|
+
if (onBehalfOf && caller.via !== "hook") caller = { ...caller, onBehalfOf };
|
|
1123
|
+
if (scopes !== "any") {
|
|
1124
|
+
const list: readonly Scope[] = typeof scopes === "string" ? [scopes] : scopes;
|
|
1125
|
+
if (!list.some((s) => callerHasScope(caller, s))) {
|
|
1126
|
+
return c.json(
|
|
1127
|
+
{
|
|
1128
|
+
error: `${callerLabel(caller)} has no ${list.map((s) => `"${s}"`).join(" or ")} scope for any Workflow (ADR-0078)`,
|
|
1129
|
+
scope: list.length === 1 ? list[0] : list,
|
|
1130
|
+
},
|
|
1131
|
+
403,
|
|
1132
|
+
);
|
|
1133
|
+
}
|
|
1134
|
+
}
|
|
1135
|
+
c.set("principal", { kind: "caller", caller });
|
|
1136
|
+
return next();
|
|
1137
|
+
};
|
|
1138
|
+
|
|
1139
|
+
/** The 403 for a Caller that may not answer the Gate named `name` (its invoke id) of `workflow`,
|
|
1140
|
+
* or undefined when it may. It names the scope, the Workflow and the Gate (ADR-0078). */
|
|
1141
|
+
const mayAnswer = (c: Context<JR2Env>, workflow: string, name: string): Response | undefined => {
|
|
1142
|
+
const caller = callerOf(c);
|
|
1143
|
+
if (callerMayAnswer(caller, workflow, name)) return undefined;
|
|
1144
|
+
return c.json(
|
|
1145
|
+
{
|
|
1146
|
+
error: `${callerLabel(caller)} needs the "answer" scope for Gate "${name}" of Workflow "${workflow}" (ADR-0078)`,
|
|
1147
|
+
scope: "answer",
|
|
1148
|
+
workflow,
|
|
1149
|
+
gate: name,
|
|
1150
|
+
},
|
|
1151
|
+
403,
|
|
1152
|
+
);
|
|
1153
|
+
};
|
|
1154
|
+
|
|
1155
|
+
/** The 403 for a Caller without `scope` on `workflow`, or undefined when it has it. It names the
|
|
1156
|
+
* scope and the Workflow that the request needed (ADR-0078). */
|
|
1157
|
+
const mayFor = (c: Context<JR2Env>, scope: Scope, workflow: string): Response | undefined => {
|
|
1158
|
+
const caller = callerOf(c);
|
|
1159
|
+
if (callerMay(caller, scope, workflow)) return undefined;
|
|
1160
|
+
return c.json(
|
|
1161
|
+
{
|
|
1162
|
+
error: `${callerLabel(caller)} needs the "${scope}" scope for Workflow "${workflow}" (ADR-0078)`,
|
|
1163
|
+
scope,
|
|
1164
|
+
workflow,
|
|
1165
|
+
},
|
|
1166
|
+
403,
|
|
1167
|
+
);
|
|
1168
|
+
};
|
|
839
1169
|
|
|
840
1170
|
/**
|
|
841
|
-
* Authenticate, AND require the
|
|
842
|
-
*
|
|
843
|
-
*
|
|
844
|
-
* CANCEL any run. Neither is on the Agent's surface. Its scope is delivering to agent
|
|
845
|
-
* registrations recorded against its OWN Sandbox (ADR-0013), and the gate route already says so in
|
|
846
|
-
* the other direction.
|
|
1171
|
+
* Authenticate, AND require the Instance token's Caller (ADR-0014): the routes that no scope
|
|
1172
|
+
* names — the runtime Chart routes and `GET /repos`. A named token is refused here, whatever its
|
|
1173
|
+
* scopes, and so is every principal that is not a Caller.
|
|
847
1174
|
*/
|
|
848
1175
|
const instanceOnly: MiddlewareHandler<JR2Env> = async (c, next) => {
|
|
849
1176
|
if (!auth) return next(); // no authenticator configured: tests only (see the doc comment)
|
|
850
|
-
const principal = auth(bearerOf(c));
|
|
1177
|
+
const principal = await auth(bearerOf(c), c.req.header(SLOT_HEADER));
|
|
851
1178
|
if (!principal) return c.json({ error: "unauthorized" }, 401);
|
|
852
1179
|
if (principal.kind === "publisher") return c.json({ error: PUBLISHER_ONLY }, 403);
|
|
853
|
-
if (principal.kind !== "
|
|
854
|
-
|
|
1180
|
+
if (principal.kind !== "caller") return c.json({ error: NOT_A_CALLER }, 403);
|
|
1181
|
+
if (!isInstancePrincipal(principal)) {
|
|
1182
|
+
return c.json(
|
|
1183
|
+
{ error: `this route needs the Instance token: no scope opens it, so ${callerLabel(principal.caller)} cannot` },
|
|
1184
|
+
403,
|
|
1185
|
+
);
|
|
855
1186
|
}
|
|
856
1187
|
c.set("principal", principal);
|
|
857
1188
|
return next();
|
|
858
1189
|
};
|
|
859
1190
|
|
|
1191
|
+
// The origins a browser app may call from (ADR-0078): an allowlist, empty by default, read per
|
|
1192
|
+
// request so a publish changes it. The Console is served from this origin and needs no entry. A
|
|
1193
|
+
// preflight from an origin that is not listed gets no CORS header, so the browser refuses it.
|
|
1194
|
+
const allowedOrigins = opts.cors ?? (() => []);
|
|
1195
|
+
app.use(
|
|
1196
|
+
"*",
|
|
1197
|
+
cors({
|
|
1198
|
+
origin: (origin) => (origin !== "" && allowedOrigins().includes(origin) ? origin : null),
|
|
1199
|
+
allowMethods: ["GET", "POST", "PUT", "DELETE"],
|
|
1200
|
+
allowHeaders: ["authorization", "content-type", ON_BEHALF_OF_HEADER, DISPATCHED_BY_HEADER, KEY_ONLY_HEADER],
|
|
1201
|
+
maxAge: 600,
|
|
1202
|
+
}),
|
|
1203
|
+
);
|
|
1204
|
+
|
|
860
1205
|
// Liveness, and the one place an instance says WHAT IT IS. Unauthenticated because it is the
|
|
861
1206
|
// readiness probe's target and because identity is not run state — it is the same class of thing
|
|
862
1207
|
// as the route table, which is public by being served. The CLI probes it to explain a failure it
|
|
@@ -865,6 +1210,42 @@ export function createApp(host: RunHost, auth?: Authenticator, opts: CreateAppOp
|
|
|
865
1210
|
app.get("/healthz", (c) => c.json({ ok: true, version: KIT_VERSION, hash: opts.assembly?.current()?.hash }));
|
|
866
1211
|
app.get("/readyz", (c) => c.json({ ready: true }));
|
|
867
1212
|
|
|
1213
|
+
// The request's Caller record (ADR-0078): what a credential may do, asked before it fails. Any
|
|
1214
|
+
// Caller: it says nothing that the credential's holder does not already hold.
|
|
1215
|
+
app.get("/whoami", guarded("any"), (c) => c.json(callerOf(c)));
|
|
1216
|
+
|
|
1217
|
+
// The Hook's door (ADR-0011, ADR-0078). Outside the bands: no bearer token opens it, and the
|
|
1218
|
+
// Hook's own verifier authenticates each delivery, in hooks.ts. The raw bytes, never a parsed
|
|
1219
|
+
// body, because a signature covers the bytes. The door reads the body only after it knows the
|
|
1220
|
+
// Hook, and no more of it than its limit (`limitedBody`).
|
|
1221
|
+
app.post("/hooks/:name", async (c) => {
|
|
1222
|
+
if (!opts.hooks) {
|
|
1223
|
+
return c.json({ error: "the Orchestrator has no current Assembly, so no Hook is declared now — ask again" }, 503);
|
|
1224
|
+
}
|
|
1225
|
+
const answer = await opts.hooks.deliver(c.req.param("name"), c.req.raw.headers, limitedBody(c.req.raw));
|
|
1226
|
+
// The follow-up (the reply to a Slack click) goes after the answer: the service waits for the
|
|
1227
|
+
// answer, not for the post (ADR-0011). It never rejects.
|
|
1228
|
+
const after = answer.after;
|
|
1229
|
+
if (after) setImmediate(() => void after());
|
|
1230
|
+
const status = answer.status as 200 | 400 | 401 | 404 | 413 | 429 | 503;
|
|
1231
|
+
if (answer.body === null) return c.body(null, status);
|
|
1232
|
+
if (typeof answer.body === "string") return c.text(answer.body, status);
|
|
1233
|
+
// As text, not `c.json`: hono's JSON type of a recursive `Json` does not end.
|
|
1234
|
+
return c.body(JSON.stringify(answer.body), status, { "content-type": "application/json" });
|
|
1235
|
+
});
|
|
1236
|
+
|
|
1237
|
+
// A Hook's log (ADR-0011): the Instance token alone. A line can carry a payload's issues and a
|
|
1238
|
+
// handler's words, and no scope names the act of reading what an outside service sent.
|
|
1239
|
+
app.get("/hooks/:name", instanceOnly, (c) => {
|
|
1240
|
+
const raw = c.req.query("limit");
|
|
1241
|
+
const limit = raw === undefined ? HOOK_LOG_DEFAULT : Number(raw);
|
|
1242
|
+
if (!Number.isInteger(limit) || limit < 1 || limit > HOOK_LOG_MAX) {
|
|
1243
|
+
return c.json({ error: `limit is a whole number from 1 to ${HOOK_LOG_MAX}` }, 400);
|
|
1244
|
+
}
|
|
1245
|
+
const name = c.req.param("name");
|
|
1246
|
+
return c.json(opts.hooks ? opts.hooks.log(name, limit) : { hook: name, declared: false, lines: [] });
|
|
1247
|
+
});
|
|
1248
|
+
|
|
868
1249
|
// Structure, not state: the workflow listing, a template's Machine, and the Console shell are
|
|
869
1250
|
// unauthenticated. They expose no run, drive nothing, and the Console is a BROWSER page — it
|
|
870
1251
|
// loads before any token is entered (ADR-0032). Everything that reads or moves a run is guarded
|
|
@@ -878,10 +1259,15 @@ export function createApp(host: RunHost, auth?: Authenticator, opts: CreateAppOp
|
|
|
878
1259
|
|
|
879
1260
|
// The current Assembly and the ones live runs hold (ADR-0069). Open like `/workflows`: a hash
|
|
880
1261
|
// and a Kit version are identity, the class of thing `/healthz` already serves.
|
|
881
|
-
// `mounts` is what this process reads from its mounted ConfigMaps
|
|
882
|
-
// the kubelet has delivered the data
|
|
1262
|
+
// `mounts` is what this process reads from its mounted ConfigMaps and holds from its digests
|
|
1263
|
+
// Secrets, so a command can wait until the kubelet has delivered the data it wrote. A digest of
|
|
1264
|
+
// digests: no token value.
|
|
883
1265
|
app.get("/assembly", async (c) => {
|
|
884
|
-
const
|
|
1266
|
+
const base = opts.assembly ? await opts.assembly.view() : { kitVersion: KIT_VERSION, current: null, held: [] };
|
|
1267
|
+
// The standing Workflows (ADR-0017): each one's state and how many items it holds, which
|
|
1268
|
+
// `jr2 up` and `jr2 status` print. Counts and run ids, as `held` has: no key and no item.
|
|
1269
|
+
const standing = await host.standingView();
|
|
1270
|
+
const view = standing.length ? { ...base, standing } : base;
|
|
885
1271
|
return c.json(opts.mounts ? { ...view, mounts: await opts.mounts() } : view);
|
|
886
1272
|
});
|
|
887
1273
|
|
|
@@ -891,7 +1277,7 @@ export function createApp(host: RunHost, auth?: Authenticator, opts: CreateAppOp
|
|
|
891
1277
|
// its own status, and the old Assembly stays current through every refusal.
|
|
892
1278
|
app.put("/assembly", async (c) => {
|
|
893
1279
|
if (auth) {
|
|
894
|
-
const principal = auth(bearerOf(c));
|
|
1280
|
+
const principal = await auth(bearerOf(c));
|
|
895
1281
|
if (!principal) return c.json({ error: "unauthorized" }, 401);
|
|
896
1282
|
if (principal.kind !== "publisher") {
|
|
897
1283
|
return c.json(
|
|
@@ -910,6 +1296,12 @@ export function createApp(host: RunHost, auth?: Authenticator, opts: CreateAppOp
|
|
|
910
1296
|
hash: c.req.header("jr2-assembly-hash"),
|
|
911
1297
|
body: c.req.raw.body,
|
|
912
1298
|
});
|
|
1299
|
+
// A publish that landed names each standing Workflow's state after it (ADR-0017): `jr2 up` prints
|
|
1300
|
+
// it, the paused ones with how many items they hold.
|
|
1301
|
+
if (answer.status === 200) {
|
|
1302
|
+
const standing = await host.standingView();
|
|
1303
|
+
if (standing.length) return c.json({ ...(answer.body as object), standing }, 200);
|
|
1304
|
+
}
|
|
913
1305
|
return c.json(answer.body, answer.status);
|
|
914
1306
|
});
|
|
915
1307
|
|
|
@@ -978,37 +1370,81 @@ export function createApp(host: RunHost, auth?: Authenticator, opts: CreateAppOp
|
|
|
978
1370
|
*
|
|
979
1371
|
* A foreign `dispatch` sends `jr2-dispatched-by` (ADR-0071): its key makes a repeated start one
|
|
980
1372
|
* run, answered `200 { runId, instanceId, existing: true }` where a new one is `201`. The key is
|
|
981
|
-
* asked first, so a re-send answers its run even when the name would be refused now
|
|
982
|
-
*
|
|
1373
|
+
* asked first, so a re-send answers its run even when the name would be refused now. The key is
|
|
1374
|
+
* scoped to the Workflow named: the same key in another Workflow is another run. A header that
|
|
1375
|
+
* does not parse is 400. `jr2 run` and the
|
|
983
1376
|
* Console send none. Any throw that is not a refusal is a defect, 500: a foreign caller reads a
|
|
984
1377
|
* 404 as "nothing was started", and a defect may come after a start.
|
|
1378
|
+
*
|
|
1379
|
+
* With `jr2-key-only: true` as well, the start is a key-only ask (ADR-0071): the Instance
|
|
1380
|
+
* Effector sends it, with no body, when the calling slot's schema refuses the body. The door
|
|
1381
|
+
* asks the key as above, and with no run under it answers `200 { existing: false }` and starts
|
|
1382
|
+
* nothing: not a 404, which reads as a refused name, and not a 5xx, which reads as a defect.
|
|
1383
|
+
* The header without `jr2-dispatched-by`, or with another value, is 400.
|
|
985
1384
|
*/
|
|
986
1385
|
const startRun = async (c: Context<JR2Env>, name: string): Promise<Response> => {
|
|
1386
|
+
// The scope first (ADR-0078): a Caller without `start` on this Workflow learns no key's run.
|
|
1387
|
+
const refused = mayFor(c, "start", name);
|
|
1388
|
+
if (refused) return refused;
|
|
987
1389
|
const header = dispatchedByOf(c.req.header(DISPATCHED_BY_HEADER));
|
|
988
1390
|
if ("error" in header) return c.json({ error: header.error }, 400);
|
|
989
1391
|
const { by } = header;
|
|
1392
|
+
const keyOnly = c.req.header(KEY_ONLY_HEADER);
|
|
1393
|
+
if (keyOnly !== undefined && (keyOnly !== "true" || !by)) {
|
|
1394
|
+
return c.json(
|
|
1395
|
+
{
|
|
1396
|
+
error: `the ${KEY_ONLY_HEADER} header is "true", with a ${DISPATCHED_BY_HEADER} header that carries the key (ADR-0071)`,
|
|
1397
|
+
},
|
|
1398
|
+
400,
|
|
1399
|
+
);
|
|
1400
|
+
}
|
|
990
1401
|
if (by) {
|
|
991
|
-
const found = await host.dispatched(by
|
|
992
|
-
if (found
|
|
993
|
-
|
|
1402
|
+
const found = await host.dispatched(name, dispatchKeyOf(by));
|
|
1403
|
+
if (found) return c.json({ runId: found.runId, instanceId: found.instanceId, existing: true }, 200);
|
|
1404
|
+
// A key-only ask, and no run has the key: nothing is read, nothing is started.
|
|
1405
|
+
if (keyOnly !== undefined) return c.json({ existing: false }, 200);
|
|
994
1406
|
}
|
|
995
1407
|
const unrunnable = opts.charts?.get(name)?.unrunnable;
|
|
996
1408
|
if (unrunnable !== undefined) return c.json({ error: `named Chart "${name}" cannot run: ${unrunnable}` }, 409);
|
|
1409
|
+
// jr2 starts a standing Workflow's run (ADR-0017): a Caller's start only resumes a paused one,
|
|
1410
|
+
// and a dispatch never starts one. `?drop-held=true` resumes with no held item.
|
|
1411
|
+
if (host.isStanding(name)) {
|
|
1412
|
+
if (by) {
|
|
1413
|
+
return c.json(
|
|
1414
|
+
{ error: `"${name}" is a standing Workflow: jr2 starts its run, and a dispatch cannot (ADR-0017)` },
|
|
1415
|
+
409,
|
|
1416
|
+
);
|
|
1417
|
+
}
|
|
1418
|
+
try {
|
|
1419
|
+
const resumed = await host.resumeStanding(name, callerRecord(callerOf(c)), {
|
|
1420
|
+
dropHeld: c.req.query("drop-held") === "true",
|
|
1421
|
+
});
|
|
1422
|
+
return c.json({ ...resumed, resumed: true }, 201);
|
|
1423
|
+
} catch (err) {
|
|
1424
|
+
if (err instanceof StandingRefused) return c.json({ error: errMessage(err) }, 409);
|
|
1425
|
+
return c.json({ error: errMessage(err) }, 500);
|
|
1426
|
+
}
|
|
1427
|
+
}
|
|
997
1428
|
const input = await readJson(c.req.text());
|
|
998
1429
|
try {
|
|
999
|
-
|
|
1430
|
+
// The run records its Caller (ADR-0078). A foreign dispatch's too: the Caller is the one whose
|
|
1431
|
+
// credential the other Instance sent, with the party it says the start acts for.
|
|
1432
|
+
const startedBy = { kind: "caller" as const, ...callerRecord(callerOf(c)) };
|
|
1433
|
+
const { runId, instanceId, existing } = await host.start(name, input, {
|
|
1434
|
+
...(by ? { dispatchedBy: by } : {}),
|
|
1435
|
+
startedBy,
|
|
1436
|
+
});
|
|
1000
1437
|
return existing ? c.json({ runId, instanceId, existing }, 200) : c.json({ runId, instanceId }, 201);
|
|
1001
1438
|
} catch (err) {
|
|
1002
1439
|
if (err instanceof ChartRefusal) return c.json({ error: "refused", report: err.report }, 422);
|
|
1003
1440
|
if (err instanceof EventValidationError) return c.json({ error: errMessage(err) }, 400);
|
|
1004
|
-
if (err instanceof DispatchKeyConflictError) return c.json({ error: errMessage(err) }, 409);
|
|
1005
1441
|
if (err instanceof UnknownWorkflowError) return c.json({ error: errMessage(err) }, 404);
|
|
1006
1442
|
return c.json({ error: errMessage(err) }, 500);
|
|
1007
1443
|
}
|
|
1008
1444
|
};
|
|
1009
1445
|
|
|
1010
1446
|
// The documented start of a named Chart; `POST /workflows/<wrapper>%2F<name>/runs` is the same.
|
|
1011
|
-
app.post("/workflows/:wrapper/charts/:name/runs",
|
|
1447
|
+
app.post("/workflows/:wrapper/charts/:name/runs", guarded("start"), (c) =>
|
|
1012
1448
|
startRun(c, chartName(c.req.param("wrapper"), c.req.param("name"))),
|
|
1013
1449
|
);
|
|
1014
1450
|
|
|
@@ -1051,6 +1487,11 @@ export function createApp(host: RunHost, auth?: Authenticator, opts: CreateAppOp
|
|
|
1051
1487
|
if (prefersHtml(c)) return consoleAsset("page.html", "text/html; charset=utf-8");
|
|
1052
1488
|
return c.json({ error: "no JSON resource at / — the Console is the HTML dialect" }, 404);
|
|
1053
1489
|
});
|
|
1490
|
+
// What the Console needs before a login (ADR-0032): the issuers the current config offers a login
|
|
1491
|
+
// against, each with its client id (the issuer's `audience`). Open, because the page reads it
|
|
1492
|
+
// before it holds a credential, and because both values are public: a public client has no
|
|
1493
|
+
// secret, and an issuer's URL is in every JWT it signs. Read per request, so a publish changes it.
|
|
1494
|
+
app.get("/console/config", (c) => c.json({ login: opts.consoleLogin?.() ?? [] }));
|
|
1054
1495
|
app.get("/workflows/:name", (c) => {
|
|
1055
1496
|
// The shell for ANY name — an unknown workflow surfaces in-page via its 404'd /machine fetch.
|
|
1056
1497
|
if (prefersHtml(c)) return consoleAsset("page.html", "text/html; charset=utf-8");
|
|
@@ -1079,7 +1520,7 @@ export function createApp(host: RunHost, auth?: Authenticator, opts: CreateAppOp
|
|
|
1079
1520
|
// allowlist not forwarding this route is a second layer, not the control (ADR-0059).
|
|
1080
1521
|
// A Chart wrapper's one-shot door (ADR-0070): a Chart field in the body is admitted before the
|
|
1081
1522
|
// run starts, and a refusal is 422 with the report. A named Chart rides the path encoded.
|
|
1082
|
-
app.post("/workflows/:name/runs",
|
|
1523
|
+
app.post("/workflows/:name/runs", guarded("start"), (c) => startRun(c, c.req.param("name")));
|
|
1083
1524
|
|
|
1084
1525
|
// The registered template Machine's structure — what the Console's diagram renders (structure
|
|
1085
1526
|
// is provider-independent, so the un-`provide()`d template is exactly right).
|
|
@@ -1171,6 +1612,13 @@ export function createApp(host: RunHost, auth?: Authenticator, opts: CreateAppOp
|
|
|
1171
1612
|
// A child's error below an Agent's state (ADR-0024) is mechanism text, like `fault`: behind the
|
|
1172
1613
|
// token.
|
|
1173
1614
|
if (ev.kind === "child-error") return undefined;
|
|
1615
|
+
// A look's mark (ADR-0011) names a Gate id, which a workflow may compute from its data: behind
|
|
1616
|
+
// the token.
|
|
1617
|
+
if (ev.kind === "looked") return undefined;
|
|
1618
|
+
// A Hook's refusal (ADR-0011) carries a handler's own words: behind the token.
|
|
1619
|
+
if (ev.kind === "hook-refused") return undefined;
|
|
1620
|
+
// A Pool's marks (ADR-0017) name its items' keys, which are the workflow's data: behind the token.
|
|
1621
|
+
if (ev.kind === "pool") return undefined;
|
|
1174
1622
|
// An Effect's lines (ADR-0067) carry argv and output — working data: behind the token.
|
|
1175
1623
|
if (ev.kind === "effect") return undefined;
|
|
1176
1624
|
return sseFrame("status", observe(ev.status));
|
|
@@ -1238,7 +1686,30 @@ export function createApp(host: RunHost, auth?: Authenticator, opts: CreateAppOp
|
|
|
1238
1686
|
});
|
|
1239
1687
|
});
|
|
1240
1688
|
|
|
1241
|
-
|
|
1689
|
+
// The run listing (ADR-0009): live runs by default, the history with `status`, newest start
|
|
1690
|
+
// first, a page at a time. Of the Workflows that the Caller may read (ADR-0078): a run of another
|
|
1691
|
+
// Workflow is left out, not refused, so one Caller's list says nothing of another's runs.
|
|
1692
|
+
app.get("/runs", guarded("read"), async (c) => {
|
|
1693
|
+
const query = parseRunListQuery(
|
|
1694
|
+
{
|
|
1695
|
+
status: c.req.query("status"),
|
|
1696
|
+
workflow: c.req.query("workflow"),
|
|
1697
|
+
since: c.req.query("since"),
|
|
1698
|
+
cursor: c.req.query("cursor"),
|
|
1699
|
+
limit: c.req.query("limit"),
|
|
1700
|
+
},
|
|
1701
|
+
Date.now(),
|
|
1702
|
+
);
|
|
1703
|
+
if ("error" in query) return c.json({ error: query.error }, 400);
|
|
1704
|
+
const page = await host.history(query, callerOf(c).may.read ?? []);
|
|
1705
|
+
// A run whose kept conversations the space guard deleted says so (ADR-0065).
|
|
1706
|
+
const swept = opts.conversations?.sweptRuns(page.runs.map((r) => r.runId)) ?? new Set<string>();
|
|
1707
|
+
if (swept.size === 0) return c.json(page);
|
|
1708
|
+
return c.json({
|
|
1709
|
+
...page,
|
|
1710
|
+
runs: page.runs.map((r) => (swept.has(r.runId) ? { ...r, swept: CONVERSATION_SWEPT } : r)),
|
|
1711
|
+
});
|
|
1712
|
+
});
|
|
1242
1713
|
|
|
1243
1714
|
// The Repos as the cluster currently reports them (ADR-0048/0051) — what `jr2 status` renders,
|
|
1244
1715
|
// and the place ADR-0047's "register the key, the cache agent retries" points at. Instance band,
|
|
@@ -1260,13 +1731,24 @@ export function createApp(host: RunHost, auth?: Authenticator, opts: CreateAppOp
|
|
|
1260
1731
|
// ambiguous hash, and it holds down what this newly reveals — settled run ids are now visible to
|
|
1261
1732
|
// an Instance-token holder, which `GET /runs`'s deferred `?all` had withheld. Deliberately not on
|
|
1262
1733
|
// the open observation routes: prefix probing there would be a run-id enumeration oracle.
|
|
1263
|
-
|
|
1734
|
+
//
|
|
1735
|
+
// Any Caller, for the Workflows in any of its scopes (ADR-0014): a Caller resolves only the ids it
|
|
1736
|
+
// could address. A Caller with a scope for every Workflow needs no filter; any other reads each
|
|
1737
|
+
// candidate's Workflow, over a wider scan, so the ids of other Workflows do not fill its answer.
|
|
1738
|
+
app.get("/runs/resolve", guarded("any"), async (c) => {
|
|
1264
1739
|
const prefix = c.req.query("prefix") ?? "";
|
|
1265
1740
|
if (prefix.length < MIN_RUN_ID_PREFIX) {
|
|
1266
1741
|
return c.json({ error: `prefix must be at least ${MIN_RUN_ID_PREFIX} characters` }, 400);
|
|
1267
1742
|
}
|
|
1743
|
+
const caller = callerOf(c);
|
|
1744
|
+
const everyWorkflow = Object.values(caller.may).some((grant) => grant === "all");
|
|
1268
1745
|
// Scan one past the cap so `truncated` is knowable without a second COUNT.
|
|
1269
|
-
const found =
|
|
1746
|
+
const found = everyWorkflow
|
|
1747
|
+
? await host.candidates(prefix, RESOLVE_LIMIT + 1)
|
|
1748
|
+
: await filterAsync(await host.candidates(prefix, RESOLVE_SCAN), async (id) => {
|
|
1749
|
+
const workflow = (await host.read(id))?.workflow;
|
|
1750
|
+
return workflow !== undefined && callerMayAny(caller, workflow);
|
|
1751
|
+
});
|
|
1270
1752
|
return c.json({
|
|
1271
1753
|
prefix,
|
|
1272
1754
|
runIds: found.slice(0, RESOLVE_LIMIT),
|
|
@@ -1274,39 +1756,76 @@ export function createApp(host: RunHost, auth?: Authenticator, opts: CreateAppOp
|
|
|
1274
1756
|
});
|
|
1275
1757
|
});
|
|
1276
1758
|
|
|
1759
|
+
// The Gate inbox (ADR-0011): the open Gates of every live run that the Caller may answer, oldest
|
|
1760
|
+
// first, with each Gate's run, Workflow and the time it opened. A Gate the Caller may not answer is
|
|
1761
|
+
// left out, not refused, so one Caller's inbox says nothing of another's Gates (ADR-0078). It never
|
|
1762
|
+
// gives a run's context (ADR-0014): a person who answers a Gate reads its `meta`.
|
|
1763
|
+
app.get("/gates", guarded("answer"), (c) => {
|
|
1764
|
+
const caller = callerOf(c);
|
|
1765
|
+
const workflow = c.req.query("workflow") || undefined;
|
|
1766
|
+
return c.json({ gates: host.gateInbox((w, name) => callerMayAnswer(caller, w, name), workflow) });
|
|
1767
|
+
});
|
|
1768
|
+
|
|
1277
1769
|
// Read-through (ADR-0009): a completed run's final status lives in the store after the registry
|
|
1278
1770
|
// drops it, so this serves terminal runs too — only a genuinely unknown run is a 404. The status
|
|
1279
1771
|
// carries the run's OPEN GATES (ADR-0011) — the discovery listing external callers act on
|
|
1280
1772
|
// (`jr2 send` menus, UI inbox cards, webhook translators matching on meta). Settled run → [].
|
|
1281
|
-
|
|
1773
|
+
//
|
|
1774
|
+
// `read` opens the status, `answer` opens the Gates (ADR-0014): a Caller with `answer` alone gets
|
|
1775
|
+
// the run's observation and its Gates, and never its context — a person who answers a Gate reads
|
|
1776
|
+
// the Gate's `meta`, which the author wrote for that person.
|
|
1777
|
+
app.get("/runs/:runId", guarded(["read", "answer"]), async (c) => {
|
|
1282
1778
|
const runId = c.req.param("runId");
|
|
1283
|
-
const
|
|
1284
|
-
|
|
1779
|
+
const found = await host.read(runId);
|
|
1780
|
+
if (!found) return c.json({ error: `no run "${runId}"` }, 404);
|
|
1781
|
+
// A run whose kept conversations the space guard deleted says so (ADR-0065).
|
|
1782
|
+
const status = opts.conversations?.sweptRuns([runId]).has(runId) ? { ...found, swept: CONVERSATION_SWEPT } : found;
|
|
1783
|
+
const caller = callerOf(c);
|
|
1784
|
+
const read = callerMay(caller, "read", status.workflow);
|
|
1785
|
+
const answer = callerMay(caller, "answer", status.workflow);
|
|
1786
|
+
if (!read && !answer) return mayFor(c, "read", status.workflow)!;
|
|
1787
|
+
if (!answer) return c.json(status);
|
|
1788
|
+
// Only the Gates that the Caller may answer (ADR-0078): a scope that names one Gate of the
|
|
1789
|
+
// Workflow does not show the others.
|
|
1790
|
+
const gates = host.gates(runId, (name) => callerMayAnswer(caller, status.workflow, name));
|
|
1791
|
+
if (!read) return c.json({ ...observe(status), gates });
|
|
1792
|
+
return c.json({ ...status, gates });
|
|
1285
1793
|
});
|
|
1286
1794
|
|
|
1287
1795
|
// Gates delivery (ADR-0011): validate the body against the gate's named schema and deliver into
|
|
1288
1796
|
// the gated state. Unknown gate (never opened, state exited, run settled) → 404; a name the gate
|
|
1289
1797
|
// doesn't accept, or a payload failing its schema → 400 naming what IS accepted.
|
|
1290
1798
|
//
|
|
1291
|
-
// A Gate is a HUMAN's decision (or a
|
|
1292
|
-
//
|
|
1293
|
-
//
|
|
1294
|
-
|
|
1295
|
-
|
|
1296
|
-
|
|
1297
|
-
|
|
1799
|
+
// A Gate is a HUMAN's decision (or a Hook's, or CI's): a Caller with `answer` on the run's
|
|
1800
|
+
// Workflow. Every Agent principal — a control token or a fetch token — is refused by the band,
|
|
1801
|
+
// because it is never a Caller: this is the exact line between "the Agent reports an outcome" and
|
|
1802
|
+
// "the Agent approves its own PR" (ADR-0013, ADR-0076). A run that is not live has no open Gate,
|
|
1803
|
+
// and the delivery below answers it 404.
|
|
1804
|
+
app.post("/runs/:runId/gates/:gate/events", guarded("answer"), async (c) => {
|
|
1805
|
+
const runId = c.req.param("runId");
|
|
1806
|
+
const gate = c.req.param("gate");
|
|
1807
|
+
// The body first: no `await` comes between the scope check and the delivery below, so a Gate
|
|
1808
|
+
// that opens while the body is in flight is checked too (ADR-0078).
|
|
1298
1809
|
const body = await readJson(c.req.text());
|
|
1810
|
+
const caller = callerOf(c);
|
|
1811
|
+
const live = host.status(runId);
|
|
1812
|
+
if (live) {
|
|
1813
|
+
const refused = mayFor(c, "answer", live.workflow);
|
|
1814
|
+
if (refused) return refused;
|
|
1815
|
+
}
|
|
1299
1816
|
try {
|
|
1300
|
-
|
|
1817
|
+
// The Gate's name, its invoke id, is what a scope that names single Gates names (ADR-0078).
|
|
1818
|
+
// The delivery checks it against the registration that it delivers to.
|
|
1819
|
+
const opens = live ? (name: string) => callerMayAnswer(caller, live.workflow, name) : undefined;
|
|
1820
|
+
host.sendToGate(runId, gate, body, callerRecord(caller), undefined, opens);
|
|
1301
1821
|
return c.json({ ok: true });
|
|
1302
1822
|
} catch (err) {
|
|
1823
|
+
if (err instanceof GateRefused) return mayAnswer(c, live!.workflow, err.gate)!;
|
|
1303
1824
|
if (err instanceof UnknownAddressError) return c.json({ error: errMessage(err) }, 404);
|
|
1304
1825
|
if (err instanceof EventValidationError) return c.json({ error: errMessage(err) }, 400);
|
|
1305
1826
|
// The run would not take it from this Gate now (ADR-0013): never `{ ok: true }`. The body
|
|
1306
1827
|
// names what the Gate takes now, as `GET /runs/:id` lists it.
|
|
1307
1828
|
if (err instanceof NotTakenError) {
|
|
1308
|
-
const runId = c.req.param("runId");
|
|
1309
|
-
const gate = c.req.param("gate");
|
|
1310
1829
|
const acceptsNow = host.gates(runId).find((g) => g.gate === gate)?.acceptsNow ?? [];
|
|
1311
1830
|
return c.json({ error: errMessage(err), acceptsNow }, 409);
|
|
1312
1831
|
}
|
|
@@ -1329,6 +1848,12 @@ export function createApp(host: RunHost, auth?: Authenticator, opts: CreateAppOp
|
|
|
1329
1848
|
// A child's error below an Agent's state, which the Agent's answer does not carry (ADR-0024):
|
|
1330
1849
|
// the operator reads it here, with its message.
|
|
1331
1850
|
if (ev.kind === "child-error") return sseFrame(ev.kind, ev);
|
|
1851
|
+
// A Gate's look gave its event and the run took it (ADR-0011).
|
|
1852
|
+
if (ev.kind === "looked") return sseFrame(ev.kind, ev);
|
|
1853
|
+
// A Hook's delivery reached an open Gate, and the Gate did not take it (ADR-0011).
|
|
1854
|
+
if (ev.kind === "hook-refused") return sseFrame(ev.kind, ev);
|
|
1855
|
+
// What a Pool of runs did with an item (ADR-0017): started, settled, or a held item refused.
|
|
1856
|
+
if (ev.kind === "pool") return sseFrame(ev.kind, ev);
|
|
1332
1857
|
// An Effect's lines (ADR-0067): its start, its stream, its output, an abort — Instance-token band.
|
|
1333
1858
|
if (ev.kind === "effect") return sseFrame("effect", ev);
|
|
1334
1859
|
return sseFrame("status", ev.status);
|
|
@@ -1336,12 +1861,19 @@ export function createApp(host: RunHost, auth?: Authenticator, opts: CreateAppOp
|
|
|
1336
1861
|
|
|
1337
1862
|
// SSE: a live run streams its status deltas + author `emit`s (current status replayed on attach,
|
|
1338
1863
|
// then live until the terminal transition or client abort). A run that has already settled streams
|
|
1339
|
-
// its final status once and closes (so `jr2
|
|
1340
|
-
app.get("/runs/:runId/events",
|
|
1864
|
+
// its final status once and closes (so `jr2 watch` works on a finished run). Unknown run → 404.
|
|
1865
|
+
app.get("/runs/:runId/events", guarded("read"), async (c) => {
|
|
1341
1866
|
const runId = c.req.param("runId");
|
|
1342
|
-
|
|
1867
|
+
const live = host.status(runId);
|
|
1868
|
+
if (live) {
|
|
1869
|
+
const refused = mayFor(c, "read", live.workflow);
|
|
1870
|
+
if (refused) return refused;
|
|
1871
|
+
}
|
|
1872
|
+
if (live === undefined) {
|
|
1343
1873
|
const finalStatus = await host.read(runId);
|
|
1344
1874
|
if (!finalStatus) return c.json({ error: `no run "${runId}"` }, 404);
|
|
1875
|
+
const refused = mayFor(c, "read", finalStatus.workflow);
|
|
1876
|
+
if (refused) return refused;
|
|
1345
1877
|
return streamSSE(c, async (stream) => {
|
|
1346
1878
|
await stream.writeSSE({ event: "status", data: JSON.stringify(finalStatus) });
|
|
1347
1879
|
await sayGoneIfParked(stream, finalStatus);
|
|
@@ -1389,8 +1921,13 @@ export function createApp(host: RunHost, auth?: Authenticator, opts: CreateAppOp
|
|
|
1389
1921
|
// It ENDS the run (ADR-0025): the Agents' turns end with it, the run does not come back on the
|
|
1390
1922
|
// next restore, and its Workspaces are destroyed unless the body says `keep: true`. `RunHost.stop()` — park it, keep it restorable — is a different verb, and
|
|
1391
1923
|
// deliberately not on the wire.
|
|
1392
|
-
app.post("/runs/:runId/events",
|
|
1924
|
+
app.post("/runs/:runId/events", guarded("cancel"), async (c) => {
|
|
1393
1925
|
const runId = c.req.param("runId");
|
|
1926
|
+
const known = await host.read(runId);
|
|
1927
|
+
if (known) {
|
|
1928
|
+
const refused = mayFor(c, "cancel", known.workflow);
|
|
1929
|
+
if (refused) return refused;
|
|
1930
|
+
}
|
|
1394
1931
|
const body = (await readJson(c.req.text())) as RunEventBody;
|
|
1395
1932
|
if (body.type !== "CANCEL") {
|
|
1396
1933
|
return c.json({ error: `unknown event type "${body.type ?? ""}" (accepts: CANCEL)` }, 400);
|
|
@@ -1400,13 +1937,202 @@ export function createApp(host: RunHost, auth?: Authenticator, opts: CreateAppOp
|
|
|
1400
1937
|
return c.json({ error: `"keep" must be a boolean` }, 400);
|
|
1401
1938
|
}
|
|
1402
1939
|
try {
|
|
1403
|
-
await host.cancel(runId, { keep: body.keep === true });
|
|
1940
|
+
await host.cancel(runId, { keep: body.keep === true, by: callerRecord(callerOf(c)) });
|
|
1404
1941
|
return c.json({ ok: true });
|
|
1405
1942
|
} catch (err) {
|
|
1406
1943
|
return c.json({ error: errMessage(err) }, 404);
|
|
1407
1944
|
}
|
|
1408
1945
|
});
|
|
1409
1946
|
|
|
1947
|
+
// Steering messages (ADR-0002): free text from a Caller to one Agent slot of a run, which the
|
|
1948
|
+
// Agent hears once, at a step boundary of its open Turn or at its next Turn's admission. The list
|
|
1949
|
+
// says where a message can go, so a Caller never guesses an Instance ID; the same scope opens both.
|
|
1950
|
+
app.get("/runs/:runId/agents", guarded("steer"), async (c) => {
|
|
1951
|
+
const runId = c.req.param("runId");
|
|
1952
|
+
const known = await host.read(runId);
|
|
1953
|
+
if (!known) return c.json({ error: `no run "${runId}"` }, 404);
|
|
1954
|
+
const refused = mayFor(c, "steer", known.workflow);
|
|
1955
|
+
if (refused) return refused;
|
|
1956
|
+
return c.json(host.agents(runId) ?? []);
|
|
1957
|
+
});
|
|
1958
|
+
|
|
1959
|
+
app.post("/runs/:runId/steering", guarded("steer"), async (c) => {
|
|
1960
|
+
const runId = c.req.param("runId");
|
|
1961
|
+
const known = await host.read(runId);
|
|
1962
|
+
if (!known) return c.json({ error: `no run "${runId}"` }, 404);
|
|
1963
|
+
const refused = mayFor(c, "steer", known.workflow);
|
|
1964
|
+
if (refused) return refused;
|
|
1965
|
+
const body = (await readJson(c.req.text())) as { agent?: unknown; text?: unknown };
|
|
1966
|
+
if (typeof body.agent !== "string" || body.agent === "") {
|
|
1967
|
+
return c.json(
|
|
1968
|
+
{ error: `a Steering message names an Agent slot: { agent, text } (see GET /runs/${runId}/agents)` },
|
|
1969
|
+
400,
|
|
1970
|
+
);
|
|
1971
|
+
}
|
|
1972
|
+
if (typeof body.text !== "string" || body.text.trim() === "") {
|
|
1973
|
+
return c.json({ error: "a Steering message has a text: { agent, text }" }, 400);
|
|
1974
|
+
}
|
|
1975
|
+
if (new TextEncoder().encode(body.text).length > STEERING_TEXT_MAX) {
|
|
1976
|
+
return c.json({ error: `the text is longer than ${STEERING_TEXT_MAX} bytes (16 KiB)` }, 413);
|
|
1977
|
+
}
|
|
1978
|
+
try {
|
|
1979
|
+
return c.json(host.steer(runId, body.agent, body.text, callerRecord(callerOf(c))), 202);
|
|
1980
|
+
} catch (err) {
|
|
1981
|
+
if (err instanceof SteeringRefused) {
|
|
1982
|
+
return c.json({ error: err.message, ...(err.agents !== undefined ? { agents: err.agents } : {}) }, err.status);
|
|
1983
|
+
}
|
|
1984
|
+
throw err;
|
|
1985
|
+
}
|
|
1986
|
+
});
|
|
1987
|
+
|
|
1988
|
+
// ---- The kept conversations (ADR-0023) ----------------------------------------------------------
|
|
1989
|
+
// What the Harness sent of each conversation while its Turns ran, for as long as the run is kept
|
|
1990
|
+
// (ADR-0065). `read` opens all three, as it opens the run's status (ADR-0014). A conversation is
|
|
1991
|
+
// open while a Turn of it is registered on that live run, and the host is not going away.
|
|
1992
|
+
const conversationOpen = (runId: string, instanceId: string): boolean =>
|
|
1993
|
+
!host.stopping && host.agentRun(instanceId) === runId;
|
|
1994
|
+
|
|
1995
|
+
/** The run, read and in the Caller's scope, or the answer that refuses it. An Instance ID that is
|
|
1996
|
+
* not of the run is refused before the store is read: the scope is the run's Workflow. */
|
|
1997
|
+
const conversationsRun = async (
|
|
1998
|
+
c: Context<JR2Env>,
|
|
1999
|
+
runId: string,
|
|
2000
|
+
instanceId?: string,
|
|
2001
|
+
): Promise<Response | undefined> => {
|
|
2002
|
+
if (instanceId !== undefined && !instanceId.startsWith(`${runId}/`)) {
|
|
2003
|
+
return c.json({ error: `run "${runId}" keeps no conversation "${instanceId}"` }, 404);
|
|
2004
|
+
}
|
|
2005
|
+
const known = await host.read(runId);
|
|
2006
|
+
if (!known) return c.json({ error: `no run "${runId}"` }, 404);
|
|
2007
|
+
return mayFor(c, "read", known.workflow);
|
|
2008
|
+
};
|
|
2009
|
+
|
|
2010
|
+
app.get("/runs/:runId/conversations", guarded("read"), async (c) => {
|
|
2011
|
+
const runId = c.req.param("runId");
|
|
2012
|
+
const refused = await conversationsRun(c, runId);
|
|
2013
|
+
if (refused) return refused;
|
|
2014
|
+
const kept = opts.conversations?.list(runId) ?? [];
|
|
2015
|
+
// Each conversation's Agent slot, from the run's admission ledger: the address `jr2 logs` and
|
|
2016
|
+
// `jr2 steer` take (ADR-0002, ADR-0009). Absent for a row that the ledger does not name.
|
|
2017
|
+
const slots = await Promise.all(kept.map(({ instanceId }) => host.conversationPlace(instanceId)));
|
|
2018
|
+
return c.json(
|
|
2019
|
+
kept.map(({ agent, instanceId, bytes, cut, swept }, i) => ({
|
|
2020
|
+
agent,
|
|
2021
|
+
...(slots[i]?.slot !== undefined ? { slot: slots[i].slot } : {}),
|
|
2022
|
+
instanceId,
|
|
2023
|
+
open: conversationOpen(runId, instanceId),
|
|
2024
|
+
bytes,
|
|
2025
|
+
cut,
|
|
2026
|
+
...(swept !== undefined ? { swept } : {}),
|
|
2027
|
+
})),
|
|
2028
|
+
);
|
|
2029
|
+
});
|
|
2030
|
+
|
|
2031
|
+
/** `?after=<seq>` (-1, the default, is from the first entry) and `?limit=`, or the 400. */
|
|
2032
|
+
const entryQuery = (c: Context<JR2Env>): { after: number; limit: number } | { error: string } => {
|
|
2033
|
+
const after = c.req.query("after");
|
|
2034
|
+
const limit = c.req.query("limit");
|
|
2035
|
+
const a = after === undefined ? -1 : Number(after);
|
|
2036
|
+
const l = limit === undefined ? CONVERSATION_PAGE_DEFAULT : Number(limit);
|
|
2037
|
+
if (!Number.isInteger(a) || a < -1)
|
|
2038
|
+
return { error: `after is a sequence number (an integer of -1 or more), not "${after}"` };
|
|
2039
|
+
if (!Number.isInteger(l) || l < 1 || l > CONVERSATION_PAGE_MAX) {
|
|
2040
|
+
return { error: `limit is an integer from 1 to ${CONVERSATION_PAGE_MAX}, not "${limit}"` };
|
|
2041
|
+
}
|
|
2042
|
+
return { after: a, limit: l };
|
|
2043
|
+
};
|
|
2044
|
+
|
|
2045
|
+
app.get("/runs/:runId/conversations/:instanceId", guarded("read"), async (c) => {
|
|
2046
|
+
const runId = c.req.param("runId");
|
|
2047
|
+
const instanceId = c.req.param("instanceId");
|
|
2048
|
+
const refused = await conversationsRun(c, runId, instanceId);
|
|
2049
|
+
if (refused) return refused;
|
|
2050
|
+
const query = entryQuery(c);
|
|
2051
|
+
if ("error" in query) return c.json({ error: query.error }, 400);
|
|
2052
|
+
const store = opts.conversations;
|
|
2053
|
+
if (!store?.conversation(runId, instanceId)) {
|
|
2054
|
+
return c.json({ error: `run "${runId}" keeps no conversation "${instanceId}"` }, 404);
|
|
2055
|
+
}
|
|
2056
|
+
return c.json(store.entries(instanceId, query.after, query.limit));
|
|
2057
|
+
});
|
|
2058
|
+
|
|
2059
|
+
// Follow one conversation: what is kept after `after`, then each new entry as it is kept. It ends
|
|
2060
|
+
// when the conversation is not open (its Turn is not registered, or its run ended) and no entry
|
|
2061
|
+
// came for a short time, so a batch that the Harness sends at its Turn's end is still read; and
|
|
2062
|
+
// when the conversation is cut or swept.
|
|
2063
|
+
app.get("/runs/:runId/conversations/:instanceId/events", guarded("read"), async (c) => {
|
|
2064
|
+
const runId = c.req.param("runId");
|
|
2065
|
+
const instanceId = c.req.param("instanceId");
|
|
2066
|
+
const refused = await conversationsRun(c, runId, instanceId);
|
|
2067
|
+
if (refused) return refused;
|
|
2068
|
+
const query = entryQuery(c);
|
|
2069
|
+
if ("error" in query) return c.json({ error: query.error }, 400);
|
|
2070
|
+
const store = opts.conversations;
|
|
2071
|
+
const known = store?.conversation(runId, instanceId);
|
|
2072
|
+
// A conversation that is open and has no entry kept yet is followed too.
|
|
2073
|
+
if (!store || (!known && !conversationOpen(runId, instanceId))) {
|
|
2074
|
+
return c.json({ error: `run "${runId}" keeps no conversation "${instanceId}"` }, 404);
|
|
2075
|
+
}
|
|
2076
|
+
return streamSSE(c, async (stream) => {
|
|
2077
|
+
await new Promise<void>((resolve) => {
|
|
2078
|
+
const exit = closer(resolve);
|
|
2079
|
+
const sub = subscriber(stream);
|
|
2080
|
+
let last = query.after;
|
|
2081
|
+
let quietSince = Date.now();
|
|
2082
|
+
const send = (entries: ConversationEntry[]) => {
|
|
2083
|
+
for (const entry of entries) {
|
|
2084
|
+
if (entry.seq <= last) continue;
|
|
2085
|
+
last = entry.seq;
|
|
2086
|
+
quietSince = Date.now();
|
|
2087
|
+
void sub.send(`event: entry\ndata: ${JSON.stringify(entry)}\n\n`);
|
|
2088
|
+
}
|
|
2089
|
+
};
|
|
2090
|
+
// Subscribe before the replay, so an entry kept between the two is not lost; `last` drops
|
|
2091
|
+
// what both give.
|
|
2092
|
+
const held: ConversationEntry[] = [];
|
|
2093
|
+
let replaying = true;
|
|
2094
|
+
exit.onExit(
|
|
2095
|
+
store.follow(instanceId, (entries) => {
|
|
2096
|
+
if (replaying) held.push(...entries);
|
|
2097
|
+
else send(entries);
|
|
2098
|
+
}),
|
|
2099
|
+
);
|
|
2100
|
+
for (;;) {
|
|
2101
|
+
const page = store.entries(instanceId, last, CONVERSATION_PAGE_MAX);
|
|
2102
|
+
send(page);
|
|
2103
|
+
if (page.length < CONVERSATION_PAGE_MAX) break;
|
|
2104
|
+
}
|
|
2105
|
+
replaying = false;
|
|
2106
|
+
send(held);
|
|
2107
|
+
let ending = false;
|
|
2108
|
+
const end = () => {
|
|
2109
|
+
if (ending) return;
|
|
2110
|
+
ending = true;
|
|
2111
|
+
const now = store.conversation(runId, instanceId);
|
|
2112
|
+
void sub
|
|
2113
|
+
.send(
|
|
2114
|
+
`event: end\ndata: ${JSON.stringify({ cut: now?.cut ?? false, ...(now?.swept ? { swept: now.swept } : {}) })}\n\n`,
|
|
2115
|
+
)
|
|
2116
|
+
.then(exit.done);
|
|
2117
|
+
};
|
|
2118
|
+
const check = () => {
|
|
2119
|
+
if (stream.aborted || stream.closed) return exit.done();
|
|
2120
|
+
// The Orchestrator is going away: the server waits for this request, so end it now.
|
|
2121
|
+
if (host.stopping) return end();
|
|
2122
|
+
const now = store.conversation(runId, instanceId);
|
|
2123
|
+
if (now?.cut || now?.swept) return end();
|
|
2124
|
+
if (!conversationOpen(runId, instanceId) && Date.now() - quietSince >= followGraceMs) end();
|
|
2125
|
+
};
|
|
2126
|
+
const timer = setInterval(check, Math.min(followGraceMs, 1_000));
|
|
2127
|
+
exit.onExit(() => clearInterval(timer));
|
|
2128
|
+
exit.onExit(host.onClose(check));
|
|
2129
|
+
exit.onExit(pinger(stream, sub, exit.done, pingMs));
|
|
2130
|
+
stream.onAbort(exit.done);
|
|
2131
|
+
check();
|
|
2132
|
+
});
|
|
2133
|
+
});
|
|
2134
|
+
});
|
|
2135
|
+
|
|
1410
2136
|
// ---- The Agent's surface (`/agents/:iid/*` — ADR-0013) --------------------------------------
|
|
1411
2137
|
// Served to ONE caller: the Agent's Harness, through the Custodian in its pod. The Harness renders
|
|
1412
2138
|
// `surface` as the Menu and turns a pick into an `events` POST. The Orchestrator therefore keeps
|
|
@@ -1419,7 +2145,9 @@ export function createApp(host: RunHost, auth?: Authenticator, opts: CreateAppOp
|
|
|
1419
2145
|
// and delivery are implemented once, in `registration.ts`.
|
|
1420
2146
|
|
|
1421
2147
|
/** Guard: the surface must exist, and this principal must be allowed to speak for it. */
|
|
1422
|
-
const agentRegistration = (
|
|
2148
|
+
const agentRegistration = (
|
|
2149
|
+
c: Context<JR2Env, "/agents/:instanceId/surface" | "/agents/:instanceId/events" | "/agents/:instanceId/notices">,
|
|
2150
|
+
) => {
|
|
1423
2151
|
const instanceId = c.req.param("instanceId");
|
|
1424
2152
|
// Only a Turn that is over answers 404 (ADR-0026). While the host is stopping its runs are
|
|
1425
2153
|
// parked for restore, not over, so every `/agents/*` call answers 503: the Harness re-asks a
|
|
@@ -1442,9 +2170,10 @@ export function createApp(host: RunHost, auth?: Authenticator, opts: CreateAppOp
|
|
|
1442
2170
|
// iid) → there is no surface to serve. 404 BEFORE the scope check — a caller with a valid
|
|
1443
2171
|
// token learns nothing from it that it did not already know.
|
|
1444
2172
|
if (!surface) return { error: c.json({ error: `no live agent surface for instance "${instanceId}"` }, 404) };
|
|
1445
|
-
if (!mayDeliverToAgent(principalOf(c), surface
|
|
1446
|
-
// A
|
|
1447
|
-
//
|
|
2173
|
+
if (!mayDeliverToAgent(principalOf(c), surface)) {
|
|
2174
|
+
// A control token for a DIFFERENT pod (or for a workspace-less run, which no pod owns), a
|
|
2175
|
+
// stated slot that is not this registration's (ADR-0076), or a fetch token. The first is the
|
|
2176
|
+
// check that keeps one feature's coder out of another's reviewer.
|
|
1448
2177
|
return { error: c.json({ error: `this token cannot speak for instance "${instanceId}"` }, 403) };
|
|
1449
2178
|
}
|
|
1450
2179
|
return { surface };
|
|
@@ -1454,11 +2183,82 @@ export function createApp(host: RunHost, auth?: Authenticator, opts: CreateAppOp
|
|
|
1454
2183
|
// semantics, fixed for the conversation — and this Turn's Allowed picks (ADR-0029). A transition
|
|
1455
2184
|
// swaps the registration, which swaps the Allowed picks, never the Menu; the Harness reads both
|
|
1456
2185
|
// per Submission and needs no `list_changed`.
|
|
1457
|
-
|
|
2186
|
+
// The same read at a step boundary carries the conversation's pending notices in its audience
|
|
2187
|
+
// (ADR-0062), reserved for this read: the Harness appends them and acknowledges them below.
|
|
2188
|
+
app.get("/agents/:instanceId/surface", authenticated, async (c) => {
|
|
1458
2189
|
const { surface, error } = agentRegistration(c);
|
|
1459
2190
|
if (error) return error;
|
|
1460
2191
|
host.agentSurfaceServed(c.req.param("instanceId"));
|
|
1461
|
-
|
|
2192
|
+
// A notice and a Steering message ride the read apart (ADR-0062): the Harness frames a message
|
|
2193
|
+
// by its author. One acknowledgment hears both, by the ids the read named.
|
|
2194
|
+
const held = await host.boundaryNotices(c.req.param("instanceId"));
|
|
2195
|
+
const notices = held.filter((h) => h.notice.kind !== "steering");
|
|
2196
|
+
const steering = held.flatMap((h) => (h.notice.kind === "steering" ? [{ id: h.id, message: h.notice }] : []));
|
|
2197
|
+
return c.json({ ...surface, notices, ...(steering.length > 0 ? { steering } : {}) });
|
|
2198
|
+
});
|
|
2199
|
+
|
|
2200
|
+
// The Harness appended a step boundary's notices to the Turn (ADR-0062, ADR-0076): they are heard
|
|
2201
|
+
// in that conversation, ledgered in one save. An id this conversation did not reserve is ignored,
|
|
2202
|
+
// so the call says nothing a caller did not already know.
|
|
2203
|
+
app.post("/agents/:instanceId/notices", authenticated, async (c) => {
|
|
2204
|
+
const { error } = agentRegistration(c);
|
|
2205
|
+
if (error) return error;
|
|
2206
|
+
const body = await readJson(c.req.text());
|
|
2207
|
+
const heard = (body as { heard?: unknown }).heard;
|
|
2208
|
+
if (!Array.isArray(heard) || heard.some((id) => typeof id !== "string")) {
|
|
2209
|
+
return c.json({ error: "an acknowledgment names the notices heard: { heard: string[] }" }, 400);
|
|
2210
|
+
}
|
|
2211
|
+
return c.json({ heard: host.noticesHeard(c.req.param("instanceId"), heard as string[]) });
|
|
2212
|
+
});
|
|
2213
|
+
|
|
2214
|
+
// A batch of a conversation's entries (ADR-0023), from the Harness that runs it, through its
|
|
2215
|
+
// Custodian. Only the control token of the pod that the run's admission ledger names, for the
|
|
2216
|
+
// ledger's slot: the ledger and not the live registration, because the ledger outlives the Turn
|
|
2217
|
+
// and the run, and the last batch of a Turn often comes after its sweep. The Instance token does
|
|
2218
|
+
// not open it: a holder of it can speak as an Agent, but it cannot write an Agent's kept
|
|
2219
|
+
// conversation. A batch that is sent again adds nothing (the store keeps each `seq` once).
|
|
2220
|
+
app.post("/agents/:instanceId/conversation", authenticated, async (c) => {
|
|
2221
|
+
const instanceId = c.req.param("instanceId");
|
|
2222
|
+
const store = opts.conversations;
|
|
2223
|
+
if (!store) return c.json({ error: "this Orchestrator keeps no conversation" }, 404);
|
|
2224
|
+
const principal = principalOf(c);
|
|
2225
|
+
if (auth && principal.kind !== "agent") {
|
|
2226
|
+
return c.json(
|
|
2227
|
+
{ error: `only the Harness of the conversation's pod writes it: this token cannot (ADR-0023)` },
|
|
2228
|
+
403,
|
|
2229
|
+
);
|
|
2230
|
+
}
|
|
2231
|
+
const text = await c.req.text();
|
|
2232
|
+
if (Buffer.byteLength(text) > CONVERSATION_BATCH_MAX) {
|
|
2233
|
+
return c.json({ error: `a batch is at most ${CONVERSATION_BATCH_MAX} bytes: send fewer entries` }, 413);
|
|
2234
|
+
}
|
|
2235
|
+
const parsed = conversationBatch.safeParse(await readJson(Promise.resolve(text)));
|
|
2236
|
+
if (!parsed.success) {
|
|
2237
|
+
return c.json(
|
|
2238
|
+
{
|
|
2239
|
+
error: `a batch is { entries: Array<{ id, seq, at, kind, text }> }: ${parsed.error.issues[0]?.message ?? "no entries"}`,
|
|
2240
|
+
},
|
|
2241
|
+
400,
|
|
2242
|
+
);
|
|
2243
|
+
}
|
|
2244
|
+
const place = await host.conversationPlace(instanceId);
|
|
2245
|
+
if (!place) return c.json({ error: `no run for instance "${instanceId}"` }, 404);
|
|
2246
|
+
if (place.agent === undefined) {
|
|
2247
|
+
// A live run whose ledger does not name the conversation yet: the batch came before the
|
|
2248
|
+
// admission's save. The Harness sends it again.
|
|
2249
|
+
if (place.live) return c.json({ error: `the run does not name instance "${instanceId}" yet — send again` }, 409);
|
|
2250
|
+
return c.json({ error: `run "${place.runId}" has no conversation "${instanceId}"` }, 404);
|
|
2251
|
+
}
|
|
2252
|
+
if (
|
|
2253
|
+
principal.kind === "agent" &&
|
|
2254
|
+
(place.sandbox === undefined ||
|
|
2255
|
+
place.sandbox !== principal.sandbox ||
|
|
2256
|
+
place.slot === undefined ||
|
|
2257
|
+
place.slot !== principal.slot)
|
|
2258
|
+
) {
|
|
2259
|
+
return c.json({ error: `this token cannot write the conversation of instance "${instanceId}"` }, 403);
|
|
2260
|
+
}
|
|
2261
|
+
return c.json(store.append(place.runId, instanceId, place.agent, parsed.data.entries));
|
|
1462
2262
|
});
|
|
1463
2263
|
|
|
1464
2264
|
// The Agent's pick, delivered into the state that invoked it. The receipt describes itself
|
|
@@ -1514,7 +2314,7 @@ export function createApp(host: RunHost, auth?: Authenticator, opts: CreateAppOp
|
|
|
1514
2314
|
return c.json({ error: restorePendingMessage(`delivery "${deliveryId}"`) }, 503);
|
|
1515
2315
|
}
|
|
1516
2316
|
if (!scope) return c.json({ error: `no delivery "${deliveryId}" for instance "${instanceId}"` }, 404);
|
|
1517
|
-
if (!mayDeliverToAgent(principalOf(c), scope
|
|
2317
|
+
if (!mayDeliverToAgent(principalOf(c), scope)) {
|
|
1518
2318
|
return c.json({ error: `this token cannot speak for instance "${instanceId}"` }, 403);
|
|
1519
2319
|
}
|
|
1520
2320
|
const receipt = await host.awaitDelivery(instanceId, deliveryId, opts.deliveryWaitMs, c.req.raw.signal);
|
|
@@ -1560,3 +2360,37 @@ export function createApp(host: RunHost, auth?: Authenticator, opts: CreateAppOp
|
|
|
1560
2360
|
|
|
1561
2361
|
return app;
|
|
1562
2362
|
}
|
|
2363
|
+
|
|
2364
|
+
/**
|
|
2365
|
+
* The body of a request as the Hook's door reads it (ADR-0011): at most `max` bytes, and
|
|
2366
|
+
* `undefined` when the body is longer. A `content-length` above `max` reads nothing; else the
|
|
2367
|
+
* stream is read until it ends or passes `max`, and then it is cancelled. So an unbounded body is
|
|
2368
|
+
* never kept in memory.
|
|
2369
|
+
*/
|
|
2370
|
+
export function limitedBody(req: Request): (max: number) => Promise<Uint8Array | undefined> {
|
|
2371
|
+
return async (max) => {
|
|
2372
|
+
const declared = Number(req.headers.get("content-length") ?? "");
|
|
2373
|
+
if (Number.isFinite(declared) && declared > max) return undefined;
|
|
2374
|
+
if (req.body === null) return new Uint8Array();
|
|
2375
|
+
const reader = req.body.getReader();
|
|
2376
|
+
const chunks: Uint8Array[] = [];
|
|
2377
|
+
let size = 0;
|
|
2378
|
+
for (;;) {
|
|
2379
|
+
const { done, value } = await reader.read();
|
|
2380
|
+
if (done) break;
|
|
2381
|
+
size += value.byteLength;
|
|
2382
|
+
if (size > max) {
|
|
2383
|
+
await reader.cancel().catch(() => {});
|
|
2384
|
+
return undefined;
|
|
2385
|
+
}
|
|
2386
|
+
chunks.push(value);
|
|
2387
|
+
}
|
|
2388
|
+
const out = new Uint8Array(size);
|
|
2389
|
+
let at = 0;
|
|
2390
|
+
for (const chunk of chunks) {
|
|
2391
|
+
out.set(chunk, at);
|
|
2392
|
+
at += chunk.byteLength;
|
|
2393
|
+
}
|
|
2394
|
+
return out;
|
|
2395
|
+
};
|
|
2396
|
+
}
|