@ankorcs/protocol 0.0.0-stage → 0.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1764 @@
1
+ /**
2
+ * Outbound agent WebSocket authenticated by bearer token.
3
+ *
4
+ * Requests carry `id`. A reply echoes it in `reply_to` and names the request's `type` in `request`.
5
+ * Unsolicited frames omit correlation fields.
6
+ */
7
+ import { z } from 'zod';
8
+ import { MatchId, MAX_SERVER_PLAYERS, PlayerName, PlayingSide, RoundBackup, RoundNumber, Side, SteamId64, } from './game-events.js';
9
+ import { addGsltIssues, addSlotLayoutIssues } from './helpers.js';
10
+ import { ServerLaunchConfig } from './launch.js';
11
+ import { MAX_REPORTED_BACKLOGS, MAX_REPORTED_DEMOS, MAX_SLOTS, } from './limits.js';
12
+ import { AGENT_MIN_SLOT_PORT, AllocationId, DemoUploadEdit, Gslt, GsltState, ImageReference, Ipv4, MemoryLimit, ReportedDemoUpload, ReportedSlot, ServerSlot, SlotId, ControllerAddress, } from './values.js';
13
+ /** The `code` of a failed {@link AgentReply | reply}. `detail` carries the human-readable rest. */
14
+ export const AGENT_ERRORS = {
15
+ /** No slot is idle, unreserved and on a token Steam has not refused. */
16
+ noFreeSlot: 'no_free_slot',
17
+ /**
18
+ * A newer public CS2 build is out and the node has not updated to it yet, so its `cs2.build_id`
19
+ * is behind `cs2.latest_build_id`. The node takes launches again once it has updated. A node
20
+ * whose `auto_update` is `false` never refuses with this.
21
+ */
22
+ cs2Outdated: 'cs2_outdated',
23
+ /**
24
+ * CS2 is not installed on the node, so its `cs2.build_id` is `null`. The node takes launches once
25
+ * an {@link ControllerUpdateCs2 | update_cs2} has installed it.
26
+ */
27
+ cs2NotInstalled: 'cs2_not_installed',
28
+ /** The node reports `unavailable` for a reason other than `maintenance`. */
29
+ nodeUnavailable: 'node_unavailable',
30
+ /**
31
+ * An operation that needs every slot idle found one held: {@link ControllerUpdateCs2 | update_cs2}, or a
32
+ * {@link ControllerSetConfig | set_config} that changes any key under `controller`.
33
+ */
34
+ slotsHeld: 'slots_held',
35
+ /**
36
+ * A slot holds a server that is `starting`, `running` or `stopping`, and
37
+ * {@link ControllerSetConfig | set_config} would remove or re-port the slot or give its server's token
38
+ * to another slot, or {@link ControllerReserveSlot | reserve_slot} asked for it. `detail` names the
39
+ * slots.
40
+ */
41
+ slotHeld: 'slot_held',
42
+ /** The node has no slot with that id. */
43
+ unknownSlot: 'unknown_slot',
44
+ /**
45
+ * {@link ControllerSetConfig | set_config} kept the token of a slot the node does not have yet. A new
46
+ * slot needs a new token.
47
+ */
48
+ slotTokenRequired: 'slot_token_required',
49
+ /**
50
+ * {@link ControllerSetConfig | set_config} kept the value of a demo upload header the node does not
51
+ * send. A new header needs its value.
52
+ */
53
+ demoHeaderValueRequired: 'demo_header_value_required',
54
+ /** No server with that allocation id. */
55
+ unknownAllocation: 'unknown_allocation',
56
+ /**
57
+ * A maintenance operation runs on the node. That operation is unaffected, and `unavailable` shows
58
+ * it as `maintenance`.
59
+ */
60
+ maintenanceRunning: 'maintenance_running',
61
+ /**
62
+ * The image is not on the node and the request asked for no pull. Asking again with `pull`
63
+ * fetches it.
64
+ */
65
+ imageMissing: 'image_missing',
66
+ /**
67
+ * The image is not an official one, and the node's `official_images_only` is on. The node's
68
+ * operator decides which images it runs.
69
+ */
70
+ imageNotAllowed: 'image_not_allowed',
71
+ /** The demo is uploading. It can be deleted once the upload ends. */
72
+ demoUploading: 'demo_uploading',
73
+ /** The slot has no demo of that name, such as one an upload or a delete has already removed. */
74
+ unknownDemo: 'unknown_demo',
75
+ /** The demo is still being recorded. It can be deleted once its server ends the recording. */
76
+ demoRecording: 'demo_recording',
77
+ /** The server's plugin is not connected to the agent, or left before it answered. */
78
+ pluginOffline: 'plugin_offline',
79
+ /**
80
+ * The plugin did not answer {@link ControllerGetServerStatus | get_server_status},
81
+ * {@link ControllerConsoleCommand | console_command}, {@link ControllerStartMatch | start_match} or
82
+ * {@link ControllerRestoreRound | restore_round} within {@link SERVER_STATUS_TIMEOUT_MS}.
83
+ */
84
+ pluginStatusTimeout: 'plugin_status_timeout',
85
+ /** The server runs without a match. */
86
+ noMatch: 'no_match',
87
+ /** The match is past its warmup. */
88
+ notInWarmup: 'not_in_warmup',
89
+ /** A team has no player connected. */
90
+ teamEmpty: 'team_empty',
91
+ /** The match is not live, such as in warmup, during the knife round or after it ended. */
92
+ notLive: 'not_live',
93
+ /**
94
+ * The match is in its halftime break, while the sides are about to swap. Ask again once the next
95
+ * round has started.
96
+ */
97
+ atHalftime: 'at_halftime',
98
+ /**
99
+ * The node has no backup of the round that {@link ControllerRestoreRound | restore_round} named for that
100
+ * server.
101
+ */
102
+ noBackup: 'no_backup',
103
+ /** The request did not match its schema. */
104
+ invalidRequest: 'invalid_request',
105
+ /**
106
+ * {@link ControllerStartServer | start_server} named an allocation this node is already launching or
107
+ * running. Its server exists or is on its way, as after a {@link ControllerStartServer | start_server}
108
+ * sent again when its reply was slow.
109
+ */
110
+ allocationInUse: 'allocation_in_use',
111
+ /**
112
+ * {@link ControllerStartServer | start_server} named an allocation that this node launched before, among
113
+ * the last {@link REMEMBERED_ALLOCATIONS}. The node files a server's events and demos under its
114
+ * allocation, so every launch needs a new one.
115
+ */
116
+ allocationReused: 'allocation_reused',
117
+ /** The agent does not know the request's `type`, or the plugin the command's. */
118
+ unsupportedRequest: 'unsupported_request',
119
+ /**
120
+ * The agent is stopping and started no work for the request, as after its
121
+ * {@link AgentShutdown | shutdown}. Send the request again once the node reconnects.
122
+ */
123
+ shuttingDown: 'shutting_down',
124
+ /** Any failure that no other code names, such as a Docker failure. */
125
+ failed: 'failed',
126
+ };
127
+ /** The `code` of a failed reply, one of the values of {@link AGENT_ERRORS}. */
128
+ export const AgentErrorCode = z.enum(AGENT_ERRORS);
129
+ /** Longest `detail` a refused request carries. The agent cuts the message to it. */
130
+ export const AGENT_ERROR_DETAIL_MAX_CHARS = 512;
131
+ /** Why a request failed. Branch on `code`. `detail` is for people. */
132
+ export const AgentError = z.object({
133
+ /** What went wrong. Branch on it. */
134
+ code: AgentErrorCode,
135
+ /**
136
+ * The rest of the reason, such as Docker's message, up to {@link AGENT_ERROR_DETAIL_MAX_CHARS}
137
+ * characters.
138
+ */
139
+ detail: z.string().max(AGENT_ERROR_DETAIL_MAX_CHARS),
140
+ });
141
+ /**
142
+ * The {@link ControllerStartServer | start_server} refusals the agent sends before reserving a slot.
143
+ * Nothing started, so another node may take the launch.
144
+ */
145
+ export const StartRefusal = z.enum([
146
+ AGENT_ERRORS.noFreeSlot, AGENT_ERRORS.maintenanceRunning, AGENT_ERRORS.cs2NotInstalled, AGENT_ERRORS.cs2Outdated, AGENT_ERRORS.nodeUnavailable,
147
+ AGENT_ERRORS.shuttingDown,
148
+ ]);
149
+ /**
150
+ * What a slot is doing.
151
+ *
152
+ * - `idle`: the slot has no server.
153
+ * - `starting`: the slot holds a server that its plugin has not yet reported ready for players with
154
+ * a `server_status` with `ready`. A server that a restarted agent took over starts here too.
155
+ * - `running`: the plugin has reported the server ready.
156
+ * - `stopping`: the server is being stopped.
157
+ */
158
+ export const SlotState = z.enum(['idle', 'starting', 'running', 'stopping']);
159
+ /**
160
+ * Why the agent stopped a server on its own.
161
+ *
162
+ * - `idle_empty`: the server stayed empty for its launch's `idle_shutdown_seconds`.
163
+ * - `config_rejected`: the plugin refused its match config, as `config_error` says.
164
+ * - `start_timeout`: the server was still `starting` {@link START_TIMEOUT_SECONDS} after its
165
+ * launch, or after a restarted agent took it over.
166
+ * - `quit_timeout`: the server was still running {@link QUIT_TIMEOUT_SECONDS} after its plugin sent
167
+ * `quitting`.
168
+ * - `slot_removed`: a restarted agent found the server running on a slot that `agent.yml` no longer
169
+ * has.
170
+ */
171
+ export const AgentStopReason = z.enum(['idle_empty', 'config_rejected', 'start_timeout', 'quit_timeout', 'slot_removed']);
172
+ /**
173
+ * Why a server ended: one of the {@link AgentStopReason} values when the agent stopped it on its
174
+ * own, or one of these.
175
+ *
176
+ * - `requested`: a {@link ControllerStopServer | stop_server} from the controller stopped it.
177
+ * - `clean_exit`: it ended on its own with exit code `0`, as after its match or a console `quit`.
178
+ * - `crashed`: it ended on its own with another exit code.
179
+ * - `launch_failed`: its container never started.
180
+ * - `lost`: it ended and the agent did not see how, such as a container removed by hand.
181
+ */
182
+ export const ServerExitReason = z.enum([...AgentStopReason.options, 'requested', 'clean_exit', 'crashed', 'launch_failed', 'lost']);
183
+ /**
184
+ * Up to {@link MAX_SLOTS} slots. Rejected when two slots share an `id`, or when two ports are equal.
185
+ * Every slot's `port` and `tv_port` count, so a slot's two ports differ too. It is also rejected
186
+ * when a port is {@link EVENT_RELAY_PORT}, where the agent listens for plugins.
187
+ */
188
+ const SlotLayout = z.array(ReportedSlot).max(MAX_SLOTS).superRefine((slots, ctx) => addSlotLayoutIssues(ctx, slots, AGENT_MIN_SLOT_PORT));
189
+ /**
190
+ * A slot as {@link ControllerSetConfig | set_config} asks for it.
191
+ *
192
+ * `gslt` is a new token, which may replace the token of a slot that runs a server. That server
193
+ * keeps the token it started with, and no other slot may use that token until the server ends
194
+ * (`slot_held`). Without `gslt` the slot keeps its current token, so a slot the node does not have
195
+ * yet needs one (`slot_token_required`), and a slot from a {@link AgentHello | hello} can be sent
196
+ * back unchanged.
197
+ */
198
+ export const SlotEdit = ServerSlot.extend({
199
+ /** The slot's new token. Without one, the slot keeps the token it has. */
200
+ gslt: Gslt.optional(),
201
+ });
202
+ /** The layout rules of `SlotLayout`, and no token twice among the slots that carry one. */
203
+ const SlotEditLayout = z.array(SlotEdit).max(MAX_SLOTS).superRefine((slots, ctx) => {
204
+ addSlotLayoutIssues(ctx, slots, AGENT_MIN_SLOT_PORT);
205
+ addGsltIssues(ctx, slots);
206
+ });
207
+ /** The id Docker gives an image: `sha256:` and 64 hex digits. */
208
+ export const ImageId = z.string().regex(/^sha256:[0-9a-f]{64}$/, 'must be a sha256 image id');
209
+ /** A Steam build id, in decimal. */
210
+ export const BuildId = z.string().regex(/^\d{1,20}$/);
211
+ const slotStatusFields = {
212
+ /** The slot this describes. */
213
+ slot_id: SlotId,
214
+ /** What the slot is doing. */
215
+ state: SlotState,
216
+ /** The server on the slot. Null when the slot is `idle`. */
217
+ allocation_id: AllocationId.nullable(),
218
+ /** The server's match id. Null for a server without a match or a match launched without one. */
219
+ match_id: MatchId.nullable(),
220
+ /**
221
+ * Whether the server's plugin is connected to the agent.
222
+ * {@link ControllerGetServerStatus | get_server_status}, {@link ControllerConsoleCommand | console_command},
223
+ * {@link ControllerRestoreRound | restore_round} and {@link ControllerStartMatch | start_match} reach it only
224
+ * while it is.
225
+ */
226
+ plugin_online: z.boolean(),
227
+ /**
228
+ * Whether Steam accepts the slot's token. {@link ControllerStartServer | start_server} never picks a
229
+ * slot whose token is `invalid`.
230
+ */
231
+ gslt_state: GsltState,
232
+ /**
233
+ * Whether the idle slot is kept from launches, while the controller replaces its token with
234
+ * {@link ControllerReserveSlot | reserve_slot} or while the agent logs its token on to Steam.
235
+ */
236
+ reserved: z.boolean(),
237
+ /**
238
+ * The `server_steam_id` that the server's plugin reported. The agent sends a
239
+ * {@link AgentSlotStatus | slot_status} as soon as it learns the ID. Null when the slot is `idle`
240
+ * or until the server is `ready`.
241
+ */
242
+ server_steam_id: SteamId64.nullable(),
243
+ };
244
+ function addSlotStatusIssues(slot, ctx) {
245
+ if (slot.state === 'idle' ? slot.allocation_id !== null || slot.match_id !== null : slot.allocation_id === null) {
246
+ ctx.addIssue({ code: 'custom', message: 'a held slot names its allocation, an idle one none' });
247
+ }
248
+ if (slot.state !== 'idle' && slot.reserved) {
249
+ ctx.addIssue({ code: 'custom', message: 'only an idle slot is reserved' });
250
+ }
251
+ if (slot.state === 'idle' && slot.server_steam_id !== null) {
252
+ ctx.addIssue({ code: 'custom', message: 'an idle slot names no server_steam_id' });
253
+ }
254
+ }
255
+ /**
256
+ * One slot as the agent sees it.
257
+ *
258
+ * Rejected when an `idle` slot names an `allocation_id`, a `match_id` or a `server_steam_id`, or
259
+ * when a slot in any other state has no `allocation_id`. It is also rejected when a slot that is
260
+ * not `idle` is `reserved`.
261
+ */
262
+ export const SlotStatus = z.object(slotStatusFields).superRefine(addSlotStatusIssues);
263
+ /**
264
+ * The part of `agent.yml` that the controller may edit, as {@link AgentHello | hello} reports it
265
+ * applied.
266
+ */
267
+ export const NodeSettings = z.object({
268
+ /** The slots servers run in. */
269
+ slots: SlotLayout,
270
+ /**
271
+ * The address players connect to. Null for the node's `public_ip`. Servers already running keep
272
+ * the address their launch announced.
273
+ */
274
+ pinned_ip: Ipv4.nullable(),
275
+ /** Where the agent dials the controller. It redials when any part of it changes. */
276
+ controller: ControllerAddress,
277
+ /**
278
+ * Whether the node keeps itself up to date: CS2 on `cs2.latest_build_id`, and its agent and cs2
279
+ * images on the newest release of their major. When false, the node stays on `cs2.build_id` until
280
+ * an {@link ControllerUpdateCs2 | update_cs2}, and accepts launches while it is behind
281
+ * `cs2.latest_build_id`, so players on a newer client cannot join its servers. Its images then
282
+ * change only with {@link ControllerUpdateAgent | update_agent} and
283
+ * {@link ControllerUpdateCs2Image | update_cs2_image}.
284
+ */
285
+ auto_update: z.boolean(),
286
+ /**
287
+ * Where the agent uploads demos. Null for no uploads, which keeps demos on the node. The header's
288
+ * value is reported as its SHA-256.
289
+ */
290
+ demo_upload: ReportedDemoUpload.nullable(),
291
+ /**
292
+ * MiB of memory each game container may use. Null for no cap. A change applies to servers launched
293
+ * afterwards, and CS2 installs and updates are exempt.
294
+ *
295
+ * Rejected when it is not a whole number from {@link MIN_MEMORY_MB} to {@link MAX_MEMORY_MB}.
296
+ */
297
+ memory_mb: MemoryLimit.nullable(),
298
+ });
299
+ /**
300
+ * {@link NodeSettings} as {@link ControllerSetConfig | set_config} asks for them. A missing field keeps
301
+ * the node's value. Null is a value, as {@link NodeSettings} describes. `slots`, when present, is
302
+ * the whole layout.
303
+ */
304
+ export const NodeSettingsEdit = NodeSettings.extend({
305
+ /**
306
+ * The slots servers run in. {@link SlotEdit} describes their tokens.
307
+ *
308
+ * Rejected when two slots share an `id` or a `gslt`, or when two ports are equal. Every slot's
309
+ * `port` and `tv_port` count, so a slot's two ports differ too. It is also rejected when a port
310
+ * is {@link EVENT_RELAY_PORT}, where the agent listens for plugins. The agent fills in the kept
311
+ * tokens and refuses a layout in which two slots then share one.
312
+ */
313
+ slots: SlotEditLayout,
314
+ /**
315
+ * Where the agent uploads demos. Null for no uploads, which keeps demos on the node.
316
+ * {@link DemoUploadEdit} describes its header.
317
+ */
318
+ demo_upload: DemoUploadEdit.nullable(),
319
+ }).partial();
320
+ /** Longer os-release names are cut to this many characters. */
321
+ export const OS_NAME_MAX_CHARS = 256;
322
+ /** Longest demo file name a node reports, `.dem` included. */
323
+ export const DEMO_FILE_NAME_MAX_CHARS = 160;
324
+ /**
325
+ * A demo's file name, as it sits in its launch's directory, `demos/<allocation_id>/` in its slot.
326
+ */
327
+ export const DemoFileName = z.string().regex(new RegExp(`^[A-Za-z0-9._-]{1,${DEMO_FILE_NAME_MAX_CHARS - 4}}\\.dem$`), `must be a .dem file name of up to ${DEMO_FILE_NAME_MAX_CHARS} letters, digits, dots, dashes and underscores`);
328
+ /** Longest `upload_error` a demo carries. The agent cuts the failure's message to it. */
329
+ export const DEMO_UPLOAD_ERROR_MAX_CHARS = 512;
330
+ /** A demo kept on the node. */
331
+ export const DemoFile = z.object({
332
+ /** The slot whose directory holds it. */
333
+ slot_id: SlotId,
334
+ /** The file's name. */
335
+ file_name: DemoFileName,
336
+ /** Bytes in the file. */
337
+ size_bytes: z.number().int().nonnegative(),
338
+ /** Unix milliseconds when the file was last written. */
339
+ modified_at: z.number().int(),
340
+ /**
341
+ * Whether its server is still recording it. While it is, the demo grows with each report and is
342
+ * neither uploaded nor deleted.
343
+ */
344
+ recording: z.boolean(),
345
+ /**
346
+ * Whether it is uploading now. {@link AgentDemoProgress | demo_progress} reports how far, and a
347
+ * node uploads one demo at a time.
348
+ */
349
+ uploading: z.boolean(),
350
+ /**
351
+ * Why its upload failed or why it waits, up to {@link DEMO_UPLOAD_ERROR_MAX_CHARS} characters: a
352
+ * failure being retried, a refusal as invalid, or every demo held while the target refuses the
353
+ * node or has failed for a day. Null once an upload works again or the target changes.
354
+ */
355
+ upload_error: z.string().min(1).max(DEMO_UPLOAD_ERROR_MAX_CHARS).nullable(),
356
+ });
357
+ /** Longer reasons a node cannot run servers are cut to this many characters. */
358
+ export const NODE_UNAVAILABLE_DETAIL_MAX_CHARS = 512;
359
+ /** Free space a node needs on its data disk to take launches, in bytes. */
360
+ export const DISK_SPACE_MIN_BYTES = 1 << 30;
361
+ /** Why a node cannot run servers. */
362
+ export const NODE_UNAVAILABLE = {
363
+ /** The {@link MaintenanceOperation} in `operation` runs. It clears when the operation ends. */
364
+ maintenance: 'maintenance',
365
+ /**
366
+ * The agent cannot use the local store that keeps gameplay events until the controller takes
367
+ * them. `detail` says why.
368
+ * - If a call to the store waits past its limit, as on a disk that hangs, the code clears once
369
+ * the store answers. Deleting the store or restarting the agent loses what waits to be written.
370
+ * - If the agent cannot open the store, such as one that a newer agent wrote, a corrupt file or
371
+ * one it cannot write, the code lasts until a restart. Update the agent, or delete the store on
372
+ * the host and restart the agent. Deleting it loses the events and server exits it keeps.
373
+ */
374
+ eventStorage: 'event_storage',
375
+ /**
376
+ * The disk under the agent's data directory has less than {@link DISK_SPACE_MIN_BYTES} free, too
377
+ * little for a match's events and demo. The agent asks the disk every 30 seconds and before each
378
+ * launch, so the code clears within 30 seconds of space being freed, and a launch that arrives
379
+ * sooner is taken if the disk has room by then.
380
+ */
381
+ diskSpace: 'disk_space',
382
+ };
383
+ /** Why a node cannot run servers, one of the values of {@link NODE_UNAVAILABLE}. */
384
+ export const NodeUnavailableCode = z.enum(NODE_UNAVAILABLE);
385
+ /**
386
+ * The maintenance operation that runs on the node.
387
+ *
388
+ * - `cs2_update`: a CS2 install or update, also one an earlier agent started.
389
+ * - `agent_update`: an {@link ControllerUpdateAgent | update_agent}, until its replacement has proved it
390
+ * stays up.
391
+ * - `cs2_image_update`: an {@link ControllerUpdateCs2Image | update_cs2_image}.
392
+ * - `image_cleanup`: the agent's removal of replaced images when it starts.
393
+ */
394
+ export const MaintenanceOperation = z.enum(['cs2_update', 'agent_update', 'cs2_image_update', 'image_cleanup']);
395
+ /**
396
+ * Why the node cannot run servers now, with `maintenance` first while it lasts. Meanwhile
397
+ * {@link ControllerStartServer | start_server} is refused with `maintenance_running` for `maintenance` and
398
+ * `node_unavailable` otherwise. Everything else works.
399
+ *
400
+ * Rejected when `operation` is null while `code` is `maintenance`, or set while `code` is another
401
+ * value.
402
+ */
403
+ export const NodeUnavailable = z.object({
404
+ code: NodeUnavailableCode,
405
+ /**
406
+ * The maintenance operation that runs when `code` is `maintenance`. Null for any other `code`.
407
+ */
408
+ operation: MaintenanceOperation.nullable(),
409
+ /** The agent's message, up to {@link NODE_UNAVAILABLE_DETAIL_MAX_CHARS} characters. */
410
+ detail: z.string().min(1).max(NODE_UNAVAILABLE_DETAIL_MAX_CHARS),
411
+ }).superRefine((unavailable, ctx) => {
412
+ if ((unavailable.code === NODE_UNAVAILABLE.maintenance) !== (unavailable.operation !== null)) {
413
+ ctx.addIssue({ code: 'custom', path: ['operation'], message: 'names the operation exactly when the code is maintenance' });
414
+ }
415
+ });
416
+ /** The running agent. */
417
+ export const AgentInfo = z.object({
418
+ /** The agent package version. */
419
+ version: z.string().min(1).max(64),
420
+ /** The image this agent's container was created from. */
421
+ image: ImageReference,
422
+ /** The id Docker holds for that image. Two builds of one tag differ here. */
423
+ image_id: ImageId,
424
+ });
425
+ /** Longest `detail` an agent update carries. The agent cuts the swap's message to it. */
426
+ export const AGENT_UPDATE_DETAIL_MAX_CHARS = 512;
427
+ /**
428
+ * Where an {@link ControllerUpdateAgent | update_agent} or {@link ControllerUpdateCs2Image | update_cs2_image}
429
+ * is.
430
+ *
431
+ * - `pulling`: the image is being pulled.
432
+ * - `switching`: the image is on the node and is put into use. A switch of the agent replaces its
433
+ * container, so the node goes offline and comes back. A switch of the cs2 image extracts its
434
+ * plugin files for future launches.
435
+ */
436
+ export const ImageUpdateStep = z.enum(['pulling', 'switching']);
437
+ /**
438
+ * How an {@link ControllerUpdateAgent | update_agent} ended.
439
+ *
440
+ * - `completed`: the replacement agent stayed up and the old container is removed.
441
+ * - `unchanged`: the node already ran that image, so nothing was swapped.
442
+ * - `pull_failed`: the image could not be pulled, so the node never tried it.
443
+ * - `failed`: the swap did not hold and the old agent was put back, or its helper did not start.
444
+ */
445
+ export const AgentUpdateOutcome = z.enum(['completed', 'unchanged', 'pull_failed', 'failed']);
446
+ /** An agent update: the one in progress, one this agent saw end, or the last one to fail. */
447
+ export const AgentUpdate = z.object({
448
+ /** The agent image the update was to. */
449
+ image: ImageReference,
450
+ /**
451
+ * Where the update is while it runs, with the replacement still on trial in `switching`. Null
452
+ * once it ended.
453
+ */
454
+ step: ImageUpdateStep.nullable(),
455
+ /** How the update ended. Null while it runs. */
456
+ outcome: AgentUpdateOutcome.nullable(),
457
+ /**
458
+ * Why the update failed, up to {@link AGENT_UPDATE_DETAIL_MAX_CHARS} characters. Null unless it
459
+ * failed.
460
+ */
461
+ detail: z.string().min(1).max(AGENT_UPDATE_DETAIL_MAX_CHARS).nullable(),
462
+ });
463
+ /** The machine the agent runs on. */
464
+ export const HostInfo = z.object({
465
+ /** Logical CPUs the agent can use. */
466
+ cpu_count: z.number().int().positive(),
467
+ /**
468
+ * The host's os-release `PRETTY_NAME`, up to {@link OS_NAME_MAX_CHARS} characters. Null when
469
+ * unreadable.
470
+ */
471
+ os_name: z.string().min(1).max(OS_NAME_MAX_CHARS).nullable(),
472
+ /** The kernel release, which the agent's container shares with the host. */
473
+ kernel: z.string().min(1).max(256),
474
+ });
475
+ /**
476
+ * How thoroughly a CS2 install or update runs.
477
+ *
478
+ * - `update`: downloads what changed.
479
+ * - `verify`: also re-hashes every file.
480
+ * - `reinstall`: wipes the install volume first and downloads all of it.
481
+ */
482
+ export const Cs2UpdateMode = z.enum(['update', 'verify', 'reinstall']);
483
+ /**
484
+ * How a CS2 install or update ended.
485
+ *
486
+ * - `completed`: the game files are installed.
487
+ * - `pull_failed`: the cs2 image could not be pulled.
488
+ * - `overlay_failed`: its addon overlay could not be extracted.
489
+ * - `failed`: any other failure, such as the update container exiting with an error.
490
+ * - `unobserved`: an update container that an earlier agent process started exited, and the agent
491
+ * did not see the exit.
492
+ *
493
+ * An update container that an earlier agent process started reports progress again until it exits.
494
+ * It ends `completed` or `failed` by its exit code, or `unobserved`.
495
+ */
496
+ export const Cs2UpdateOutcome = z.enum(['completed', 'pull_failed', 'overlay_failed', 'failed', 'unobserved']);
497
+ /** Longest `detail` a CS2 update carries. The agent cuts the failure's message to it. */
498
+ export const CS2_UPDATE_DETAIL_MAX_CHARS = 512;
499
+ /**
500
+ * Chosen by the agent for each CS2 install or update it runs or finds running. A lowercase UUID.
501
+ */
502
+ export const UpdateId = z.uuid().regex(/^[^A-F]*$/, 'must be lowercase');
503
+ /**
504
+ * A CS2 install or update on the node: one running, or the last one to end since the agent started.
505
+ */
506
+ export const Cs2Update = z.object({
507
+ /**
508
+ * Identifies the update and its output, the `cs2_update` {@link LogSource} with this `update_id`.
509
+ */
510
+ id: UpdateId,
511
+ /** How the update runs. */
512
+ mode: Cs2UpdateMode,
513
+ /**
514
+ * Whether the agent started the update on its own, to reach the newest public build. When false,
515
+ * an {@link ControllerUpdateCs2 | update_cs2} started it.
516
+ */
517
+ automatic: z.boolean(),
518
+ /** How the update ended. Null while it runs. */
519
+ outcome: Cs2UpdateOutcome.nullable(),
520
+ /**
521
+ * Why the update failed, up to {@link CS2_UPDATE_DETAIL_MAX_CHARS} characters. Null unless it
522
+ * failed.
523
+ */
524
+ detail: z.string().min(1).max(CS2_UPDATE_DETAIL_MAX_CHARS).nullable(),
525
+ });
526
+ /**
527
+ * How an {@link ControllerUpdateCs2Image | update_cs2_image} ended.
528
+ *
529
+ * - `completed`: future launches use the image.
530
+ * - `pull_failed`: the image could not be pulled.
531
+ * - `overlay_failed`: its addon overlay could not be extracted.
532
+ * - `failed`: anything else, such as the agent not saving the image setting.
533
+ */
534
+ export const Cs2ImageUpdateOutcome = z.enum(['completed', 'pull_failed', 'overlay_failed', 'failed']);
535
+ /**
536
+ * A switch of the cs2 image: the one running now, or the last one to end since the agent started.
537
+ */
538
+ export const Cs2ImageUpdate = z.object({
539
+ /** The image the switch is to. */
540
+ image: ImageReference,
541
+ /** Where the switch is while it runs. Null once it ended. */
542
+ step: ImageUpdateStep.nullable(),
543
+ /** How the switch ended. Null while it runs. */
544
+ outcome: Cs2ImageUpdateOutcome.nullable(),
545
+ /**
546
+ * Why the switch failed, up to {@link CS2_UPDATE_DETAIL_MAX_CHARS} characters. Null unless it
547
+ * failed.
548
+ */
549
+ detail: z.string().min(1).max(CS2_UPDATE_DETAIL_MAX_CHARS).nullable(),
550
+ });
551
+ /** The node's game install and image. */
552
+ export const Cs2State = z.object({
553
+ /** The installed game build. Null before the first install. */
554
+ build_id: BuildId.nullable(),
555
+ /**
556
+ * The newest public CS2 build the agent read from Steam. Null until its first successful read.
557
+ * The agent updates an installed node to it on its own once every slot is idle, unless the
558
+ * node's `auto_update` is `false`.
559
+ */
560
+ latest_build_id: BuildId.nullable(),
561
+ /** The cs2 image set in `agent.yml`, which future launches use. */
562
+ image: ImageReference,
563
+ /** The local id that `image` resolves to on the node. Null until the image is there. */
564
+ image_id: ImageId.nullable(),
565
+ /**
566
+ * The install or update that runs now, or else the last one to end since the agent started. Null
567
+ * before any. An agent that starts while an update that it started earlier still runs reports
568
+ * that update.
569
+ */
570
+ update: Cs2Update.nullable(),
571
+ /**
572
+ * The switch of `image` that runs now, or else the last one to end since the agent started. Null
573
+ * before any.
574
+ */
575
+ image_update: Cs2ImageUpdate.nullable(),
576
+ });
577
+ /** An ISO 3166-1 alpha-2 country code: `DK`, `FR`, or `GB` for the United Kingdom. */
578
+ export const CountryCode = z.string().regex(/^[A-Z]{2}$/);
579
+ /** Longest `detail` a server's exit carries. The agent cuts the message to it. */
580
+ export const SERVER_EXIT_DETAIL_MAX_CHARS = 512;
581
+ /** A server whose container is gone, and why. */
582
+ export const ServerExit = z.object({
583
+ /** The slot it ran in. */
584
+ slot_id: SlotId,
585
+ /** The server that ended. */
586
+ allocation_id: AllocationId,
587
+ /** Why it ended. */
588
+ reason: ServerExitReason,
589
+ /** The container's exit code. Null when the agent did not see one. */
590
+ exit_code: z.number().int().nullable(),
591
+ /**
592
+ * What the reason does not say, up to {@link SERVER_EXIT_DETAIL_MAX_CHARS} characters: the
593
+ * plugin's `config_error` for `config_rejected`, and Docker's message for `launch_failed`. Null
594
+ * for every other reason.
595
+ */
596
+ detail: z.string().min(1).max(SERVER_EXIT_DETAIL_MAX_CHARS).nullable(),
597
+ });
598
+ /** Longest `reason` an event backlog carries. The agent cuts the failure's message to it. */
599
+ export const EVENT_BACKLOG_REASON_MAX_CHARS = 512;
600
+ /** A server whose events wait on the node. */
601
+ export const StuckServer = z.object({
602
+ /** The server whose plugin sent them. */
603
+ allocation_id: AllocationId,
604
+ /** Events of the server still queued on the node. */
605
+ pending: z.number().int().positive(),
606
+ });
607
+ const eventBacklogFields = {
608
+ /**
609
+ * The servers with the oldest stuck events, oldest first, up to {@link MAX_REPORTED_BACKLOGS}.
610
+ */
611
+ servers: z.array(StuckServer).max(MAX_REPORTED_BACKLOGS),
612
+ /** Servers with stuck events on the node, those past `servers` too. */
613
+ server_count: z.number().int().nonnegative(),
614
+ /** Events that every stuck server holds together. */
615
+ event_count: z.number().int().nonnegative(),
616
+ /**
617
+ * Why the last upload failed, up to {@link EVENT_BACKLOG_REASON_MAX_CHARS} characters. Null when
618
+ * nothing is stuck, and only then.
619
+ */
620
+ reason: z.string().min(1).max(EVENT_BACKLOG_REASON_MAX_CHARS).nullable(),
621
+ };
622
+ function addBacklogIssues(backlog, ctx) {
623
+ if (backlog.servers.length !== Math.min(backlog.server_count, MAX_REPORTED_BACKLOGS)) {
624
+ ctx.addIssue({ code: 'custom', path: ['servers'], message: `lists the oldest ${MAX_REPORTED_BACKLOGS} of server_count` });
625
+ }
626
+ if (backlog.event_count < backlog.servers.reduce((sum, server) => sum + server.pending, 0)) {
627
+ ctx.addIssue({ code: 'custom', path: ['event_count'], message: 'counts at least the listed servers\' events' });
628
+ }
629
+ if ((backlog.server_count === 0) !== (backlog.reason === null)) {
630
+ ctx.addIssue({ code: 'custom', path: ['reason'], message: 'null exactly when nothing is stuck' });
631
+ }
632
+ }
633
+ /**
634
+ * The events that wait on the node because uploading them fails, as the agent read them. While
635
+ * uploads work, nothing is stuck, so `servers` is empty, the counts are zero and `reason` is
636
+ * `null`.
637
+ *
638
+ * Rejected unless `servers` holds as many entries as `server_count`, or
639
+ * {@link MAX_REPORTED_BACKLOGS} when `server_count` is larger. It is also rejected when
640
+ * `event_count` is below the sum of `pending` over `servers`. A `reason` is rejected when
641
+ * `server_count` is zero, and a null `reason` when it is not.
642
+ */
643
+ export const EventBacklog = z.object(eventBacklogFields).superRefine(addBacklogIssues);
644
+ /**
645
+ * A server has ended and its container is gone. Sent ahead of the `idle`
646
+ * {@link AgentSlotStatus | slot_status} of its slot, also for a launch that failed before or while
647
+ * its container started.
648
+ *
649
+ * A server that ended while no connection was welcomed goes in `server_exits` of the next
650
+ * {@link AgentHello | hello} instead. If it ended after that {@link AgentHello | hello} went out,
651
+ * it is sent after the {@link ControllerWelcome | welcome}, ahead of the slot statuses.
652
+ *
653
+ * The agent keeps a {@link AgentServerExited | server_exited} until the socket takes its write,
654
+ * across its restarts, for up to {@link EVENT_SPOOL_RETENTION_MS}, so one may also come again after
655
+ * a restart. One the socket took is not sent again, even if the connection drops before the
656
+ * controller reads it. The agent does not keep them while it cannot open that store, which it reports as
657
+ * `unavailable` with `event_storage`. While the store does not answer, the agent keeps one only
658
+ * once the store answers again. A server that ends while the agent is not running is missing
659
+ * from its next {@link AgentHello | hello}.
660
+ */
661
+ export const AgentServerExited = ServerExit.extend({
662
+ type: z.literal('server_exited'),
663
+ });
664
+ /**
665
+ * The agent's first message on every connection, and sent only then. An applied
666
+ * {@link ControllerSetConfig | set_config} answers with the settings instead.
667
+ *
668
+ * Answer an accepted {@link AgentHello | hello} with {@link ControllerWelcome | welcome}. Until then the
669
+ * agent sends only replies, or a {@link AgentShutdown | shutdown} when it stops. Runtime changes
670
+ * use status messages. Reconnecting never changes the node's configuration.
671
+ *
672
+ * Rejected unless `slot_status` names each slot of `config.slots` once and names no other slot.
673
+ */
674
+ export const AgentHello = z.object({
675
+ type: z.literal('hello'),
676
+ /** The agent sending this. */
677
+ agent: AgentInfo,
678
+ /** The host the agent runs on. */
679
+ host: HostInfo,
680
+ /**
681
+ * The node's public IPv4 address as the agent looked it up at start. Null when the lookup failed,
682
+ * and players then reach the node only at `config.pinned_ip`. Players connect to
683
+ * `config.pinned_ip` whenever it is set. The agent looks again only when it restarts.
684
+ */
685
+ public_ip: Ipv4.nullable(),
686
+ /**
687
+ * Where the node's address is, as a geolocation lookup reports it when the agent starts. Null
688
+ * when that lookup failed. The agent tries again only when it restarts.
689
+ */
690
+ country: CountryCode.nullable(),
691
+ /** The configuration the agent runs with. */
692
+ config: NodeSettings,
693
+ /** One per configured slot. */
694
+ slot_status: z.array(SlotStatus).max(MAX_SLOTS),
695
+ /** The game install and image. */
696
+ cs2: Cs2State,
697
+ /**
698
+ * Why the node cannot run servers. Null when it can. Changes arrive as
699
+ * {@link AgentNodeStatus | node_status}.
700
+ */
701
+ unavailable: NodeUnavailable.nullable(),
702
+ /**
703
+ * The demos kept on the node, up to {@link MAX_REPORTED_DEMOS}. {@link AgentDemo | demo} reports
704
+ * each change afterwards.
705
+ */
706
+ demos: z.array(DemoFile).max(MAX_REPORTED_DEMOS),
707
+ /**
708
+ * Servers that ended while no connection was welcomed, oldest first, each with its reason. Their
709
+ * slots already show idle here, so a {@link AgentServerExited | server_exited} after the
710
+ * {@link ControllerWelcome | welcome} would come too late. It carries the oldest {@link MAX_SLOTS} at
711
+ * most, and the rest follow as {@link AgentServerExited | server_exited} after the
712
+ * {@link ControllerWelcome | welcome}.
713
+ */
714
+ server_exits: z.array(ServerExit).max(MAX_SLOTS),
715
+ /**
716
+ * The node's event backlog when the {@link AgentHello | hello} was built.
717
+ * {@link AgentEventBacklog | event_backlog} replaces it.
718
+ */
719
+ event_backlog: EventBacklog,
720
+ /**
721
+ * The swap in progress, or else the one this agent saw end, or else the last that failed. A
722
+ * failure is kept, across agent restarts, until a swap holds or `node-setup.sh` starts the agent.
723
+ * Null when there is none of these. A swap that held shows in `agent.image`.
724
+ */
725
+ agent_update: AgentUpdate.nullable(),
726
+ }).superRefine((hello, ctx) => {
727
+ const slots = new Set(hello.config.slots.map((slot) => slot.id));
728
+ const seen = new Set();
729
+ for (const [index, status] of hello.slot_status.entries()) {
730
+ if (!slots.has(status.slot_id) || seen.has(status.slot_id)) {
731
+ ctx.addIssue({ code: 'custom', path: ['slot_status', index, 'slot_id'], message: 'slot_status names each configured slot once' });
732
+ }
733
+ seen.add(status.slot_id);
734
+ }
735
+ if (seen.size !== slots.size) {
736
+ ctx.addIssue({ code: 'custom', path: ['slot_status'], message: 'slot_status names every configured slot' });
737
+ }
738
+ });
739
+ /**
740
+ * Replaces `cs2`, `unavailable` and `agent_update` of the last {@link AgentHello | hello}.
741
+ *
742
+ * Sent when they change:
743
+ *
744
+ * - When a maintenance operation starts or ends, so also when a CS2 install or update finishes,
745
+ * whoever started it, or {@link ControllerUpdateCs2Image | update_cs2_image} has switched the image.
746
+ * - When `agent_update` or `cs2.image_update` moves from `pulling` to `switching`.
747
+ * - When the newest public build changes.
748
+ * - When an agent swap that is still proving its replacement ends.
749
+ *
750
+ * Each frame is the node's state when it was read, not a step in a history, so a change that is
751
+ * over before its report reads the state may go unreported.
752
+ */
753
+ export const AgentNodeStatus = z.object({
754
+ type: z.literal('node_status'),
755
+ /** The game install and image. */
756
+ cs2: Cs2State,
757
+ /** Why the node cannot run servers. Null when it can. */
758
+ unavailable: NodeUnavailable.nullable(),
759
+ /** The last agent swap, as {@link AgentHello | hello} has it. */
760
+ agent_update: AgentUpdate.nullable(),
761
+ });
762
+ /**
763
+ * A slot's status changed since the last {@link AgentHello | hello} or
764
+ * {@link AgentSlotStatus | slot_status} for it. Rejected when it breaks a rule of
765
+ * {@link SlotStatus}.
766
+ */
767
+ export const AgentSlotStatus = z.object({
768
+ type: z.literal('slot_status'),
769
+ ...slotStatusFields,
770
+ }).superRefine(addSlotStatusIssues);
771
+ /** How serious an agent log line is. */
772
+ export const AgentLogLevel = z.enum(['info', 'warn', 'error']);
773
+ /** Longer log lines, from the agent, a server or a CS2 update, are cut to this many characters. */
774
+ export const LOG_LINE_MAX_CHARS = 2_000;
775
+ /** The agent keeps this many lines of its log. */
776
+ export const AGENT_LOG_LINES = 500;
777
+ /**
778
+ * The agent keeps this many lines of each server's output and of each CS2 install or update's
779
+ * output.
780
+ */
781
+ export const SERVER_LOG_LINES = 2_000;
782
+ /** Servers that ended and whose output the agent still keeps, oldest dropped first. */
783
+ export const ENDED_SERVER_LOGS = 10;
784
+ /**
785
+ * CS2 installs and updates that ended and whose output the agent still keeps, oldest dropped first.
786
+ */
787
+ export const ENDED_UPDATE_LOGS = 1;
788
+ /**
789
+ * A {@link AgentLogBatch | log_batch} stays under this many bytes of JSON, so a replay spans as
790
+ * many frames as it needs.
791
+ */
792
+ export const LOG_BATCH_MAX_BYTES = 256 * 1024;
793
+ /**
794
+ * One log of a node: the agent's log, one CS2 install or update's output, or one server's output.
795
+ */
796
+ export const LogSource = z.discriminatedUnion('kind', [
797
+ z.object({ kind: z.literal('agent') }),
798
+ z.object({
799
+ kind: z.literal('cs2_update'),
800
+ /** The install or update that printed it, the `id` of its `cs2.update`. */
801
+ update_id: UpdateId,
802
+ }),
803
+ z.object({
804
+ kind: z.literal('server'),
805
+ /** The server that printed it. */
806
+ allocation_id: AllocationId,
807
+ }),
808
+ ]);
809
+ /** One log line. */
810
+ export const LogLine = z.object({
811
+ /** Unix milliseconds when the agent logged or read the line. */
812
+ at: z.number().int().nonnegative(),
813
+ /** How serious the agent rated the line. Null for server and CS2 update output. */
814
+ level: AgentLogLevel.nullable(),
815
+ /**
816
+ * The line without its newline. The agent's lines carry the prefix of the component that logged
817
+ * them. A line keeps its ANSI colour escapes: SGR, `ESC[` with digits and `;`, then `m`. The agent
818
+ * removes every other escape, every control character but tab, and the Unicode characters that
819
+ * reorder text or break a line. The length limit may cut the last escape short.
820
+ */
821
+ line: z.string().max(LOG_LINE_MAX_CHARS),
822
+ });
823
+ /** Identifies one watcher's replay, so the frames answering it reach only that watcher. */
824
+ export const ReplayId = z.string().min(1).max(64);
825
+ /**
826
+ * One source's lines, sent only while a {@link ControllerWatchLogs | watch_logs} holds the lease of that
827
+ * source. A replay of the source starts with `reset` and may span several frames. Between replays,
828
+ * new lines follow in batches. A replay larger than the remaining log budget of the agent leaves
829
+ * out the oldest lines.
830
+ */
831
+ export const AgentLogBatch = z.object({
832
+ type: z.literal('log_batch'),
833
+ /** The log these lines belong to. */
834
+ source: LogSource,
835
+ /**
836
+ * The {@link ControllerWatchLogs | watch_logs} replay these frames answer, for that watcher alone. Null
837
+ * for new lines and for a replay sent to every watcher.
838
+ */
839
+ replay_id: ReplayId.nullable(),
840
+ /** Whether a replay starts, so the controller drops every line it holds of this source first. */
841
+ reset: z.boolean(),
842
+ /** The lines, oldest first. */
843
+ lines: z.array(LogLine),
844
+ });
845
+ /** One source the agent keeps. */
846
+ export const LogSourceSummary = z.object({
847
+ /** Which log. */
848
+ source: LogSource,
849
+ /** Lines kept of it. */
850
+ line_count: z.number().int().nonnegative(),
851
+ /** Unix milliseconds of the newest line kept of it. Null for a source with no lines kept. */
852
+ last_line_at: z.number().int().nonnegative().nullable(),
853
+ /**
854
+ * Whether the source has ended, as a server whose slot went idle or a CS2 install or update that
855
+ * ended. Always `false` for the agent's log.
856
+ */
857
+ ended: z.boolean(),
858
+ });
859
+ /**
860
+ * Milliseconds between {@link AgentLogIndex | log_index} frames that only bring counts and
861
+ * `last_line_at` up to date while lines arrive. A source's newest line shows in an index within
862
+ * this long.
863
+ */
864
+ export const LOG_INDEX_REFRESH_MS = 30_000;
865
+ /**
866
+ * Every source the agent keeps, whole every time: its log, each CS2 install or update running or
867
+ * among the last {@link ENDED_UPDATE_LOGS} ended, and each server with output, running or among the
868
+ * last {@link ENDED_SERVER_LOGS} ended.
869
+ *
870
+ * Sent while any log lease runs, with each lease that starts and each replay asked for. Also sent
871
+ * when an update starts or ends, a server's first line arrives, a server ends, or a source's output
872
+ * is dropped, and every {@link LOG_INDEX_REFRESH_MS} while lines arrive. Counts and `last_line_at`
873
+ * are as of that frame. A source missing from it is no longer kept.
874
+ */
875
+ export const AgentLogIndex = z.object({
876
+ type: z.literal('log_index'),
877
+ /**
878
+ * The agent's log first, then CS2 updates in the order they started, then servers in the order
879
+ * their output began.
880
+ */
881
+ sources: z.array(LogSourceSummary).max(2 + ENDED_UPDATE_LOGS + MAX_SLOTS + ENDED_SERVER_LOGS),
882
+ });
883
+ /** Longest `message` a CS2 update's progress carries. The agent cuts the line to it. */
884
+ export const CS2_UPDATE_MESSAGE_MAX_CHARS = 512;
885
+ /**
886
+ * Progress of a CS2 install or update while it runs. `cs2.update` in
887
+ * {@link AgentNodeStatus | node_status} says how it ended.
888
+ */
889
+ export const AgentCs2UpdateProgress = z.object({
890
+ type: z.literal('cs2_update_progress'),
891
+ /**
892
+ * The update it reports on, as `cs2.update.id`. A frame can arrive before the
893
+ * {@link AgentNodeStatus | node_status} that names the update.
894
+ */
895
+ update_id: UpdateId,
896
+ /** Percent done, as `steamcmd` reports it. Null when `steamcmd` reports none. */
897
+ percent: z.number().min(0).max(100).nullable(),
898
+ /** What the install is doing, up to {@link CS2_UPDATE_MESSAGE_MAX_CHARS} characters. */
899
+ message: z.string().max(CS2_UPDATE_MESSAGE_MAX_CHARS),
900
+ });
901
+ /**
902
+ * One demo as it is now, sent when it appears or changes: its file, its upload starting or
903
+ * stopping, or its `upload_error`. It replaces the demo with the same `slot_id` and `file_name`, or
904
+ * adds it. {@link AgentHello | hello} lists the newest {@link MAX_REPORTED_DEMOS}. A demo that
905
+ * appears after the {@link AgentHello | hello} is added, however many the node keeps.
906
+ */
907
+ export const AgentDemo = DemoFile.extend({
908
+ type: z.literal('demo'),
909
+ });
910
+ /**
911
+ * Why a demo is no longer on the node.
912
+ *
913
+ * - `uploaded`: the target took it and the agent removed it.
914
+ * - `deleted`: {@link ControllerDeleteDemo | delete_demo} or anything else removed it.
915
+ */
916
+ export const DemoRemovedReason = z.enum(['uploaded', 'deleted']);
917
+ /** A demo that the controller knows of is no longer on the node. */
918
+ export const AgentDemoRemoved = z.object({
919
+ type: z.literal('demo_removed'),
920
+ /** The slot whose directory held it. */
921
+ slot_id: SlotId,
922
+ /** The file's name. */
923
+ file_name: DemoFileName,
924
+ /** Why it is no longer on the node. */
925
+ reason: DemoRemovedReason,
926
+ });
927
+ /** How far the uploading demo is, at most once a second while it uploads. */
928
+ export const AgentDemoProgress = z.object({
929
+ type: z.literal('demo_progress'),
930
+ /** The slot whose directory holds it. */
931
+ slot_id: SlotId,
932
+ /** The file's name. */
933
+ file_name: DemoFileName,
934
+ /** Bytes sent so far. */
935
+ sent_bytes: z.number().int().nonnegative(),
936
+ /**
937
+ * Bytes a second since the previous {@link AgentDemoProgress | demo_progress} of this upload, or
938
+ * since it began.
939
+ */
940
+ bytes_per_second: z.number().int().nonnegative(),
941
+ });
942
+ /**
943
+ * Milliseconds between the agent's pings to the controller. The agent drops the connection when
944
+ * the last ping went unanswered.
945
+ */
946
+ export const AGENT_PING_INTERVAL_MS = 30_000;
947
+ /**
948
+ * Milliseconds an agent may send nothing before the controller drops it, which allows two pings
949
+ * and slack.
950
+ */
951
+ export const AGENT_PING_TIMEOUT_MS = 65_000;
952
+ /** Milliseconds each agent dial has to finish the WebSocket upgrade. */
953
+ export const AGENT_HANDSHAKE_TIMEOUT_MS = 10_000;
954
+ /**
955
+ * Milliseconds of the agent's first redial delay. Each failed attempt doubles it, and a
956
+ * {@link ControllerWelcome | welcome} or a redial to a new address resets it.
957
+ */
958
+ export const AGENT_RECONNECT_MIN_MS = 1_000;
959
+ /** Milliseconds of the agent's longest redial delay, before jitter. */
960
+ export const AGENT_RECONNECT_MAX_MS = 30_000;
961
+ /**
962
+ * Each redial delay is randomised by up to this fraction either way, never below
963
+ * {@link AGENT_RECONNECT_MIN_MS}.
964
+ */
965
+ export const AGENT_RECONNECT_JITTER = 0.25;
966
+ /** Milliseconds between the agent's resource ticks, taken while it runs, watched or not. */
967
+ export const RESOURCE_TICK_MS = 3_000;
968
+ /** Ticks that the agent holds and the controller shows per node, the newest last. */
969
+ export const RESOURCE_TICKS_HELD = 100;
970
+ /**
971
+ * Milliseconds a {@link ControllerWatchResources | watch_resources} or {@link ControllerWatchLogs | watch_logs}
972
+ * keeps its stream flowing. The next one resets it.
973
+ */
974
+ export const WATCH_LEASE_MS = 120_000;
975
+ /** A capacity and how much of it is in use, in bytes. */
976
+ export const ResourceUsage = z.object({
977
+ /** Bytes of capacity. */
978
+ cap: z.number().int().nonnegative(),
979
+ /** Bytes in use. */
980
+ used: z.number().int().nonnegative(),
981
+ });
982
+ /** One sample of the node's disk, memory and CPU, taken every {@link RESOURCE_TICK_MS}. */
983
+ export const ResourceTick = z.object({
984
+ /** Unix milliseconds when the tick was taken. */
985
+ at: z.number().int().nonnegative(),
986
+ /**
987
+ * The filesystem holding the node's data directory, as `df` counts it. Null when it could not be
988
+ * read.
989
+ */
990
+ disk: ResourceUsage.nullable(),
991
+ /**
992
+ * The memory the node may use, and how much it uses. Cache the kernel can free counts as free.
993
+ */
994
+ memory: ResourceUsage,
995
+ /**
996
+ * How busy the node's CPUs are, in percent of all the CPUs it may use. Null in the first tick.
997
+ */
998
+ cpu: z.object({
999
+ /** Percent busy at the busiest moment of the tick. */
1000
+ max: z.number().int().min(0).max(100),
1001
+ /** Percent busy on average over the tick. */
1002
+ average: z.number().int().min(0).max(100),
1003
+ }).nullable(),
1004
+ });
1005
+ /**
1006
+ * The agent's resource ticks, oldest first. A {@link ControllerWatchResources | watch_resources} lease
1007
+ * that starts receives every tick the agent holds, then each new tick while it runs. The agent
1008
+ * sends nothing while nobody watches, and nothing is stored.
1009
+ */
1010
+ export const AgentResourceBatch = z.object({
1011
+ type: z.literal('resource_batch'),
1012
+ /**
1013
+ * The ticks, oldest first: every tick the agent holds, up to the last
1014
+ * {@link RESOURCE_TICKS_HELD}, or the ticks since the previous frame.
1015
+ */
1016
+ ticks: z.array(ResourceTick).min(1).max(RESOURCE_TICKS_HELD),
1017
+ /**
1018
+ * Whether `ticks` is every tick the agent holds and replaces the ticks the controller holds. It
1019
+ * is `true` when a {@link ControllerWatchResources | watch_resources} starts the lease or asks for a
1020
+ * replay, and on the first tick after each accepted {@link AgentHello | hello} while the lease
1021
+ * runs. When false, `ticks` adds to them.
1022
+ */
1023
+ reset: z.boolean(),
1024
+ });
1025
+ /**
1026
+ * The node's {@link EventBacklog} now, replacing the backlog of the last {@link AgentHello | hello}
1027
+ * or {@link AgentEventBacklog | event_backlog}. Sent when it changes: after each failed upload, as
1028
+ * its counts grow, and once, with nothing stuck, when uploads work again. Rejected when it breaks a
1029
+ * rule of {@link EventBacklog}.
1030
+ */
1031
+ export const AgentEventBacklog = z.object({
1032
+ type: z.literal('event_backlog'),
1033
+ ...eventBacklogFields,
1034
+ }).superRefine(addBacklogIssues);
1035
+ /**
1036
+ * Chosen by the controller. The reply repeats it as `reply_to`. While a request is running, the
1037
+ * agent ignores another with the same id on that connection, because a reply could not tell the two
1038
+ * apart.
1039
+ */
1040
+ const RequestId = z.string().min(1).max(64);
1041
+ /**
1042
+ * Milliseconds a stopping agent waits for the requests it is running before it closes the socket,
1043
+ * counted from its {@link AgentShutdown | shutdown}. A request still running then is cut off.
1044
+ */
1045
+ export const SHUTDOWN_REQUEST_WAIT_MS = 5_000;
1046
+ /**
1047
+ * The agent is stopping cleanly and starts no new work. It answers a request that arrives after
1048
+ * this frame with `shutting_down` at once. Replies to the requests it is running may still follow
1049
+ * for up to {@link SHUTDOWN_REQUEST_WAIT_MS}, and then it closes with code `1000`.
1050
+ *
1051
+ * Send no new requests after it. A request without a reply when the socket closes may or may not
1052
+ * have run; the node's reports after it reconnects say which. Its servers keep running, and the
1053
+ * agent takes them over when it returns. It is sent whether or not the controller has welcomed the
1054
+ * {@link AgentHello | hello}.
1055
+ */
1056
+ export const AgentShutdown = z.object({
1057
+ type: z.literal('shutdown'),
1058
+ /** The signal that stopped the agent. */
1059
+ signal: z.enum(['SIGINT', 'SIGTERM', 'SIGQUIT']),
1060
+ });
1061
+ const request = {
1062
+ /**
1063
+ * Chosen by the controller and unique among its open requests. The reply repeats it as
1064
+ * `reply_to`. The agent ignores a request whose id is still running on the connection.
1065
+ */
1066
+ id: RequestId,
1067
+ };
1068
+ /**
1069
+ * Launch a server on an idle slot the agent picks. The server logs in with the slot's token. The
1070
+ * agent reports the slot `starting` before any container work, so two launches never share one.
1071
+ *
1072
+ * The reply comes once the container has started, before the server is up, and
1073
+ * {@link AgentSlotStatus | slot_status} reports it `running` later. The agent pulls no image here.
1074
+ * A launch on a `cs2_image` whose plugin files the agent has not extracted yet waits while it does.
1075
+ * Refused with:
1076
+ *
1077
+ * - A {@link StartRefusal} when the node cannot take the launch.
1078
+ * - `allocation_in_use` when the node is already launching the allocation or its server holds a
1079
+ * slot.
1080
+ * - `allocation_reused` when the node launched the allocation before.
1081
+ * - `failed` when Docker fails the launch, once its container is gone, after a
1082
+ * {@link AgentServerExited | server_exited} with `launch_failed`.
1083
+ */
1084
+ export const ControllerStartServer = z.object({
1085
+ type: z.literal('start_server'),
1086
+ ...request,
1087
+ /**
1088
+ * Identifies the server from now on. Every launch needs a new one, a relaunch too, because the
1089
+ * node files the server's events and demos under it. A launch that a {@link StartRefusal} turned
1090
+ * away used nothing, so the same id may be sent again. Any other failure uses it up.
1091
+ */
1092
+ allocation_id: AllocationId,
1093
+ /** What to launch. */
1094
+ launch: ServerLaunchConfig,
1095
+ });
1096
+ /** The server that started, and the slot the agent picked for it. */
1097
+ export const StartServerReply = z.object({
1098
+ /** The allocation the request launched. */
1099
+ allocation_id: AllocationId,
1100
+ /** The slot the server runs in. */
1101
+ slot_id: SlotId,
1102
+ });
1103
+ /** Seconds of grace before `SIGKILL` for an ordinary stop. */
1104
+ export const STOP_TIMEOUT_SECONDS = 10;
1105
+ /**
1106
+ * Milliseconds the agent waits for an answer from the Docker daemon on its host before it fails the
1107
+ * call. A stop waits its grace on top.
1108
+ */
1109
+ export const DOCKER_ANSWER_TIMEOUT_MS = 30_000;
1110
+ /**
1111
+ * Seconds a stop's reply may take beyond its grace: Docker's answer to the stop, which the agent
1112
+ * waits for up to {@link DOCKER_ANSWER_TIMEOUT_MS}, and the reply.
1113
+ */
1114
+ export const STOP_REPLY_MARGIN_SECONDS = 35;
1115
+ /**
1116
+ * Stop an allocation's server. Once asked, the agent stops it: a stop that Docker fails is answered
1117
+ * with `failed`, and the agent tries it again until the server is gone. The slot stays held until
1118
+ * then.
1119
+ *
1120
+ * The reply comes once its container has stopped: up to {@link STOP_TIMEOUT_SECONDS} before the kill, then up
1121
+ * to {@link STOP_REPLY_MARGIN_SECONDS} seconds for the kill and the reply. A stop during a launch
1122
+ * of the allocation first waits for the launch's current step to end. That step can take minutes on
1123
+ * the first launch after an image switch, which extracts the overlay, so the reply can come after
1124
+ * the session's wait. If the launch is stopped between its steps, it starts no server and its
1125
+ * `start_server` fails. If it is stopped while Docker starts its container, its `start_server`
1126
+ * succeeds and the server is then stopped. Refused with `unknown_allocation` for an allocation the
1127
+ * node does not run.
1128
+ */
1129
+ export const ControllerStopServer = z.object({
1130
+ type: z.literal('stop_server'),
1131
+ /** The server to stop. */
1132
+ allocation_id: AllocationId,
1133
+ ...request,
1134
+ });
1135
+ /** The server that stopped. */
1136
+ export const StopServerReply = z.object({
1137
+ /** The allocation the request stopped. */
1138
+ allocation_id: AllocationId,
1139
+ });
1140
+ /**
1141
+ * Install or update CS2 on the node's shared volume. The first install happens only through this
1142
+ * request.
1143
+ *
1144
+ * The reply comes immediately with the update's id, as {@link UpdateCs2Reply}, and the update runs
1145
+ * after it, which can take tens of minutes. Refused with `slots_held` while any slot is held and
1146
+ * `maintenance_running` while a maintenance operation runs.
1147
+ *
1148
+ * `cs2.update` in {@link AgentNodeStatus | node_status} reports the update from `outcome: null` to
1149
+ * how it ended, a failed pull of the cs2 image included. Meanwhile
1150
+ * {@link AgentCs2UpdateProgress | cs2_update_progress} and {@link AgentLogBatch | log_batch} report
1151
+ * on it, each naming it by that id.
1152
+ *
1153
+ * An installed node also updates itself to `cs2.latest_build_id` once every slot is idle, unless
1154
+ * its `auto_update` is `false`.
1155
+ */
1156
+ export const ControllerUpdateCs2 = z.object({
1157
+ type: z.literal('update_cs2'),
1158
+ ...request,
1159
+ /** How thoroughly to update. */
1160
+ mode: Cs2UpdateMode,
1161
+ });
1162
+ /** The update an accepted {@link ControllerUpdateCs2 | update_cs2} started. */
1163
+ export const UpdateCs2Reply = z.object({
1164
+ /**
1165
+ * The update's id, as `cs2.update.id`, {@link AgentCs2UpdateProgress | cs2_update_progress} and
1166
+ * its log source carry it.
1167
+ */
1168
+ update_id: UpdateId,
1169
+ });
1170
+ /**
1171
+ * Read the newest public CS2 build from Steam now instead of at the next scheduled read, which
1172
+ * comes every {@link CS2_BUILD_POLL_INTERVAL_MS}.
1173
+ *
1174
+ * It receives no reply. The agent handles at most one per {@link CS2_BUILD_CHECK_INTERVAL_MS} and
1175
+ * drops any sooner one. One sent during a scheduled read shares it. Neither busy slots nor
1176
+ * maintenance stop it. A changed build arrives as {@link AgentNodeStatus | node_status}, and an
1177
+ * installed node updates to it once every slot is idle, as it always does.
1178
+ */
1179
+ export const ControllerCheckCs2Build = z.object({
1180
+ type: z.literal('check_cs2_build'),
1181
+ });
1182
+ /**
1183
+ * Delete one demo by slot and file name.
1184
+ *
1185
+ * The reply comes once the file is removed. Refused with:
1186
+ *
1187
+ * - `unknown_slot` when the node has no directory for the slot.
1188
+ * - `unknown_demo` when the slot has no demo of that name.
1189
+ * - `demo_recording` while its server records the demo.
1190
+ * - `demo_uploading` while the demo uploads.
1191
+ * - `invalid_request` when the name is outside the slot's `demos/` directory.
1192
+ * - `failed` when the removal fails.
1193
+ */
1194
+ export const ControllerDeleteDemo = z.object({
1195
+ type: z.literal('delete_demo'),
1196
+ ...request,
1197
+ /** The slot whose directory holds it. */
1198
+ slot_id: SlotId,
1199
+ /** The file to delete. */
1200
+ file_name: DemoFileName,
1201
+ });
1202
+ /** The demo that was removed. */
1203
+ export const DeleteDemoReply = z.object({
1204
+ /** The slot whose directory held it. */
1205
+ slot_id: SlotId,
1206
+ /** The file removed. */
1207
+ file_name: DemoFileName,
1208
+ });
1209
+ /**
1210
+ * Seconds a {@link ControllerReserveSlot | reserve_slot} lasts, unless a {@link ControllerSetConfig | set_config}
1211
+ * with the slot's new token ends it first.
1212
+ */
1213
+ export const SLOT_RESERVATION_SECONDS = 60;
1214
+ /**
1215
+ * Keep an idle slot from launches while the controller replaces its token, so that no server
1216
+ * starts on a token that the controller is about to revoke at Steam.
1217
+ *
1218
+ * The agent marks the slot `reserved` before it replies, the same way a launch marks its slot
1219
+ * `starting`, so no {@link ControllerStartServer | start_server} can take the slot once the reply is sent.
1220
+ * The reply comes immediately. Refused with `slot_held` for a slot holding a server, and with
1221
+ * `unknown_slot` for a slot the node does not have.
1222
+ *
1223
+ * The reservation ends with the {@link ControllerSetConfig | set_config} that gives the slot a new token,
1224
+ * with the slot's removal, after {@link SLOT_RESERVATION_SECONDS}, or with the agent's restart.
1225
+ * Asking again restarts the wait.
1226
+ */
1227
+ export const ControllerReserveSlot = z.object({
1228
+ type: z.literal('reserve_slot'),
1229
+ ...request,
1230
+ /** The slot to reserve. */
1231
+ slot_id: SlotId,
1232
+ });
1233
+ /** The slot that is reserved. */
1234
+ export const ReserveSlotReply = z.object({
1235
+ /** The slot reserved. */
1236
+ slot_id: SlotId,
1237
+ });
1238
+ /**
1239
+ * Apply an edit to `agent.yml`, or refuse it without saving any part. Nothing is queued.
1240
+ *
1241
+ * The reply comes once the edit is saved and applied, and carries the node's current settings, the
1242
+ * {@link NodeSettings} that `hello.config` reports. An edit that changes nothing is not saved
1243
+ * again. A slot missing from the reply's `slots` has been removed. Refused with:
1244
+ *
1245
+ * - `slot_held` when the edit would remove or re-port a held slot, or give its server's token to
1246
+ * another slot.
1247
+ * - `slots_held` when the edit changes any key under `controller` (its address, TLS or paths) while any
1248
+ * slot is held.
1249
+ * - `slot_token_required` or `demo_header_value_required` when the edit keeps a value that the
1250
+ * node does not have.
1251
+ * - `invalid_request` when two slots have one token or anything else fails validation.
1252
+ *
1253
+ * After the reply, the agent sends a {@link AgentSlotStatus | slot_status} for each slot that the
1254
+ * edit added or gave a new token. The slot reads `unchecked` until the agent has logged the new
1255
+ * token on to Steam, which the agent starts then unless a server on the node is using the token. A
1256
+ * change to `controller` closes the socket with `1000` once the reply has gone, and the agent sends its
1257
+ * {@link AgentHello | hello} to the new address.
1258
+ *
1259
+ * Required of a controller that hands nodes an `agent.yml` without slots, because it is how they
1260
+ * receive any.
1261
+ */
1262
+ export const ControllerSetConfig = z.object({
1263
+ type: z.literal('set_config'),
1264
+ ...request,
1265
+ /** The settings to change. */
1266
+ config: NodeSettingsEdit,
1267
+ });
1268
+ /**
1269
+ * Replace the agent container with another image. Running servers keep running.
1270
+ *
1271
+ * The reply comes immediately with what happens next, as {@link UpdateAgentReply}, and
1272
+ * `agent_update` reports the rest: its `step` while it runs, then how it ended. Refused with
1273
+ * `maintenance_running` while a maintenance operation runs, with `image_missing` for an image the
1274
+ * node lacks without `pull`, and with `image_not_allowed` for an image outside the official agent
1275
+ * repository while the node's `official_images_only` is on.
1276
+ *
1277
+ * Switching stops this agent, and the agent that comes up, the new one or the old one put back,
1278
+ * reports how it went. The update counts as a maintenance operation until it ends, and a switch
1279
+ * until its replacement has proved it stays up.
1280
+ */
1281
+ export const ControllerUpdateAgent = z.object({
1282
+ type: z.literal('update_agent'),
1283
+ ...request,
1284
+ /** The agent image to run. */
1285
+ image: ImageReference,
1286
+ /** Whether to pull the image. When false, the agent uses an image already on the node. */
1287
+ pull: z.boolean(),
1288
+ });
1289
+ /**
1290
+ * What follows an accepted {@link ControllerUpdateAgent | update_agent}: the update's first
1291
+ * {@link ImageUpdateStep}, or `unchanged` when the node runs that image already, as the registry or
1292
+ * the local image says, and nothing more happens.
1293
+ */
1294
+ export const UpdateAgentReply = z.object({
1295
+ /** The agent image the update is to. */
1296
+ image: ImageReference,
1297
+ /** The next step. */
1298
+ step: z.enum([...ImageUpdateStep.options, 'unchanged']),
1299
+ });
1300
+ /**
1301
+ * Switch future launches to another cs2 image. Running servers keep theirs, and so does a launch
1302
+ * that took its slot before the switch.
1303
+ *
1304
+ * The reply comes immediately with what happens next, as {@link UpdateCs2ImageReply}. Refused with
1305
+ * `maintenance_running` while a maintenance operation runs, with `image_missing` for an image the
1306
+ * node lacks without `pull`, and with `image_not_allowed` for an image outside the official cs2
1307
+ * repository while the node's `official_images_only` is on.
1308
+ *
1309
+ * The agent then pulls the image when asked to, extracts its plugin files and removes the images
1310
+ * it supersedes. `cs2.image_update` in {@link AgentNodeStatus | node_status} reports its `step`
1311
+ * while it runs, then how it ended, in the same {@link AgentNodeStatus | node_status} as the new
1312
+ * `cs2.image`.
1313
+ */
1314
+ export const ControllerUpdateCs2Image = z.object({
1315
+ type: z.literal('update_cs2_image'),
1316
+ ...request,
1317
+ /** The cs2 image future launches use. */
1318
+ image: ImageReference,
1319
+ /** Whether to pull the image. When false, the agent uses an image already on the node. */
1320
+ pull: z.boolean(),
1321
+ });
1322
+ /**
1323
+ * What follows an accepted {@link ControllerUpdateCs2Image | update_cs2_image}: its first
1324
+ * {@link ImageUpdateStep}.
1325
+ */
1326
+ export const UpdateCs2ImageReply = z.object({
1327
+ /** The cs2 image the switch is to. */
1328
+ image: ImageReference,
1329
+ /** The next step. */
1330
+ step: ImageUpdateStep,
1331
+ });
1332
+ /**
1333
+ * Start or renew the resource lease, which lasts {@link WATCH_LEASE_MS}. It receives no reply.
1334
+ *
1335
+ * While the lease runs, the agent streams {@link AgentResourceBatch | resource_batch}. A lease that
1336
+ * starts first sends every tick the agent holds.
1337
+ */
1338
+ export const ControllerWatchResources = z.object({
1339
+ type: z.literal('watch_resources'),
1340
+ /** Whether to send every tick the agent holds now, also while the lease runs. */
1341
+ replay: z.boolean(),
1342
+ });
1343
+ /**
1344
+ * Start or renew the lease of one log source, which lasts {@link WATCH_LEASE_MS}. It receives no
1345
+ * reply.
1346
+ *
1347
+ * While the lease runs, the agent streams the source's {@link AgentLogBatch | log_batch} frames.
1348
+ * While any source's lease runs, the agent also sends {@link AgentLogIndex | log_index}. A lease
1349
+ * that starts replays the source, to every watcher unless a `replay_id` names the one that asked.
1350
+ * Any number of sources can be watched together, each on a lease of its own.
1351
+ */
1352
+ export const ControllerWatchLogs = z.object({
1353
+ type: z.literal('watch_logs'),
1354
+ /** The log to stream. A source the agent does not keep streams nothing until it prints. */
1355
+ source: LogSource,
1356
+ /**
1357
+ * The id that the replay frames carry, for the watcher that asked for the replay. Null for no
1358
+ * replay, as in a renewal. A lease that starts still replays the source, to every watcher.
1359
+ */
1360
+ replay_id: ReplayId.nullable(),
1361
+ });
1362
+ /**
1363
+ * The controller's answer to a {@link AgentHello | hello} it accepted, before anything else on the
1364
+ * socket. Without one the agent redials after {@link AGENT_WELCOME_TIMEOUT_MS}.
1365
+ *
1366
+ * After each {@link ControllerWelcome | welcome} the agent sends what changed since the
1367
+ * {@link AgentHello | hello}, its pending {@link AgentServerExited | server_exited} first and then
1368
+ * the changed statuses, demos, backlog and watched streams. It sends each state once, so a slot
1369
+ * that went from `starting` to `running` arrives as `running`.
1370
+ */
1371
+ export const ControllerWelcome = z.object({ type: z.literal('welcome') });
1372
+ /**
1373
+ * Why the controller refused a {@link AgentHello | hello}: `invalid_hello`, the
1374
+ * {@link AgentHello | hello} fails its schema. A controller refuses nothing else at hello: an agent
1375
+ * on a version it does not support connects, so it can still be updated, and receives no launches.
1376
+ */
1377
+ export const HelloRefusedReason = z.enum(['invalid_hello']);
1378
+ /**
1379
+ * The controller's answer to a {@link AgentHello | hello} it will not accept, followed by a close
1380
+ * with code `1008` and reason `hello_refused`. The agent stops dialling until it is restarted,
1381
+ * because the same {@link AgentHello | hello} would be refused again.
1382
+ */
1383
+ export const ControllerHelloRefused = z.object({
1384
+ type: z.literal('hello_refused'),
1385
+ reason: HelloRefusedReason,
1386
+ /** A reason for the operator reading the agent's log, such as the field that failed. */
1387
+ detail: z.string().max(512),
1388
+ });
1389
+ /**
1390
+ * Longest console command line, in characters. CS2 refuses a longer one with `Command too long...
1391
+ * ignoring!` somewhere past 500 (tested in October 2026), and reports it only in its log.
1392
+ */
1393
+ export const CONSOLE_COMMAND_MAX_CHARS = 500;
1394
+ /**
1395
+ * Run a console command on a server through its plugin.
1396
+ *
1397
+ * The reply comes once the plugin has handed the command to the engine, which reports nothing
1398
+ * back, so the reply cannot say that it worked. Refused with:
1399
+ *
1400
+ * - `unknown_allocation` when the node does not run the server.
1401
+ * - `plugin_offline` while the slot's `plugin_online` is `false`.
1402
+ * - `plugin_status_timeout` when the plugin does not answer.
1403
+ *
1404
+ * The output of the command joins the server's log, which {@link AgentLogBatch | log_batch} streams
1405
+ * while a {@link ControllerWatchLogs | watch_logs} lease covers it and replays when one starts.
1406
+ */
1407
+ export const ControllerConsoleCommand = z.object({
1408
+ type: z.literal('console_command'),
1409
+ ...request,
1410
+ /** The server to run it on. */
1411
+ allocation_id: AllocationId,
1412
+ /**
1413
+ * The console command line, up to {@link CONSOLE_COMMAND_MAX_CHARS} characters, handed to the
1414
+ * engine as it is, so a newline or `;` runs more than one command. It must not be blank, and tabs
1415
+ * and newlines are its only control characters.
1416
+ */
1417
+ command: z.string().max(CONSOLE_COMMAND_MAX_CHARS)
1418
+ .regex(/\S/u, 'must not be blank')
1419
+ .regex(/^(?:[^\p{Cc}]|[\t\n])*$/u, 'must not contain control characters other than tabs and newlines'),
1420
+ });
1421
+ /** The server the command was handed to. */
1422
+ export const ConsoleCommandReply = z.object({
1423
+ /** The allocation the command went to. */
1424
+ allocation_id: AllocationId,
1425
+ });
1426
+ /**
1427
+ * Play a running match on from one of its earlier rounds, on the same server, without a new
1428
+ * launch. The engine writes a backup as each live round starts, and the node keeps the backups for
1429
+ * the server's life. This request loads the one for `round`.
1430
+ *
1431
+ * The reply comes once the plugin has asked the engine to load the backup. The
1432
+ * {@link MatchRestored | match_restored} event, which the plugin sends ahead of the restored
1433
+ * round's {@link RoundStart | round_start}, confirms that the engine did. Refused with:
1434
+ *
1435
+ * - `unknown_allocation` when the node does not run the server.
1436
+ * - `no_backup` when the node has no backup of that round, as in a game mode without backups
1437
+ * ({@link GameMode}).
1438
+ * - `plugin_offline` while the slot's `plugin_online` is `false`.
1439
+ * - `plugin_status_timeout` when the plugin does not answer.
1440
+ * - `no_match`, `not_live` or `at_halftime` when the plugin refuses the restore.
1441
+ *
1442
+ * The node keeps the backup of every round it has played, including rounds after one it was
1443
+ * restored to. If `round` is past the current round, the server loads play that the match has
1444
+ * since left. Whether to allow that is the controller's rule.
1445
+ */
1446
+ export const ControllerRestoreRound = z.object({
1447
+ type: z.literal('restore_round'),
1448
+ ...request,
1449
+ /** The server whose match to restore. */
1450
+ allocation_id: AllocationId,
1451
+ /** The one-based round to play on from, whose backup the engine wrote as the round started. */
1452
+ round: RoundNumber,
1453
+ });
1454
+ /** The server and round the restore was handed to. */
1455
+ export const RestoreRoundReply = z.object({
1456
+ /** The allocation the restore went to. */
1457
+ allocation_id: AllocationId,
1458
+ /** The round it loads. */
1459
+ round: RoundNumber,
1460
+ });
1461
+ /**
1462
+ * Start a match in warmup whatever its `ready_mode`, without waiting for anyone to be ready. An
1463
+ * admin can always start the match this way, and under `external` it is the only way to.
1464
+ *
1465
+ * The reply comes once the plugin has started the countdown to the knife round or live play.
1466
+ * Refused with:
1467
+ *
1468
+ * - `unknown_allocation` when the node does not run the server.
1469
+ * - `plugin_offline` while the slot's `plugin_online` is `false`.
1470
+ * - `plugin_status_timeout` when the plugin does not answer.
1471
+ * - `no_match`, `not_in_warmup` or `team_empty` when the plugin refuses the start.
1472
+ *
1473
+ * A repeat while the countdown runs is accepted and changes nothing.
1474
+ */
1475
+ export const ControllerStartMatch = z.object({
1476
+ type: z.literal('start_match'),
1477
+ ...request,
1478
+ /** The server whose match to start. */
1479
+ allocation_id: AllocationId,
1480
+ });
1481
+ /** The server the start was handed to. */
1482
+ export const StartMatchReply = z.object({
1483
+ /** The allocation the start went to. */
1484
+ allocation_id: AllocationId,
1485
+ });
1486
+ /**
1487
+ * Milliseconds the agent waits for the plugin to answer
1488
+ * {@link ControllerGetServerStatus | get_server_status}, {@link ControllerConsoleCommand | console_command},
1489
+ * {@link ControllerRestoreRound | restore_round} or {@link ControllerStartMatch | start_match}. Past it, the
1490
+ * request fails with `plugin_status_timeout`.
1491
+ */
1492
+ export const SERVER_STATUS_TIMEOUT_MS = 10_000;
1493
+ /**
1494
+ * Read a server's state as its plugin reports it now.
1495
+ *
1496
+ * The reply comes within {@link SERVER_STATUS_TIMEOUT_MS}. Refused with:
1497
+ *
1498
+ * - `unknown_allocation` when the node does not run the server.
1499
+ * - `plugin_offline` while the slot's `plugin_online` is `false` or when the plugin leaves before
1500
+ * answering.
1501
+ * - `plugin_status_timeout` after {@link SERVER_STATUS_TIMEOUT_MS}.
1502
+ */
1503
+ export const ControllerGetServerStatus = z.object({
1504
+ type: z.literal('get_server_status'),
1505
+ ...request,
1506
+ /** The server to ask. */
1507
+ allocation_id: AllocationId,
1508
+ });
1509
+ /**
1510
+ * Where a match is.
1511
+ *
1512
+ * - `loading`: its map is loading.
1513
+ * - `warmup`: warmup.
1514
+ * - `pending_restore`: a launch with a restore went here from warmup while the engine loads the
1515
+ * backup, and goes to `live` once the restored round starts. A load that fails leaves the match
1516
+ * here, where the plugin reports nothing, until a load is tried again or the server is stopped.
1517
+ * - `knife`: the knife round.
1518
+ * - `side_choice`: the side choice.
1519
+ * - `live`: live play.
1520
+ * - `post_live`: after the last round.
1521
+ */
1522
+ export const MatchPhase = z.enum(['loading', 'warmup', 'pending_restore', 'knife', 'side_choice', 'live', 'post_live']);
1523
+ /** Longest `map_name` a server's state carries. The plugin cuts the engine's name to it. */
1524
+ export const MAP_NAME_MAX_CHARS = 64;
1525
+ /** Longest `config_error` a server's state carries. The plugin cuts its reason to it. */
1526
+ export const CONFIG_ERROR_MAX_CHARS = 512;
1527
+ /** A game server's state as its plugin reports it. */
1528
+ export const ServerState = z.object({
1529
+ /** The map the server is on, up to {@link MAP_NAME_MAX_CHARS} characters. */
1530
+ map_name: z.string().max(MAP_NAME_MAX_CHARS),
1531
+ /**
1532
+ * The loaded match and its team state. Null on a server without a match or after a refused
1533
+ * config.
1534
+ */
1535
+ match: z.object({
1536
+ /**
1537
+ * The `match_id` the server was launched with. Null for a match without one. The launch's
1538
+ * config is the controller's to keep, so it is not echoed.
1539
+ */
1540
+ match_id: MatchId.nullable(),
1541
+ /** Where the match is. */
1542
+ phase: MatchPhase,
1543
+ /** Team 1's current side. */
1544
+ team1_side: PlayingSide,
1545
+ /**
1546
+ * Team 2's current side, always the other side, sent so a reader shows each team without
1547
+ * deriving it.
1548
+ */
1549
+ team2_side: PlayingSide,
1550
+ /** Team 1's score. */
1551
+ team1_score: z.number().int().nonnegative(),
1552
+ /** Team 2's score. */
1553
+ team2_score: z.number().int().nonnegative(),
1554
+ }).nullable(),
1555
+ /** Everyone connected, up to {@link MAX_SERVER_PLAYERS}. */
1556
+ players: z.array(z.object({
1557
+ /** The player's SteamID64. */
1558
+ steam_id: SteamId64,
1559
+ /** The player's name. */
1560
+ name: PlayerName,
1561
+ /** The player's side. */
1562
+ side: Side,
1563
+ })).max(MAX_SERVER_PLAYERS),
1564
+ /**
1565
+ * Why the plugin could not read or use the match config it was launched with, up to
1566
+ * {@link CONFIG_ERROR_MAX_CHARS} characters. Null when the server has no match config or the
1567
+ * plugin accepted it.
1568
+ */
1569
+ config_error: z.string().max(CONFIG_ERROR_MAX_CHARS).nullable(),
1570
+ /**
1571
+ * Whether players can join. When `true`, the server is logged on to Steam and has loaded the map
1572
+ * that its launch asked for, the workshop map when it has one. It stays `true` once set.
1573
+ *
1574
+ * CS2 does not fall back to an anonymous logon when Steam refuses its token, so `true` also
1575
+ * indicates that Steam accepted the GSLT and that its last use can be updated.
1576
+ */
1577
+ ready: z.boolean(),
1578
+ /**
1579
+ * The SteamID64 that Steam gave the server when it logged on with its GSLT. Each token keeps
1580
+ * one SteamID across logons, so a controller can look the server up in Steam's server list by
1581
+ * it. The plugin also adds the `allocation_id` without its dashes to `sv_tags`, which that list
1582
+ * shows. Null until `ready`.
1583
+ */
1584
+ server_steam_id: SteamId64.nullable(),
1585
+ });
1586
+ /** The server asked about, and its state as its plugin reports it now. */
1587
+ export const GetServerStatusReply = ServerState.extend({
1588
+ /** The allocation the request asked about. */
1589
+ allocation_id: AllocationId,
1590
+ });
1591
+ /**
1592
+ * A match moved to another phase, as its plugin reports it, whether or not the match sends
1593
+ * statistics. An {@link EventBatch | event_batch} carries the events behind it, such as
1594
+ * {@link GoingLive | going_live} and {@link MapEnd | map_end}.
1595
+ *
1596
+ * The agent sends it once. If the plugin is not connected to the agent, or the controller has not
1597
+ * welcomed the agent's {@link AgentHello | hello}, it is lost, and
1598
+ * {@link ControllerGetServerStatus | get_server_status} reads the phase again.
1599
+ */
1600
+ export const AgentPhaseChange = z.object({
1601
+ type: z.literal('phase_change'),
1602
+ /** The server whose match changed phase. */
1603
+ allocation_id: AllocationId,
1604
+ /** The phase the match is in now. */
1605
+ phase: MatchPhase,
1606
+ });
1607
+ /**
1608
+ * A match's score changed, as its plugin reads it off the scoreboard, whether or not the match
1609
+ * sends statistics: after a round, when going live resets it, or after a restore. An
1610
+ * {@link EventBatch | event_batch} carries the rounds behind it as {@link RoundEnd | round_end}
1611
+ * and {@link MatchRestored | match_restored}.
1612
+ *
1613
+ * Like {@link AgentPhaseChange | phase_change}, it is sent once, and
1614
+ * {@link ControllerGetServerStatus | get_server_status} reads the score again.
1615
+ */
1616
+ export const AgentScore = z.object({
1617
+ type: z.literal('score'),
1618
+ /** The server whose match scored. */
1619
+ allocation_id: AllocationId,
1620
+ /** Team 1's score. */
1621
+ team1_score: z.number().int().nonnegative(),
1622
+ /** Team 2's score. */
1623
+ team2_score: z.number().int().nonnegative(),
1624
+ });
1625
+ /**
1626
+ * The {@link RoundBackup | round_backup} of a match that sends no statistics, which a match with
1627
+ * statistics carries in its {@link EventBatch | event_batch} instead. Keep the newest of each match
1628
+ * to restore it on a new server.
1629
+ *
1630
+ * The message wraps the complete event, unchanged, so code that reads a `round_backup` from the
1631
+ * event stream reads `backup` the same way. The wrapper adds only the server that sent it.
1632
+ *
1633
+ * Like {@link AgentPhaseChange | phase_change}, it is sent once, so a backup is lost if the plugin
1634
+ * or the agent is not connected when the round starts. The next round's backup replaces it.
1635
+ */
1636
+ export const AgentBackup = z.object({
1637
+ type: z.literal('backup'),
1638
+ /** The server whose match the backup is of. */
1639
+ allocation_id: AllocationId,
1640
+ /** The {@link RoundBackup | round_backup} event, as the event stream would carry it. */
1641
+ backup: RoundBackup,
1642
+ });
1643
+ /**
1644
+ * What the controller sends:
1645
+ *
1646
+ * - The {@link ControllerWelcome | welcome} that accepts a {@link AgentHello | hello}, or the
1647
+ * {@link ControllerHelloRefused | hello_refused} that refuses it.
1648
+ * - Requests, which carry an `id` and receive one {@link AgentReply | reply} each.
1649
+ * - The watches that start or renew a stream of logs or resource samples.
1650
+ * - {@link ControllerCheckCs2Build | check_cs2_build}, which receives no answer.
1651
+ */
1652
+ export const ControllerToAgentMessage = z.discriminatedUnion('type', [
1653
+ ControllerStartServer,
1654
+ ControllerStopServer,
1655
+ ControllerGetServerStatus,
1656
+ ControllerUpdateCs2,
1657
+ ControllerCheckCs2Build,
1658
+ ControllerDeleteDemo,
1659
+ ControllerReserveSlot,
1660
+ ControllerSetConfig,
1661
+ ControllerUpdateAgent,
1662
+ ControllerUpdateCs2Image,
1663
+ ControllerWatchResources,
1664
+ ControllerWatchLogs,
1665
+ ControllerWelcome,
1666
+ ControllerHelloRefused,
1667
+ ControllerConsoleCommand,
1668
+ ControllerRestoreRound,
1669
+ ControllerStartMatch,
1670
+ ]);
1671
+ /**
1672
+ * The `type` of each request the controller sends, which its {@link AgentReply | reply} repeats as
1673
+ * `request`.
1674
+ */
1675
+ export const RequestType = z.enum([
1676
+ 'start_server',
1677
+ 'stop_server',
1678
+ 'get_server_status',
1679
+ 'update_cs2',
1680
+ 'delete_demo',
1681
+ 'reserve_slot',
1682
+ 'set_config',
1683
+ 'update_agent',
1684
+ 'update_cs2_image',
1685
+ 'console_command',
1686
+ 'restore_round',
1687
+ 'start_match',
1688
+ ]);
1689
+ /**
1690
+ * What every successful reply carries. Each request's branch of {@link AgentReplySuccess} sets
1691
+ * `request` and `data`.
1692
+ */
1693
+ const replySuccess = z.object({
1694
+ type: z.literal('reply'),
1695
+ /** The `id` of the request this answers. */
1696
+ reply_to: RequestId,
1697
+ /** The `type` of the request this answers, which says what `data` holds. */
1698
+ request: RequestType,
1699
+ /** The request succeeded. */
1700
+ ok: z.literal(true),
1701
+ /** What the request returns, which names the object it acted on. */
1702
+ data: z.unknown(),
1703
+ });
1704
+ /**
1705
+ * A request that succeeded. `request` names the request, so `data` can be read without the request
1706
+ * that `reply_to` answers. The settings in a {@link ControllerSetConfig | set_config} reply are the
1707
+ * node's, whichever edit asked.
1708
+ */
1709
+ export const AgentReplySuccess = z.discriminatedUnion('request', [
1710
+ replySuccess.extend({ request: z.literal('start_server'), data: StartServerReply }),
1711
+ replySuccess.extend({ request: z.literal('stop_server'), data: StopServerReply }),
1712
+ replySuccess.extend({ request: z.literal('get_server_status'), data: GetServerStatusReply }),
1713
+ replySuccess.extend({ request: z.literal('update_cs2'), data: UpdateCs2Reply }),
1714
+ replySuccess.extend({ request: z.literal('delete_demo'), data: DeleteDemoReply }),
1715
+ replySuccess.extend({ request: z.literal('reserve_slot'), data: ReserveSlotReply }),
1716
+ replySuccess.extend({ request: z.literal('set_config'), data: NodeSettings }),
1717
+ replySuccess.extend({ request: z.literal('update_agent'), data: UpdateAgentReply }),
1718
+ replySuccess.extend({ request: z.literal('update_cs2_image'), data: UpdateCs2ImageReply }),
1719
+ replySuccess.extend({ request: z.literal('console_command'), data: ConsoleCommandReply }),
1720
+ replySuccess.extend({ request: z.literal('restore_round'), data: RestoreRoundReply }),
1721
+ replySuccess.extend({ request: z.literal('start_match'), data: StartMatchReply }),
1722
+ ]);
1723
+ /** A request that failed. */
1724
+ export const AgentReplyFailure = z.object({
1725
+ type: z.literal('reply'),
1726
+ /** The `id` of the request this answers. */
1727
+ reply_to: RequestId,
1728
+ /** The `type` of the request this answers. Null when the agent did not know it. */
1729
+ request: RequestType.nullable(),
1730
+ /** The request failed. */
1731
+ ok: z.literal(false),
1732
+ /** Why it failed. */
1733
+ error: AgentError,
1734
+ });
1735
+ /**
1736
+ * The answer to a request, in any order. A success carries the request's data, and a failure its
1737
+ * reason.
1738
+ */
1739
+ export const AgentReply = z.discriminatedUnion('ok', [AgentReplySuccess, AgentReplyFailure]);
1740
+ /**
1741
+ * What the agent sends: its {@link AgentHello | hello}, one {@link AgentReply | reply} to each
1742
+ * request, and reports of the node as it changes. Until a {@link ControllerWelcome | welcome} answers the
1743
+ * {@link AgentHello | hello}, only replies follow it, and a {@link AgentShutdown | shutdown} if the
1744
+ * agent stops.
1745
+ */
1746
+ export const AgentToControllerMessage = z.discriminatedUnion('type', [
1747
+ AgentHello,
1748
+ AgentNodeStatus,
1749
+ AgentShutdown,
1750
+ AgentSlotStatus,
1751
+ AgentServerExited,
1752
+ AgentLogBatch,
1753
+ AgentLogIndex,
1754
+ AgentCs2UpdateProgress,
1755
+ AgentDemo,
1756
+ AgentDemoRemoved,
1757
+ AgentDemoProgress,
1758
+ AgentResourceBatch,
1759
+ AgentEventBacklog,
1760
+ AgentPhaseChange,
1761
+ AgentScore,
1762
+ AgentBackup,
1763
+ AgentReply,
1764
+ ]);