alvin-bot 4.18.0 → 4.18.2

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 (74) hide show
  1. package/AEC-PLUGINS-SOURCES.md +53 -0
  2. package/CHANGELOG.md +37 -2
  3. package/DESIGN-SKILLS-SOURCES.md +81 -0
  4. package/bin/cli.js +1 -1
  5. package/dist/providers/claude-sdk-provider.js +24 -0
  6. package/package.json +3 -1
  7. package/test/allowed-users-gate.test.ts +0 -98
  8. package/test/alvin-dispatch.test.ts +0 -220
  9. package/test/async-agent-chunk-flow.test.ts +0 -244
  10. package/test/async-agent-parser-staleness.test.ts +0 -412
  11. package/test/async-agent-parser-streamjson.test.ts +0 -273
  12. package/test/async-agent-parser.test.ts +0 -322
  13. package/test/async-agent-watcher.test.ts +0 -229
  14. package/test/background-bypass-integration.test.ts +0 -443
  15. package/test/background-bypass-stress.test.ts +0 -417
  16. package/test/background-bypass.test.ts +0 -127
  17. package/test/browser-webfetch.test.ts +0 -121
  18. package/test/claude-sdk-provider.test.ts +0 -115
  19. package/test/claude-sdk-tool-use-id.test.ts +0 -180
  20. package/test/console-timestamps.test.ts +0 -98
  21. package/test/cron-progress-ticker.test.ts +0 -76
  22. package/test/cron-restart-resilience.test.ts +0 -191
  23. package/test/cron-run-resolver.test.ts +0 -133
  24. package/test/cron-runjobnow-throw.test.ts +0 -100
  25. package/test/debounce.test.ts +0 -60
  26. package/test/delivery-registry.test.ts +0 -71
  27. package/test/exec-guard-metachars.test.ts +0 -110
  28. package/test/file-permissions.test.ts +0 -130
  29. package/test/i18n.test.ts +0 -108
  30. package/test/list-subagents-merged.test.ts +0 -172
  31. package/test/memory-extractor.test.ts +0 -151
  32. package/test/memory-layers.test.ts +0 -169
  33. package/test/memory-sdk-injection.test.ts +0 -146
  34. package/test/memory-stress-restart.test.ts +0 -337
  35. package/test/multi-session-stress.test.ts +0 -255
  36. package/test/platform-session-key.test.ts +0 -69
  37. package/test/process-manager.test.ts +0 -186
  38. package/test/registry.test.ts +0 -201
  39. package/test/session-pending-background.test.ts +0 -59
  40. package/test/session-persistence.test.ts +0 -195
  41. package/test/slack-progress-ticker.test.ts +0 -123
  42. package/test/slack-slash-command.test.ts +0 -61
  43. package/test/slack-test-connection.test.ts +0 -176
  44. package/test/stress-scenarios.test.ts +0 -356
  45. package/test/stuck-timer.test.ts +0 -116
  46. package/test/subagent-delivery-markdown-fallback.test.ts +0 -147
  47. package/test/subagent-delivery-platform-routing.test.ts +0 -232
  48. package/test/subagent-delivery.test.ts +0 -273
  49. package/test/subagent-final-text.test.ts +0 -132
  50. package/test/subagent-stats.test.ts +0 -119
  51. package/test/subagent-toolset-allowlist.test.ts +0 -146
  52. package/test/subagents-commands.test.ts +0 -64
  53. package/test/subagents-config.test.ts +0 -114
  54. package/test/subagents-depth.test.ts +0 -58
  55. package/test/subagents-inheritance.test.ts +0 -67
  56. package/test/subagents-name-resolver.test.ts +0 -122
  57. package/test/subagents-priority-reject.test.ts +0 -88
  58. package/test/subagents-queue.test.ts +0 -127
  59. package/test/subagents-shutdown.test.ts +0 -126
  60. package/test/subagents-toolset.test.ts +0 -71
  61. package/test/sync-task-timeout.test.ts +0 -153
  62. package/test/system-prompt-background-hint.test.ts +0 -65
  63. package/test/telegram-error-filter.test.ts +0 -85
  64. package/test/telegram-workspace-command.test.ts +0 -78
  65. package/test/timing-safe-bearer.test.ts +0 -65
  66. package/test/watchdog-brake.test.ts +0 -157
  67. package/test/watcher-pending-count.test.ts +0 -228
  68. package/test/watcher-zombie-fix.test.ts +0 -252
  69. package/test/web-server-integration.test.ts +0 -189
  70. package/test/web-server-resilience.test.ts +0 -118
  71. package/test/web-server-shutdown.test.ts +0 -117
  72. package/test/whatsapp-auth-resilience.test.ts +0 -96
  73. package/test/workspaces.test.ts +0 -196
  74. package/vitest.config.ts +0 -17
@@ -1,255 +0,0 @@
1
- /**
2
- * v4.12.0 — Multi-session end-to-end stress tests.
3
- *
4
- * Covers the full stack: workspace registry + session key + resolver +
5
- * persistence + cost aggregation. Validates that parallel sessions
6
- * across different channels/workspaces stay isolated, survive bot
7
- * restart, and report correct aggregated metrics.
8
- */
9
- import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
10
- import fs from "fs";
11
- import os from "os";
12
- import { resolve } from "path";
13
-
14
- const TEST_DATA_DIR = resolve(os.tmpdir(), `alvin-multi-stress-${process.pid}-${Date.now()}`);
15
-
16
- beforeEach(() => {
17
- if (fs.existsSync(TEST_DATA_DIR)) fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
18
- fs.mkdirSync(resolve(TEST_DATA_DIR, "workspaces"), { recursive: true });
19
- fs.mkdirSync(resolve(TEST_DATA_DIR, "memory"), { recursive: true });
20
- process.env.ALVIN_DATA_DIR = TEST_DATA_DIR;
21
- process.env.SESSION_MODE = "per-channel";
22
- vi.resetModules();
23
- });
24
-
25
- afterEach(() => {
26
- try { fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); } catch { /* ignore */ }
27
- });
28
-
29
- function writeWs(name: string, purpose: string, body: string, channels: string[] = []): void {
30
- const fm = [
31
- `purpose: ${JSON.stringify(purpose)}`,
32
- `cwd: ${JSON.stringify("~/tmp/" + name)}`,
33
- channels.length > 0 ? `channels: ${JSON.stringify(channels)}` : "",
34
- ].filter(Boolean).join("\n");
35
- fs.writeFileSync(
36
- resolve(TEST_DATA_DIR, "workspaces", `${name}.md`),
37
- `---\n${fm}\n---\n${body}`,
38
- );
39
- }
40
-
41
- describe("multi-session stress (v4.12.0)", () => {
42
- it("5 parallel Slack channels each get isolated sessions", async () => {
43
- writeWs("my-project", "my-project dev", "my-project persona", ["C_ALEV"]);
44
- writeWs("homes", "HOMES SaaS", "HOMES persona", ["C_HOMES"]);
45
- writeWs("my-landing", "my-landing app", "my-landing persona", ["C_JOBS"]);
46
- writeWs("perseus", "Trading bot", "Perseus persona", ["C_PERSEUS"]);
47
- writeWs("alvin", "Bot development", "Alvin persona", ["C_ALVIN"]);
48
-
49
- const { initWorkspaces, resolveWorkspaceOrDefault } = await import("../src/services/workspaces.js");
50
- const { buildSessionKey, getSession } = await import("../src/services/session.js");
51
- initWorkspaces();
52
-
53
- const channels = [
54
- { id: "C_ALEV", ws: "my-project" },
55
- { id: "C_HOMES", ws: "homes" },
56
- { id: "C_JOBS", ws: "my-landing" },
57
- { id: "C_PERSEUS", ws: "perseus" },
58
- { id: "C_ALVIN", ws: "alvin" },
59
- ];
60
-
61
- for (const { id, ws } of channels) {
62
- const workspace = resolveWorkspaceOrDefault("slack", id, undefined);
63
- expect(workspace.name).toBe(ws);
64
- const sessionKey = buildSessionKey("slack", id, "U_ALI");
65
- const session = getSession(sessionKey);
66
- session.workspaceName = workspace.name;
67
- session.workingDir = workspace.cwd;
68
- session.history.push({ role: "user", content: `hello from ${ws}` });
69
- session.sessionId = `sdk-${ws}`;
70
- session.totalCost = Math.random() * 0.1;
71
- session.messageCount = 1;
72
- }
73
-
74
- // Verify isolation: each session key is unique, each has its own workspace
75
- const { getAllSessions } = await import("../src/services/session.js");
76
- const allSessions = getAllSessions();
77
- const slackSessions = Array.from(allSessions.entries()).filter(([k]) => k.startsWith("slack:"));
78
- expect(slackSessions).toHaveLength(5);
79
- const wsNames = new Set(slackSessions.map(([, s]) => s.workspaceName));
80
- expect(wsNames.size).toBe(5);
81
- });
82
-
83
- it("survives full restart: 5 workspaces + 5 sessions persisted and rehydrated", async () => {
84
- writeWs("my-project", "my-project", "persona", ["C_ALEV"]);
85
- writeWs("homes", "HOMES", "persona", ["C_HOMES"]);
86
- writeWs("my-landing", "my-landing", "persona", ["C_JOBS"]);
87
- writeWs("perseus", "Perseus", "persona", ["C_PERSEUS"]);
88
- writeWs("alvin", "Alvin", "persona", ["C_ALVIN"]);
89
-
90
- const { initWorkspaces, resolveWorkspaceOrDefault } = await import("../src/services/workspaces.js");
91
- const { buildSessionKey, getSession } = await import("../src/services/session.js");
92
- const { flushSessions } = await import("../src/services/session-persistence.js");
93
- initWorkspaces();
94
-
95
- for (const id of ["C_ALEV", "C_HOMES", "C_JOBS", "C_PERSEUS", "C_ALVIN"]) {
96
- const ws = resolveWorkspaceOrDefault("slack", id, undefined);
97
- const key = buildSessionKey("slack", id, "U_ALI");
98
- const s = getSession(key);
99
- s.sessionId = `sdk-${ws.name}`;
100
- s.workspaceName = ws.name;
101
- s.workingDir = ws.cwd;
102
- s.history = [
103
- { role: "user", content: `persistent ${ws.name}` },
104
- { role: "assistant", content: `ack ${ws.name}` },
105
- ];
106
- }
107
- await flushSessions();
108
-
109
- // Simulate restart
110
- vi.resetModules();
111
- const s2 = await import("../src/services/session.js");
112
- const p2 = await import("../src/services/session-persistence.js");
113
- const loaded = p2.loadPersistedSessions();
114
- expect(loaded).toBe(5);
115
-
116
- for (const id of ["C_ALEV", "C_HOMES", "C_JOBS", "C_PERSEUS", "C_ALVIN"]) {
117
- const key = `slack:${id}`;
118
- const s = s2.getSession(key);
119
- expect(s.sessionId).toMatch(/^sdk-/);
120
- expect(s.history).toHaveLength(2);
121
- expect(s.workspaceName).not.toBeNull();
122
- }
123
- });
124
-
125
- it("getCostByWorkspace aggregates across sessions correctly", async () => {
126
- const { getSession, getCostByWorkspace } = await import("../src/services/session.js");
127
-
128
- const a = getSession("slack:C_A");
129
- a.workspaceName = "my-project";
130
- a.totalCost = 0.10;
131
- a.messageCount = 3;
132
- a.toolUseCount = 5;
133
-
134
- const b = getSession("slack:C_B");
135
- b.workspaceName = "my-project";
136
- b.totalCost = 0.05;
137
- b.messageCount = 2;
138
- b.toolUseCount = 1;
139
-
140
- const c = getSession("slack:C_C");
141
- c.workspaceName = "homes";
142
- c.totalCost = 0.25;
143
- c.messageCount = 10;
144
- c.toolUseCount = 8;
145
-
146
- const breakdown = getCostByWorkspace();
147
- expect(breakdown["my-project"].sessionCount).toBe(2);
148
- expect(breakdown["my-project"].messageCount).toBe(5);
149
- expect(breakdown["my-project"].toolUseCount).toBe(6);
150
- expect(breakdown["my-project"].totalCost).toBeCloseTo(0.15, 10);
151
- expect(breakdown["homes"].sessionCount).toBe(1);
152
- expect(breakdown["homes"].messageCount).toBe(10);
153
- expect(breakdown["homes"].toolUseCount).toBe(8);
154
- expect(breakdown["homes"].totalCost).toBeCloseTo(0.25, 10);
155
- });
156
-
157
- it("workspaces hot-reload picks up a new channel ID", async () => {
158
- writeWs("my-project", "my-project", "persona");
159
- const { initWorkspaces, resolveWorkspaceOrDefault, reloadWorkspaces } =
160
- await import("../src/services/workspaces.js");
161
- initWorkspaces();
162
-
163
- // Initially no channel mapping → default
164
- let ws = resolveWorkspaceOrDefault("slack", "C_NEW", undefined);
165
- expect(ws.name).toBe("default");
166
-
167
- // Add channel to config + reload
168
- writeWs("my-project", "my-project", "persona", ["C_NEW"]);
169
- reloadWorkspaces();
170
-
171
- ws = resolveWorkspaceOrDefault("slack", "C_NEW", undefined);
172
- expect(ws.name).toBe("my-project");
173
- });
174
-
175
- it("channel-name fallback finds workspace when no explicit ID mapping", async () => {
176
- writeWs("my-project", "my-project", "persona");
177
- const { initWorkspaces, resolveWorkspaceOrDefault } = await import("../src/services/workspaces.js");
178
- initWorkspaces();
179
-
180
- const ws = resolveWorkspaceOrDefault("slack", "C_UNMAPPED", "#my-project");
181
- expect(ws.name).toBe("my-project");
182
- });
183
-
184
- it("malformed workspace doesn't break loading of other workspaces", async () => {
185
- fs.writeFileSync(
186
- resolve(TEST_DATA_DIR, "workspaces", "broken.md"),
187
- "---\n{{{{ not yaml at all }}}}\n---\n",
188
- );
189
- writeWs("good", "good one", "body");
190
- const { initWorkspaces, listWorkspaces } = await import("../src/services/workspaces.js");
191
- initWorkspaces();
192
- const names = listWorkspaces().map(w => w.name);
193
- expect(names).toContain("good");
194
- });
195
-
196
- it("unicode in workspace filenames + bodies works", async () => {
197
- writeWs("café-int", "Café International ☕️", "Emoji persona 🦊", ["C_CAFE"]);
198
- const { initWorkspaces, resolveWorkspaceOrDefault } = await import("../src/services/workspaces.js");
199
- initWorkspaces();
200
- const ws = resolveWorkspaceOrDefault("slack", "C_CAFE", undefined);
201
- expect(ws.name).toBe("café-int");
202
- expect(ws.purpose).toContain("☕");
203
- expect(ws.systemPromptOverride).toContain("🦊");
204
- });
205
-
206
- it("workspace with no cwd frontmatter falls back to config.defaultWorkingDir", async () => {
207
- fs.writeFileSync(
208
- resolve(TEST_DATA_DIR, "workspaces", "no-cwd.md"),
209
- "---\npurpose: test\n---\nbody",
210
- );
211
- const { initWorkspaces, getWorkspace } = await import("../src/services/workspaces.js");
212
- initWorkspaces();
213
- const ws = getWorkspace("no-cwd");
214
- expect(ws!.cwd).toBeTruthy();
215
- expect(ws!.cwd.length).toBeGreaterThan(0);
216
- });
217
-
218
- it("session with workspaceName: null aggregates under 'default' in breakdown", async () => {
219
- const { getSession, getCostByWorkspace } = await import("../src/services/session.js");
220
- const s = getSession("slack:C_UNKNOWN");
221
- s.workspaceName = null;
222
- s.totalCost = 0.42;
223
- s.messageCount = 7;
224
-
225
- const breakdown = getCostByWorkspace();
226
- expect(breakdown["default"]).toBeDefined();
227
- expect(breakdown["default"].totalCost).toBeGreaterThanOrEqual(0.42);
228
- });
229
-
230
- it("simulated restart + workspace switch: workspaceName persists across flush cycles", async () => {
231
- writeWs("my-project", "my-project", "persona", ["C_ALEV"]);
232
- const { initWorkspaces, resolveWorkspaceOrDefault } = await import("../src/services/workspaces.js");
233
- const { buildSessionKey, getSession } = await import("../src/services/session.js");
234
- const { flushSessions } = await import("../src/services/session-persistence.js");
235
- initWorkspaces();
236
-
237
- const key = buildSessionKey("slack", "C_ALEV", "U_ALI");
238
- const s = getSession(key);
239
- const ws = resolveWorkspaceOrDefault("slack", "C_ALEV", undefined);
240
- s.sessionId = "alev-resume";
241
- s.workspaceName = ws.name;
242
- s.workingDir = ws.cwd;
243
- await flushSessions();
244
-
245
- vi.resetModules();
246
- const s2 = await import("../src/services/session.js");
247
- const p2 = await import("../src/services/session-persistence.js");
248
- p2.loadPersistedSessions();
249
-
250
- const restored = s2.getSession("slack:C_ALEV");
251
- expect(restored.sessionId).toBe("alev-resume");
252
- expect(restored.workspaceName).toBe("my-project");
253
- expect(restored.workingDir).toContain("my-project");
254
- });
255
- });
@@ -1,69 +0,0 @@
1
- /**
2
- * v4.12.0 — Platform session key must honor channelId, not just userId.
3
- *
4
- * Before v4.12.0 platform-message.ts used hashUserId(msg.userId) which
5
- * collapsed all channels from the same user into one session. This broke
6
- * multi-session on Slack where different channels should be isolated.
7
- *
8
- * The fix: route through buildSessionKey(platform, channelId, userId).
9
- */
10
- import { describe, it, expect, beforeEach, vi } from "vitest";
11
- import fs from "fs";
12
- import os from "os";
13
- import { resolve } from "path";
14
-
15
- const TEST_DATA_DIR = resolve(os.tmpdir(), `alvin-platform-key-${process.pid}-${Date.now()}`);
16
-
17
- beforeEach(() => {
18
- if (fs.existsSync(TEST_DATA_DIR)) fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
19
- fs.mkdirSync(TEST_DATA_DIR, { recursive: true });
20
- process.env.ALVIN_DATA_DIR = TEST_DATA_DIR;
21
- process.env.SESSION_MODE = "per-channel";
22
- vi.resetModules();
23
- });
24
-
25
- describe("buildSessionKey with string userIds (v4.12.0)", () => {
26
- it("per-channel mode returns platform:channelId", async () => {
27
- const { buildSessionKey } = await import("../src/services/session.js");
28
- const key = buildSessionKey("slack", "C01ABCDEF", "U01HIJKLM");
29
- expect(key).toBe("slack:C01ABCDEF");
30
- });
31
-
32
- it("per-channel-peer mode returns platform:channelId:userId", async () => {
33
- process.env.SESSION_MODE = "per-channel-peer";
34
- vi.resetModules();
35
- const { buildSessionKey } = await import("../src/services/session.js");
36
- const key = buildSessionKey("slack", "C01ABC", "U01XYZ");
37
- expect(key).toBe("slack:C01ABC:U01XYZ");
38
- });
39
-
40
- it("per-user mode returns just the userId as string", async () => {
41
- process.env.SESSION_MODE = "per-user";
42
- vi.resetModules();
43
- const { buildSessionKey } = await import("../src/services/session.js");
44
- const key = buildSessionKey("slack", "C01ABC", "U01XYZ");
45
- expect(key).toBe("U01XYZ");
46
- });
47
-
48
- it("two different channels for the same Slack user produce different session keys", async () => {
49
- const { buildSessionKey } = await import("../src/services/session.js");
50
- const a = buildSessionKey("slack", "C_ALEV_B", "U01XYZ");
51
- const b = buildSessionKey("slack", "C_HOMES", "U01XYZ");
52
- expect(a).not.toBe(b);
53
- });
54
-
55
- it("two different platforms with the same channel id produce different session keys", async () => {
56
- const { buildSessionKey } = await import("../src/services/session.js");
57
- const slack = buildSessionKey("slack", "ABC123", "U01");
58
- const discord = buildSessionKey("discord", "ABC123", "U01");
59
- expect(slack).not.toBe(discord);
60
- });
61
-
62
- it("backwards compat: numeric Telegram userIds still work (per-user)", async () => {
63
- process.env.SESSION_MODE = "per-user";
64
- vi.resetModules();
65
- const { buildSessionKey } = await import("../src/services/session.js");
66
- const key = buildSessionKey("telegram", "123456", 1234567890);
67
- expect(key).toBe("1234567890");
68
- });
69
- });
@@ -1,186 +0,0 @@
1
- /**
2
- * v4.13.1 — process-manager abstraction tests.
3
- *
4
- * The maintenance section in the Web UI used to hard-wire PM2 commands
5
- * (`pm2 jlist`, `pm2 restart`, `pm2 stop`, `pm2 logs ...`). Since v4.8
6
- * the Mac install uses launchd (`com.alvinbot.app.plist`) — PM2 isn't
7
- * running, so those calls returned "PM2 not available" and the buttons
8
- * did nothing.
9
- *
10
- * This module abstracts the process manager and auto-detects which one
11
- * is actually managing the bot. Detection order:
12
- *
13
- * 1. launchd (macOS) — if `launchctl print gui/$UID/com.alvinbot.app`
14
- * succeeds AND the bot's actual running pid matches
15
- * 2. PM2 — if `pm2 jlist` returns our process
16
- * 3. standalone — neither detected; only the in-process graceful
17
- * restart works (scheduleGracefulRestart — since there's no
18
- * supervisor to bring it back, "stop" is effectively "kill")
19
- *
20
- * Each manager implements: getStatus(), stop(), start(), getLogs().
21
- * Restart is intentionally NOT on the manager — it always routes through
22
- * scheduleGracefulRestart() (Grammy-safe) and the supervisor auto-brings-
23
- * back behaviour.
24
- */
25
- import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
26
-
27
- interface ExecCall {
28
- cmd: string;
29
- opts?: unknown;
30
- }
31
-
32
- let execLog: ExecCall[] = [];
33
- let execReturn: Record<string, string | Error> = {};
34
-
35
- function stubExec() {
36
- vi.doMock("node:child_process", () => ({
37
- execSync: (cmd: string, opts?: unknown) => {
38
- execLog.push({ cmd, opts });
39
- // Find match by pattern — longest matching prefix wins
40
- const matches = Object.keys(execReturn).filter((k) => cmd.includes(k));
41
- matches.sort((a, b) => b.length - a.length);
42
- const key = matches[0];
43
- if (key) {
44
- const v = execReturn[key];
45
- if (v instanceof Error) throw v;
46
- return v;
47
- }
48
- throw new Error(`execSync: no stub for ${cmd}`);
49
- },
50
- }));
51
- }
52
-
53
- beforeEach(() => {
54
- execLog = [];
55
- execReturn = {};
56
- vi.resetModules();
57
- stubExec();
58
- });
59
-
60
- afterEach(() => {
61
- vi.doUnmock("node:child_process");
62
- });
63
-
64
- describe("detectProcessManager (v4.13.1)", () => {
65
- it("detects 'launchd' when launchctl print succeeds on darwin", async () => {
66
- execReturn["launchctl print"] = `gui/502/com.alvinbot.app = {
67
- state = running
68
- program = /opt/homebrew/bin/node
69
- }`;
70
- const mod = await import("../src/services/process-manager.js");
71
- const pm = mod.detectProcessManager({ platform: "darwin" });
72
- expect(pm.kind).toBe("launchd");
73
- });
74
-
75
- it("falls through to 'pm2' when launchd is not detected", async () => {
76
- execReturn["launchctl print"] = new Error("Could not find service");
77
- execReturn["pm2 jlist"] = JSON.stringify([
78
- { name: "alvin-bot", pid: 1234, pm2_env: { status: "online" } },
79
- ]);
80
- const mod = await import("../src/services/process-manager.js");
81
- const pm = mod.detectProcessManager({ platform: "linux" });
82
- expect(pm.kind).toBe("pm2");
83
- });
84
-
85
- it("falls through to 'standalone' when neither is detected", async () => {
86
- execReturn["launchctl print"] = new Error("not found");
87
- execReturn["pm2 jlist"] = new Error("command not found");
88
- const mod = await import("../src/services/process-manager.js");
89
- const pm = mod.detectProcessManager({ platform: "linux" });
90
- expect(pm.kind).toBe("standalone");
91
- });
92
-
93
- it("skips launchd detection on non-darwin platforms", async () => {
94
- // No launchctl command should be issued on Linux
95
- execReturn["pm2 jlist"] = JSON.stringify([
96
- { name: "alvin-bot", pid: 1234, pm2_env: { status: "online" } },
97
- ]);
98
- const mod = await import("../src/services/process-manager.js");
99
- const pm = mod.detectProcessManager({ platform: "linux" });
100
- expect(pm.kind).toBe("pm2");
101
- // Verify launchctl was NOT called
102
- expect(execLog.some((e) => e.cmd.includes("launchctl"))).toBe(false);
103
- });
104
- });
105
-
106
- describe("launchd process manager (v4.13.1)", () => {
107
- it("getStatus parses launchctl print output for state + PID", async () => {
108
- execReturn["launchctl print"] = `gui/502/com.alvinbot.app = {
109
- active count = 1
110
- state = running
111
- program = /opt/homebrew/Cellar/node/25.9.0_1/bin/node
112
- pid = 65432
113
- program path = /usr/bin/node
114
- working directory = /Users/alvin_de/Projects/alvin-bot
115
- stdout path = /Users/alvin_de/.alvin-bot/logs/alvin-bot.out.log
116
- }`;
117
- const mod = await import("../src/services/process-manager.js");
118
- const pm = mod.createLaunchdManager(502);
119
- const status = await pm.getStatus();
120
- expect(status.status).toBe("running");
121
- expect(status.pid).toBe(65432);
122
- expect(status.kind).toBe("launchd");
123
- });
124
-
125
- it("getStatus returns 'not-loaded' when service is not registered", async () => {
126
- execReturn["launchctl print"] = new Error("Could not find service");
127
- const mod = await import("../src/services/process-manager.js");
128
- const pm = mod.createLaunchdManager(502);
129
- const status = await pm.getStatus();
130
- expect(status.status).toBe("not-loaded");
131
- });
132
-
133
- it("stop uses launchctl bootout", async () => {
134
- execReturn["launchctl bootout"] = "";
135
- const mod = await import("../src/services/process-manager.js");
136
- const pm = mod.createLaunchdManager(502);
137
- await pm.stop();
138
- const stopCall = execLog.find((e) => e.cmd.includes("bootout"));
139
- expect(stopCall).toBeDefined();
140
- expect(stopCall!.cmd).toContain("gui/502/com.alvinbot.app");
141
- });
142
-
143
- it("start uses launchctl bootstrap", async () => {
144
- execReturn["launchctl bootstrap"] = "";
145
- const mod = await import("../src/services/process-manager.js");
146
- const pm = mod.createLaunchdManager(502);
147
- await pm.start();
148
- const startCall = execLog.find((e) => e.cmd.includes("bootstrap"));
149
- expect(startCall).toBeDefined();
150
- expect(startCall!.cmd).toMatch(/com\.alvinbot\.app\.plist/);
151
- });
152
- });
153
-
154
- describe("pm2 process manager (v4.13.1)", () => {
155
- it("getStatus parses pm2 jlist for our process", async () => {
156
- execReturn["pm2 jlist"] = JSON.stringify([
157
- {
158
- name: "alvin-bot",
159
- pid: 9999,
160
- pm2_env: {
161
- status: "online",
162
- pm_uptime: Date.now() - 60_000,
163
- restart_time: 2,
164
- },
165
- monit: { memory: 123456, cpu: 1.5 },
166
- },
167
- ]);
168
- const mod = await import("../src/services/process-manager.js");
169
- const pm = mod.createPm2Manager();
170
- const status = await pm.getStatus();
171
- expect(status.status).toBe("online");
172
- expect(status.pid).toBe(9999);
173
- expect(status.kind).toBe("pm2");
174
- expect(status.restarts).toBe(2);
175
- });
176
-
177
- it("getStatus returns 'unknown' if pm2 jlist does not include our process", async () => {
178
- execReturn["pm2 jlist"] = JSON.stringify([
179
- { name: "other-service", pid: 1111, pm2_env: { status: "online" } },
180
- ]);
181
- const mod = await import("../src/services/process-manager.js");
182
- const pm = mod.createPm2Manager();
183
- const status = await pm.getStatus();
184
- expect(status.status).toBe("unknown");
185
- });
186
- });