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.
- package/AEC-PLUGINS-SOURCES.md +53 -0
- package/CHANGELOG.md +37 -2
- package/DESIGN-SKILLS-SOURCES.md +81 -0
- package/bin/cli.js +1 -1
- package/dist/providers/claude-sdk-provider.js +24 -0
- package/package.json +3 -1
- package/test/allowed-users-gate.test.ts +0 -98
- package/test/alvin-dispatch.test.ts +0 -220
- package/test/async-agent-chunk-flow.test.ts +0 -244
- package/test/async-agent-parser-staleness.test.ts +0 -412
- package/test/async-agent-parser-streamjson.test.ts +0 -273
- package/test/async-agent-parser.test.ts +0 -322
- package/test/async-agent-watcher.test.ts +0 -229
- package/test/background-bypass-integration.test.ts +0 -443
- package/test/background-bypass-stress.test.ts +0 -417
- package/test/background-bypass.test.ts +0 -127
- package/test/browser-webfetch.test.ts +0 -121
- package/test/claude-sdk-provider.test.ts +0 -115
- package/test/claude-sdk-tool-use-id.test.ts +0 -180
- package/test/console-timestamps.test.ts +0 -98
- package/test/cron-progress-ticker.test.ts +0 -76
- package/test/cron-restart-resilience.test.ts +0 -191
- package/test/cron-run-resolver.test.ts +0 -133
- package/test/cron-runjobnow-throw.test.ts +0 -100
- package/test/debounce.test.ts +0 -60
- package/test/delivery-registry.test.ts +0 -71
- package/test/exec-guard-metachars.test.ts +0 -110
- package/test/file-permissions.test.ts +0 -130
- package/test/i18n.test.ts +0 -108
- package/test/list-subagents-merged.test.ts +0 -172
- package/test/memory-extractor.test.ts +0 -151
- package/test/memory-layers.test.ts +0 -169
- package/test/memory-sdk-injection.test.ts +0 -146
- package/test/memory-stress-restart.test.ts +0 -337
- package/test/multi-session-stress.test.ts +0 -255
- package/test/platform-session-key.test.ts +0 -69
- package/test/process-manager.test.ts +0 -186
- package/test/registry.test.ts +0 -201
- package/test/session-pending-background.test.ts +0 -59
- package/test/session-persistence.test.ts +0 -195
- package/test/slack-progress-ticker.test.ts +0 -123
- package/test/slack-slash-command.test.ts +0 -61
- package/test/slack-test-connection.test.ts +0 -176
- package/test/stress-scenarios.test.ts +0 -356
- package/test/stuck-timer.test.ts +0 -116
- package/test/subagent-delivery-markdown-fallback.test.ts +0 -147
- package/test/subagent-delivery-platform-routing.test.ts +0 -232
- package/test/subagent-delivery.test.ts +0 -273
- package/test/subagent-final-text.test.ts +0 -132
- package/test/subagent-stats.test.ts +0 -119
- package/test/subagent-toolset-allowlist.test.ts +0 -146
- package/test/subagents-commands.test.ts +0 -64
- package/test/subagents-config.test.ts +0 -114
- package/test/subagents-depth.test.ts +0 -58
- package/test/subagents-inheritance.test.ts +0 -67
- package/test/subagents-name-resolver.test.ts +0 -122
- package/test/subagents-priority-reject.test.ts +0 -88
- package/test/subagents-queue.test.ts +0 -127
- package/test/subagents-shutdown.test.ts +0 -126
- package/test/subagents-toolset.test.ts +0 -71
- package/test/sync-task-timeout.test.ts +0 -153
- package/test/system-prompt-background-hint.test.ts +0 -65
- package/test/telegram-error-filter.test.ts +0 -85
- package/test/telegram-workspace-command.test.ts +0 -78
- package/test/timing-safe-bearer.test.ts +0 -65
- package/test/watchdog-brake.test.ts +0 -157
- package/test/watcher-pending-count.test.ts +0 -228
- package/test/watcher-zombie-fix.test.ts +0 -252
- package/test/web-server-integration.test.ts +0 -189
- package/test/web-server-resilience.test.ts +0 -118
- package/test/web-server-shutdown.test.ts +0 -117
- package/test/whatsapp-auth-resilience.test.ts +0 -96
- package/test/workspaces.test.ts +0 -196
- 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
|
-
});
|