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