@jr2/orchestrator 0.6.0 → 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 +86 -9
- package/docs/concepts/architecture.md +41 -28
- package/docs/concepts/callers-and-hooks.md +205 -0
- package/docs/concepts/charts.md +7 -5
- package/docs/concepts/composition.md +10 -1
- package/docs/concepts/converge.md +4 -2
- package/docs/concepts/durable-runs.md +43 -16
- package/docs/concepts/effects.md +50 -22
- package/docs/concepts/gates.md +95 -25
- package/docs/concepts/machines-and-workflows.md +5 -4
- package/docs/concepts/pools-and-sources.md +187 -11
- package/docs/concepts/secrets-and-trust.md +73 -33
- package/docs/concepts/the-menu.md +11 -0
- package/docs/concepts/workspaces.md +6 -5
- package/docs/get-started/a-model-decides.md +14 -13
- package/docs/get-started/an-agent-in-a-sandbox.md +43 -34
- package/docs/get-started/connect-a-model.md +1 -1
- package/docs/get-started/create-an-instance.md +17 -14
- package/docs/guides/add-tools-to-the-sandbox.md +55 -11
- 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 +24 -8
- 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/fix-a-failed-converge.md +18 -6
- 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 +9 -6
- package/docs/guides/look-inside-a-sandbox.md +101 -31
- package/docs/guides/open-the-console.md +63 -14
- package/docs/guides/read-a-conversation.md +225 -0
- package/docs/guides/run-a-chart-by-name.md +17 -16
- package/docs/guides/run-one-worker-per-item.md +15 -9
- package/docs/guides/set-an-agents-access-and-dials.md +2 -0
- 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 +1 -1
- package/docs/guides/watch-a-run.md +127 -121
- 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 +17 -7
- package/docs/llms.txt +81 -15
- package/docs/reference/authoring-surface/agent.md +1 -1
- package/docs/reference/authoring-surface/answer.md +1 -1
- package/docs/reference/authoring-surface/arg.md +1 -1
- 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 +1 -1
- 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 +1 -1
- 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 +1 -1
- package/docs/reference/authoring-surface/file.md +1 -1
- 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 +1 -1
- package/docs/reference/authoring-surface/isfailure.md +1 -1
- package/docs/reference/authoring-surface/isready.md +1 -1
- package/docs/reference/authoring-surface/jr2setup.md +13 -3
- package/docs/reference/authoring-surface/mcp.md +107 -0
- package/docs/reference/authoring-surface/open.md +5 -3
- package/docs/reference/authoring-surface/openbody.md +1 -1
- package/docs/reference/authoring-surface/paramsof.md +1 -1
- package/docs/reference/authoring-surface/pool.md +77 -9
- package/docs/reference/authoring-surface/push.md +1 -1
- package/docs/reference/authoring-surface/readyof.md +1 -1
- package/docs/reference/authoring-surface/reread.md +19 -0
- package/docs/reference/authoring-surface/secret.md +1 -1
- 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 +56 -1
- 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 +14 -1
- package/docs/reference/authoring-surface/type-agentmcpserver.md +54 -0
- package/docs/reference/authoring-surface/type-anyframepath.md +1 -1
- package/docs/reference/authoring-surface/type-anyworktreepath.md +1 -1
- package/docs/reference/authoring-surface/type-binding.md +1 -1
- 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-commandoutput.md +1 -1
- package/docs/reference/authoring-surface/type-customize.md +16 -1
- 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 +1 -1
- package/docs/reference/authoring-surface/type-effectbinding.md +12 -2
- package/docs/reference/authoring-surface/type-framepath.md +1 -1
- 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 +1 -1
- package/docs/reference/authoring-surface/type-httpoutput.md +1 -1
- package/docs/reference/authoring-surface/type-jr2config.md +37 -2
- package/docs/reference/authoring-surface/type-lateroutcome.md +1 -1
- 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-openbodydeclaration.md +1 -1
- package/docs/reference/authoring-surface/type-openbodyoutput.md +1 -1
- package/docs/reference/authoring-surface/type-poolnextinput.md +1 -1
- package/docs/reference/authoring-surface/type-poolspec.md +11 -6
- package/docs/reference/authoring-surface/type-pushoutput.md +1 -1
- 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 +1 -1
- 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-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-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 +16 -7
- 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 +1 -1
- package/docs/reference/authoring-surface/type-worktreefound.md +3 -3
- package/docs/reference/authoring-surface/type-worktreeinput.md +1 -1
- package/docs/reference/authoring-surface/type-worktreeoptions.md +1 -1
- package/docs/reference/authoring-surface/type-worktreeoutput.md +1 -1
- package/docs/reference/authoring-surface/type-worktreepath.md +1 -1
- package/docs/reference/authoring-surface/type-worktreeready.md +1 -1
- package/docs/reference/authoring-surface/type-worktreerefused.md +4 -4
- package/docs/reference/authoring-surface/workspace.md +1 -1
- package/docs/reference/authoring-surface/worktree.md +1 -1
- package/docs/reference/authoring-surface.md +88 -167
- package/docs/reference/cli.md +413 -74
- package/docs/reference/config.md +235 -2
- package/docs/reference/decisions/0002-duplex-streaming-actor.md +71 -11
- package/docs/reference/decisions/0005-sandbox-pod-composition.md +14 -10
- package/docs/reference/decisions/0006-agent-control-surface.md +4 -4
- 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 +9 -2
- package/docs/reference/decisions/0013-the-agent-reaches-its-machine-through-a-container-it-cannot-read.md +20 -14
- 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 +18 -14
- package/docs/reference/decisions/0017-pool-over-a-source-port.md +226 -12
- 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 +32 -18
- package/docs/reference/decisions/0021-workspace-continuity-is-a-lease-that-answers-back.md +14 -1
- 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 +116 -34
- package/docs/reference/decisions/0024-an-agents-turn-ends-with-the-state-that-asked-for-it.md +38 -37
- 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 +7 -6
- package/docs/reference/decisions/0028-what-an-agent-may-do-to-the-workspace-is-part-of-its-definition.md +23 -7
- 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 -18
- 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 +4 -3
- 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 +5 -4
- package/docs/reference/decisions/0049-a-machine-carries-its-parts-and-composes-by-invoke.md +19 -19
- package/docs/reference/decisions/0050-what-code-names-config-declares-what-the-cli-names-discovery-finds.md +6 -1
- 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 +7 -0
- package/docs/reference/decisions/0058-a-harness-answers-the-orchestrator-alone.md +13 -7
- package/docs/reference/decisions/0059-a-harness-holds-stand-ins-and-the-custodian-holds-the-keys.md +38 -30
- package/docs/reference/decisions/0062-jr2-briefs-the-agent-on-its-seat-never-on-its-task.md +64 -17
- package/docs/reference/decisions/0063-the-orchestrator-watches-the-cluster.md +2 -1
- 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 +51 -23
- package/docs/reference/decisions/0067-an-effect-is-an-actor-slot-and-runs-at-most-once-unless-marked-idempotent.md +94 -54
- package/docs/reference/decisions/0068-an-effector-holds-an-effects-secrets-and-signs-its-wire.md +101 -47
- 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 +17 -13
- 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/0075-a-machine-cuts-a-worktree-and-the-workspace-only-clones.md +4 -3
- package/docs/reference/decisions/0076-an-agents-control-credential-lives-only-where-no-workspace-code-runs.md +37 -10
- 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 +6 -4
- package/docs/reference/glossary.md +96 -42
- package/docs/reference/hooks.md +1077 -0
- package/docs/reference/http-api.md +976 -149
- package/docs/why.md +2 -1
- package/package.json +10 -3
- package/src/actor.ts +36 -10
- package/src/agent.ts +50 -0
- package/src/assembly-checks.ts +530 -4
- package/src/callers.ts +245 -0
- package/src/chart.ts +52 -7
- package/src/config.ts +550 -1
- package/src/conversations.ts +453 -0
- package/src/custodian.ts +8 -2
- package/src/customize.ts +143 -4
- package/src/dispatch-effect.ts +142 -26
- package/src/durability.ts +2 -0
- package/src/duration.ts +20 -0
- package/src/effect-checks.ts +2 -1
- package/src/effect.ts +60 -7
- package/src/effector-wire.ts +272 -27
- package/src/gate-brand.ts +16 -0
- package/src/gate.ts +634 -48
- package/src/harness-client.ts +10 -4
- package/src/held-secrets.ts +72 -23
- package/src/holds.ts +2 -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 +76 -38
- package/src/http.ts +889 -94
- package/src/index.ts +16 -5
- package/src/instance.ts +196 -26
- package/src/internal.ts +22 -3
- package/src/machine-doc.ts +26 -1
- package/src/mcp-effect.ts +424 -0
- package/src/mount-digest.ts +41 -7
- package/src/names.ts +64 -2
- package/src/notices.ts +138 -10
- package/src/open-body.ts +25 -11
- package/src/open-effect.ts +17 -4
- package/src/open.ts +4 -2
- package/src/parts.ts +87 -9
- package/src/party.ts +2 -1
- package/src/pick.ts +2 -2
- package/src/pool.ts +1142 -163
- package/src/registration.ts +203 -13
- package/src/run-host.ts +1509 -171
- package/src/run-list.ts +191 -0
- package/src/sandbox-kube.ts +53 -5
- package/src/secret-placement.ts +239 -33
- package/src/server.ts +53 -4
- package/src/setup.ts +72 -32
- package/src/snapshot-store.ts +565 -48
- package/src/steer.ts +111 -0
- package/src/stub-harness.ts +6 -2
- package/src/tokens.ts +263 -22
- package/src/wire.ts +17 -0
- package/src/workspace.ts +50 -6
- package/src/worktree.ts +3 -3
- 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,20 +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
73
|
import {
|
|
65
|
-
|
|
74
|
+
callerHasScope,
|
|
75
|
+
callerMay,
|
|
76
|
+
callerMayAnswer,
|
|
77
|
+
callerMayAny,
|
|
78
|
+
instanceCaller,
|
|
79
|
+
isInstancePrincipal,
|
|
66
80
|
mayAskForSandbox,
|
|
67
81
|
mayDeliverToAgent,
|
|
82
|
+
ON_BEHALF_OF_HEADER,
|
|
83
|
+
ON_BEHALF_OF_MAX,
|
|
68
84
|
SLOT_HEADER,
|
|
69
85
|
type Authenticator,
|
|
86
|
+
type Caller,
|
|
70
87
|
type Principal,
|
|
71
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";
|
|
72
92
|
import {
|
|
73
|
-
|
|
74
|
-
keyAnswer,
|
|
75
|
-
keyConflict,
|
|
93
|
+
GateRefused,
|
|
76
94
|
observe,
|
|
95
|
+
StandingRefused,
|
|
96
|
+
SteeringRefused,
|
|
77
97
|
UnknownWorkflowError,
|
|
78
98
|
type RunFeedEvent,
|
|
79
99
|
type RunHost,
|
|
@@ -83,6 +103,7 @@ import {
|
|
|
83
103
|
import { KIT_VERSION } from "./config.ts";
|
|
84
104
|
import type { AssemblyDoor } from "./assembly.ts";
|
|
85
105
|
import { ChartRefusal } from "./chart.ts";
|
|
106
|
+
import { HOOK_LOG_DEFAULT, HOOK_LOG_MAX, type HookDoor } from "./hooks.ts";
|
|
86
107
|
import { chartName, NamedChartError, splitChartName, type NamedCharts } from "./charts.ts";
|
|
87
108
|
|
|
88
109
|
// ---- The route table (ADR-0072) -------------------------------------------------------------
|
|
@@ -92,9 +113,12 @@ import { chartName, NamedChartError, splitChartName, type NamedCharts } from "./
|
|
|
92
113
|
// the app's own registrations both ways and checks each row's band on the wire, so the table
|
|
93
114
|
// cannot drift from the code by forgetting.
|
|
94
115
|
|
|
95
|
-
/** The access bands of ADR-0014, named for
|
|
96
|
-
* band (ADR-
|
|
97
|
-
|
|
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";
|
|
98
122
|
|
|
99
123
|
/** One route of the Orchestrator's HTTP API (ADR-0072). */
|
|
100
124
|
export type HttpRoute = {
|
|
@@ -102,6 +126,10 @@ export type HttpRoute = {
|
|
|
102
126
|
/** In Hono syntax, exactly as registered: `:param`, and `:param{regex}` for a constrained one. */
|
|
103
127
|
path: string;
|
|
104
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";
|
|
105
133
|
/** One line: what the route does. */
|
|
106
134
|
summary: string;
|
|
107
135
|
/** The request body, as a type name or in short; absent when the route reads none. */
|
|
@@ -134,6 +162,38 @@ export const HTTP_ROUTES: readonly HttpRoute[] = [
|
|
|
134
162
|
stream: false,
|
|
135
163
|
adrs: ["0009"],
|
|
136
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
|
+
},
|
|
137
197
|
{
|
|
138
198
|
method: "GET",
|
|
139
199
|
path: "/workflows",
|
|
@@ -148,10 +208,11 @@ export const HTTP_ROUTES: readonly HttpRoute[] = [
|
|
|
148
208
|
method: "GET",
|
|
149
209
|
path: "/assembly",
|
|
150
210
|
band: "open",
|
|
151
|
-
summary:
|
|
152
|
-
|
|
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[] }",
|
|
153
214
|
stream: false,
|
|
154
|
-
adrs: ["0069"],
|
|
215
|
+
adrs: ["0017", "0069"],
|
|
155
216
|
},
|
|
156
217
|
{
|
|
157
218
|
method: "PUT",
|
|
@@ -161,7 +222,7 @@ export const HTTP_ROUTES: readonly HttpRoute[] = [
|
|
|
161
222
|
"Publish the next Assembly. `jr2 up` calls it, and nothing else. A refused Assembly leaves the current one current.",
|
|
162
223
|
body: "the Assembly as a tar stream, with the headers `jr2-kit-version` and `jr2-assembly-hash`",
|
|
163
224
|
response:
|
|
164
|
-
"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",
|
|
165
226
|
stream: false,
|
|
166
227
|
adrs: ["0069"],
|
|
167
228
|
},
|
|
@@ -196,12 +257,13 @@ export const HTTP_ROUTES: readonly HttpRoute[] = [
|
|
|
196
257
|
{
|
|
197
258
|
method: "POST",
|
|
198
259
|
path: "/workflows/:wrapper/charts/:name/runs",
|
|
199
|
-
band: "
|
|
260
|
+
band: "guarded",
|
|
261
|
+
scope: "start",
|
|
200
262
|
summary: "Start a run of a named Chart, as `POST /workflows/<wrapper>%2F<name>/runs` does.",
|
|
201
263
|
body: "the Chart wrapper's input, without the Chart",
|
|
202
264
|
response: "201 { runId, instanceId }; 409 when the named Chart cannot run now",
|
|
203
265
|
stream: false,
|
|
204
|
-
adrs: ["0070"],
|
|
266
|
+
adrs: ["0070", "0078"],
|
|
205
267
|
},
|
|
206
268
|
{
|
|
207
269
|
method: "GET",
|
|
@@ -221,6 +283,16 @@ export const HTTP_ROUTES: readonly HttpRoute[] = [
|
|
|
221
283
|
stream: false,
|
|
222
284
|
adrs: ["0032"],
|
|
223
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
|
+
},
|
|
224
296
|
{
|
|
225
297
|
method: "GET",
|
|
226
298
|
path: "/workflows/:name",
|
|
@@ -233,14 +305,15 @@ export const HTTP_ROUTES: readonly HttpRoute[] = [
|
|
|
233
305
|
{
|
|
234
306
|
method: "POST",
|
|
235
307
|
path: "/workflows/:name/runs",
|
|
236
|
-
band: "
|
|
308
|
+
band: "guarded",
|
|
309
|
+
scope: "start",
|
|
237
310
|
summary:
|
|
238
|
-
"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.",
|
|
239
312
|
body: "the workflow's input, checked against its declared input schema",
|
|
240
313
|
response:
|
|
241
|
-
"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",
|
|
242
315
|
stream: false,
|
|
243
|
-
adrs: ["0033", "0058", "0070", "0071"],
|
|
316
|
+
adrs: ["0017", "0033", "0058", "0070", "0071", "0078"],
|
|
244
317
|
},
|
|
245
318
|
{
|
|
246
319
|
method: "GET",
|
|
@@ -326,11 +399,14 @@ export const HTTP_ROUTES: readonly HttpRoute[] = [
|
|
|
326
399
|
{
|
|
327
400
|
method: "GET",
|
|
328
401
|
path: "/runs",
|
|
329
|
-
band: "
|
|
330
|
-
|
|
331
|
-
|
|
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? }",
|
|
332
408
|
stream: false,
|
|
333
|
-
adrs: ["0009", "0014"],
|
|
409
|
+
adrs: ["0009", "0014", "0078"],
|
|
334
410
|
},
|
|
335
411
|
{
|
|
336
412
|
method: "GET",
|
|
@@ -344,59 +420,135 @@ export const HTTP_ROUTES: readonly HttpRoute[] = [
|
|
|
344
420
|
{
|
|
345
421
|
method: "GET",
|
|
346
422
|
path: "/runs/resolve",
|
|
347
|
-
band: "
|
|
348
|
-
|
|
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.",
|
|
349
427
|
response: "{ prefix, runIds: string[], truncated }",
|
|
350
428
|
stream: false,
|
|
351
|
-
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"],
|
|
352
441
|
},
|
|
353
442
|
{
|
|
354
443
|
method: "GET",
|
|
355
444
|
path: "/runs/:runId",
|
|
356
|
-
band: "
|
|
357
|
-
|
|
358
|
-
|
|
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",
|
|
359
451
|
stream: false,
|
|
360
|
-
adrs: ["0009", "0011"],
|
|
452
|
+
adrs: ["0009", "0011", "0078"],
|
|
361
453
|
},
|
|
362
454
|
{
|
|
363
|
-
// `authenticated` in the code, and the handler refuses a Sandbox token itself (ADR-0013): the
|
|
364
|
-
// credential the route requires is the Instance token's, so this is its band.
|
|
365
455
|
method: "POST",
|
|
366
456
|
path: "/runs/:runId/gates/:gate/events",
|
|
367
|
-
band: "
|
|
368
|
-
|
|
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.",
|
|
369
461
|
body: "{ type, ...payload }, checked against the Gate's schema for that event",
|
|
370
462
|
response: "{ ok: true }; 409 { error, acceptsNow } when the run does not take it now",
|
|
371
463
|
stream: false,
|
|
372
|
-
adrs: ["0011", "0013"],
|
|
464
|
+
adrs: ["0011", "0013", "0078"],
|
|
373
465
|
},
|
|
374
466
|
{
|
|
375
467
|
method: "GET",
|
|
376
468
|
path: "/runs/:runId/events",
|
|
377
|
-
band: "
|
|
469
|
+
band: "guarded",
|
|
470
|
+
scope: "read",
|
|
378
471
|
summary: "One run's full activity until it settles; a settled run's final status, once.",
|
|
379
|
-
response:
|
|
472
|
+
response:
|
|
473
|
+
"SSE: status, emit, retry, admission, pick, placing, placed, child-error, looked, hook-refused, pool, effect, gone",
|
|
380
474
|
stream: true,
|
|
381
|
-
adrs: ["0022", "0023", "0064", "0067"],
|
|
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",
|
|
507
|
+
stream: true,
|
|
508
|
+
adrs: ["0023"],
|
|
382
509
|
},
|
|
383
510
|
{
|
|
384
511
|
method: "POST",
|
|
385
512
|
path: "/runs/:runId/events",
|
|
386
|
-
band: "
|
|
513
|
+
band: "guarded",
|
|
514
|
+
scope: "cancel",
|
|
387
515
|
summary: "Cancel a run: it ends, and its Workspaces go unless `keep` is true.",
|
|
388
516
|
body: '{ type: "CANCEL", keep?: boolean }',
|
|
389
517
|
response: "{ ok: true }",
|
|
390
518
|
stream: false,
|
|
391
|
-
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"],
|
|
392
544
|
},
|
|
393
545
|
{
|
|
394
546
|
method: "GET",
|
|
395
547
|
path: "/agents/:instanceId/surface",
|
|
396
548
|
band: "authenticated",
|
|
397
549
|
summary:
|
|
398
|
-
"An Agent's Menu, this Turn's Allowed picks
|
|
399
|
-
response: "AgentSurfaceView & { notices: Array<{ id, notice }> }",
|
|
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 }> }",
|
|
400
552
|
stream: false,
|
|
401
553
|
adrs: ["0013", "0029"],
|
|
402
554
|
},
|
|
@@ -423,12 +575,25 @@ export const HTTP_ROUTES: readonly HttpRoute[] = [
|
|
|
423
575
|
method: "POST",
|
|
424
576
|
path: "/agents/:instanceId/notices",
|
|
425
577
|
band: "authenticated",
|
|
426
|
-
summary:
|
|
578
|
+
summary:
|
|
579
|
+
"Acknowledge the notices and Steering messages a step boundary appended to the Turn: they are heard in its conversation.",
|
|
427
580
|
body: "{ heard: string[] }",
|
|
428
581
|
response: "{ heard: number }",
|
|
429
582
|
stream: false,
|
|
430
583
|
adrs: ["0062", "0076"],
|
|
431
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
|
+
},
|
|
432
597
|
{
|
|
433
598
|
method: "POST",
|
|
434
599
|
path: "/sandboxes/:name/fetch",
|
|
@@ -451,6 +616,17 @@ type RunEventBody = { type?: string; keep?: unknown };
|
|
|
451
616
|
* the same floor on the argument; this one guards the scan regardless of who is calling. */
|
|
452
617
|
const MIN_RUN_ID_PREFIX = 4;
|
|
453
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
|
+
|
|
454
630
|
/** How many ambiguous candidates are worth showing. Proving ambiguity takes 2; letting the caller
|
|
455
631
|
* PICK is the point, so the listing goes deeper before it truncates. */
|
|
456
632
|
const RESOLVE_LIMIT = 10;
|
|
@@ -515,6 +691,15 @@ function restorePendingMessage(what: string): string {
|
|
|
515
691
|
* Instance principal, so it reads no run, starts none, and speaks for no Agent. */
|
|
516
692
|
const PUBLISHER_ONLY = "the publish token publishes an Assembly (PUT /assembly) and does nothing else";
|
|
517
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
|
+
|
|
518
703
|
/** The app's context: `authenticated` resolves the bearer to a `principal` the routes read back. */
|
|
519
704
|
type JR2Env = { Variables: { principal: Principal } };
|
|
520
705
|
|
|
@@ -661,6 +846,28 @@ function closer(resolve: () => void) {
|
|
|
661
846
|
/** How often an otherwise-silent feed writes a ping. */
|
|
662
847
|
const PING_MS = 15_000;
|
|
663
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
|
+
|
|
664
871
|
/**
|
|
665
872
|
* Keep a quiet feed alive.
|
|
666
873
|
*
|
|
@@ -785,6 +992,9 @@ function subscriber(stream: SseStream): Subscriber {
|
|
|
785
992
|
export type CreateAppOptions = {
|
|
786
993
|
/** Ping interval for the SSE feeds. Tests shorten it; nothing in production sets it. */
|
|
787
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;
|
|
788
998
|
/**
|
|
789
999
|
* Whether this instance has a data plane at all (ADR-0051, ADR-0069): the process is deployed in
|
|
790
1000
|
* a cluster, so it wires its kube client whatever the current Assembly composes. `false` is an
|
|
@@ -800,8 +1010,10 @@ export type CreateAppOptions = {
|
|
|
800
1010
|
assembly?: AssemblyDoor;
|
|
801
1011
|
/**
|
|
802
1012
|
* The digests of the ConfigMaps this process reads through a mount (ADR-0069), as
|
|
803
|
-
* `readMountDigests` reads them
|
|
804
|
-
*
|
|
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.
|
|
805
1017
|
*/
|
|
806
1018
|
mounts?: () => Promise<Record<string, string>>;
|
|
807
1019
|
/**
|
|
@@ -829,6 +1041,20 @@ export type CreateAppOptions = {
|
|
|
829
1041
|
* that build an app straight over a host: those routes then answer 404.
|
|
830
1042
|
*/
|
|
831
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;
|
|
832
1058
|
};
|
|
833
1059
|
|
|
834
1060
|
/**
|
|
@@ -840,6 +1066,7 @@ export type CreateAppOptions = {
|
|
|
840
1066
|
*/
|
|
841
1067
|
export function createApp(host: RunHost, auth?: Authenticator, opts: CreateAppOptions = {}): Hono<JR2Env> {
|
|
842
1068
|
const pingMs = opts.pingMs ?? PING_MS;
|
|
1069
|
+
const followGraceMs = opts.followGraceMs ?? FOLLOW_GRACE_MS;
|
|
843
1070
|
const app = new Hono<JR2Env>();
|
|
844
1071
|
|
|
845
1072
|
/** Authenticate, or refuse. There is no anonymous principal (ADR-0013) — an open surface would
|
|
@@ -847,35 +1074,134 @@ export function createApp(host: RunHost, auth?: Authenticator, opts: CreateAppOp
|
|
|
847
1074
|
const authenticated: MiddlewareHandler<JR2Env> = async (c, next) => {
|
|
848
1075
|
if (!auth) return next(); // no authenticator configured: tests only (see the doc comment)
|
|
849
1076
|
// The slot a control request states (ADR-0076): a control token without one resolves to nothing.
|
|
850
|
-
const principal = auth(bearerOf(c), c.req.header(SLOT_HEADER));
|
|
1077
|
+
const principal = await auth(bearerOf(c), c.req.header(SLOT_HEADER));
|
|
851
1078
|
if (!principal) return c.json({ error: "unauthorized" }, 401);
|
|
852
1079
|
if (principal.kind === "publisher") return c.json({ error: PUBLISHER_ONLY }, 403);
|
|
853
1080
|
c.set("principal", principal);
|
|
854
1081
|
return next();
|
|
855
1082
|
};
|
|
856
1083
|
/** The principal `authenticated` resolved. An unconfigured `auth` means full trust. */
|
|
857
|
-
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
|
+
};
|
|
858
1169
|
|
|
859
1170
|
/**
|
|
860
|
-
* Authenticate, AND require the
|
|
861
|
-
*
|
|
862
|
-
*
|
|
863
|
-
* CANCEL any run. Neither is on the Agent's surface. Its scope is delivering to agent
|
|
864
|
-
* registrations recorded against its OWN Sandbox (ADR-0013), and the gate route already says so in
|
|
865
|
-
* 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.
|
|
866
1174
|
*/
|
|
867
1175
|
const instanceOnly: MiddlewareHandler<JR2Env> = async (c, next) => {
|
|
868
1176
|
if (!auth) return next(); // no authenticator configured: tests only (see the doc comment)
|
|
869
|
-
const principal = auth(bearerOf(c), c.req.header(SLOT_HEADER));
|
|
1177
|
+
const principal = await auth(bearerOf(c), c.req.header(SLOT_HEADER));
|
|
870
1178
|
if (!principal) return c.json({ error: "unauthorized" }, 401);
|
|
871
1179
|
if (principal.kind === "publisher") return c.json({ error: PUBLISHER_ONLY }, 403);
|
|
872
|
-
if (principal.kind !== "
|
|
873
|
-
|
|
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
|
+
);
|
|
874
1186
|
}
|
|
875
1187
|
c.set("principal", principal);
|
|
876
1188
|
return next();
|
|
877
1189
|
};
|
|
878
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
|
+
|
|
879
1205
|
// Liveness, and the one place an instance says WHAT IT IS. Unauthenticated because it is the
|
|
880
1206
|
// readiness probe's target and because identity is not run state — it is the same class of thing
|
|
881
1207
|
// as the route table, which is public by being served. The CLI probes it to explain a failure it
|
|
@@ -884,6 +1210,42 @@ export function createApp(host: RunHost, auth?: Authenticator, opts: CreateAppOp
|
|
|
884
1210
|
app.get("/healthz", (c) => c.json({ ok: true, version: KIT_VERSION, hash: opts.assembly?.current()?.hash }));
|
|
885
1211
|
app.get("/readyz", (c) => c.json({ ready: true }));
|
|
886
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
|
+
|
|
887
1249
|
// Structure, not state: the workflow listing, a template's Machine, and the Console shell are
|
|
888
1250
|
// unauthenticated. They expose no run, drive nothing, and the Console is a BROWSER page — it
|
|
889
1251
|
// loads before any token is entered (ADR-0032). Everything that reads or moves a run is guarded
|
|
@@ -897,10 +1259,15 @@ export function createApp(host: RunHost, auth?: Authenticator, opts: CreateAppOp
|
|
|
897
1259
|
|
|
898
1260
|
// The current Assembly and the ones live runs hold (ADR-0069). Open like `/workflows`: a hash
|
|
899
1261
|
// and a Kit version are identity, the class of thing `/healthz` already serves.
|
|
900
|
-
// `mounts` is what this process reads from its mounted ConfigMaps
|
|
901
|
-
// 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.
|
|
902
1265
|
app.get("/assembly", async (c) => {
|
|
903
|
-
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;
|
|
904
1271
|
return c.json(opts.mounts ? { ...view, mounts: await opts.mounts() } : view);
|
|
905
1272
|
});
|
|
906
1273
|
|
|
@@ -910,7 +1277,7 @@ export function createApp(host: RunHost, auth?: Authenticator, opts: CreateAppOp
|
|
|
910
1277
|
// its own status, and the old Assembly stays current through every refusal.
|
|
911
1278
|
app.put("/assembly", async (c) => {
|
|
912
1279
|
if (auth) {
|
|
913
|
-
const principal = auth(bearerOf(c));
|
|
1280
|
+
const principal = await auth(bearerOf(c));
|
|
914
1281
|
if (!principal) return c.json({ error: "unauthorized" }, 401);
|
|
915
1282
|
if (principal.kind !== "publisher") {
|
|
916
1283
|
return c.json(
|
|
@@ -929,6 +1296,12 @@ export function createApp(host: RunHost, auth?: Authenticator, opts: CreateAppOp
|
|
|
929
1296
|
hash: c.req.header("jr2-assembly-hash"),
|
|
930
1297
|
body: c.req.raw.body,
|
|
931
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
|
+
}
|
|
932
1305
|
return c.json(answer.body, answer.status);
|
|
933
1306
|
});
|
|
934
1307
|
|
|
@@ -997,37 +1370,81 @@ export function createApp(host: RunHost, auth?: Authenticator, opts: CreateAppOp
|
|
|
997
1370
|
*
|
|
998
1371
|
* A foreign `dispatch` sends `jr2-dispatched-by` (ADR-0071): its key makes a repeated start one
|
|
999
1372
|
* run, answered `200 { runId, instanceId, existing: true }` where a new one is `201`. The key is
|
|
1000
|
-
* asked first, so a re-send answers its run even when the name would be refused now
|
|
1001
|
-
*
|
|
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
|
|
1002
1376
|
* Console send none. Any throw that is not a refusal is a defect, 500: a foreign caller reads a
|
|
1003
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.
|
|
1004
1384
|
*/
|
|
1005
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;
|
|
1006
1389
|
const header = dispatchedByOf(c.req.header(DISPATCHED_BY_HEADER));
|
|
1007
1390
|
if ("error" in header) return c.json({ error: header.error }, 400);
|
|
1008
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
|
+
}
|
|
1009
1401
|
if (by) {
|
|
1010
|
-
const found = await host.dispatched(by
|
|
1011
|
-
if (found
|
|
1012
|
-
|
|
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);
|
|
1013
1406
|
}
|
|
1014
1407
|
const unrunnable = opts.charts?.get(name)?.unrunnable;
|
|
1015
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
|
+
}
|
|
1016
1428
|
const input = await readJson(c.req.text());
|
|
1017
1429
|
try {
|
|
1018
|
-
|
|
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
|
+
});
|
|
1019
1437
|
return existing ? c.json({ runId, instanceId, existing }, 200) : c.json({ runId, instanceId }, 201);
|
|
1020
1438
|
} catch (err) {
|
|
1021
1439
|
if (err instanceof ChartRefusal) return c.json({ error: "refused", report: err.report }, 422);
|
|
1022
1440
|
if (err instanceof EventValidationError) return c.json({ error: errMessage(err) }, 400);
|
|
1023
|
-
if (err instanceof DispatchKeyConflictError) return c.json({ error: errMessage(err) }, 409);
|
|
1024
1441
|
if (err instanceof UnknownWorkflowError) return c.json({ error: errMessage(err) }, 404);
|
|
1025
1442
|
return c.json({ error: errMessage(err) }, 500);
|
|
1026
1443
|
}
|
|
1027
1444
|
};
|
|
1028
1445
|
|
|
1029
1446
|
// The documented start of a named Chart; `POST /workflows/<wrapper>%2F<name>/runs` is the same.
|
|
1030
|
-
app.post("/workflows/:wrapper/charts/:name/runs",
|
|
1447
|
+
app.post("/workflows/:wrapper/charts/:name/runs", guarded("start"), (c) =>
|
|
1031
1448
|
startRun(c, chartName(c.req.param("wrapper"), c.req.param("name"))),
|
|
1032
1449
|
);
|
|
1033
1450
|
|
|
@@ -1070,6 +1487,11 @@ export function createApp(host: RunHost, auth?: Authenticator, opts: CreateAppOp
|
|
|
1070
1487
|
if (prefersHtml(c)) return consoleAsset("page.html", "text/html; charset=utf-8");
|
|
1071
1488
|
return c.json({ error: "no JSON resource at / — the Console is the HTML dialect" }, 404);
|
|
1072
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?.() ?? [] }));
|
|
1073
1495
|
app.get("/workflows/:name", (c) => {
|
|
1074
1496
|
// The shell for ANY name — an unknown workflow surfaces in-page via its 404'd /machine fetch.
|
|
1075
1497
|
if (prefersHtml(c)) return consoleAsset("page.html", "text/html; charset=utf-8");
|
|
@@ -1098,7 +1520,7 @@ export function createApp(host: RunHost, auth?: Authenticator, opts: CreateAppOp
|
|
|
1098
1520
|
// allowlist not forwarding this route is a second layer, not the control (ADR-0059).
|
|
1099
1521
|
// A Chart wrapper's one-shot door (ADR-0070): a Chart field in the body is admitted before the
|
|
1100
1522
|
// run starts, and a refusal is 422 with the report. A named Chart rides the path encoded.
|
|
1101
|
-
app.post("/workflows/:name/runs",
|
|
1523
|
+
app.post("/workflows/:name/runs", guarded("start"), (c) => startRun(c, c.req.param("name")));
|
|
1102
1524
|
|
|
1103
1525
|
// The registered template Machine's structure — what the Console's diagram renders (structure
|
|
1104
1526
|
// is provider-independent, so the un-`provide()`d template is exactly right).
|
|
@@ -1190,6 +1612,13 @@ export function createApp(host: RunHost, auth?: Authenticator, opts: CreateAppOp
|
|
|
1190
1612
|
// A child's error below an Agent's state (ADR-0024) is mechanism text, like `fault`: behind the
|
|
1191
1613
|
// token.
|
|
1192
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;
|
|
1193
1622
|
// An Effect's lines (ADR-0067) carry argv and output — working data: behind the token.
|
|
1194
1623
|
if (ev.kind === "effect") return undefined;
|
|
1195
1624
|
return sseFrame("status", observe(ev.status));
|
|
@@ -1257,7 +1686,30 @@ export function createApp(host: RunHost, auth?: Authenticator, opts: CreateAppOp
|
|
|
1257
1686
|
});
|
|
1258
1687
|
});
|
|
1259
1688
|
|
|
1260
|
-
|
|
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
|
+
});
|
|
1261
1713
|
|
|
1262
1714
|
// The Repos as the cluster currently reports them (ADR-0048/0051) — what `jr2 status` renders,
|
|
1263
1715
|
// and the place ADR-0047's "register the key, the cache agent retries" points at. Instance band,
|
|
@@ -1279,13 +1731,24 @@ export function createApp(host: RunHost, auth?: Authenticator, opts: CreateAppOp
|
|
|
1279
1731
|
// ambiguous hash, and it holds down what this newly reveals — settled run ids are now visible to
|
|
1280
1732
|
// an Instance-token holder, which `GET /runs`'s deferred `?all` had withheld. Deliberately not on
|
|
1281
1733
|
// the open observation routes: prefix probing there would be a run-id enumeration oracle.
|
|
1282
|
-
|
|
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) => {
|
|
1283
1739
|
const prefix = c.req.query("prefix") ?? "";
|
|
1284
1740
|
if (prefix.length < MIN_RUN_ID_PREFIX) {
|
|
1285
1741
|
return c.json({ error: `prefix must be at least ${MIN_RUN_ID_PREFIX} characters` }, 400);
|
|
1286
1742
|
}
|
|
1743
|
+
const caller = callerOf(c);
|
|
1744
|
+
const everyWorkflow = Object.values(caller.may).some((grant) => grant === "all");
|
|
1287
1745
|
// Scan one past the cap so `truncated` is knowable without a second COUNT.
|
|
1288
|
-
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
|
+
});
|
|
1289
1752
|
return c.json({
|
|
1290
1753
|
prefix,
|
|
1291
1754
|
runIds: found.slice(0, RESOLVE_LIMIT),
|
|
@@ -1293,39 +1756,76 @@ export function createApp(host: RunHost, auth?: Authenticator, opts: CreateAppOp
|
|
|
1293
1756
|
});
|
|
1294
1757
|
});
|
|
1295
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
|
+
|
|
1296
1769
|
// Read-through (ADR-0009): a completed run's final status lives in the store after the registry
|
|
1297
1770
|
// drops it, so this serves terminal runs too — only a genuinely unknown run is a 404. The status
|
|
1298
1771
|
// carries the run's OPEN GATES (ADR-0011) — the discovery listing external callers act on
|
|
1299
1772
|
// (`jr2 send` menus, UI inbox cards, webhook translators matching on meta). Settled run → [].
|
|
1300
|
-
|
|
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) => {
|
|
1301
1778
|
const runId = c.req.param("runId");
|
|
1302
|
-
const
|
|
1303
|
-
|
|
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 });
|
|
1304
1793
|
});
|
|
1305
1794
|
|
|
1306
1795
|
// Gates delivery (ADR-0011): validate the body against the gate's named schema and deliver into
|
|
1307
1796
|
// the gated state. Unknown gate (never opened, state exited, run settled) → 404; a name the gate
|
|
1308
1797
|
// doesn't accept, or a payload failing its schema → 400 naming what IS accepted.
|
|
1309
1798
|
//
|
|
1310
|
-
// A Gate is a HUMAN's decision (or a
|
|
1311
|
-
// token or a fetch token — is refused
|
|
1312
|
-
//
|
|
1313
|
-
|
|
1314
|
-
|
|
1315
|
-
|
|
1316
|
-
|
|
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).
|
|
1317
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
|
+
}
|
|
1318
1816
|
try {
|
|
1319
|
-
|
|
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);
|
|
1320
1821
|
return c.json({ ok: true });
|
|
1321
1822
|
} catch (err) {
|
|
1823
|
+
if (err instanceof GateRefused) return mayAnswer(c, live!.workflow, err.gate)!;
|
|
1322
1824
|
if (err instanceof UnknownAddressError) return c.json({ error: errMessage(err) }, 404);
|
|
1323
1825
|
if (err instanceof EventValidationError) return c.json({ error: errMessage(err) }, 400);
|
|
1324
1826
|
// The run would not take it from this Gate now (ADR-0013): never `{ ok: true }`. The body
|
|
1325
1827
|
// names what the Gate takes now, as `GET /runs/:id` lists it.
|
|
1326
1828
|
if (err instanceof NotTakenError) {
|
|
1327
|
-
const runId = c.req.param("runId");
|
|
1328
|
-
const gate = c.req.param("gate");
|
|
1329
1829
|
const acceptsNow = host.gates(runId).find((g) => g.gate === gate)?.acceptsNow ?? [];
|
|
1330
1830
|
return c.json({ error: errMessage(err), acceptsNow }, 409);
|
|
1331
1831
|
}
|
|
@@ -1348,6 +1848,12 @@ export function createApp(host: RunHost, auth?: Authenticator, opts: CreateAppOp
|
|
|
1348
1848
|
// A child's error below an Agent's state, which the Agent's answer does not carry (ADR-0024):
|
|
1349
1849
|
// the operator reads it here, with its message.
|
|
1350
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);
|
|
1351
1857
|
// An Effect's lines (ADR-0067): its start, its stream, its output, an abort — Instance-token band.
|
|
1352
1858
|
if (ev.kind === "effect") return sseFrame("effect", ev);
|
|
1353
1859
|
return sseFrame("status", ev.status);
|
|
@@ -1355,12 +1861,19 @@ export function createApp(host: RunHost, auth?: Authenticator, opts: CreateAppOp
|
|
|
1355
1861
|
|
|
1356
1862
|
// SSE: a live run streams its status deltas + author `emit`s (current status replayed on attach,
|
|
1357
1863
|
// then live until the terminal transition or client abort). A run that has already settled streams
|
|
1358
|
-
// its final status once and closes (so `jr2
|
|
1359
|
-
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) => {
|
|
1360
1866
|
const runId = c.req.param("runId");
|
|
1361
|
-
|
|
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) {
|
|
1362
1873
|
const finalStatus = await host.read(runId);
|
|
1363
1874
|
if (!finalStatus) return c.json({ error: `no run "${runId}"` }, 404);
|
|
1875
|
+
const refused = mayFor(c, "read", finalStatus.workflow);
|
|
1876
|
+
if (refused) return refused;
|
|
1364
1877
|
return streamSSE(c, async (stream) => {
|
|
1365
1878
|
await stream.writeSSE({ event: "status", data: JSON.stringify(finalStatus) });
|
|
1366
1879
|
await sayGoneIfParked(stream, finalStatus);
|
|
@@ -1408,8 +1921,13 @@ export function createApp(host: RunHost, auth?: Authenticator, opts: CreateAppOp
|
|
|
1408
1921
|
// It ENDS the run (ADR-0025): the Agents' turns end with it, the run does not come back on the
|
|
1409
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
|
|
1410
1923
|
// deliberately not on the wire.
|
|
1411
|
-
app.post("/runs/:runId/events",
|
|
1924
|
+
app.post("/runs/:runId/events", guarded("cancel"), async (c) => {
|
|
1412
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
|
+
}
|
|
1413
1931
|
const body = (await readJson(c.req.text())) as RunEventBody;
|
|
1414
1932
|
if (body.type !== "CANCEL") {
|
|
1415
1933
|
return c.json({ error: `unknown event type "${body.type ?? ""}" (accepts: CANCEL)` }, 400);
|
|
@@ -1419,13 +1937,202 @@ export function createApp(host: RunHost, auth?: Authenticator, opts: CreateAppOp
|
|
|
1419
1937
|
return c.json({ error: `"keep" must be a boolean` }, 400);
|
|
1420
1938
|
}
|
|
1421
1939
|
try {
|
|
1422
|
-
await host.cancel(runId, { keep: body.keep === true });
|
|
1940
|
+
await host.cancel(runId, { keep: body.keep === true, by: callerRecord(callerOf(c)) });
|
|
1423
1941
|
return c.json({ ok: true });
|
|
1424
1942
|
} catch (err) {
|
|
1425
1943
|
return c.json({ error: errMessage(err) }, 404);
|
|
1426
1944
|
}
|
|
1427
1945
|
});
|
|
1428
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
|
+
|
|
1429
2136
|
// ---- The Agent's surface (`/agents/:iid/*` — ADR-0013) --------------------------------------
|
|
1430
2137
|
// Served to ONE caller: the Agent's Harness, through the Custodian in its pod. The Harness renders
|
|
1431
2138
|
// `surface` as the Menu and turns a pick into an `events` POST. The Orchestrator therefore keeps
|
|
@@ -1482,8 +2189,12 @@ export function createApp(host: RunHost, auth?: Authenticator, opts: CreateAppOp
|
|
|
1482
2189
|
const { surface, error } = agentRegistration(c);
|
|
1483
2190
|
if (error) return error;
|
|
1484
2191
|
host.agentSurfaceServed(c.req.param("instanceId"));
|
|
1485
|
-
|
|
1486
|
-
|
|
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 } : {}) });
|
|
1487
2198
|
});
|
|
1488
2199
|
|
|
1489
2200
|
// The Harness appended a step boundary's notices to the Turn (ADR-0062, ADR-0076): they are heard
|
|
@@ -1500,6 +2211,56 @@ export function createApp(host: RunHost, auth?: Authenticator, opts: CreateAppOp
|
|
|
1500
2211
|
return c.json({ heard: host.noticesHeard(c.req.param("instanceId"), heard as string[]) });
|
|
1501
2212
|
});
|
|
1502
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));
|
|
2262
|
+
});
|
|
2263
|
+
|
|
1503
2264
|
// The Agent's pick, delivered into the state that invoked it. The receipt describes itself
|
|
1504
2265
|
// (ADR-0024): what was delivered, and whether that ended the turn — which the Harness renders as
|
|
1505
2266
|
// prose. Its `deliveryId` makes an outstanding pick addressable after the fact: its answer is
|
|
@@ -1599,3 +2360,37 @@ export function createApp(host: RunHost, auth?: Authenticator, opts: CreateAppOp
|
|
|
1599
2360
|
|
|
1600
2361
|
return app;
|
|
1601
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
|
+
}
|