Skip to content

Commit 24dfb21

Browse files
committed
fix(cloud): harden mock stack e2e harness
1 parent dd4b6db commit 24dfb21

18 files changed

Lines changed: 699 additions & 215 deletions

File tree

packages/cloud-api/v1/_container-control-plane-forward.ts

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -51,15 +51,19 @@ async function forwardControlPlaneRequest(
5151
configureHeaders(headers);
5252

5353
try {
54-
const upstream = await fetch(target, {
55-
body:
56-
c.req.method === "GET" || c.req.method === "HEAD"
57-
? undefined
58-
: c.req.raw.body,
54+
const body =
55+
c.req.method === "GET" || c.req.method === "HEAD"
56+
? undefined
57+
: c.req.raw.body;
58+
const init: RequestInit & { duplex?: "half" } = {
59+
body,
5960
headers,
6061
method: c.req.method,
6162
redirect: "manual",
62-
});
63+
};
64+
if (body) init.duplex = "half";
65+
66+
const upstream = await fetch(target, init);
6367

6468
return new Response(upstream.body, {
6569
headers: upstream.headers,

packages/cloud-api/v1/eliza/agents/[agentId]/resume/route.ts

Lines changed: 3 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -245,12 +245,8 @@ async function __hono_POST(
245245
const __hono_app = new Hono<AppEnv>();
246246
__hono_app.options("/", () => handleCorsOptions(CORS_METHODS));
247247
__hono_app.post("/", async (c) =>
248-
__hono_POST(
249-
c.req.raw,
250-
c.env,
251-
{
252-
params: Promise.resolve({ agentId: c.req.param("agentId")! }),
253-
},
254-
),
248+
__hono_POST(c.req.raw, c.env, {
249+
params: Promise.resolve({ agentId: c.req.param("agentId")! }),
250+
}),
255251
);
256252
export default __hono_app;

packages/cloud-api/v1/eliza/agents/[agentId]/snapshot/route.ts

Lines changed: 3 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -96,12 +96,8 @@ async function __hono_POST(
9696
const __hono_app = new Hono<AppEnv>();
9797
__hono_app.options("/", () => handleCorsOptions(CORS_METHODS));
9898
__hono_app.post("/", async (c) =>
99-
__hono_POST(
100-
c.req.raw,
101-
c.env,
102-
{
103-
params: Promise.resolve({ agentId: c.req.param("agentId")! }),
104-
},
105-
),
99+
__hono_POST(c.req.raw, c.env, {
100+
params: Promise.resolve({ agentId: c.req.param("agentId")! }),
101+
}),
106102
);
107103
export default __hono_app;

packages/cloud-api/v1/eliza/agents/[agentId]/suspend/route.ts

Lines changed: 3 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -131,12 +131,8 @@ async function __hono_POST(
131131
const __hono_app = new Hono<AppEnv>();
132132
__hono_app.options("/", () => handleCorsOptions(CORS_METHODS));
133133
__hono_app.post("/", async (c) =>
134-
__hono_POST(
135-
c.req.raw,
136-
c.env,
137-
{
138-
params: Promise.resolve({ agentId: c.req.param("agentId")! }),
139-
},
140-
),
134+
__hono_POST(c.req.raw, c.env, {
135+
params: Promise.resolve({ agentId: c.req.param("agentId")! }),
136+
}),
141137
);
142138
export default __hono_app;

packages/cloud-api/webhooks/bluebubbles/route.test.ts

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { beforeEach, describe, expect, mock, test } from "bun:test";
2+
import type { RegisterPhoneGatewayDeviceResult } from "@/lib/services/phone-gateway-devices";
23

34
const routePhoneMessage = mock(async () => ({
45
handled: true,
@@ -7,10 +8,12 @@ const routePhoneMessage = mock(async () => ({
78
userId: "user-1",
89
organizationId: "org-1",
910
}));
10-
const registerPhoneGatewayDevice = mock(async () => ({
11-
id: "gateway-device-1",
12-
registered: true,
13-
}));
11+
const registerPhoneGatewayDevice = mock(
12+
async (): Promise<RegisterPhoneGatewayDeviceResult> => ({
13+
id: "gateway-device-1",
14+
registered: true,
15+
}),
16+
);
1417

1518
mock.module("@/lib/services/agent-gateway-router", () => ({
1619
agentGatewayRouterService: {

packages/cloud-shared/src/db/client.ts

Lines changed: 47 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,11 @@ type Database = NodePgDatabase<typeof schema>;
3434
/** Transaction handle for `writeTransaction` callbacks. */
3535
type DbTransaction = NodePgTransaction<typeof schema, SchemaTables>;
3636

37+
interface ManagedConnection {
38+
database: Database;
39+
close?: () => Promise<void>;
40+
}
41+
3742
/**
3843
* Get the primary database URL (always required)
3944
*/
@@ -81,7 +86,7 @@ function parsePGliteDataDir(url: string): string {
8186
* call site type as `Database`; PGlite is bun/node-only and does not exist
8287
* on the Workers runtime.
8388
*/
84-
function createPGliteClient(dataDir: string): Database {
89+
function createPGliteClient(dataDir: string): ManagedConnection {
8590
const { PGlite } = require("@electric-sql/pglite") as typeof import("@electric-sql/pglite");
8691
const { vector } =
8792
require("@electric-sql/pglite/vector") as typeof import("@electric-sql/pglite/vector");
@@ -90,7 +95,12 @@ function createPGliteClient(dataDir: string): Database {
9095
extensions: { vector },
9196
});
9297
const database: PgliteDatabase<typeof schema> = drizzlePGlite({ client, schema });
93-
return database as Database;
98+
return {
99+
database: database as Database,
100+
close: async () => {
101+
await client.close();
102+
},
103+
};
94104
}
95105

96106
function isLocalTcpPostgresUrl(url: string): boolean {
@@ -149,7 +159,7 @@ function createPgPool(url: string): PgPool {
149159
/**
150160
* Create a database connection from a URL
151161
*/
152-
function createConnection(url: string): Database {
162+
function createConnection(url: string): ManagedConnection {
153163
if (url.startsWith("pglite://")) {
154164
if (isCloudflareWorkerRuntime()) {
155165
throw new Error("pglite:// URLs are local-only and cannot run inside a Cloudflare Worker.");
@@ -164,11 +174,21 @@ function createConnection(url: string): Database {
164174
neonConfig.webSocketConstructor = ws;
165175
}
166176
const pool = new NeonPool({ connectionString: url });
167-
return drizzleNeon(pool, { schema }) as Database;
177+
return {
178+
database: drizzleNeon(pool, { schema }) as Database,
179+
close: async () => {
180+
await pool.end();
181+
},
182+
};
168183
}
169184

170185
const pool = createPgPool(url);
171-
return drizzleNode(pool, { schema }) as Database;
186+
return {
187+
database: drizzleNode(pool, { schema }) as Database,
188+
close: async () => {
189+
await pool.end();
190+
},
191+
};
172192
}
173193

174194
// ============================================================================
@@ -207,7 +227,7 @@ export async function runWithDbCacheAsync<T>(fn: () => Promise<T>): Promise<T> {
207227
* can safely live for the lifetime of the process.
208228
*/
209229
class DatabaseConnectionManager {
210-
private connections: Map<string, Database> = new Map();
230+
private connections: Map<string, ManagedConnection> = new Map();
211231
private initialized = false;
212232

213233
/**
@@ -224,18 +244,18 @@ class DatabaseConnectionManager {
224244
if (requestCache) {
225245
let cached = requestCache.get(url);
226246
if (!cached) {
227-
cached = createConnection(url);
247+
cached = createConnection(url).database;
228248
requestCache.set(url, cached);
229249
}
230250
return cached;
231251
}
232-
return createConnection(url);
252+
return createConnection(url).database;
233253
}
234254

235255
if (!this.connections.has(url)) {
236256
this.connections.set(url, createConnection(url));
237257
}
238-
return this.connections.get(url)!;
258+
return this.connections.get(url)!.database;
239259
}
240260

241261
/**
@@ -265,6 +285,16 @@ class DatabaseConnectionManager {
265285
databaseUrlConfigured: !!applyDatabaseUrlFallback(env),
266286
};
267287
}
288+
289+
async closeAll(): Promise<void> {
290+
const connections = [...this.connections.values()];
291+
this.connections.clear();
292+
await Promise.all(
293+
connections.map(async (connection) => {
294+
await connection.close?.();
295+
}),
296+
);
297+
}
268298
}
269299

270300
const connectionManager = new DatabaseConnectionManager();
@@ -329,6 +359,14 @@ export function getDbConnectionInfo() {
329359
return connectionManager.getConnectionInfo();
330360
}
331361

362+
/**
363+
* Close process-level DB pools. Mainly used by local/E2E harnesses before
364+
* shutting down an ephemeral database server.
365+
*/
366+
export async function closeDbConnections(): Promise<void> {
367+
await connectionManager.closeAll();
368+
}
369+
332370
/**
333371
* Execute a read-intent query.
334372
*/

packages/cloud-shared/src/lib/services/api-keys.ts

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -254,11 +254,10 @@ export class ApiKeysService {
254254

255255
async revokeForAgent(agentSandboxId: string): Promise<void> {
256256
const name = ApiKeysService.agentApiKeyName(agentSandboxId);
257-
const keys = await apiKeysRepository.findByName(name);
257+
const keys = await apiKeysRepository.deleteByName(name);
258258
for (const key of keys) {
259259
await this.invalidateCache(key.key_hash);
260260
}
261-
await apiKeysRepository.deleteByName(name);
262261
}
263262
}
264263

packages/cloud-shared/src/lib/services/eliza-sandbox.ts

Lines changed: 17 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -516,7 +516,7 @@ export class ElizaSandboxService {
516516
}
517517

518518
async deleteAgent(agentId: string, orgId: string): Promise<DeleteAgentResult> {
519-
return dbWrite.transaction(async (tx) => {
519+
const result = await dbWrite.transaction(async (tx) => {
520520
await this.lockLifecycle(tx, agentId, orgId);
521521

522522
const rec = await this.getAgentForLifecycleMutation(tx, agentId, orgId);
@@ -584,23 +584,26 @@ export class ElizaSandboxService {
584584
`);
585585
const deletedSandbox = result.rows[0];
586586

587-
if (deletedSandbox) {
588-
// Best-effort: revoke the per-agent API key. A failure here doesn't
589-
// un-delete the sandbox; the key just lingers as inactive data.
590-
try {
591-
await apiKeysService.revokeForAgent(agentId);
592-
} catch (err) {
593-
logger.warn("[agent-sandbox] Failed to revoke per-agent API key", {
594-
agentId,
595-
error: err instanceof Error ? err.message : String(err),
596-
});
597-
}
598-
}
599-
600587
return deletedSandbox
601588
? ({ success: true, deletedSandbox } as const)
602589
: ({ success: false, error: "Agent not found" } as const);
603590
});
591+
592+
if (result.success) {
593+
// Best-effort: revoke the per-agent API key after the row delete commits.
594+
// A failure here does not un-delete the sandbox; the key just lingers as
595+
// inactive data and can be cleaned by ops.
596+
try {
597+
await apiKeysService.revokeForAgent(agentId);
598+
} catch (err) {
599+
logger.warn("[agent-sandbox] Failed to revoke per-agent API key", {
600+
agentId,
601+
error: err instanceof Error ? err.message : String(err),
602+
});
603+
}
604+
}
605+
606+
return result;
604607
}
605608

606609
/**

0 commit comments

Comments
 (0)