@gjermundgaraba/clankerbox-sdk 0.15.6 → 0.18.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,662 +0,0 @@
1
- import { t as __exportAll } from "./rolldown-runtime-D7D4PA-g.mjs";
2
- import * as Action from "@gjermundgaraba/effect-actions/Action";
3
- import * as ActionHttp from "@gjermundgaraba/effect-actions/ActionHttp";
4
- import { Context, Duration, Effect, Fiber, Match, Predicate, Result, Schema, SchemaIssue } from "effect";
5
- import { HttpClient, HttpClientRequest } from "effect/http";
6
- //#region src/errors.ts
7
- /**
8
- * The seven errors every clankerbox call can fail with. `retryable` is derived from the
9
- * tag, so callers never keep their own table of which failures to repeat.
10
- */
11
- /** The request is malformed: a bad ID or name, an undeclared field, a value out of range. */
12
- var Invalid = class extends Schema.TaggedError()("Invalid", { message: Schema.String }, { httpApiStatus: 400 }) {
13
- get retryable() {
14
- return false;
15
- }
16
- };
17
- /** The machine or checkpoint doesn't exist. A `delete` that meets it is done. */
18
- var NotFound = class extends Schema.TaggedError()("NotFound", { message: Schema.String }, { httpApiStatus: 404 }) {
19
- get retryable() {
20
- return false;
21
- }
22
- };
23
- const ConflictKind = Schema.Literals(["exists", "busy"]);
24
- /** The name is taken (`exists`), or another action holds the resource (`busy`). */
25
- var Conflict = class extends Schema.TaggedError()("Conflict", {
26
- message: Schema.String,
27
- kind: ConflictKind
28
- }, { httpApiStatus: 409 }) {
29
- get retryable() {
30
- return this.kind === "busy";
31
- }
32
- };
33
- /** The resource's state doesn't allow the action, or no host offers the requested base. */
34
- var Precondition = class extends Schema.TaggedError()("Precondition", { message: Schema.String }, { httpApiStatus: 412 }) {
35
- get retryable() {
36
- return false;
37
- }
38
- };
39
- /** The host has no room now; room can free up, so a later call may succeed. */
40
- var Capacity = class extends Schema.TaggedError()("Capacity", { message: Schema.String }, { httpApiStatus: 503 }) {
41
- get retryable() {
42
- return true;
43
- }
44
- };
45
- /**
46
- * The client couldn't reach the host, or lost its reply. Only the client library produces
47
- * it. `readOnly` is that of the action that met it: a mutation's request may have reached
48
- * the host and run, so it is never retryable.
49
- */
50
- var Unavailable = class extends Schema.TaggedError()("Unavailable", {
51
- message: Schema.String,
52
- readOnly: Schema.Boolean
53
- }) {
54
- get retryable() {
55
- return this.readOnly;
56
- }
57
- };
58
- var Internal = class extends Schema.TaggedError()("Internal", { message: Schema.String }, { httpApiStatus: 500 }) {
59
- get retryable() {
60
- return false;
61
- }
62
- };
63
- /** What a host answers with; every action declares these. */
64
- const hostErrors = [
65
- Invalid,
66
- NotFound,
67
- Conflict,
68
- Precondition,
69
- Capacity,
70
- Internal
71
- ];
72
- /** The tag a host records on a failed action: always one of the errors it answers with. */
73
- const HostErrorTag = Schema.Literals(hostErrors.map((error) => error.fields._tag.schema.literal));
74
- /** The tag of any error a call through the client library can fail with. */
75
- const ErrorTag = Schema.Literals([...HostErrorTag.literals, Unavailable.fields._tag.schema.literal]);
76
- //#endregion
77
- //#region src/ids.ts
78
- /**
79
- * Machine and checkpoint IDs, `<host>_<name>`: the one place that parses and formats them.
80
- * Neither part contains `_`, so an ID has exactly one. The whole ID matches
81
- * `^[A-Za-z0-9_-]{1,62}$` because it is written to `/var/lib/clankerbox/machine-id`, and
82
- * clankercreds accepts only that pattern.
83
- */
84
- const separator = "_";
85
- const maxIdLength = 62;
86
- const idPattern = /^[A-Za-z0-9_-]{1,62}$/u;
87
- /**
88
- * boat's named snapshots are `cbx-<host>-<inst>`, and boat allows `^[a-z0-9][a-z0-9-]{0,62}$`:
89
- * a lowercase host ID of at most 32 characters keeps the name within 63.
90
- */
91
- const hostIdPattern = /^[a-z][a-z0-9-]{0,31}$/u;
92
- const hostIdRule = "a host ID is 1 to 32 lowercase letters, digits and '-', starting with a letter";
93
- /**
94
- * smolvm names a machine `<name>-<inst>` and refuses consecutive hyphens, so a name has no
95
- * `--` and doesn't end in `-`.
96
- */
97
- const namePattern = /^[A-Za-z][A-Za-z0-9]*(?:-[A-Za-z0-9]+)*$/u;
98
- const nameRule = "a name starts with a letter, then letters, digits and '-', with no '--' and no '-' at the end";
99
- /** A host's part of every ID it holds. */
100
- const HostId = Schema.String.check(Schema.isPattern(hostIdPattern, { message: hostIdRule }));
101
- /** A machine's or checkpoint's name, unique per host and per resource type. */
102
- const Name = Schema.String.check(Schema.isPattern(namePattern, { message: nameRule }));
103
- const describeId = (id) => {
104
- if (!idPattern.test(id)) return `ID ${JSON.stringify(id)} must be 1 to ${maxIdLength} letters, digits, '_' or '-'`;
105
- const at = id.indexOf(separator);
106
- if (at <= 0 || at === id.length - 1) return `ID ${JSON.stringify(id)} must be <host>_<name>`;
107
- if (!hostIdPattern.test(id.slice(0, at))) return `ID ${JSON.stringify(id)} has an invalid host: ${hostIdRule}`;
108
- if (!namePattern.test(id.slice(at + 1))) return `ID ${JSON.stringify(id)} has an invalid name: ${nameRule}`;
109
- };
110
- /** A full `<host>_<name>` ID. */
111
- const Id = Schema.String.check(Schema.makeFilter((id) => describeId(id) ?? true));
112
- /** Splits an ID at its `_` into host and name. */
113
- const parseId = (id) => {
114
- const problem = describeId(id);
115
- if (problem !== void 0) return Effect.fail(new Invalid({ message: problem }));
116
- const at = id.indexOf(separator);
117
- return Effect.succeed({
118
- host: id.slice(0, at),
119
- name: id.slice(at + 1)
120
- });
121
- };
122
- /** Checks a name on its own, before it is joined to a host. */
123
- const parseName = (name) => namePattern.test(name) ? Effect.succeed(name) : Effect.fail(new Invalid({ message: `name ${JSON.stringify(name)}: ${nameRule}` }));
124
- /** Joins a host ID and a name into the resource's ID, checking both parts and the whole. */
125
- const formatId = (host, name) => {
126
- if (!hostIdPattern.test(host)) return Effect.fail(new Invalid({ message: `host ID ${JSON.stringify(host)}: ${hostIdRule}` }));
127
- const id = `${host}${separator}${name}`;
128
- return parseName(name).pipe(Effect.andThen(parseId(id)), Effect.as(id));
129
- };
130
- //#endregion
131
- //#region package.json
132
- var version$1 = "0.15.6";
133
- //#endregion
134
- //#region src/resources.ts
135
- /** The resources a host reports: machines, checkpoints and the host itself. */
136
- const Runtime = Schema.Literals([
137
- "smolvm",
138
- "tart",
139
- "boat"
140
- ]);
141
- const Size = Schema.Int.check(Schema.isGreaterThan(0));
142
- /** The mutations that record themselves on a resource's `action`. */
143
- const ActionName = Schema.Literals([
144
- "create",
145
- "start",
146
- "stop",
147
- "delete",
148
- "fork",
149
- "restore",
150
- "capture"
151
- ]);
152
- /**
153
- * A resource's last action: `running` while it holds the row, `failed` with its error
154
- * after a native failure, `done` after a success. The next action replaces it.
155
- */
156
- const ActionRecord = Schema.Struct({
157
- name: ActionName,
158
- status: Schema.Literals([
159
- "running",
160
- "failed",
161
- "done"
162
- ]),
163
- error: Schema.optionalKey(Schema.Struct({
164
- tag: HostErrorTag,
165
- message: Schema.String
166
- }))
167
- });
168
- /** Where a machine's guest port 22 is reached. */
169
- const SshEndpoint = Schema.Struct({
170
- host: Schema.String,
171
- port: Schema.Int.check(Schema.isBetween({
172
- minimum: 1,
173
- maximum: 65535
174
- }))
175
- });
176
- /** Where a running machine's SSH is reached, and the guest user its runtime's logins are for. */
177
- const SshLogin = Schema.Struct({
178
- user: Schema.String,
179
- ...SshEndpoint.fields
180
- });
181
- const Machine = Schema.Struct({
182
- id: Id,
183
- runtime: Runtime,
184
- createdAt: Schema.DateTimeUtcFromString,
185
- base: Schema.String,
186
- /** The label the client passed at create, normally its profile's name. */
187
- profile: Schema.optionalKey(Schema.String),
188
- cpu: Size,
189
- ramMib: Size,
190
- diskGib: Size,
191
- /**
192
- * Read from the runtime, never stored: `missing` when the runtime doesn't have the machine,
193
- * `unknown` when it couldn't be read.
194
- */
195
- state: Schema.Literals([
196
- "running",
197
- "stopped",
198
- "missing",
199
- "unknown"
200
- ]),
201
- /**
202
- * Whether its create, fork or restore has made it. One that isn't made, because that action
203
- * still runs or failed, can only be read, stopped or deleted.
204
- */
205
- made: Schema.Boolean,
206
- action: ActionRecord,
207
- /** Only on a running machine whose runtime gives it an endpoint. */
208
- ssh: Schema.optionalKey(SshLogin),
209
- /** The guest's SSH host public key, as `<type> <base64>`, once preparation has recorded it. */
210
- hostKey: Schema.optionalKey(Schema.String)
211
- }).annotate({ identifier: "Machine" });
212
- const Checkpoint = Schema.Struct({
213
- id: Id,
214
- createdAt: Schema.DateTimeUtcFromString,
215
- /** The ID of the machine it was captured from. */
216
- machine: Id,
217
- /** What it holds, by runtime: `ram` (RAM and disks) on smolvm, `disk` on Tart and boat. */
218
- kind: Schema.Literals(["ram", "disk"]),
219
- base: Schema.String,
220
- profile: Schema.optionalKey(Schema.String),
221
- cpu: Size,
222
- ramMib: Size,
223
- diskGib: Size,
224
- action: ActionRecord
225
- }).annotate({ identifier: "Checkpoint" });
226
- /** The SDK's version, which is also the version of the clankerbox binaries it ships with. */
227
- const version = version$1;
228
- /** A version's major.minor: releases that share it speak the same API. */
229
- const release = (of) => of.split(".").slice(0, 2).join(".");
230
- const Host = Schema.Struct({
231
- id: HostId,
232
- runtime: Runtime,
233
- /** The clankerbox version the host runs. */
234
- version: Schema.String,
235
- /** The runtime's version; for boat, its API version. */
236
- runtimeVersion: Schema.String,
237
- bases: Schema.Array(Schema.String)
238
- }).annotate({ identifier: "Host" });
239
- /**
240
- * One limit a host's `Capacity` refusals come from, and how much of it is in use, counted as
241
- * the refusal counts it, so `used` needn't match the host's own machines. Read from the runtime,
242
- * never stored.
243
- */
244
- const Limit = Schema.Struct({
245
- host: HostId,
246
- /**
247
- * `ramMib`: smolvm's RAM budget. `runningVms`: Tart's, Apple's two VMs per Mac.
248
- * `activeSandboxes` and the `starts` of each rolling window: the boat account's.
249
- */
250
- resource: Schema.Literals([
251
- "ramMib",
252
- "runningVms",
253
- "activeSandboxes",
254
- "startsPerMinute",
255
- "startsPerHour",
256
- "startsPerDay"
257
- ]),
258
- limit: Schema.Int,
259
- used: Schema.Int
260
- }).annotate({ identifier: "Limit" });
261
- /**
262
- * A setup script and how long it may run, the host's default when it doesn't say. Nothing logs
263
- * the script: it can carry secrets.
264
- */
265
- const Setup = Schema.Struct({
266
- script: Schema.String,
267
- timeoutSeconds: Schema.optionalKey(Size)
268
- });
269
- const specFields = {
270
- base: Schema.String,
271
- cpu: Size,
272
- ramMib: Size,
273
- diskGib: Size,
274
- setup: Schema.optionalKey(Setup),
275
- profile: Schema.optionalKey(Schema.String)
276
- };
277
- /**
278
- * What a new machine is made from: a base, its sizes, an optional setup script with its
279
- * timeout, and an optional `profile` label. A profile file fills these in; the host never
280
- * sees the profile itself.
281
- */
282
- const MachineSpec = Schema.Struct(specFields);
283
- /**
284
- * `machine.create`'s input: the spec and the new machine's full ID. The host checks that the ID
285
- * names it, so a create never lands under another host's ID.
286
- */
287
- const CreateRequest = Schema.Struct({
288
- id: Id,
289
- ...specFields
290
- });
291
- /**
292
- * A profile file: a client-side file that fills in a create request. `setup.path` is a script
293
- * file or a recipe directory, relative to the profile file; without a `timeoutSeconds` the host's
294
- * default applies. `host` places the create on that host instead of by base.
295
- */
296
- const Profile = Schema.Struct({
297
- base: specFields.base,
298
- cpu: specFields.cpu,
299
- ramMib: specFields.ramMib,
300
- diskGib: specFields.diskGib,
301
- setup: Schema.optionalKey(Schema.Struct({
302
- path: Schema.String,
303
- timeoutSeconds: Schema.optionalKey(Size)
304
- })),
305
- host: Schema.optionalKey(HostId)
306
- });
307
- //#endregion
308
- //#region src/api.ts
309
- /**
310
- * The host API: one effect-actions binding, each action a JSON POST route at `/api/<action>`.
311
- * A mutation replies when its action has finished. Every action is public: a host listens
312
- * only on the tailnet or loopback.
313
- */
314
- /**
315
- * How long a client waits for a read's reply from one host, unless it sets its own. A host
316
- * bounds its own reads of runtime state under it, so a list answers in time.
317
- */
318
- const readTimeout = Duration.seconds(10);
319
- /**
320
- * The header every client request carries the SDK's `version` in. A host refuses a request
321
- * whose release (major.minor) differs from its own, or that carries none, before it runs
322
- * anything.
323
- */
324
- const versionHeader = "clankerbox-version";
325
- const ById = Schema.Struct({ id: Id });
326
- /** Input of the actions that make a new resource from a machine: its ID and the new name. */
327
- const FromMachine = Schema.Struct({
328
- machine: Id,
329
- name: Name
330
- });
331
- /**
332
- * What every action shares, by whether it reads or changes the host. Every action declares
333
- * `hostErrors`, so a client decodes the `Invalid` the host's version check answers with.
334
- */
335
- const read = {
336
- caller: Action.Anyone,
337
- error: hostErrors,
338
- readOnly: true
339
- };
340
- const write = {
341
- caller: Action.Anyone,
342
- error: hostErrors,
343
- readOnly: false
344
- };
345
- const Http = ActionHttp.make([
346
- Action.make("listMachines", {
347
- ...read,
348
- description: "List the host's machines.",
349
- success: Schema.Array(Machine)
350
- }),
351
- Action.make("getMachine", {
352
- ...read,
353
- description: "Read one machine.",
354
- input: ById,
355
- success: Machine
356
- }),
357
- Action.make("createMachine", {
358
- ...write,
359
- description: "Create a machine from a base, run its setup once, and prepare it.",
360
- input: CreateRequest,
361
- success: Machine
362
- }),
363
- Action.make("startMachine", {
364
- ...write,
365
- description: "Start a machine. On a running machine, run preparation again.",
366
- input: ById,
367
- success: Machine
368
- }),
369
- Action.make("stopMachine", {
370
- ...write,
371
- description: "Stop a machine. A stopped machine stays stopped, and the stop is still its last action.",
372
- input: ById,
373
- success: Machine
374
- }),
375
- Action.make("deleteMachine", {
376
- ...write,
377
- description: "Delete a machine and everything native it owns.",
378
- input: ById
379
- }),
380
- Action.make("forkMachine", {
381
- ...write,
382
- description: "Copy a machine to a new name on the same host.",
383
- input: FromMachine,
384
- success: Machine
385
- }),
386
- Action.make("restoreMachine", {
387
- ...write,
388
- description: "Create a machine on the checkpoint's host from that checkpoint.",
389
- input: Schema.Struct({
390
- checkpoint: Id,
391
- name: Name
392
- }),
393
- success: Machine
394
- }),
395
- Action.make("listCheckpoints", {
396
- ...read,
397
- description: "List the host's checkpoints.",
398
- success: Schema.Array(Checkpoint)
399
- }),
400
- Action.make("getCheckpoint", {
401
- ...read,
402
- description: "Read one checkpoint.",
403
- input: ById,
404
- success: Checkpoint
405
- }),
406
- Action.make("captureCheckpoint", {
407
- ...write,
408
- description: "Capture a checkpoint of a machine. Its kind follows the runtime: ram on smolvm, which captures only a running machine, and disk on Tart and boat.",
409
- input: FromMachine,
410
- success: Checkpoint
411
- }),
412
- Action.make("deleteCheckpoint", {
413
- ...write,
414
- description: "Delete a checkpoint.",
415
- input: ById
416
- }),
417
- Action.make("getHost", {
418
- ...read,
419
- description: "Read the host: its runtime, versions and bases.",
420
- success: Host
421
- }),
422
- Action.make("getCapacity", {
423
- ...read,
424
- description: "Read the host's limits and how much of each is in use. It fails when the runtime can't be read.",
425
- success: Schema.Array(Limit)
426
- })
427
- ], { prefix: "/api" });
428
- //#endregion
429
- //#region src/text.ts
430
- /** How sizes, decode failures and file failures read in a message or a table cell. */
431
- const mibPerGib = 1024;
432
- /**
433
- * A RAM size, exactly, as a size flag takes one: GiB when it is whole or half GiB, `32G` or
434
- * `1.5G`, and MiB when it isn't, `512M` or `1500M`.
435
- */
436
- const formatMib = (mib) => mib >= mibPerGib && mib % (mibPerGib / 2) === 0 ? `${mib / mibPerGib}G` : `${mib}M`;
437
- /**
438
- * Where a decode failed, as a JSON path: `hosts[0].id`. `names` gives a top-level field the
439
- * name its caller knows it by.
440
- */
441
- const jsonPath = (path, names) => path.map((key, index) => {
442
- if (Number.isInteger(key)) return `[${String(key)}]`;
443
- return index === 0 ? names?.get(key) ?? String(key) : `.${String(key)}`;
444
- }).join("");
445
- const flatten = SchemaIssue.makeFormatterStandardSchemaV1({ leafHook: (issue) => Match.value(issue).pipe(Match.tag("MissingKey", () => "missing"), Match.tag("UnexpectedKey", () => "not a known field"), Match.orElse(SchemaIssue.defaultLeafHook)) });
446
- /**
447
- * Everything a decode refused, on one line, each under its JSON path: `cpu: missing; ram: not a
448
- * known field`. `names` renames top-level fields, as when a flag set each: `--cpu: missing`.
449
- */
450
- const describeIssues = (error, names) => flatten(error.issue).issues.map(({ path = [], message }) => {
451
- const keys = path.map((segment) => Predicate.isPropertyKey(segment) ? segment : segment.key);
452
- return keys.length === 0 ? message : `${jsonPath(keys, names)}: ${message}`;
453
- }).join("; ");
454
- /**
455
- * Why a file couldn't be read, without the platform call that met it. Node describes a failure
456
- * only in its cause, so what has no wording here reads as the system's own.
457
- */
458
- const describeFileError = ({ reason }) => {
459
- if (Predicate.isTagged(reason, "NotFound")) return "no such file or directory";
460
- if (Predicate.isTagged(reason, "PermissionDenied")) return "permission denied";
461
- return reason.description ?? (reason.cause instanceof Error ? reason.cause.message : reason._tag);
462
- };
463
- //#endregion
464
- //#region src/client.ts
465
- /**
466
- * The client library: a host list in placement order, routing by ID, list fan-out and
467
- * create placement. Nothing here retries: a mutation whose reply is lost, or that outlasts
468
- * the client's mutation timeout, is `Unavailable`, and the caller reads the resource to see
469
- * what happened.
470
- */
471
- var client_exports = /* @__PURE__ */ __exportAll({
472
- Client: () => Client,
473
- HostEntry: () => HostEntry,
474
- make: () => make
475
- });
476
- /** One host a client talks to. `id` is the host part of every ID it holds. */
477
- const HostEntry = Schema.Struct({
478
- id: HostId,
479
- url: Schema.String.check(Schema.makeFilter((url) => URL.canParse(url) || "a URL, such as http://linux:8484"))
480
- });
481
- var Client = class extends Context.Service()("@gjermundgaraba/clankerbox-sdk/Client") {};
482
- /**
483
- * A client for one host. Every request carries the SDK's version, which the host checks
484
- * against its own. Input that doesn't encode fails as `InvalidInput` before anything is
485
- * sent, so a `SchemaError` that reaches `settle` is a reply that didn't decode.
486
- */
487
- const makeHostApi = (entry) => ActionHttp.client(Http, {
488
- baseUrl: entry.url,
489
- transformClient: HttpClient.mapRequest(HttpClientRequest.setHeader(versionHeader, version))
490
- });
491
- /** Every action's contract, by name: a call's timeout and retry follow its `readOnly`. */
492
- const actions = Action.byName(Http.actions);
493
- const causeDetail = (error) => error.cause instanceof Error ? error.cause.message : error.message;
494
- /**
495
- * No reply came: the connection failed or dropped, or the timeout passed. A mutation's
496
- * request may or may not have reached its host, so it may have run.
497
- */
498
- const unanswered = (context, detail) => context.readOnly ? new Unavailable({
499
- message: `host ${context.host.id} (${context.host.url}) didn't answer ${context.action}: ${detail}`,
500
- readOnly: context.readOnly
501
- }) : new Unavailable({
502
- message: `no reply from host ${context.host.id} to ${context.action} (${detail}); the action may have run: read ${context.target ?? "the resource"} to see`,
503
- readOnly: context.readOnly
504
- });
505
- /** Bounds the wait for the reply; the clock starts when the request is sent. */
506
- const bounded = (call, context) => {
507
- const { timeout } = context;
508
- return timeout === void 0 ? call : Effect.timeoutOrElse(call, {
509
- duration: timeout,
510
- orElse: () => Effect.fail(unanswered(context, `timed out after ${Duration.format(timeout)}`))
511
- });
512
- };
513
- /** The host answered with something the call doesn't expect. */
514
- const unexpected = (context, error) => new Internal({ message: `host ${context.host.id} answered ${context.action} unexpectedly: ${error.message}` });
515
- const settle = (call, context) => bounded(call, context).pipe(Effect.catchTags({
516
- HttpClientError: (error) => Effect.fail(Match.value(error.reason).pipe(Match.tag("TransportError", () => unanswered(context, causeDetail(error))), Match.when({
517
- _tag: "DecodeError",
518
- cause: (cause) => cause !== void 0 && !(cause instanceof SyntaxError)
519
- }, () => unanswered(context, causeDetail(error))), Match.orElse(() => unexpected(context, error)))),
520
- InvalidInput: (error) => Effect.fail(new Invalid({ message: `${context.action}: the request doesn't match the action's input: ${error.message}` })),
521
- SchemaError: (error) => Effect.fail(new Internal({ message: `host ${context.host.id}'s reply didn't decode: ${error.message}` })),
522
- Unauthenticated: (error) => Effect.fail(unexpected(context, error)),
523
- Forbidden: (error) => Effect.fail(unexpected(context, error))
524
- }));
525
- /** A host this client can use: it calls itself by its entry's ID. */
526
- const checkHost = (entry, host) => host.id === entry.id ? Effect.succeed(host) : Effect.fail(new Invalid({ message: `host ${entry.id} (${entry.url}) calls itself ${host.id}; its entry in the host list must use that ID` }));
527
- /** Every entry is checked here, so a call never meets a malformed URL. */
528
- const decodeHosts = Schema.decodeUnknownEffect(Schema.Array(HostEntry));
529
- /**
530
- * Builds a client over `hosts`, in placement order. Making it sends nothing. An empty list
531
- * is Invalid, and so is a malformed entry or a host ID that appears twice: IDs route by it.
532
- */
533
- const make = (hosts, options) => Effect.gen(function* () {
534
- if (hosts.length === 0) return yield* new Invalid({ message: "the host list is empty: a client needs a host" });
535
- yield* decodeHosts(hosts, { errors: "all" }).pipe(Effect.mapError((error) => new Invalid({ message: `host list: ${describeIssues(error)}` })));
536
- const twice = hosts.find(({ id }, index) => hosts.findIndex((other) => other.id === id) < index);
537
- if (twice !== void 0) return yield* new Invalid({ message: `host ${twice.id} appears twice in the host list` });
538
- const routed = yield* Effect.forEach(hosts, (entry) => Effect.map(makeHostApi(entry), (api) => ({
539
- entry,
540
- api
541
- })));
542
- const routes = new Map(routed.map((route) => [route.entry.id, route]));
543
- const route = (host) => {
544
- const found = routes.get(host);
545
- return found === void 0 ? Effect.fail(new Invalid({ message: `host ${host} isn't in the host list` })) : Effect.succeed(found);
546
- };
547
- /**
548
- * Sends one call to one host, bounded by the read or the mutation timeout. `target` is the
549
- * ID to read when a mutation's reply is lost.
550
- */
551
- const ask = ({ entry, api }, { readOnly }, action, call, target) => settle(call(api), {
552
- host: entry,
553
- timeout: readOnly ? options?.readTimeout ?? readTimeout : options?.timeout,
554
- readOnly,
555
- action,
556
- target
557
- });
558
- /**
559
- * Routes a call by the ID's host part: no network is needed to find the host. `made` is
560
- * the ID of the resource the call makes, if any, which is what to read after a lost reply.
561
- */
562
- const byId = (id, contract, action, call, made) => Effect.gen(function* () {
563
- const { host } = yield* parseId(id);
564
- return yield* ask(yield* route(host), contract, action, call, made ?? id);
565
- });
566
- /** Asks every host at once; a host that fails is named rather than failing the whole. */
567
- const gather = (call) => Effect.forEach(routed, (route) => Effect.map(Effect.result(call(route)), (result) => ({
568
- host: route.entry.id,
569
- result
570
- })), { concurrency: "unbounded" }).pipe(Effect.map((results) => {
571
- const answers = [];
572
- const unreachable = [];
573
- for (const { host, result } of results) if (Result.isSuccess(result)) answers.push(...result.success);
574
- else unreachable.push({
575
- host,
576
- error: result.failure
577
- });
578
- return {
579
- answers,
580
- unreachable
581
- };
582
- }));
583
- /**
584
- * Reads a host. IDs route by its entry's ID, so a host that calls itself something else
585
- * fails rather than being placed on.
586
- */
587
- const readHost = (route) => ask(route, actions.getHost, "get host", (api) => Effect.flatMap(api.getHost(), (host) => checkHost(route.entry, host)));
588
- const createOn = (host, name, spec) => Effect.gen(function* () {
589
- const id = yield* formatId(host, name);
590
- return yield* ask(yield* route(host), actions.createMachine, `create ${id}`, (api) => api.createMachine({
591
- ...spec,
592
- id
593
- }), id);
594
- });
595
- /**
596
- * Asks every host at once and goes through the answers in list order: the first host
597
- * that answers and offers `base` wins, and the requests still out are interrupted, so a
598
- * silent host after it delays nothing. With no winner the create is `Unavailable` only if
599
- * a host gave no reply; otherwise asking again changes nothing, and it is `Precondition`.
600
- */
601
- const offering = (base) => Effect.gen(function* () {
602
- const asked = yield* Effect.forEach(routed, (route) => Effect.map(Effect.forkScoped(Effect.result(readHost(route))), (fiber) => ({
603
- host: route.entry.id,
604
- fiber
605
- })));
606
- /** What each host answered, in list order. */
607
- const misses = [];
608
- let noReply = false;
609
- for (const { host, fiber } of asked) {
610
- const result = yield* Fiber.join(fiber);
611
- if (Result.isFailure(result)) {
612
- noReply ||= Predicate.isTagged(result.failure, "Unavailable");
613
- misses.push(`${host} failed: ${result.failure.message}`);
614
- } else if (result.success.bases.includes(base)) return result.success;
615
- else {
616
- const { bases } = result.success;
617
- misses.push(`${host} offers ${bases.length > 0 ? bases.join(", ") : "no bases"}`);
618
- }
619
- }
620
- const message = `no host offers base ${base}: ${misses.join("; ")}`;
621
- return yield* noReply ? new Unavailable({
622
- message,
623
- readOnly: true
624
- }) : new Precondition({ message });
625
- }).pipe(Effect.scoped);
626
- const create = (name, spec, options) => Effect.gen(function* () {
627
- yield* parseName(name);
628
- if (options?.host !== void 0) return yield* createOn(options.host, name, spec);
629
- return yield* createOn((yield* offering(spec.base)).id, name, spec);
630
- });
631
- const place = (base, options) => options?.host === void 0 ? offering(base) : Effect.flatMap(route(options.host), readHost);
632
- /** The ID a call that makes a resource on `source`'s host gives it. */
633
- const sibling = (source, name) => Effect.flatMap(parseId(source), ({ host }) => formatId(host, name));
634
- return {
635
- hosts: gather((route) => Effect.map(readHost(route), (host) => [host])),
636
- machines: gather((route) => ask(route, actions.listMachines, "list machines", (api) => api.listMachines())),
637
- checkpoints: gather((route) => ask(route, actions.listCheckpoints, "list checkpoints", (api) => api.listCheckpoints())),
638
- capacity: gather((route) => ask(route, actions.getCapacity, "get capacity", (api) => api.getCapacity())),
639
- machine: (id) => byId(id, actions.getMachine, `get ${id}`, (api) => api.getMachine({ id })),
640
- checkpoint: (id) => byId(id, actions.getCheckpoint, `get ${id}`, (api) => api.getCheckpoint({ id })),
641
- place,
642
- create,
643
- start: (id) => byId(id, actions.startMachine, `start ${id}`, (api) => api.startMachine({ id })),
644
- stop: (id) => byId(id, actions.stopMachine, `stop ${id}`, (api) => api.stopMachine({ id })),
645
- delete: (id) => byId(id, actions.deleteMachine, `delete ${id}`, (api) => api.deleteMachine({ id })),
646
- fork: (machine, name) => Effect.flatMap(sibling(machine, name), (made) => byId(machine, actions.forkMachine, `fork ${machine} to ${made}`, (api) => api.forkMachine({
647
- machine,
648
- name
649
- }), made)),
650
- restore: (checkpoint, name) => Effect.flatMap(sibling(checkpoint, name), (made) => byId(checkpoint, actions.restoreMachine, `restore ${checkpoint} to ${made}`, (api) => api.restoreMachine({
651
- checkpoint,
652
- name
653
- }), made)),
654
- capture: (machine, name) => Effect.flatMap(sibling(machine, name), (made) => byId(machine, actions.captureCheckpoint, `capture ${machine} to ${made}`, (api) => api.captureCheckpoint({
655
- machine,
656
- name
657
- }), made)),
658
- deleteCheckpoint: (id) => byId(id, actions.deleteCheckpoint, `delete ${id}`, (api) => api.deleteCheckpoint({ id }))
659
- };
660
- });
661
- //#endregion
662
- export { parseName as A, Unavailable as B, release as C, Name as D, Id as E, HostErrorTag as F, Internal as I, Invalid as L, Conflict as M, ConflictKind as N, formatId as O, ErrorTag as P, NotFound as R, SshLogin as S, HostId as T, hostErrors as V, MachineSpec as _, describeIssues as a, Setup as b, readTimeout as c, ActionRecord as d, Checkpoint as f, Machine as g, Limit as h, describeFileError as i, Capacity as j, parseId as k, versionHeader as l, Host as m, client_exports as n, formatMib as o, CreateRequest as p, make as r, Http as s, Client as t, ActionName as u, Profile as v, version as w, SshEndpoint as x, Runtime as y, Precondition as z };