@jr2/orchestrator 0.3.0 → 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 -4
- package/src/actor.ts +502 -49
- 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 +152 -46
- package/src/content-hash.ts +105 -0
- package/src/custodian.lua +6 -5
- package/src/custodian.ts +71 -19
- 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 +245 -23
- package/src/held-secrets.ts +82 -26
- package/src/holds.ts +156 -0
- package/src/http-effect.ts +981 -0
- package/src/http.ts +496 -104
- package/src/images.ts +47 -21
- package/src/index.ts +43 -2
- package/src/instance.ts +444 -58
- package/src/kube-client.ts +333 -0
- package/src/machine-doc.ts +88 -6
- package/src/mount-digest.ts +53 -0
- package/src/names.ts +66 -8
- 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 +60 -85
- package/src/repos.ts +63 -74
- package/src/rest.ts +133 -0
- package/src/run-host.ts +1832 -98
- 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 +281 -84
- 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 +35 -4
- package/src/vocabulary.ts +66 -1
- package/src/wire.ts +156 -2
- package/src/workspace.ts +529 -194
- package/src/sandbox-kubectl.ts +0 -1108
|
@@ -0,0 +1,981 @@
|
|
|
1
|
+
// The `http` Effect (ADR-0068): call a URL from the Instance Effector, with a secret the Agent
|
|
2
|
+
// never reads. An actor slot beside `command()` (ADR-0067):
|
|
3
|
+
//
|
|
4
|
+
// comment: http({
|
|
5
|
+
// method: "POST",
|
|
6
|
+
// url: "https://jira.example.com/rest/api/2/issue",
|
|
7
|
+
// auth: bearer(secret("jira")),
|
|
8
|
+
// input: z.object({ key: z.string(), body: z.string() }),
|
|
9
|
+
// request: ({ key, body }) => ({ path: [key, "comment"], json: { body } }),
|
|
10
|
+
// })
|
|
11
|
+
//
|
|
12
|
+
// Its body may be a Worktree file instead (ADR-0067), and then it runs in the Sandbox Effector of the
|
|
13
|
+
// Workspace whose body invokes it, which opens the file beneath the Worktree with no symlink:
|
|
14
|
+
//
|
|
15
|
+
// report: http({
|
|
16
|
+
// method: "PUT",
|
|
17
|
+
// url: "https://files.example.com/reports",
|
|
18
|
+
// auth: bearer(secret("files")),
|
|
19
|
+
// input: z.object({ marker: z.string() }),
|
|
20
|
+
// body: file("dist/report.pdf", { segments: ({ marker }) => [marker], contentType: "application/pdf" }),
|
|
21
|
+
// })
|
|
22
|
+
//
|
|
23
|
+
// The definition is plain data `jr2 up` reads without running anything (`HttpParts`): the kind,
|
|
24
|
+
// the placement (`instance`, or `sandbox` for a file body), the fixed host, the secret names. `request` is the one function: it
|
|
25
|
+
// maps the Effect's typed input to the run's part of the request — `path` segments and a body —
|
|
26
|
+
// and nothing else, so an Agent's param reaches a path segment or a body and never the host, a
|
|
27
|
+
// header or a secret. The Effector builds the request; the Orchestrator holds no secret.
|
|
28
|
+
//
|
|
29
|
+
// What this module adds to the Effect's restart safety (effect.ts) for this kind:
|
|
30
|
+
//
|
|
31
|
+
// - THE RESTORE RULE. The intent row names the placement and no replica. After an
|
|
32
|
+
// Orchestrator restart, or a start whose answer never came, the Effect asks EVERY replica for
|
|
33
|
+
// its id: one that holds it is followed; when none does, an Effect the author marked
|
|
34
|
+
// `idempotent` is sent again, and any other settles `unknown`. A start that certainly never
|
|
35
|
+
// reached a replica (no connection, a stopping replica) is sent again either way.
|
|
36
|
+
// - A FORGED ANSWER. An answer the placement's key does not verify is not the Effector's: a key
|
|
37
|
+
// that differs from the Effector's, or another process on its address. A poll's is read as a
|
|
38
|
+
// replica gone (its address may be stale), so every replica is asked; when asking every
|
|
39
|
+
// replica gets one, the Effect settles `unknown` and is not sent again — no retry changes the
|
|
40
|
+
// key the answer is checked with, and such a process can make the call fail, never answer it.
|
|
41
|
+
// - D1. When its state exits, the Orchestrator stops polling and records the abort, "outcome
|
|
42
|
+
// unknown", on the ledger and the feed. An http Effect has no stop route: the Effector finishes the call
|
|
43
|
+
// and drops an outcome nobody reads.
|
|
44
|
+
|
|
45
|
+
import { randomUUID } from "node:crypto";
|
|
46
|
+
import type { ActorLogic, AnyActorRef, EventObject } from "xstate";
|
|
47
|
+
import type { z } from "zod";
|
|
48
|
+
import type {
|
|
49
|
+
Duration,
|
|
50
|
+
EffectLogic,
|
|
51
|
+
EffectMarker,
|
|
52
|
+
EffectRow,
|
|
53
|
+
EffectSnapshot,
|
|
54
|
+
HttpParts,
|
|
55
|
+
OpenHttpParts,
|
|
56
|
+
Unknown,
|
|
57
|
+
} from "./effect.ts";
|
|
58
|
+
import {
|
|
59
|
+
createEffectorClient,
|
|
60
|
+
EffectorForged,
|
|
61
|
+
EffectorMalformed,
|
|
62
|
+
EffectorNotSent,
|
|
63
|
+
EffectorRefused,
|
|
64
|
+
HTTP_METHODS,
|
|
65
|
+
isEffectHeader,
|
|
66
|
+
MAX_START_BYTES,
|
|
67
|
+
nodeEffectorTransport,
|
|
68
|
+
type EffectorPort,
|
|
69
|
+
type EffectState,
|
|
70
|
+
type HttpMethod,
|
|
71
|
+
type HttpOutput,
|
|
72
|
+
type Replica,
|
|
73
|
+
type HttpStartRequest,
|
|
74
|
+
type StartRequest,
|
|
75
|
+
type TextPart,
|
|
76
|
+
type WireAuth,
|
|
77
|
+
type WireBody,
|
|
78
|
+
} from "./effector-wire.ts";
|
|
79
|
+
import { boundRun, type AnyActorSystem } from "./registration.ts";
|
|
80
|
+
import { open } from "./open.ts";
|
|
81
|
+
import { effectBindingShape, openEffectLogic, openPartsOf, type OpenOf } from "./open-effect.ts";
|
|
82
|
+
import { httpFileWith, sandboxEffectorPort } from "./sandbox-effect.ts";
|
|
83
|
+
|
|
84
|
+
// --- Naming a secret -----------------------------------------------------------------------------
|
|
85
|
+
|
|
86
|
+
/** A secret an Effect names, by its name in the config's `secrets` list (ADR-0068). Naming it in an
|
|
87
|
+
* Effect is what places it in that Effect's Effector: an Effect secret. Its value never leaves the
|
|
88
|
+
* Effector; on the wire it is a name. */
|
|
89
|
+
export type Secret = { readonly secret: string; readonly path: { readonly secret: string; readonly path: true } };
|
|
90
|
+
|
|
91
|
+
/** A string the Effector builds: literal text and secrets, in order — the `text` template's value. */
|
|
92
|
+
export type Text = { readonly text: readonly TextPart[] };
|
|
93
|
+
|
|
94
|
+
declare const openSecretMark: unique symbol;
|
|
95
|
+
/** The TYPE half of an Open secret (ADR-0054): whatever carries `secret(open)` — the secret, its
|
|
96
|
+
* `.path`, a `text` or a `basic` pair holding it, a `bearer` of it — carries this mark, so the
|
|
97
|
+
* Effect's constructor states the Open part "secret" and `customize()` offers `{ secret }`. Phantom. */
|
|
98
|
+
export type OpenSecretMark = { readonly [openSecretMark]: true };
|
|
99
|
+
|
|
100
|
+
/** `secret(open)`: a secret a packaged Machine names in its auth, a header or a command's env, and
|
|
101
|
+
* leaves for its composer to name (ADR-0054). */
|
|
102
|
+
export type OpenSecret = {
|
|
103
|
+
readonly secret: string;
|
|
104
|
+
readonly path: { readonly secret: string; readonly path: true } & OpenSecretMark;
|
|
105
|
+
} & OpenSecretMark;
|
|
106
|
+
|
|
107
|
+
/** The name an Open secret stands under in a definition's plain data until `customize()` binds it.
|
|
108
|
+
* `@` is no character of a secret's name, so it can never be one; an Effect that still holds it
|
|
109
|
+
* runs nothing (open-effect.ts). */
|
|
110
|
+
export const OPEN_SECRET = "@open";
|
|
111
|
+
|
|
112
|
+
const SECRET_NAME = /^[A-Za-z0-9._-]{1,253}$/;
|
|
113
|
+
|
|
114
|
+
/** Each `secret(open)` a definition names, by every object that stands for it — the secret, its
|
|
115
|
+
* `.path`, a `text` part built from it — so a definition that names one Open secret in two places
|
|
116
|
+
* (a value and its `.path`) is told from one that names two. */
|
|
117
|
+
const openSecretOf = new WeakMap<object, object>();
|
|
118
|
+
|
|
119
|
+
/** Name a secret: `secret("jira")`. `secret(open)` leaves the name to the composer (ADR-0054). */
|
|
120
|
+
export function secret(name: typeof open): OpenSecret;
|
|
121
|
+
export function secret(name: string): Secret;
|
|
122
|
+
export function secret(name: string | typeof open): Secret | OpenSecret {
|
|
123
|
+
if ((name as unknown) === open) {
|
|
124
|
+
// An Open secret (ADR-0054): the composer binds its name once, with `customize()`'s
|
|
125
|
+
// `{ effects: { <slot>: { secret: "<name>" } } }`. Its plain data names `OPEN_SECRET` until then.
|
|
126
|
+
const s = { secret: OPEN_SECRET } as OpenSecret;
|
|
127
|
+
const path = Object.freeze({ secret: OPEN_SECRET, path: true as const });
|
|
128
|
+
Object.defineProperty(s, "path", { value: path, enumerable: false });
|
|
129
|
+
openSecretOf.set(s, s);
|
|
130
|
+
openSecretOf.set(path, s);
|
|
131
|
+
return s;
|
|
132
|
+
}
|
|
133
|
+
if (typeof name !== "string" || !SECRET_NAME.test(name) || name.startsWith(".")) {
|
|
134
|
+
throw new Error(
|
|
135
|
+
`secret("${String(name)}"): a secret's name is letters, digits, ".", "_" and "-", and does not start with "." — ` +
|
|
136
|
+
"it is the file the Effector reads it from (ADR-0068)",
|
|
137
|
+
);
|
|
138
|
+
}
|
|
139
|
+
// `.path` names the secret's FILE in the Effector's own mount, for a credentialed command's env
|
|
140
|
+
// (ADR-0067): `env: { KUBECONFIG: secret("kube").path }`. Not enumerable, so a definition's plain
|
|
141
|
+
// data never carries it.
|
|
142
|
+
const s = { secret: name } as Secret;
|
|
143
|
+
Object.defineProperty(s, "path", { value: Object.freeze({ secret: name, path: true as const }), enumerable: false });
|
|
144
|
+
return s;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** What a value that may hold `secret(open)` gives: marked when it does. */
|
|
148
|
+
type MarkedIf<V, T> = [Extract<V, OpenSecretMark>] extends [never] ? T : T & OpenSecretMark;
|
|
149
|
+
|
|
150
|
+
/** The values of a record type, or never. */
|
|
151
|
+
type ValuesOf<R> = R extends Readonly<Record<string, infer V>> ? V : never;
|
|
152
|
+
|
|
153
|
+
/** "secret" when one of these values holds `secret(open)`, else never: the Open part it states. */
|
|
154
|
+
export type OpenSecretIn<V> = [Extract<V, OpenSecretMark>] extends [never] ? never : "secret";
|
|
155
|
+
|
|
156
|
+
/** The Open parts an http definition's types state (ADR-0054): its url, when it is `open`, and its
|
|
157
|
+
* secret, when its auth or a header holds `secret(open)`. */
|
|
158
|
+
export type HttpOpenParts<U, A, H> = (U extends typeof open ? "url" : never) | OpenSecretIn<A | ValuesOf<H>>;
|
|
159
|
+
|
|
160
|
+
/** A secret's wire part: `{ secret: name }` — for an Open secret, a part that stands for it. */
|
|
161
|
+
function partOf(s: Secret): TextPart {
|
|
162
|
+
const part = { secret: s.secret };
|
|
163
|
+
const owner = openSecretOf.get(s);
|
|
164
|
+
if (owner) openSecretOf.set(part, owner);
|
|
165
|
+
return part;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** A string with secrets in it: text`Token ${secret("x")}`. Each `${…}` is a secret or a string. */
|
|
169
|
+
export function text<const V extends Array<Secret | OpenSecret | string>>(
|
|
170
|
+
strings: TemplateStringsArray,
|
|
171
|
+
...values: V
|
|
172
|
+
): MarkedIf<V[number], Text>;
|
|
173
|
+
export function text(strings: TemplateStringsArray, ...values: Array<Secret | string>): Text {
|
|
174
|
+
const parts: TextPart[] = [];
|
|
175
|
+
const push = (s: string): void => {
|
|
176
|
+
if (s === "") return;
|
|
177
|
+
const last = parts.at(-1);
|
|
178
|
+
if (last && "text" in last) parts[parts.length - 1] = { text: last.text + s };
|
|
179
|
+
else parts.push({ text: s });
|
|
180
|
+
};
|
|
181
|
+
strings.forEach((s, i) => {
|
|
182
|
+
push(s);
|
|
183
|
+
if (i >= values.length) return;
|
|
184
|
+
const v = values[i];
|
|
185
|
+
if (typeof v === "string") push(v);
|
|
186
|
+
else if (isSecret(v)) parts.push(partOf(v));
|
|
187
|
+
else throw new Error(`text\`…\`: a value is a secret("name") or a string, got ${JSON.stringify(v)}`);
|
|
188
|
+
});
|
|
189
|
+
return { text: parts };
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/** `Authorization: Bearer <value>`. The secret may be `secret(open)` (ADR-0054). */
|
|
193
|
+
export function bearer<const S extends Secret>(s: S): MarkedIf<S, { readonly bearer: Secret }>;
|
|
194
|
+
export function bearer(s: Secret): { readonly bearer: Secret } {
|
|
195
|
+
if (!isSecret(s)) throw new Error('bearer(…) takes a secret("name")');
|
|
196
|
+
return { bearer: s };
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/** `Authorization: Basic base64(user:password)`. Each is a string, a secret, or a `text`; either may
|
|
200
|
+
* hold `secret(open)` (ADR-0054). */
|
|
201
|
+
export function basic<const U extends string | Secret | Text, const P extends string | Secret | Text>(
|
|
202
|
+
user: U,
|
|
203
|
+
password: P,
|
|
204
|
+
): MarkedIf<U | P, { readonly basic: { user: Text; password: Text } }>;
|
|
205
|
+
export function basic(
|
|
206
|
+
user: string | Secret | Text,
|
|
207
|
+
password: string | Secret | Text,
|
|
208
|
+
): { readonly basic: { user: Text; password: Text } } {
|
|
209
|
+
return { basic: { user: asText(user), password: asText(password) } };
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function isSecret(v: unknown): v is Secret {
|
|
213
|
+
return typeof v === "object" && v !== null && typeof (v as Secret).secret === "string" && !("text" in v);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Whether these places of one definition — wire parts, secrets, `.path`s — name `secret(open)`
|
|
218
|
+
* (ADR-0054). They may name one Open secret, in as many places as the author likes (a value and its
|
|
219
|
+
* `.path`); a second `secret(open)` is refused: one binding names one secret.
|
|
220
|
+
*/
|
|
221
|
+
export function openSecretsIn(items: readonly unknown[]): boolean {
|
|
222
|
+
const owners = new Set<object>();
|
|
223
|
+
for (const item of items) {
|
|
224
|
+
if (typeof item !== "object" || item === null || (item as { secret?: unknown }).secret !== OPEN_SECRET) continue;
|
|
225
|
+
owners.add(openSecretOf.get(item) ?? item);
|
|
226
|
+
}
|
|
227
|
+
if (owners.size > 1) {
|
|
228
|
+
throw new Error(
|
|
229
|
+
"an Effect takes one Open secret — it names secret(open) in two places that are two secrets; name one " +
|
|
230
|
+
'secret(open) and use it in each place, or name the others with secret("<name>") (ADR-0054)',
|
|
231
|
+
);
|
|
232
|
+
}
|
|
233
|
+
return owners.size === 1;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* One value of a definition with its Open secret named (ADR-0054): `secret(open)` becomes
|
|
238
|
+
* `secret(name)`, its `.path` that secret's `.path`, and a `text` part `{ secret: name }` inside a
|
|
239
|
+
* `text`. A string, a named secret and a `text` with no Open part are as written. `secret(name)`
|
|
240
|
+
* checks the name, as it checks one the author writes.
|
|
241
|
+
*/
|
|
242
|
+
function bindOpenValue(v: unknown, name: string): unknown {
|
|
243
|
+
if (typeof v !== "object" || v === null) return v;
|
|
244
|
+
const o = v as { secret?: unknown; path?: unknown; text?: unknown };
|
|
245
|
+
if (o.secret === OPEN_SECRET) return o.path === true ? secret(name).path : secret(name);
|
|
246
|
+
if (Array.isArray(o.text)) return { text: o.text.map((part) => bindOpenValue(part, name)) };
|
|
247
|
+
return v;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* A header or env record with its Open secret named (ADR-0054): each VALUE is bound, by its place —
|
|
252
|
+
* a key is the author's header or variable name, whatever it is (`bearer`, `secret`, `basic`), and
|
|
253
|
+
* never read as a shape.
|
|
254
|
+
*/
|
|
255
|
+
export function bindOpenSecretIn<R extends Readonly<Record<string, unknown>> | undefined>(record: R, name: string): R {
|
|
256
|
+
if (record === undefined) return record;
|
|
257
|
+
return Object.fromEntries(Object.entries(record).map(([k, v]) => [k, bindOpenValue(v, name)])) as R;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/** An http definition's auth with its Open secret named (ADR-0054): a `bearer` of it, a `basic`
|
|
261
|
+
* pair holding it, or a `text` of it. */
|
|
262
|
+
export function bindOpenAuth<A extends HttpDefinition<z.ZodType | undefined>["auth"]>(auth: A, name: string): A {
|
|
263
|
+
if (auth === undefined) return auth;
|
|
264
|
+
if ("bearer" in auth) return { bearer: bindOpenValue(auth.bearer, name) } as A;
|
|
265
|
+
if ("basic" in auth) {
|
|
266
|
+
return {
|
|
267
|
+
basic: { user: bindOpenValue(auth.basic.user, name), password: bindOpenValue(auth.basic.password, name) },
|
|
268
|
+
} as A;
|
|
269
|
+
}
|
|
270
|
+
return bindOpenValue(auth, name) as A;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
function asText(v: string | Secret | Text): Text {
|
|
274
|
+
if (typeof v === "string") return { text: v === "" ? [] : [{ text: v }] };
|
|
275
|
+
if (isSecret(v)) return { text: [partOf(v)] };
|
|
276
|
+
if (typeof v === "object" && v !== null && Array.isArray((v as Text).text)) return v;
|
|
277
|
+
throw new Error(`expected a string, a secret("name") or a text\`…\`, got ${JSON.stringify(v)}`);
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// --- A Worktree file as the body (ADR-0067) ------------------------------------------------------------
|
|
281
|
+
|
|
282
|
+
/** A value of a file body's part: as written, or from the Effect's input. */
|
|
283
|
+
export type FromInput<S, T> = T | ((input: S extends z.ZodType ? z.infer<S> : void) => T);
|
|
284
|
+
|
|
285
|
+
/** `file(path, { slot?, segments?, contentType? })`: a Worktree file as an `http` body. */
|
|
286
|
+
export type FileBody<S extends z.ZodType | undefined = undefined> = {
|
|
287
|
+
readonly file: {
|
|
288
|
+
/** The file, relative to the Repo Slot's Worktree. */
|
|
289
|
+
readonly path: FromInput<S, string>;
|
|
290
|
+
/** The Repo Slot whose Worktree holds it — needed only where the Workspace carries several. */
|
|
291
|
+
readonly slot?: string;
|
|
292
|
+
/** Path segments appended to the url, as `request`'s `path` is for a json or text body. */
|
|
293
|
+
readonly segments?: FromInput<S, readonly string[]>;
|
|
294
|
+
/** The body's content type: this or a `content-type` header of the definition's, not both (a
|
|
295
|
+
* definition with both is refused). With neither it is `application/octet-stream`. */
|
|
296
|
+
readonly contentType?: string;
|
|
297
|
+
};
|
|
298
|
+
};
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* A Worktree file as an `http` body (ADR-0067): `body: file("dist/report.pdf")`. The Effect then runs in
|
|
302
|
+
* the Sandbox Effector of the Workspace whose body invokes it, which opens the file beneath the
|
|
303
|
+
* Worktree — a symlink at any component, `..`, an absolute path, a FIFO, a directory and a file
|
|
304
|
+
* with more than one link are output `refused`, with nothing sent. `path` and `segments` may be
|
|
305
|
+
* functions of the Effect's input.
|
|
306
|
+
*/
|
|
307
|
+
export function file<S extends z.ZodType | undefined = undefined>(
|
|
308
|
+
path: FromInput<S, string>,
|
|
309
|
+
options: { slot?: string; segments?: FromInput<S, readonly string[]>; contentType?: string } = {},
|
|
310
|
+
): FileBody<S> {
|
|
311
|
+
if (typeof path !== "string" && typeof path !== "function") {
|
|
312
|
+
throw new Error("file(path): the path is a string, or a function of the Effect's input that returns one");
|
|
313
|
+
}
|
|
314
|
+
return {
|
|
315
|
+
file: {
|
|
316
|
+
path,
|
|
317
|
+
...(options.slot !== undefined ? { slot: options.slot } : {}),
|
|
318
|
+
...(options.segments !== undefined ? { segments: options.segments } : {}),
|
|
319
|
+
...(options.contentType !== undefined ? { contentType: options.contentType } : {}),
|
|
320
|
+
},
|
|
321
|
+
};
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/** The methods that send a file body. */
|
|
325
|
+
const FILE_METHODS: readonly HttpMethod[] = ["PUT", "POST", "PATCH"];
|
|
326
|
+
|
|
327
|
+
// --- The definition ------------------------------------------------------------------------------
|
|
328
|
+
|
|
329
|
+
/** What `request` returns: the run's part of the request. Everything else is the definition's. */
|
|
330
|
+
export type HttpRequestParts = {
|
|
331
|
+
/** Path segments appended to the url: each is encoded; `.`, `..` and an empty one are refused
|
|
332
|
+
* in the Effector as output, with nothing sent. */
|
|
333
|
+
path?: readonly string[];
|
|
334
|
+
/** A JSON body. */
|
|
335
|
+
json?: unknown;
|
|
336
|
+
/** A text body. */
|
|
337
|
+
text?: string;
|
|
338
|
+
};
|
|
339
|
+
|
|
340
|
+
/** An `http` Effect's definition. */
|
|
341
|
+
export type HttpDefinition<S extends z.ZodType | undefined = undefined> = {
|
|
342
|
+
method: HttpMethod;
|
|
343
|
+
/** The url. Its scheme and host are fixed here — never a param — and a secret may sit only in
|
|
344
|
+
* its path: text`https://api.telegram.org/bot${secret("tg")}/sendMessage`. Or `open`: a packaged
|
|
345
|
+
* Machine that cannot know the host leaves the url Open, and the composer binds it once with
|
|
346
|
+
* `customize(machine, { effects: { <slot>: { url } } })` — never per run (ADR-0054). */
|
|
347
|
+
url: string | Text | typeof open;
|
|
348
|
+
/** Static headers, lowercase; a value may be a `text` with a secret. */
|
|
349
|
+
headers?: Readonly<Record<string, string | Text>>;
|
|
350
|
+
auth?: { readonly bearer: Secret } | { readonly basic: { user: Text; password: Text } } | Text;
|
|
351
|
+
/** The Effect's input — what an invoke's `input` (or `fromPick`) hands it. */
|
|
352
|
+
input?: S;
|
|
353
|
+
/** The run's part of the request, from the input. */
|
|
354
|
+
request?: (input: S extends z.ZodType ? z.infer<S> : void) => HttpRequestParts;
|
|
355
|
+
/** A Worktree file as the body: `file(path, { slot?, segments?, contentType? })` (ADR-0067). Then the
|
|
356
|
+
* Effect runs in its Workspace's Sandbox Effector, and `request` is not written: `file()`'s
|
|
357
|
+
* `segments` are the path. */
|
|
358
|
+
body?: FileBody<S>;
|
|
359
|
+
/** Default 30s. Past it with no answer: output `unknown`. */
|
|
360
|
+
timeout?: Duration;
|
|
361
|
+
/** Bytes of a text body returned. Default 65536. */
|
|
362
|
+
maxBody?: number;
|
|
363
|
+
/** Marked by the author: the call may be sent twice (it carries its own idempotency key, or it
|
|
364
|
+
* is a read). A restore that finds no replica holding it then sends it again; unmarked, it
|
|
365
|
+
* settles `unknown` (ADR-0067). Default false. */
|
|
366
|
+
idempotent?: boolean;
|
|
367
|
+
};
|
|
368
|
+
|
|
369
|
+
export type HttpInput<S> = S extends z.ZodType ? z.infer<S> : void;
|
|
370
|
+
|
|
371
|
+
const DEFAULT_TIMEOUT_MS = 30_000;
|
|
372
|
+
const DEFAULT_MAX_BODY = 65_536;
|
|
373
|
+
/** How long a re-asked request waits, at most, after a transport failure. */
|
|
374
|
+
const RETRY_MAX_MS = 10_000;
|
|
375
|
+
|
|
376
|
+
/** The definition's plain data, checked: every refusal names what to write instead. */
|
|
377
|
+
export function httpParts<S extends z.ZodType | undefined>(def: HttpDefinition<S>): HttpParts {
|
|
378
|
+
if (!(HTTP_METHODS as readonly string[]).includes(def.method)) {
|
|
379
|
+
throw new Error(`http({ method: "${def.method}" }): the method is one of ${HTTP_METHODS.join(", ")}`);
|
|
380
|
+
}
|
|
381
|
+
if (def.url === open) throw new Error("httpParts: an Open url has no target — read it with openHttpParts");
|
|
382
|
+
const url = asText(def.url).text;
|
|
383
|
+
if (url.some((p) => "secret" in p && p.secret === OPEN_SECRET)) {
|
|
384
|
+
// A url that carries a secret is left Open whole (ADR-0054): the composer binds it with its
|
|
385
|
+
// secret in its path, so two copies of one Machine spend two teams' secrets.
|
|
386
|
+
throw new Error(
|
|
387
|
+
"secret(open) in a url: a url's secret is not an Open part — leave the whole url Open " +
|
|
388
|
+
"(`url: open`) and let the composer bind it with its secret in its path (ADR-0054)",
|
|
389
|
+
);
|
|
390
|
+
}
|
|
391
|
+
const target = targetOf(url);
|
|
392
|
+
const headers: Record<string, TextPart[]> = {};
|
|
393
|
+
for (const [name, value] of Object.entries(def.headers ?? {})) {
|
|
394
|
+
// The wire's own rule, so a definition the Machine loads is a start the Effector takes.
|
|
395
|
+
if (!isEffectHeader(name)) {
|
|
396
|
+
throw new Error(
|
|
397
|
+
`http({ headers }): "${name}" is not a header an Effect sets — a lowercase name that does not frame or ` +
|
|
398
|
+
"route the request (ADR-0068)",
|
|
399
|
+
);
|
|
400
|
+
}
|
|
401
|
+
if (name === "authorization" && def.auth !== undefined) {
|
|
402
|
+
throw new Error('http({ headers }): "authorization" is set by `auth` — write one of them');
|
|
403
|
+
}
|
|
404
|
+
headers[name] = [...asText(value).text];
|
|
405
|
+
}
|
|
406
|
+
const auth: WireAuth | undefined =
|
|
407
|
+
def.auth === undefined
|
|
408
|
+
? undefined
|
|
409
|
+
: "bearer" in def.auth
|
|
410
|
+
? { bearer: { secret: def.auth.bearer.secret } }
|
|
411
|
+
: "basic" in def.auth
|
|
412
|
+
? { basic: { user: [...def.auth.basic.user.text], password: [...def.auth.basic.password.text] } }
|
|
413
|
+
: { text: [...asText(def.auth).text] };
|
|
414
|
+
const secrets: string[] = [];
|
|
415
|
+
const note = (parts: readonly TextPart[]): void => {
|
|
416
|
+
for (const p of parts) if ("secret" in p && !secrets.includes(p.secret)) secrets.push(p.secret);
|
|
417
|
+
};
|
|
418
|
+
note(url);
|
|
419
|
+
for (const name of Object.keys(headers).sort()) note(headers[name]!);
|
|
420
|
+
if (auth && "bearer" in auth) note([{ secret: auth.bearer.secret }]);
|
|
421
|
+
if (auth && "basic" in auth) {
|
|
422
|
+
note(auth.basic.user);
|
|
423
|
+
note(auth.basic.password);
|
|
424
|
+
}
|
|
425
|
+
if (auth && "text" in auth) note(auth.text);
|
|
426
|
+
// An Open secret (ADR-0054) is the composer's to name: its places are the auth and the headers,
|
|
427
|
+
// one per Effect, and it names no secret until `customize()` binds it.
|
|
428
|
+
const opened = openSecretsIn([
|
|
429
|
+
...Object.values(headers).flat(),
|
|
430
|
+
...(def.auth === undefined
|
|
431
|
+
? []
|
|
432
|
+
: "bearer" in def.auth
|
|
433
|
+
? [def.auth.bearer]
|
|
434
|
+
: "basic" in def.auth
|
|
435
|
+
? [...def.auth.basic.user.text, ...def.auth.basic.password.text]
|
|
436
|
+
: asText(def.auth).text),
|
|
437
|
+
]);
|
|
438
|
+
const named = secrets.filter((name) => name !== OPEN_SECRET);
|
|
439
|
+
secrets.splice(0, secrets.length, ...named);
|
|
440
|
+
for (const name of secrets) secret(name);
|
|
441
|
+
const maxBody = def.maxBody ?? DEFAULT_MAX_BODY;
|
|
442
|
+
if (!Number.isInteger(maxBody) || maxBody <= 0) throw new Error("http({ maxBody }): a positive number of bytes");
|
|
443
|
+
const body = def.body?.file;
|
|
444
|
+
if (def.body !== undefined && (typeof body !== "object" || body === null)) {
|
|
445
|
+
throw new Error(
|
|
446
|
+
"http({ body }): a body is file(path, { slot?, segments?, contentType? }) — json and text come from `request`",
|
|
447
|
+
);
|
|
448
|
+
}
|
|
449
|
+
if (body && !FILE_METHODS.includes(def.method)) {
|
|
450
|
+
throw new Error(
|
|
451
|
+
`http({ method: "${def.method}", body: file(...) }): a file body is sent with ${FILE_METHODS.join(", ")} (ADR-0068)`,
|
|
452
|
+
);
|
|
453
|
+
}
|
|
454
|
+
if (body?.contentType !== undefined && headers["content-type"] !== undefined) {
|
|
455
|
+
throw new Error(
|
|
456
|
+
'http({ headers, body: file(path, { contentType }) }): "content-type" is set by file()\'s contentType — write ' +
|
|
457
|
+
"one of them (ADR-0067)",
|
|
458
|
+
);
|
|
459
|
+
}
|
|
460
|
+
if (body && def.request !== undefined) {
|
|
461
|
+
throw new Error(
|
|
462
|
+
"http({ body: file(...), request }): with a file body the run's part is file()'s — its path, from the input " +
|
|
463
|
+
"if it must be, and its `segments` — so write no `request` (ADR-0068)",
|
|
464
|
+
);
|
|
465
|
+
}
|
|
466
|
+
return {
|
|
467
|
+
kind: "http",
|
|
468
|
+
method: def.method,
|
|
469
|
+
target,
|
|
470
|
+
url,
|
|
471
|
+
headers,
|
|
472
|
+
...(auth ? { auth } : {}),
|
|
473
|
+
secrets,
|
|
474
|
+
inputKeys: inputKeysOfSchema(def.input),
|
|
475
|
+
timeoutMs: def.timeout === undefined ? DEFAULT_TIMEOUT_MS : parseDuration(def.timeout),
|
|
476
|
+
maxBody,
|
|
477
|
+
idempotent: def.idempotent === true,
|
|
478
|
+
...(opened ? { openSecret: true as const } : {}),
|
|
479
|
+
...(body
|
|
480
|
+
? {
|
|
481
|
+
file: {
|
|
482
|
+
...(body.slot !== undefined ? { slot: body.slot } : {}),
|
|
483
|
+
...(body.contentType !== undefined ? { contentType: body.contentType } : {}),
|
|
484
|
+
},
|
|
485
|
+
}
|
|
486
|
+
: {}),
|
|
487
|
+
};
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
/** The url an Open definition's other parts are checked against: it names no secret, and nothing
|
|
491
|
+
* is ever sent to it — the unbound logic sends nothing (open-effect.ts). */
|
|
492
|
+
const OPEN_PROBE_URL = "https://open.invalid";
|
|
493
|
+
|
|
494
|
+
/**
|
|
495
|
+
* An Open url's plain data (ADR-0054): every other part checked as `httpParts` checks it, the
|
|
496
|
+
* secrets its headers and auth name, and the sentinel where the url and its target go. The
|
|
497
|
+
* secrets the bound url names join when `customize()` binds it.
|
|
498
|
+
*/
|
|
499
|
+
export function openHttpParts<S extends z.ZodType | undefined>(def: HttpDefinition<S>): OpenHttpParts {
|
|
500
|
+
const { url: _url, target: _target, ...rest } = httpParts({ ...def, url: OPEN_PROBE_URL });
|
|
501
|
+
return { ...rest, url: open, target: open };
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
/**
|
|
505
|
+
* The unbound logic of an http definition with Open parts (ADR-0054) — its url, an Open secret in
|
|
506
|
+
* its auth or a header, or both — or undefined when every part is fixed. Binding builds the Effect
|
|
507
|
+
* again, whole, with `rebuild` (its own constructor) over the definition with the bound parts, so the
|
|
508
|
+
* bound one is checked like any definition; a binding that names one of two Open parts gives an
|
|
509
|
+
* Effect still Open in the other.
|
|
510
|
+
*/
|
|
511
|
+
export function openHttpOf<S extends z.ZodType | undefined>(
|
|
512
|
+
def: HttpDefinition<S>,
|
|
513
|
+
rebuild: (def: HttpDefinition<S>) => unknown,
|
|
514
|
+
): EffectLogic<unknown, unknown> | undefined {
|
|
515
|
+
const parts = def.url === open ? openHttpParts(def) : httpParts(def);
|
|
516
|
+
const opened = openPartsOf(parts);
|
|
517
|
+
if (opened.length === 0) return undefined;
|
|
518
|
+
return openEffectLogic(parts, opened, (binding) => {
|
|
519
|
+
if (binding.argv !== undefined) {
|
|
520
|
+
throw new Error(
|
|
521
|
+
`an http Effect's Open parts are its url and its secret — bind it with ${effectBindingShape(opened)}, not an argv`,
|
|
522
|
+
);
|
|
523
|
+
}
|
|
524
|
+
let bound: HttpDefinition<S> = def;
|
|
525
|
+
if (binding.url !== undefined) bound = { ...bound, url: binding.url };
|
|
526
|
+
if (binding.secret !== undefined) {
|
|
527
|
+
const headers = bindOpenSecretIn(bound.headers, binding.secret);
|
|
528
|
+
const auth = bindOpenAuth(bound.auth, binding.secret);
|
|
529
|
+
bound = { ...bound, ...(headers !== undefined ? { headers } : {}), ...(auth !== undefined ? { auth } : {}) };
|
|
530
|
+
}
|
|
531
|
+
return rebuild(bound) as EffectLogic<unknown, unknown>;
|
|
532
|
+
});
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
/** The scheme, host and port, from the url's literal head: a secret may sit only after the host,
|
|
536
|
+
* in the path, so the host is fixed by the definition and never a value. */
|
|
537
|
+
function targetOf(url: readonly TextPart[]): HttpParts["target"] {
|
|
538
|
+
const first = url[0];
|
|
539
|
+
const head = first && "text" in first ? first.text : "";
|
|
540
|
+
const m = /^(https?):\/\/([^/?#]*)(.*)$/i.exec(head);
|
|
541
|
+
const literalPathFollows = m !== null && (m[3] !== "" || url.length === 1);
|
|
542
|
+
if (!m || !literalPathFollows) {
|
|
543
|
+
throw new Error(
|
|
544
|
+
"http({ url }): the url is an http:// or https:// url whose scheme and host are literal — a secret may sit " +
|
|
545
|
+
'only in its path, after a "/": text`https://api.example.com/bot${secret("tg")}/send` (ADR-0068)',
|
|
546
|
+
);
|
|
547
|
+
}
|
|
548
|
+
// The Effector encodes a secret as a path segment, which a query or a fragment would read as a
|
|
549
|
+
// different value: a secret after a `?` or a `#` is refused here, as the Effector refuses it.
|
|
550
|
+
let before = "";
|
|
551
|
+
for (const part of url) {
|
|
552
|
+
if ("text" in part) {
|
|
553
|
+
before += part.text;
|
|
554
|
+
} else if (/[?#]/.test(before)) {
|
|
555
|
+
throw new Error(
|
|
556
|
+
`http({ url }): secret("${part.secret}") sits in the url's query or fragment — a secret may sit only in ` +
|
|
557
|
+
'its path; send it in `auth` or a header instead: headers: { "x-api-key": text`${secret("...")}` } (ADR-0068)',
|
|
558
|
+
);
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
let parsed: URL;
|
|
562
|
+
try {
|
|
563
|
+
parsed = new URL(`${m[1]}://${m[2]}/`);
|
|
564
|
+
} catch {
|
|
565
|
+
throw new Error(`http({ url }): "${m[1]}://${m[2]}" is not a host`);
|
|
566
|
+
}
|
|
567
|
+
if (parsed.username || parsed.password) throw new Error("http({ url }): the url carries userinfo — use `auth`");
|
|
568
|
+
const scheme = parsed.protocol === "https:" ? "https" : "http";
|
|
569
|
+
const port = parsed.port ? Number(parsed.port) : scheme === "https" ? 443 : 80;
|
|
570
|
+
return { scheme, host: parsed.hostname.replace(/^\[|\]$/g, "").toLowerCase(), port };
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
function inputKeysOfSchema(schema: z.ZodType | undefined): string[] {
|
|
574
|
+
const shape = (schema as { shape?: unknown } | undefined)?.shape;
|
|
575
|
+
return shape && typeof shape === "object" ? Object.keys(shape) : [];
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
/** The wire request for one run: the definition's parts, and `request`'s from the input. */
|
|
579
|
+
export function startRequestOf(parts: HttpParts, run: HttpRequestParts): HttpStartRequest {
|
|
580
|
+
const path = run.path === undefined ? undefined : [...run.path];
|
|
581
|
+
if (path !== undefined && !path.every((seg) => typeof seg === "string")) {
|
|
582
|
+
throw new Error("http: `request` returned a path that is not a list of strings");
|
|
583
|
+
}
|
|
584
|
+
if (run.json !== undefined && run.text !== undefined) throw new Error("http: `request` returned both json and text");
|
|
585
|
+
const body: WireBody | undefined =
|
|
586
|
+
run.json !== undefined ? { json: run.json } : run.text !== undefined ? { text: run.text } : undefined;
|
|
587
|
+
return {
|
|
588
|
+
kind: "http",
|
|
589
|
+
method: parts.method,
|
|
590
|
+
url: [...parts.url],
|
|
591
|
+
...(path?.length ? { path } : {}),
|
|
592
|
+
...(Object.keys(parts.headers).length ? { headers: { ...parts.headers } as Record<string, TextPart[]> } : {}),
|
|
593
|
+
...(parts.auth ? { auth: parts.auth } : {}),
|
|
594
|
+
...(body ? { body } : {}),
|
|
595
|
+
timeoutMs: parts.timeoutMs,
|
|
596
|
+
maxBody: parts.maxBody,
|
|
597
|
+
};
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
// --- The actor logic -----------------------------------------------------------------------------
|
|
601
|
+
|
|
602
|
+
/** Which Effector an Effect asks for: the Instance Effector, or the Sandbox Effector of one
|
|
603
|
+
* Workspace's Sandbox (ADR-0067). */
|
|
604
|
+
export type EffectorPlacement = "instance" | { sandbox: string };
|
|
605
|
+
|
|
606
|
+
/** Where a placement's Effector is, as the host knows it (ADR-0068): its Service and its key. */
|
|
607
|
+
export type EffectorTarget = { placement: "instance" | "sandbox"; service: string; key: Buffer | string };
|
|
608
|
+
export type EffectorPortFactory = (target: EffectorTarget) => EffectorPort;
|
|
609
|
+
|
|
610
|
+
type Settle = { type: "jr2.effect.settle"; output: unknown };
|
|
611
|
+
type Fault = { type: "jr2.effect.error"; error: unknown };
|
|
612
|
+
|
|
613
|
+
type Running = { controller: AbortController };
|
|
614
|
+
const running = new WeakMap<AnyActorRef, Running>();
|
|
615
|
+
|
|
616
|
+
/**
|
|
617
|
+
* An `http` Effect's slot logic over an injected port factory — the seam `http()` and the unit
|
|
618
|
+
* tests share, as `commandWith` is for a command.
|
|
619
|
+
*/
|
|
620
|
+
export function httpWith<
|
|
621
|
+
S extends z.ZodType | undefined = undefined,
|
|
622
|
+
const U extends HttpDefinition<S>["url"] = string,
|
|
623
|
+
const A extends HttpDefinition<S>["auth"] = undefined,
|
|
624
|
+
const H extends HttpDefinition<S>["headers"] = undefined,
|
|
625
|
+
>(
|
|
626
|
+
portFactory: EffectorPortFactory,
|
|
627
|
+
def: HttpDefinition<S> & { url: U; auth?: A; headers?: H },
|
|
628
|
+
): EffectLogic<HttpInput<S>, HttpOutput> & OpenOf<HttpOpenParts<U, A, H>>;
|
|
629
|
+
export function httpWith<S extends z.ZodType | undefined = undefined>(
|
|
630
|
+
portFactory: EffectorPortFactory,
|
|
631
|
+
def: HttpDefinition<S>,
|
|
632
|
+
): EffectLogic<HttpInput<S>, HttpOutput> {
|
|
633
|
+
const unbound = openHttpOf(def, (bound) => httpWith(portFactory, bound));
|
|
634
|
+
if (unbound) return unbound as EffectLogic<HttpInput<S>, HttpOutput>;
|
|
635
|
+
const parts = httpParts(def);
|
|
636
|
+
type I = HttpInput<S>;
|
|
637
|
+
type O = HttpOutput;
|
|
638
|
+
const logic: ActorLogic<EffectSnapshot<O, I>, EventObject, I> = {
|
|
639
|
+
getInitialSnapshot: (_scope, input) => ({
|
|
640
|
+
status: "active",
|
|
641
|
+
output: undefined,
|
|
642
|
+
error: undefined,
|
|
643
|
+
input,
|
|
644
|
+
effectId: randomUUID(),
|
|
645
|
+
}),
|
|
646
|
+
transition: (snapshot, event, { self, system }) => {
|
|
647
|
+
if (snapshot.status !== "active") return snapshot;
|
|
648
|
+
if (event.type === "jr2.effect.settle") {
|
|
649
|
+
return { ...snapshot, status: "done", output: (event as Settle).output as O, input: undefined };
|
|
650
|
+
}
|
|
651
|
+
if (event.type === "jr2.effect.error") {
|
|
652
|
+
return { ...snapshot, status: "error", error: (event as Fault).error, input: undefined };
|
|
653
|
+
}
|
|
654
|
+
if (event.type === "xstate.stop") {
|
|
655
|
+
stopped(self, system, snapshot.effectId);
|
|
656
|
+
return { ...snapshot, status: "stopped", input: undefined };
|
|
657
|
+
}
|
|
658
|
+
return snapshot;
|
|
659
|
+
},
|
|
660
|
+
start: (snapshot, { self, system }) => {
|
|
661
|
+
if (snapshot.status !== "active") return;
|
|
662
|
+
const run: Running = { controller: new AbortController() };
|
|
663
|
+
running.set(self, run);
|
|
664
|
+
void drive(self, system, snapshot, parts, def, portFactory, run).catch((error: unknown) => {
|
|
665
|
+
if (run.controller.signal.aborted) return;
|
|
666
|
+
if (self.getSnapshot().status === "active") self.send({ type: "jr2.effect.error", error } as Fault);
|
|
667
|
+
});
|
|
668
|
+
},
|
|
669
|
+
getPersistedSnapshot: (snapshot) => snapshot,
|
|
670
|
+
};
|
|
671
|
+
return Object.assign(logic, { effect: parts });
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
const slotOf = (self: AnyActorRef): string => (typeof self.src === "string" ? self.src : self.id);
|
|
675
|
+
|
|
676
|
+
async function drive<I, O>(
|
|
677
|
+
self: AnyActorRef,
|
|
678
|
+
system: AnyActorSystem,
|
|
679
|
+
snapshot: EffectSnapshot<O, I>,
|
|
680
|
+
parts: HttpParts,
|
|
681
|
+
def: HttpDefinition<z.ZodType | undefined>,
|
|
682
|
+
portFactory: EffectorPortFactory,
|
|
683
|
+
run: Running,
|
|
684
|
+
): Promise<void> {
|
|
685
|
+
const binding = boundRun(system);
|
|
686
|
+
const id = snapshot.effectId;
|
|
687
|
+
const slot = slotOf(self);
|
|
688
|
+
const signal = run.controller.signal;
|
|
689
|
+
const now = (): string => new Date().toISOString();
|
|
690
|
+
const mark = (marker: Omit<EffectMarker, "kind" | "slot" | "effect">): void =>
|
|
691
|
+
binding?.marker?.({ kind: "effect", slot, effect: id, ...marker });
|
|
692
|
+
const settle = (output: HttpOutput): void => {
|
|
693
|
+
if (signal.aborted || self.getSnapshot().status !== "active") return;
|
|
694
|
+
// The settled row first, saved at the write: a restart before the transition is saved delivers
|
|
695
|
+
// this output, and sends nothing.
|
|
696
|
+
binding?.recordEffect?.(id, { state: "settled", kind: "http", slot, output, at: now() });
|
|
697
|
+
mark({ stage: "settled", output });
|
|
698
|
+
self.send({ type: "jr2.effect.settle", output } satisfies Settle);
|
|
699
|
+
};
|
|
700
|
+
const unknown = (reason: string): Unknown => ({ outcome: "unknown", reason });
|
|
701
|
+
|
|
702
|
+
const row = binding?.effectRow?.(id);
|
|
703
|
+
if (row?.state === "settled") {
|
|
704
|
+
await Promise.resolve();
|
|
705
|
+
if (!signal.aborted && self.getSnapshot().status === "active") {
|
|
706
|
+
self.send({ type: "jr2.effect.settle", output: row.output } satisfies Settle);
|
|
707
|
+
}
|
|
708
|
+
return;
|
|
709
|
+
}
|
|
710
|
+
if (row?.state === "aborted") {
|
|
711
|
+
settle(unknown("its state exited while it ran; not sent again"));
|
|
712
|
+
return;
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
const target = binding?.effector?.("instance");
|
|
716
|
+
if (!target) {
|
|
717
|
+
throw new Error(
|
|
718
|
+
`http "${slot}" runs in the Instance Effector, and this Orchestrator knows none — \`jr2 up\` converges it ` +
|
|
719
|
+
"when a registered Machine carries an http Effect (ADR-0068)",
|
|
720
|
+
);
|
|
721
|
+
}
|
|
722
|
+
const port = portFactory(target);
|
|
723
|
+
/** Ask every replica. Undefined when an answer was forged: the Effect is settled then. */
|
|
724
|
+
const findAll = async (): Promise<{ found: EffectState | undefined } | undefined> => {
|
|
725
|
+
try {
|
|
726
|
+
return { found: await retrying(() => port.find(id, signal), signal) };
|
|
727
|
+
} catch (err) {
|
|
728
|
+
if (signal.aborted || !(err instanceof EffectorForged)) throw err;
|
|
729
|
+
settle(
|
|
730
|
+
unknown(
|
|
731
|
+
`${err.message}: the Orchestrator's key for this Effector is not the Effector's, or another process ` +
|
|
732
|
+
"answered; not sent again",
|
|
733
|
+
),
|
|
734
|
+
);
|
|
735
|
+
return undefined;
|
|
736
|
+
}
|
|
737
|
+
};
|
|
738
|
+
|
|
739
|
+
if (row?.state === "intent") {
|
|
740
|
+
// In flight at an Orchestrator restart: ask every replica, follow the one that holds it, and
|
|
741
|
+
// when none does, send it again only if the author marked it idempotent.
|
|
742
|
+
const asked = await findAll();
|
|
743
|
+
if (!asked || signal.aborted) return;
|
|
744
|
+
const { found } = asked;
|
|
745
|
+
if (found) {
|
|
746
|
+
await follow(found);
|
|
747
|
+
return;
|
|
748
|
+
}
|
|
749
|
+
if (!parts.idempotent) {
|
|
750
|
+
settle(
|
|
751
|
+
unknown(
|
|
752
|
+
"it was in flight when the Orchestrator restarted, and no Effector replica holds it; not sent again " +
|
|
753
|
+
"(it is not marked idempotent)",
|
|
754
|
+
),
|
|
755
|
+
);
|
|
756
|
+
return;
|
|
757
|
+
}
|
|
758
|
+
mark({ stage: "started", text: `${parts.method} ${hostText(parts)} (sent again: it is idempotent)` });
|
|
759
|
+
await send(requestFor());
|
|
760
|
+
return;
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
// Fresh: nothing was sent. The run's part of the request is built before the intent row, so a
|
|
764
|
+
// defect in `request` is the Machine's error and sends nothing.
|
|
765
|
+
const req = requestFor();
|
|
766
|
+
// A start the Effector would not read is refused here, before the intent row: nothing is sent.
|
|
767
|
+
const big = tooLarge(req);
|
|
768
|
+
if (big) {
|
|
769
|
+
settle(big);
|
|
770
|
+
return;
|
|
771
|
+
}
|
|
772
|
+
// Write-ahead: the intent row is saved, with the snapshot that holds this child, before the
|
|
773
|
+
// request leaves.
|
|
774
|
+
binding?.recordEffect?.(id, {
|
|
775
|
+
state: "intent",
|
|
776
|
+
kind: "http",
|
|
777
|
+
slot,
|
|
778
|
+
placement: "instance",
|
|
779
|
+
idempotent: parts.idempotent,
|
|
780
|
+
at: now(),
|
|
781
|
+
});
|
|
782
|
+
mark({ stage: "started", text: `${parts.method} ${hostText(parts)}` });
|
|
783
|
+
await send(req);
|
|
784
|
+
|
|
785
|
+
function requestFor(): StartRequest {
|
|
786
|
+
let input: unknown = snapshot.input;
|
|
787
|
+
if (def.input !== undefined) {
|
|
788
|
+
const parsed = def.input.safeParse(input);
|
|
789
|
+
if (!parsed.success) {
|
|
790
|
+
throw new Error(
|
|
791
|
+
`http "${slot}": its input does not match the definition's input schema — ` +
|
|
792
|
+
parsed.error.issues.map((i) => `${i.path.join(".") || "(root)"}: ${i.message}`).join("; "),
|
|
793
|
+
);
|
|
794
|
+
}
|
|
795
|
+
input = parsed.data;
|
|
796
|
+
}
|
|
797
|
+
const runParts = def.request ? (def.request as (i: unknown) => HttpRequestParts)(input) : {};
|
|
798
|
+
return startRequestOf(parts, runParts ?? {});
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
/** Send the start, and follow it. A start that never reached a replica is sent again; one whose
|
|
802
|
+
* answer did not come is looked for on every replica, never sent blind. */
|
|
803
|
+
async function send(req: StartRequest): Promise<void> {
|
|
804
|
+
const big = tooLarge(req);
|
|
805
|
+
if (big) {
|
|
806
|
+
settle(big);
|
|
807
|
+
return;
|
|
808
|
+
}
|
|
809
|
+
for (let wait = 250; ; wait = Math.min(wait * 2, RETRY_MAX_MS)) {
|
|
810
|
+
let state: EffectState;
|
|
811
|
+
try {
|
|
812
|
+
state = await port.start(id, req, signal);
|
|
813
|
+
} catch (err) {
|
|
814
|
+
if (signal.aborted) return;
|
|
815
|
+
if (err instanceof EffectorRefused || err instanceof EffectorMalformed) throw err;
|
|
816
|
+
if (!(err instanceof EffectorNotSent)) {
|
|
817
|
+
// It may have started — a lost answer, or a forged one: ask every replica first.
|
|
818
|
+
const asked = await findAll();
|
|
819
|
+
if (!asked || signal.aborted) return;
|
|
820
|
+
const { found } = asked;
|
|
821
|
+
if (found) {
|
|
822
|
+
await follow(found);
|
|
823
|
+
return;
|
|
824
|
+
}
|
|
825
|
+
if (!parts.idempotent) {
|
|
826
|
+
settle(unknown("its start may have reached the Effector, and no replica holds it now; not sent again"));
|
|
827
|
+
return;
|
|
828
|
+
}
|
|
829
|
+
}
|
|
830
|
+
await sleep(wait, signal);
|
|
831
|
+
if (signal.aborted) return;
|
|
832
|
+
continue;
|
|
833
|
+
}
|
|
834
|
+
await follow(state);
|
|
835
|
+
return;
|
|
836
|
+
}
|
|
837
|
+
}
|
|
838
|
+
|
|
839
|
+
/** Poll the replica that holds it to its outcome. A replica that stops answering for it — gone,
|
|
840
|
+
* or restarted — sends the Effect to every replica again; none holding it is the restore rule. */
|
|
841
|
+
async function follow(first: EffectState): Promise<void> {
|
|
842
|
+
let state = first;
|
|
843
|
+
let replica: Replica = first.replica;
|
|
844
|
+
for (;;) {
|
|
845
|
+
if (state.state === "settled" && state.output) {
|
|
846
|
+
settle(state.output as HttpOutput);
|
|
847
|
+
return;
|
|
848
|
+
}
|
|
849
|
+
if (state.state === "unknown") {
|
|
850
|
+
const asked = await findAll();
|
|
851
|
+
if (!asked || signal.aborted) return;
|
|
852
|
+
const { found } = asked;
|
|
853
|
+
if (!found) {
|
|
854
|
+
if (parts.idempotent) {
|
|
855
|
+
await send(requestFor());
|
|
856
|
+
return;
|
|
857
|
+
}
|
|
858
|
+
settle(unknown("the Effector replica that held it no longer knows it (it restarted); not sent again"));
|
|
859
|
+
return;
|
|
860
|
+
}
|
|
861
|
+
state = found;
|
|
862
|
+
replica = found.replica;
|
|
863
|
+
continue;
|
|
864
|
+
}
|
|
865
|
+
try {
|
|
866
|
+
state = await port.poll(replica, id, signal);
|
|
867
|
+
} catch (err) {
|
|
868
|
+
if (signal.aborted) return;
|
|
869
|
+
if (err instanceof EffectorRefused || err instanceof EffectorMalformed) throw err;
|
|
870
|
+
// The replica did not answer, or not with the Effector's signature (its address may be
|
|
871
|
+
// stale): the next pass asks every replica.
|
|
872
|
+
state = { effect: id, replica, state: "unknown" };
|
|
873
|
+
}
|
|
874
|
+
if (signal.aborted) return;
|
|
875
|
+
}
|
|
876
|
+
}
|
|
877
|
+
}
|
|
878
|
+
|
|
879
|
+
/**
|
|
880
|
+
* The state that invoked this Effect exited (D1) — or the host is parking the run. Parking keeps
|
|
881
|
+
* the call for the restore to find. An exit stops the polling and records the abort, "outcome
|
|
882
|
+
* unknown": an http Effect has no stop route, so the Effector finishes the call and drops an outcome nobody
|
|
883
|
+
* reads. The Machine never hears it.
|
|
884
|
+
*/
|
|
885
|
+
function stopped(self: AnyActorRef, system: AnyActorSystem, effectId: string): void {
|
|
886
|
+
const run = running.get(self);
|
|
887
|
+
running.delete(self);
|
|
888
|
+
run?.controller.abort();
|
|
889
|
+
const binding = boundRun(system);
|
|
890
|
+
if (binding?.hostStopping) return;
|
|
891
|
+
const row = binding?.effectRow?.(effectId);
|
|
892
|
+
if (row?.state !== "intent" || row.kind !== "http") return;
|
|
893
|
+
const aborted: EffectRow = { state: "aborted", kind: "http", slot: row.slot, at: new Date().toISOString() };
|
|
894
|
+
binding?.recordEffect?.(effectId, aborted, "with-transition");
|
|
895
|
+
binding?.marker?.({
|
|
896
|
+
kind: "effect",
|
|
897
|
+
slot: row.slot,
|
|
898
|
+
effect: effectId,
|
|
899
|
+
stage: "aborted",
|
|
900
|
+
text: "its state exited while it ran: no longer followed; the Effector may still send it; outcome unknown",
|
|
901
|
+
});
|
|
902
|
+
}
|
|
903
|
+
|
|
904
|
+
/** A start over the bytes the Effector reads is output `refused` (ADR-0068): the Effector could not
|
|
905
|
+
* verify it, and a body built from context or a pick's param can reach the bound. */
|
|
906
|
+
function tooLarge(req: StartRequest): HttpOutput | undefined {
|
|
907
|
+
const bytes = Buffer.byteLength(JSON.stringify(req), "utf8");
|
|
908
|
+
if (bytes <= MAX_START_BYTES) return undefined;
|
|
909
|
+
return {
|
|
910
|
+
outcome: "refused",
|
|
911
|
+
reason: `the request is ${bytes} bytes, over the ${MAX_START_BYTES} bytes an Effector reads; nothing was sent`,
|
|
912
|
+
};
|
|
913
|
+
}
|
|
914
|
+
|
|
915
|
+
function hostText(parts: HttpParts): string {
|
|
916
|
+
const { scheme, host, port } = parts.target;
|
|
917
|
+
const standard = (scheme === "https" && port === 443) || (scheme === "http" && port === 80);
|
|
918
|
+
return `${scheme}://${host}${standard ? "" : `:${port}`}`;
|
|
919
|
+
}
|
|
920
|
+
|
|
921
|
+
async function retrying<T>(call: () => Promise<T>, signal: AbortSignal): Promise<T> {
|
|
922
|
+
for (let wait = 250; ; wait = Math.min(wait * 2, RETRY_MAX_MS)) {
|
|
923
|
+
try {
|
|
924
|
+
return await call();
|
|
925
|
+
} catch (err) {
|
|
926
|
+
// A refusal and a malformed answer are defects; a forged one would be forged again.
|
|
927
|
+
if (
|
|
928
|
+
signal.aborted ||
|
|
929
|
+
err instanceof EffectorRefused ||
|
|
930
|
+
err instanceof EffectorMalformed ||
|
|
931
|
+
err instanceof EffectorForged
|
|
932
|
+
) {
|
|
933
|
+
throw err;
|
|
934
|
+
}
|
|
935
|
+
await sleep(wait, signal);
|
|
936
|
+
if (signal.aborted) throw err;
|
|
937
|
+
}
|
|
938
|
+
}
|
|
939
|
+
}
|
|
940
|
+
|
|
941
|
+
function sleep(ms: number, signal: AbortSignal): Promise<void> {
|
|
942
|
+
return new Promise((resolve) => {
|
|
943
|
+
const timer = setTimeout(resolve, ms / 2 + Math.random() * (ms / 2));
|
|
944
|
+
signal.addEventListener("abort", () => {
|
|
945
|
+
clearTimeout(timer);
|
|
946
|
+
resolve();
|
|
947
|
+
});
|
|
948
|
+
});
|
|
949
|
+
}
|
|
950
|
+
|
|
951
|
+
function parseDuration(d: Duration): number {
|
|
952
|
+
const n = Number.parseFloat(d);
|
|
953
|
+
if (!(n > 0)) throw new Error(`http: timeout "${d}" is not a positive duration (like "30s", "2m")`);
|
|
954
|
+
return d.endsWith("h") ? n * 3_600_000 : d.endsWith("m") ? n * 60_000 : n * 1000;
|
|
955
|
+
}
|
|
956
|
+
|
|
957
|
+
/**
|
|
958
|
+
* THE authoring surface for an `http` Effect (ADR-0068): `actors: { comment: http({ method, url,
|
|
959
|
+
* auth: bearer(secret("jira")), input, request }) }`, invoked as `src: "comment"`. It runs in the
|
|
960
|
+
* Instance Effector, which `jr2 up` converges because this definition is placed there — or, with
|
|
961
|
+
* `body: file(...)`, in the Sandbox Effector of the Workspace whose body invokes it (ADR-0067). The
|
|
962
|
+
* Orchestrator signs the call and holds no secret. Every outcome is output:
|
|
963
|
+
* `response | unreachable | refused | unknown`.
|
|
964
|
+
*/
|
|
965
|
+
export function http<
|
|
966
|
+
S extends z.ZodType | undefined = undefined,
|
|
967
|
+
const U extends HttpDefinition<S>["url"] = string,
|
|
968
|
+
const A extends HttpDefinition<S>["auth"] = undefined,
|
|
969
|
+
const H extends HttpDefinition<S>["headers"] = undefined,
|
|
970
|
+
>(
|
|
971
|
+
def: HttpDefinition<S> & { url: U; auth?: A; headers?: H },
|
|
972
|
+
): EffectLogic<HttpInput<S>, HttpOutput> & OpenOf<HttpOpenParts<U, A, H>>;
|
|
973
|
+
export function http<S extends z.ZodType | undefined = undefined>(
|
|
974
|
+
def: HttpDefinition<S>,
|
|
975
|
+
): EffectLogic<HttpInput<S>, HttpOutput> {
|
|
976
|
+
if (def.body !== undefined) return httpFileWith(sandboxEffectorPort, def);
|
|
977
|
+
return httpWith(
|
|
978
|
+
(target) => createEffectorClient({ key: target.key, transport: nodeEffectorTransport(target.service) }),
|
|
979
|
+
def,
|
|
980
|
+
);
|
|
981
|
+
}
|