@serverkgg/bridge 0.1.4 → 0.1.6

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.
Files changed (75) hide show
  1. package/package.json +3 -3
  2. package/src/bot/botCauses.test.ts +292 -0
  3. package/src/bot/botCauses.ts +58 -0
  4. package/src/bot/botDeploy.test.ts +260 -1
  5. package/src/bot/botDeploy.ts +125 -6
  6. package/src/bot/botState.ts +6 -0
  7. package/src/bot/detect/botFrameworks.ts +1 -0
  8. package/src/bot/detect/botInspectWarning.ts +4 -1
  9. package/src/bot/detect/botPackageManager.ts +3 -0
  10. package/src/bot/detect/corpus/alexmubarakshin-ts-telegraf-boilerplate.json +37 -0
  11. package/src/bot/detect/corpus/alsarmad-discord-quran-bot.json +40 -0
  12. package/src/bot/detect/corpus/anasty17-mirror-leech-telegram-bot.json +50 -0
  13. package/src/bot/detect/corpus/anidiotsguide-guidebot.json +36 -0
  14. package/src/bot/detect/corpus/anonymousx1025-anonxmusic.json +45 -0
  15. package/src/bot/detect/corpus/bot-base-telegram-bot-template.json +35 -0
  16. package/src/bot/detect/corpus/bracketed-templates-discordjs.json +40 -0
  17. package/src/bot/detect/corpus/cog-creators-red-discordbot.json +49 -0
  18. package/src/bot/detect/corpus/cranberriez-discord.ts-bot-template.json +35 -0
  19. package/src/bot/detect/corpus/crazycat256-nextcord-bot-template.json +38 -0
  20. package/src/bot/detect/corpus/discord-tickets-bot.json +45 -0
  21. package/src/bot/detect/corpus/edjopato-telegram-typescript-bot-template.json +37 -0
  22. package/src/bot/detect/corpus/haniali3161-bot-adhkar-telegram.json +33 -0
  23. package/src/bot/detect/corpus/hellosverre-discord-ts-template.json +34 -0
  24. package/src/bot/detect/corpus/kevinnovak-discord-bot-typescript-template.json +43 -0
  25. package/src/bot/detect/corpus/kkrypt0nn-python-discord-bot-template.json +41 -0
  26. package/src/bot/detect/corpus/kolezka-telegram-grammy-template.json +37 -0
  27. package/src/bot/detect/corpus/latand-tgbot_template_v3.json +37 -0
  28. package/src/bot/detect/corpus/mahmoudbm77-zekr-telegram-bot.json +36 -0
  29. package/src/bot/detect/corpus/mericcan41-discordjs-v14-template-ts.json +35 -0
  30. package/src/bot/detect/corpus/modmail-dev-modmail.json +52 -0
  31. package/src/bot/detect/corpus/nezdemkovski-telegram-bot-boilerplate.json +39 -0
  32. package/src/bot/detect/corpus/notchum-discord-bot-template.json +39 -0
  33. package/src/bot/detect/corpus/python-discord-bot.json +44 -0
  34. package/src/bot/detect/corpus/python-telegram-bot-rules-bot.json +39 -0
  35. package/src/bot/detect/corpus/rajdave69-pycord-bot-template.json +31 -0
  36. package/src/bot/detect/corpus/rapptz-robodanny.json +36 -0
  37. package/src/bot/detect/corpus/ry2x-typescript-discord.js-v.14-template.json +35 -0
  38. package/src/bot/detect/corpus/saiteja-madha-discord-js-bot.json +45 -0
  39. package/src/bot/detect/corpus/seegreatness-discord.js-for-heroku.json +37 -0
  40. package/src/bot/detect/corpus/serverk-discord-node.json +36 -0
  41. package/src/bot/detect/corpus/serverk-discord-python.json +35 -0
  42. package/src/bot/detect/corpus/serverk-telegram-node.json +36 -0
  43. package/src/bot/detect/corpus/serverk-telegram-python.json +35 -0
  44. package/src/bot/detect/corpus/shuruhatik-quran-radio-bot-discord.json +33 -0
  45. package/src/bot/detect/corpus/skyra-project-skyra.json +46 -0
  46. package/src/bot/detect/corpus/tgbot-collection-ytdlbot.json +41 -0
  47. package/src/bot/detect/corpus/zeriodev-music-bot.json +38 -0
  48. package/src/bot/detect/detect.corpus.test.ts +77 -0
  49. package/src/bot/detect/detect.test.ts +443 -17
  50. package/src/bot/detect/detect.ts +375 -34
  51. package/src/bot/detect/detectResult.ts +4 -0
  52. package/src/bot/detect/detectTable.test.ts +22 -6
  53. package/src/bot/detect/detectTable.ts +187 -19
  54. package/src/bot/detect/detectedVersion.ts +5 -0
  55. package/src/bot/detect/envExample.test.ts +55 -1
  56. package/src/bot/detect/envExample.ts +14 -0
  57. package/src/bot/detect/index.ts +1 -0
  58. package/src/bot/detect/keys.test.ts +32 -1
  59. package/src/bot/detect/keys.ts +15 -0
  60. package/src/bot/detect/serverkJson.test.ts +2 -0
  61. package/src/bot/detect/serverkJson.ts +4 -1
  62. package/src/bot/index.ts +1 -0
  63. package/src/manifest/manifest.ts +2 -1
  64. package/src/modules/bridge.ts +2 -0
  65. package/src/modules/bridgeModule.ts +5 -0
  66. package/src/probes/minecraftPing.test.ts +2 -1
  67. package/src/protocol/bridgeCrash.ts +49 -0
  68. package/src/protocol/bridgeEvents.ts +9 -0
  69. package/src/rcon/rconClient.test.ts +3 -2
  70. package/src/runtime/bridgeDriver.test.ts +45 -0
  71. package/src/runtime/bridgeDriver.ts +25 -0
  72. package/src/runtime/bridgeEvents.ts +2 -0
  73. package/src/runtime/bridgeSupervisor.test.ts +163 -3
  74. package/src/runtime/bridgeSupervisor.ts +42 -1
  75. package/src/runtime/serve.ts +26 -5
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@serverkgg/bridge",
3
- "version": "0.1.4",
3
+ "version": "0.1.6",
4
4
  "description": "Serverk game bridge and driver runtime, wire protocol, manifest schema, and maintainer CLI",
5
5
  "homepage": "https://serverk.gg",
6
6
  "license": "SEE LICENSE IN LICENSE.md",
@@ -39,9 +39,9 @@
39
39
  },
40
40
  "scripts": {
41
41
  "tsc": "tsc",
42
- "test": "bun test --parallel"
42
+ "test": "bun test --parallel=8"
43
43
  },
44
44
  "dependencies": {
45
- "zod": "4.6.4"
45
+ "zod": "4.6.5"
46
46
  }
47
47
  }
@@ -0,0 +1,292 @@
1
+ import { describe, expect, it } from "bun:test";
2
+ import { BridgeKind, BridgeStream } from "../core";
3
+ import { BridgeCrashCause, BridgeCrashPhase, type BridgeCrashRecord } from "../protocol";
4
+ import { createEventRing } from "../runtime/bridgeEvents";
5
+ import type { BridgeSupervisor, SupervisorLine } from "../runtime/bridgeSupervisor";
6
+ import { BOT_CRASH_EVIDENCE } from "./botCauses";
7
+
8
+ const CASES: readonly {
9
+ line: string;
10
+ cause: BridgeCrashCause;
11
+ }[] = [
12
+ {
13
+ line: "Error [TokenInvalid]: An invalid token was provided.",
14
+ cause: BridgeCrashCause.TokenInvalid,
15
+ },
16
+ {
17
+ line: "discord.errors.LoginFailure: Improper token has been passed.",
18
+ cause: BridgeCrashCause.TokenInvalid,
19
+ },
20
+ {
21
+ line: "GrammyError: Call to 'getMe' failed! (401: Unauthorized)",
22
+ cause: BridgeCrashCause.TokenInvalid,
23
+ },
24
+ {
25
+ line: "telegram.error.InvalidToken: The token `123:abc` was rejected by the server.",
26
+ cause: BridgeCrashCause.TokenInvalid,
27
+ },
28
+ {
29
+ line: "GrammyError: Call to 'deleteWebhook' failed! (404: Not Found)",
30
+ cause: BridgeCrashCause.TokenInvalid,
31
+ },
32
+ {
33
+ line: "404: Not Found at https://api.telegram.org/bot***/getMe",
34
+ cause: BridgeCrashCause.TokenInvalid,
35
+ },
36
+ {
37
+ line: "aiogram.exceptions.TelegramUnauthorizedError: Telegram server says - Unauthorized",
38
+ cause: BridgeCrashCause.TokenInvalid,
39
+ },
40
+ {
41
+ line: "pyrogram.errors.exceptions.bad_request_400.AccessTokenInvalid: Telegram says: [400 ACCESS_TOKEN_INVALID] - The bot access token is invalid",
42
+ cause: BridgeCrashCause.TokenInvalid,
43
+ },
44
+ {
45
+ line: "pyrogram.errors.exceptions.bad_request_400.AccessTokenExpired: Telegram says: [400 ACCESS_TOKEN_EXPIRED] - The bot token has expired",
46
+ cause: BridgeCrashCause.TokenInvalid,
47
+ },
48
+ {
49
+ line: "Error: Used disallowed intents",
50
+ cause: BridgeCrashCause.IntentsDisallowed,
51
+ },
52
+ {
53
+ line: "Error [DisallowedIntents]: Privileged intent provided is not enabled or whitelisted.",
54
+ cause: BridgeCrashCause.IntentsDisallowed,
55
+ },
56
+ {
57
+ line: "discord.errors.PrivilegedIntentsRequired: Shard ID None is requesting privileged intents that have not been explicitly enabled in the developer portal.",
58
+ cause: BridgeCrashCause.IntentsDisallowed,
59
+ },
60
+ {
61
+ line: "telegram.error.Conflict: Conflict: terminated by other getUpdates request; make sure that only one bot instance is running",
62
+ cause: BridgeCrashCause.TokenInUse,
63
+ },
64
+ {
65
+ line: "GrammyError: Call to 'getUpdates' failed! (409: Conflict: terminated by other getUpdates request; make sure that only one bot instance is running)",
66
+ cause: BridgeCrashCause.TokenInUse,
67
+ },
68
+ {
69
+ line: "Error: Cannot find module 'discord.js'",
70
+ cause: BridgeCrashCause.MissingDependency,
71
+ },
72
+ {
73
+ line: "Error [ERR_MODULE_NOT_FOUND]: Cannot find package 'telegraf' imported from /home/container/index.js",
74
+ cause: BridgeCrashCause.MissingDependency,
75
+ },
76
+ {
77
+ line: "ModuleNotFoundError: No module named 'discord'",
78
+ cause: BridgeCrashCause.MissingDependency,
79
+ },
80
+ {
81
+ line: "RuntimeError: davey library needed in order to use voice",
82
+ cause: BridgeCrashCause.MissingDependency,
83
+ },
84
+ {
85
+ line: "RuntimeError: PyNaCl library needed in order to use voice",
86
+ cause: BridgeCrashCause.MissingDependency,
87
+ },
88
+ {
89
+ line: "ImportError: cannot import name 'Update' from 'telegram' (/home/container/.venv/lib/python3.12/site-packages/telegram/__init__.py)",
90
+ cause: BridgeCrashCause.WrongTelegramPackage,
91
+ },
92
+ {
93
+ line: "AttributeError: module 'discord' has no attribute 'Bot'",
94
+ cause: BridgeCrashCause.WrongDiscordPackage,
95
+ },
96
+ {
97
+ line: "ModuleNotFoundError: No module named 'discord.app_commands'",
98
+ cause: BridgeCrashCause.WrongDiscordPackage,
99
+ },
100
+ {
101
+ line: "ImportError: cannot import name 'app_commands' from 'discord' (/home/container/.venv/lib/python3.12/site-packages/discord/__init__.py)",
102
+ cause: BridgeCrashCause.WrongDiscordPackage,
103
+ },
104
+ {
105
+ line: "gyp ERR! stack Error: not found: make",
106
+ cause: BridgeCrashCause.BuildTools,
107
+ },
108
+ {
109
+ line: "error: command 'gcc' failed: No such file or directory",
110
+ cause: BridgeCrashCause.BuildTools,
111
+ },
112
+ {
113
+ line: "error: command 'x86_64-linux-gnu-gcc' failed with exit status 1",
114
+ cause: BridgeCrashCause.BuildTools,
115
+ },
116
+ {
117
+ line: "discord.errors.ClientException: ffmpeg was not found.",
118
+ cause: BridgeCrashCause.FfmpegMissing,
119
+ },
120
+ {
121
+ line: "Error: spawn ffmpeg ENOENT",
122
+ cause: BridgeCrashCause.FfmpegMissing,
123
+ },
124
+ {
125
+ line: "ModuleNotFoundError: No module named 'audioop'",
126
+ cause: BridgeCrashCause.AudioopRemoved,
127
+ },
128
+ {
129
+ line: "discord.errors.HTTPException: 429 Too Many Requests (error code: 0): You are being blocked from accessing our API temporarily due to exceeding our rate limits frequently.",
130
+ cause: BridgeCrashCause.RateLimited,
131
+ },
132
+ {
133
+ line: "GrammyError: Call to 'sendMessage' failed! (429: Too Many Requests: retry after 5)",
134
+ cause: BridgeCrashCause.RateLimited,
135
+ },
136
+ {
137
+ line: "error code: 1015",
138
+ cause: BridgeCrashCause.RateLimited,
139
+ },
140
+ {
141
+ line: "FATAL ERROR: Reached heap limit Allocation failed - JavaScript heap out of memory",
142
+ cause: BridgeCrashCause.OutOfMemory,
143
+ },
144
+ {
145
+ line: "FATAL ERROR: Ineffective mark-compacts near heap limit Allocation failed - JavaScript heap out of memory",
146
+ cause: BridgeCrashCause.OutOfMemory,
147
+ },
148
+ {
149
+ line: "MemoryError",
150
+ cause: BridgeCrashCause.OutOfMemory,
151
+ },
152
+ ];
153
+
154
+ const recordOf = (overrides: Partial<BridgeCrashRecord> = {}): BridgeCrashRecord => {
155
+ return {
156
+ exitCode: 1,
157
+ outOfMemory: false,
158
+ phase: BridgeCrashPhase.Booting,
159
+ uptimeSeconds: 5,
160
+ restarting: false,
161
+ looping: false,
162
+ restarts: 0,
163
+ lastLine: null,
164
+ ...overrides,
165
+ };
166
+ };
167
+
168
+ const botRing = () => {
169
+ const watchers: ((entry: SupervisorLine) => void)[] = [];
170
+
171
+ const supervisor = {
172
+ subscribe(watcher: (entry: SupervisorLine) => void) {
173
+ watchers.push(watcher);
174
+
175
+ return () => {};
176
+ },
177
+ } as unknown as BridgeSupervisor;
178
+
179
+ const ring = createEventRing(
180
+ {
181
+ kind: BridgeKind.Events,
182
+ patterns: [
183
+ ...BOT_CRASH_EVIDENCE,
184
+ ],
185
+ },
186
+ supervisor,
187
+ );
188
+
189
+ const print = (line: string) => {
190
+ for (const watcher of watchers) {
191
+ watcher({
192
+ stream: BridgeStream.Stderr,
193
+ line,
194
+ });
195
+ }
196
+ };
197
+
198
+ return {
199
+ ring,
200
+ print,
201
+ };
202
+ };
203
+
204
+ describe("BOT_CRASH_EVIDENCE", () => {
205
+ for (const item of CASES) {
206
+ it(`names ${item.cause} from "${item.line}"`, () => {
207
+ const { ring, print } = botRing();
208
+
209
+ print(item.line);
210
+
211
+ expect(ring.crash(recordOf())).toBe(item.cause);
212
+ expect(ring.drain(0).events.at(0)?.payload?.cause).toBe(item.cause);
213
+ });
214
+ }
215
+
216
+ it("names the removed audioop module before any other missing module", () => {
217
+ const { ring, print } = botRing();
218
+
219
+ print("ModuleNotFoundError: No module named 'audioop'");
220
+ print("ModuleNotFoundError: No module named 'discord'");
221
+
222
+ expect(ring.crash(recordOf())).toBe(BridgeCrashCause.AudioopRemoved);
223
+ });
224
+
225
+ it("names the wrong discord library before the missing module it shows up as", () => {
226
+ const { ring, print } = botRing();
227
+
228
+ print("ModuleNotFoundError: No module named 'discord.app_commands'");
229
+
230
+ expect(ring.crash(recordOf())).toBe(BridgeCrashCause.WrongDiscordPackage);
231
+ });
232
+
233
+ it("names missing build tools before the missing module they left behind", () => {
234
+ const { ring, print } = botRing();
235
+
236
+ print("gyp ERR! stack Error: not found: make");
237
+ print("Error: Cannot find module 'better-sqlite3'");
238
+
239
+ expect(ring.crash(recordOf())).toBe(BridgeCrashCause.BuildTools);
240
+ });
241
+
242
+ it("names a missing module before a token the bot never got to use", () => {
243
+ const { ring, print } = botRing();
244
+
245
+ print("GrammyError: Call to 'getMe' failed! (401: Unauthorized)");
246
+ print("Error: Cannot find module 'grammy'");
247
+
248
+ expect(ring.crash(recordOf())).toBe(BridgeCrashCause.MissingDependency);
249
+ });
250
+
251
+ it("leaves a 404 from a Telegram call that says nothing about the token unexplained", () => {
252
+ const { ring, print } = botRing();
253
+
254
+ print("GrammyError: Call to 'sendMessage' failed! (400: Bad Request: chat not found)");
255
+ print("GET /assets/logo.png 404: Not Found");
256
+
257
+ expect(ring.crash(recordOf())).toBe(BridgeCrashCause.Unknown);
258
+ });
259
+
260
+ it("leaves a line no library prints unexplained", () => {
261
+ const { ring, print } = botRing();
262
+
263
+ print("TypeError: Cannot read properties of undefined (reading 'send')");
264
+
265
+ expect(ring.crash(recordOf())).toBe(BridgeCrashCause.Unknown);
266
+ });
267
+
268
+ it("calls exit 137 out of memory only when the record carries the flag", () => {
269
+ const unflagged = botRing();
270
+
271
+ expect(
272
+ unflagged.ring.crash(
273
+ recordOf({
274
+ exitCode: 137,
275
+ }),
276
+ ),
277
+ ).toBe(BridgeCrashCause.Unknown);
278
+
279
+ const flagged = botRing();
280
+
281
+ flagged.print("Error [TokenInvalid]: An invalid token was provided.");
282
+
283
+ expect(
284
+ flagged.ring.crash(
285
+ recordOf({
286
+ exitCode: 137,
287
+ outOfMemory: true,
288
+ }),
289
+ ),
290
+ ).toBe(BridgeCrashCause.OomKill);
291
+ });
292
+ });
@@ -0,0 +1,58 @@
1
+ import type { BridgeEventPattern } from "../modules";
2
+ import { BridgeEventName } from "../protocol";
3
+
4
+ export const BOT_CRASH_EVIDENCE: readonly BridgeEventPattern[] = [
5
+ {
6
+ match:
7
+ /\[TokenInvalid\]|An invalid token was provided|LoginFailure: Improper token|\b401:? Unauthorized\b|\bInvalidToken\b|TelegramUnauthorizedError|\bACCESS_TOKEN_(?:INVALID|EXPIRED)\b/,
8
+ emit: BridgeEventName.TokenInvalid,
9
+ },
10
+ {
11
+ match:
12
+ /Call to '(?:getMe|deleteWebhook|getUpdates)' failed! \(404: Not Found\)|\/bot[^/\s]*\/(?:getMe|deleteWebhook|getUpdates)\b.*\b404:? Not Found\b|\b404:? Not Found\b.*\/bot[^/\s]*\/(?:getMe|deleteWebhook|getUpdates)\b/,
13
+ emit: BridgeEventName.TokenInvalid,
14
+ },
15
+ {
16
+ match: /Used disallowed intents|\[DisallowedIntents\]|PrivilegedIntentsRequired|close code:? 4014\b/,
17
+ emit: BridgeEventName.IntentsDisallowed,
18
+ },
19
+ {
20
+ match: /terminated by other getUpdates request|\b409:? Conflict\b/,
21
+ emit: BridgeEventName.TokenInUse,
22
+ },
23
+ {
24
+ match:
25
+ /Cannot find module|ERR_MODULE_NOT_FOUND|ModuleNotFoundError: No module named|(?:davey|PyNaCl) library needed in order to use voice/,
26
+ emit: BridgeEventName.MissingDependency,
27
+ },
28
+ {
29
+ match: /ImportError: cannot import name .+ from 'telegram'/,
30
+ emit: BridgeEventName.WrongTelegramPackage,
31
+ },
32
+ {
33
+ match:
34
+ /AttributeError: module 'discord' has no attribute 'Bot'|No module named 'discord\.app_commands'|ImportError: cannot import name 'app_commands' from 'discord'/,
35
+ emit: BridgeEventName.WrongDiscordPackage,
36
+ },
37
+ {
38
+ match: /gyp ERR!|error: command '(?:[\w.-]+-)?gcc' failed/,
39
+ emit: BridgeEventName.BuildToolsMissing,
40
+ },
41
+ {
42
+ match: /ffmpeg was not found|\bENOENT\b.*\bffmpeg\b|\bffmpeg\b.*\bENOENT\b/i,
43
+ emit: BridgeEventName.FfmpegMissing,
44
+ },
45
+ {
46
+ match: /No module named 'audioop'/,
47
+ emit: BridgeEventName.AudioopRemoved,
48
+ },
49
+ {
50
+ match: /\b429:? Too Many Requests\b|\berror code:? 1015\b|\bError 1015\b|You are being rate limited/i,
51
+ emit: BridgeEventName.RateLimited,
52
+ },
53
+ {
54
+ match:
55
+ /FATAL ERROR: (?:Reached heap limit|Ineffective mark-compacts near heap limit) Allocation failed - JavaScript heap out of memory|^MemoryError\b/,
56
+ emit: BridgeEventName.ServerCrashed,
57
+ },
58
+ ];
@@ -1,7 +1,9 @@
1
1
  import { describe, expect, it } from "bun:test";
2
2
  import type { BridgeValues } from "../core";
3
- import { BridgeEventName } from "../protocol";
3
+ import { BridgeCrashCause, BridgeCrashPhase, type BridgeCrashReport, BridgeEventName } from "../protocol";
4
4
  import {
5
+ BOT_READY_GRACE_SECONDS,
6
+ createBotReadiness,
5
7
  DEPLOY_MARKER,
6
8
  DEPLOY_MARKER_DRAFT,
7
9
  DEPLOYMENT_EVENT_PATTERN,
@@ -9,6 +11,7 @@ import {
9
11
  type DeployMarker,
10
12
  deploymentLine,
11
13
  deployPending,
14
+ pendingDeploymentOf,
12
15
  requestDeploy,
13
16
  } from "./botDeploy";
14
17
  import { BotDeploymentOutcome, BotDeploymentSource, BotDeploymentStage, DEPLOYMENT_STATE_FILE } from "./botState";
@@ -361,3 +364,259 @@ describe("deployPending", () => {
361
364
  expect(await deployPending(stopped.context)).toBe(false);
362
365
  });
363
366
  });
367
+
368
+ const LIVE_AT = Date.parse("2026-09-27T10:00:00.000Z");
369
+
370
+ const LAUNCHED_BEFORE = LIVE_AT - 60_000;
371
+
372
+ const LAUNCHED_AFTER = LIVE_AT + 60_000;
373
+
374
+ const reportOf = (overrides: Partial<BridgeCrashReport> = {}): BridgeCrashReport => {
375
+ return {
376
+ cause: BridgeCrashCause.TokenInvalid,
377
+ exitCode: 1,
378
+ outOfMemory: false,
379
+ phase: BridgeCrashPhase.Booting,
380
+ uptimeSeconds: 5,
381
+ restarting: false,
382
+ looping: false,
383
+ restarts: 0,
384
+ lastLine: null,
385
+ ...overrides,
386
+ };
387
+ };
388
+
389
+ const readyBot = async (launchedAt: number) => {
390
+ const deploy = deployContext(true, liveState("dep_a1b2c3d4"));
391
+ const readiness = createBotReadiness();
392
+
393
+ readiness.launching(launchedAt);
394
+
395
+ await readiness.ready(deploy.context);
396
+
397
+ return {
398
+ ...deploy,
399
+ readiness,
400
+ };
401
+ };
402
+
403
+ describe("createBotReadiness", () => {
404
+ it("waits twenty seconds for a bot to prove it is ready", () => {
405
+ expect(BOT_READY_GRACE_SECONDS).toBe(20);
406
+ });
407
+
408
+ it("reports the deployment ready once the bot is stable, never when the launcher hands over", async () => {
409
+ const { context, emitted, stored, readiness } = await readyBot(LAUNCHED_BEFORE);
410
+
411
+ expect(emitted).toEqual([]);
412
+
413
+ await readiness.stable(context);
414
+
415
+ expect(JSON.parse(stored.get(DEPLOYMENT_STATE_FILE) ?? "null").settled).toBe(true);
416
+
417
+ expect(emitted).toEqual([
418
+ {
419
+ event: BridgeEventName.ServerUpdated,
420
+ payload: {
421
+ kind: "deployment",
422
+ deployment: "dep_a1b2c3d4",
423
+ outcome: "ready",
424
+ commit: "0123456789ab",
425
+ },
426
+ },
427
+ ]);
428
+ });
429
+
430
+ it("fails the deployment at the start stage with the cause when the bot exits inside the grace", async () => {
431
+ const { context, stored, emitted, readiness } = await readyBot(LAUNCHED_BEFORE);
432
+
433
+ await readiness.exit(context, reportOf());
434
+
435
+ expect(emitted).toEqual([
436
+ {
437
+ event: BridgeEventName.ServerUpdated,
438
+ payload: {
439
+ kind: "deployment",
440
+ deployment: "dep_a1b2c3d4",
441
+ outcome: "failed",
442
+ stage: "start",
443
+ detail: "token-invalid",
444
+ },
445
+ },
446
+ ]);
447
+
448
+ const state = JSON.parse(stored.get(DEPLOYMENT_STATE_FILE) ?? "null");
449
+
450
+ expect({
451
+ ...state,
452
+ finishedAt: null,
453
+ }).toEqual({
454
+ ...JSON.parse(liveState("dep_a1b2c3d4")[DEPLOYMENT_STATE_FILE]),
455
+ outcome: BotDeploymentOutcome.Failed,
456
+ stage: BotDeploymentStage.Start,
457
+ detail: "token-invalid",
458
+ finishedAt: null,
459
+ settled: true,
460
+ });
461
+ });
462
+
463
+ it("names the failure unknown when nothing the bot printed explains it", async () => {
464
+ const { context, emitted, readiness } = await readyBot(LAUNCHED_BEFORE);
465
+
466
+ await readiness.exit(
467
+ context,
468
+ reportOf({
469
+ cause: BridgeCrashCause.Unknown,
470
+ }),
471
+ );
472
+
473
+ expect(emitted.at(0)?.payload?.detail).toBe("unknown");
474
+ });
475
+
476
+ it("leaves a deployment that went live before this launch alone on a plain restart", async () => {
477
+ const { context, emitted, stored, readiness } = await readyBot(LAUNCHED_AFTER);
478
+ const before = stored.get(DEPLOYMENT_STATE_FILE);
479
+
480
+ await readiness.exit(context, reportOf());
481
+ await readiness.stable(context);
482
+
483
+ expect(emitted).toEqual([]);
484
+ expect(stored.get(DEPLOYMENT_STATE_FILE)).toBe(before);
485
+ });
486
+
487
+ it("spends the deployment once, so a crash after the bot became stable fails nothing", async () => {
488
+ const { context, emitted, readiness } = await readyBot(LAUNCHED_BEFORE);
489
+
490
+ await readiness.stable(context);
491
+ await readiness.exit(
492
+ context,
493
+ reportOf({
494
+ phase: BridgeCrashPhase.Running,
495
+ }),
496
+ );
497
+
498
+ expect(emitted.map((entry) => entry.payload?.outcome)).toEqual([
499
+ "ready",
500
+ ]);
501
+ });
502
+ });
503
+
504
+ describe("the deployment a bot is still proving", () => {
505
+ it("waits for the state read still in flight before it reports ready", async () => {
506
+ const deploy = deployContext(true, liveState("dep_a1b2c3d4"));
507
+ const readiness = createBotReadiness();
508
+
509
+ readiness.launching(LAUNCHED_BEFORE);
510
+
511
+ const reading = readiness.ready(deploy.context);
512
+
513
+ await readiness.stable(deploy.context);
514
+ await reading;
515
+
516
+ expect(deploy.emitted.map((entry) => entry.payload?.outcome)).toEqual([
517
+ "ready",
518
+ ]);
519
+ });
520
+
521
+ it("keeps proving a deployment the launcher left unsettled when the bot starts again", async () => {
522
+ const unsettled = JSON.parse(liveState("dep_a1b2c3d4")[DEPLOYMENT_STATE_FILE]);
523
+ const deploy = deployContext(true, {
524
+ [DEPLOYMENT_STATE_FILE]: JSON.stringify({
525
+ ...unsettled,
526
+ settled: false,
527
+ }),
528
+ });
529
+ const readiness = createBotReadiness();
530
+
531
+ readiness.launching(LAUNCHED_AFTER);
532
+
533
+ await readiness.ready(deploy.context);
534
+ await readiness.exit(deploy.context, reportOf());
535
+
536
+ expect(deploy.emitted.map((entry) => entry.payload?.outcome)).toEqual([
537
+ "failed",
538
+ ]);
539
+ });
540
+
541
+ it("does not call a deployment live while the bot is still proving it, and restarts nothing", async () => {
542
+ const unsettled = JSON.parse(liveState("dep_a1b2c3d4")[DEPLOYMENT_STATE_FILE]);
543
+ const { context, stored, events, emitted } = deployContext(true, {
544
+ [DEPLOYMENT_STATE_FILE]: JSON.stringify({
545
+ ...unsettled,
546
+ settled: false,
547
+ }),
548
+ });
549
+
550
+ await requestDeploy(context, {
551
+ deployment: "dep_a1b2c3d4",
552
+ ref: SHA,
553
+ snapshot: null,
554
+ reinstall: false,
555
+ });
556
+
557
+ expect(emitted).toEqual([]);
558
+ expect(stored.has(DEPLOY_MARKER)).toBe(false);
559
+ expect(events).toEqual([
560
+ "log the deployment is running but has not proven itself yet, its outcome follows",
561
+ ]);
562
+ });
563
+ });
564
+
565
+ describe("pendingDeploymentOf", () => {
566
+ it("takes a ready deployment the launcher wrote during this launch or left unsettled, never a settled one", () => {
567
+ const state = {
568
+ deployment: "dep_a1b2c3d4",
569
+ source: BotDeploymentSource.Snapshot,
570
+ ref: null,
571
+ commit: null,
572
+ snapshot: "snap_1",
573
+ outcome: BotDeploymentOutcome.Ready,
574
+ stage: null,
575
+ detail: null,
576
+ finishedAt: "2026-09-27T10:00:00.000Z",
577
+ };
578
+
579
+ expect(pendingDeploymentOf(state, LAUNCHED_BEFORE)).toEqual(state);
580
+ expect(pendingDeploymentOf(state, LAUNCHED_AFTER)).toBeNull();
581
+ expect(
582
+ pendingDeploymentOf(
583
+ {
584
+ ...state,
585
+ outcome: BotDeploymentOutcome.Failed,
586
+ },
587
+ LAUNCHED_BEFORE,
588
+ ),
589
+ ).toBeNull();
590
+ expect(
591
+ pendingDeploymentOf(
592
+ {
593
+ ...state,
594
+ deployment: null,
595
+ },
596
+ LAUNCHED_BEFORE,
597
+ ),
598
+ ).toBeNull();
599
+ expect(pendingDeploymentOf(null, LAUNCHED_BEFORE)).toBeNull();
600
+ expect(
601
+ pendingDeploymentOf(
602
+ {
603
+ ...state,
604
+ settled: false,
605
+ },
606
+ LAUNCHED_AFTER,
607
+ ),
608
+ ).toEqual({
609
+ ...state,
610
+ settled: false,
611
+ });
612
+ expect(
613
+ pendingDeploymentOf(
614
+ {
615
+ ...state,
616
+ settled: true,
617
+ },
618
+ LAUNCHED_BEFORE,
619
+ ),
620
+ ).toBeNull();
621
+ });
622
+ });