@jr2/orchestrator 0.2.1 → 0.4.1
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/bin/server.ts +7 -4
- package/console/canvas.ts +27 -14
- package/console/components/app.ts +10 -1
- package/console/components/fleet.ts +39 -6
- package/console/components/machine-pane.ts +89 -2
- package/console/components/nav.ts +1 -1
- package/console/components/schema-form.ts +12 -4
- package/console/json-field.ts +23 -0
- package/console/main.ts +110 -39
- package/console/store.ts +221 -17
- package/console/style.css +90 -0
- package/package.json +4 -5
- package/src/actor.ts +507 -54
- package/src/agent.ts +2 -2
- package/src/ambient.ts +2 -0
- package/src/assembly-checks.ts +165 -0
- package/src/assembly-tar.ts +494 -0
- package/src/assembly.ts +721 -0
- package/src/chart.ts +2050 -0
- package/src/charts.ts +720 -0
- package/src/config.ts +233 -22
- package/src/content-hash.ts +105 -0
- package/src/custodian.lua +313 -0
- package/src/custodian.ts +951 -0
- package/src/customize.ts +216 -25
- package/src/effect-checks.ts +557 -0
- package/src/effect.ts +905 -0
- package/src/effector-wire.ts +525 -0
- package/src/fingerprint.ts +4 -3
- package/src/gate.ts +8 -0
- package/src/git-clone.ts +65 -0
- package/src/git-push.ts +336 -0
- package/src/harness-client.ts +247 -25
- package/src/held-secrets.ts +643 -0
- package/src/holds.ts +156 -0
- package/src/http-effect.ts +981 -0
- package/src/http.ts +514 -119
- package/src/images.ts +58 -31
- package/src/index.ts +49 -4
- package/src/instance.ts +445 -59
- package/src/kube-client.ts +333 -0
- package/src/machine-doc.ts +88 -6
- package/src/model-keys.ts +92 -0
- package/src/mount-digest.ts +53 -0
- package/src/names.ts +89 -7
- package/src/notices.ts +88 -0
- package/src/open-body.ts +1444 -0
- package/src/open-effect.ts +144 -0
- package/src/open.ts +4 -4
- package/src/parts.ts +530 -33
- package/src/party.ts +468 -0
- package/src/pick.ts +309 -0
- package/src/pool.ts +1 -1
- package/src/registration.ts +301 -17
- package/src/repo-fetch.ts +61 -86
- package/src/repos.ts +63 -74
- package/src/rest.ts +133 -0
- package/src/run-host.ts +1841 -105
- package/src/sandbox-effect.ts +891 -0
- package/src/sandbox-effector.ts +111 -0
- package/src/sandbox-kube.ts +1719 -0
- package/src/sandbox-watch.ts +260 -0
- package/src/secret-placement.ts +494 -0
- package/src/server.ts +285 -89
- package/src/setup.ts +589 -70
- package/src/size.ts +342 -0
- package/src/snapshot-store.ts +64 -13
- package/src/stub-harness.ts +5 -0
- package/src/sync-client.ts +54 -0
- package/src/timers.ts +178 -0
- package/src/tokens.ts +44 -12
- package/src/vocabulary.ts +66 -1
- package/src/wire.ts +156 -2
- package/src/workspace.ts +529 -194
- package/src/sandbox-kubectl.ts +0 -1144
|
@@ -0,0 +1,891 @@
|
|
|
1
|
+
// The Sandbox Effector's kinds (ADR-0067, ADR-0068): an `http` whose body is a Worktree file, a
|
|
2
|
+
// `push`, and a `command` that names a secret. Each is an actor slot; each runs in the Sandbox
|
|
3
|
+
// Effector of the Workspace whose body invokes it — a container beside the Agent in its Sandbox pod,
|
|
4
|
+
// with the Worktree mounted read-only, that holds the Effect secrets and the push credential the
|
|
5
|
+
// Agent never reads.
|
|
6
|
+
//
|
|
7
|
+
// publish: push({ sign: true }),
|
|
8
|
+
// report: http({ method: "PUT", url: "https://files.example.com/r", auth: bearer(secret("files")),
|
|
9
|
+
// input: z.object({ path: z.string() }), body: file(({ path }) => path) }),
|
|
10
|
+
// deploy: command(["terraform", "apply", "-auto-approve"], { env: { AWS_PROFILE_FILE: secret("aws").path } }),
|
|
11
|
+
//
|
|
12
|
+
// What this module adds to the Effector's wire (http-effect.ts has the http kind's):
|
|
13
|
+
//
|
|
14
|
+
// - THE PIN. A push and a credentialed command run against one commit: the Agent's branch tip
|
|
15
|
+
// when the Effect starts — which the Effector reads as data and its answer names — or the
|
|
16
|
+
// commit `pin` names (an approved one, from the Effect's input). It is written to the intent
|
|
17
|
+
// row, so a restore repeats a push of only that commit.
|
|
18
|
+
// - THE RESTORE RULE PER KIND. After an Orchestrator restart, or a start whose answer never came,
|
|
19
|
+
// the Effect asks its Effector for its id: found, it is followed. Not found: a push and a
|
|
20
|
+
// credentialed command are sent again with only the pinned commit the intent row holds — a run
|
|
21
|
+
// is a function of (commit, argv, secrets), never of the Agent's tip now — and with none
|
|
22
|
+
// recorded settle `unknown`; a file-body http is sent again only when marked `idempotent`.
|
|
23
|
+
// - THE PAIRS AND THE SYNC. A push sends every push of its Workspace and Repo Slot that published
|
|
24
|
+
// (each one's published commit, source and `squashed`), oldest first, as `pairs`: the input of
|
|
25
|
+
// the Effector's `pair` fallback and of its map before signing.
|
|
26
|
+
// After every push whose published commit is not the Agent's own — signed copies, or one
|
|
27
|
+
// squashed commit, signed or not — the Orchestrator relays the bundle of the published commits
|
|
28
|
+
// to the Workspace's Harness, which moves the Agent's branch to them (`POST /sync`); its
|
|
29
|
+
// answer rides the output as `sync`, a
|
|
30
|
+
// field beside the push's outcome. When the newest push's sync did not finish, it is run again
|
|
31
|
+
// before the next push. The push never trusts it.
|
|
32
|
+
// - D1. When its state exits, the Orchestrator stops polling and records the abort, "outcome
|
|
33
|
+
// unknown". A credentialed command is stopped: one signed DELETE on its own path, and the
|
|
34
|
+
// Effector ends its process group (SIGTERM, then SIGKILL past a grace); a stop that reaches it
|
|
35
|
+
// before the command's start is kept there, and that start runs nothing. A push and a file-body
|
|
36
|
+
// http are one request each and have no stop: the Effector runs them to their end. A park is
|
|
37
|
+
// not an exit, and stops nothing.
|
|
38
|
+
|
|
39
|
+
import { randomUUID } from "node:crypto";
|
|
40
|
+
import { posix } from "node:path";
|
|
41
|
+
import type { ActorLogic, AnyActorRef, EventObject } from "xstate";
|
|
42
|
+
import type { z } from "zod";
|
|
43
|
+
import { ambientHandlesFor, type AmbientHandles } from "./ambient.ts";
|
|
44
|
+
import type {
|
|
45
|
+
Arg,
|
|
46
|
+
CommandParts,
|
|
47
|
+
Duration,
|
|
48
|
+
EffectLogic,
|
|
49
|
+
EffectMarker,
|
|
50
|
+
EffectRow,
|
|
51
|
+
EffectSnapshot,
|
|
52
|
+
HttpParts,
|
|
53
|
+
PushParts,
|
|
54
|
+
Unknown,
|
|
55
|
+
} from "./effect.ts";
|
|
56
|
+
import { lastPushOf, pushPairsOf } from "./effect.ts";
|
|
57
|
+
import {
|
|
58
|
+
createEffectorClient,
|
|
59
|
+
EffectorForged,
|
|
60
|
+
EffectorMalformed,
|
|
61
|
+
EffectorNotSent,
|
|
62
|
+
EffectorRefused,
|
|
63
|
+
MAX_START_BYTES,
|
|
64
|
+
nodeEffectorTransport,
|
|
65
|
+
type CommandStartRequest,
|
|
66
|
+
type CommandWireOutput,
|
|
67
|
+
type EffectorPort,
|
|
68
|
+
type EffectState,
|
|
69
|
+
type HttpOutput,
|
|
70
|
+
type HttpStartRequest,
|
|
71
|
+
type Pair,
|
|
72
|
+
type PushStartRequest,
|
|
73
|
+
type PushWireOutput,
|
|
74
|
+
type RepoRef,
|
|
75
|
+
type StartRequest,
|
|
76
|
+
} from "./effector-wire.ts";
|
|
77
|
+
import {
|
|
78
|
+
httpParts,
|
|
79
|
+
openHttpOf,
|
|
80
|
+
OPEN_SECRET,
|
|
81
|
+
openSecretsIn,
|
|
82
|
+
secret,
|
|
83
|
+
startRequestOf,
|
|
84
|
+
type EffectorPortFactory,
|
|
85
|
+
type FromInput,
|
|
86
|
+
type HttpDefinition,
|
|
87
|
+
type HttpOpenParts,
|
|
88
|
+
} from "./http-effect.ts";
|
|
89
|
+
import { open } from "./open.ts";
|
|
90
|
+
import type { OpenOf } from "./open-effect.ts";
|
|
91
|
+
import { boundRun, type AnyActorSystem } from "./registration.ts";
|
|
92
|
+
import { repoKey } from "./repo-identity.ts";
|
|
93
|
+
import { harnessSyncPort, type SyncPortFactory } from "./sync-client.ts";
|
|
94
|
+
import type { SyncAnswer } from "./wire.ts";
|
|
95
|
+
|
|
96
|
+
/** The work root a Sandbox pod's Worktrees sit under (ADR-0004), as both containers mount it. */
|
|
97
|
+
const WORK_ROOT = "/work";
|
|
98
|
+
|
|
99
|
+
const DEFAULT_PUSH_TIMEOUT_MS = 5 * 60_000;
|
|
100
|
+
const DEFAULT_COMMAND_TIMEOUT_MS = 10 * 60_000;
|
|
101
|
+
const DEFAULT_TAIL = 4096;
|
|
102
|
+
const RETRY_MAX_MS = 10_000;
|
|
103
|
+
|
|
104
|
+
/** The real port factory: the Sandbox Effector's address, signed with its per-pod key. */
|
|
105
|
+
export function sandboxEffectorPort(target: Parameters<EffectorPortFactory>[0]): ReturnType<EffectorPortFactory> {
|
|
106
|
+
return createEffectorClient({ key: target.key, transport: nodeEffectorTransport(target.service) });
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// --- Naming a secret's file -----------------------------------------------------------------------
|
|
110
|
+
|
|
111
|
+
/** `secret("x").path` in a command's env: the path of the secret's file in the Sandbox Effector's
|
|
112
|
+
* own mount, never its value (ADR-0067). */
|
|
113
|
+
export type SecretPath = { readonly secret: string; readonly path: true };
|
|
114
|
+
|
|
115
|
+
// --- Where a Repo Slot's repositories are in the pod ----------------------------------------------
|
|
116
|
+
|
|
117
|
+
/** The Repo Slot an Effect runs on: the one named, or the Workspace's only one. */
|
|
118
|
+
function slotOf(kind: string, slot: string, named: string | undefined, ambient: AmbientHandles): string {
|
|
119
|
+
const slots = Object.keys(ambient.repos);
|
|
120
|
+
if (named !== undefined) {
|
|
121
|
+
if (ambient.repos[named] === undefined) {
|
|
122
|
+
throw new Error(
|
|
123
|
+
`${kind} "${slot}" names Repo Slot "${named}", which its Workspace does not carry (${slots.join(", ") || "none"})`,
|
|
124
|
+
);
|
|
125
|
+
}
|
|
126
|
+
return named;
|
|
127
|
+
}
|
|
128
|
+
if (slots.length === 1) return slots[0]!;
|
|
129
|
+
throw new Error(
|
|
130
|
+
`${kind} "${slot}" runs under a Workspace carrying ${slots.length === 0 ? "no" : "more than one"} Repo Slot ` +
|
|
131
|
+
`(${slots.join(", ") || "none"}) — say which: { slot: "<slot>" } (ADR-0051)`,
|
|
132
|
+
);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** The slot's repositories, relative to the work root: the Agent's common git dir is the pod-local
|
|
136
|
+
* clone's, beside the branch Worktree (ADR-0004); the cache is the Binding's key (ADR-0051). */
|
|
137
|
+
export function repoRefOf(ambient: AmbientHandles, repoSlot: string): RepoRef & { url: string } {
|
|
138
|
+
const worktree = ambient.repos[repoSlot]!;
|
|
139
|
+
const url = ambient.bindings?.[repoSlot];
|
|
140
|
+
if (url === undefined) {
|
|
141
|
+
throw new Error(`Repo Slot "${repoSlot}" has no Binding url in its Workspace's handles — a jr2 defect`);
|
|
142
|
+
}
|
|
143
|
+
const rel = posix.relative(WORK_ROOT, worktree);
|
|
144
|
+
return {
|
|
145
|
+
gitDir: posix.join(posix.dirname(rel), "default", ".git"),
|
|
146
|
+
worktree: rel,
|
|
147
|
+
cache: repoKey(url),
|
|
148
|
+
url,
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// --- The definitions -------------------------------------------------------------------------------
|
|
153
|
+
|
|
154
|
+
/** `push()`'s options (ADR-0067). */
|
|
155
|
+
export type PushOptions = {
|
|
156
|
+
/** The Effect's input names the commit (`{ sha }`, one a Gate approved); absent, the Agent's
|
|
157
|
+
* branch tip when the Effect starts. A pinned push the Agent pruned is refused by the remote,
|
|
158
|
+
* never substituted. */
|
|
159
|
+
pin?: boolean;
|
|
160
|
+
/** Push signed copies; the matching `git.credentials` entry's `sign` names the key. */
|
|
161
|
+
sign?: boolean;
|
|
162
|
+
/** Squash into one commit; the Effect's input carries the message (`{ message }`). Signed, its
|
|
163
|
+
* committer is the signer's; unsigned, the matching `git.credentials` entry's `committer`, which
|
|
164
|
+
* `jr2 up` requires when the entry has no `sign` (ADR-0067). */
|
|
165
|
+
squash?: boolean;
|
|
166
|
+
/** Replace what the remote holds. */
|
|
167
|
+
force?: boolean;
|
|
168
|
+
/** The Repo Slot pushed — needed only where the Workspace carries several. */
|
|
169
|
+
slot?: string;
|
|
170
|
+
/** Default 5m. Past it with no answer: output `unknown`. */
|
|
171
|
+
timeout?: Duration;
|
|
172
|
+
};
|
|
173
|
+
|
|
174
|
+
/** A push's input: what `pin` and `squash` add. */
|
|
175
|
+
export type PushInput<O extends PushOptions> = (O["pin"] extends true ? { sha: string } : unknown) &
|
|
176
|
+
(O["squash"] extends true ? { message: string } : unknown);
|
|
177
|
+
|
|
178
|
+
/** The sync's answer as a push's output carries it: a field beside the outcome, never the outcome. */
|
|
179
|
+
export type SyncState = SyncAnswer["sync"];
|
|
180
|
+
|
|
181
|
+
/** A push's output, as the Machine routes on it: `pushed | rejected | diverged | unknown`, and beside
|
|
182
|
+
* `pushed` the sync's answer — `synced | already | skipped | busy | not-synced`, with its reason
|
|
183
|
+
* when it did not move the branch. The sync's bundle never reaches the Machine. */
|
|
184
|
+
export type PushOutput =
|
|
185
|
+
| Exclude<PushWireOutput, { outcome: "pushed" }>
|
|
186
|
+
| (Omit<Extract<PushWireOutput, { outcome: "pushed" }>, "bundle"> & { sync: SyncState; syncReason?: string });
|
|
187
|
+
|
|
188
|
+
export function pushParts(options: PushOptions = {}): PushParts {
|
|
189
|
+
const inputKeys = [...(options.pin ? ["sha"] : []), ...(options.squash ? ["message"] : [])];
|
|
190
|
+
return {
|
|
191
|
+
kind: "push",
|
|
192
|
+
pin: options.pin === true,
|
|
193
|
+
sign: options.sign === true,
|
|
194
|
+
squash: options.squash === true,
|
|
195
|
+
force: options.force === true,
|
|
196
|
+
timeoutMs: options.timeout === undefined ? DEFAULT_PUSH_TIMEOUT_MS : parseDuration(options.timeout),
|
|
197
|
+
secrets: [],
|
|
198
|
+
inputKeys,
|
|
199
|
+
...(options.slot !== undefined ? { slot: options.slot } : {}),
|
|
200
|
+
};
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/** The push's start for one run: the Binding, the branch, the pin, the ledger's last pair. */
|
|
204
|
+
export function pushStartOf(
|
|
205
|
+
parts: PushParts,
|
|
206
|
+
ref: RepoRef & { url: string },
|
|
207
|
+
branch: string,
|
|
208
|
+
input: { sha?: string; message?: string } | undefined,
|
|
209
|
+
pairs?: readonly Pair[],
|
|
210
|
+
): PushStartRequest {
|
|
211
|
+
const pin = parts.pin ? input?.sha : undefined;
|
|
212
|
+
if (parts.pin && (typeof pin !== "string" || !/^[0-9a-f]{40}$/.test(pin))) {
|
|
213
|
+
throw new Error(`push({ pin: true }): its input's sha is not a full commit id (got ${JSON.stringify(pin)})`);
|
|
214
|
+
}
|
|
215
|
+
const message = parts.squash ? input?.message : undefined;
|
|
216
|
+
if (parts.squash && (typeof message !== "string" || message.trim() === "")) {
|
|
217
|
+
throw new Error("push({ squash: true }): its input carries no message");
|
|
218
|
+
}
|
|
219
|
+
return {
|
|
220
|
+
kind: "push",
|
|
221
|
+
push: {
|
|
222
|
+
repo: { gitDir: ref.gitDir, worktree: ref.worktree, cache: ref.cache },
|
|
223
|
+
url: ref.url,
|
|
224
|
+
branch,
|
|
225
|
+
...(pin ? { pin } : {}),
|
|
226
|
+
...(parts.sign ? { sign: true } : {}),
|
|
227
|
+
...(message ? { squash: message } : {}),
|
|
228
|
+
...(parts.force ? { force: true } : {}),
|
|
229
|
+
...(pairs?.length ? { pairs: [...pairs] } : {}),
|
|
230
|
+
},
|
|
231
|
+
timeoutMs: parts.timeoutMs,
|
|
232
|
+
};
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/** A file-body http's start for one run (ADR-0067): the definition's parts, the url's segments, and the
|
|
236
|
+
* Worktree file as the body. */
|
|
237
|
+
export function fileStartOf(
|
|
238
|
+
parts: HttpParts,
|
|
239
|
+
worktree: string,
|
|
240
|
+
run: { path: string; segments?: readonly string[] },
|
|
241
|
+
): HttpStartRequest {
|
|
242
|
+
if (typeof run?.path !== "string") throw new Error("http: `file()` gave no path");
|
|
243
|
+
if (run.segments !== undefined && !run.segments.every((s) => typeof s === "string")) {
|
|
244
|
+
throw new Error("http: `file()`'s segments are not a list of strings");
|
|
245
|
+
}
|
|
246
|
+
const http = startRequestOf(parts, run.segments ? { path: run.segments } : {});
|
|
247
|
+
const contentType = parts.file?.contentType;
|
|
248
|
+
return { ...http, body: { file: { worktree, path: run.path, ...(contentType ? { contentType } : {}) } } };
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/** A credentialed command's env as its definition names it. */
|
|
252
|
+
export type CommandEnv = Readonly<Record<string, { readonly secret: string; readonly path: unknown }>>;
|
|
253
|
+
|
|
254
|
+
export function commandEnvParts(env: CommandEnv): Pick<CommandParts, "env" | "secrets" | "openSecret"> {
|
|
255
|
+
const out: Record<string, { secret: string; path?: true }> = {};
|
|
256
|
+
for (const [name, ref] of Object.entries(env)) {
|
|
257
|
+
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(name) || name === "PATH" || name === "HOME" || name === "TMPDIR") {
|
|
258
|
+
throw new Error(
|
|
259
|
+
`command(argv, { env }): "${name}" is not a variable the author sets — PATH, HOME and TMPDIR are the ` +
|
|
260
|
+
"Effector's (ADR-0067)",
|
|
261
|
+
);
|
|
262
|
+
}
|
|
263
|
+
// `secret(open)` and its `.path` stand under OPEN_SECRET until the composer names it (ADR-0054).
|
|
264
|
+
const s = ref.secret === OPEN_SECRET ? ref : secret(ref.secret);
|
|
265
|
+
out[name] = (ref as { path?: unknown }).path === true ? { secret: s.secret, path: true } : { secret: s.secret };
|
|
266
|
+
}
|
|
267
|
+
const opened = openSecretsIn(Object.values(env));
|
|
268
|
+
const secrets = [...new Set(Object.values(out).map((r) => r.secret))].filter((n) => n !== OPEN_SECRET).sort();
|
|
269
|
+
return { env: out, secrets, ...(opened ? { openSecret: true as const } : {}) };
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/** The credentialed command's start for one run. */
|
|
273
|
+
export function commandStartOf(
|
|
274
|
+
parts: CommandParts,
|
|
275
|
+
argv: string[],
|
|
276
|
+
ref: RepoRef,
|
|
277
|
+
branch: string,
|
|
278
|
+
pin?: string,
|
|
279
|
+
): CommandStartRequest {
|
|
280
|
+
return {
|
|
281
|
+
kind: "command",
|
|
282
|
+
command: {
|
|
283
|
+
repo: { gitDir: ref.gitDir, worktree: ref.worktree, cache: ref.cache },
|
|
284
|
+
branch,
|
|
285
|
+
...(pin ? { pin } : {}),
|
|
286
|
+
argv,
|
|
287
|
+
env: Object.fromEntries(
|
|
288
|
+
Object.entries(parts.env ?? {}).map(([k, v]) => [
|
|
289
|
+
k,
|
|
290
|
+
v.path ? { secret: v.secret, path: true } : { secret: v.secret },
|
|
291
|
+
]),
|
|
292
|
+
),
|
|
293
|
+
tail: parts.tail,
|
|
294
|
+
},
|
|
295
|
+
timeoutMs: parts.timeoutMs,
|
|
296
|
+
};
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
// --- The actor logic -------------------------------------------------------------------------------
|
|
300
|
+
|
|
301
|
+
type Settle = { type: "jr2.effect.settle"; output: unknown };
|
|
302
|
+
type Fault = { type: "jr2.effect.error"; error: unknown };
|
|
303
|
+
|
|
304
|
+
/** What a kind's hooks get: the run's ledger, the Workspace, and the call's signal. */
|
|
305
|
+
type KindContext = {
|
|
306
|
+
ambient: AmbientHandles;
|
|
307
|
+
binding: ReturnType<typeof boundRun>;
|
|
308
|
+
id: string;
|
|
309
|
+
slot: string;
|
|
310
|
+
signal: AbortSignal;
|
|
311
|
+
mark: (marker: Omit<EffectMarker, "kind" | "slot" | "effect">) => void;
|
|
312
|
+
};
|
|
313
|
+
|
|
314
|
+
/** How one Sandbox kind builds its start and what a restore may send again. */
|
|
315
|
+
type KindSpec = {
|
|
316
|
+
kind: "http" | "push" | "command";
|
|
317
|
+
idempotent: boolean;
|
|
318
|
+
/** A restore that finds no Effect sends it again with only the pinned commit. */
|
|
319
|
+
repeatsPinned: boolean;
|
|
320
|
+
label: (ambient: AmbientHandles) => string;
|
|
321
|
+
/** The Repo Slot the Effect runs on, written to its settled row. */
|
|
322
|
+
repoOf: (ambient: AmbientHandles, slot: string) => string;
|
|
323
|
+
/** The start for this run; `pinned` is the commit the intent row recorded. */
|
|
324
|
+
build: (input: unknown, ambient: AmbientHandles, slot: string, pinned?: string, extra?: StartExtra) => StartRequest;
|
|
325
|
+
/** Run before a start is sent (a push: the last sync again, and the ledger's pair). */
|
|
326
|
+
before?: (ctx: KindContext) => Promise<StartExtra>;
|
|
327
|
+
/** Run on the Effector's settled output before it is ledgered (a push: the sync). */
|
|
328
|
+
finish?: (output: unknown, ctx: KindContext) => Promise<unknown>;
|
|
329
|
+
/** What a settled output becomes for the Machine. */
|
|
330
|
+
deliver?: (output: unknown) => unknown;
|
|
331
|
+
};
|
|
332
|
+
|
|
333
|
+
type StartExtra = { pairs?: readonly Pair[] };
|
|
334
|
+
|
|
335
|
+
/** An Effect's run: its abort, and — for a credentialed command, once its Effector is known — the
|
|
336
|
+
* port its stop goes through (ADR-0067). */
|
|
337
|
+
type Running = { controller: AbortController; port?: EffectorPort };
|
|
338
|
+
const running = new WeakMap<AnyActorRef, Running>();
|
|
339
|
+
|
|
340
|
+
/** How long a stop's DELETE may take: the Effector answers once the command's group is gone,
|
|
341
|
+
* SIGTERM then SIGKILL past its grace, within its poll's hold. */
|
|
342
|
+
const STOP_WAIT_MS = 60_000;
|
|
343
|
+
|
|
344
|
+
function sandboxLogic<I, O>(
|
|
345
|
+
portFactory: EffectorPortFactory,
|
|
346
|
+
parts: HttpParts | PushParts | CommandParts,
|
|
347
|
+
spec: KindSpec,
|
|
348
|
+
): EffectLogic<I, O> {
|
|
349
|
+
const logic: ActorLogic<EffectSnapshot<O, I>, EventObject, I> = {
|
|
350
|
+
getInitialSnapshot: (_scope, input) => ({
|
|
351
|
+
status: "active",
|
|
352
|
+
output: undefined,
|
|
353
|
+
error: undefined,
|
|
354
|
+
input,
|
|
355
|
+
effectId: randomUUID(),
|
|
356
|
+
}),
|
|
357
|
+
transition: (snapshot, event, { self, system }) => {
|
|
358
|
+
if (snapshot.status !== "active") return snapshot;
|
|
359
|
+
if (event.type === "jr2.effect.settle") {
|
|
360
|
+
return { ...snapshot, status: "done", output: (event as Settle).output as O, input: undefined };
|
|
361
|
+
}
|
|
362
|
+
if (event.type === "jr2.effect.error") {
|
|
363
|
+
return { ...snapshot, status: "error", error: (event as Fault).error, input: undefined };
|
|
364
|
+
}
|
|
365
|
+
if (event.type === "xstate.stop") {
|
|
366
|
+
stopped(self, system, snapshot.effectId);
|
|
367
|
+
return { ...snapshot, status: "stopped", input: undefined };
|
|
368
|
+
}
|
|
369
|
+
return snapshot;
|
|
370
|
+
},
|
|
371
|
+
start: (snapshot, { self, system }) => {
|
|
372
|
+
if (snapshot.status !== "active") return;
|
|
373
|
+
const controller = new AbortController();
|
|
374
|
+
running.set(self, { controller });
|
|
375
|
+
void drive(self, system, snapshot, spec, portFactory, controller.signal).catch((error: unknown) => {
|
|
376
|
+
if (controller.signal.aborted) return;
|
|
377
|
+
if (self.getSnapshot().status === "active") self.send({ type: "jr2.effect.error", error } as Fault);
|
|
378
|
+
});
|
|
379
|
+
},
|
|
380
|
+
getPersistedSnapshot: (snapshot) => snapshot,
|
|
381
|
+
};
|
|
382
|
+
return Object.assign(logic, { effect: parts });
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
const slotName = (self: AnyActorRef): string => (typeof self.src === "string" ? self.src : self.id);
|
|
386
|
+
|
|
387
|
+
async function drive<I, O>(
|
|
388
|
+
self: AnyActorRef,
|
|
389
|
+
system: AnyActorSystem,
|
|
390
|
+
snapshot: EffectSnapshot<O, I>,
|
|
391
|
+
spec: KindSpec,
|
|
392
|
+
portFactory: EffectorPortFactory,
|
|
393
|
+
signal: AbortSignal,
|
|
394
|
+
): Promise<void> {
|
|
395
|
+
const binding = boundRun(system);
|
|
396
|
+
const id = snapshot.effectId;
|
|
397
|
+
const slot = slotName(self);
|
|
398
|
+
const ambient = ambientHandlesFor(self);
|
|
399
|
+
const now = (): string => new Date().toISOString();
|
|
400
|
+
const mark = (marker: Omit<EffectMarker, "kind" | "slot" | "effect">): void =>
|
|
401
|
+
binding?.marker?.({ kind: "effect", slot, effect: id, ...marker });
|
|
402
|
+
const settle = (output: unknown): void => {
|
|
403
|
+
if (signal.aborted || self.getSnapshot().status !== "active") return;
|
|
404
|
+
binding?.recordEffect?.(id, {
|
|
405
|
+
state: "settled",
|
|
406
|
+
kind: spec.kind,
|
|
407
|
+
slot,
|
|
408
|
+
...(ambient ? { sandbox: ambient.sandbox, ...repoField(spec, ambient, slot) } : {}),
|
|
409
|
+
output,
|
|
410
|
+
at: now(),
|
|
411
|
+
});
|
|
412
|
+
mark({ stage: "settled", output: output as HttpOutput });
|
|
413
|
+
self.send({ type: "jr2.effect.settle", output: spec.deliver ? spec.deliver(output) : output } satisfies Settle);
|
|
414
|
+
};
|
|
415
|
+
const unknown = (reason: string): Unknown => ({ outcome: "unknown", reason });
|
|
416
|
+
|
|
417
|
+
const row = binding?.effectRow?.(id);
|
|
418
|
+
if (row?.state === "settled") {
|
|
419
|
+
await Promise.resolve();
|
|
420
|
+
if (!signal.aborted && self.getSnapshot().status === "active") {
|
|
421
|
+
self.send({
|
|
422
|
+
type: "jr2.effect.settle",
|
|
423
|
+
output: spec.deliver ? spec.deliver(row.output) : row.output,
|
|
424
|
+
} satisfies Settle);
|
|
425
|
+
}
|
|
426
|
+
return;
|
|
427
|
+
}
|
|
428
|
+
if (row?.state === "aborted") {
|
|
429
|
+
settle(unknown("its state exited while it ran; not run again"));
|
|
430
|
+
return;
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
if (!ambient) {
|
|
434
|
+
throw new Error(
|
|
435
|
+
`${spec.kind} "${slot}" runs in the Sandbox Effector of a Workspace, and its state is not inside a ` +
|
|
436
|
+
"workspace() — invoke it from a Workspace's body (ADR-0067)",
|
|
437
|
+
);
|
|
438
|
+
}
|
|
439
|
+
const target = binding?.effector?.({ sandbox: ambient.sandbox });
|
|
440
|
+
if (!target) {
|
|
441
|
+
throw new Error(
|
|
442
|
+
`${spec.kind} "${slot}" runs in the Sandbox Effector of Sandbox "${ambient.sandbox}", and this Orchestrator ` +
|
|
443
|
+
"knows no address for it (ADR-0067)",
|
|
444
|
+
);
|
|
445
|
+
}
|
|
446
|
+
const port = portFactory(target);
|
|
447
|
+
// A credentialed command's stop goes through this port when its state exits (ADR-0067).
|
|
448
|
+
const run = running.get(self);
|
|
449
|
+
if (spec.kind === "command" && run) run.port = port;
|
|
450
|
+
const ctx: KindContext = { ambient, binding, id, slot, signal, mark };
|
|
451
|
+
|
|
452
|
+
const find = async (): Promise<{ found: EffectState | undefined } | undefined> => {
|
|
453
|
+
try {
|
|
454
|
+
return { found: await retrying(() => port.find(id, signal), signal) };
|
|
455
|
+
} catch (err) {
|
|
456
|
+
if (signal.aborted || !(err instanceof EffectorForged)) throw err;
|
|
457
|
+
settle(unknown(`${err.message}: the answer is not the Sandbox Effector's; not run again`));
|
|
458
|
+
return undefined;
|
|
459
|
+
}
|
|
460
|
+
};
|
|
461
|
+
const intent = (pinned?: string): EffectRow => ({
|
|
462
|
+
state: "intent",
|
|
463
|
+
kind: spec.kind,
|
|
464
|
+
slot,
|
|
465
|
+
placement: "sandbox",
|
|
466
|
+
sandbox: ambient.sandbox,
|
|
467
|
+
idempotent: spec.idempotent,
|
|
468
|
+
...(pinned ? { pinned } : {}),
|
|
469
|
+
at: now(),
|
|
470
|
+
});
|
|
471
|
+
/** Not found where it was: the restore rule of the kind. */
|
|
472
|
+
const notFound = async (pinned: string | undefined, why: string): Promise<void> => {
|
|
473
|
+
if (spec.repeatsPinned && pinned) {
|
|
474
|
+
mark({
|
|
475
|
+
stage: "started",
|
|
476
|
+
text: `${spec.label(ambient)} (sent again: only the pinned commit ${pinned.slice(0, 12)})`,
|
|
477
|
+
});
|
|
478
|
+
const extra = spec.before ? await spec.before(ctx) : {};
|
|
479
|
+
if (signal.aborted) return;
|
|
480
|
+
await send(spec.build(snapshot.input, ambient, slot, pinned, extra), pinned);
|
|
481
|
+
return;
|
|
482
|
+
}
|
|
483
|
+
if (spec.kind === "http" && spec.idempotent) {
|
|
484
|
+
mark({ stage: "started", text: `${spec.label(ambient)} (sent again: it is idempotent)` });
|
|
485
|
+
await send(spec.build(snapshot.input, ambient, slot), undefined);
|
|
486
|
+
return;
|
|
487
|
+
}
|
|
488
|
+
settle(unknown(`${why}; not run again`));
|
|
489
|
+
};
|
|
490
|
+
|
|
491
|
+
if (row?.state === "intent") {
|
|
492
|
+
const pinned = "pinned" in row ? row.pinned : undefined;
|
|
493
|
+
const asked = await find();
|
|
494
|
+
if (!asked || signal.aborted) return;
|
|
495
|
+
if (asked.found) {
|
|
496
|
+
await follow(asked.found, pinned);
|
|
497
|
+
return;
|
|
498
|
+
}
|
|
499
|
+
await notFound(
|
|
500
|
+
pinned,
|
|
501
|
+
"it was in flight when the Orchestrator restarted, and its Sandbox Effector no longer holds it",
|
|
502
|
+
);
|
|
503
|
+
return;
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
const extra = spec.before ? await spec.before(ctx) : {};
|
|
507
|
+
if (signal.aborted) return;
|
|
508
|
+
const req = spec.build(snapshot.input, ambient, slot, undefined, extra);
|
|
509
|
+
const big = tooLarge(req);
|
|
510
|
+
if (big) {
|
|
511
|
+
settle(big);
|
|
512
|
+
return;
|
|
513
|
+
}
|
|
514
|
+
binding?.recordEffect?.(id, intent());
|
|
515
|
+
mark({ stage: "started", text: spec.label(ambient) });
|
|
516
|
+
await send(req, undefined);
|
|
517
|
+
|
|
518
|
+
async function send(req: StartRequest, pinned: string | undefined): Promise<void> {
|
|
519
|
+
for (let wait = 250; ; wait = Math.min(wait * 2, RETRY_MAX_MS)) {
|
|
520
|
+
let state: EffectState;
|
|
521
|
+
try {
|
|
522
|
+
state = await port.start(id, req, signal);
|
|
523
|
+
} catch (err) {
|
|
524
|
+
if (signal.aborted) return;
|
|
525
|
+
if (err instanceof EffectorRefused || err instanceof EffectorMalformed) throw err;
|
|
526
|
+
if (!(err instanceof EffectorNotSent)) {
|
|
527
|
+
const asked = await find();
|
|
528
|
+
if (!asked || signal.aborted) return;
|
|
529
|
+
if (asked.found) {
|
|
530
|
+
await follow(asked.found, pinned);
|
|
531
|
+
return;
|
|
532
|
+
}
|
|
533
|
+
await notFound(pinned, "its start may have reached the Sandbox Effector, which does not hold it now");
|
|
534
|
+
return;
|
|
535
|
+
}
|
|
536
|
+
await sleep(wait, signal);
|
|
537
|
+
if (signal.aborted) return;
|
|
538
|
+
continue;
|
|
539
|
+
}
|
|
540
|
+
await follow(state, pinned);
|
|
541
|
+
return;
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
async function follow(first: EffectState, pinned: string | undefined): Promise<void> {
|
|
546
|
+
let state = first;
|
|
547
|
+
// The commit the Effector pinned goes to the intent row, saved at the write: a restore repeats
|
|
548
|
+
// only it.
|
|
549
|
+
if (state.pinned && state.pinned !== pinned) {
|
|
550
|
+
pinned = state.pinned;
|
|
551
|
+
binding?.recordEffect?.(id, intent(pinned));
|
|
552
|
+
}
|
|
553
|
+
for (;;) {
|
|
554
|
+
if (state.state === "settled" && state.output) {
|
|
555
|
+
const output = spec.finish ? await spec.finish(state.output, ctx) : state.output;
|
|
556
|
+
if (signal.aborted) return;
|
|
557
|
+
settle(output);
|
|
558
|
+
return;
|
|
559
|
+
}
|
|
560
|
+
if (state.state === "unknown") {
|
|
561
|
+
const asked = await find();
|
|
562
|
+
if (!asked || signal.aborted) return;
|
|
563
|
+
if (!asked.found) {
|
|
564
|
+
await notFound(pinned, "its Sandbox Effector no longer holds it (it restarted)");
|
|
565
|
+
return;
|
|
566
|
+
}
|
|
567
|
+
state = asked.found;
|
|
568
|
+
continue;
|
|
569
|
+
}
|
|
570
|
+
try {
|
|
571
|
+
state = await port.poll(state.replica, id, signal);
|
|
572
|
+
} catch (err) {
|
|
573
|
+
if (signal.aborted) return;
|
|
574
|
+
if (err instanceof EffectorRefused || err instanceof EffectorMalformed) throw err;
|
|
575
|
+
state = { effect: id, replica: state.replica, state: "unknown" };
|
|
576
|
+
}
|
|
577
|
+
if (signal.aborted) return;
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
/** The settled row's Repo Slot, or none when the Workspace does not name one the kind can run on. */
|
|
583
|
+
function repoField(spec: KindSpec, ambient: AmbientHandles, slot: string): { repo?: string } {
|
|
584
|
+
try {
|
|
585
|
+
return { repo: spec.repoOf(ambient, slot) };
|
|
586
|
+
} catch {
|
|
587
|
+
return {};
|
|
588
|
+
}
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
function stopped(self: AnyActorRef, system: AnyActorSystem, effectId: string): void {
|
|
592
|
+
const run = running.get(self);
|
|
593
|
+
run?.controller.abort();
|
|
594
|
+
running.delete(self);
|
|
595
|
+
const binding = boundRun(system);
|
|
596
|
+
if (binding?.hostStopping) return;
|
|
597
|
+
const row = binding?.effectRow?.(effectId);
|
|
598
|
+
if (row?.state !== "intent" || !("sandbox" in row)) return;
|
|
599
|
+
// A credentialed command is stopped where it runs: one signed DELETE on its own path, and its
|
|
600
|
+
// Sandbox Effector ends its process group (ADR-0067). A push and a file-body http are one request
|
|
601
|
+
// each, and have no stop.
|
|
602
|
+
// What the stop's answer says goes to the feed after the abort's line: the outcome the Effector
|
|
603
|
+
// settled it with (`exited` with the signal), or that the stop did not reach it. It is sent at once,
|
|
604
|
+
// while a start may still be on the wire: the Effector keeps a stop of an id it does not hold, and
|
|
605
|
+
// the start that arrives after it runs nothing.
|
|
606
|
+
const port = row.kind === "command" ? run?.port : undefined;
|
|
607
|
+
port?.stop(effectId, AbortSignal.timeout(STOP_WAIT_MS)).then(
|
|
608
|
+
(state) => {
|
|
609
|
+
binding?.marker?.({
|
|
610
|
+
kind: "effect",
|
|
611
|
+
slot: row.slot,
|
|
612
|
+
effect: effectId,
|
|
613
|
+
stage: "aborted",
|
|
614
|
+
text: stoppedText(state),
|
|
615
|
+
});
|
|
616
|
+
},
|
|
617
|
+
(err: unknown) => {
|
|
618
|
+
binding?.marker?.({
|
|
619
|
+
kind: "effect",
|
|
620
|
+
slot: row.slot,
|
|
621
|
+
effect: effectId,
|
|
622
|
+
stage: "aborted",
|
|
623
|
+
text: `the stop did not reach its Sandbox Effector (${err instanceof Error ? err.message : String(err)}); it may run until its timeout`,
|
|
624
|
+
});
|
|
625
|
+
},
|
|
626
|
+
);
|
|
627
|
+
binding?.recordEffect?.(
|
|
628
|
+
effectId,
|
|
629
|
+
{ state: "aborted", kind: row.kind, slot: row.slot, at: new Date().toISOString() },
|
|
630
|
+
"with-transition",
|
|
631
|
+
);
|
|
632
|
+
binding?.marker?.({
|
|
633
|
+
kind: "effect",
|
|
634
|
+
slot: row.slot,
|
|
635
|
+
effect: effectId,
|
|
636
|
+
stage: "aborted",
|
|
637
|
+
text: port
|
|
638
|
+
? "its state exited while it ran: its Sandbox Effector was asked to stop it; outcome unknown"
|
|
639
|
+
: "its state exited while it ran: no longer followed; the Sandbox Effector may still run it; outcome unknown",
|
|
640
|
+
});
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
/** The feed's line for a stop's answer (ADR-0067): the outcome a stopped command settled with —
|
|
644
|
+
* `exited` with the signal the stop sent last, and its code when the program caught SIGTERM; for
|
|
645
|
+
* one stopped before its program started, `unknown` with the Effector's reason (it did not run);
|
|
646
|
+
* for one that had ended first, that outcome. When no replica holds it, the start may not have
|
|
647
|
+
* arrived yet: the Effector keeps the stop, and the line says that a start still on the way runs
|
|
648
|
+
* nothing; one not settled within the stop's answer is said as such. */
|
|
649
|
+
function stoppedText(state: EffectState | undefined): string {
|
|
650
|
+
if (state === undefined) {
|
|
651
|
+
return "its Sandbox Effector held no such Effect when asked to stop it; a start still on the way runs nothing";
|
|
652
|
+
}
|
|
653
|
+
const out = state.state === "settled" ? state.output : undefined;
|
|
654
|
+
if (!out) return "its Sandbox Effector had not settled it when the stop answered; outcome unknown";
|
|
655
|
+
if (out.outcome === "exited" && "signal" in out) {
|
|
656
|
+
return `its Sandbox Effector stopped it: exited with ${out.signal}${out.code === undefined ? "" : `, code ${out.code}`}`;
|
|
657
|
+
}
|
|
658
|
+
if (out.outcome === "unknown" && "reason" in out) return `its Sandbox Effector stopped it: ${out.reason}`;
|
|
659
|
+
return `its Sandbox Effector stopped it: it had already ended (${out.outcome})`;
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
/** A start over the bytes an Effector reads is sent nowhere, and settles as its kind's own outcome
|
|
663
|
+
* (ADR-0067): an http `refused`; a push `rejected` at stage `start` — nothing pushed; a credentialed
|
|
664
|
+
* command `exited` 127 with the reason in its tail, the Effector's own form for a command that
|
|
665
|
+
* could not start. An Agent's pick parameter — a squash message, an argv part — can reach the bound. */
|
|
666
|
+
function tooLarge(req: StartRequest): HttpOutput | PushWireOutput | CommandWireOutput | undefined {
|
|
667
|
+
const bytes = Buffer.byteLength(JSON.stringify(req), "utf8");
|
|
668
|
+
if (bytes <= MAX_START_BYTES) return undefined;
|
|
669
|
+
const reason = `the request is ${bytes} bytes, over the ${MAX_START_BYTES} bytes an Effector reads`;
|
|
670
|
+
if (req.kind === "push") return { outcome: "rejected", stage: "start", reason: `${reason}; nothing was pushed` };
|
|
671
|
+
if (req.kind === "command") return { outcome: "exited", code: 127, tail: `${reason}; nothing was run\n` };
|
|
672
|
+
return { outcome: "refused", reason: `${reason}; nothing was sent` };
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
async function retrying<T>(call: () => Promise<T>, signal: AbortSignal): Promise<T> {
|
|
676
|
+
for (let wait = 250; ; wait = Math.min(wait * 2, RETRY_MAX_MS)) {
|
|
677
|
+
try {
|
|
678
|
+
return await call();
|
|
679
|
+
} catch (err) {
|
|
680
|
+
if (
|
|
681
|
+
signal.aborted ||
|
|
682
|
+
err instanceof EffectorRefused ||
|
|
683
|
+
err instanceof EffectorMalformed ||
|
|
684
|
+
err instanceof EffectorForged
|
|
685
|
+
) {
|
|
686
|
+
throw err;
|
|
687
|
+
}
|
|
688
|
+
await sleep(wait, signal);
|
|
689
|
+
if (signal.aborted) throw err;
|
|
690
|
+
}
|
|
691
|
+
}
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
function sleep(ms: number, signal: AbortSignal): Promise<void> {
|
|
695
|
+
return new Promise((resolve) => {
|
|
696
|
+
const timer = setTimeout(resolve, ms / 2 + Math.random() * (ms / 2));
|
|
697
|
+
signal.addEventListener("abort", () => {
|
|
698
|
+
clearTimeout(timer);
|
|
699
|
+
resolve();
|
|
700
|
+
});
|
|
701
|
+
});
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
function parseDuration(d: Duration): number {
|
|
705
|
+
const n = Number.parseFloat(d);
|
|
706
|
+
if (!(n > 0)) throw new Error(`timeout "${d}" is not a positive duration (like "30s", "5m")`);
|
|
707
|
+
return d.endsWith("h") ? n * 3_600_000 : d.endsWith("m") ? n * 60_000 : n * 1000;
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
// --- The constructors, over an injected port factory (the unit tests' seam) ----------------------
|
|
711
|
+
|
|
712
|
+
export function pushWith<const O extends PushOptions>(
|
|
713
|
+
portFactory: EffectorPortFactory,
|
|
714
|
+
options?: O,
|
|
715
|
+
syncPorts: SyncPortFactory = harnessSyncPort,
|
|
716
|
+
): EffectLogic<PushInput<O> | void, PushOutput> {
|
|
717
|
+
const parts = pushParts(options);
|
|
718
|
+
const repoOf = (ambient: AmbientHandles, slot: string) => slotOf("push", slot, parts.slot, ambient);
|
|
719
|
+
const worktreeOf = (ctx: KindContext) => ctx.ambient.repos[repoOf(ctx.ambient, ctx.slot)]!;
|
|
720
|
+
const syncOf = (ctx: KindContext) =>
|
|
721
|
+
syncPorts(ctx.ambient.endpoint, ctx.binding?.harnessBearer?.(ctx.ambient.sandbox));
|
|
722
|
+
return sandboxLogic(portFactory, parts, {
|
|
723
|
+
kind: "push",
|
|
724
|
+
idempotent: false,
|
|
725
|
+
repeatsPinned: true,
|
|
726
|
+
label: (ambient) => `push ${ambient.branch}${parts.sign ? " (signed)" : ""}`,
|
|
727
|
+
repoOf,
|
|
728
|
+
build: (input, ambient, slot, pinned, extra) => {
|
|
729
|
+
const repoSlot = slotOf("push", slot, parts.slot, ambient);
|
|
730
|
+
const ref = repoRefOf(ambient, repoSlot);
|
|
731
|
+
const req = pushStartOf(parts, ref, ambient.branch, input as { sha?: string; message?: string }, extra?.pairs);
|
|
732
|
+
if (pinned) req.push.pin = pinned;
|
|
733
|
+
return req;
|
|
734
|
+
},
|
|
735
|
+
// Every push of this Workspace's Repo Slot — one branch, one remote — that published gives the
|
|
736
|
+
// Effector a pair, oldest first (ADR-0067). The newest one's sync, when it did not finish, runs
|
|
737
|
+
// again first, in that slot's Worktree and from the bundle the row keeps while it is the newest
|
|
738
|
+
// push and its sync did not finish (`dropSavedTails`); the push does not depend on it.
|
|
739
|
+
before: async (ctx) => {
|
|
740
|
+
const rows = ctx.binding?.effectRows?.() ?? {};
|
|
741
|
+
const repo = repoOf(ctx.ambient, ctx.slot);
|
|
742
|
+
const last = lastPushOf(rows, ctx.ambient.sandbox, repo, ctx.id);
|
|
743
|
+
if (last === undefined) return {};
|
|
744
|
+
const { row, rowId, output } = last;
|
|
745
|
+
if (output.sync === "busy" || output.sync === "not-synced") {
|
|
746
|
+
const answer = await syncOf(ctx).sync(
|
|
747
|
+
{
|
|
748
|
+
worktree: worktreeOf(ctx),
|
|
749
|
+
branch: ctx.ambient.branch,
|
|
750
|
+
published: output.published,
|
|
751
|
+
source: output.source,
|
|
752
|
+
...(typeof output.bundle === "string" ? { bundle: output.bundle } : {}),
|
|
753
|
+
},
|
|
754
|
+
ctx.signal,
|
|
755
|
+
);
|
|
756
|
+
if (ctx.signal.aborted) return {};
|
|
757
|
+
ctx.mark({ stage: "output", text: `the last push's sync, run again before this push: ${answer.sync}` });
|
|
758
|
+
ctx.binding?.recordEffect?.(rowId, { ...row, output: { ...output, ...syncFields(answer) } });
|
|
759
|
+
}
|
|
760
|
+
// Each row's `squashed` rides beside its pair: a push that maps the same commit names what it is.
|
|
761
|
+
return { pairs: pushPairsOf(rows, ctx.ambient.sandbox, repo, ctx.id) };
|
|
762
|
+
},
|
|
763
|
+
// After every pushed outcome whose published commit is not the Agent's own — signed copies, or
|
|
764
|
+
// one squashed commit, signed or not (ADR-0067): the sync, in the Workspace's Harness, from the
|
|
765
|
+
// bundle the Effector wrote, a push that published nothing new (a pair or cherry that mapped
|
|
766
|
+
// the published tip) included. The decision keys on published != source, never on `sign`. A
|
|
767
|
+
// push of the Agent's own commit is `skipped`, nothing to send.
|
|
768
|
+
finish: async (output, ctx) => {
|
|
769
|
+
const o = output as PushWireOutput;
|
|
770
|
+
if (o.outcome !== "pushed") return o;
|
|
771
|
+
if (o.published === o.source) return { ...o, sync: "skipped" satisfies SyncState };
|
|
772
|
+
const answer = await syncOf(ctx).sync(
|
|
773
|
+
{
|
|
774
|
+
worktree: worktreeOf(ctx),
|
|
775
|
+
branch: ctx.ambient.branch,
|
|
776
|
+
published: o.published,
|
|
777
|
+
source: o.source,
|
|
778
|
+
...(o.bundle !== undefined ? { bundle: o.bundle } : {}),
|
|
779
|
+
},
|
|
780
|
+
ctx.signal,
|
|
781
|
+
);
|
|
782
|
+
return { ...o, ...syncFields(answer) };
|
|
783
|
+
},
|
|
784
|
+
deliver: (output) => {
|
|
785
|
+
const o = output as PushWireOutput;
|
|
786
|
+
if (o.outcome !== "pushed" || o.bundle === undefined) return o;
|
|
787
|
+
const { bundle: _bundle, ...rest } = o;
|
|
788
|
+
return rest;
|
|
789
|
+
},
|
|
790
|
+
}) as EffectLogic<PushInput<O> | void, PushOutput>;
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
/** The sync's answer as the output's fields: its state, and its reason when it moved nothing. */
|
|
794
|
+
function syncFields(answer: SyncAnswer): { sync: SyncState; syncReason?: string } {
|
|
795
|
+
return {
|
|
796
|
+
sync: answer.sync,
|
|
797
|
+
...("reason" in answer && answer.sync !== "skipped" ? { syncReason: answer.reason } : {}),
|
|
798
|
+
};
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
export { lastPushOf };
|
|
802
|
+
|
|
803
|
+
/** An http whose body is a Worktree file (ADR-0067), over an injected port factory — the seam `http()`
|
|
804
|
+
* and the unit tests share. It runs in the Sandbox Effector of the Workspace whose body invokes it. */
|
|
805
|
+
export function httpFileWith<
|
|
806
|
+
S extends z.ZodType | undefined = undefined,
|
|
807
|
+
const U extends HttpDefinition<S>["url"] = string,
|
|
808
|
+
const A extends HttpDefinition<S>["auth"] = undefined,
|
|
809
|
+
const H extends HttpDefinition<S>["headers"] = undefined,
|
|
810
|
+
>(
|
|
811
|
+
portFactory: EffectorPortFactory,
|
|
812
|
+
def: HttpDefinition<S> & { url: U; auth?: A; headers?: H },
|
|
813
|
+
): EffectLogic<S extends z.ZodType ? z.infer<S> : void, HttpOutput> & OpenOf<HttpOpenParts<U, A, H>>;
|
|
814
|
+
export function httpFileWith<S extends z.ZodType | undefined = undefined>(
|
|
815
|
+
portFactory: EffectorPortFactory,
|
|
816
|
+
def: HttpDefinition<S>,
|
|
817
|
+
): EffectLogic<S extends z.ZodType ? z.infer<S> : void, HttpOutput> {
|
|
818
|
+
// Open parts (ADR-0054), as http's own: the unbound logic binds by building this Effect again,
|
|
819
|
+
// whole, with the composer's url or secret — a file body changes where it runs, not how it binds.
|
|
820
|
+
const unbound = openHttpOf(def, (bound) => httpFileWith(portFactory, bound));
|
|
821
|
+
if (unbound) return unbound as EffectLogic<S extends z.ZodType ? z.infer<S> : void, HttpOutput>;
|
|
822
|
+
const parts = httpParts(def);
|
|
823
|
+
const body = def.body!.file;
|
|
824
|
+
const fromInput = <T>(v: FromInput<S, T> | undefined, input: unknown): T | undefined =>
|
|
825
|
+
typeof v === "function" ? (v as (i: unknown) => T)(input) : v;
|
|
826
|
+
return sandboxLogic(portFactory, parts, {
|
|
827
|
+
kind: "http",
|
|
828
|
+
idempotent: parts.idempotent,
|
|
829
|
+
repeatsPinned: false,
|
|
830
|
+
label: () => `http ${parts.method} ${parts.target.scheme}://${parts.target.host} (a Worktree file)`,
|
|
831
|
+
repoOf: (ambient, slot) => slotOf("http", slot, body.slot, ambient),
|
|
832
|
+
build: (input, ambient, slot) => {
|
|
833
|
+
let value: unknown = input;
|
|
834
|
+
if (def.input !== undefined) {
|
|
835
|
+
const parsed = def.input.safeParse(input);
|
|
836
|
+
if (!parsed.success) throw new Error(`http "${slot}": its input does not match the definition's input schema`);
|
|
837
|
+
value = parsed.data;
|
|
838
|
+
}
|
|
839
|
+
const repoSlot = slotOf("http", slot, body.slot, ambient);
|
|
840
|
+
const worktree = posix.relative(WORK_ROOT, ambient.repos[repoSlot]!);
|
|
841
|
+
const segments = fromInput<readonly string[]>(body.segments, value);
|
|
842
|
+
return fileStartOf(parts, worktree, {
|
|
843
|
+
path: fromInput<string>(body.path, value)!,
|
|
844
|
+
...(segments !== undefined ? { segments } : {}),
|
|
845
|
+
});
|
|
846
|
+
},
|
|
847
|
+
});
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
/** A command whose env names a secret: it runs in the Sandbox Effector against the pinned commit. */
|
|
851
|
+
export function credentialedCommandWith(
|
|
852
|
+
portFactory: EffectorPortFactory,
|
|
853
|
+
parts: CommandParts,
|
|
854
|
+
argvOf: (input: unknown, slot: string) => string[],
|
|
855
|
+
pin = false,
|
|
856
|
+
): EffectLogic<unknown, CommandWireOutput> {
|
|
857
|
+
return sandboxLogic(portFactory, parts, {
|
|
858
|
+
kind: "command",
|
|
859
|
+
idempotent: false,
|
|
860
|
+
repeatsPinned: true,
|
|
861
|
+
label: () => parts.argv.map((a) => (typeof a === "string" ? a : `<${(a as Arg).arg}>`)).join(" "),
|
|
862
|
+
repoOf: (ambient, slot) => slotOf("command", slot, parts.slot, ambient),
|
|
863
|
+
build: (input, ambient, slot, pinned) => {
|
|
864
|
+
const repoSlot = slotOf("command", slot, parts.slot, ambient);
|
|
865
|
+
const ref = repoRefOf(ambient, repoSlot);
|
|
866
|
+
const approved = pin ? (input as { sha?: unknown } | undefined)?.sha : undefined;
|
|
867
|
+
if (pin && (typeof approved !== "string" || !/^[0-9a-f]{40}$/.test(approved))) {
|
|
868
|
+
throw new Error(`command "${slot}" (pin): its input's sha is not a full commit id`);
|
|
869
|
+
}
|
|
870
|
+
return commandStartOf(
|
|
871
|
+
parts,
|
|
872
|
+
argvOf(input, slot),
|
|
873
|
+
ref,
|
|
874
|
+
ambient.branch,
|
|
875
|
+
pinned ?? (approved as string | undefined),
|
|
876
|
+
);
|
|
877
|
+
},
|
|
878
|
+
});
|
|
879
|
+
}
|
|
880
|
+
|
|
881
|
+
/**
|
|
882
|
+
* THE authoring surface for a push (ADR-0067): `actors: { publish: push({ sign: true }) }`, invoked
|
|
883
|
+
* from a Workspace's body. It pushes the Workspace's branch to its Repo Slot's Binding from the
|
|
884
|
+
* Sandbox Effector, with the push credential of the `git.credentials` entry the Binding matches.
|
|
885
|
+
* Every outcome is output: `pushed | rejected | diverged | unknown`.
|
|
886
|
+
*/
|
|
887
|
+
export function push<const O extends PushOptions>(options?: O): EffectLogic<PushInput<O> | void, PushOutput> {
|
|
888
|
+
return pushWith(sandboxEffectorPort, options);
|
|
889
|
+
}
|
|
890
|
+
|
|
891
|
+
export { DEFAULT_COMMAND_TIMEOUT_MS, DEFAULT_TAIL };
|