@serverkgg/bridge 0.1.12 → 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.
- package/package.json +1 -1
- package/rules/package.md +2 -1
- package/src/bot/botDeploy.test.ts +57 -0
- package/src/bot/botEnvironment.test.ts +21 -0
- package/src/bot/botEnvironment.ts +10 -0
- package/src/bot/botRun.test.ts +296 -0
- package/src/bot/botRun.ts +109 -0
- package/src/bot/botState.test.ts +34 -1
- package/src/bot/botState.ts +1 -0
- package/src/bot/botVariables.test.ts +22 -0
- package/src/bot/botVariables.ts +3 -0
- package/src/bot/detect/detect.test.ts +172 -0
- package/src/bot/detect/detect.ts +31 -0
- package/src/bot/detect/detectResult.ts +1 -0
- package/src/bot/detect/detectTable.test.ts +25 -2
- package/src/bot/detect/detectTable.ts +24 -1
- package/src/bot/detect/keys.test.ts +7 -0
- package/src/bot/detect/keys.ts +3 -0
- package/src/bot/detect/serverkJson.test.ts +21 -0
- package/src/bot/detect/serverkJson.ts +13 -1
- package/src/bot/index.ts +1 -0
- package/src/cli/link.ts +3 -3
- package/src/cli/main.ts +8 -0
- package/src/cli/readme.test.ts +470 -0
- package/src/cli/readme.ts +570 -0
- package/src/cli/validate.test.ts +41 -1
- package/src/cli/validate.ts +25 -0
- package/src/context/bridgeContext.ts +1 -0
- package/src/modules/bridgeModule.ts +1 -0
- package/src/protocol/bridgeManifest.ts +1 -0
- package/src/runtime/bridgeContext.test.ts +6 -0
- package/src/runtime/bridgeContext.ts +7 -2
- package/src/runtime/bridgeExec.test.ts +150 -0
- package/src/runtime/bridgeExec.ts +67 -19
- package/src/runtime/bridgeLog.ts +9 -0
- package/src/runtime/bridgeRedaction.test.ts +28 -2
- package/src/runtime/bridgeRedaction.ts +7 -1
- package/src/runtime/bridgeRegistry.test.ts +30 -0
- package/src/runtime/bridgeRegistry.ts +1 -0
- package/src/runtime/bridgeScopedContext.ts +3 -0
- package/src/runtime/bridgeSetupHandlers.test.ts +3 -0
- package/src/runtime/bridgeSupervisor.ts +2 -6
- package/src/runtime/serve.ts +9 -2
- package/src/wire/panel.test.ts +32 -0
- package/src/wire/panel.ts +1 -0
package/package.json
CHANGED
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
|
+
};
|
package/src/bot/botState.test.ts
CHANGED
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
import { describe, expect, it } from "bun:test";
|
|
2
|
-
import {
|
|
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();
|