@jr2/orchestrator 0.5.1 → 0.6.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/docs/agents-block.md +2 -2
- package/docs/concepts/agents-and-turns.md +56 -20
- package/docs/concepts/architecture.md +30 -17
- package/docs/concepts/charts.md +6 -3
- package/docs/concepts/converge.md +3 -0
- package/docs/concepts/effects.md +86 -31
- package/docs/concepts/machines-and-workflows.md +9 -0
- package/docs/concepts/pools-and-sources.md +6 -6
- package/docs/concepts/secrets-and-trust.md +126 -42
- package/docs/concepts/the-menu.md +3 -0
- package/docs/concepts/workspaces.md +129 -51
- package/docs/get-started/an-agent-in-a-sandbox.md +21 -19
- package/docs/get-started/have-your-assistant-write-a-machine.md +111 -40
- package/docs/get-started/install.md +4 -0
- package/docs/guides/add-tools-to-the-sandbox.md +17 -4
- package/docs/guides/bind-open-parts.md +5 -4
- package/docs/guides/cut-a-worktree.md +560 -0
- package/docs/guides/fix-a-failed-converge.md +1 -1
- package/docs/guides/let-an-agent-write-a-chart.md +5 -1
- package/docs/guides/look-inside-a-sandbox.md +18 -15
- package/docs/guides/move-from-kind-to-a-shared-cluster.md +16 -0
- package/docs/guides/push-and-sign-a-branch.md +67 -21
- package/docs/guides/run-a-command-or-call-a-url.md +84 -41
- package/docs/guides/run-one-worker-per-item.md +19 -18
- package/docs/guides/set-an-agents-access-and-dials.md +67 -22
- package/docs/guides/size-a-workspace.md +19 -6
- package/docs/guides/use-a-hosted-provider.md +6 -0
- package/docs/guides/watch-a-run.md +47 -24
- package/docs/guides/write-a-workflow.md +1 -1
- package/docs/llms.txt +33 -12
- package/docs/reference/authoring-surface/agent.md +34 -11
- package/docs/reference/authoring-surface/answer.md +19 -3
- package/docs/reference/authoring-surface/arg.md +27 -5
- package/docs/reference/authoring-surface/branchname.md +15 -0
- package/docs/reference/authoring-surface/command.md +36 -21
- package/docs/reference/authoring-surface/fail.md +17 -4
- package/docs/reference/authoring-surface/file.md +36 -16
- package/docs/reference/authoring-surface/http.md +108 -8
- package/docs/reference/authoring-surface/isready.md +14 -0
- package/docs/reference/authoring-surface/jr2setup.md +1 -1
- package/docs/reference/authoring-surface/open.md +36 -5
- package/docs/reference/authoring-surface/paramsof.md +22 -4
- package/docs/reference/authoring-surface/pool.md +4 -4
- package/docs/reference/authoring-surface/push.md +30 -12
- package/docs/reference/authoring-surface/readyof.md +21 -0
- package/docs/reference/authoring-surface/secret.md +25 -4
- package/docs/reference/authoring-surface/source.md +3 -3
- package/docs/reference/authoring-surface/type-agentdefinition.md +2 -2
- package/docs/reference/authoring-surface/type-agentslots.md +4 -1
- package/docs/reference/authoring-surface/type-anyframepath.md +14 -0
- package/docs/reference/authoring-surface/type-anyworktreepath.md +15 -0
- package/docs/reference/authoring-surface/type-bindactors.md +14 -0
- package/docs/reference/authoring-surface/type-binding.md +5 -6
- package/docs/reference/authoring-surface/type-commandinput.md +4 -3
- package/docs/reference/authoring-surface/type-commandoptions.md +0 -22
- package/docs/reference/authoring-surface/type-commandoutput.md +22 -4
- package/docs/reference/authoring-surface/type-customize.md +4 -2
- package/docs/reference/authoring-surface/type-dispatchoutput.md +8 -1
- package/docs/reference/authoring-surface/type-doorlessinput.md +19 -0
- package/docs/reference/authoring-surface/type-filebody.md +4 -8
- package/docs/reference/authoring-surface/type-framepath.md +14 -0
- package/docs/reference/authoring-surface/type-hostinjectedinput.md +2 -4
- package/docs/reference/authoring-surface/type-httpdefinition.md +6 -3
- package/docs/reference/authoring-surface/type-httpfileinput.md +20 -0
- package/docs/reference/authoring-surface/type-httpoutput.md +7 -1
- package/docs/reference/authoring-surface/type-lateroutcome.md +21 -0
- package/docs/reference/authoring-surface/type-namestart.md +77 -0
- package/docs/reference/authoring-surface/type-noslotworkspaceoptions.md +1 -6
- package/docs/reference/authoring-surface/type-notworktreekeys.md +12 -0
- package/docs/reference/authoring-surface/type-opencommandinput.md +7 -2
- package/docs/reference/authoring-surface/type-permissivenoslotworkspaceoptions.md +1 -6
- package/docs/reference/authoring-surface/type-permissiveworkspaceoptions.md +5 -10
- package/docs/reference/authoring-surface/type-poolnextinput.md +22 -0
- package/docs/reference/authoring-surface/type-poolspec.md +2 -2
- package/docs/reference/authoring-surface/type-pushinput.md +22 -3
- package/docs/reference/authoring-surface/type-pushoutput.md +32 -7
- package/docs/reference/authoring-surface/type-reposlot.md +5 -5
- package/docs/reference/authoring-surface/type-sandboxoptions.md +11 -4
- package/docs/reference/authoring-surface/type-slothandles.md +5 -13
- package/docs/reference/authoring-surface/type-sourcespec.md +10 -10
- package/docs/reference/authoring-surface/type-workspaced.md +29 -8
- package/docs/reference/authoring-surface/type-workspacehandles.md +5 -6
- package/docs/reference/authoring-surface/type-workspaceoptions.md +1 -13
- package/docs/reference/authoring-surface/type-worktreefound.md +16 -0
- package/docs/reference/authoring-surface/type-worktreeinput.md +56 -0
- package/docs/reference/authoring-surface/type-worktreekey.md +14 -0
- package/docs/reference/authoring-surface/type-worktreeoptions.md +29 -0
- package/docs/reference/authoring-surface/type-worktreeoutput.md +45 -0
- package/docs/reference/authoring-surface/type-worktreepath.md +16 -0
- package/docs/reference/authoring-surface/type-worktreeready.md +37 -0
- package/docs/reference/authoring-surface/type-worktreerefused.md +21 -0
- package/docs/reference/authoring-surface/workspace.md +65 -37
- package/docs/reference/authoring-surface/worktree.md +71 -0
- package/docs/reference/authoring-surface.md +30 -10
- package/docs/reference/cli.md +1 -1
- package/docs/reference/config.md +13 -4
- package/docs/reference/decisions/0001-operator-owned-generic-sandbox.md +10 -9
- package/docs/reference/decisions/0002-duplex-streaming-actor.md +17 -10
- package/docs/reference/decisions/0004-worktree-provisioning-model.md +27 -21
- package/docs/reference/decisions/0005-sandbox-pod-composition.md +29 -19
- package/docs/reference/decisions/0006-agent-control-surface.md +6 -3
- package/docs/reference/decisions/0012-workspace-wrapper-machine.md +30 -30
- package/docs/reference/decisions/0013-the-agent-reaches-its-machine-through-a-container-it-cannot-read.md +35 -17
- package/docs/reference/decisions/0015-authoring-surface-absorbs-the-mechanism.md +8 -2
- package/docs/reference/decisions/0016-agent-turn-mechanics-are-internal.md +9 -7
- package/docs/reference/decisions/0017-pool-over-a-source-port.md +6 -4
- package/docs/reference/decisions/0019-one-converging-command-against-the-current-context.md +21 -9
- package/docs/reference/decisions/0021-workspace-continuity-is-a-lease-that-answers-back.md +2 -1
- package/docs/reference/decisions/0023-the-harness-prints-the-conversation.md +8 -9
- package/docs/reference/decisions/0024-an-agents-turn-ends-with-the-state-that-asked-for-it.md +62 -65
- package/docs/reference/decisions/0027-the-harness-is-jr2s-own-server-flue-retires-the-wire-stays.md +9 -1
- package/docs/reference/decisions/0028-what-an-agent-may-do-to-the-workspace-is-part-of-its-definition.md +13 -12
- package/docs/reference/decisions/0031-menu-only-agents-run-on-the-instance-harness.md +4 -1
- package/docs/reference/decisions/0033-a-machine-declares-the-input-that-starts-it.md +18 -19
- package/docs/reference/decisions/0037-an-instance-builds-its-sandbox-images-jr2-injects-the-harness.md +8 -8
- package/docs/reference/decisions/0049-a-machine-carries-its-parts-and-composes-by-invoke.md +4 -4
- package/docs/reference/decisions/0051-a-repo-is-a-slot-on-the-workspace-and-a-cache-on-the-node.md +57 -46
- package/docs/reference/decisions/0052-a-sandbox-lands-where-an-ordinary-pod-lands-and-the-cache-follows-it.md +5 -0
- package/docs/reference/decisions/0053-a-fetch-inside-the-pod-asks-the-node-cache-and-the-cache-asks-the-remote.md +20 -8
- package/docs/reference/decisions/0057-a-turn-is-its-frame-its-dials-and-whether-it-continues.md +24 -24
- package/docs/reference/decisions/0058-a-harness-answers-the-orchestrator-alone.md +5 -3
- package/docs/reference/decisions/0059-a-harness-holds-stand-ins-and-the-custodian-holds-the-keys.md +110 -67
- package/docs/reference/decisions/0062-jr2-briefs-the-agent-on-its-seat-never-on-its-task.md +101 -74
- package/docs/reference/decisions/0063-the-orchestrator-watches-the-cluster.md +5 -6
- package/docs/reference/decisions/0066-a-model-tier-runs-a-real-model-and-a-change-to-what-it-reads-runs-the-tier.md +2 -0
- package/docs/reference/decisions/0067-an-effect-is-an-actor-slot-and-runs-at-most-once-unless-marked-idempotent.md +181 -153
- package/docs/reference/decisions/0068-an-effector-holds-an-effects-secrets-and-signs-its-wire.md +74 -63
- package/docs/reference/decisions/0070-a-machine-lends-its-parts-to-a-chart.md +31 -20
- package/docs/reference/decisions/0073-a-machine-fails-a-run-with-fail-and-an-uncaught-failure-ends-it-failed.md +1 -1
- package/docs/reference/decisions/0075-a-machine-cuts-a-worktree-and-the-workspace-only-clones.md +295 -0
- package/docs/reference/decisions/0076-an-agents-control-credential-lives-only-where-no-workspace-code-runs.md +320 -0
- package/docs/reference/decisions/0077-jr2-requires-landlock-on-every-sandbox-node.md +79 -0
- package/docs/reference/decisions.md +3 -0
- package/docs/reference/glossary.md +51 -12
- package/docs/reference/http-api.md +45 -7
- package/docs/reference/machines.md +0 -1
- package/docs/why.md +5 -4
- package/package.json +3 -3
- package/src/actor.ts +161 -35
- package/src/agent.ts +6 -6
- package/src/ambient.ts +3 -5
- package/src/chart.ts +186 -19
- package/src/charts.ts +9 -2
- package/src/config.ts +16 -6
- package/src/custodian.lua +17 -5
- package/src/custodian.ts +251 -77
- package/src/customize.ts +11 -3
- package/src/effect-checks.ts +50 -3
- package/src/effect.ts +343 -127
- package/src/effector-wire.ts +6 -3
- package/src/fail.ts +17 -4
- package/src/harness-client.ts +74 -31
- package/src/harness-env.ts +33 -0
- package/src/held-secrets.ts +125 -16
- package/src/holds.ts +3 -0
- package/src/http-effect.ts +88 -32
- package/src/http.ts +56 -17
- package/src/index.ts +15 -1
- package/src/internal.ts +1 -0
- package/src/kube-client.ts +6 -3
- package/src/machine-doc.ts +3 -4
- package/src/model-keys.ts +24 -0
- package/src/names.ts +19 -6
- package/src/notices.ts +443 -54
- package/src/open-body.ts +85 -19
- package/src/open.ts +36 -5
- package/src/parts.ts +40 -28
- package/src/party.ts +33 -0
- package/src/pick.ts +41 -7
- package/src/pool.ts +44 -23
- package/src/registration.ts +53 -11
- package/src/repo-identity.ts +7 -3
- package/src/repos.ts +20 -2
- package/src/run-host.ts +106 -20
- package/src/sandbox-effect.ts +217 -66
- package/src/sandbox-kube.ts +63 -16
- package/src/secret-placement.ts +9 -2
- package/src/setup.ts +86 -6
- package/src/tokens.ts +107 -32
- package/src/vocabulary.ts +23 -4
- package/src/wire.ts +10 -10
- package/src/workspace.ts +264 -253
- package/src/worktree.ts +249 -0
- package/docs/reference/authoring-surface/type-noslotspec.md +0 -26
- package/docs/reference/authoring-surface/type-workspacespec.md +0 -37
package/src/effect.ts
CHANGED
|
@@ -44,6 +44,7 @@ import type { ExecOutcome, ExecRead, ExecRequest, SyncAnswer } from "./wire.ts";
|
|
|
44
44
|
import { bindOpenSecretIn, type EffectorPortFactory, type OpenSecretIn } from "./http-effect.ts";
|
|
45
45
|
import { commandEnvParts, credentialedCommandWith, sandboxEffectorPort, type CommandEnv } from "./sandbox-effect.ts";
|
|
46
46
|
import { open } from "./open.ts";
|
|
47
|
+
import type { AnyWorktreePath, WorktreeOutput } from "./worktree.ts";
|
|
47
48
|
import {
|
|
48
49
|
effectBindingShape,
|
|
49
50
|
openEffectLogic,
|
|
@@ -76,30 +77,52 @@ export type Arg<N extends string = string> = { readonly arg: N };
|
|
|
76
77
|
*
|
|
77
78
|
* @example
|
|
78
79
|
* ```ts
|
|
79
|
-
* import {
|
|
80
|
+
* import {
|
|
81
|
+
* arg,
|
|
82
|
+
* command,
|
|
83
|
+
* fail,
|
|
84
|
+
* isReady,
|
|
85
|
+
* jr2Setup,
|
|
86
|
+
* workspace,
|
|
87
|
+
* worktree,
|
|
88
|
+
* type Workspaced,
|
|
89
|
+
* } from "@jr2/orchestrator";
|
|
80
90
|
*
|
|
81
91
|
* type Input = Workspaced<{ file: string }, "app">;
|
|
82
92
|
*
|
|
93
|
+
* const lintTree = (context: Input) => `${context.workspace.repos.app}/lint` as const;
|
|
94
|
+
*
|
|
83
95
|
* const body = jr2Setup({
|
|
84
96
|
* types: {} as { context: Input; input: Input },
|
|
85
|
-
* actors: { lint: command(["npx", "eslint", arg("file")]), fail: fail() },
|
|
97
|
+
* actors: { cut: worktree(), lint: command(["npx", "eslint", arg("file")]), fail: fail() },
|
|
86
98
|
* }).createMachine({
|
|
87
99
|
* id: "lint",
|
|
88
100
|
* context: ({ input }) => input,
|
|
89
101
|
* on: { "workspace.lost": ".lost" },
|
|
90
|
-
* initial: "
|
|
102
|
+
* initial: "cutting",
|
|
91
103
|
* states: {
|
|
104
|
+
* cutting: {
|
|
105
|
+
* invoke: {
|
|
106
|
+
* src: "cut",
|
|
107
|
+
* input: ({ context }) => ({ path: lintTree(context), branch: "lint" }),
|
|
108
|
+
* onDone: [{ guard: ({ event }) => isReady(event.output), target: "lint" }, { target: "notCut" }],
|
|
109
|
+
* },
|
|
110
|
+
* },
|
|
92
111
|
* lint: {
|
|
93
|
-
* invoke: {
|
|
112
|
+
* invoke: {
|
|
113
|
+
* src: "lint",
|
|
114
|
+
* input: ({ context }) => ({ file: context.file, worktree: lintTree(context) }),
|
|
115
|
+
* onDone: "done",
|
|
116
|
+
* },
|
|
94
117
|
* },
|
|
95
118
|
* done: { type: "final" },
|
|
119
|
+
* notCut: { invoke: { src: "fail", input: { reason: "the Worktree was not cut" } } },
|
|
96
120
|
* lost: { invoke: { src: "fail", input: { reason: "the Sandbox is gone" } } },
|
|
97
121
|
* },
|
|
98
122
|
* });
|
|
99
123
|
*
|
|
100
124
|
* export const machine = workspace(body, {
|
|
101
125
|
* repos: { app: { url: "https://github.com/you/app.git" } },
|
|
102
|
-
* spec: () => ({ branch: "lint" }),
|
|
103
126
|
* });
|
|
104
127
|
* ```
|
|
105
128
|
*
|
|
@@ -109,6 +132,13 @@ export type Arg<N extends string = string> = { readonly arg: N };
|
|
|
109
132
|
*/
|
|
110
133
|
export const arg = <const N extends string>(name: N): Arg<N> => ({ arg: name });
|
|
111
134
|
|
|
135
|
+
/** What an Effect's input takes: `required` keys, `optional` keys, and exactly one of `oneOf`. */
|
|
136
|
+
export type InputShape = {
|
|
137
|
+
readonly required: readonly string[];
|
|
138
|
+
readonly optional: readonly string[];
|
|
139
|
+
readonly oneOf?: readonly string[];
|
|
140
|
+
};
|
|
141
|
+
|
|
112
142
|
/** A `command` Effect's plain data. */
|
|
113
143
|
export type CommandParts = {
|
|
114
144
|
readonly kind: "command";
|
|
@@ -124,8 +154,12 @@ export type CommandParts = {
|
|
|
124
154
|
/** A credentialed command's `pin`: its input names the commit (`{ sha }`, an approved one) in
|
|
125
155
|
* place of the Agent's branch tip (ADR-0067). */
|
|
126
156
|
readonly pin?: true;
|
|
127
|
-
/** The
|
|
128
|
-
|
|
157
|
+
/** The kit's cut (`worktree()`): its `expect`, static, so that a Chart cannot change it. A command
|
|
158
|
+
* with this is the kit's program, and its output is a `WorktreeOutput`. */
|
|
159
|
+
readonly cut?: { readonly expect?: "new" | "existing" };
|
|
160
|
+
/** Marked by the kit, on a cut alone: a restore or a Harness restart sends it again, under its
|
|
161
|
+
* id. An author has no such option: a command is at most once (ADR-0067). */
|
|
162
|
+
readonly idempotent?: true;
|
|
129
163
|
readonly timeoutMs: number;
|
|
130
164
|
/** Characters of the stream's end kept in the output. The full stream goes to the feed. */
|
|
131
165
|
readonly tail: number;
|
|
@@ -156,16 +190,15 @@ export type HttpParts = {
|
|
|
156
190
|
/** Marked by the author: a restore that cannot find the Effect sends it again. */
|
|
157
191
|
readonly idempotent: boolean;
|
|
158
192
|
/** The body is a Worktree file (`body: file(...)`, ADR-0067): the Effect runs in the Sandbox
|
|
159
|
-
* Effector of the Workspace whose body invokes it. `
|
|
160
|
-
* the file — needed only where the Workspace carries several. Absent: a json, text or no body, in
|
|
193
|
+
* Effector of the Workspace whose body invokes it. The input's `worktree` holds the file. Absent: a json, text or no body, in
|
|
161
194
|
* the Instance Effector. */
|
|
162
|
-
readonly file?: { readonly
|
|
195
|
+
readonly file?: { readonly contentType?: string };
|
|
163
196
|
};
|
|
164
197
|
|
|
165
198
|
/**
|
|
166
|
-
* A `push` Effect's plain data (ADR-0067, ADR-
|
|
199
|
+
* A `push` Effect's plain data (ADR-0067, ADR-0075): a cut Worktree's branch to its Repo Slot's
|
|
167
200
|
* Binding. `sign`, `squash`, `force` are the author's; `pin` says the Effect's input names the
|
|
168
|
-
* commit (an approved one) instead of the
|
|
201
|
+
* commit (an approved one) instead of the branch's tip.
|
|
169
202
|
*/
|
|
170
203
|
export type PushParts = {
|
|
171
204
|
readonly kind: "push";
|
|
@@ -291,10 +324,30 @@ export function targetText(parts: { readonly target: HttpParts["target"] | typeo
|
|
|
291
324
|
* `customize()` binds it (ADR-0054); and for a dispatch with no input schema: the callee's door is
|
|
292
325
|
* the only check, so nothing here says what it reads (ADR-0071). */
|
|
293
326
|
export function inputKeysOf(parts: EffectParts): string[] | undefined {
|
|
294
|
-
|
|
295
|
-
|
|
327
|
+
const shape = inputShapeOf(parts);
|
|
328
|
+
return shape && [...shape.required, ...shape.optional, ...(shape.oneOf ?? [])];
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/** The shape of a cut's input. */
|
|
332
|
+
const CUT_SHAPE: InputShape = { required: ["path"], optional: ["from"], oneOf: ["branch", "detach"] };
|
|
333
|
+
|
|
334
|
+
/** An Effect's input as a Chart's door and its start hold a Chart to it. Undefined where
|
|
335
|
+
* {@link inputKeysOf} is. `worktree` is optional for a key the Workspace decides: the door makes it
|
|
336
|
+
* required when it knows that the Workspace has Repo Slots, and a start refuses it then. */
|
|
337
|
+
export function inputShapeOf(parts: EffectParts): InputShape | undefined {
|
|
338
|
+
if (parts.kind === "dispatch") {
|
|
339
|
+
return parts.inputKeys === undefined ? undefined : { required: [...parts.inputKeys], optional: [] };
|
|
340
|
+
}
|
|
341
|
+
if (parts.kind === "push") return { required: [...parts.inputKeys], optional: [] };
|
|
342
|
+
if (parts.kind === "http") {
|
|
343
|
+
return { required: [...parts.inputKeys], optional: parts.file ? ["worktree"] : [] };
|
|
344
|
+
}
|
|
345
|
+
if (parts.cut) return CUT_SHAPE;
|
|
296
346
|
if (parts.argv === open) return undefined;
|
|
297
|
-
|
|
347
|
+
const keys = [...parts.argv.flatMap((a) => (typeof a === "string" ? [] : [a.arg])), ...(parts.pin ? ["sha"] : [])];
|
|
348
|
+
// A command with an Effect secret runs on a commit of a Worktree, so it names one in each Workspace.
|
|
349
|
+
const credentialed = (parts.secrets?.length ?? 0) > 0 || parts.openSecret === true;
|
|
350
|
+
return credentialed ? { required: [...keys, "worktree"], optional: [] } : { required: keys, optional: ["worktree"] };
|
|
298
351
|
}
|
|
299
352
|
|
|
300
353
|
// --- Output -------------------------------------------------------------------------------------
|
|
@@ -304,6 +357,11 @@ export function inputKeysOf(parts: EffectParts): string[] | undefined {
|
|
|
304
357
|
* not run the Effect again. */
|
|
305
358
|
export type Unknown = { outcome: "unknown"; reason: string };
|
|
306
359
|
|
|
360
|
+
/** A value of `outcome` that this kit does not give. It stands in the union so that no author can
|
|
361
|
+
* write an exhaustive check, so that a later outcome is no break: route on what you know, and give
|
|
362
|
+
* the rest a last arm. */
|
|
363
|
+
export type LaterOutcome = { outcome: "(a later outcome)"; reason?: string };
|
|
364
|
+
|
|
307
365
|
// `dirty` is a credentialed command's alone (ADR-0067): it runs in the Sandbox Effector against
|
|
308
366
|
// the pinned commit, never the live Worktree.
|
|
309
367
|
/**
|
|
@@ -321,34 +379,48 @@ export type Unknown = { outcome: "unknown"; reason: string };
|
|
|
321
379
|
* characters. `dirty` is `true` when a command that names an Effect secret ran against a commit,
|
|
322
380
|
* and the Worktree held changes that it did not see.
|
|
323
381
|
*
|
|
382
|
+
* The union is open ({@link LaterOutcome}): give the rest a last arm with no guard.
|
|
383
|
+
*
|
|
324
384
|
* @example
|
|
325
385
|
* ```ts
|
|
326
386
|
* import {
|
|
327
387
|
* command,
|
|
328
388
|
* type CommandOutput,
|
|
329
389
|
* fail,
|
|
390
|
+
* isReady,
|
|
330
391
|
* jr2Setup,
|
|
331
392
|
* workspace,
|
|
393
|
+
* worktree,
|
|
332
394
|
* type Workspaced,
|
|
333
395
|
* } from "@jr2/orchestrator";
|
|
334
396
|
*
|
|
335
397
|
* type Input = Workspaced<{}, "app">;
|
|
336
398
|
*
|
|
399
|
+
* const testTree = (context: Input) => `${context.workspace.repos.app}/test` as const;
|
|
400
|
+
*
|
|
337
401
|
* const passed = (output: CommandOutput): boolean =>
|
|
338
402
|
* output.outcome === "exited" && output.code === 0;
|
|
339
403
|
*
|
|
340
404
|
* const body = jr2Setup({
|
|
341
405
|
* types: {} as { context: Input; input: Input },
|
|
342
|
-
* actors: { tests: command(["npm", "test"], { timeout: "15m" }), fail: fail() },
|
|
406
|
+
* actors: { cut: worktree(), tests: command(["npm", "test"], { timeout: "15m" }), fail: fail() },
|
|
343
407
|
* }).createMachine({
|
|
344
408
|
* id: "tests",
|
|
345
409
|
* context: ({ input }) => input,
|
|
346
410
|
* on: { "workspace.lost": ".lost" },
|
|
347
|
-
* initial: "
|
|
411
|
+
* initial: "cutting",
|
|
348
412
|
* states: {
|
|
413
|
+
* cutting: {
|
|
414
|
+
* invoke: {
|
|
415
|
+
* src: "cut",
|
|
416
|
+
* input: ({ context }) => ({ path: testTree(context), branch: "tests" }),
|
|
417
|
+
* onDone: [{ guard: ({ event }) => isReady(event.output), target: "testing" }, { target: "notCut" }],
|
|
418
|
+
* },
|
|
419
|
+
* },
|
|
349
420
|
* testing: {
|
|
350
421
|
* invoke: {
|
|
351
422
|
* src: "tests",
|
|
423
|
+
* input: ({ context }) => ({ worktree: testTree(context) }),
|
|
352
424
|
* onDone: [
|
|
353
425
|
* { guard: ({ event }) => passed(event.output), target: "green" },
|
|
354
426
|
* { target: "red" },
|
|
@@ -358,13 +430,13 @@ export type Unknown = { outcome: "unknown"; reason: string };
|
|
|
358
430
|
* green: { type: "final" },
|
|
359
431
|
* // Not a final state: a final state means `done`, and the run ends `failed` here.
|
|
360
432
|
* red: { invoke: { src: "fail", input: { reason: "npm test did not pass" } } },
|
|
433
|
+
* notCut: { invoke: { src: "fail", input: { reason: "the Worktree was not cut" } } },
|
|
361
434
|
* lost: { invoke: { src: "fail", input: { reason: "the Sandbox is gone" } } },
|
|
362
435
|
* },
|
|
363
436
|
* });
|
|
364
437
|
*
|
|
365
438
|
* export const machine = workspace(body, {
|
|
366
439
|
* repos: { app: { url: "https://github.com/you/app.git" } },
|
|
367
|
-
* spec: () => ({ branch: "tests" }),
|
|
368
440
|
* });
|
|
369
441
|
* ```
|
|
370
442
|
*
|
|
@@ -372,7 +444,7 @@ export type Unknown = { outcome: "unknown"; reason: string };
|
|
|
372
444
|
* @see docs/guides/run-a-command-or-call-a-url.md
|
|
373
445
|
* @see docs/reference/decisions/0067-an-effect-is-an-actor-slot-and-runs-at-most-once-unless-marked-idempotent.md
|
|
374
446
|
*/
|
|
375
|
-
export type CommandOutput = (ExecOutcome & { dirty?: boolean }) | Unknown;
|
|
447
|
+
export type CommandOutput = (ExecOutcome & { dirty?: boolean }) | Unknown | LaterOutcome;
|
|
376
448
|
|
|
377
449
|
// Reach is output, as an http Effect's is (ADR-0067): a body a model wrote can be fixed through
|
|
378
450
|
// the Chain, so a refusal is output too.
|
|
@@ -390,6 +462,8 @@ export type CommandOutput = (ExecOutcome & { dirty?: boolean }) | Unknown;
|
|
|
390
462
|
* - `unreachable`: the Orchestrator of the target did not answer. No run started.
|
|
391
463
|
* - `unknown`: jr2 cannot tell if a run started.
|
|
392
464
|
*
|
|
465
|
+
* The union is open ({@link LaterOutcome}): give the rest a last arm with no guard.
|
|
466
|
+
*
|
|
393
467
|
* @see docs/concepts/effects.md
|
|
394
468
|
*/
|
|
395
469
|
export type DispatchOutput =
|
|
@@ -402,7 +476,8 @@ export type DispatchOutput =
|
|
|
402
476
|
}
|
|
403
477
|
| { outcome: "refused"; status: number; report: string[] }
|
|
404
478
|
| { outcome: "unreachable"; reason: string }
|
|
405
|
-
| Unknown
|
|
479
|
+
| Unknown
|
|
480
|
+
| LaterOutcome;
|
|
406
481
|
|
|
407
482
|
// --- The ledger row (persisted in the run's blob, ADR-0007) --------------------------------------
|
|
408
483
|
|
|
@@ -445,15 +520,19 @@ export type EffectRow =
|
|
|
445
520
|
run?: string;
|
|
446
521
|
at: string;
|
|
447
522
|
}
|
|
448
|
-
/** `sandbox` and `
|
|
449
|
-
* — every later push of its own
|
|
450
|
-
*
|
|
523
|
+
/** `sandbox`, `repo` and `branch`: the Workspace, the Repo Slot and the branch an Effect of the
|
|
524
|
+
* Sandbox Effector ran on — every later push of its own (slot, branch) sends its pair, and the
|
|
525
|
+
* newest push's unfinished sync runs again in `worktree`. `cut`: a `worktree()` that settled
|
|
526
|
+
* `ready`: its path and branch as fields, because a settled row drops its tail. A push, a
|
|
527
|
+
* credentialed command and a file body read their branch here. */
|
|
451
528
|
| {
|
|
452
529
|
state: "settled";
|
|
453
530
|
kind: EffectParts["kind"];
|
|
454
531
|
slot: string;
|
|
455
532
|
sandbox?: string;
|
|
456
533
|
repo?: string;
|
|
534
|
+
branch?: string;
|
|
535
|
+
cut?: { path: string; branch: string | null };
|
|
457
536
|
output: unknown;
|
|
458
537
|
at: string;
|
|
459
538
|
}
|
|
@@ -530,19 +609,19 @@ export type PushedRow = { published: string; source: string; sync?: SyncAnswer["
|
|
|
530
609
|
unknown
|
|
531
610
|
>;
|
|
532
611
|
|
|
533
|
-
/** The ledger's last push of a
|
|
534
|
-
* Effect's (`self`).
|
|
535
|
-
*/
|
|
612
|
+
/** The ledger's last push of a (slot, branch) of a Workspace that published: its row, by `at`, never
|
|
613
|
+
* this Effect's (`self`). Each (slot, branch) has its own pairs: one branch on one remote. */
|
|
536
614
|
export function lastPushOf(
|
|
537
615
|
rows: Readonly<Record<string, EffectRow>>,
|
|
538
616
|
sandbox: string,
|
|
539
617
|
repo: string,
|
|
618
|
+
branch: string,
|
|
540
619
|
self?: string,
|
|
541
620
|
): { rowId: string; row: Extract<EffectRow, { state: "settled" }>; output: PushedRow } | undefined {
|
|
542
621
|
let best: { rowId: string; row: Extract<EffectRow, { state: "settled" }>; output: PushedRow } | undefined;
|
|
543
622
|
for (const [rowId, row] of Object.entries(rows)) {
|
|
544
623
|
if (rowId === self || row.state !== "settled" || row.kind !== "push") continue;
|
|
545
|
-
if (row.sandbox !== sandbox || row.repo !== repo) continue;
|
|
624
|
+
if (row.sandbox !== sandbox || row.repo !== repo || row.branch !== branch) continue;
|
|
546
625
|
const output = row.output as Partial<PushedRow> & { outcome?: unknown };
|
|
547
626
|
if (output?.outcome !== "pushed" || typeof output.published !== "string" || typeof output.source !== "string") {
|
|
548
627
|
continue;
|
|
@@ -552,20 +631,21 @@ export function lastPushOf(
|
|
|
552
631
|
return best;
|
|
553
632
|
}
|
|
554
633
|
|
|
555
|
-
/** Every pair the ledger holds for a
|
|
556
|
-
* published, oldest first by `at`, never this Effect's (`self`) — its published commit, its
|
|
557
|
-
* and its own `squashed`. What a push start carries, so the Effector sees every push's pair. */
|
|
634
|
+
/** Every pair the ledger holds for a (slot, branch) of a Workspace (ADR-0067): each settled push row
|
|
635
|
+
* that published, oldest first by `at`, never this Effect's (`self`) — its published commit, its
|
|
636
|
+
* source, and its own `squashed`. What a push start carries, so the Effector sees every push's pair. */
|
|
558
637
|
export function pushPairsOf(
|
|
559
638
|
rows: Readonly<Record<string, EffectRow>>,
|
|
560
639
|
sandbox: string,
|
|
561
640
|
repo: string,
|
|
641
|
+
branch: string,
|
|
562
642
|
self?: string,
|
|
563
643
|
): Array<{ published: string; source: string; squashed?: "signed" | "unsigned" }> {
|
|
564
644
|
const found: Array<{ at: string; pair: { published: string; source: string; squashed?: "signed" | "unsigned" } }> =
|
|
565
645
|
[];
|
|
566
646
|
for (const [rowId, row] of Object.entries(rows)) {
|
|
567
647
|
if (rowId === self || row.state !== "settled" || row.kind !== "push") continue;
|
|
568
|
-
if (row.sandbox !== sandbox || row.repo !== repo) continue;
|
|
648
|
+
if (row.sandbox !== sandbox || row.repo !== repo || row.branch !== branch) continue;
|
|
569
649
|
const output = row.output as Partial<PushedRow> & { outcome?: unknown; squashed?: unknown };
|
|
570
650
|
if (output?.outcome !== "pushed" || typeof output.published !== "string" || typeof output.source !== "string") {
|
|
571
651
|
continue;
|
|
@@ -579,21 +659,41 @@ export function pushPairsOf(
|
|
|
579
659
|
return found.sort((a, b) => (a.at < b.at ? -1 : a.at > b.at ? 1 : 0)).map((f) => f.pair);
|
|
580
660
|
}
|
|
581
661
|
|
|
582
|
-
/** The row id of each
|
|
662
|
+
/** The row id of each (slot, branch)'s newest push that published (`lastPushOf`), over the ledger. */
|
|
583
663
|
function newestPushesOf(effects: Readonly<Record<string, EffectRow>>): Set<string> {
|
|
584
664
|
const ids = new Set<string>();
|
|
585
665
|
const seen = new Set<string>();
|
|
586
666
|
for (const row of Object.values(effects)) {
|
|
587
667
|
if (row.state !== "settled" || row.kind !== "push" || row.sandbox === undefined || row.repo === undefined) continue;
|
|
588
|
-
|
|
668
|
+
if (row.branch === undefined) continue;
|
|
669
|
+
const key = JSON.stringify([row.sandbox, row.repo, row.branch]);
|
|
589
670
|
if (seen.has(key)) continue;
|
|
590
671
|
seen.add(key);
|
|
591
|
-
const last = lastPushOf(effects, row.sandbox, row.repo);
|
|
672
|
+
const last = lastPushOf(effects, row.sandbox, row.repo, row.branch);
|
|
592
673
|
if (last !== undefined) ids.add(last.rowId);
|
|
593
674
|
}
|
|
594
675
|
return ids;
|
|
595
676
|
}
|
|
596
677
|
|
|
678
|
+
/** The cut that made `path` in this Sandbox: the newest settled `ready` row, whatever its Repo Slot.
|
|
679
|
+
* What a push, a credentialed command and a file body read. A row of another Sandbox says nothing
|
|
680
|
+
* of this one: a re-provision has an empty `/work`. */
|
|
681
|
+
export function cutRowOf(
|
|
682
|
+
rows: Readonly<Record<string, EffectRow>>,
|
|
683
|
+
sandbox: string,
|
|
684
|
+
path: unknown,
|
|
685
|
+
): { repo: string; path: string; branch: string | null } | undefined {
|
|
686
|
+
let best: { at: string; repo: string; path: string; branch: string | null } | undefined;
|
|
687
|
+
for (const row of Object.values(rows)) {
|
|
688
|
+
if (row.state !== "settled" || row.cut === undefined || row.sandbox !== sandbox) continue;
|
|
689
|
+
if (row.cut.path !== path || row.repo === undefined) continue;
|
|
690
|
+
if (best === undefined || row.at > best.at) {
|
|
691
|
+
best = { at: row.at, repo: row.repo, path: row.cut.path, branch: row.cut.branch };
|
|
692
|
+
}
|
|
693
|
+
}
|
|
694
|
+
return best;
|
|
695
|
+
}
|
|
696
|
+
|
|
597
697
|
/** One Effect line on the run's feed (Instance-token band, like the Turn markers). `output` lines
|
|
598
698
|
* carry the stream; `aborted` and an `unknown` settle say the outside world may have acted. */
|
|
599
699
|
export type EffectMarker = {
|
|
@@ -602,7 +702,7 @@ export type EffectMarker = {
|
|
|
602
702
|
effect: string;
|
|
603
703
|
stage: "started" | "output" | "settled" | "aborted";
|
|
604
704
|
text?: string;
|
|
605
|
-
output?: CommandOutput | HttpOutput | DispatchOutput;
|
|
705
|
+
output?: CommandOutput | WorktreeOutput | HttpOutput | DispatchOutput;
|
|
606
706
|
};
|
|
607
707
|
|
|
608
708
|
// --- The port -----------------------------------------------------------------------------------
|
|
@@ -643,19 +743,33 @@ export type EffectLogic<I, O> = ActorLogic<EffectSnapshot<O, I>, EventObject, I>
|
|
|
643
743
|
type ArgNames<A extends readonly unknown[]> = A[number] extends infer E ? (E extends Arg<infer N> ? N : never) : never;
|
|
644
744
|
/** A command's input: one string per argv slot, and with `pin` the approved commit (`{ sha }`);
|
|
645
745
|
* none when there is neither. */
|
|
646
|
-
export type CommandInput<A extends readonly unknown[], P = false> = [
|
|
746
|
+
export type CommandInput<A extends readonly unknown[], P = false, Opt = unknown> = [
|
|
647
747
|
ArgNames<A> | ([P] extends [true] ? "sha" : never),
|
|
648
748
|
] extends [never]
|
|
649
|
-
? void
|
|
650
|
-
: { [K in ArgNames<A> | ([P] extends [true] ? "sha" : never)]: string }
|
|
749
|
+
? (WorktreeKey<Opt> & NotWorktreeKeys) | ([Opt] extends [{ readonly env: object }] ? never : void)
|
|
750
|
+
: { [K in ArgNames<A> | ([P] extends [true] ? "sha" : never)]: string } & WorktreeKey<Opt> &
|
|
751
|
+
Omit<NotWorktreeKeys, ArgNames<A>>;
|
|
752
|
+
|
|
753
|
+
// A command takes its Worktree in the invoke's input. `jr2Setup` makes the key required under a body with Repo Slots, and drops it under a body with
|
|
754
|
+
// none. A credentialed command runs against one commit of a Worktree, so there it is always required.
|
|
755
|
+
/** @internal */
|
|
756
|
+
export type WorktreeKey<Opt> = [Opt] extends [{ readonly env: object }]
|
|
757
|
+
? { worktree: AnyWorktreePath }
|
|
758
|
+
: { worktree?: AnyWorktreePath };
|
|
759
|
+
|
|
760
|
+
/** The keys of a Turn and a cut, refused by name on an Effect's input: a mapper's excess key is no
|
|
761
|
+
* compile error, and the Worktree would be lost.
|
|
762
|
+
* @internal */
|
|
763
|
+
export type NotWorktreeKeys = { cwd?: never; path?: never; branch?: never };
|
|
651
764
|
|
|
652
765
|
// An Open argv's slots are the composer's (ADR-0054), so any key is one string — what the
|
|
653
766
|
// package's invoke hands it, read by the argv the composer binds (`argvOf`).
|
|
654
767
|
/** The input of a `command(open)`: a string for each `arg()` of the argv that the Instance binds,
|
|
655
768
|
* and with `pin`, the commit (`{ sha }`). The compiler cannot check the names. When the input has
|
|
656
769
|
* no string for an `arg()`, the Effect fails when it starts. */
|
|
657
|
-
export type OpenCommandInput<P = false> = Readonly<Record<string, string>> &
|
|
658
|
-
([P] extends [true] ? { sha: string } : unknown)
|
|
770
|
+
export type OpenCommandInput<P = false, Opt = unknown> = Readonly<Record<string, string>> &
|
|
771
|
+
([P] extends [true] ? { sha: string } : unknown) &
|
|
772
|
+
WorktreeKey<Opt>;
|
|
659
773
|
|
|
660
774
|
/** A length of time: a positive number and a unit, `s`, `m` or `h`. For example `"90s"`, `"10m"`
|
|
661
775
|
* or `"1h"`. */
|
|
@@ -664,28 +778,6 @@ export type Duration = `${number}s` | `${number}m` | `${number}h`;
|
|
|
664
778
|
/** The options of `command()`: the Repo Slot it runs in, how long it can run, how much of its
|
|
665
779
|
* output it keeps, its Effect secrets, and the commit it runs against. */
|
|
666
780
|
export type CommandOptions = {
|
|
667
|
-
/**
|
|
668
|
-
* The Repo Slot whose Worktree is the working directory of the command. Write it only when the
|
|
669
|
-
* Workspace has more than one Repo Slot. In a Workspace with no Repo Slot, the command starts in
|
|
670
|
-
* `/work`, the folder that the steps of the Workspace share.
|
|
671
|
-
*
|
|
672
|
-
* The command starts in the root of that Worktree. To run it in a subfolder, give the path as an
|
|
673
|
-
* argument, or write `["sh", "-c", "cd <dir> && <program>"]`.
|
|
674
|
-
*
|
|
675
|
-
* When it starts, it refuses:
|
|
676
|
-
* - no `slot` under a Workspace with more than one Repo Slot: `command "<slot>" runs under a
|
|
677
|
-
* Workspace carrying more than one Repo Slot (<slots>) and names none — say which Worktree it
|
|
678
|
-
* runs in: command(argv, { slot: "<slot>" }) (ADR-0051)`. When `env` names an Effect secret,
|
|
679
|
-
* the text is `command "<slot>" runs under a Workspace carrying more than one Repo Slot
|
|
680
|
-
* (<slots>) — say which: { slot: "<slot>" } (ADR-0051)`.
|
|
681
|
-
* - with an Effect secret in `env`, a Workspace with no Repo Slot: `command "<slot>" runs under
|
|
682
|
-
* a Workspace with no Repo Slot: a command with an Effect secret runs against one commit of a
|
|
683
|
-
* Repo Slot's branch, and there is none — declare a Repo Slot, or name no secret in its env, and
|
|
684
|
-
* it runs in /work beside the Agent (ADR-0051)`.
|
|
685
|
-
* - a `slot` that the Workspace does not have: `command "<slot>" names Repo Slot "<name>", which
|
|
686
|
-
* its Workspace does not carry (<slots>)`.
|
|
687
|
-
*/
|
|
688
|
-
slot?: string;
|
|
689
781
|
/**
|
|
690
782
|
* How long the command can run: a number and a unit, such as `"90s"`, `"10m"` or `"1h"`.
|
|
691
783
|
* Default `"10m"`. When the time ends, jr2 kills the process group, and the output is
|
|
@@ -735,10 +827,14 @@ export type EnvOpenParts<Opt> = Opt extends { readonly env: infer E }
|
|
|
735
827
|
|
|
736
828
|
/**
|
|
737
829
|
* Why an Effect kind cannot run in a Workspace with no Repo Slot (ADR-0051, ADR-0067), or undefined
|
|
738
|
-
* when it can: a `push` and a credentialed `command` run on a commit of a slot's branch, and
|
|
739
|
-
* Workspace has none. The invoke and the
|
|
830
|
+
* when it can: a `push` and a credentialed `command` run on a commit of a slot's branch, and a
|
|
831
|
+
* `worktree` makes a Worktree of a slot's clone, and that Workspace has none. The invoke and the
|
|
832
|
+
* `jr2 up` walk refuse with the same words.
|
|
740
833
|
*/
|
|
741
834
|
export function needsASlot(kind: string): string | undefined {
|
|
835
|
+
if (kind === "worktree") {
|
|
836
|
+
return 'a cut makes a Worktree of a Repo Slot, and there is none — declare one: repos: { app: "<url>" }';
|
|
837
|
+
}
|
|
742
838
|
if (kind === "push")
|
|
743
839
|
return 'a push publishes a Repo Slot\'s branch, and there is none — declare one: repos: { app: "<url>" }';
|
|
744
840
|
if (kind === "command") {
|
|
@@ -773,7 +869,7 @@ export function commandWith<const Opt extends CommandOptions = CommandOptions>(
|
|
|
773
869
|
argv: typeof open,
|
|
774
870
|
options?: Opt,
|
|
775
871
|
effectorPorts?: EffectorPortFactory,
|
|
776
|
-
): EffectLogic<OpenCommandInput<Opt["pin"]>, CommandOutput> & OpenEffect<"argv" | EnvOpenParts<Opt>>;
|
|
872
|
+
): EffectLogic<OpenCommandInput<Opt["pin"], Opt>, CommandOutput> & OpenEffect<"argv" | EnvOpenParts<Opt>>;
|
|
777
873
|
export function commandWith<
|
|
778
874
|
const A extends readonly (string | Arg)[],
|
|
779
875
|
const Opt extends CommandOptions = CommandOptions,
|
|
@@ -782,12 +878,15 @@ export function commandWith<
|
|
|
782
878
|
argv: A,
|
|
783
879
|
options?: Opt,
|
|
784
880
|
effectorPorts?: EffectorPortFactory,
|
|
785
|
-
|
|
881
|
+
cut?: CommandParts["cut"],
|
|
882
|
+
): EffectLogic<CommandInput<A, Opt["pin"], Opt>, CommandOutput> & OpenOf<EnvOpenParts<Opt>>;
|
|
786
883
|
export function commandWith(
|
|
787
884
|
portFactory: ExecPortFactory,
|
|
788
885
|
argv: readonly (string | Arg)[] | typeof open,
|
|
789
886
|
options: CommandOptions = {},
|
|
790
887
|
effectorPorts: EffectorPortFactory = sandboxEffectorPort,
|
|
888
|
+
// `worktree()` alone passes this: an author's command is at most once.
|
|
889
|
+
cut?: CommandParts["cut"],
|
|
791
890
|
// The implementation of the overloads above: each one states the input type.
|
|
792
891
|
): EffectLogic<any, CommandOutput> {
|
|
793
892
|
if (argv === open) {
|
|
@@ -814,9 +913,9 @@ export function commandWith(
|
|
|
814
913
|
argv: [...argv],
|
|
815
914
|
timeoutMs: options.timeout === undefined ? DEFAULT_TIMEOUT_MS : parseDuration(options.timeout),
|
|
816
915
|
tail: options.tail ?? DEFAULT_TAIL,
|
|
817
|
-
...(options.slot !== undefined ? { slot: options.slot } : {}),
|
|
818
916
|
...(options.env !== undefined && Object.keys(options.env).length > 0 ? commandEnvParts(options.env) : {}),
|
|
819
917
|
...(options.pin === true ? { pin: true as const } : {}),
|
|
918
|
+
...(cut !== undefined ? { cut, idempotent: true as const } : {}),
|
|
820
919
|
};
|
|
821
920
|
if (parts.pin && (parts.secrets?.length ?? 0) === 0 && !parts.openSecret) {
|
|
822
921
|
// A command that names no secret runs in the Harness container, in the live Worktree: there
|
|
@@ -844,7 +943,7 @@ export function commandWith(
|
|
|
844
943
|
) as unknown as EffectLogic<unknown, CommandOutput>;
|
|
845
944
|
}
|
|
846
945
|
type I = unknown;
|
|
847
|
-
type O = CommandOutput;
|
|
946
|
+
type O = CommandOutput | WorktreeOutput;
|
|
848
947
|
const logic: ActorLogic<EffectSnapshot<O, I>, EventObject, I> = {
|
|
849
948
|
getInitialSnapshot: (_scope, input) => ({
|
|
850
949
|
status: "active",
|
|
@@ -878,12 +977,63 @@ export function commandWith(
|
|
|
878
977
|
},
|
|
879
978
|
getPersistedSnapshot: (snapshot) => snapshot,
|
|
880
979
|
};
|
|
881
|
-
return Object.assign(logic, { effect: parts })
|
|
980
|
+
return Object.assign(logic, { effect: parts }) as unknown as EffectLogic<any, CommandOutput>;
|
|
882
981
|
}
|
|
883
982
|
|
|
884
983
|
/** The slot's name as its parent invoked it — what the ledger and the feed call the Effect. */
|
|
885
984
|
const slotOf = (self: AnyActorRef): string => (typeof self.src === "string" ? self.src : self.id);
|
|
886
985
|
|
|
986
|
+
/** How many times a marked Effect goes out again after a Harness that lost it. Bounded, so that a
|
|
987
|
+
* Harness in a crash loop ends the wait: the Effect settles `unknown`, and the Machine routes. */
|
|
988
|
+
const RESENDS = 3;
|
|
989
|
+
|
|
990
|
+
/** What a cut's program answers: its last line. Anything else is a cut that did not end. */
|
|
991
|
+
function cutOutputOf(raw: CommandOutput): WorktreeOutput {
|
|
992
|
+
if (raw.outcome === "unknown") return raw as Unknown;
|
|
993
|
+
const tail = "tail" in raw && typeof raw.tail === "string" ? raw.tail : "";
|
|
994
|
+
if (raw.outcome === "exited" && raw.code === 0) {
|
|
995
|
+
try {
|
|
996
|
+
const answer = JSON.parse(tail.trim().split("\n").pop() ?? "") as { outcome?: unknown };
|
|
997
|
+
if (answer.outcome === "ready" || answer.outcome === "refused") return answer as WorktreeOutput;
|
|
998
|
+
} catch {
|
|
999
|
+
// Falls through: the program said no answer.
|
|
1000
|
+
}
|
|
1001
|
+
}
|
|
1002
|
+
return { outcome: "unknown", reason: `the cut ended ${raw.outcome} with no answer: ${tail.slice(-400)}` };
|
|
1003
|
+
}
|
|
1004
|
+
|
|
1005
|
+
/** Why a cut's input cannot go to the Harness, or undefined. The door checked a Chart's literals; a
|
|
1006
|
+
* reference, a pick param or a mapper can still say this. No exec, no fetch. */
|
|
1007
|
+
function cutFaultOf(input: unknown, ambient: AmbientHandles): string | undefined {
|
|
1008
|
+
const given = (input ?? {}) as { path?: unknown; branch?: unknown; detach?: unknown; from?: unknown };
|
|
1009
|
+
const fault = worktreePathFault(given.path, ambient, { cut: true });
|
|
1010
|
+
if (fault !== undefined) return fault;
|
|
1011
|
+
if ((given.branch === undefined) === (given.detach === undefined))
|
|
1012
|
+
return "a cut names a branch, or a commit in `detach`";
|
|
1013
|
+
if (given.detach !== undefined && given.from !== undefined) return "a detached cut takes no `from`";
|
|
1014
|
+
for (const key of ["branch", "detach", "from"] as const) {
|
|
1015
|
+
if (given[key] !== undefined && typeof given[key] !== "string") return `a cut's \`${key}\` is a string`;
|
|
1016
|
+
}
|
|
1017
|
+
return undefined;
|
|
1018
|
+
}
|
|
1019
|
+
|
|
1020
|
+
/** The cut's one argument: its input, its id (a re-send answers the first `found`), its static
|
|
1021
|
+
* `expect`, and the base, sent each time, so that no checkout of an Agent's can move it. */
|
|
1022
|
+
function cutRequestOf(id: string, input: unknown, parts: CommandParts, ambient: AmbientHandles): string {
|
|
1023
|
+
const given = (input ?? {}) as { path: string; branch?: string; detach?: string; from?: string };
|
|
1024
|
+
const slot = given.path.split("/")[2]!;
|
|
1025
|
+
const base = ambient.bases?.[slot];
|
|
1026
|
+
return JSON.stringify({
|
|
1027
|
+
id,
|
|
1028
|
+
path: given.path,
|
|
1029
|
+
...(given.branch !== undefined ? { branch: given.branch } : {}),
|
|
1030
|
+
...(given.detach !== undefined ? { detach: given.detach } : {}),
|
|
1031
|
+
...(given.from !== undefined ? { from: given.from } : {}),
|
|
1032
|
+
...(given.branch !== undefined && parts.cut?.expect ? { expect: parts.cut.expect } : {}),
|
|
1033
|
+
...(base !== undefined ? { base } : {}),
|
|
1034
|
+
});
|
|
1035
|
+
}
|
|
1036
|
+
|
|
887
1037
|
/** Run, re-attach to, or deliver one Effect, by its ledger row (see the module header). */
|
|
888
1038
|
async function drive<I, O>(
|
|
889
1039
|
self: AnyActorRef,
|
|
@@ -900,14 +1050,30 @@ async function drive<I, O>(
|
|
|
900
1050
|
const now = (): string => new Date().toISOString();
|
|
901
1051
|
const mark = (marker: Omit<EffectMarker, "kind" | "slot" | "effect">): void =>
|
|
902
1052
|
binding?.marker?.({ kind: "effect", slot, effect: id, ...marker });
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
1053
|
+
/** A settled row of an exec. `sandbox` is the row's own on a restore: the Workspace it ran in. */
|
|
1054
|
+
const settleIn =
|
|
1055
|
+
(sandbox: string | undefined) =>
|
|
1056
|
+
(raw: CommandOutput | WorktreeOutput): void => {
|
|
1057
|
+
if (signal.aborted || self.getSnapshot().status !== "active") return;
|
|
1058
|
+
const output = parts.cut && raw.outcome !== "refused" ? cutOutputOf(raw as CommandOutput) : raw;
|
|
1059
|
+
// The settled row first, saved at the write: a restart before the transition is saved delivers
|
|
1060
|
+
// this output, and runs nothing. A `ready` cut's row keeps its fields: a push reads them.
|
|
1061
|
+
const cutRow =
|
|
1062
|
+
parts.cut && output.outcome === "ready"
|
|
1063
|
+
? { sandbox, repo: output.slot, cut: { path: output.path, branch: output.branch } }
|
|
1064
|
+
: {};
|
|
1065
|
+
binding?.recordEffect?.(id, {
|
|
1066
|
+
state: "settled",
|
|
1067
|
+
kind: parts.kind,
|
|
1068
|
+
slot,
|
|
1069
|
+
...(cutRow as object),
|
|
1070
|
+
output,
|
|
1071
|
+
at: now(),
|
|
1072
|
+
});
|
|
1073
|
+
mark({ stage: "settled", output });
|
|
1074
|
+
self.send({ type: "jr2.effect.settle", output } satisfies Settle);
|
|
1075
|
+
};
|
|
1076
|
+
const settle = settleIn(undefined);
|
|
911
1077
|
|
|
912
1078
|
const row = binding?.effectRow?.(id);
|
|
913
1079
|
if (row?.state === "settled") {
|
|
@@ -923,13 +1089,14 @@ async function drive<I, O>(
|
|
|
923
1089
|
return;
|
|
924
1090
|
}
|
|
925
1091
|
if (row?.state === "intent" && row.kind === "command" && "endpoint" in row) {
|
|
926
|
-
// In flight at an Orchestrator restart: re-attach by id, or settle `unknown`. Never run again
|
|
1092
|
+
// In flight at an Orchestrator restart: re-attach by id, or settle `unknown`. Never run again,
|
|
1093
|
+
// unless the kit marked it: a cut goes on below, under the same id.
|
|
927
1094
|
const port = portFactory(row.endpoint, binding?.harnessBearer?.(row.placement));
|
|
928
1095
|
run.port = port;
|
|
929
1096
|
run.started = true;
|
|
930
1097
|
const first = await retrying(() => port.read(id, undefined, signal), signal);
|
|
931
1098
|
if (signal.aborted) return;
|
|
932
|
-
if (first === undefined) {
|
|
1099
|
+
if (first === undefined && !parts.idempotent) {
|
|
933
1100
|
const output: Unknown = {
|
|
934
1101
|
outcome: "unknown",
|
|
935
1102
|
reason: "it was running when the Orchestrator restarted, and the Harness no longer knows it; not run again",
|
|
@@ -937,8 +1104,11 @@ async function drive<I, O>(
|
|
|
937
1104
|
settle(output);
|
|
938
1105
|
return;
|
|
939
1106
|
}
|
|
940
|
-
|
|
941
|
-
|
|
1107
|
+
if (first !== undefined) {
|
|
1108
|
+
const lost = await follow(port, id, first, signal, mark, settleIn(row.placement), parts.idempotent === true);
|
|
1109
|
+
if (!lost) return;
|
|
1110
|
+
}
|
|
1111
|
+
mark({ stage: "started", text: "sent again: the Harness lost it, and a cut is idempotent" });
|
|
942
1112
|
}
|
|
943
1113
|
|
|
944
1114
|
// Fresh: nothing was sent. Where it runs is the enclosing Workspace's Harness — refused outside one.
|
|
@@ -949,9 +1119,43 @@ async function drive<I, O>(
|
|
|
949
1119
|
`workspace() — invoke it from a Workspace's body (ADR-0067)`,
|
|
950
1120
|
);
|
|
951
1121
|
}
|
|
1122
|
+
const settled = settleIn(ambient.sandbox);
|
|
1123
|
+
// A bad Worktree is a word an Agent or a Chart can say, so it is output (ADR-0067), in the form a
|
|
1124
|
+
// command that could not start has: exited 127, with the reason in the tail. A key that no one
|
|
1125
|
+
// wrote is a defect of the Machine: a throw, and `onError`.
|
|
1126
|
+
const worktree = (snapshot.input as { worktree?: unknown } | undefined | void)?.worktree;
|
|
1127
|
+
if (parts.cut) {
|
|
1128
|
+
if (Object.keys(ambient.repos).length === 0) {
|
|
1129
|
+
throw new Error(
|
|
1130
|
+
`worktree "${slot}" runs under a Workspace with no Repo Slot: ${needsASlot("worktree")} (ADR-0075)`,
|
|
1131
|
+
);
|
|
1132
|
+
}
|
|
1133
|
+
const fault = cutFaultOf(snapshot.input, ambient);
|
|
1134
|
+
if (fault !== undefined) {
|
|
1135
|
+
settled({ outcome: "refused", reason: fault });
|
|
1136
|
+
return;
|
|
1137
|
+
}
|
|
1138
|
+
} else if (worktree === undefined) {
|
|
1139
|
+
if (Object.keys(ambient.repos).length > 0) {
|
|
1140
|
+
throw new Error(
|
|
1141
|
+
`command "${slot}" runs under a Workspace with Repo Slots (${Object.keys(ambient.repos).join(", ")}) and its ` +
|
|
1142
|
+
"input names no `worktree`: say which, a Worktree that a cut gave or `<slot>/default`",
|
|
1143
|
+
);
|
|
1144
|
+
}
|
|
1145
|
+
} else {
|
|
1146
|
+
const fault = worktreePathFault(worktree, ambient);
|
|
1147
|
+
if (fault !== undefined) {
|
|
1148
|
+
settled({ outcome: "exited", code: 127, tail: `jr2: ${fault}; nothing ran\n` });
|
|
1149
|
+
return;
|
|
1150
|
+
}
|
|
1151
|
+
}
|
|
952
1152
|
const req: ExecRequest = {
|
|
953
|
-
argv:
|
|
954
|
-
|
|
1153
|
+
argv: parts.cut
|
|
1154
|
+
? [...(parts.argv as string[]), cutRequestOf(id, snapshot.input, parts, ambient)]
|
|
1155
|
+
: argvOf(slot, parts, snapshot.input),
|
|
1156
|
+
// Named, never left to the Harness: a spawn with no cwd runs in the image's own WORKDIR.
|
|
1157
|
+
cwd: parts.cut ? WORK_ROOT : ((worktree as string | undefined) ?? WORK_ROOT),
|
|
1158
|
+
...(!parts.cut && worktree !== undefined ? { root: true as const } : {}),
|
|
955
1159
|
timeoutMs: parts.timeoutMs,
|
|
956
1160
|
tail: parts.tail,
|
|
957
1161
|
};
|
|
@@ -969,26 +1173,38 @@ async function drive<I, O>(
|
|
|
969
1173
|
});
|
|
970
1174
|
run.started = true;
|
|
971
1175
|
mark({ stage: "started", text: req.argv.join(" ") });
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
const
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
if (
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
1176
|
+
// One pass for every Effect but the kit's cut: a Harness that restarted knows no id, and a start
|
|
1177
|
+
// that may have reached it is never sent again. A cut goes out again: it is idempotent.
|
|
1178
|
+
const passes = parts.idempotent ? RESENDS + 1 : 1;
|
|
1179
|
+
for (let pass = 1; pass <= passes; pass++) {
|
|
1180
|
+
const sent = await startOnce(port, id, req, signal);
|
|
1181
|
+
if (signal.aborted) return;
|
|
1182
|
+
const first = await retrying(() => port.read(id, 0, signal), signal);
|
|
1183
|
+
if (signal.aborted) return;
|
|
1184
|
+
const last = pass === passes;
|
|
1185
|
+
if (first === undefined) {
|
|
1186
|
+
if (!last) {
|
|
1187
|
+
mark({ stage: "started", text: "sent again: the Harness restarted, and a cut is idempotent" });
|
|
1188
|
+
continue;
|
|
1189
|
+
}
|
|
1190
|
+
if (sent === "uncertain" && !parts.idempotent) {
|
|
1191
|
+
// The start may still be on its way to the Harness that answers now: a kill keeps it from
|
|
1192
|
+
// running there (exec.ts keeps an unknown id killed). Best effort — `unknown` either way.
|
|
1193
|
+
void killUntilKept(port, id, parts.timeoutMs).catch(() => {});
|
|
1194
|
+
}
|
|
1195
|
+
settled({
|
|
1196
|
+
outcome: "unknown",
|
|
1197
|
+
reason:
|
|
1198
|
+
sent === "uncertain"
|
|
1199
|
+
? "its start may have reached the Harness, which no longer knows it (it restarted); not run again"
|
|
1200
|
+
: "the Harness lost it after it started (it restarted); not run again",
|
|
1201
|
+
});
|
|
1202
|
+
return;
|
|
981
1203
|
}
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
sent === "uncertain"
|
|
986
|
-
? "its start may have reached the Harness, which no longer knows it (it restarted); not run again"
|
|
987
|
-
: "the Harness lost it after it started (it restarted); not run again",
|
|
988
|
-
});
|
|
989
|
-
return;
|
|
1204
|
+
const lost = await follow(port, id, first, signal, mark, settled, !last);
|
|
1205
|
+
if (!lost) return;
|
|
1206
|
+
mark({ stage: "started", text: "sent again: the Harness lost it, and a cut is idempotent" });
|
|
990
1207
|
}
|
|
991
|
-
await follow(port, id, first, signal, mark, settle);
|
|
992
1208
|
}
|
|
993
1209
|
|
|
994
1210
|
/**
|
|
@@ -1018,7 +1234,8 @@ async function startOnce(
|
|
|
1018
1234
|
}
|
|
1019
1235
|
}
|
|
1020
1236
|
|
|
1021
|
-
/** Read the exec to its end: each chunk of stream to the feed, the output to the Machine.
|
|
1237
|
+
/** Read the exec to its end: each chunk of stream to the feed, the output to the Machine. With
|
|
1238
|
+
* `again`, a Harness that lost it settles nothing: the answer is true, and the caller sends it. */
|
|
1022
1239
|
async function follow(
|
|
1023
1240
|
port: ExecPort,
|
|
1024
1241
|
id: string,
|
|
@@ -1026,22 +1243,24 @@ async function follow(
|
|
|
1026
1243
|
signal: AbortSignal,
|
|
1027
1244
|
mark: (marker: Omit<EffectMarker, "kind" | "slot" | "effect">) => void,
|
|
1028
1245
|
settle: (output: CommandOutput) => void,
|
|
1029
|
-
|
|
1246
|
+
again = false,
|
|
1247
|
+
): Promise<boolean> {
|
|
1030
1248
|
let read: ExecRead | undefined = first;
|
|
1031
1249
|
for (;;) {
|
|
1032
1250
|
if (read === undefined) {
|
|
1251
|
+
if (again) return true;
|
|
1033
1252
|
settle({ outcome: "unknown", reason: "the Harness lost it while it ran (it restarted); not run again" });
|
|
1034
|
-
return;
|
|
1253
|
+
return false;
|
|
1035
1254
|
}
|
|
1036
1255
|
if (read.stream) mark({ stage: "output", text: read.stream });
|
|
1037
1256
|
if (read.state === "settled" && read.output) {
|
|
1038
1257
|
settle(read.output);
|
|
1039
1258
|
void port.forget(id).catch(() => {});
|
|
1040
|
-
return;
|
|
1259
|
+
return false;
|
|
1041
1260
|
}
|
|
1042
1261
|
const offset: number = read.next;
|
|
1043
1262
|
read = await retrying(() => port.read(id, offset, signal), signal);
|
|
1044
|
-
if (signal.aborted) return;
|
|
1263
|
+
if (signal.aborted) return false;
|
|
1045
1264
|
}
|
|
1046
1265
|
}
|
|
1047
1266
|
|
|
@@ -1146,30 +1365,27 @@ function argvOf(slot: string, parts: CommandParts, input: unknown): string[] {
|
|
|
1146
1365
|
}
|
|
1147
1366
|
|
|
1148
1367
|
/**
|
|
1149
|
-
*
|
|
1150
|
-
*
|
|
1151
|
-
*
|
|
1152
|
-
* Several and none named is refused, as a Turn's Frame is (ADR-0057): the kit gives no slot a
|
|
1153
|
-
* meaning.
|
|
1368
|
+
* Why `path` is not a Worktree of this Workspace, or undefined when it is. Exactly
|
|
1369
|
+
* `/work/<slot>/<name>`, of one of its Repo Slots, one segment that starts with neither `.` nor `-`.
|
|
1370
|
+
* `default` is a Worktree (the clone), and with `cut` it is not one a cut makes.
|
|
1154
1371
|
*/
|
|
1155
|
-
function
|
|
1372
|
+
export function worktreePathFault(
|
|
1373
|
+
path: unknown,
|
|
1374
|
+
ambient: AmbientHandles,
|
|
1375
|
+
opts: { cut?: boolean } = {},
|
|
1376
|
+
): string | undefined {
|
|
1377
|
+
const match = typeof path === "string" ? path.match(/^\/work\/([^/]+)\/([^/]+)$/) : null;
|
|
1378
|
+
if (match === null) {
|
|
1379
|
+
return `${JSON.stringify(path)} is not a Worktree path: /work/<slot>/<name>, with one segment in <name>`;
|
|
1380
|
+
}
|
|
1381
|
+
const [, slot, name] = match as unknown as [string, string, string];
|
|
1382
|
+
if (name.startsWith(".") || name.startsWith("-")) return `"${path}": a Worktree's name starts with neither . nor -`;
|
|
1383
|
+
if (opts.cut && name === "default") return `"${path}" is the clone: it stays as the attach left it; cut a sibling`;
|
|
1156
1384
|
const slots = Object.keys(ambient.repos);
|
|
1157
|
-
if (
|
|
1158
|
-
|
|
1159
|
-
if (cwd === undefined) {
|
|
1160
|
-
throw new Error(
|
|
1161
|
-
`command "${slot}" names Repo Slot "${parts.slot}", which its Workspace does not carry ` +
|
|
1162
|
-
`(${slots.join(", ") || "none"})`,
|
|
1163
|
-
);
|
|
1164
|
-
}
|
|
1165
|
-
return { cwd };
|
|
1385
|
+
if (!slots.includes(slot)) {
|
|
1386
|
+
return `"${path}" names Repo Slot "${slot}", which its Workspace does not carry (${slots.join(", ") || "none"})`;
|
|
1166
1387
|
}
|
|
1167
|
-
|
|
1168
|
-
if (slots.length === 1) return { cwd: ambient.repos[slots[0]!]! };
|
|
1169
|
-
throw new Error(
|
|
1170
|
-
`command "${slot}" runs under a Workspace carrying more than one Repo Slot (${slots.join(", ")}) and names ` +
|
|
1171
|
-
`none — say which Worktree it runs in: command(argv, { slot: "<slot>" }) (ADR-0051)`,
|
|
1172
|
-
);
|
|
1388
|
+
return undefined;
|
|
1173
1389
|
}
|
|
1174
1390
|
|
|
1175
1391
|
/** Ask again on a transport failure, with backoff, until it answers or the Effect stops. For a
|