plum-e2e 2.1.0 → 2.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -442,6 +442,56 @@ Stops the node started from the current folder. You can also stop individual run
442
442
 
443
443
  ---
444
444
 
445
+ ## 6. Notifications (Discord & Slack)
446
+
447
+ Plum can post a pass/fail summary to a Discord channel or Slack workspace after every automated or manual test run. The notification includes the job name, overall result, scenario counts, browser, tags, and a link to the full report.
448
+
449
+ ### Step 1 — Get a webhook URL
450
+
451
+ **Discord:**
452
+
453
+ 1. Open your Discord server, right-click the target channel → **Edit Channel**
454
+ 2. Go to **Integrations → Webhooks → New Webhook**
455
+ 3. Copy the webhook URL
456
+
457
+ **Slack:**
458
+
459
+ 1. Go to your workspace's [Slack App directory](https://api.slack.com/apps) → **Create New App → From scratch**
460
+ 2. Under **Features**, choose **Incoming Webhooks** and activate them
461
+ 3. Click **Add New Webhook to Workspace**, choose the channel, and copy the webhook URL
462
+
463
+ ### Step 2 — Configure in Plum
464
+
465
+ 1. Open the Plum UI and go to **Settings → Integrations**
466
+ 2. Paste your webhook URL(s) into the **Discord Webhook URL** and/or **Slack Webhook URL** fields
467
+ 3. Set **Public URL** to the base address of your Plum instance (e.g. `http://192.168.1.5:5173`). This is used to generate the "View Report" link in the notification. Leave it blank if you don't want report links included.
468
+ 4. Click **Save Integrations**
469
+
470
+ ### Step 3 — Enable notifications
471
+
472
+ **Scheduled runs:**
473
+
474
+ Open **Scheduled Tests**, click **Edit** (or **New Job**) on a job, and check the **Discord** and/or **Slack** boxes that appear at the bottom of the form. Each job has its own toggle so you can notify only on the jobs that matter.
475
+
476
+ > The notification toggles only appear if the corresponding webhook URL is configured in Settings.
477
+
478
+ **Manual runs:**
479
+
480
+ When at least one webhook is configured, small **Discord** and **Slack** buttons appear in the runner panel at the bottom of every page. Click a button to highlight it — the notification will fire when the run finishes.
481
+
482
+ ### What the notification contains
483
+
484
+ | Field | Example |
485
+ | ----------- | ------------------------------------------------------------ |
486
+ | Job / Run | `nightly-login-suite` or `Manual Run` |
487
+ | Status | ✅ PASS or ❌ FAIL |
488
+ | Results | `42 / 45 passed` |
489
+ | Browser | `chromium` |
490
+ | Tags | `@suite-login` |
491
+ | Report link | Button / link to the full HTML report (if Public URL is set) |
492
+
493
+ ---
494
+
445
495
  ## Command Reference
446
496
 
447
497
  | Command | Description |
@@ -49,6 +49,34 @@ function saveRegistry(registry) {
49
49
  fs.writeFileSync(REGISTRY_PATH, JSON.stringify(registry, null, 2), 'utf8');
50
50
  }
51
51
 
52
+ /**
53
+ * Returns the PID of the process listening on the given TCP port, or null.
54
+ * Uses lsof on macOS/Linux and netstat on Windows.
55
+ */
56
+ function findPidOnPort(port) {
57
+ const portStr = String(port);
58
+ try {
59
+ if (process.platform === 'win32') {
60
+ const out = execSync('netstat -ano', { encoding: 'utf8' });
61
+ for (const line of out.split('\n')) {
62
+ const upper = line.toUpperCase();
63
+ if (upper.includes(`:${portStr}`) && upper.includes('LISTENING')) {
64
+ const parts = line.trim().split(/\s+/);
65
+ const pid = parseInt(parts[parts.length - 1], 10);
66
+ if (!isNaN(pid) && pid > 0) return pid;
67
+ }
68
+ }
69
+ } else {
70
+ const out = execSync(`lsof -i :${portStr} -t -sTCP:LISTEN`, { encoding: 'utf8' }).trim();
71
+ if (out) {
72
+ const pid = parseInt(out.split('\n')[0].trim(), 10);
73
+ if (!isNaN(pid) && pid > 0) return pid;
74
+ }
75
+ }
76
+ } catch {}
77
+ return null;
78
+ }
79
+
52
80
  // Signal 0 performs the OS-level permission/existence check without actually
53
81
  // delivering a signal — the portable way to ask "is this pid alive?".
54
82
  function isAlive(pid) {
@@ -128,7 +156,13 @@ function startNode({ id, port, token }) {
128
156
 
129
157
  const child = spawn(process.execPath, [SERVER_PATH], {
130
158
  cwd: BACKEND_DIR,
131
- env: { ...process.env, NODE_TOKEN: token, PLUM_MODE: 'node', PORT: String(port) },
159
+ env: {
160
+ ...process.env,
161
+ NODE_TOKEN: token,
162
+ PLUM_MODE: 'node',
163
+ PORT: String(port),
164
+ RUNNER_ID: String(id)
165
+ },
132
166
  detached: true,
133
167
  stdio: ['ignore', out, out],
134
168
  windowsHide: true
@@ -146,14 +180,25 @@ function startNode({ id, port, token }) {
146
180
  /**
147
181
  * Stops the managed process for a runner. Returns true if a live process was
148
182
  * signalled, false if nothing was running.
183
+ *
184
+ * Falls back to port-based PID discovery when the registry entry is missing or
185
+ * its PID is stale, using the port stored in the entry or an explicit fallback.
149
186
  */
150
- function stopNode(id) {
187
+ function stopNode(id, fallbackPort = null) {
151
188
  const registry = loadRegistry();
152
189
  const entry = registry[id];
153
190
  let signalled = false;
154
- if (entry?.pid && isAlive(entry.pid)) {
191
+
192
+ let pid = entry?.pid && isAlive(entry.pid) ? entry.pid : null;
193
+
194
+ if (!pid) {
195
+ const port = fallbackPort ?? (entry?.port ? Number(entry.port) : null);
196
+ if (port) pid = findPidOnPort(port);
197
+ }
198
+
199
+ if (pid) {
155
200
  try {
156
- process.kill(entry.pid, 'SIGTERM');
201
+ process.kill(pid, 'SIGTERM');
157
202
  signalled = true;
158
203
  } catch {}
159
204
  }
@@ -171,6 +216,7 @@ module.exports = {
171
216
  isAlive,
172
217
  isLocalUrl,
173
218
  parsePort,
219
+ findPidOnPort,
174
220
  pruneDead,
175
221
  statusOf,
176
222
  prepareEnv,
@@ -0,0 +1,22 @@
1
+ /*
2
+ This file is part of Plum.
3
+
4
+ Plum is free software: you can redistribute it and/or modify
5
+ it under the terms of the GNU General Public License as published by
6
+ the Free Software Foundation, either version 3 of the License, or
7
+ (at your option) any later version.
8
+
9
+ Plum is distributed in the hope that it will be useful,
10
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
11
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12
+ GNU General Public License for more details.
13
+
14
+ You should have received a copy of the GNU General Public License
15
+ along with Plum. If not, see https://www.gnu.org/licenses/.
16
+ */
17
+ 📂 Loading tests from: /Users/silverlunah/Projects/plum/backend/tests
18
+ Backend running on port 3002 (node/runner mode)
19
+ 📂 Loading tests from: /Users/silverlunah/Projects/plum/backend/tests
20
+ Backend running on port 3002 (node/runner mode)
21
+ (node:14993) [DEP0190] DeprecationWarning: Passing args to a child process with shell option true can lead to security vulnerabilities, as the arguments are not escaped, only concatenated.
22
+ (Use `node --trace-deprecation ...` to show where the warning was created)
@@ -0,0 +1,20 @@
1
+ /*
2
+ This file is part of Plum.
3
+
4
+ Plum is free software: you can redistribute it and/or modify
5
+ it under the terms of the GNU General Public License as published by
6
+ the Free Software Foundation, either version 3 of the License, or
7
+ (at your option) any later version.
8
+
9
+ Plum is distributed in the hope that it will be useful,
10
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
11
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12
+ GNU General Public License for more details.
13
+
14
+ You should have received a copy of the GNU General Public License
15
+ along with Plum. If not, see https://www.gnu.org/licenses/.
16
+ */
17
+ 📂 Loading tests from: /Users/silverlunah/Projects/plum/backend/tests
18
+ Backend running on port 3002 (node/runner mode)
19
+ (node:23570) [DEP0190] DeprecationWarning: Passing args to a child process with shell option true can lead to security vulnerabilities, as the arguments are not escaped, only concatenated.
20
+ (Use `node --trace-deprecation ...` to show where the warning was created)
@@ -0,0 +1,43 @@
1
+ /*
2
+ This file is part of Plum.
3
+
4
+ Plum is free software: you can redistribute it and/or modify
5
+ it under the terms of the GNU General Public License as published by
6
+ the Free Software Foundation, either version 3 of the License, or
7
+ (at your option) any later version.
8
+
9
+ Plum is distributed in the hope that it will be useful,
10
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
11
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12
+ GNU General Public License for more details.
13
+
14
+ You should have received a copy of the GNU General Public License
15
+ along with Plum. If not, see https://www.gnu.org/licenses/.
16
+ */
17
+ 📂 Loading tests from: /Users/silverlunah/Projects/plum/backend/tests
18
+ node:events:487
19
+ throw er; // Unhandled 'error' event
20
+ ^
21
+
22
+ Error: listen EADDRINUSE: address already in use :::3002
23
+ at Server.setupListenHandle [as _listen2] (node:net:2008:16)
24
+ at listenInCluster (node:net:2065:12)
25
+ at Server.listen (node:net:2170:7)
26
+ at start (/Users/silverlunah/Projects/plum/backend/server.js:54:9)
27
+ at Object.<anonymous> (/Users/silverlunah/Projects/plum/backend/server.js:120:1)
28
+ at Module._compile (node:internal/modules/cjs/loader:1829:14)
29
+ at Module._extensions..js (node:internal/modules/cjs/loader:1969:10)
30
+ at Module.load (node:internal/modules/cjs/loader:1552:32)
31
+ at Module._load (node:internal/modules/cjs/loader:1354:12)
32
+ at wrapModuleLoad (node:internal/modules/cjs/loader:255:19)
33
+ Emitted 'error' event on Server instance at:
34
+ at emitErrorNT (node:net:2044:8)
35
+ at process.processTicksAndRejections (node:internal/process/task_queues:90:21) {
36
+ code: 'EADDRINUSE',
37
+ errno: -48,
38
+ syscall: 'listen',
39
+ address: '::',
40
+ port: 3002
41
+ }
42
+
43
+ Node.js v25.9.0
@@ -0,0 +1,20 @@
1
+ /*
2
+ This file is part of Plum.
3
+
4
+ Plum is free software: you can redistribute it and/or modify
5
+ it under the terms of the GNU General Public License as published by
6
+ the Free Software Foundation, either version 3 of the License, or
7
+ (at your option) any later version.
8
+
9
+ Plum is distributed in the hope that it will be useful,
10
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
11
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12
+ GNU General Public License for more details.
13
+
14
+ You should have received a copy of the GNU General Public License
15
+ along with Plum. If not, see https://www.gnu.org/licenses/.
16
+ */
17
+ 📂 Loading tests from: /Users/silverlunah/Projects/plum/backend/tests
18
+ Backend running on port 3003 (node/runner mode)
19
+ (node:23686) [DEP0190] DeprecationWarning: Passing args to a child process with shell option true can lead to security vulnerabilities, as the arguments are not escaped, only concatenated.
20
+ (Use `node --trace-deprecation ...` to show where the warning was created)
@@ -0,0 +1,20 @@
1
+ /*
2
+ This file is part of Plum.
3
+
4
+ Plum is free software: you can redistribute it and/or modify
5
+ it under the terms of the GNU General Public License as published by
6
+ the Free Software Foundation, either version 3 of the License, or
7
+ (at your option) any later version.
8
+
9
+ Plum is distributed in the hope that it will be useful,
10
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
11
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12
+ GNU General Public License for more details.
13
+
14
+ You should have received a copy of the GNU General Public License
15
+ along with Plum. If not, see https://www.gnu.org/licenses/.
16
+ */
17
+ 📂 Loading tests from: /Users/silverlunah/Projects/plum/backend/tests
18
+ Backend running on port 3004 (node/runner mode)
19
+ (node:23733) [DEP0190] DeprecationWarning: Passing args to a child process with shell option true can lead to security vulnerabilities, as the arguments are not escaped, only concatenated.
20
+ (Use `node --trace-deprecation ...` to show where the warning was created)
@@ -0,0 +1,20 @@
1
+ /*
2
+ This file is part of Plum.
3
+
4
+ Plum is free software: you can redistribute it and/or modify
5
+ it under the terms of the GNU General Public License as published by
6
+ the Free Software Foundation, either version 3 of the License, or
7
+ (at your option) any later version.
8
+
9
+ Plum is distributed in the hope that it will be useful,
10
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
11
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12
+ GNU General Public License for more details.
13
+
14
+ You should have received a copy of the GNU General Public License
15
+ along with Plum. If not, see https://www.gnu.org/licenses/.
16
+ */
17
+ 📂 Loading tests from: /Users/silverlunah/Projects/plum/backend/tests
18
+ Backend running on port 3005 (node/runner mode)
19
+ (node:23776) [DEP0190] DeprecationWarning: Passing args to a child process with shell option true can lead to security vulnerabilities, as the arguments are not escaped, only concatenated.
20
+ (Use `node --trace-deprecation ...` to show where the warning was created)
@@ -0,0 +1,8 @@
1
+ -- AlterTable Project: add webhook and public URL fields
2
+ ALTER TABLE "Project" ADD COLUMN "discordWebhookUrl" TEXT NOT NULL DEFAULT '';
3
+ ALTER TABLE "Project" ADD COLUMN "slackWebhookUrl" TEXT NOT NULL DEFAULT '';
4
+ ALTER TABLE "Project" ADD COLUMN "notifyPublicUrl" TEXT NOT NULL DEFAULT '';
5
+
6
+ -- AlterTable CronJob: add notification toggles
7
+ ALTER TABLE "CronJob" ADD COLUMN "notifyDiscord" BOOLEAN NOT NULL DEFAULT false;
8
+ ALTER TABLE "CronJob" ADD COLUMN "notifySlack" BOOLEAN NOT NULL DEFAULT false;
@@ -44,6 +44,8 @@ model CronJob {
44
44
  enabled Boolean @default(true)
45
45
  runnerId String?
46
46
  runnerIds String @default("built-in")
47
+ notifyDiscord Boolean @default(false)
48
+ notifySlack Boolean @default(false)
47
49
  runner Runner? @relation(fields: [runnerId], references: [id], onDelete: SetNull)
48
50
  createdAt DateTime @default(now())
49
51
  updatedAt DateTime @updatedAt
@@ -68,13 +70,16 @@ model Report {
68
70
  }
69
71
 
70
72
  model Project {
71
- id Int @id @default(autoincrement())
72
- name String @default("")
73
- logoUrl String @default("")
74
- testCasePrefix String @default("TC")
75
- testSuitePrefix String @default("TS")
76
- caseSeqNext Int @default(0)
77
- suiteSeqNext Int @default(0)
73
+ id Int @id @default(autoincrement())
74
+ name String @default("")
75
+ logoUrl String @default("")
76
+ testCasePrefix String @default("TC")
77
+ testSuitePrefix String @default("TS")
78
+ caseSeqNext Int @default(0)
79
+ suiteSeqNext Int @default(0)
80
+ discordWebhookUrl String @default("")
81
+ slackWebhookUrl String @default("")
82
+ notifyPublicUrl String @default("")
78
83
  }
79
84
 
80
85
  model User {
@@ -34,6 +34,15 @@ router.get('/ping', authGuard, (req, res) => {
34
34
  res.json({ ok: true, mode: process.env.PLUM_MODE || 'server' });
35
35
  });
36
36
 
37
+ // Graceful shutdown for node-mode processes (no-op on the primary server)
38
+ router.post('/shutdown', authGuard, (req, res) => {
39
+ if (process.env.PLUM_MODE !== 'node') {
40
+ return res.status(403).json({ error: 'Not a node runner' });
41
+ }
42
+ res.json({ ok: true });
43
+ setTimeout(() => process.exit(0), 200);
44
+ });
45
+
37
46
  // Start a remote test job
38
47
  router.post('/execute', authGuard, (req, res) => {
39
48
  const { tags, browser = 'chromium', workers = 1, tests = null } = req.body;
@@ -64,6 +64,16 @@ router.put('/:id', async (req, res) => {
64
64
 
65
65
  router.delete('/:id', async (req, res) => {
66
66
  try {
67
+ const runner = await runnerService.getById(req.params.id);
68
+ if (runner) {
69
+ try {
70
+ await fetch(`${runner.url}/api/shutdown`, {
71
+ method: 'POST',
72
+ headers: { Authorization: `Bearer ${runner.token}` },
73
+ signal: AbortSignal.timeout(3000)
74
+ });
75
+ } catch {}
76
+ }
67
77
  await runnerService.remove(req.params.id);
68
78
  res.json({ message: 'Runner deleted' });
69
79
  } catch (e) {
@@ -76,4 +76,31 @@ router.post('/test-prefixes/migrate', jwtAuth, async (req, res, next) => {
76
76
  }
77
77
  });
78
78
 
79
+ router.get('/integrations', async (req, res, next) => {
80
+ try {
81
+ const webhooks = await settingsService.getWebhooks();
82
+ res.json(webhooks);
83
+ } catch (e) {
84
+ next(e);
85
+ }
86
+ });
87
+
88
+ router.post('/integrations', async (req, res, next) => {
89
+ try {
90
+ const { discordWebhookUrl, slackWebhookUrl, notifyPublicUrl } = req.body;
91
+ const project = await settingsService.updateWebhooks({
92
+ discordWebhookUrl,
93
+ slackWebhookUrl,
94
+ notifyPublicUrl
95
+ });
96
+ res.json({
97
+ discordWebhookUrl: project.discordWebhookUrl,
98
+ slackWebhookUrl: project.slackWebhookUrl,
99
+ notifyPublicUrl: project.notifyPublicUrl
100
+ });
101
+ } catch (e) {
102
+ next(e);
103
+ }
104
+ });
105
+
79
106
  module.exports = router;
@@ -32,8 +32,16 @@ import pc from 'picocolors';
32
32
  import runnerProcess from '../lib/runnerProcess.js';
33
33
  import nodeRegister from '../lib/nodeRegister.js';
34
34
 
35
- const { isLocalUrl, parsePort, pruneDead, statusOf, prepareEnv, startNode, stopNode } =
36
- runnerProcess;
35
+ const {
36
+ isLocalUrl,
37
+ parsePort,
38
+ pruneDead,
39
+ statusOf,
40
+ prepareEnv,
41
+ startNode,
42
+ stopNode,
43
+ findPidOnPort
44
+ } = runnerProcess;
37
45
  const { generateToken, registerWithPrimary } = nodeRegister;
38
46
 
39
47
  const API_URL = process.env.PLUM_API_URL || 'http://localhost:3001';
@@ -68,6 +76,9 @@ async function deleteRunner(id) {
68
76
  /**
69
77
  * Resolves the display + control state for every runner: reachability (ping),
70
78
  * whether we own a live process for it, and whether we can control it at all.
79
+ *
80
+ * Local runners that are online but absent from the registry are automatically
81
+ * reclaimed by scanning their port for a running process.
71
82
  */
72
83
  async function describeRunners() {
73
84
  const runners = await fetchRunners();
@@ -77,7 +88,19 @@ async function describeRunners() {
77
88
  runners.map(async (r) => {
78
89
  const online = await pingRunner(r.id);
79
90
  const local = isLocalUrl(r.url);
80
- const managed = statusOf(r.id) === 'running';
91
+ let managed = statusOf(r.id) === 'running';
92
+
93
+ if (local && online && !managed) {
94
+ const port = Number(parsePort(r.url));
95
+ const pid = findPidOnPort(port);
96
+ if (pid) {
97
+ const registry = runnerProcess.loadRegistry();
98
+ registry[r.id] = { pid, port: String(port), startedAt: Date.now() };
99
+ runnerProcess.saveRegistry(registry);
100
+ managed = true;
101
+ }
102
+ }
103
+
81
104
  let state;
82
105
  if (managed) state = 'managed';
83
106
  else if (online) state = 'unmanaged';
@@ -181,7 +204,7 @@ async function runAction(r) {
181
204
  if (cancelled(confirmed) || !confirmed) return;
182
205
  const s = clack.spinner();
183
206
  s.start(`Deleting "${r.name}"...`);
184
- if (r.managed) stopNode(r.id);
207
+ if (r.local) stopNode(r.id, Number(parsePort(r.url)));
185
208
  try {
186
209
  await deleteRunner(r.id);
187
210
  s.stop(pc.green(`Deleted "${r.name}"`));
@@ -194,7 +217,11 @@ async function runAction(r) {
194
217
  async function addRunner() {
195
218
  const suggested = `node-${generateToken().slice(0, 6)}`;
196
219
 
197
- const name = await clack.text({ message: 'Runner name', placeholder: suggested, defaultValue: suggested });
220
+ const name = await clack.text({
221
+ message: 'Runner name',
222
+ placeholder: suggested,
223
+ defaultValue: suggested
224
+ });
198
225
  if (cancelled(name)) return;
199
226
 
200
227
  const port = await clack.text({
@@ -205,7 +232,11 @@ async function addRunner() {
205
232
  if (cancelled(port)) return;
206
233
 
207
234
  const defToken = process.env.NODE_TOKEN || generateToken();
208
- const token = await clack.text({ message: 'Auth token', placeholder: defToken, defaultValue: defToken });
235
+ const token = await clack.text({
236
+ message: 'Auth token',
237
+ placeholder: defToken,
238
+ defaultValue: defToken
239
+ });
209
240
  if (cancelled(token)) return;
210
241
 
211
242
  // Dev nodes run as a bare process on the host; the dockerized primary reaches
@@ -216,9 +247,19 @@ async function addRunner() {
216
247
  s.start(`Registering "${name}" with the primary...`);
217
248
  let id;
218
249
  try {
219
- const res = await registerWithPrimary({ primary: API_URL, name, url, token, browser: 'chromium' });
250
+ const res = await registerWithPrimary({
251
+ primary: API_URL,
252
+ name,
253
+ url,
254
+ token,
255
+ browser: 'chromium'
256
+ });
220
257
  id = res.id;
221
- s.stop(res.reused ? pc.green(`Reusing existing runner "${name}"`) : pc.green(`Registered "${name}" (id ${id})`));
258
+ s.stop(
259
+ res.reused
260
+ ? pc.green(`Reusing existing runner "${name}"`)
261
+ : pc.green(`Registered "${name}" (id ${id})`)
262
+ );
222
263
  } catch (e) {
223
264
  s.stop(pc.red(`Could not register "${name}": ${e.message}`));
224
265
  return;
package/backend/server.js CHANGED
@@ -53,7 +53,28 @@ async function start() {
53
53
 
54
54
  server.listen(port, async () => {
55
55
  console.log(`Backend running on port ${port}${isNodeMode ? ' (node/runner mode)' : ''}`);
56
- if (isNodeMode) return; // nodes don't watch files or schedule jobs
56
+ if (isNodeMode) {
57
+ // Self-register PID so manage-runners can track and stop this process.
58
+ const runnerId = process.env.RUNNER_ID;
59
+ if (runnerId) {
60
+ const { loadRegistry, saveRegistry } = require('./lib/runnerProcess');
61
+ const registry = loadRegistry();
62
+ registry[runnerId] = { pid: process.pid, port: String(port), startedAt: Date.now() };
63
+ saveRegistry(registry);
64
+
65
+ const cleanup = () => {
66
+ try {
67
+ const reg = loadRegistry();
68
+ delete reg[runnerId];
69
+ saveRegistry(reg);
70
+ } catch {}
71
+ };
72
+ process.once('SIGTERM', cleanup);
73
+ process.once('SIGINT', cleanup);
74
+ process.once('exit', cleanup);
75
+ }
76
+ return;
77
+ }
57
78
 
58
79
  // chokidar v5+ is ESM-only — use dynamic import to stay compatible with CJS
59
80
  let chokidar;