@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/config.ts
CHANGED
|
@@ -20,8 +20,11 @@ import { readFile } from "node:fs/promises";
|
|
|
20
20
|
import { join } from "node:path";
|
|
21
21
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
22
22
|
import { z } from "zod";
|
|
23
|
+
import type { Hook } from "./hook-kinds/index.ts";
|
|
24
|
+
import { hookParts, isHook, kindsById } from "./hook-kinds/door.ts";
|
|
23
25
|
import { repoIdentity } from "./repo-identity.ts";
|
|
24
26
|
import { assertSize, type Size } from "./size.ts";
|
|
27
|
+
import { periodMs } from "./duration.ts";
|
|
25
28
|
|
|
26
29
|
// ------------------------------------------------------------------------------------------------
|
|
27
30
|
// `git.credentials` (ADR-0051): how the cluster authenticates to a Repo, and the fence.
|
|
@@ -360,6 +363,68 @@ export type SecretEntry = {
|
|
|
360
363
|
paths?: readonly string[];
|
|
361
364
|
};
|
|
362
365
|
|
|
366
|
+
// One MCP server of the Instance (ADR-0079), by the author's name. A url and a secret are facts of
|
|
367
|
+
// the Instance, never of an Agent's definition, so a packaged Machine names a server and the
|
|
368
|
+
// Instance declares it. The name is the author's: two servers of one service with two secrets are
|
|
369
|
+
// two names.
|
|
370
|
+
/**
|
|
371
|
+
* A remote MCP server, which the Harness reaches over Streamable HTTP. An Agent that names it in its
|
|
372
|
+
* `mcp` list gets the tools that its `tools` allowlist names.
|
|
373
|
+
*
|
|
374
|
+
* A server with a `secret` needs an `https://` url, because the Custodian puts the value on TLS
|
|
375
|
+
* only: `mcp.<name>.url must be an https:// URL`. A server with no secret can use `http://`. The
|
|
376
|
+
* Harness sends `Authorization: Bearer <Stand-in>`, and the Custodian of each pod whose Agents name
|
|
377
|
+
* the server puts the value in its place. A `secret` that the `secrets` list does not hold is
|
|
378
|
+
* refused when the config loads: `<file> at mcp.<name>.secret: "<NAME>" is not an entry of secrets`.
|
|
379
|
+
* Past `timeout`, the Harness cancels the call, and the model reads jr2's text.
|
|
380
|
+
*
|
|
381
|
+
* @example
|
|
382
|
+
* ```ts
|
|
383
|
+
* import { defineConfig } from "@jr2/orchestrator";
|
|
384
|
+
*
|
|
385
|
+
* export default defineConfig({
|
|
386
|
+
* secrets: [{ name: "SLACK_ACME_TOKEN", value: process.env.SLACK_ACME_TOKEN }],
|
|
387
|
+
* mcp: { "slack-acme": { url: "https://mcp.slack.example/mcp", secret: "SLACK_ACME_TOKEN" } },
|
|
388
|
+
* });
|
|
389
|
+
* ```
|
|
390
|
+
*/
|
|
391
|
+
export type McpRemoteServer = {
|
|
392
|
+
/** Its Streamable HTTP endpoint: `https://` when it has a `secret`. */
|
|
393
|
+
url: string;
|
|
394
|
+
/** A name of the `secrets` list: its bearer token. With no `hosts`, that entry is bound to the
|
|
395
|
+
* url's host, in `authorization`. Default: none. */
|
|
396
|
+
secret?: string;
|
|
397
|
+
/** How long one call can run, such as `"90s"`. Default `"60s"`. */
|
|
398
|
+
timeout?: `${number}s` | `${number}m` | `${number}h`;
|
|
399
|
+
};
|
|
400
|
+
|
|
401
|
+
/**
|
|
402
|
+
* A stdio MCP server: a program of the Sandbox Image that the Harness starts for each Agent
|
|
403
|
+
* conversation, through jr2's spawner, inside the Agent's confinement. It is Workspace code: its env
|
|
404
|
+
* holds only the Stand-ins that the `workspace()` grants, so it has no `secret` of its own. A
|
|
405
|
+
* `"none"` Agent cannot name one, because its Turns run on the Instance Harness, where no Workspace
|
|
406
|
+
* code runs.
|
|
407
|
+
*
|
|
408
|
+
* @example
|
|
409
|
+
* ```ts
|
|
410
|
+
* import { defineConfig } from "@jr2/orchestrator";
|
|
411
|
+
*
|
|
412
|
+
* export default defineConfig({ mcp: { notes: { command: "notes-mcp", args: ["--root", "/work"] } } });
|
|
413
|
+
* ```
|
|
414
|
+
*/
|
|
415
|
+
export type McpStdioServer = {
|
|
416
|
+
/** A program of the Sandbox Image. */
|
|
417
|
+
command: string;
|
|
418
|
+
/** Its arguments. */
|
|
419
|
+
args?: readonly string[];
|
|
420
|
+
/** How long one call can run, such as `"90s"`. Default `"60s"`. */
|
|
421
|
+
timeout?: `${number}s` | `${number}m` | `${number}h`;
|
|
422
|
+
};
|
|
423
|
+
|
|
424
|
+
/** One MCP server of the Instance: a {@link McpRemoteServer} (`url`) or a {@link McpStdioServer}
|
|
425
|
+
* (`command`). */
|
|
426
|
+
export type McpServer = McpRemoteServer | McpStdioServer;
|
|
427
|
+
|
|
363
428
|
// The agent-runtime section (ADR-0018): what the stock Harness image consumes alongside the Agent
|
|
364
429
|
// definition each Turn hands it (ADR-0049). Moved out of `sandbox` deliberately — `sandbox` is pod
|
|
365
430
|
// transport (it still CARRIES this env to the Harness container), but model concerns are Harness
|
|
@@ -489,6 +554,184 @@ export type PriorityClasses = {
|
|
|
489
554
|
sandbox?: string;
|
|
490
555
|
};
|
|
491
556
|
|
|
557
|
+
/**
|
|
558
|
+
* The Workflows that one scope of a Caller opens (ADR-0078): `"all"` for every Workflow, or a list
|
|
559
|
+
* of Workflow names. A Chart wrapper's name in the list also opens each named Chart of that wrapper
|
|
560
|
+
* (`plan` opens `plan/weekly`), because a named Chart runs its wrapper's Machine. A named Chart's
|
|
561
|
+
* own name (`plan/weekly`) opens that Chart alone.
|
|
562
|
+
*/
|
|
563
|
+
export type ScopeGrant = "all" | readonly string[];
|
|
564
|
+
|
|
565
|
+
/**
|
|
566
|
+
* What a Caller may do: five scopes, each one for the Workflows that it names (ADR-0078). A scope
|
|
567
|
+
* that is absent opens nothing. Which scope opens which route is in the HTTP API Reference.
|
|
568
|
+
*
|
|
569
|
+
* A Caller's scope does not limit what its run dispatches: a Caller with `start` on `triage` starts
|
|
570
|
+
* a run whose Machine can dispatch `deploy`. The Machine is the author's, and it is the boundary of
|
|
571
|
+
* what its run does.
|
|
572
|
+
*/
|
|
573
|
+
export type Scopes = {
|
|
574
|
+
/** Start a run. */
|
|
575
|
+
start?: ScopeGrant;
|
|
576
|
+
/** Answer a Gate, and read a run's open Gates without its context. A list item
|
|
577
|
+
* `{ workflow, gates }` opens only the Gates that it names, by invoke id (see {@link GateGrant}). */
|
|
578
|
+
answer?: AnswerGrant;
|
|
579
|
+
/** Cancel a run. */
|
|
580
|
+
cancel?: ScopeGrant;
|
|
581
|
+
/** Read a run's state: its status, its context and its feed. */
|
|
582
|
+
read?: ScopeGrant;
|
|
583
|
+
/** Send a Steering message to an Agent of a run. */
|
|
584
|
+
steer?: ScopeGrant;
|
|
585
|
+
};
|
|
586
|
+
|
|
587
|
+
/**
|
|
588
|
+
* The Gates that the `answer` scope opens (ADR-0078): `"all"`, or a list in which each item is a
|
|
589
|
+
* Workflow name, which opens every Gate of that Workflow, or a {@link GateGrant}, which opens only
|
|
590
|
+
* the Gates that it names: `["triage", { workflow: "deploy", gates: ["approve"] }]`.
|
|
591
|
+
*/
|
|
592
|
+
export type AnswerGrant = "all" | readonly (string | GateGrant)[];
|
|
593
|
+
|
|
594
|
+
/**
|
|
595
|
+
* Some Gates of one Workflow, in the `answer` scope (ADR-0078). A Gate's name is its invoke id: the
|
|
596
|
+
* static name that it has in the Machine that invokes it, and the id that its events carry as their
|
|
597
|
+
* sender (ADR-0011). That is the invoke `id` that the author wrote, or else the key path of its
|
|
598
|
+
* gated state below the root of that Machine (`body.humanReview`). One name opens every Gate of the
|
|
599
|
+
* Workflow with that id, in each child of a Pool too.
|
|
600
|
+
*
|
|
601
|
+
* `jr2 up` refuses an item for a Workflow that the Assembly does not have, a name that no Machine
|
|
602
|
+
* of the Workflow has, and a name that Gates in more than one Machine of the Workflow have. A Chart
|
|
603
|
+
* wrapper's item (`{ workflow: "plan", gates }`) opens those Gates in each Chart of `plan` too, and
|
|
604
|
+
* `jr2 up` checks its names against the wrapper's Machines and its Chart files. A runtime Chart is
|
|
605
|
+
* not in the Assembly, so `jr2 up` cannot check its Gates: a name that no Machine and no Chart file
|
|
606
|
+
* of the wrapper has, and an item for a runtime Chart (`plan/weekly` with no Chart file), are
|
|
607
|
+
* warnings, not refusals.
|
|
608
|
+
*/
|
|
609
|
+
export type GateGrant = {
|
|
610
|
+
/** The Workflow, as a bare name in the list names it. */
|
|
611
|
+
workflow: string;
|
|
612
|
+
/** The Gates' names. */
|
|
613
|
+
gates: readonly string[];
|
|
614
|
+
};
|
|
615
|
+
|
|
616
|
+
/** The name of one scope of a Caller (ADR-0078). */
|
|
617
|
+
export type Scope = keyof Scopes;
|
|
618
|
+
|
|
619
|
+
/** Every scope, in the order a reader meets them. */
|
|
620
|
+
export const SCOPES: readonly Scope[] = ["start", "answer", "cancel", "read", "steer"];
|
|
621
|
+
|
|
622
|
+
/**
|
|
623
|
+
* One named Caller token (ADR-0078). `jr2 up` makes its value once, in a Secret of its own, and
|
|
624
|
+
* gives the Orchestrator only the sha-256 of the value. `jr2 caller token <name>` prints the value,
|
|
625
|
+
* and `jr2 caller rotate <name>` makes a new one.
|
|
626
|
+
*/
|
|
627
|
+
export type CallerToken = {
|
|
628
|
+
/** What the token may do. See {@link Scopes}. */
|
|
629
|
+
may: Scopes;
|
|
630
|
+
};
|
|
631
|
+
|
|
632
|
+
/**
|
|
633
|
+
* The Callers of an Instance that do not hold the Instance token (ADR-0078). The CLI on a kube
|
|
634
|
+
* context is the Caller `token/instance`, which needs no entry. When the config loads, a bad entry
|
|
635
|
+
* is refused: `<file> at callers<path>: <problem>`, followed by the shape of the block.
|
|
636
|
+
*/
|
|
637
|
+
export type CallersConfig = {
|
|
638
|
+
// The name is part of a Secret's name (`jr2 up`), so it is a DNS label. `instance` is the
|
|
639
|
+
// Instance token's Caller name, so no named token takes it.
|
|
640
|
+
/** The named Caller tokens, by name. A name is lowercase letters, digits and `-`, at most 40
|
|
641
|
+
* characters, from a letter to a letter or digit. The config load refuses `instance`, the
|
|
642
|
+
* Instance token's name. A name that the config no longer declares stops working at the next
|
|
643
|
+
* `jr2 up`. */
|
|
644
|
+
tokens?: Readonly<Record<string, CallerToken>>;
|
|
645
|
+
/** The identity providers whose JWTs are Callers. See {@link JwtIssuer}. Two entries with one
|
|
646
|
+
* `issuer` are refused. */
|
|
647
|
+
issuers?: readonly JwtIssuer[];
|
|
648
|
+
};
|
|
649
|
+
|
|
650
|
+
/**
|
|
651
|
+
* One identity provider whose JWTs are Callers (ADR-0078). For each bearer that is a JWT with this
|
|
652
|
+
* `issuer` in its `iss` claim, jr2 checks the signature against the issuer's published keys, and the
|
|
653
|
+
* issuer, the audience and the expiry. A JWT that fails a check is 401.
|
|
654
|
+
*
|
|
655
|
+
* The Caller's name is the `sub` claim, and the name that jr2 shows is the `email` claim; `claims`
|
|
656
|
+
* names other claims. Its scopes come from the values of one claim, a list of groups or roles,
|
|
657
|
+
* through `groups`: each value that `groups` names adds its scopes. A JWT that gets no scope is 403
|
|
658
|
+
* on every route that needs a scope. jr2 never treats it as read-only.
|
|
659
|
+
*
|
|
660
|
+
* The Orchestrator fetches the keys from the issuer: this is its one call out of the Instance, and
|
|
661
|
+
* it spends no secret (ADR-0068). It keeps the keys for 10 minutes, and fetches them again at once
|
|
662
|
+
* for a JWT whose key it does not know, at most once each 30 seconds, so a key that the issuer
|
|
663
|
+
* rotated works with no restart.
|
|
664
|
+
*/
|
|
665
|
+
export type JwtIssuer = {
|
|
666
|
+
/** The issuer's URL, exactly as the `iss` claim gives it, such as `https://dex.example.com`. */
|
|
667
|
+
issuer: string;
|
|
668
|
+
/** The audience that a JWT must name in its `aud` claim: for a login, the client id. */
|
|
669
|
+
audience: string;
|
|
670
|
+
/** The URL of the issuer's public keys (a JWKS). Default: the `jwks_uri` of the issuer's
|
|
671
|
+
* `/.well-known/openid-configuration`. */
|
|
672
|
+
jwks?: string;
|
|
673
|
+
/** The claims to read. Default: `{ name: "sub", shown: "email", groups: "groups" }`. */
|
|
674
|
+
claims?: {
|
|
675
|
+
/** The claim that is the Caller's name. Use a claim that the provider never reassigns. */
|
|
676
|
+
name?: string;
|
|
677
|
+
/** The claim that is the name that jr2 shows. */
|
|
678
|
+
shown?: string;
|
|
679
|
+
/** The claim that holds the list of groups or roles that `groups` maps to scopes. */
|
|
680
|
+
groups?: string;
|
|
681
|
+
};
|
|
682
|
+
/** The scopes that each value of the groups claim gives, such as
|
|
683
|
+
* `{ "jr2-ops": { start: "all", answer: "all" } }`. A JWT with more than one value gets the scopes
|
|
684
|
+
* of each, joined. A value that is not here gives nothing. */
|
|
685
|
+
groups: Readonly<Record<string, Scopes>>;
|
|
686
|
+
/** The Console offers a login against this issuer (ADR-0032): OIDC, the authorization code flow
|
|
687
|
+
* with PKCE, as a public client, with `audience` as the client id. Register the client as public
|
|
688
|
+
* (no secret), with the Console's root (`https://<console>/`) as its redirect URI. The Console
|
|
689
|
+
* reads the issuer and the client id before the login, from the open route
|
|
690
|
+
* `GET /console/config`: both are public values. A login needs the Console on https or on
|
|
691
|
+
* localhost, and the issuer must let the Console's origin call its discovery document and its
|
|
692
|
+
* token endpoint (CORS). Default: `false`, because an issuer for machines (a CI provider's
|
|
693
|
+
* tokens) has no login page. */
|
|
694
|
+
login?: boolean;
|
|
695
|
+
};
|
|
696
|
+
|
|
697
|
+
/**
|
|
698
|
+
* How long jr2 keeps an ended run, and the volume that keeps it (ADR-0065). When the config loads,
|
|
699
|
+
* a bad field is refused: `<file> at runs.<field>: <problem>`.
|
|
700
|
+
*
|
|
701
|
+
* @example
|
|
702
|
+
* ```ts
|
|
703
|
+
* import { defineConfig } from "@jr2/orchestrator";
|
|
704
|
+
*
|
|
705
|
+
* export default defineConfig({ runs: { retain: "90d", volume: "20Gi" } });
|
|
706
|
+
* ```
|
|
707
|
+
*/
|
|
708
|
+
export type RunsConfig = {
|
|
709
|
+
/** How long jr2 keeps an ended run: its row, its conversations and the hook log. A number and
|
|
710
|
+
* `m`, `h`, `d` or `w`, such as `"90d"`. At least `"1h"`, with no upper limit. A live run is
|
|
711
|
+
* never removed. Default: `"30d"`. */
|
|
712
|
+
retain?: `${number}${"m" | "h" | "d" | "w"}`;
|
|
713
|
+
/** The size of the Orchestrator's state volume claim: a number and a unit, such as `"5Gi"` or
|
|
714
|
+
* `"500Mi"` (a number with no unit is refused). Default: `"5Gi"` for a
|
|
715
|
+
* new claim; unset, `jr2 up` keeps the size of a claim that exists. A larger size grows the
|
|
716
|
+
* claim where its StorageClass allows expansion, else `jr2 up` refuses it. A smaller size is
|
|
717
|
+
* refused: Kubernetes never makes a claim smaller. */
|
|
718
|
+
volume?: string;
|
|
719
|
+
};
|
|
720
|
+
|
|
721
|
+
/** `runs.retain` when the config sets none (ADR-0065). */
|
|
722
|
+
export const RUNS_RETAIN_DEFAULT = "30d";
|
|
723
|
+
/** The shortest `runs.retain` (ADR-0065). */
|
|
724
|
+
export const RUNS_RETAIN_MIN_MS = 3_600_000;
|
|
725
|
+
/** The size of a new state volume claim when the config sets no `runs.volume` (ADR-0065). */
|
|
726
|
+
export const STATE_VOLUME_DEFAULT = "5Gi";
|
|
727
|
+
|
|
728
|
+
/** How long an ended run is kept, in milliseconds: `runs.retain`, or the default. The config load
|
|
729
|
+
* refuses a bad value, so here a bad one reads as the default. */
|
|
730
|
+
export function retainMs(runs: RunsConfig | undefined): number {
|
|
731
|
+
const ms = runs?.retain === undefined ? undefined : periodMs(runs.retain);
|
|
732
|
+
return ms !== undefined && ms >= RUNS_RETAIN_MIN_MS ? ms : periodMs(RUNS_RETAIN_DEFAULT)!;
|
|
733
|
+
}
|
|
734
|
+
|
|
492
735
|
/**
|
|
493
736
|
* The shape of `jr2.config.ts`: the deployment facts of an Instance, which a Machine cannot carry.
|
|
494
737
|
* It holds the Instance's name, its secrets, how the cluster authenticates to Repos, what the
|
|
@@ -498,7 +741,7 @@ export type PriorityClasses = {
|
|
|
498
741
|
* Every field is optional, and an Instance can run with no `jr2.config.ts`. Write the file as
|
|
499
742
|
* `export default defineConfig({ … })`. When the config loads, a file with no default export is
|
|
500
743
|
* refused: `` <file> has no default export (use `export default defineConfig({…})`) ``. The load also
|
|
501
|
-
* checks the shape of `git.credentials`, `secrets`, `harness.catalog` and `
|
|
744
|
+
* checks the shape of `git.credentials`, `secrets`, `harness.catalog`, `priorityClasses` and `mcp`. It
|
|
502
745
|
* checks that `sandbox` is an object, and checks its `resources`. Each refusal names the field.
|
|
503
746
|
* `jr2 up` checks the rest.
|
|
504
747
|
*
|
|
@@ -554,6 +797,37 @@ export type JR2Config = {
|
|
|
554
797
|
/** Existing PriorityClasses to use in place of the two that `jr2 up` creates. See
|
|
555
798
|
* {@link PriorityClasses}. */
|
|
556
799
|
priorityClasses?: PriorityClasses;
|
|
800
|
+
// See `CallersConfig` (ADR-0078).
|
|
801
|
+
/** The Callers that act on the Instance with a credential of their own, and what each may do.
|
|
802
|
+
* Default: none; only the Instance token acts. See {@link CallersConfig}. */
|
|
803
|
+
callers?: CallersConfig;
|
|
804
|
+
// An allowlist, never a pattern (ADR-0078): the Console needs no entry.
|
|
805
|
+
/** The origins from which a browser app may call the Instance's HTTP API, such as
|
|
806
|
+
* `https://ops.example.com`. Each entry is an origin alone: a scheme, a host and an optional port,
|
|
807
|
+
* with no path. Default: none. The Console is served from the Orchestrator's own origin, so it
|
|
808
|
+
* needs no entry. The config load refuses an entry that is not an origin:
|
|
809
|
+
* `<file> at cors[<i>]: <entry> is not an origin — scheme://host[:port], with no path`. */
|
|
810
|
+
cors?: readonly string[];
|
|
811
|
+
// A Hook is a Caller with `answer` alone (ADR-0078); its door is `POST /hooks/:name` (ADR-0011).
|
|
812
|
+
/** The Hooks of the Instance, by name: each one is a door, `POST /hooks/<name>`, for one outside
|
|
813
|
+
* service. A Hook's value comes from its kind's constructor, which the config imports:
|
|
814
|
+
* `github({ secret: secret("GH_HOOK") })`, `gitlab({ secret: secret("GL_HOOK"), may })`,
|
|
815
|
+
* `discord({ publicKey: "…" })`. `may.answer` names the Workflows whose Gates the Hook may answer,
|
|
816
|
+
* every Workflow by default. A Hook never starts a run. A name has the rule of a Caller token's
|
|
817
|
+
* name. When the config loads, a bad entry is refused: `<file> at hooks.<name>: <problem>`.
|
|
818
|
+
* Default: none. */
|
|
819
|
+
hooks?: Readonly<Record<string, Hook>>;
|
|
820
|
+
// Reach for tools (ADR-0079): a name of the author's, so an Agent's definition and an Effect name
|
|
821
|
+
// a server without its url or its secret.
|
|
822
|
+
/** The MCP servers, by a name of yours: `[a-z][a-z0-9-]*`, not `jr2`. A remote server has a
|
|
823
|
+
* `url` (`https://` with a secret), a `secret` (a name of the `secrets` list; with no `hosts`, it
|
|
824
|
+
* is bound to the url's host) and a `timeout` (default `"60s"`). A stdio server has a `command`
|
|
825
|
+
* of the Sandbox Image, its `args` and a `timeout`. An Agent names a server in its `mcp` list.
|
|
826
|
+
* Default: none. See {@link McpServer}. */
|
|
827
|
+
mcp?: Readonly<Record<string, McpServer>>;
|
|
828
|
+
// The history (ADR-0065): how long an ended run is kept, and the volume that keeps it.
|
|
829
|
+
/** How long ended runs are kept, and the volume size. Default: `{ retain: "30d", volume: "5Gi" }`. */
|
|
830
|
+
runs?: RunsConfig;
|
|
557
831
|
// Deployment-varying — resolve from env (ADR-0019).
|
|
558
832
|
/** The image registry prefix to which `jr2 up` pushes the images that it builds, such as
|
|
559
833
|
* `registry.example.com/team`. It changes between deployments, so read it from `process.env`
|
|
@@ -650,9 +924,284 @@ export async function loadConfig(dir: string): Promise<JR2Config | undefined> {
|
|
|
650
924
|
if (mod.default.harness !== undefined) checkHarness(mod.default.harness, file);
|
|
651
925
|
if (mod.default.sandbox !== undefined) checkSandbox(mod.default.sandbox, file);
|
|
652
926
|
if (mod.default.priorityClasses !== undefined) checkPriorityClasses(mod.default.priorityClasses, file);
|
|
927
|
+
if (mod.default.callers !== undefined) checkCallers(mod.default.callers, file);
|
|
928
|
+
if (mod.default.cors !== undefined) checkCors(mod.default.cors, file);
|
|
929
|
+
if (mod.default.hooks !== undefined) checkHooks(mod.default.hooks, file);
|
|
930
|
+
if (mod.default.mcp !== undefined) checkMcp(mod.default.mcp, mod.default.secrets ?? [], file);
|
|
931
|
+
if (mod.default.runs !== undefined) checkRuns(mod.default.runs, file);
|
|
653
932
|
return mod.default;
|
|
654
933
|
}
|
|
655
934
|
|
|
935
|
+
const RUNS_HINT = 'runs is { retain?, volume? }, such as { retain: "30d", volume: "5Gi" } (ADR-0065)';
|
|
936
|
+
const RETAIN_FORM = /^\d+(\.\d+)?[mhdw]$/;
|
|
937
|
+
// A unit is required: a bare number is bytes to Kubernetes, so "5" for "5Gi" is a claim of 5 bytes.
|
|
938
|
+
const VOLUME_FORM = /^\d+(\.\d+)?(Ki|Mi|Gi|Ti|Pi|Ei|k|M|G|T|P|E)$/;
|
|
939
|
+
|
|
940
|
+
/** `runs`' SHAPE (ADR-0065): `retain` a duration of at least 1h, `volume` a storage quantity. */
|
|
941
|
+
export function checkRuns(runs: unknown, where: string): void {
|
|
942
|
+
if (typeof runs !== "object" || runs === null || Array.isArray(runs)) {
|
|
943
|
+
throw new Error(`${where} at runs: expected an object — ${RUNS_HINT}`);
|
|
944
|
+
}
|
|
945
|
+
for (const key of Object.keys(runs)) {
|
|
946
|
+
if (key !== "retain" && key !== "volume") throw new Error(`${where} at runs.${key}: not a field — ${RUNS_HINT}`);
|
|
947
|
+
}
|
|
948
|
+
const { retain, volume } = runs as { retain?: unknown; volume?: unknown };
|
|
949
|
+
if (retain !== undefined) {
|
|
950
|
+
const ms = typeof retain === "string" && RETAIN_FORM.test(retain) ? periodMs(retain) : undefined;
|
|
951
|
+
if (ms === undefined) {
|
|
952
|
+
throw new Error(
|
|
953
|
+
`${where} at runs.retain: ${JSON.stringify(retain)} is not a duration — a number and m, h, d or w, such as "30d" (ADR-0065)`,
|
|
954
|
+
);
|
|
955
|
+
}
|
|
956
|
+
if (ms < RUNS_RETAIN_MIN_MS) {
|
|
957
|
+
throw new Error(
|
|
958
|
+
`${where} at runs.retain: ${JSON.stringify(retain)} is shorter than 1h — jr2 keeps an ended run for at least 1h (ADR-0065)`,
|
|
959
|
+
);
|
|
960
|
+
}
|
|
961
|
+
}
|
|
962
|
+
if (
|
|
963
|
+
volume !== undefined &&
|
|
964
|
+
(typeof volume !== "string" || !VOLUME_FORM.test(volume) || !(Number.parseFloat(volume) > 0))
|
|
965
|
+
) {
|
|
966
|
+
throw new Error(
|
|
967
|
+
`${where} at runs.volume: ${JSON.stringify(volume)} is not a storage quantity — a number and a unit, such as "5Gi" or "500Mi" (ADR-0065)`,
|
|
968
|
+
);
|
|
969
|
+
}
|
|
970
|
+
}
|
|
971
|
+
|
|
972
|
+
/** A list item of a scope other than `answer`: a Workflow name. An item that names Gates is refused
|
|
973
|
+
* with the reason: only `answer` acts on one Gate, and the other scopes act on a whole run. */
|
|
974
|
+
const workflowItem = (scope: Scope) =>
|
|
975
|
+
z.unknown().superRefine((item, ctx) => {
|
|
976
|
+
if (typeof item === "string" && item !== "") return;
|
|
977
|
+
ctx.addIssue({
|
|
978
|
+
code: "custom",
|
|
979
|
+
message:
|
|
980
|
+
typeof item === "object" && item !== null && "gates" in item
|
|
981
|
+
? `only "answer" can name single Gates; "${scope}" names whole Workflows, because it acts on a whole run`
|
|
982
|
+
: "a Workflow name is a string that is not empty",
|
|
983
|
+
});
|
|
984
|
+
});
|
|
985
|
+
const ScopeGrantShape = (scope: Scope) => z.union([z.literal("all"), z.array(workflowItem(scope)).min(1)]);
|
|
986
|
+
const GateGrantShape = z
|
|
987
|
+
.object({
|
|
988
|
+
workflow: z.string().min(1, "a Workflow name is a string that is not empty"),
|
|
989
|
+
gates: z
|
|
990
|
+
.array(z.string().min(1, "a Gate name is a string that is not empty"))
|
|
991
|
+
.min(1, "gates names at least one Gate"),
|
|
992
|
+
})
|
|
993
|
+
.strict();
|
|
994
|
+
const AnswerGrantShape = z.union([z.literal("all"), z.array(z.union([z.string().min(1), GateGrantShape])).min(1)]);
|
|
995
|
+
const ScopesShape = z
|
|
996
|
+
.object({
|
|
997
|
+
start: ScopeGrantShape("start").optional(),
|
|
998
|
+
answer: AnswerGrantShape.optional(),
|
|
999
|
+
cancel: ScopeGrantShape("cancel").optional(),
|
|
1000
|
+
read: ScopeGrantShape("read").optional(),
|
|
1001
|
+
steer: ScopeGrantShape("steer").optional(),
|
|
1002
|
+
})
|
|
1003
|
+
.strict();
|
|
1004
|
+
const CallersShape = z
|
|
1005
|
+
.object({
|
|
1006
|
+
tokens: z
|
|
1007
|
+
.record(
|
|
1008
|
+
z
|
|
1009
|
+
.string()
|
|
1010
|
+
.regex(
|
|
1011
|
+
/^[a-z]([a-z0-9-]{0,38}[a-z0-9])?$/,
|
|
1012
|
+
"a token name is lowercase letters, digits and -, at most 40, starts with a letter and ends with a letter or digit",
|
|
1013
|
+
)
|
|
1014
|
+
.refine((name) => name !== "instance", "the name instance is the Instance token's"),
|
|
1015
|
+
z.object({ may: ScopesShape }).strict(),
|
|
1016
|
+
)
|
|
1017
|
+
.optional(),
|
|
1018
|
+
issuers: z
|
|
1019
|
+
.array(
|
|
1020
|
+
z
|
|
1021
|
+
.object({
|
|
1022
|
+
issuer: z.string().refine(isHttpUrl, "the issuer is an http or https URL"),
|
|
1023
|
+
audience: z.string().min(1),
|
|
1024
|
+
jwks: z.string().refine(isHttpUrl, "jwks is an http or https URL").optional(),
|
|
1025
|
+
claims: z
|
|
1026
|
+
.object({
|
|
1027
|
+
name: z.string().min(1).optional(),
|
|
1028
|
+
shown: z.string().min(1).optional(),
|
|
1029
|
+
groups: z.string().min(1).optional(),
|
|
1030
|
+
})
|
|
1031
|
+
.strict()
|
|
1032
|
+
.optional(),
|
|
1033
|
+
groups: z.record(z.string().min(1), ScopesShape),
|
|
1034
|
+
login: z.boolean().optional(),
|
|
1035
|
+
})
|
|
1036
|
+
.strict(),
|
|
1037
|
+
)
|
|
1038
|
+
.refine(
|
|
1039
|
+
(list) => new Set(list.map((i) => i.issuer)).size === list.length,
|
|
1040
|
+
"two issuers have one issuer URL; each issuer is one entry",
|
|
1041
|
+
)
|
|
1042
|
+
.optional(),
|
|
1043
|
+
})
|
|
1044
|
+
.strict();
|
|
1045
|
+
|
|
1046
|
+
/** Whether `text` is an http or https URL. */
|
|
1047
|
+
function isHttpUrl(text: string): boolean {
|
|
1048
|
+
try {
|
|
1049
|
+
const url = new URL(text);
|
|
1050
|
+
return url.protocol === "http:" || url.protocol === "https:";
|
|
1051
|
+
} catch {
|
|
1052
|
+
return false;
|
|
1053
|
+
}
|
|
1054
|
+
}
|
|
1055
|
+
|
|
1056
|
+
/** `callers`' SHAPE (ADR-0078), the `checkGit` style: a bad entry names its path. */
|
|
1057
|
+
function checkCallers(callers: unknown, where: string): void {
|
|
1058
|
+
const parsed = CallersShape.safeParse(callers);
|
|
1059
|
+
if (parsed.success) return;
|
|
1060
|
+
const issue = parsed.error.issues[0];
|
|
1061
|
+
const at = ["", ...(issue?.path ?? [])].map(String).join(".");
|
|
1062
|
+
// A bad token NAME is an issue about the key, whose own issues say why.
|
|
1063
|
+
const inner = issue?.code === "invalid_key" ? issue.issues[0]?.message : undefined;
|
|
1064
|
+
throw new Error(`${where} at callers${at}: ${inner ?? issue?.message ?? "invalid"} — ${CALLERS_HINT}`);
|
|
1065
|
+
}
|
|
1066
|
+
|
|
1067
|
+
const CALLERS_HINT =
|
|
1068
|
+
"callers is { tokens?: { <name>: { may: <scopes> } }, issuers?: [{ issuer, audience, jwks?, " +
|
|
1069
|
+
"claims?: { name?, shown?, groups? }, groups: { <value>: <scopes> }, login? }] }, where <scopes> is " +
|
|
1070
|
+
'{ start?, answer?, cancel?, read?, steer? }, each scope "all" or a list of Workflow names, and an answer ' +
|
|
1071
|
+
"item may be { workflow, gates: [<Gate name>] } (ADR-0078)";
|
|
1072
|
+
|
|
1073
|
+
/** The name of a Hook (ADR-0078): the last segment of `POST /hooks/:name`, the rule of a Caller
|
|
1074
|
+
* token's name. */
|
|
1075
|
+
export const HOOK_NAME = /^[a-z]([a-z0-9-]{0,38}[a-z0-9])?$/;
|
|
1076
|
+
|
|
1077
|
+
/** `hooks`' SHAPE (ADR-0078): each entry is a Hook that a kind's constructor made, under a name of
|
|
1078
|
+
* the Caller name's form, and one kind id is one kind (`kindsById`). */
|
|
1079
|
+
function checkHooks(hooks: unknown, where: string): void {
|
|
1080
|
+
if (typeof hooks !== "object" || hooks === null || Array.isArray(hooks)) {
|
|
1081
|
+
throw new Error(`${where} at hooks: expected { <name>: <Hook> }, such as { forge: github({ secret }) } (ADR-0078)`);
|
|
1082
|
+
}
|
|
1083
|
+
const kinds = [];
|
|
1084
|
+
for (const [name, hook] of Object.entries(hooks)) {
|
|
1085
|
+
if (!HOOK_NAME.test(name)) {
|
|
1086
|
+
throw new Error(
|
|
1087
|
+
`${where} at hooks.${name}: a Hook's name is lowercase letters, digits and -, at most 40, starts with a ` +
|
|
1088
|
+
"letter and ends with a letter or digit (ADR-0078)",
|
|
1089
|
+
);
|
|
1090
|
+
}
|
|
1091
|
+
if (!isHook(hook)) {
|
|
1092
|
+
throw new Error(
|
|
1093
|
+
`${where} at hooks.${name}: not a Hook — make it with its kind, such as github({ secret: secret("GH_HOOK") }) (ADR-0078)`,
|
|
1094
|
+
);
|
|
1095
|
+
}
|
|
1096
|
+
kinds.push(hookParts(hook).kind);
|
|
1097
|
+
}
|
|
1098
|
+
try {
|
|
1099
|
+
kindsById(kinds);
|
|
1100
|
+
} catch (err) {
|
|
1101
|
+
throw new Error(`${where} at hooks: ${err instanceof Error ? err.message : String(err)} (ADR-0078)`);
|
|
1102
|
+
}
|
|
1103
|
+
}
|
|
1104
|
+
|
|
1105
|
+
/** `cors`' SHAPE (ADR-0078): a list of origins, each one exactly what a browser sends as `Origin`. */
|
|
1106
|
+
function checkCors(cors: unknown, where: string): void {
|
|
1107
|
+
if (!Array.isArray(cors)) throw new Error(`${where} at cors: expected an array of origins (ADR-0078)`);
|
|
1108
|
+
for (const [i, entry] of cors.entries()) {
|
|
1109
|
+
if (typeof entry !== "string" || !isOrigin(entry)) {
|
|
1110
|
+
throw new Error(
|
|
1111
|
+
`${where} at cors[${i}]: ${JSON.stringify(entry)} is not an origin — scheme://host[:port], with no path (ADR-0078)`,
|
|
1112
|
+
);
|
|
1113
|
+
}
|
|
1114
|
+
}
|
|
1115
|
+
}
|
|
1116
|
+
|
|
1117
|
+
/** Whether `text` is an origin as a browser sends it: `URL` gives back the same text as its origin. */
|
|
1118
|
+
function isOrigin(text: string): boolean {
|
|
1119
|
+
try {
|
|
1120
|
+
const url = new URL(text);
|
|
1121
|
+
return (url.protocol === "http:" || url.protocol === "https:") && url.origin === text;
|
|
1122
|
+
} catch {
|
|
1123
|
+
return false;
|
|
1124
|
+
}
|
|
1125
|
+
}
|
|
1126
|
+
|
|
1127
|
+
/** An MCP server's name (ADR-0079): the `<server>` of `mcp__<server>__<tool>`. It has no `_`, so
|
|
1128
|
+
* the prefix of a tool name is never ambiguous. */
|
|
1129
|
+
export const MCP_SERVER_NAME = /^[a-z][a-z0-9-]*$/;
|
|
1130
|
+
|
|
1131
|
+
/** The default `timeout` of one MCP call (ADR-0079). */
|
|
1132
|
+
export const MCP_DEFAULT_TIMEOUT_MS = 60_000;
|
|
1133
|
+
|
|
1134
|
+
/** One MCP server's `timeout` in milliseconds, or undefined when it is not a duration of 1 ms or
|
|
1135
|
+
* more (a shorter one rounds to 0 ms, which no Harness can wait). */
|
|
1136
|
+
export function mcpTimeoutMs(timeout: string | undefined): number | undefined {
|
|
1137
|
+
if (timeout === undefined) return MCP_DEFAULT_TIMEOUT_MS;
|
|
1138
|
+
const m = /^(\d+(?:\.\d+)?)(s|m|h)$/.exec(timeout);
|
|
1139
|
+
const n = m ? Number(m[1]) : NaN;
|
|
1140
|
+
if (!(n > 0)) return undefined;
|
|
1141
|
+
const ms = Math.round(m![2] === "h" ? n * 3_600_000 : m![2] === "m" ? n * 60_000 : n * 1000);
|
|
1142
|
+
return ms >= 1 ? ms : undefined;
|
|
1143
|
+
}
|
|
1144
|
+
|
|
1145
|
+
/** Is this MCP server a stdio one (`command`), not a remote one (`url`)? */
|
|
1146
|
+
export function isStdioServer(server: McpServer): server is McpStdioServer {
|
|
1147
|
+
return "command" in server;
|
|
1148
|
+
}
|
|
1149
|
+
|
|
1150
|
+
const McpTimeoutShape = z
|
|
1151
|
+
.string()
|
|
1152
|
+
.refine((t) => mcpTimeoutMs(t) !== undefined, 'a timeout is a positive duration, such as "90s", "5m" or "1h"');
|
|
1153
|
+
const McpServerShape = z.union([
|
|
1154
|
+
z.object({ url: z.string(), secret: z.string().min(1).optional(), timeout: McpTimeoutShape.optional() }).strict(),
|
|
1155
|
+
z
|
|
1156
|
+
.object({
|
|
1157
|
+
command: z.string().min(1),
|
|
1158
|
+
args: z.array(z.string()).optional(),
|
|
1159
|
+
timeout: McpTimeoutShape.optional(),
|
|
1160
|
+
})
|
|
1161
|
+
.strict(),
|
|
1162
|
+
]);
|
|
1163
|
+
|
|
1164
|
+
const MCP_HINT =
|
|
1165
|
+
"an entry is <name>: { url, secret?, timeout? } for a remote server, or <name>: { command, args?, timeout? } for a stdio server (ADR-0079)";
|
|
1166
|
+
|
|
1167
|
+
/** `mcp`'s SHAPE (ADR-0079), the `checkGit` style: a bad entry names its path. The url is not
|
|
1168
|
+
* checked here: in-cluster it reads `.env`, which is absent, so `jr2 up` checks it host-side. */
|
|
1169
|
+
function checkMcp(mcp: unknown, secrets: readonly SecretEntry[], where: string): void {
|
|
1170
|
+
if (typeof mcp !== "object" || mcp === null || Array.isArray(mcp)) {
|
|
1171
|
+
throw new Error(`${where} at mcp: expected an object keyed by server name — ${MCP_HINT}`);
|
|
1172
|
+
}
|
|
1173
|
+
for (const [name, server] of Object.entries(mcp)) {
|
|
1174
|
+
if (name === "jr2") {
|
|
1175
|
+
throw new Error(
|
|
1176
|
+
`${where} at mcp.jr2: the server name jr2 is reserved — the Menu's tools are named mcp__jr2__<event>; ` +
|
|
1177
|
+
"give the server another name (ADR-0079)",
|
|
1178
|
+
);
|
|
1179
|
+
}
|
|
1180
|
+
if (!MCP_SERVER_NAME.test(name)) {
|
|
1181
|
+
throw new Error(
|
|
1182
|
+
`${where} at mcp.${name}: a server name is lowercase letters, digits and -, and starts with a letter ` +
|
|
1183
|
+
"([a-z][a-z0-9-]*) — it is the <server> of the tool names mcp__<server>__<tool> (ADR-0079)",
|
|
1184
|
+
);
|
|
1185
|
+
}
|
|
1186
|
+
const parsed = McpServerShape.safeParse(server);
|
|
1187
|
+
if (!parsed.success) {
|
|
1188
|
+
// A union's issue says only "invalid input": name the branch the entry meant.
|
|
1189
|
+
const meant = typeof server === "object" && server !== null && "command" in server ? 1 : 0;
|
|
1190
|
+
const issues = parsed.error.issues[0];
|
|
1191
|
+
const inner = issues?.code === "invalid_union" ? (issues.errors[meant]?.[0] ?? issues.errors[0]?.[0]) : issues;
|
|
1192
|
+
const at = ["", ...(inner?.path ?? [])].map(String).join(".");
|
|
1193
|
+
throw new Error(`${where} at mcp.${name}${at}: ${inner?.message ?? "invalid"} — ${MCP_HINT}`);
|
|
1194
|
+
}
|
|
1195
|
+
const secret = (server as { secret?: string }).secret;
|
|
1196
|
+
if (secret !== undefined && !secrets.some((s) => s.name === secret)) {
|
|
1197
|
+
throw new Error(
|
|
1198
|
+
`${where} at mcp.${name}.secret: "${secret}" is not an entry of secrets — add it: ` +
|
|
1199
|
+
`secrets: [{ name: "${secret}", value: process.env.${secret} }] (ADR-0079)`,
|
|
1200
|
+
);
|
|
1201
|
+
}
|
|
1202
|
+
}
|
|
1203
|
+
}
|
|
1204
|
+
|
|
656
1205
|
const NonEmpty = z.string().min(1);
|
|
657
1206
|
const Credential = z
|
|
658
1207
|
.object({
|