@gjermundgaraba/clankerbox-sdk 0.4.0 → 0.12.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.
@@ -0,0 +1,583 @@
1
+ import { t as __exportAll } from "./rolldown-runtime-D7D4PA-g.mjs";
2
+ import * as Action from "@gjermundgaraba/effect-actions/Action";
3
+ import * as ActionGroup from "@gjermundgaraba/effect-actions/ActionGroup";
4
+ import * as ActionHttp from "@gjermundgaraba/effect-actions/ActionHttp";
5
+ import { Context, Duration, Effect, Fiber, Match, Predicate, Result, Schema } from "effect";
6
+ import { HttpClient, HttpClientRequest } from "effect/http";
7
+ import { HttpApiClient } from "effect/http-api";
8
+ //#region src/errors.ts
9
+ /** The request is malformed: a bad ID or name, an undeclared field, a value out of range. */
10
+ var Invalid = class extends Schema.TaggedError()("Invalid", { message: Schema.String }, { httpApiStatus: 400 }) {
11
+ get retryable() {
12
+ return false;
13
+ }
14
+ };
15
+ /** The machine or checkpoint doesn't exist. A `delete` that meets it is done. */
16
+ var NotFound = class extends Schema.TaggedError()("NotFound", { message: Schema.String }, { httpApiStatus: 404 }) {
17
+ get retryable() {
18
+ return false;
19
+ }
20
+ };
21
+ const ConflictKind = Schema.Literals(["exists", "busy"]);
22
+ /** The name is taken (`exists`), or another action holds the resource (`busy`). */
23
+ var Conflict = class extends Schema.TaggedError()("Conflict", {
24
+ message: Schema.String,
25
+ kind: ConflictKind
26
+ }, { httpApiStatus: 409 }) {
27
+ get retryable() {
28
+ return this.kind === "busy";
29
+ }
30
+ };
31
+ /** The resource's state doesn't allow the action, or no host offers the requested base. */
32
+ var Precondition = class extends Schema.TaggedError()("Precondition", { message: Schema.String }, { httpApiStatus: 412 }) {
33
+ get retryable() {
34
+ return false;
35
+ }
36
+ };
37
+ /** The host has no room now; room can free up, so a later call may succeed. */
38
+ var Capacity = class extends Schema.TaggedError()("Capacity", { message: Schema.String }, { httpApiStatus: 503 }) {
39
+ get retryable() {
40
+ return true;
41
+ }
42
+ };
43
+ /**
44
+ * The client couldn't reach the host, or lost its reply. Only the client library produces
45
+ * it. `access` is the access of the action that met it: a mutation's request may have
46
+ * reached the host and run, so it is never retryable.
47
+ */
48
+ var Unavailable = class extends Schema.TaggedError()("Unavailable", {
49
+ message: Schema.String,
50
+ access: Schema.Literals(["read", "write"])
51
+ }) {
52
+ get retryable() {
53
+ return this.access === "read";
54
+ }
55
+ };
56
+ var Internal = class extends Schema.TaggedError()("Internal", { message: Schema.String }, { httpApiStatus: 500 }) {
57
+ get retryable() {
58
+ return false;
59
+ }
60
+ };
61
+ /** What a host answers with; every action group declares these. */
62
+ const hostErrors = [
63
+ Invalid,
64
+ NotFound,
65
+ Conflict,
66
+ Precondition,
67
+ Capacity,
68
+ Internal
69
+ ];
70
+ /** The tag a host records on a failed action: always one of the errors it answers with. */
71
+ const HostErrorTag = Schema.Literals(hostErrors.map((error) => error.fields._tag.schema.literal));
72
+ /** The tag of any error a call through the client library can fail with. */
73
+ const ErrorTag = Schema.Literals([...HostErrorTag.literals, Unavailable.fields._tag.schema.literal]);
74
+ //#endregion
75
+ //#region src/ids.ts
76
+ /**
77
+ * Machine and checkpoint IDs, `<host>_<name>`: the one place that parses and formats them.
78
+ * Neither part contains `_`, so an ID has exactly one. The whole ID matches
79
+ * `^[A-Za-z0-9_-]{1,62}$` because it is written to `/var/lib/clankerbox/machine-id`, and
80
+ * clankercreds accepts only that pattern.
81
+ */
82
+ const separator = "_";
83
+ const maxIdLength = 62;
84
+ const idPattern = /^[A-Za-z0-9_-]{1,62}$/u;
85
+ /**
86
+ * boat's named snapshots are `cbx-<host>-<inst>`, and boat allows `^[a-z0-9][a-z0-9-]{0,62}$`:
87
+ * a lowercase host ID of at most 32 characters keeps the name within 63.
88
+ */
89
+ const hostIdPattern = /^[a-z][a-z0-9-]{0,31}$/u;
90
+ const hostIdRule = "a host ID is 1 to 32 lowercase letters, digits and '-', starting with a letter";
91
+ /**
92
+ * smolvm names a machine `<name>-<inst>` and refuses consecutive hyphens, so a name has no
93
+ * `--` and doesn't end in `-`.
94
+ */
95
+ const namePattern = /^[A-Za-z][A-Za-z0-9]*(?:-[A-Za-z0-9]+)*$/u;
96
+ const nameRule = "a name starts with a letter, then letters, digits and '-', with no '--' and no '-' at the end";
97
+ /** A host's part of every ID it holds. */
98
+ const HostId = Schema.String.check(Schema.isPattern(hostIdPattern, { message: hostIdRule }));
99
+ /** A machine's or checkpoint's name, unique per host and per resource type. */
100
+ const Name = Schema.String.check(Schema.isPattern(namePattern, { message: nameRule }));
101
+ const describeId = (id) => {
102
+ if (!idPattern.test(id)) return `ID ${JSON.stringify(id)} must be 1 to ${maxIdLength} letters, digits, '_' or '-'`;
103
+ const at = id.indexOf(separator);
104
+ if (at <= 0 || at === id.length - 1) return `ID ${JSON.stringify(id)} must be <host>_<name>`;
105
+ if (!hostIdPattern.test(id.slice(0, at))) return `ID ${JSON.stringify(id)} has an invalid host: ${hostIdRule}`;
106
+ if (!namePattern.test(id.slice(at + 1))) return `ID ${JSON.stringify(id)} has an invalid name: ${nameRule}`;
107
+ };
108
+ /** A full `<host>_<name>` ID. */
109
+ const Id = Schema.String.check(Schema.makeFilter((id) => describeId(id) ?? true));
110
+ /** Whether `create`'s target is a full ID rather than a bare name: a name has no `_`. */
111
+ const isId = (target) => target.includes(separator);
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.12.0";
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
+ const Machine = Schema.Struct({
177
+ id: Id,
178
+ runtime: Runtime,
179
+ createdAt: Schema.DateTimeUtcFromString,
180
+ base: Schema.String,
181
+ /** The label the client passed at create, normally its profile's name. */
182
+ profile: Schema.optionalKey(Schema.String),
183
+ cpu: Size,
184
+ ramMib: Size,
185
+ diskGib: Size,
186
+ /**
187
+ * Read from the runtime, never stored: `missing` when the runtime doesn't have the machine,
188
+ * `unknown` when it couldn't be read.
189
+ */
190
+ state: Schema.Literals([
191
+ "running",
192
+ "stopped",
193
+ "missing",
194
+ "unknown"
195
+ ]),
196
+ action: ActionRecord,
197
+ ssh: Schema.optionalKey(SshEndpoint),
198
+ /** The guest's SSH host public key, as `<type> <base64>`. */
199
+ hostKey: Schema.optionalKey(Schema.String)
200
+ }).annotate({ identifier: "Machine" });
201
+ const Checkpoint = Schema.Struct({
202
+ id: Id,
203
+ createdAt: Schema.DateTimeUtcFromString,
204
+ /** The ID of the machine it was captured from. */
205
+ machine: Id,
206
+ /** What it holds, by runtime: `ram` (RAM and disks) on smolvm, `disk` on Tart and boat. */
207
+ kind: Schema.Literals(["ram", "disk"]),
208
+ base: Schema.String,
209
+ profile: Schema.optionalKey(Schema.String),
210
+ cpu: Size,
211
+ ramMib: Size,
212
+ diskGib: Size,
213
+ action: ActionRecord
214
+ }).annotate({ identifier: "Checkpoint" });
215
+ /** The SDK's version, which is also the version of the clankerbox binaries it ships with. */
216
+ const version = version$1;
217
+ /** A version's major.minor: releases that share it speak the same API. */
218
+ const release = (of) => of.split(".").slice(0, 2).join(".");
219
+ const Host = Schema.Struct({
220
+ id: HostId,
221
+ runtime: Runtime,
222
+ /** The clankerbox version the host runs. */
223
+ version: Schema.String,
224
+ /** The runtime's version; for boat, its API version. */
225
+ runtimeVersion: Schema.String,
226
+ bases: Schema.Array(Schema.String)
227
+ }).annotate({ identifier: "Host" });
228
+ /** A setup script and how long it may run. Nothing logs the script: it can carry secrets. */
229
+ const Setup = Schema.Struct({
230
+ script: Schema.String,
231
+ timeoutSeconds: Size
232
+ });
233
+ const specFields = {
234
+ base: Schema.String,
235
+ cpu: Size,
236
+ ramMib: Size,
237
+ diskGib: Size,
238
+ setup: Schema.optionalKey(Setup),
239
+ profile: Schema.optionalKey(Schema.String)
240
+ };
241
+ /**
242
+ * What a new machine is made from: a base, its sizes, an optional setup script with its
243
+ * timeout, and an optional `profile` label. A profile file fills these in; the host never
244
+ * sees the profile itself.
245
+ */
246
+ const MachineSpec = Schema.Struct(specFields);
247
+ /**
248
+ * `machine.create`'s input: the spec and the new machine's full ID. The host checks that the ID
249
+ * names it, so a create never lands under another host's ID.
250
+ */
251
+ const CreateRequest = Schema.Struct({
252
+ id: Id,
253
+ ...specFields
254
+ });
255
+ /**
256
+ * A profile file: a client-side file that fills in a create request. `setup.path` is a script
257
+ * file or a recipe directory, relative to the profile file, and comes with its timeout: the
258
+ * profile's author sets it, and there is no default. `host` places the create on that host
259
+ * instead of by base.
260
+ */
261
+ const Profile = Schema.Struct({
262
+ base: specFields.base,
263
+ cpu: specFields.cpu,
264
+ ramMib: specFields.ramMib,
265
+ diskGib: specFields.diskGib,
266
+ setup: Schema.optionalKey(Schema.Struct({
267
+ path: Schema.String,
268
+ timeoutSeconds: Size
269
+ })),
270
+ host: Schema.optionalKey(HostId)
271
+ });
272
+ //#endregion
273
+ //#region src/api.ts
274
+ /**
275
+ * The host API: three effect-actions groups, served as JSON POST routes under `/api`.
276
+ * A mutation replies when its action has finished.
277
+ */
278
+ /**
279
+ * Without a policy, effect-actions answers undeclared or malformed input with an empty 400.
280
+ * Schema issues never carry the rejected values, so a setup script never reaches a message.
281
+ */
282
+ const schemaError = {
283
+ invalid: {
284
+ schema: Invalid,
285
+ make: (failure) => new Invalid({ message: `the request doesn't match the action's input: ${failure.cause.message}` })
286
+ },
287
+ internal: {
288
+ schema: Internal,
289
+ make: () => new Internal({ message: "the host's reply did not encode" })
290
+ }
291
+ };
292
+ /**
293
+ * How long a client waits for a read's reply from one host, unless it sets its own. A host
294
+ * bounds its own reads of runtime state under it, so a list answers in time.
295
+ */
296
+ const readTimeout = Duration.seconds(10);
297
+ /**
298
+ * The header every client request carries the SDK's `version` in. A host refuses a request
299
+ * whose release (major.minor) differs from its own, or that carries none, before it runs
300
+ * anything.
301
+ */
302
+ const versionHeader = "clankerbox-version";
303
+ const ById = Schema.Struct({ id: Id });
304
+ /** Input of the actions that make a new resource from a machine: its ID and the new name. */
305
+ const FromMachine = Schema.Struct({
306
+ machine: Id,
307
+ name: Name
308
+ });
309
+ const MachineGroup = ActionGroup.make({
310
+ name: "machine",
311
+ errors: hostErrors,
312
+ schemaError
313
+ }, Action.make("list", {
314
+ description: "List the host's machines.",
315
+ success: Schema.Array(Machine),
316
+ access: "read"
317
+ }), Action.make("get", {
318
+ description: "Read one machine.",
319
+ input: ById,
320
+ success: Machine,
321
+ access: "read"
322
+ }), Action.make("create", {
323
+ description: "Create a machine from a base, run its setup once, and prepare it.",
324
+ input: CreateRequest,
325
+ success: Machine,
326
+ access: "write"
327
+ }), Action.make("start", {
328
+ description: "Start a machine. On a running machine, run preparation again.",
329
+ input: ById,
330
+ success: Machine,
331
+ access: "write"
332
+ }), Action.make("stop", {
333
+ description: "Stop a machine. A stopped machine stays stopped, and the stop is still its last action.",
334
+ input: ById,
335
+ success: Machine,
336
+ access: "write"
337
+ }), Action.make("delete", {
338
+ description: "Delete a machine and everything native it owns.",
339
+ input: ById,
340
+ success: Schema.Void,
341
+ access: "write"
342
+ }), Action.make("fork", {
343
+ description: "Copy a machine to a new name on the same host.",
344
+ input: FromMachine,
345
+ success: Machine,
346
+ access: "write"
347
+ }), Action.make("restore", {
348
+ description: "Create a machine on the checkpoint's host from that checkpoint.",
349
+ input: Schema.Struct({
350
+ checkpoint: Id,
351
+ name: Name
352
+ }),
353
+ success: Machine,
354
+ access: "write"
355
+ }));
356
+ const CheckpointGroup = ActionGroup.make({
357
+ name: "checkpoint",
358
+ errors: hostErrors,
359
+ schemaError
360
+ }, Action.make("list", {
361
+ description: "List the host's checkpoints.",
362
+ success: Schema.Array(Checkpoint),
363
+ access: "read"
364
+ }), Action.make("get", {
365
+ description: "Read one checkpoint.",
366
+ input: ById,
367
+ success: Checkpoint,
368
+ access: "read"
369
+ }), Action.make("capture", {
370
+ 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.",
371
+ input: FromMachine,
372
+ success: Checkpoint,
373
+ access: "write"
374
+ }), Action.make("delete", {
375
+ description: "Delete a checkpoint.",
376
+ input: ById,
377
+ success: Schema.Void,
378
+ access: "write"
379
+ }));
380
+ const HostGroup = ActionGroup.make({
381
+ name: "host",
382
+ errors: hostErrors,
383
+ schemaError
384
+ }, Action.make("get", {
385
+ description: "Read the host: its runtime, versions and bases.",
386
+ success: Host,
387
+ access: "read"
388
+ }));
389
+ const Http = ActionHttp.make({ apiPath: "/api" }, MachineGroup, CheckpointGroup, HostGroup);
390
+ //#endregion
391
+ //#region src/client.ts
392
+ var client_exports = /* @__PURE__ */ __exportAll({
393
+ Client: () => Client,
394
+ HostEntry: () => HostEntry,
395
+ make: () => make
396
+ });
397
+ /** One host a client talks to. `id` is the host part of every ID it holds. */
398
+ const HostEntry = Schema.Struct({
399
+ id: HostId,
400
+ url: Schema.String.check(Schema.makeFilter((url) => URL.canParse(url) || "a URL, such as http://linux:8484"))
401
+ });
402
+ var Client = class extends Context.Service()("@gjermundgaraba/clankerbox-sdk/Client") {};
403
+ /**
404
+ * A client for one host. Every request carries the SDK's version, which the host checks
405
+ * against its own. Its reply decoding is the only step `transformResponse` wraps, so a reply
406
+ * that doesn't decode is `Internal` here, and a `SchemaError` that reaches `settle` came from
407
+ * encoding the request.
408
+ */
409
+ const makeHostApi = (entry) => HttpApiClient.make(Http.api, {
410
+ baseUrl: entry.url,
411
+ transformClient: HttpClient.mapRequest(HttpClientRequest.setHeader(versionHeader, version)),
412
+ transformResponse: (reply) => Effect.mapError(reply, (error) => Schema.isSchemaError(error) ? new Internal({ message: `host ${entry.id}'s reply didn't decode: ${error.message}` }) : error)
413
+ });
414
+ /** Every action's contract, keyed `<group>.<action>`: a call reads its access from its action. */
415
+ const actions = ActionGroup.contracts(MachineGroup, CheckpointGroup, HostGroup);
416
+ const causeDetail = (error) => error.cause instanceof Error ? error.cause.message : error.message;
417
+ /**
418
+ * No reply came: the connection failed or dropped, or the timeout passed. A mutation's
419
+ * request may or may not have reached its host, so it may have run.
420
+ */
421
+ const unanswered = (context, detail) => context.access === "read" ? new Unavailable({
422
+ message: `host ${context.host.id} (${context.host.url}) didn't answer ${context.action}: ${detail}`,
423
+ access: "read"
424
+ }) : new Unavailable({
425
+ message: `no reply from host ${context.host.id} to ${context.action} (${detail}); the action may have run: read ${context.target ?? "the resource"} to see`,
426
+ access: "write"
427
+ });
428
+ /** Bounds the wait for the reply; the clock starts when the request is sent. */
429
+ const bounded = (call, context) => {
430
+ const { timeout } = context;
431
+ return timeout === void 0 ? call : Effect.timeoutOrElse(call, {
432
+ duration: timeout,
433
+ orElse: () => Effect.fail(unanswered(context, `timed out after ${Duration.format(timeout)}`))
434
+ });
435
+ };
436
+ const settle = (call, context) => bounded(call, context).pipe(Effect.catchTag("HttpClientError", (error) => Effect.fail(Match.value(error.reason).pipe(Match.tag("TransportError", () => unanswered(context, causeDetail(error))), Match.when({
437
+ _tag: "DecodeError",
438
+ cause: (cause) => cause !== void 0 && !(cause instanceof SyntaxError)
439
+ }, () => unanswered(context, causeDetail(error))), Match.orElse(() => new Internal({ message: `host ${context.host.id} answered ${context.action} unexpectedly: ${error.message}` }))))), Effect.catchTag("SchemaError", (error) => Effect.fail(new Invalid({ message: `${context.action}: the request doesn't match the action's input: ${error.message}` }))));
440
+ /** A host this client can use: it calls itself by its entry's ID. */
441
+ 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` }));
442
+ /** Every entry is checked here, so a call never meets a malformed URL. */
443
+ const decodeHosts = Schema.decodeUnknownEffect(Schema.Array(HostEntry));
444
+ /**
445
+ * Builds a client over `hosts`, in placement order. Making it sends nothing. An empty list
446
+ * is Invalid, and so is a malformed entry or a host ID that appears twice: IDs route by it.
447
+ */
448
+ const make = (hosts, options) => Effect.gen(function* () {
449
+ if (hosts.length === 0) return yield* new Invalid({ message: "the host list is empty: a client needs a host" });
450
+ yield* decodeHosts(hosts).pipe(Effect.mapError((error) => new Invalid({ message: `host list: ${error.message}` })));
451
+ const twice = hosts.find(({ id }, index) => hosts.findIndex((other) => other.id === id) < index);
452
+ if (twice !== void 0) return yield* new Invalid({ message: `host ${twice.id} appears twice in the host list` });
453
+ const routed = yield* Effect.forEach(hosts, (entry) => Effect.map(makeHostApi(entry), (api) => ({
454
+ entry,
455
+ api
456
+ })));
457
+ const routes = new Map(routed.map((route) => [route.entry.id, route]));
458
+ /** How long a call waits for its reply, by its action's access. */
459
+ const bounds = {
460
+ read: options?.readTimeout ?? readTimeout,
461
+ write: options?.timeout
462
+ };
463
+ const route = (host) => {
464
+ const found = routes.get(host);
465
+ return found === void 0 ? Effect.fail(new Invalid({ message: `host ${host} isn't in the host list` })) : Effect.succeed(found);
466
+ };
467
+ /**
468
+ * Sends one call to one host, bounded by its action's access. `target` is the ID to read
469
+ * when a mutation's reply is lost.
470
+ */
471
+ const ask = ({ entry, api }, contract, action, call, target) => settle(call(api), {
472
+ host: entry,
473
+ timeout: bounds[contract.access],
474
+ access: contract.access,
475
+ action,
476
+ target
477
+ });
478
+ /**
479
+ * Routes a call by the ID's host part: no network is needed to find the host. `made` is
480
+ * the ID of the resource the call makes, if any, which is what to read after a lost reply.
481
+ */
482
+ const byId = (id, contract, action, call, made) => Effect.gen(function* () {
483
+ const { host } = yield* parseId(id);
484
+ return yield* ask(yield* route(host), contract, action, call, made ?? id);
485
+ });
486
+ /** Asks every host at once; a host that fails is named rather than failing the whole. */
487
+ const gather = (call) => Effect.forEach(routed, (route) => Effect.map(Effect.result(call(route)), (result) => ({
488
+ host: route.entry.id,
489
+ result
490
+ })), { concurrency: "unbounded" }).pipe(Effect.map((results) => {
491
+ const answers = [];
492
+ const unreachable = [];
493
+ for (const { host, result } of results) if (Result.isSuccess(result)) answers.push(...result.success);
494
+ else unreachable.push({
495
+ host,
496
+ error: result.failure
497
+ });
498
+ return {
499
+ answers,
500
+ unreachable
501
+ };
502
+ }));
503
+ /**
504
+ * Reads a host. IDs route by its entry's ID, so a host that calls itself something else
505
+ * fails rather than being placed on.
506
+ */
507
+ const readHost = (route) => ask(route, actions["host.get"], "get host", (api) => Effect.flatMap(api.host.get({ payload: {} }), (host) => checkHost(route.entry, host)));
508
+ const createOn = (host, name, spec) => Effect.gen(function* () {
509
+ const id = yield* formatId(host, name);
510
+ return yield* ask(yield* route(host), actions["machine.create"], `create ${id}`, (api) => api.machine.create({ payload: {
511
+ ...spec,
512
+ id
513
+ } }), id);
514
+ });
515
+ /**
516
+ * Asks every host at once and goes through the answers in list order: the first host
517
+ * that answers and offers `base` wins, and the requests still out are interrupted, so a
518
+ * silent host after it delays nothing. With no winner the create is `Unavailable` only if
519
+ * a host gave no reply; otherwise asking again changes nothing, and it is `Precondition`.
520
+ */
521
+ const place = (base) => Effect.gen(function* () {
522
+ const asked = yield* Effect.forEach(routed, (route) => Effect.map(Effect.forkScoped(Effect.result(readHost(route))), (fiber) => ({
523
+ host: route.entry.id,
524
+ fiber
525
+ })));
526
+ /** What each host answered, in list order. */
527
+ const misses = [];
528
+ let noReply = false;
529
+ for (const { host, fiber } of asked) {
530
+ const result = yield* Fiber.join(fiber);
531
+ if (Result.isFailure(result)) {
532
+ noReply ||= Predicate.isTagged(result.failure, "Unavailable");
533
+ misses.push(`${host} failed: ${result.failure._tag}: ${result.failure.message}`);
534
+ } else if (result.success.bases.includes(base)) return host;
535
+ else {
536
+ const { bases } = result.success;
537
+ misses.push(`${host} offers ${bases.length > 0 ? bases.join(", ") : "no bases"}`);
538
+ }
539
+ }
540
+ const message = `no host offers base ${base}: ${misses.join("; ")}`;
541
+ return yield* noReply ? new Unavailable({
542
+ message,
543
+ access: "read"
544
+ }) : new Precondition({ message });
545
+ }).pipe(Effect.scoped);
546
+ const create = (target, spec, options) => Effect.gen(function* () {
547
+ if (isId(target)) {
548
+ const { host, name } = yield* parseId(target);
549
+ return yield* createOn(host, name, spec);
550
+ }
551
+ const name = yield* parseName(target);
552
+ if (options?.host !== void 0) return yield* createOn(options.host, name, spec);
553
+ return yield* createOn(yield* place(spec.base), name, spec);
554
+ });
555
+ /** The ID a call that makes a resource on `source`'s host gives it. */
556
+ const sibling = (source, name) => Effect.flatMap(parseId(source), ({ host }) => formatId(host, name));
557
+ return {
558
+ hosts: gather((route) => Effect.map(readHost(route), (host) => [host])),
559
+ machines: gather((route) => ask(route, actions["machine.list"], "list machines", (api) => api.machine.list({ payload: {} }))),
560
+ checkpoints: gather((route) => ask(route, actions["checkpoint.list"], "list checkpoints", (api) => api.checkpoint.list({ payload: {} }))),
561
+ machine: (id) => byId(id, actions["machine.get"], `get ${id}`, (api) => api.machine.get({ payload: { id } })),
562
+ checkpoint: (id) => byId(id, actions["checkpoint.get"], `get ${id}`, (api) => api.checkpoint.get({ payload: { id } })),
563
+ create,
564
+ start: (id) => byId(id, actions["machine.start"], `start ${id}`, (api) => api.machine.start({ payload: { id } })),
565
+ stop: (id) => byId(id, actions["machine.stop"], `stop ${id}`, (api) => api.machine.stop({ payload: { id } })),
566
+ delete: (id) => byId(id, actions["machine.delete"], `delete ${id}`, (api) => api.machine.delete({ payload: { id } })),
567
+ fork: (machine, name) => Effect.flatMap(sibling(machine, name), (made) => byId(machine, actions["machine.fork"], `fork ${machine} to ${made}`, (api) => api.machine.fork({ payload: {
568
+ machine,
569
+ name
570
+ } }), made)),
571
+ restore: (checkpoint, name) => Effect.flatMap(sibling(checkpoint, name), (made) => byId(checkpoint, actions["machine.restore"], `restore ${checkpoint} to ${made}`, (api) => api.machine.restore({ payload: {
572
+ checkpoint,
573
+ name
574
+ } }), made)),
575
+ capture: (machine, name) => Effect.flatMap(sibling(machine, name), (made) => byId(machine, actions["checkpoint.capture"], `capture ${machine} to ${made}`, (api) => api.checkpoint.capture({ payload: {
576
+ machine,
577
+ name
578
+ } }), made)),
579
+ deleteCheckpoint: (id) => byId(id, actions["checkpoint.delete"], `delete ${id}`, (api) => api.checkpoint.delete({ payload: { id } }))
580
+ };
581
+ });
582
+ //#endregion
583
+ export { Capacity as A, hostErrors as B, HostId as C, isId as D, formatId as E, Internal as F, Invalid as I, NotFound as L, ConflictKind as M, ErrorTag as N, parseId as O, HostErrorTag as P, Precondition as R, version as S, Name as T, Profile as _, HostGroup as a, SshEndpoint as b, readTimeout as c, ActionRecord as d, Checkpoint as f, MachineSpec as g, Machine as h, CheckpointGroup as i, Conflict as j, parseName as k, versionHeader as l, Host as m, client_exports as n, Http as o, CreateRequest as p, make as r, MachineGroup as s, Client as t, ActionName as u, Runtime as v, Id as w, release as x, Setup as y, Unavailable as z };