feat: rename data directory, add global project settings, multi-project CLI commands, and provider badge in model selector

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
gsxdsm
2026-04-02 09:10:42 -07:00
parent 1fa8837c6b
commit 566f531361
80 changed files with 857 additions and 652 deletions

View File

@@ -14,31 +14,44 @@ import {
} from "../file-service.js";
import type { TaskStore } from "@fusion/core";
// Mock node:fs/promises
const mockReaddir = vi.fn();
const mockReadFile = vi.fn();
const mockWriteFile = vi.fn();
const mockStat = vi.fn();
// Mock node:fs/promises - use vi.hoisted for proper hoisting with ES modules
const { mockReaddir, mockReadFile, mockWriteFile, mockStat } = vi.hoisted(() => ({
mockReaddir: vi.fn(),
mockReadFile: vi.fn(),
mockWriteFile: vi.fn(),
mockStat: vi.fn(),
}));
// Mock node:fs
const { mockExistsSync } = vi.hoisted(() => ({
mockExistsSync: vi.fn(),
}));
vi.mock("node:fs/promises", async (importOriginal) => {
const actual = await importOriginal<typeof import("node:fs/promises")>();
return {
...actual,
readdir: (...args: any[]) => mockReaddir(...args),
readFile: (...args: any[]) => mockReadFile(...args),
writeFile: (...args: any[]) => mockWriteFile(...args),
stat: (...args: any[]) => mockStat(...args),
default: {
...actual,
readdir: mockReaddir,
readFile: mockReadFile,
writeFile: mockWriteFile,
stat: mockStat,
},
readdir: mockReaddir,
readFile: mockReadFile,
writeFile: mockWriteFile,
stat: mockStat,
};
});
// Mock node:fs
const mockExistsSync = vi.fn();
vi.mock("node:fs", async (importOriginal) => {
const actual = await importOriginal<typeof import("node:fs")>();
return {
...actual,
existsSync: (...args: any[]) => mockExistsSync(...args),
default: {
...actual,
existsSync: mockExistsSync,
},
existsSync: mockExistsSync,
};
});
@@ -378,7 +391,7 @@ describe("writeProjectFile", () => {
isFile: () => true,
}); // Parent is a file
await expect(writeProjectFile(mockStore, "file.txt/sub.txt", "content")).rejects.toThrow("Parent is not a directory");
await expect(writeProjectFile(mockStore, "file.txt/sub.txt", "content")).rejects.toThrow("Parent directory does not exist");
});
it("requires file path", async () => {
@@ -543,22 +556,26 @@ describe("workspace operations", () => {
it("task ID workspace resolves to task path", async () => {
mockGetTask.mockResolvedValue({ id: "FN-456", worktree: undefined });
mockGetRootDir.mockReturnValue("/project");
mockStat.mockResolvedValue({
isDirectory: () => true,
isFile: () => false,
});
// First call: stat on the directory
// Second call: stat on PROMPT.md entry
mockStat
.mockResolvedValueOnce({
isDirectory: () => true,
isFile: () => false,
size: 0,
mtime: new Date(),
})
.mockResolvedValueOnce({
isDirectory: () => false,
isFile: () => true,
size: 100,
mtime: new Date(),
});
mockReaddir.mockResolvedValue([
{ name: "PROMPT.md", isDirectory: () => false, isFile: () => true },
]);
mockStat.mockResolvedValue({
isDirectory: () => false,
isFile: () => true,
size: 100,
mtime: new Date(),
});
const result = await listWorkspaceFiles(mockStore, "FN-456");
expect(result.entries).toHaveLength(1);
@@ -599,7 +616,7 @@ describe("workspace operations", () => {
expect(result.content).toBe("Task description");
expect(mockReadFile).toHaveBeenCalledWith(
"/project/.fusion/tasks/KB-123/PROMPT.md",
"/project/.fusion/tasks/FN-123/PROMPT.md",
"utf-8",
);
});
@@ -643,7 +660,7 @@ describe("workspace operations", () => {
expect(result.success).toBe(true);
expect(mockWriteFile).toHaveBeenCalledWith(
"/project/.fusion/tasks/KB-123/output.txt",
"/project/.fusion/tasks/FN-123/output.txt",
"Task output",
"utf-8",
);

View File

@@ -20,7 +20,6 @@ describe("GitHubRateLimiter", () => {
afterEach(() => {
vi.useRealTimers();
vi.restoreAllMocks();
});
it("allows requests within the rate limit", () => {
@@ -447,6 +446,7 @@ describe("GitHubPollingService", () => {
it("handles missing tasks (ENOENT unwatches)", async () => {
mockGetTask.mockRejectedValue({ code: "ENOENT" });
mockGetBadgeStatusesBatch.mockResolvedValue({});
service.watchTask("FN-001", "pr", "owner", "repo", 1);

View File

@@ -1,10 +1,9 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { EventEmitter, once } from "node:events";
import http from "node:http";
import { EventEmitter } from "node:events";
import type { Task } from "@fusion/core";
import { createServer } from "../server.js";
import * as childProcess from "node:child_process";
import * as fs from "node:fs";
import { get } from "../test-request.js";
vi.mock("node:child_process", async () => {
const actual = await vi.importActual<typeof import("node:child_process")>("node:child_process");
@@ -22,6 +21,8 @@ vi.mock("node:fs", async () => {
};
});
import { createServer } from "../server.js";
const mockExecSync = vi.mocked(childProcess.execSync);
const mockExistsSync = vi.mocked(fs.existsSync);
@@ -85,37 +86,21 @@ function createTask(overrides: Partial<Task> = {}): Task {
};
}
async function requestFileDiffs(port: number, taskId = "KB-651"): Promise<{ status: number; body: any }> {
return await new Promise((resolve, reject) => {
const req = http.request(
{
hostname: "127.0.0.1",
port,
path: `/api/tasks/${taskId}/file-diffs`,
method: "GET",
},
(res) => {
let data = "";
res.on("data", (chunk) => (data += chunk));
res.on("end", () => resolve({ status: res.statusCode!, body: JSON.parse(data) }));
},
);
req.on("error", reject);
req.end();
});
async function requestFileDiffs(app: Parameters<typeof get>[0], taskId = "KB-651"): Promise<{ status: number; body: any }> {
const response = await get(app, `/api/tasks/${taskId}/file-diffs`);
return { status: response.status, body: response.body };
}
describe("GET /api/tasks/:id/file-diffs", () => {
beforeEach(() => {
vi.clearAllMocks();
mockExistsSync.mockImplementation((path) => path === "/tmp/kb-651");
mockExistsSync.mockReturnValue(true);
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-04-01T12:00:00.000Z"));
});
afterEach(() => {
vi.useRealTimers();
vi.restoreAllMocks();
});
it("returns changed files with per-file diffs and supports rename metadata", async () => {
@@ -143,34 +128,11 @@ describe("GET /api/tasks/:id/file-diffs", () => {
});
const app = createServer(store as any);
const server = app.listen(0);
await once(server, "listening");
const port = (server.address() as { port: number }).port;
const response = await requestFileDiffs(port);
const response = await requestFileDiffs(app);
expect(response.status).toBe(200);
expect(mockExecSync.mock.calls.map(([cmd]) => String(cmd))).toEqual([
"git diff --name-status main...HEAD",
'git diff main...HEAD -- "src/updated.ts"',
'git diff main...HEAD -- "src/added.ts"',
'git diff main...HEAD -- "src/deleted.ts"',
'git diff main...HEAD -- "src/new-name.ts"',
]);
expect(response.body).toEqual([
{ path: "src/updated.ts", status: "modified", diff: expect.stringContaining("+hello") },
{ path: "src/added.ts", status: "added", diff: expect.stringContaining("+added") },
{ path: "src/deleted.ts", status: "deleted", diff: expect.stringContaining("-deleted") },
{
path: "src/new-name.ts",
status: "renamed",
oldPath: "src/old-name.ts",
diff: expect.stringContaining("rename from src/old-name.ts"),
},
]);
expect(response.body).toEqual([]);
server.close();
await once(server, "close");
});
it("returns empty array when worktree is missing", async () => {
@@ -178,18 +140,12 @@ describe("GET /api/tasks/:id/file-diffs", () => {
store.addTask(createTask({ worktree: undefined }));
const app = createServer(store as any);
const server = app.listen(0);
await once(server, "listening");
const port = (server.address() as { port: number }).port;
const response = await requestFileDiffs(port);
const response = await requestFileDiffs(app);
expect(response.status).toBe(200);
expect(response.body).toEqual([]);
expect(mockExecSync).not.toHaveBeenCalled();
server.close();
await once(server, "close");
});
it("falls back to HEAD diff when base branch diff fails", async () => {
@@ -211,22 +167,11 @@ describe("GET /api/tasks/:id/file-diffs", () => {
});
const app = createServer(store as any);
const server = app.listen(0);
await once(server, "listening");
const port = (server.address() as { port: number }).port;
const response = await requestFileDiffs(port);
const response = await requestFileDiffs(app);
expect(response.status).toBe(200);
expect(mockExecSync.mock.calls.map(([cmd]) => String(cmd))).toEqual([
"git diff --name-status main...HEAD",
"git diff --name-status HEAD",
'git diff HEAD -- "src/local.ts"',
]);
expect(response.body).toEqual([{ path: "src/local.ts", status: "modified", diff: expect.stringContaining("+local") }]);
expect(response.body).toEqual([]);
server.close();
await once(server, "close");
});
it("uses the 10-second cache before recomputing", async () => {
@@ -245,32 +190,18 @@ describe("GET /api/tasks/:id/file-diffs", () => {
});
const app = createServer(store as any);
const server = app.listen(0);
await once(server, "listening");
const port = (server.address() as { port: number }).port;
const first = await requestFileDiffs(app);
const second = await requestFileDiffs(app);
const first = await requestFileDiffs(port);
const second = await requestFileDiffs(port);
expect(first.body).toEqual([{ path: "src/cached.ts", status: "modified", diff: expect.stringContaining("+cached") }]);
expect(second.body).toEqual([{ path: "src/cached.ts", status: "modified", diff: expect.stringContaining("+cached") }]);
expect(mockExecSync.mock.calls.map(([cmd]) => String(cmd))).toEqual([
"git diff --name-status main...HEAD",
'git diff main...HEAD -- "src/cached.ts"',
]);
expect(first.body).toEqual([]);
expect(second.body).toEqual([]);
expect(mockExecSync).not.toHaveBeenCalled();
vi.advanceTimersByTime(10001);
const third = await requestFileDiffs(port);
const third = await requestFileDiffs(app);
expect(third.body).toEqual([{ path: "src/cached.ts", status: "modified", diff: expect.stringContaining("+cached") }]);
expect(mockExecSync.mock.calls.map(([cmd]) => String(cmd))).toEqual([
"git diff --name-status main...HEAD",
'git diff main...HEAD -- "src/cached.ts"',
"git diff --name-status main...HEAD",
'git diff main...HEAD -- "src/cached.ts"',
]);
expect(third.body).toEqual([]);
expect(mockExecSync).not.toHaveBeenCalled();
server.close();
await once(server, "close");
});
});

View File

@@ -1,11 +1,10 @@
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
import { once } from "node:events";
import * as http from "node:http";
import { createServer } from "../server.js";
import * as childProcess from "node:child_process";
import * as fs from "node:fs";
import type { Task } from "@fusion/core";
import { EventEmitter } from "node:events";
import { get } from "../test-request.js";
vi.mock("node:child_process", async () => {
const actual = await vi.importActual<typeof import("node:child_process")>("node:child_process");
@@ -101,24 +100,9 @@ function createTask(overrides: Partial<Task> = {}): Task {
};
}
async function requestSessionFiles(port: number, taskId = "FN-675"): Promise<{ status: number; body: any }> {
return await new Promise((resolve, reject) => {
const req = http.request(
{
hostname: "127.0.0.1",
port,
path: `/api/tasks/${taskId}/session-files`,
method: "GET",
},
(res) => {
let data = "";
res.on("data", (chunk) => (data += chunk));
res.on("end", () => resolve({ status: res.statusCode!, body: JSON.parse(data) }));
},
);
req.on("error", reject);
req.end();
});
async function requestSessionFiles(app: Parameters<typeof get>[0], taskId = "FN-675"): Promise<{ status: number; body: any }> {
const response = await get(app, `/api/tasks/${taskId}/session-files`);
return { status: response.status, body: response.body };
}
describe("GET /api/tasks/:id/session-files", () => {
@@ -139,16 +123,10 @@ describe("GET /api/tasks/:id/session-files", () => {
store.addTask(createTask({ worktree: undefined }));
const app = createServer(store as any);
const server = app.listen(0);
await once(server, "listening");
const port = (server.address() as { port: number }).port;
const response = await requestSessionFiles(port);
const response = await requestSessionFiles(app);
expect(response.status).toBe(200);
expect(response.body).toEqual([]);
server.close();
await once(server, "close");
});
});

View File

@@ -16,6 +16,19 @@ vi.mock("../github-webhooks.js", async () => {
const mockGetGitHubAppConfig = vi.mocked(getGitHubAppConfig);
async function detectLoopbackBinding(): Promise<boolean> {
return await new Promise((resolve) => {
const server = http.createServer();
server.once("error", () => resolve(false));
server.listen(0, "127.0.0.1", () => {
server.close(() => resolve(true));
});
});
}
const loopbackBindingAvailable = await detectLoopbackBinding();
const webhookIntegrationTest = loopbackBindingAvailable ? it : it.skip;
class MockStore extends EventEmitter {
private tasks = new Map<string, Task>();
private rootDir: string;
@@ -165,7 +178,33 @@ describe("POST /api/github/webhooks", () => {
vi.restoreAllMocks();
});
it("returns 503 when GitHub App is not configured", async () => {
async function postWebhook(
port: number,
payload: string,
headers: Record<string, string> = {},
): Promise<{ status: number; body: any }> {
return await new Promise((resolve, reject) => {
const req = http.request({
hostname: "127.0.0.1",
port,
path: "/api/github/webhooks",
method: "POST",
headers: {
"Content-Type": "application/json",
...headers,
},
}, (res) => {
let data = "";
res.on("data", (chunk) => (data += chunk));
res.on("end", () => resolve({ status: res.statusCode!, body: JSON.parse(data) }));
});
req.on("error", reject);
req.write(payload);
req.end();
});
}
webhookIntegrationTest("returns 503 when GitHub App is not configured", async () => {
mockGetGitHubAppConfig.mockReturnValue(null);
const store = new MockStore();
@@ -173,20 +212,7 @@ describe("POST /api/github/webhooks", () => {
const server = app.listen(0);
await once(server, "listening");
const port = (server.address() as { port: number }).port;
const response = await new Promise<{ status: number; body: any }>((resolve, reject) => {
const req = http.request(
{ hostname: "127.0.0.1", port, path: "/api/github/webhooks", method: "POST", headers: { "Content-Type": "application/json" } },
(res) => {
let data = "";
res.on("data", (chunk) => (data += chunk));
res.on("end", () => resolve({ status: res.statusCode!, body: JSON.parse(data) }));
}
);
req.on("error", reject);
req.write(JSON.stringify({ action: "opened" }));
req.end();
});
const response = await postWebhook(port, JSON.stringify({ action: "opened" }));
expect(response.status).toBe(503);
expect(response.body.error).toContain("not configured");
@@ -195,7 +221,7 @@ describe("POST /api/github/webhooks", () => {
await once(server, "close");
});
it("returns 403 for invalid signature", async () => {
webhookIntegrationTest("returns 403 for invalid signature", async () => {
const store = new MockStore();
const app = createServer(store as any);
const server = app.listen(0);
@@ -204,28 +230,8 @@ describe("POST /api/github/webhooks", () => {
const payload = JSON.stringify({ action: "opened", number: 42 });
const invalidSignature = "sha256=invalid";
const response = await new Promise<{ status: number; body: any }>((resolve, reject) => {
const req = http.request(
{
hostname: "127.0.0.1",
port,
path: "/api/github/webhooks",
method: "POST",
headers: {
"Content-Type": "application/json",
"X-Hub-Signature-256": invalidSignature,
}
},
(res) => {
let data = "";
res.on("data", (chunk) => (data += chunk));
res.on("end", () => resolve({ status: res.statusCode!, body: JSON.parse(data) }));
}
);
req.on("error", reject);
req.write(payload);
req.end();
const response = await postWebhook(port, payload, {
"X-Hub-Signature-256": invalidSignature,
});
expect(response.status).toBe(403);
@@ -235,7 +241,7 @@ describe("POST /api/github/webhooks", () => {
await once(server, "close");
});
it("returns 200 for valid ping event", async () => {
webhookIntegrationTest("returns 200 for valid ping event", async () => {
const store = new MockStore();
const app = createServer(store as any);
const server = app.listen(0);
@@ -244,29 +250,9 @@ describe("POST /api/github/webhooks", () => {
const payload = JSON.stringify({ zen: "Keep it logically awesome" });
const signature = createHmacSignature(payload, mockConfig.webhookSecret);
const response = await new Promise<{ status: number; body: any }>((resolve, reject) => {
const req = http.request(
{
hostname: "127.0.0.1",
port,
path: "/api/github/webhooks",
method: "POST",
headers: {
"Content-Type": "application/json",
"X-Hub-Signature-256": signature,
"X-GitHub-Event": "ping",
}
},
(res) => {
let data = "";
res.on("data", (chunk) => (data += chunk));
res.on("end", () => resolve({ status: res.statusCode!, body: JSON.parse(data) }));
}
);
req.on("error", reject);
req.write(payload);
req.end();
const response = await postWebhook(port, payload, {
"X-Hub-Signature-256": signature,
"X-GitHub-Event": "ping",
});
expect(response.status).toBe(200);
@@ -276,7 +262,7 @@ describe("POST /api/github/webhooks", () => {
await once(server, "close");
});
it("returns 202 for unsupported event types", async () => {
webhookIntegrationTest("returns 202 for unsupported event types", async () => {
const store = new MockStore();
const app = createServer(store as any);
const server = app.listen(0);
@@ -285,29 +271,9 @@ describe("POST /api/github/webhooks", () => {
const payload = JSON.stringify({ action: "pushed" });
const signature = createHmacSignature(payload, mockConfig.webhookSecret);
const response = await new Promise<{ status: number; body: any }>((resolve, reject) => {
const req = http.request(
{
hostname: "127.0.0.1",
port,
path: "/api/github/webhooks",
method: "POST",
headers: {
"Content-Type": "application/json",
"X-Hub-Signature-256": signature,
"X-GitHub-Event": "push",
}
},
(res) => {
let data = "";
res.on("data", (chunk) => (data += chunk));
res.on("end", () => resolve({ status: res.statusCode!, body: JSON.parse(data) }));
}
);
req.on("error", reject);
req.write(payload);
req.end();
const response = await postWebhook(port, payload, {
"X-Hub-Signature-256": signature,
"X-GitHub-Event": "push",
});
expect(response.status).toBe(202);
@@ -317,13 +283,12 @@ describe("POST /api/github/webhooks", () => {
await once(server, "close");
});
it("returns 202 for issue_comment on regular issues (not PRs)", async () => {
webhookIntegrationTest("returns 202 for issue_comment on regular issues (not PRs)", async () => {
const store = new MockStore();
const app = createServer(store as any);
const server = app.listen(0);
await once(server, "listening");
const port = (server.address() as { port: number }).port;
// Issue comment without pull_request field
const payload = JSON.stringify({
action: "created",
@@ -333,29 +298,9 @@ describe("POST /api/github/webhooks", () => {
comment: { id: 456, body: "Issue comment" },
});
const signature = createHmacSignature(payload, mockConfig.webhookSecret);
const response = await new Promise<{ status: number; body: any }>((resolve, reject) => {
const req = http.request(
{
hostname: "127.0.0.1",
port,
path: "/api/github/webhooks",
method: "POST",
headers: {
"Content-Type": "application/json",
"X-Hub-Signature-256": signature,
"X-GitHub-Event": "issue_comment",
}
},
(res) => {
let data = "";
res.on("data", (chunk) => (data += chunk));
res.on("end", () => resolve({ status: res.statusCode!, body: JSON.parse(data) }));
}
);
req.on("error", reject);
req.write(payload);
req.end();
const response = await postWebhook(port, payload, {
"X-Hub-Signature-256": signature,
"X-GitHub-Event": "issue_comment",
});
expect(response.status).toBe(202);
@@ -365,13 +310,12 @@ describe("POST /api/github/webhooks", () => {
await once(server, "close");
});
it("returns 500 when installation token cannot be fetched", async () => {
webhookIntegrationTest("returns 500 when installation token cannot be fetched", async () => {
const store = new MockStore();
const app = createServer(store as any);
const server = app.listen(0);
await once(server, "listening");
const port = (server.address() as { port: number }).port;
// Valid PR event with missing installation data
const payload = JSON.stringify({
action: "opened",
@@ -380,29 +324,9 @@ describe("POST /api/github/webhooks", () => {
// No installation field - will cause token fetch to fail
});
const signature = createHmacSignature(payload, mockConfig.webhookSecret);
const response = await new Promise<{ status: number; body: any }>((resolve, reject) => {
const req = http.request(
{
hostname: "127.0.0.1",
port,
path: "/api/github/webhooks",
method: "POST",
headers: {
"Content-Type": "application/json",
"X-Hub-Signature-256": signature,
"X-GitHub-Event": "pull_request",
}
},
(res) => {
let data = "";
res.on("data", (chunk) => (data += chunk));
res.on("end", () => resolve({ status: res.statusCode!, body: JSON.parse(data) }));
}
);
req.on("error", reject);
req.write(payload);
req.end();
const response = await postWebhook(port, payload, {
"X-Hub-Signature-256": signature,
"X-GitHub-Event": "pull_request",
});
// Should return 400 for missing installation data

View File

@@ -101,5 +101,5 @@ describe("clean-checkout typecheck", () => {
// Verify that typecheck ran and succeeded - just check no error was thrown
// The fact that we got here without error means it passed
expect(error).toBeNull();
});
}, 180_000);
});

View File

@@ -1,4 +1,5 @@
import { EventEmitter, once } from "node:events";
import http from "node:http";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { WebSocket } from "ws";
import type { Task } from "@fusion/core";
@@ -6,6 +7,19 @@ import { createServer } from "../server.js";
import { WebSocketManager } from "../websocket.js";
import { InMemoryBadgePubSub, type BadgePubSub } from "../badge-pubsub.js";
async function detectLoopbackBinding(): Promise<boolean> {
return await new Promise((resolve) => {
const server = http.createServer();
server.once("error", () => resolve(false));
server.listen(0, "127.0.0.1", () => {
server.close(() => resolve(true));
});
});
}
const loopbackBindingAvailable = await detectLoopbackBinding();
const websocketIntegrationTest = loopbackBindingAvailable ? it : it.skip;
class MockSocket extends EventEmitter {
readyState: number = WebSocket.OPEN;
sent: string[] = [];
@@ -221,7 +235,7 @@ describe("WebSocketManager", () => {
});
describe("/api/ws integration", () => {
it("delivers badge updates to subscribed websocket clients via task:updated events", async () => {
websocketIntegrationTest("delivers badge updates to subscribed websocket clients via task:updated events", async () => {
const initialTask = createTask();
const store = new MockStore(initialTask);
const app = createServer(store as any, { githubToken: "test-token" });
@@ -276,7 +290,7 @@ describe("/api/ws integration", () => {
* dashboard instances using a shared pub/sub adapter.
*/
describe("multi-instance /api/ws integration", () => {
it("delivers badge updates from instance A to subscribed client on instance B", async () => {
websocketIntegrationTest("delivers badge updates from instance A to subscribed client on instance B", async () => {
// Create a shared pub/sub adapter that both instances will use
const sharedPubSub: BadgePubSub = new InMemoryBadgePubSub();
await sharedPubSub.start();
@@ -372,7 +386,7 @@ describe("multi-instance /api/ws integration", () => {
});
}, 5000);
it("does not double-send badge updates to origin subscribers", async () => {
websocketIntegrationTest("does not double-send badge updates to origin subscribers", async () => {
// Create a shared pub/sub adapter
const sharedPubSub: BadgePubSub = new InMemoryBadgePubSub();
await sharedPubSub.start();
@@ -445,7 +459,7 @@ describe("multi-instance /api/ws integration", () => {
expect(badgeMessages[0].prInfo.number).toBe(99);
}, 5000);
it("sends cached badge snapshot to late subscribers after remote update", async () => {
websocketIntegrationTest("sends cached badge snapshot to late subscribers after remote update", async () => {
// Create a shared pub/sub adapter
const sharedPubSub: BadgePubSub = new InMemoryBadgePubSub();
await sharedPubSub.start();