@serverkgg/bridge 0.1.11 → 0.1.13

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 (51) hide show
  1. package/package.json +1 -1
  2. package/rules/package.md +2 -1
  3. package/src/bot/botDeploy.test.ts +57 -0
  4. package/src/bot/botEnvironment.test.ts +21 -0
  5. package/src/bot/botEnvironment.ts +10 -0
  6. package/src/bot/botRun.test.ts +296 -0
  7. package/src/bot/botRun.ts +109 -0
  8. package/src/bot/botState.test.ts +34 -1
  9. package/src/bot/botState.ts +1 -0
  10. package/src/bot/botVariables.test.ts +22 -0
  11. package/src/bot/botVariables.ts +3 -0
  12. package/src/bot/detect/detect.test.ts +172 -0
  13. package/src/bot/detect/detect.ts +31 -0
  14. package/src/bot/detect/detectResult.ts +1 -0
  15. package/src/bot/detect/detectTable.test.ts +25 -2
  16. package/src/bot/detect/detectTable.ts +24 -1
  17. package/src/bot/detect/keys.test.ts +7 -0
  18. package/src/bot/detect/keys.ts +3 -0
  19. package/src/bot/detect/serverkJson.test.ts +21 -0
  20. package/src/bot/detect/serverkJson.ts +13 -1
  21. package/src/bot/index.ts +1 -0
  22. package/src/cli/link.ts +3 -3
  23. package/src/cli/main.ts +8 -0
  24. package/src/cli/readme.test.ts +470 -0
  25. package/src/cli/readme.ts +570 -0
  26. package/src/cli/validate.test.ts +41 -1
  27. package/src/cli/validate.ts +25 -0
  28. package/src/context/bridgeContext.ts +1 -0
  29. package/src/modules/bridgeModule.ts +1 -0
  30. package/src/protocol/bridgeManifest.ts +1 -0
  31. package/src/runtime/bridgeContext.test.ts +6 -0
  32. package/src/runtime/bridgeContext.ts +7 -2
  33. package/src/runtime/bridgeExec.test.ts +150 -0
  34. package/src/runtime/bridgeExec.ts +67 -19
  35. package/src/runtime/bridgeLineSpacing.test.ts +132 -0
  36. package/src/runtime/bridgeLineSpacing.ts +49 -0
  37. package/src/runtime/bridgeLog.ts +9 -0
  38. package/src/runtime/bridgeRedaction.test.ts +28 -2
  39. package/src/runtime/bridgeRedaction.ts +7 -1
  40. package/src/runtime/bridgeRegistry.test.ts +30 -0
  41. package/src/runtime/bridgeRegistry.ts +1 -0
  42. package/src/runtime/bridgeScopedContext.ts +3 -0
  43. package/src/runtime/bridgeSetupHandlers.test.ts +3 -0
  44. package/src/runtime/bridgeSupervisor.ts +7 -9
  45. package/src/runtime/serve.ts +9 -2
  46. package/src/steam/steamcmdNarrator.test.ts +18 -5
  47. package/src/steam/steamcmdNarrator.ts +26 -2
  48. package/src/wine/protonCommand.test.ts +17 -1
  49. package/src/wine/protonCommand.ts +7 -1
  50. package/src/wire/panel.test.ts +32 -0
  51. package/src/wire/panel.ts +1 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@serverkgg/bridge",
3
- "version": "0.1.11",
3
+ "version": "0.1.13",
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",
package/rules/package.md CHANGED
@@ -7,6 +7,7 @@ Generated by `serverk-bridge rules` from the pinned `@serverkgg/bridge`. Do not
7
7
  One Serverk package (a game or a bot runtime) on `@serverkgg/bridge`, with no pipeline: validated with `serverk-bridge validate`, released only from `../serverk`, the monorepo beside it.
8
8
 
9
9
  - When `../serverk` exists, read `../serverk/CLAUDE.md` first; its instructions apply here.
10
+ - `README.md` is generated by `serverk-bridge readme` from `serverk.yml`, `package.json` and `.githooks/pre-commit`, and never edited by hand; `serverk-bridge validate` fails while it is out of date.
10
11
  - Before matching work, read its skill in `../serverk/.claude/skills/`: `serverk-copy` (every string a customer reads), `serverk-game-package` (a new game, its manifest, image or control channel), `serverk-game-art` (logo and banner), `serverk-deploy` (publishing to dev in `dev.md`, releasing in `bridge.md`).
11
12
 
12
13
  ## The driver
@@ -26,7 +27,7 @@ One Serverk package (a game or a bot runtime) on `@serverkgg/bridge`, with no pi
26
27
 
27
28
  - Functions are `const` arrows. No classes, `function` declarations, default exports or comments.
28
29
  - Touch the server only through `Bridge.Context` (`files`, `exec`, `net`, `codec`, `rcon`, `variable`, `secret`), never `node:fs`, `fetch`, `Bun.spawn` or `process.env`.
29
- - Log through `context.log`: a lowercase message, details second. Never `console` in `src/`.
30
+ - Log through `context.log`: a lowercase message, details second. `context.print` writes a raw console line, redacted, for output the console shows as the process's own, such as a console command's. Never `console` in `src/`.
30
31
  - Refuse a customer's request with `new BridgeUserError({ ar, en })`; a plain lowercase English `Error` is for faults the customer cannot fix.
31
32
  - A timeout, limit, pattern or file name is a `SCREAMING_SNAKE_CASE` constant (`STOP_TIMEOUT_SECONDS`), exported when a test or another folder needs it.
32
33
 
@@ -10,6 +10,7 @@ import {
10
10
  DEPLOYMENT_LINE,
11
11
  DEPLOYMENT_STAGE_LINE,
12
12
  type DeployMarker,
13
+ deploymentFailedEvent,
13
14
  deploymentLine,
14
15
  deploymentStageLine,
15
16
  deployPending,
@@ -191,6 +192,52 @@ describe("deploymentLine", () => {
191
192
  });
192
193
  });
193
194
 
195
+ it("reads a failure at the pre-deploy stage, hyphen included", () => {
196
+ const line = deploymentLine({
197
+ deployment: "dep_a1b2c3d4",
198
+ outcome: BotDeploymentOutcome.Failed,
199
+ stage: BotDeploymentStage.PreDeploy,
200
+ detail: "prisma migrate deploy exited with code 1",
201
+ });
202
+
203
+ expect(line).toBe("serverk: deployment dep_a1b2c3d4 failed: pre-deploy: prisma migrate deploy exited with code 1");
204
+ expect(DEPLOYMENT_LINE.exec(line)?.groups).toMatchObject({
205
+ deployment: "dep_a1b2c3d4",
206
+ outcome: "failed",
207
+ stage: "pre-deploy",
208
+ detail: "prisma migrate deploy exited with code 1",
209
+ });
210
+ expect(DEPLOYMENT_EVENT_PATTERN.match.test(line)).toBe(true);
211
+ expect(
212
+ deploymentFailedEvent("dep_a1b2c3d4", BotDeploymentStage.PreDeploy, "prisma migrate deploy exited with code 1"),
213
+ ).toEqual({
214
+ kind: "deployment",
215
+ deployment: "dep_a1b2c3d4",
216
+ outcome: BotDeploymentOutcome.Failed,
217
+ stage: "pre-deploy",
218
+ detail: "prisma migrate deploy exited with code 1",
219
+ });
220
+ });
221
+
222
+ it("round-trips a failure through the line pattern for every stage", () => {
223
+ for (const stage of Object.values(BotDeploymentStage)) {
224
+ expect(
225
+ DEPLOYMENT_LINE.exec(
226
+ deploymentLine({
227
+ deployment: "dep_a1b2c3d4",
228
+ outcome: BotDeploymentOutcome.Failed,
229
+ stage,
230
+ detail: "it broke",
231
+ }),
232
+ )?.groups,
233
+ stage,
234
+ ).toMatchObject({
235
+ stage,
236
+ detail: "it broke",
237
+ });
238
+ }
239
+ });
240
+
194
241
  it("cuts the detail at 200 characters, counting an emoji as one the way the Python launcher does", () => {
195
242
  const line = deploymentLine({
196
243
  deployment: "dep_a1b2c3d4",
@@ -225,6 +272,16 @@ describe("deploymentStageLine", () => {
225
272
  );
226
273
  });
227
274
 
275
+ it("names the pre-deploy stage with its hyphen", () => {
276
+ const line = deploymentStageLine("dep_a1b2c3d4", BotDeploymentStage.PreDeploy);
277
+
278
+ expect(line).toBe("serverk: deployment dep_a1b2c3d4 stage pre-deploy");
279
+ expect(DEPLOYMENT_STAGE_LINE.exec(line)?.groups).toEqual({
280
+ deployment: "dep_a1b2c3d4",
281
+ stage: "pre-deploy",
282
+ });
283
+ });
284
+
228
285
  it("round-trips through the stage pattern for every stage", () => {
229
286
  for (const stage of Object.values(BotDeploymentStage)) {
230
287
  expect(DEPLOYMENT_STAGE_LINE.exec(deploymentStageLine("dep_a1b2c3d4", stage))?.groups).toEqual({
@@ -9,10 +9,12 @@ import {
9
9
  LAUNCHER_GIT_TOKEN,
10
10
  LAUNCHER_INSTALL_COMMAND,
11
11
  LAUNCHER_KEYS,
12
+ LAUNCHER_PRE_DEPLOY_COMMAND,
12
13
  LAUNCHER_REPO_BRANCH,
13
14
  LAUNCHER_REPO_REF,
14
15
  LAUNCHER_REPO_URL,
15
16
  LAUNCHER_ROOT_DIRECTORY,
17
+ LAUNCHER_RUN_COMMAND,
16
18
  LAUNCHER_SNAPSHOT_ID,
17
19
  LAUNCHER_START_COMMAND,
18
20
  LAUNCHER_STATE_DIR,
@@ -25,6 +27,7 @@ import {
25
27
  BOT_PORT_2_KEY,
26
28
  BOT_WEB_PORT_KEY,
27
29
  BotPlatform,
30
+ isReservedBotKey,
28
31
  PORT,
29
32
  SERVERK_PORT_1,
30
33
  SERVERK_PORT_2,
@@ -41,6 +44,7 @@ const config = (overrides: Partial<BotConfig> = {}): BotConfig => ({
41
44
  branch: DEFAULT_REPO_BRANCH,
42
45
  startCommand: null,
43
46
  installCommand: null,
47
+ preDeployCommand: null,
44
48
  rootDirectory: null,
45
49
  deployment: null,
46
50
  snapshot: null,
@@ -84,6 +88,7 @@ describe("customerEnvironment", () => {
84
88
  REPO_REF: SHA,
85
89
  START_COMMAND: "node .",
86
90
  INSTALL_COMMAND: "npm i",
91
+ PRE_DEPLOY_COMMAND: "npx prisma migrate deploy",
87
92
  DEPLOYMENT_ID: "dep_1",
88
93
  SNAPSHOT_ID: "snap_1",
89
94
  BOT_PLATFORM: "discord",
@@ -122,6 +127,18 @@ describe("customerEnvironment", () => {
122
127
  API_KEY: "sealed",
123
128
  });
124
129
  });
130
+
131
+ it("reserves the console command key so a customer variable never reaches the launcher as one", () => {
132
+ expect(isReservedBotKey(LAUNCHER_RUN_COMMAND)).toBe(true);
133
+ expect(
134
+ customerEnvironment(
135
+ {
136
+ [LAUNCHER_RUN_COMMAND]: "rm -rf .",
137
+ },
138
+ {},
139
+ ),
140
+ ).toEqual({});
141
+ });
125
142
  });
126
143
 
127
144
  describe("launcherEnvironment", () => {
@@ -135,6 +152,7 @@ describe("launcherEnvironment", () => {
135
152
  gitToken: "ghs_installation",
136
153
  startCommand: "node .",
137
154
  installCommand: "npm install --legacy-peer-deps",
155
+ preDeployCommand: "npx prisma migrate deploy",
138
156
  rootDirectory: "apps/bot",
139
157
  deployment: "dep_a1b2c3d4",
140
158
  }),
@@ -148,6 +166,7 @@ describe("launcherEnvironment", () => {
148
166
  [LAUNCHER_GIT_TOKEN]: "ghs_installation",
149
167
  [LAUNCHER_START_COMMAND]: "node .",
150
168
  [LAUNCHER_INSTALL_COMMAND]: "npm install --legacy-peer-deps",
169
+ [LAUNCHER_PRE_DEPLOY_COMMAND]: "npx prisma migrate deploy",
151
170
  [LAUNCHER_ROOT_DIRECTORY]: "apps/bot",
152
171
  [LAUNCHER_DEPLOYMENT_ID]: "dep_a1b2c3d4",
153
172
  });
@@ -173,7 +192,9 @@ describe("launcherEnvironment", () => {
173
192
  expect(LAUNCHER_KEYS.every((key) => key.startsWith("SERVERK_"))).toBe(true);
174
193
  expect(LAUNCHER_KEYS).toContain(LAUNCHER_REPO_REF);
175
194
  expect(LAUNCHER_KEYS).toContain(LAUNCHER_INSTALL_COMMAND);
195
+ expect(LAUNCHER_KEYS).toContain(LAUNCHER_PRE_DEPLOY_COMMAND);
176
196
  expect(LAUNCHER_KEYS).toContain(LAUNCHER_ROOT_DIRECTORY);
197
+ expect(LAUNCHER_KEYS).toContain(LAUNCHER_RUN_COMMAND);
177
198
  });
178
199
  });
179
200
 
@@ -28,6 +28,8 @@ export const LAUNCHER_START_COMMAND = "SERVERK_START_COMMAND";
28
28
 
29
29
  export const LAUNCHER_INSTALL_COMMAND = "SERVERK_INSTALL_COMMAND";
30
30
 
31
+ export const LAUNCHER_PRE_DEPLOY_COMMAND = "SERVERK_PRE_DEPLOY_COMMAND";
32
+
31
33
  export const LAUNCHER_ROOT_DIRECTORY = "SERVERK_ROOT_DIRECTORY";
32
34
 
33
35
  export const LAUNCHER_DEPLOYMENT_ID = "SERVERK_DEPLOYMENT_ID";
@@ -38,6 +40,8 @@ export const LAUNCHER_STATE_DIR = "SERVERK_STATE_DIR";
38
40
 
39
41
  export const LAUNCHER_DETECT_TABLE = "SERVERK_DETECT_TABLE";
40
42
 
43
+ export const LAUNCHER_RUN_COMMAND = "SERVERK_RUN_COMMAND";
44
+
41
45
  export const LAUNCHER_KEYS: readonly string[] = [
42
46
  LAUNCHER_REPO_URL,
43
47
  LAUNCHER_REPO_REF,
@@ -45,11 +49,13 @@ export const LAUNCHER_KEYS: readonly string[] = [
45
49
  LAUNCHER_GIT_TOKEN,
46
50
  LAUNCHER_START_COMMAND,
47
51
  LAUNCHER_INSTALL_COMMAND,
52
+ LAUNCHER_PRE_DEPLOY_COMMAND,
48
53
  LAUNCHER_ROOT_DIRECTORY,
49
54
  LAUNCHER_DEPLOYMENT_ID,
50
55
  LAUNCHER_SNAPSHOT_ID,
51
56
  LAUNCHER_STATE_DIR,
52
57
  LAUNCHER_DETECT_TABLE,
58
+ LAUNCHER_RUN_COMMAND,
53
59
  ];
54
60
 
55
61
  export const customerEnvironment = (
@@ -103,6 +109,10 @@ export const launcherEnvironment = (config: BotConfig, stateDir: string): Record
103
109
  LAUNCHER_INSTALL_COMMAND,
104
110
  config.installCommand,
105
111
  ],
112
+ [
113
+ LAUNCHER_PRE_DEPLOY_COMMAND,
114
+ config.preDeployCommand,
115
+ ],
106
116
  [
107
117
  LAUNCHER_ROOT_DIRECTORY,
108
118
  config.rootDirectory,
@@ -0,0 +1,296 @@
1
+ import { describe, expect, it } from "bun:test";
2
+ import type { BridgeContext, BridgeExecOptions, BridgeExecResult } from "../context";
3
+ import { BridgeStream } from "../core";
4
+ import { LAUNCHER_RUN_COMMAND } from "./botEnvironment";
5
+ import { BOT_RUN_MODE, BOT_RUN_OUTPUT_LIMIT, BOT_RUN_TIMEOUT_MS, botRunEndLineOf, createBotRun } from "./botRun";
6
+
7
+ const LAUNCHER = [
8
+ "node",
9
+ "/opt/serverk/launcher.mjs",
10
+ BOT_RUN_MODE,
11
+ ];
12
+
13
+ const ENVIRONMENT = {
14
+ DISCORD_TOKEN: "sealed",
15
+ SERVERK_STATE_DIR: "/home/container/.serverk",
16
+ };
17
+
18
+ interface ExecCall {
19
+ command: string[];
20
+ options: BridgeExecOptions;
21
+ }
22
+
23
+ interface PrintedLine {
24
+ line: string;
25
+ stream: BridgeStream | null;
26
+ }
27
+
28
+ const runContextOf = () => {
29
+ const calls: ExecCall[] = [];
30
+ const printed: PrintedLine[] = [];
31
+ const settles: ((outcome: BridgeExecResult | Error) => void)[] = [];
32
+
33
+ const context = {
34
+ exec(command: string[], options: BridgeExecOptions = {}) {
35
+ calls.push({
36
+ command,
37
+ options,
38
+ });
39
+
40
+ return new Promise<BridgeExecResult>((resolve, reject) => {
41
+ settles.push((outcome) => {
42
+ if (outcome instanceof Error) {
43
+ reject(outcome);
44
+
45
+ return;
46
+ }
47
+
48
+ resolve(outcome);
49
+ });
50
+ });
51
+ },
52
+ print(line: string, stream?: BridgeStream) {
53
+ printed.push({
54
+ line,
55
+ stream: stream ?? null,
56
+ });
57
+ },
58
+ } as unknown as BridgeContext;
59
+
60
+ const settle = async (outcome: BridgeExecResult | Error) => {
61
+ settles.shift()?.(outcome);
62
+
63
+ await Bun.sleep(0);
64
+ };
65
+
66
+ return {
67
+ context,
68
+ calls,
69
+ printed,
70
+ settle,
71
+ };
72
+ };
73
+
74
+ const resultOf = (code: number): BridgeExecResult => ({
75
+ stdout: "",
76
+ stderr: "",
77
+ code,
78
+ });
79
+
80
+ const runOf = () => {
81
+ return createBotRun({
82
+ launcherCommand: () => LAUNCHER,
83
+ environment: () => ENVIRONMENT,
84
+ });
85
+ };
86
+
87
+ describe("createBotRun", () => {
88
+ it("starts the launcher in run mode and answers at once with the command sent", async () => {
89
+ const harness = runContextOf();
90
+
91
+ const reply = await runOf()(harness.context, "npx prisma migrate deploy");
92
+
93
+ expect(reply).toEqual({
94
+ sent: "npx prisma migrate deploy",
95
+ line: null,
96
+ groups: {},
97
+ });
98
+ expect(harness.calls.map((call) => call.command)).toEqual([
99
+ LAUNCHER,
100
+ ]);
101
+ expect(harness.calls[0]?.options.timeoutMs).toBe(BOT_RUN_TIMEOUT_MS);
102
+ expect(harness.printed).toEqual([]);
103
+ });
104
+
105
+ it("hands the launcher the bot's environment and exactly the trimmed command", async () => {
106
+ const harness = runContextOf();
107
+
108
+ await runOf()(harness.context, " ls -la dist ");
109
+
110
+ expect(harness.calls[0]?.options.environment).toEqual({
111
+ ...ENVIRONMENT,
112
+ [LAUNCHER_RUN_COMMAND]: "ls -la dist",
113
+ });
114
+ });
115
+
116
+ it("answers a blank input without running anything", async () => {
117
+ const harness = runContextOf();
118
+
119
+ const reply = await runOf()(harness.context, " ");
120
+
121
+ expect(reply).toEqual({
122
+ sent: "",
123
+ line: "serverk: nothing to run",
124
+ groups: {},
125
+ });
126
+ expect(harness.calls).toEqual([]);
127
+ });
128
+
129
+ it("refuses a second command while the first one runs", async () => {
130
+ const harness = runContextOf();
131
+ const run = runOf();
132
+
133
+ await run(harness.context, "sleep 60");
134
+
135
+ const reply = await run(harness.context, "ls");
136
+
137
+ expect(reply).toEqual({
138
+ sent: "ls",
139
+ line: "serverk: a command is already running",
140
+ groups: {},
141
+ });
142
+ expect(harness.calls).toHaveLength(1);
143
+ });
144
+
145
+ it("prints the command's output on its stream, then that it finished, and takes the next command", async () => {
146
+ const harness = runContextOf();
147
+ const run = runOf();
148
+
149
+ await run(harness.context, "npm run seed");
150
+
151
+ const onOutput = harness.calls[0]?.options.onOutput;
152
+
153
+ onOutput?.("seeded 3 rows", BridgeStream.Stdout);
154
+ onOutput?.("warning: no .env", BridgeStream.Stderr);
155
+
156
+ await harness.settle(resultOf(0));
157
+
158
+ const next = await run(harness.context, "ls");
159
+
160
+ expect(harness.printed).toEqual([
161
+ {
162
+ line: "seeded 3 rows",
163
+ stream: BridgeStream.Stdout,
164
+ },
165
+ {
166
+ line: "warning: no .env",
167
+ stream: BridgeStream.Stderr,
168
+ },
169
+ {
170
+ line: "serverk: command finished",
171
+ stream: null,
172
+ },
173
+ ]);
174
+ expect(next?.line).toBeNull();
175
+ expect(harness.calls).toHaveLength(2);
176
+ });
177
+
178
+ it("stops printing at the output limit, says so once, and still prints how the command ended", async () => {
179
+ const harness = runContextOf();
180
+ const line = "y".repeat(1023);
181
+ const fitting = BOT_RUN_OUTPUT_LIMIT / (line.length + 1);
182
+
183
+ await runOf()(harness.context, "yes");
184
+
185
+ const onOutput = harness.calls[0]?.options.onOutput;
186
+
187
+ for (let index = 0; index < fitting + 5; index += 1) {
188
+ onOutput?.(line, BridgeStream.Stdout);
189
+ }
190
+
191
+ await harness.settle(resultOf(0));
192
+
193
+ expect(harness.printed.filter((entry) => entry.line === line)).toHaveLength(fitting);
194
+ expect(harness.printed.slice(fitting)).toEqual([
195
+ {
196
+ line: "serverk: output cut at 256 KiB",
197
+ stream: null,
198
+ },
199
+ {
200
+ line: "serverk: command finished",
201
+ stream: null,
202
+ },
203
+ ]);
204
+ });
205
+
206
+ it("reports a failed command with its exit code and frees the console", async () => {
207
+ const harness = runContextOf();
208
+ const run = runOf();
209
+
210
+ await run(harness.context, "false");
211
+ await harness.settle(resultOf(2));
212
+
213
+ const next = await run(harness.context, "ls");
214
+
215
+ expect(harness.printed).toEqual([
216
+ {
217
+ line: "serverk: command failed (exit 2)",
218
+ stream: null,
219
+ },
220
+ ]);
221
+ expect(next?.line).toBeNull();
222
+ });
223
+
224
+ it("reports a command the exec refused to start and frees the console", async () => {
225
+ const harness = runContextOf();
226
+ const run = runOf();
227
+
228
+ await run(harness.context, "ls");
229
+ await harness.settle(new Error("the exec process runs as uid 0, expected uid 1000 — refusing to run it"));
230
+
231
+ const next = await run(harness.context, "ls");
232
+
233
+ expect(harness.printed).toEqual([
234
+ {
235
+ line: "serverk: command failed: the exec process runs as uid 0, expected uid 1000 — refusing to run it",
236
+ stream: null,
237
+ },
238
+ ]);
239
+ expect(next?.line).toBeNull();
240
+ });
241
+
242
+ it("keeps answering when the launcher environment cannot be built", async () => {
243
+ const harness = runContextOf();
244
+ const run = createBotRun({
245
+ launcherCommand: () => LAUNCHER,
246
+ environment: () => {
247
+ throw new Error("no state directory");
248
+ },
249
+ });
250
+
251
+ const reply = await run(harness.context, "ls");
252
+
253
+ await Bun.sleep(0);
254
+
255
+ const next = await run(harness.context, "ls");
256
+
257
+ expect(reply?.line).toBeNull();
258
+ expect(harness.calls).toEqual([]);
259
+ expect(harness.printed.map((entry) => entry.line)).toEqual([
260
+ "serverk: command failed: no state directory",
261
+ "serverk: command failed: no state directory",
262
+ ]);
263
+ expect(next?.line).toBeNull();
264
+ });
265
+ });
266
+
267
+ const END_LINES = [
268
+ {
269
+ code: 0,
270
+ elapsedMs: 1200,
271
+ line: "serverk: command finished",
272
+ },
273
+ {
274
+ code: 1,
275
+ elapsedMs: 1200,
276
+ line: "serverk: command failed (exit 1)",
277
+ },
278
+ {
279
+ code: 137,
280
+ elapsedMs: 4000,
281
+ line: "serverk: command failed (exit 137)",
282
+ },
283
+ {
284
+ code: 137,
285
+ elapsedMs: BOT_RUN_TIMEOUT_MS,
286
+ line: "serverk: command stopped after 10 minutes",
287
+ },
288
+ ];
289
+
290
+ describe("botRunEndLineOf", () => {
291
+ for (const item of END_LINES) {
292
+ it(`ends a command that exited ${item.code} after ${item.elapsedMs} ms with "${item.line}"`, () => {
293
+ expect(botRunEndLineOf(item.code, item.elapsedMs)).toBe(item.line);
294
+ });
295
+ }
296
+ });
@@ -0,0 +1,109 @@
1
+ import type { BridgeContext, BridgeReply } from "../context";
2
+ import { LAUNCHER_RUN_COMMAND } from "./botEnvironment";
3
+
4
+ export const BOT_RUN_MODE = "run";
5
+
6
+ export const BOT_RUN_TIMEOUT_MS = 600_000;
7
+
8
+ export const BOT_RUN_OUTPUT_LIMIT = 256 * 1024;
9
+
10
+ const EMPTY_LINE = "serverk: nothing to run";
11
+
12
+ const BUSY_LINE = "serverk: a command is already running";
13
+
14
+ const FINISHED_LINE = "serverk: command finished";
15
+
16
+ const STOPPED_LINE = "serverk: command stopped after 10 minutes";
17
+
18
+ const CUT_LINE = "serverk: output cut at 256 KiB";
19
+
20
+ export interface BotRunOptions {
21
+ launcherCommand: () => string[];
22
+ environment: (context: BridgeContext) => Record<string, string>;
23
+ }
24
+
25
+ export type BotRun = (context: BridgeContext, input: string) => Promise<BridgeReply | null>;
26
+
27
+ const replyOf = (sent: string, line: string | null): BridgeReply => {
28
+ return {
29
+ sent,
30
+ line,
31
+ groups: {},
32
+ };
33
+ };
34
+
35
+ export const botRunEndLineOf = (code: number, elapsedMs: number): string => {
36
+ if (code === 0) {
37
+ return FINISHED_LINE;
38
+ }
39
+
40
+ if (elapsedMs >= BOT_RUN_TIMEOUT_MS) {
41
+ return STOPPED_LINE;
42
+ }
43
+
44
+ return `serverk: command failed (exit ${code})`;
45
+ };
46
+
47
+ export const createBotRun = (options: BotRunOptions): BotRun => {
48
+ let running = false;
49
+
50
+ const execute = async (context: BridgeContext, command: string) => {
51
+ const startedAt = performance.now();
52
+
53
+ let printed = 0;
54
+ let cut = false;
55
+
56
+ try {
57
+ const result = await context.exec(options.launcherCommand(), {
58
+ timeoutMs: BOT_RUN_TIMEOUT_MS,
59
+ environment: {
60
+ ...options.environment(context),
61
+ [LAUNCHER_RUN_COMMAND]: command,
62
+ },
63
+ onOutput(line, stream) {
64
+ if (cut) {
65
+ return;
66
+ }
67
+
68
+ const size = Buffer.byteLength(line) + 1;
69
+
70
+ if (printed + size > BOT_RUN_OUTPUT_LIMIT) {
71
+ cut = true;
72
+
73
+ context.print(CUT_LINE);
74
+
75
+ return;
76
+ }
77
+
78
+ printed += size;
79
+
80
+ context.print(line, stream);
81
+ },
82
+ });
83
+
84
+ context.print(botRunEndLineOf(result.code, performance.now() - startedAt));
85
+ } catch (error) {
86
+ context.print(`serverk: command failed: ${error instanceof Error ? error.message : String(error)}`);
87
+ } finally {
88
+ running = false;
89
+ }
90
+ };
91
+
92
+ return async (context, input) => {
93
+ const command = input.trim();
94
+
95
+ if (command.length === 0) {
96
+ return replyOf(command, EMPTY_LINE);
97
+ }
98
+
99
+ if (running) {
100
+ return replyOf(command, BUSY_LINE);
101
+ }
102
+
103
+ running = true;
104
+
105
+ void execute(context, command).catch(() => undefined);
106
+
107
+ return replyOf(command, null);
108
+ };
109
+ };
@@ -1,5 +1,11 @@
1
1
  import { describe, expect, it } from "bun:test";
2
- import { BotDeploymentOutcome, BotDeploymentSource, DEPLOYMENT_STATE_FILE, readDeploymentState } from "./botState";
2
+ import {
3
+ BotDeploymentOutcome,
4
+ BotDeploymentSource,
5
+ BotDeploymentStage,
6
+ DEPLOYMENT_STATE_FILE,
7
+ readDeploymentState,
8
+ } from "./botState";
3
9
 
4
10
  const filesWith = (content: string | null) => {
5
11
  return {
@@ -29,6 +35,33 @@ describe("readDeploymentState", () => {
29
35
  expect(await readDeploymentState(filesWith(JSON.stringify(state)))).toEqual(state);
30
36
  });
31
37
 
38
+ it("reads a deployment that failed at the pre-deploy stage", async () => {
39
+ const state = {
40
+ deployment: "dep_a1b2c3d4",
41
+ source: BotDeploymentSource.Git,
42
+ ref: null,
43
+ commit: "abc1234abc1234abc1234abc1234abc1234abc12",
44
+ snapshot: null,
45
+ outcome: BotDeploymentOutcome.Failed,
46
+ stage: "pre-deploy",
47
+ detail: "alembic upgrade head exited with code 1",
48
+ finishedAt: "2026-10-20T10:00:00.000Z",
49
+ };
50
+
51
+ expect((await readDeploymentState(filesWith(JSON.stringify(state))))?.stage).toBe(BotDeploymentStage.PreDeploy);
52
+ });
53
+
54
+ it("runs the pre-deploy stage after the build and before the start", () => {
55
+ expect(Object.values(BotDeploymentStage)).toEqual([
56
+ BotDeploymentStage.Sync,
57
+ BotDeploymentStage.Snapshot,
58
+ BotDeploymentStage.Install,
59
+ BotDeploymentStage.Build,
60
+ BotDeploymentStage.PreDeploy,
61
+ BotDeploymentStage.Start,
62
+ ]);
63
+ });
64
+
32
65
  it("answers null for a missing, broken or foreign file", async () => {
33
66
  expect(await readDeploymentState(filesWith(null))).toBeNull();
34
67
  expect(await readDeploymentState(filesWith("{"))).toBeNull();
@@ -21,6 +21,7 @@ export enum BotDeploymentStage {
21
21
  Snapshot = "snapshot",
22
22
  Install = "install",
23
23
  Build = "build",
24
+ PreDeploy = "pre-deploy",
24
25
  Start = "start",
25
26
  }
26
27