@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/steer.ts
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
// The Machine's Steering message (ADR-0002): free text to one Agent of the Machine, which the Agent
|
|
2
|
+
// hears once, at a step boundary of its open Turn or at its next Turn's admission. The run's notice
|
|
3
|
+
// ledger keeps it (ADR-0062); this module is only the action an author writes.
|
|
4
|
+
|
|
5
|
+
import type { ActionFunction, EventObject, MachineContext, ParameterizedObject, ProvidedActor } from "xstate";
|
|
6
|
+
import type { Sender } from "@jr2/agent-protocol";
|
|
7
|
+
import { boundRun } from "./registration.ts";
|
|
8
|
+
|
|
9
|
+
/** The most text one Steering message carries, in UTF-8 bytes: the bound of
|
|
10
|
+
* `POST /runs/:runId/steering`, whose larger text is 413. The `steer` action cuts a longer text. */
|
|
11
|
+
export const STEERING_TEXT_MAX = 16 * 1024;
|
|
12
|
+
|
|
13
|
+
/** What `steer` adds to the end of a text that it cut at {@link STEERING_TEXT_MAX}. */
|
|
14
|
+
const CUT_MARK = "\n[jr2 cut this message at 16 KiB]";
|
|
15
|
+
|
|
16
|
+
/** A text, cut to {@link STEERING_TEXT_MAX} UTF-8 bytes with the cut mark when it is longer. */
|
|
17
|
+
export function boundSteeringText(text: string): string {
|
|
18
|
+
const bytes = new TextEncoder().encode(text);
|
|
19
|
+
if (bytes.length <= STEERING_TEXT_MAX) return text;
|
|
20
|
+
const room = STEERING_TEXT_MAX - new TextEncoder().encode(CUT_MARK).length;
|
|
21
|
+
// `fatal: false` drops a character that the cut split in two.
|
|
22
|
+
const head = new TextDecoder("utf-8", { fatal: false }).decode(bytes.slice(0, room)).replace(/�$/, "");
|
|
23
|
+
return `${head}${CUT_MARK}`;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** The arguments a `steer` text or `from` function gets: the Machine's context and the event that
|
|
27
|
+
* the transition takes. */
|
|
28
|
+
type SteerArgs<TContext, TEvent> = { context: TContext; event: TEvent };
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Send a Steering message to one Agent of this Machine: an action for a transition. The Agent hears
|
|
32
|
+
* the text once, without an interrupt. When a Turn of the Agent is open, its Harness adds the text
|
|
33
|
+
* at the Turn's next step boundary. Otherwise the text waits for the Agent's next Turn in this
|
|
34
|
+
* Machine instance. When this Machine instance ends first, the message is not delivered.
|
|
35
|
+
*
|
|
36
|
+
* A Steering message is not an event: no guard reads it, and no Gate takes it. The Agent decides
|
|
37
|
+
* what to do with it, and acts on the workflow only by its picks.
|
|
38
|
+
*
|
|
39
|
+
* Use it to relay what one Agent says to another. Give `from` the Sender of the event (the
|
|
40
|
+
* `sender` field that the Orchestrator writes on it): the Agent then reads the text as the word of
|
|
41
|
+
* a peer, not as an order. When the Sender is a Gate, the Agent reads the text as what the Gate
|
|
42
|
+
* received, not as an order. With no `from`, the Agent reads the text as the workflow's own.
|
|
43
|
+
*
|
|
44
|
+
* `agent` is the name of an Agent in this Machine's `actors`. A text longer than 16 KiB is cut.
|
|
45
|
+
* The Machine can steer an Agent whose definition says `steerable: false`: that refuses Callers
|
|
46
|
+
* only.
|
|
47
|
+
*
|
|
48
|
+
* @example
|
|
49
|
+
* ```ts
|
|
50
|
+
* import { z } from "zod";
|
|
51
|
+
* import { agent, defineEvent, jr2Setup, steer } from "@jr2/orchestrator";
|
|
52
|
+
*
|
|
53
|
+
* const done = defineEvent({ name: "done", description: "The draft is ready.", input: z.object({}) });
|
|
54
|
+
* const note = defineEvent({
|
|
55
|
+
* name: "note",
|
|
56
|
+
* description: "Send the draft back to the writer, with what to change.",
|
|
57
|
+
* input: z.object({ text: z.string().min(1) }),
|
|
58
|
+
* });
|
|
59
|
+
*
|
|
60
|
+
* export const machine = jr2Setup({
|
|
61
|
+
* events: [done, note],
|
|
62
|
+
* actors: {
|
|
63
|
+
* writer: agent({ model: "anthropic/claude-haiku-5-5", instructions: "You write.", workspace: "none" }),
|
|
64
|
+
* reviewer: agent({ model: "anthropic/claude-haiku-5-5", instructions: "You review.", workspace: "none" }),
|
|
65
|
+
* },
|
|
66
|
+
* }).createMachine({
|
|
67
|
+
* initial: "writing",
|
|
68
|
+
* states: {
|
|
69
|
+
* writing: {
|
|
70
|
+
* invoke: { src: "writer", input: { prompt: "Write the notes.", continue: true } },
|
|
71
|
+
* on: { done: "reviewing" },
|
|
72
|
+
* },
|
|
73
|
+
* reviewing: {
|
|
74
|
+
* invoke: { src: "reviewer", input: { prompt: "Review the notes." } },
|
|
75
|
+
* on: {
|
|
76
|
+
* // The writer's next Turn hears the note as the reviewer's word, not as an order.
|
|
77
|
+
* note: {
|
|
78
|
+
* target: "writing",
|
|
79
|
+
* actions: steer("writer", ({ event }) => event.text, { from: ({ event }) => event.sender }),
|
|
80
|
+
* },
|
|
81
|
+
* },
|
|
82
|
+
* },
|
|
83
|
+
* },
|
|
84
|
+
* });
|
|
85
|
+
* ```
|
|
86
|
+
*
|
|
87
|
+
* @see docs/guides/steer-an-agent.md
|
|
88
|
+
* @see docs/concepts/agents-and-turns.md
|
|
89
|
+
* @see docs/reference/decisions/0002-duplex-streaming-actor.md
|
|
90
|
+
*/
|
|
91
|
+
export function steer<
|
|
92
|
+
TContext extends MachineContext,
|
|
93
|
+
TExpressionEvent extends EventObject,
|
|
94
|
+
TParams extends ParameterizedObject["params"] | undefined,
|
|
95
|
+
TEvent extends EventObject,
|
|
96
|
+
TActor extends ProvidedActor,
|
|
97
|
+
>(
|
|
98
|
+
agent: string,
|
|
99
|
+
text: string | ((args: SteerArgs<TContext, TExpressionEvent>) => string),
|
|
100
|
+
opts: { from?: Sender | ((args: SteerArgs<TContext, TExpressionEvent>) => Sender | undefined) } = {},
|
|
101
|
+
): ActionFunction<TContext, TExpressionEvent, TEvent, TParams, TActor, never, never, never, never> {
|
|
102
|
+
return ({ context, event, self }) => {
|
|
103
|
+
const binding = boundRun(self.system);
|
|
104
|
+
// Outside a RunHost there is no ledger, and no Agent to hear it.
|
|
105
|
+
if (binding?.steer === undefined) return;
|
|
106
|
+
const said = typeof text === "function" ? text({ context, event }) : text;
|
|
107
|
+
if (typeof said !== "string" || said.length === 0) return;
|
|
108
|
+
const from = typeof opts.from === "function" ? opts.from({ context, event }) : opts.from;
|
|
109
|
+
binding.steer(self, agent, boundSteeringText(said), from);
|
|
110
|
+
};
|
|
111
|
+
}
|
package/src/stub-harness.ts
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
//
|
|
8
8
|
// Wire (ADR-0027 — the normative model of the conversation endpoints; `@jr2/harness` serves it for
|
|
9
9
|
// real), and the pod's memory kills, which a stub never has (ADR-0062):
|
|
10
|
-
// POST /agents/:name/:id {message, cwd?, definition, model?, thinkingLevel?, notices?, kept?} → 200 { streamUrl, offset, submissionId }
|
|
10
|
+
// POST /agents/:name/:id {message, cwd?, definition, model?, thinkingLevel?, notices?, steering?, kept?} → 200 { streamUrl, offset, submissionId }
|
|
11
11
|
// (`message` and `cwd` are the Turn's Frame — what it is about and where it works, ADR-0057;
|
|
12
12
|
// `kept` admits as any POST does, because the stub keeps every conversation — the real Harness
|
|
13
13
|
// answers 404 when it keeps none, ADR-0062)
|
|
@@ -38,7 +38,7 @@
|
|
|
38
38
|
|
|
39
39
|
import { createServer } from "node:http";
|
|
40
40
|
import type { AddressInfo, Socket } from "node:net";
|
|
41
|
-
import type { Notice } from "./wire.ts";
|
|
41
|
+
import type { Notice, SteeringMessage } from "./wire.ts";
|
|
42
42
|
|
|
43
43
|
/** One admission of an Agent: which slot, which durable exchange, this Turn's FRAME (the prompt
|
|
44
44
|
* and where it works — ADR-0057), the DEFINITION the Machine's slot carried here (ADR-0049), and
|
|
@@ -58,6 +58,8 @@ export type Admission = {
|
|
|
58
58
|
thinkingLevel?: string;
|
|
59
59
|
/** The notices the admission carried (ADR-0062). Absent when it carried none. */
|
|
60
60
|
notices?: Notice[];
|
|
61
|
+
/** The Steering messages the admission carried (ADR-0002). Absent when it carried none. */
|
|
62
|
+
steering?: SteeringMessage[];
|
|
61
63
|
};
|
|
62
64
|
|
|
63
65
|
/** One settled submission, in the shape `history()` reports it. The stub settles submissions for
|
|
@@ -165,6 +167,7 @@ export async function startStubHarness(opts: StubHarnessOptions = {}): Promise<R
|
|
|
165
167
|
model?: string;
|
|
166
168
|
thinkingLevel?: string;
|
|
167
169
|
notices?: Notice[];
|
|
170
|
+
steering?: SteeringMessage[];
|
|
168
171
|
}
|
|
169
172
|
| undefined;
|
|
170
173
|
const admission: Admission = {
|
|
@@ -178,6 +181,7 @@ export async function startStubHarness(opts: StubHarnessOptions = {}): Promise<R
|
|
|
178
181
|
...(sent?.model ? { model: sent.model } : {}),
|
|
179
182
|
...(sent?.thinkingLevel ? { thinkingLevel: sent.thinkingLevel } : {}),
|
|
180
183
|
...(sent?.notices?.length ? { notices: sent.notices } : {}),
|
|
184
|
+
...(sent?.steering?.length ? { steering: sent.steering } : {}),
|
|
181
185
|
};
|
|
182
186
|
admissions.push(admission);
|
|
183
187
|
const submissionId = `stub-${++submissionSeq}`;
|
package/src/tokens.ts
CHANGED
|
@@ -6,10 +6,12 @@
|
|
|
6
6
|
// Three principals. The asymmetry between the first two is the whole design; the third is a door
|
|
7
7
|
// key, not a seat:
|
|
8
8
|
//
|
|
9
|
-
// Instance token the human/CLI credential
|
|
10
|
-
//
|
|
11
|
-
//
|
|
12
|
-
// never enters a Sandbox.
|
|
9
|
+
// Instance token the human/CLI credential: the Caller `token/instance` (ADR-0078), with every
|
|
10
|
+
// scope, and the one credential of the routes no scope names. From the instance
|
|
11
|
+
// Secret (`JR2_INSTANCE_TOKEN`); minted per boot when absent, announced once on
|
|
12
|
+
// stdout (fixtures capture it). An Agent never has it — it never enters a Sandbox.
|
|
13
|
+
// The other Callers (named `jr2c_` tokens, JWTs) are below, under Callers:
|
|
14
|
+
// each acts within its scopes.
|
|
13
15
|
//
|
|
14
16
|
// Publish token `jr2 up`'s credential for `PUT /assembly` alone (ADR-0069). A publish imports
|
|
15
17
|
// code into the process that holds the ServiceAccount, so it must cost what an
|
|
@@ -23,7 +25,7 @@
|
|
|
23
25
|
// container ALONE, which no Workspace code reaches (ADR-0059):
|
|
24
26
|
// control token `agent:<pod>.<sig>`. Delivers to `kind: "agent"` registrations whose pod is
|
|
25
27
|
// THIS one, and only for the slot the Harness states (`X-Jr2-Slot`), on the
|
|
26
|
-
//
|
|
28
|
+
// five agent routes. The Harness process alone holds its secret Stand-in.
|
|
27
29
|
// fetch token `<pod>.<sig>`. Asks for a fetch for THIS Sandbox (ADR-0053), on
|
|
28
30
|
// `POST /sandboxes/<pod>/fetch` alone. Its Stand-in is public: it can do
|
|
29
31
|
// nothing else.
|
|
@@ -46,13 +48,229 @@
|
|
|
46
48
|
// after one, which is exactly what ADR-0012's re-attach promises the still-running Custodian.
|
|
47
49
|
|
|
48
50
|
import { createHash, createHmac, randomBytes, timingSafeEqual } from "node:crypto";
|
|
51
|
+
import { readFileSync, unwatchFile, watchFile } from "node:fs";
|
|
49
52
|
import { chmod, mkdir, readFile, writeFile } from "node:fs/promises";
|
|
50
|
-
import { join } from "node:path";
|
|
53
|
+
import { basename, join } from "node:path";
|
|
54
|
+
import { configMapDigest } from "./mount-digest.ts";
|
|
55
|
+
import { SCOPES, type AnswerGrant, type Scope, type Scopes } from "./config.ts";
|
|
56
|
+
|
|
57
|
+
// ---- Callers (ADR-0078) -----------------------------------------------------------------------
|
|
58
|
+
// A Caller acts on the Instance from outside its Machines: the CLI, a CI job, a bridge, a person in
|
|
59
|
+
// the Console. One seam makes it: an ORDERED list of authenticators. Each one takes the bearer and
|
|
60
|
+
// answers a Caller, "not mine", or "mine, and bad". The first that claims the bearer decides, so a
|
|
61
|
+
// bearer that one claims and finds bad is 401 and never falls through to the next. The Instance
|
|
62
|
+
// token is the first authenticator, the named `jr2c_` tokens the second, and the JWT issuers
|
|
63
|
+
// (callers.ts) the third. The principals inside the Orchestrator (the publish token, the control and fetch tokens)
|
|
64
|
+
// are outside the seam: no authenticator makes a Caller from one of them.
|
|
65
|
+
|
|
66
|
+
/** A request's Caller (ADR-0078): who acts, through which authenticator, and what it may do. */
|
|
67
|
+
export type Caller = {
|
|
68
|
+
/** The authenticator that made the Caller: `token` (the Instance token or a named token), `jwt`
|
|
69
|
+
* or `hook`. */
|
|
70
|
+
via: "token" | "jwt" | "hook";
|
|
71
|
+
/** The Caller's name for that authenticator: `instance`, a named token's name, a JWT's `sub`, a
|
|
72
|
+
* Hook's name. */
|
|
73
|
+
name: string;
|
|
74
|
+
/** The name that jr2 shows, when it is not `name` (a JWT's `email`). */
|
|
75
|
+
shown?: string;
|
|
76
|
+
/** What the Caller may do. */
|
|
77
|
+
may: Scopes;
|
|
78
|
+
/** The Caller's claim about the party it acts for. jr2 records it and never checks it. */
|
|
79
|
+
onBehalfOf?: string;
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
/** The header in which a Caller states the party it acts for (ADR-0078). A claim: jr2 records it
|
|
83
|
+
* and shows it, and never checks it. */
|
|
84
|
+
export const ON_BEHALF_OF_HEADER = "jr2-on-behalf-of";
|
|
85
|
+
|
|
86
|
+
/** The longest `jr2-on-behalf-of` the guarded band takes: a user name, not a document. */
|
|
87
|
+
export const ON_BEHALF_OF_MAX = 256;
|
|
88
|
+
|
|
89
|
+
/** The prefix of a named Caller token's value (ADR-0078). It lets the seam say "not mine" with no
|
|
90
|
+
* lookup, and lets a secret scanner find a value that leaked. */
|
|
91
|
+
export const CALLER_TOKEN_PREFIX = "jr2c_";
|
|
92
|
+
|
|
93
|
+
/** Every scope, for every Workflow: the Instance token's grant. */
|
|
94
|
+
const EVERY_SCOPE: Scopes = Object.fromEntries(SCOPES.map((s) => [s, "all"])) as Scopes;
|
|
95
|
+
|
|
96
|
+
/** The Instance token's Caller (ADR-0078): `token/instance`, with every scope for every Workflow. */
|
|
97
|
+
export function instanceCaller(): Caller {
|
|
98
|
+
return { via: "token", name: "instance", may: { ...EVERY_SCOPE } };
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Whether a Caller is the Instance token's. The config refuses a named token called `instance`,
|
|
102
|
+
* so the pair names it alone. */
|
|
103
|
+
export function isInstanceCaller(caller: Caller): boolean {
|
|
104
|
+
return caller.via === "token" && caller.name === "instance";
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** Whether a list item's Workflow name opens `workflow`. A wrapper's name opens each of its named
|
|
108
|
+
* Charts (`plan` opens `plan/weekly`), because a named Chart runs its wrapper's Machine (ADR-0070). */
|
|
109
|
+
function nameOpens(name: string, workflow: string): boolean {
|
|
110
|
+
if (name === workflow) return true;
|
|
111
|
+
const cut = workflow.indexOf("/");
|
|
112
|
+
return cut > 0 && name === workflow.slice(0, cut);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** Whether `grant` opens `workflow`: for the `answer` scope, at least one Gate of it (ADR-0078). */
|
|
116
|
+
export function grantOpens(grant: AnswerGrant | undefined, workflow: string): boolean {
|
|
117
|
+
if (grant === undefined) return false;
|
|
118
|
+
if (grant === "all") return true;
|
|
119
|
+
return grant.some((item) => nameOpens(typeof item === "string" ? item : item.workflow, workflow));
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** Whether an `answer` grant opens the Gate named `gate` (its invoke id, ADR-0011) of `workflow`:
|
|
123
|
+
* a bare Workflow name opens each of its Gates, and a `{ workflow, gates }` item the Gates that it
|
|
124
|
+
* names (ADR-0078). */
|
|
125
|
+
export function grantOpensGate(grant: AnswerGrant | undefined, workflow: string, gate: string): boolean {
|
|
126
|
+
if (grant === undefined) return false;
|
|
127
|
+
if (grant === "all") return true;
|
|
128
|
+
return grant.some((item) =>
|
|
129
|
+
typeof item === "string"
|
|
130
|
+
? nameOpens(item, workflow)
|
|
131
|
+
: nameOpens(item.workflow, workflow) && item.gates.includes(gate),
|
|
132
|
+
);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** Whether a Caller has `scope` for `workflow`. For `answer`, that is at least one Gate of it:
|
|
136
|
+
* {@link callerMayAnswer} asks for one Gate. */
|
|
137
|
+
export function callerMay(caller: Caller, scope: Scope, workflow: string): boolean {
|
|
138
|
+
return grantOpens(caller.may[scope], workflow);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** Whether a Caller may answer the Gate named `gate` (its invoke id) of a run of `workflow`
|
|
142
|
+
* (ADR-0078). */
|
|
143
|
+
export function callerMayAnswer(caller: Caller, workflow: string, gate: string): boolean {
|
|
144
|
+
return grantOpensGate(caller.may.answer, workflow, gate);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** Whether a Caller has `scope` for at least one Workflow. */
|
|
148
|
+
export function callerHasScope(caller: Caller, scope: Scope): boolean {
|
|
149
|
+
const grant = caller.may[scope];
|
|
150
|
+
return grant !== undefined && (grant === "all" || grant.length > 0);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** Whether `workflow` is in any scope of a Caller: what `GET /runs/resolve` answers for. */
|
|
154
|
+
export function callerMayAny(caller: Caller, workflow: string): boolean {
|
|
155
|
+
return SCOPES.some((s) => callerMay(caller, s, workflow));
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** One authenticator's answer: a Caller; `{ refused }`, which is "mine, and bad" (401, and it never
|
|
159
|
+
* falls through); or `undefined`, which is "not mine". */
|
|
160
|
+
export type CallerAnswer = Caller | { refused: string } | undefined;
|
|
161
|
+
|
|
162
|
+
/** One authenticator of the seam. An authenticator that must fetch (a JWT issuer's keys) answers
|
|
163
|
+
* with a Promise. */
|
|
164
|
+
export type CallerAuthenticator = (bearer: string) => CallerAnswer | Promise<CallerAnswer>;
|
|
165
|
+
|
|
166
|
+
/** The Instance token's authenticator (ADR-0078). */
|
|
167
|
+
export function instanceTokenAuthenticator(instanceToken: string): CallerAuthenticator {
|
|
168
|
+
return (bearer) => (constantTimeEqual(bearer, instanceToken) ? instanceCaller() : undefined);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** A named token's entry as the Orchestrator holds it: its name, the sha-256 of its value, and its
|
|
172
|
+
* scopes. Never the value (ADR-0078). */
|
|
173
|
+
export type NamedToken = { name: string; digest: string; may: Scopes };
|
|
174
|
+
|
|
175
|
+
/** The digest of a named token's value, as `jr2 up` gives it to the Orchestrator: sha-256, hex. */
|
|
176
|
+
export function callerTokenDigest(value: string): string {
|
|
177
|
+
return createHash("sha256").update(value).digest("hex");
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** Make a named token's value: `jr2c_` and 32 random bytes in base64url (ADR-0078). */
|
|
181
|
+
export function mintCallerToken(): string {
|
|
182
|
+
return CALLER_TOKEN_PREFIX + randomBytes(32).toString("base64url");
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* The named tokens' authenticator (ADR-0078). It claims every bearer with the `jr2c_` prefix, so a
|
|
187
|
+
* value that no entry knows is 401 here, never a bearer for the next authenticator. `tokens` is read
|
|
188
|
+
* on each request: a rotation or a new config applies with no restart.
|
|
189
|
+
*/
|
|
190
|
+
export function namedTokenAuthenticator(tokens: () => readonly NamedToken[]): CallerAuthenticator {
|
|
191
|
+
return (bearer) => {
|
|
192
|
+
if (!bearer.startsWith(CALLER_TOKEN_PREFIX)) return undefined;
|
|
193
|
+
const digest = callerTokenDigest(bearer);
|
|
194
|
+
// Each entry is compared, in constant time: the digest is not secret, but the loop does not
|
|
195
|
+
// stop early, so the time does not say which entry is near.
|
|
196
|
+
let found: NamedToken | undefined;
|
|
197
|
+
for (const entry of tokens()) if (constantTimeEqual(digest, entry.digest)) found = entry;
|
|
198
|
+
if (!found) return { refused: "no named Caller token has this value — it was rotated, or never made" };
|
|
199
|
+
return { via: "token", name: found.name, may: found.may };
|
|
200
|
+
};
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Join the named tokens that the config declares with the digests that `jr2 up` gave the
|
|
205
|
+
* Orchestrator (ADR-0078). A declared name with no digest has no value yet, and a digest whose name
|
|
206
|
+
* the config does not declare opens nothing.
|
|
207
|
+
*/
|
|
208
|
+
export function namedTokens(
|
|
209
|
+
declared: Readonly<Record<string, { may: Scopes }>> | undefined,
|
|
210
|
+
digests: Readonly<Record<string, string>>,
|
|
211
|
+
): NamedToken[] {
|
|
212
|
+
return Object.entries(declared ?? {}).flatMap(([name, { may }]) => {
|
|
213
|
+
const digest = digests[name];
|
|
214
|
+
return typeof digest === "string" && digest !== "" ? [{ name, digest, may }] : [];
|
|
215
|
+
});
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* The digests of the named tokens, by name, from the file that `jr2 up` mounts into the
|
|
220
|
+
* Orchestrator (`{ "<name>": "<sha-256 hex>" }`). The file is read again when it changes, so a
|
|
221
|
+
* rotation needs no restart: the kubelet updates a mounted Secret on its own period, about a
|
|
222
|
+
* minute, and until then the old value works (ADR-0078). `loaded` tells which content the process
|
|
223
|
+
* holds, so `jr2 up` and `jr2 caller rotate` wait for it. An absent file reads as no digests. A file
|
|
224
|
+
* that does not parse is logged, and the digests read last stay.
|
|
225
|
+
*/
|
|
226
|
+
export function watchCallerDigests(path: string, opts: { intervalMs?: number } = {}) {
|
|
227
|
+
let digests: Record<string, string> = {};
|
|
228
|
+
// The text that gave `digests`, so that `loaded` tells what the process holds now.
|
|
229
|
+
let text: string | undefined;
|
|
230
|
+
const read = () => {
|
|
231
|
+
try {
|
|
232
|
+
const raw = readFileSync(path, "utf8");
|
|
233
|
+
const parsed = JSON.parse(raw) as unknown;
|
|
234
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) throw new Error("not a JSON object");
|
|
235
|
+
digests = Object.fromEntries(
|
|
236
|
+
Object.entries(parsed as Record<string, unknown>).filter(
|
|
237
|
+
(e): e is [string, string] => typeof e[1] === "string",
|
|
238
|
+
),
|
|
239
|
+
);
|
|
240
|
+
text = raw;
|
|
241
|
+
} catch (err) {
|
|
242
|
+
// Absent is the ordinary case for an Instance that declares no named token: no digest. A file
|
|
243
|
+
// that does not parse keeps the digests read last, so a write seen half done stops no Caller.
|
|
244
|
+
if ((err as NodeJS.ErrnoException).code === "ENOENT") {
|
|
245
|
+
digests = {};
|
|
246
|
+
text = undefined;
|
|
247
|
+
} else console.error(`caller digests at ${path} not read: ${err instanceof Error ? err.message : err}`);
|
|
248
|
+
}
|
|
249
|
+
};
|
|
250
|
+
read();
|
|
251
|
+
// A poll of the file's stat, not `fs.watch`: the kubelet swaps a symlink above the file, which an
|
|
252
|
+
// inotify watch on the old target never sees. `persistent: false` holds no process alive.
|
|
253
|
+
const listener = () => read();
|
|
254
|
+
watchFile(path, { interval: opts.intervalMs ?? 2000, persistent: false }, listener);
|
|
255
|
+
return {
|
|
256
|
+
/** The digests now. */
|
|
257
|
+
current: (): Readonly<Record<string, string>> => digests,
|
|
258
|
+
/**
|
|
259
|
+
* The digest of the file content that gave the digests now, keyed by the file's name as a
|
|
260
|
+
* mount's digest is (`configMapDigest`), so that `jr2 up` and `jr2 caller rotate` can wait
|
|
261
|
+
* until a value they wrote works (ADR-0078). Undefined when no file was read.
|
|
262
|
+
*/
|
|
263
|
+
loaded: (): string | undefined => (text === undefined ? undefined : configMapDigest({ [basename(path)]: text })),
|
|
264
|
+
/** Read the file now (a test does not wait for the poll). */
|
|
265
|
+
reload: read,
|
|
266
|
+
stop: () => unwatchFile(path, listener),
|
|
267
|
+
};
|
|
268
|
+
}
|
|
51
269
|
|
|
52
270
|
/** Who a request is, once its bearer token checks out. */
|
|
53
271
|
export type Principal =
|
|
54
|
-
/**
|
|
55
|
-
| { kind: "
|
|
272
|
+
/** A Caller (ADR-0078): the Instance token, a named token, or a JWT. */
|
|
273
|
+
| { kind: "caller"; caller: Caller }
|
|
56
274
|
/** `jr2 up`, publishing an Assembly (ADR-0069). Only `PUT /assembly` accepts it: it is not an
|
|
57
275
|
* Instance principal, so it reads no run and starts none. */
|
|
58
276
|
| { kind: "publisher" }
|
|
@@ -70,7 +288,7 @@ export const SLOT_HEADER = "x-jr2-slot";
|
|
|
70
288
|
|
|
71
289
|
/** Resolve a bearer token, and the slot a control request states, to a principal; undefined = not a
|
|
72
290
|
* token we minted, or a control token with no slot (→ 401). */
|
|
73
|
-
export type Authenticator = (bearer: string | undefined, slot?: string) => Principal | undefined
|
|
291
|
+
export type Authenticator = (bearer: string | undefined, slot?: string) => Promise<Principal | undefined>;
|
|
74
292
|
|
|
75
293
|
/** The fetch token for one Sandbox: its name, signed. Same name + key → same token, always. The
|
|
76
294
|
* bare form of ADR-0013's Sandbox token; since ADR-0076 it opens the fetch route alone. */
|
|
@@ -167,24 +385,47 @@ export async function loadSigningKey(dir: string): Promise<Buffer> {
|
|
|
167
385
|
}
|
|
168
386
|
|
|
169
387
|
/**
|
|
170
|
-
* The instance's authenticator: the
|
|
171
|
-
*
|
|
172
|
-
*
|
|
388
|
+
* The instance's authenticator: the principals inside the Orchestrator (the publish token, and any
|
|
389
|
+
* Sandbox token this key signed), then the Caller seam (ADR-0078) in its order — the Instance
|
|
390
|
+
* token, the named tokens, then `callers`. A token we did not mint resolves to nothing, and the
|
|
391
|
+
* caller is refused — there is no anonymous principal, and no route decides for itself whether it
|
|
392
|
+
* needs one.
|
|
393
|
+
*
|
|
394
|
+
* The principals come first because each is an exact value or a signed name: no Caller credential
|
|
395
|
+
* has the form of one (a named token holds no `.`), so the order changes no answer, and no
|
|
396
|
+
* authenticator of the seam ever sees a principal's token.
|
|
173
397
|
*/
|
|
174
398
|
export function createAuthenticator(opts: {
|
|
175
399
|
instanceToken: string;
|
|
176
400
|
signingKey: Buffer;
|
|
177
401
|
/** The publish token (ADR-0069). Absent → no bearer resolves to the publisher. */
|
|
178
402
|
publishToken?: string;
|
|
403
|
+
/** The named tokens, read on each request (ADR-0078). Absent → none. */
|
|
404
|
+
namedTokens?: () => readonly NamedToken[];
|
|
405
|
+
/** More authenticators of the seam, after the named tokens: the JWT issuers (callers.ts). */
|
|
406
|
+
callers?: readonly CallerAuthenticator[];
|
|
179
407
|
}): Authenticator {
|
|
180
|
-
|
|
408
|
+
const seam: CallerAuthenticator[] = [
|
|
409
|
+
instanceTokenAuthenticator(opts.instanceToken),
|
|
410
|
+
...(opts.namedTokens ? [namedTokenAuthenticator(opts.namedTokens)] : []),
|
|
411
|
+
...(opts.callers ?? []),
|
|
412
|
+
];
|
|
413
|
+
const caller = async (bearer: string): Promise<Principal | undefined> => {
|
|
414
|
+
for (const authenticate of seam) {
|
|
415
|
+
const answer = await authenticate(bearer);
|
|
416
|
+
if (answer === undefined) continue;
|
|
417
|
+
// Claimed: the first claim decides, and a bad one never falls through.
|
|
418
|
+
return "refused" in answer ? undefined : { kind: "caller", caller: answer };
|
|
419
|
+
}
|
|
420
|
+
return undefined;
|
|
421
|
+
};
|
|
422
|
+
return async (bearer, slot) => {
|
|
181
423
|
if (!bearer) return undefined;
|
|
182
|
-
if (constantTimeEqual(bearer, opts.instanceToken)) return { kind: "instance" };
|
|
183
424
|
if (opts.publishToken !== undefined && constantTimeEqual(bearer, opts.publishToken)) return { kind: "publisher" };
|
|
184
425
|
const cut = bearer.lastIndexOf(".");
|
|
185
|
-
if (cut <= 0) return
|
|
426
|
+
if (cut <= 0) return caller(bearer);
|
|
186
427
|
const name = bearer.slice(0, cut);
|
|
187
|
-
if (!constantTimeEqual(bearer.slice(cut + 1), sign(opts.signingKey, name))) return
|
|
428
|
+
if (!constantTimeEqual(bearer.slice(cut + 1), sign(opts.signingKey, name))) return caller(bearer);
|
|
188
429
|
// A control token (ADR-0076), read BEFORE the bare form. A control request states its slot; one
|
|
189
430
|
// that states none is refused like an unknown token, so no route ever sees a control principal
|
|
190
431
|
// without a slot.
|
|
@@ -220,7 +461,7 @@ function isPodName(name: string): boolean {
|
|
|
220
461
|
* control Stand-in, so only the Harness can state a slot at all (ADR-0076).
|
|
221
462
|
*/
|
|
222
463
|
export function mayDeliverToAgent(principal: Principal, registration: { sandbox?: string; slot?: string }): boolean {
|
|
223
|
-
if (principal
|
|
464
|
+
if (isInstancePrincipal(principal)) return true;
|
|
224
465
|
if (principal.kind !== "agent") return false;
|
|
225
466
|
return (
|
|
226
467
|
registration.sandbox !== undefined &&
|
|
@@ -238,15 +479,15 @@ export function mayDeliverToAgent(principal: Principal, registration: { sandbox?
|
|
|
238
479
|
* token may not: it opens the publish door and no other (ADR-0069).
|
|
239
480
|
*/
|
|
240
481
|
export function mayAskForSandbox(principal: Principal, sandbox: string): boolean {
|
|
241
|
-
if (principal
|
|
482
|
+
if (isInstancePrincipal(principal)) return true;
|
|
242
483
|
if (principal.kind !== "sandbox") return false;
|
|
243
484
|
return principal.sandbox === sandbox;
|
|
244
485
|
}
|
|
245
486
|
|
|
246
|
-
/** Whether a principal is
|
|
247
|
-
* (ADR-
|
|
248
|
-
export function
|
|
249
|
-
return principal.kind === "
|
|
487
|
+
/** Whether a principal is the Instance token's Caller: the operator, which the routes that no
|
|
488
|
+
* scope names take alone (ADR-0014). A named token is never it, whatever its scopes. */
|
|
489
|
+
export function isInstancePrincipal(principal: Principal): boolean {
|
|
490
|
+
return principal.kind === "caller" && isInstanceCaller(principal.caller);
|
|
250
491
|
}
|
|
251
492
|
|
|
252
493
|
/** An HMAC of `value` under the signing key, base64url (43 characters). Exported so every signed
|
package/src/wire.ts
CHANGED
|
@@ -152,6 +152,23 @@ export type PickAnswerNotice = {
|
|
|
152
152
|
|
|
153
153
|
export type Notice = MemoryLimitNotice | ConversationNewNotice | PickAnswerNotice;
|
|
154
154
|
|
|
155
|
+
// ---- Steering messages (ADR-0002, ADR-0062) ----------------------------------------------------
|
|
156
|
+
// Free text to one Agent slot, with its author. Not a notice: a notice is a fact with no author,
|
|
157
|
+
// and the model weighs a Steering message by who wrote it. The two ride one ledger and one
|
|
158
|
+
// transport, and the Harness frames each message by its author.
|
|
159
|
+
|
|
160
|
+
/** Who wrote a Steering message. `caller`: a Caller sent it from outside the run (ADR-0078), with
|
|
161
|
+
* the name jr2 shows and the party it says it acts for. `machine`: the Machine sent it with its
|
|
162
|
+
* `steer` action; `from` is the party whose words it relays, when it relays some. */
|
|
163
|
+
export type SteeringAuthor =
|
|
164
|
+
| { kind: "caller"; via: "token" | "jwt" | "hook"; name: string; onBehalfOf?: string }
|
|
165
|
+
| { kind: "machine"; from?: { party: "agent" | "gate"; id: string } };
|
|
166
|
+
|
|
167
|
+
/** One Steering message, as the admission and the surface read carry it. `scope: "slot"` is the
|
|
168
|
+
* address kind it uses in the ledger. `at`: when it was sent (ISO). The Harness writes the frame;
|
|
169
|
+
* `text` is the author's. */
|
|
170
|
+
export type SteeringMessage = { kind: "steering"; scope: "slot"; text: string; author: SteeringAuthor; at: string };
|
|
171
|
+
|
|
155
172
|
/** Response header: the offset to resume the stream from (every stream read carries it). */
|
|
156
173
|
export const STREAM_NEXT_OFFSET_HEADER = "stream-next-offset";
|
|
157
174
|
|
package/src/workspace.ts
CHANGED
|
@@ -295,6 +295,9 @@ export type PlaceRequest = {
|
|
|
295
295
|
name: string;
|
|
296
296
|
runId: string;
|
|
297
297
|
workflow: string;
|
|
298
|
+
/** The Workspace's path in the run's Machine: the wrapper's actor ids joined by `/` (ADR-0012).
|
|
299
|
+
* The port writes it on the CR, where the `jr2 workspace` verbs read it (ADR-0009). */
|
|
300
|
+
path: string;
|
|
298
301
|
/** The placement route from the registered Machine to this `workspace()` (`actorRoute`). With
|
|
299
302
|
* `workflow`, it names the pod whose Held secrets and Sandbox Effector `jr2 up` placed (ADR-0059). */
|
|
300
303
|
route: PlacementRoute;
|
|
@@ -327,9 +330,10 @@ export type ProvisionedRepo = { slot: string; url: string; ref?: string; perRun:
|
|
|
327
330
|
*/
|
|
328
331
|
export interface SandboxPort {
|
|
329
332
|
/**
|
|
330
|
-
* Write the Sandbox (its token Secret, then the CR, labeled with its run
|
|
331
|
-
* with NO deadline, until the operator says its pod is
|
|
332
|
-
*
|
|
333
|
+
* Write the Sandbox (its token Secret, then the CR, labeled with its run, its Workflow and its
|
|
334
|
+
* path for `jr2 workspace ls`) and wait, with NO deadline, until the operator says its pod is
|
|
335
|
+
* scheduled (ADR-0064). `image`/`user` are the wrapper's static image options
|
|
336
|
+
* (ADR-0037/0005/0049) — a `file:` context or a registry ref,
|
|
333
337
|
* the port resolves both, and a context the last converge did not build fails here rather than
|
|
334
338
|
* converge-time. `repos` are the wrapper's slots, resolved, in declaration order (ADR-0051): the
|
|
335
339
|
* port names each Repo on the CR so the cluster mounts its cache, and refuses a per-run url no
|
|
@@ -367,6 +371,20 @@ export interface SandboxPort {
|
|
|
367
371
|
* the next renewal is minutes away and the idle timeout is thirty.
|
|
368
372
|
*/
|
|
369
373
|
renew(name: string): Promise<void>;
|
|
374
|
+
/**
|
|
375
|
+
* Mark the Sandbox as held by no run (ADR-0021): the Lease stopped for the body's final state, a
|
|
376
|
+
* fault, a cancel or a parent that left the Workspace — never for a park or a process shutdown.
|
|
377
|
+
* `jr2 workspace rm` reads the mark with the cluster alone (ADR-0009). A write, and only that; a
|
|
378
|
+
* failure rejects and the caller shrugs.
|
|
379
|
+
*/
|
|
380
|
+
releaseHold(name: string): Promise<void>;
|
|
381
|
+
/**
|
|
382
|
+
* Mark every Sandbox of a run as held by no run (ADR-0021), found by its `jr2.dev/run` label: a
|
|
383
|
+
* restore ended the run `drifted` or `lost` without hosting it, so no Lease runs to release them.
|
|
384
|
+
* A write for each, and only that; a failure rejects and the caller shrugs. Optional: a port
|
|
385
|
+
* with no cluster has nothing to mark.
|
|
386
|
+
*/
|
|
387
|
+
releaseRun?(runId: string): Promise<void>;
|
|
370
388
|
/**
|
|
371
389
|
* Hear this workspace's Continuity from the watch (ADR-0021, ADR-0063): once the watch has
|
|
372
390
|
* listed, what it holds now, then again on every change the cluster reports — a Sandbox gone, or
|
|
@@ -1359,6 +1377,7 @@ function buildWorkspaceMachine(body: AnyStateMachine): AnyStateMachine {
|
|
|
1359
1377
|
name,
|
|
1360
1378
|
runId: binding.runId,
|
|
1361
1379
|
workflow: binding.workflow,
|
|
1380
|
+
path: child || input.wsId,
|
|
1362
1381
|
route,
|
|
1363
1382
|
// The image strings straight through (ADR-0037/0005) — the port owns resolution, and the
|
|
1364
1383
|
// work group's default (ADR-0005 puts it in pod composition, where the pod is built).
|
|
@@ -1522,10 +1541,22 @@ function buildWorkspaceMachine(body: AnyStateMachine): AnyStateMachine {
|
|
|
1522
1541
|
let timer: ReturnType<typeof setTimeout>;
|
|
1523
1542
|
const schedule = (ms: number) => {
|
|
1524
1543
|
timer = setTimeout(() => {
|
|
1544
|
+
if (stopped) return;
|
|
1525
1545
|
// A Workspace that ended in error stopped processing without stopping its children (xstate
|
|
1526
|
-
// does not), and a parent that handled the error only stops what still processes:
|
|
1527
|
-
// lease
|
|
1528
|
-
|
|
1546
|
+
// does not), and a parent that handled the error only stops what still processes: so this
|
|
1547
|
+
// lease is never stopped, and its cleanup below never runs. The lease asks, asserts nothing
|
|
1548
|
+
// for a Workspace that is over, and releases the hold mark here, once: no run holds the
|
|
1549
|
+
// Sandbox now (ADR-0021). A host that stops writes nothing, as below.
|
|
1550
|
+
if (self._parent?.getSnapshot().status !== "active") {
|
|
1551
|
+
if (runBindingOf(system).hostStopping) return;
|
|
1552
|
+
stopped = true;
|
|
1553
|
+
unsubscribe();
|
|
1554
|
+
// A parent that caught the defect can invoke the same Machine again: the new Workspace
|
|
1555
|
+
// has the same invoke id, so the same Sandbox name, and its own Lease holds it now.
|
|
1556
|
+
if (runBindingOf(system).heldSandboxes?.().has(name)) return;
|
|
1557
|
+
port.releaseHold(name).catch(() => {});
|
|
1558
|
+
return;
|
|
1559
|
+
}
|
|
1529
1560
|
// A failed renewal is unknown, never lost: the next one is an interval away, well inside
|
|
1530
1561
|
// the idle timeout, and loss is the watch's to report. Before the CR's write lands it is a
|
|
1531
1562
|
// 404, and the same shrug.
|
|
@@ -1539,6 +1570,19 @@ function buildWorkspaceMachine(body: AnyStateMachine): AnyStateMachine {
|
|
|
1539
1570
|
stopped = true;
|
|
1540
1571
|
clearTimeout(timer);
|
|
1541
1572
|
unsubscribe();
|
|
1573
|
+
// The hold mark (ADR-0021). A park writes nothing, so the CR stays `held` and the restored
|
|
1574
|
+
// run's Lease renews it. Else the stop is a step of the wrapper or its end: read the wrapper
|
|
1575
|
+
// once its stop or its transition is over. Still active, it is in a next state with a Lease
|
|
1576
|
+
// of its own, or deletes the Sandbox; over (a cancel, a fault, a parent that left it), no
|
|
1577
|
+
// run holds the Sandbox now.
|
|
1578
|
+
if (runBindingOf(system).hostStopping) return;
|
|
1579
|
+
queueMicrotask(() => {
|
|
1580
|
+
if (runBindingOf(system).hostStopping) return;
|
|
1581
|
+
if (self._parent?.getSnapshot().status === "active") return;
|
|
1582
|
+
// A live Workspace of the same name (a re-invoke after a caught defect) holds it now.
|
|
1583
|
+
if (runBindingOf(system).heldSandboxes?.().has(name)) return;
|
|
1584
|
+
port.releaseHold(name).catch(() => {});
|
|
1585
|
+
});
|
|
1542
1586
|
};
|
|
1543
1587
|
},
|
|
1544
1588
|
);
|
package/src/worktree.ts
CHANGED
|
@@ -124,7 +124,7 @@ export type WorktreeInput =
|
|
|
124
124
|
expect?: never;
|
|
125
125
|
};
|
|
126
126
|
|
|
127
|
-
/** What a cut found
|
|
127
|
+
/** What a cut found. `held`: a cut made this Worktree earlier, so what is in it is earlier work;
|
|
128
128
|
* `local`, a local branch; `remote`, `origin/<branch>`, now tracked; `none`, a new branch with no
|
|
129
129
|
* upstream; `commit`, a detached cut. Open: a later value does not break an author's code. */
|
|
130
130
|
export type WorktreeFound = "held" | "local" | "remote" | "none" | "commit" | (string & {});
|
|
@@ -143,8 +143,8 @@ export type WorktreeReady<S extends string = string> = {
|
|
|
143
143
|
stale?: string;
|
|
144
144
|
};
|
|
145
145
|
|
|
146
|
-
/** No Worktree, and nothing changed
|
|
147
|
-
* not agree with the first, a branch that another Worktree holds, an `expect` that does not hold, or a
|
|
146
|
+
/** No Worktree, and nothing changed. The cause is a bad path, a name git or the push refuses, a
|
|
147
|
+
* re-cut that does not agree with the first, a branch that another Worktree holds, an `expect` that does not hold, or a
|
|
148
148
|
* name that the remote cannot confirm. */
|
|
149
149
|
export type WorktreeRefused = { outcome: "refused"; reason: string };
|
|
150
150
|
|