Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/subsequent-orange-cricket.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@inkeep/agents-core": patch
---

Add shared getWaitUntil utility for Vercel serverless function lifetime extension
5 changes: 5 additions & 0 deletions agents-api/knip.config.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
import type { KnipConfig } from 'knip';

export default {
ignoreDependencies: [
// Dynamically imported by @inkeep/agents-core's getWaitUntil() at runtime.
// Must remain a dependency of agents-api so the dynamic import resolves.
'@vercel/functions',
],
ignoreIssues: {
'agents-api/tsdown.config.ts': ['files'],
// these are being disabled for now
Expand Down
16 changes: 4 additions & 12 deletions agents-api/src/createApp.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { createRoute, OpenAPIHono, z } from '@hono/zod-openapi';
import { OrgRoles } from '@inkeep/agents-core';
import { getWaitUntil, OrgRoles } from '@inkeep/agents-core';
import { githubRoutes } from '@inkeep/agents-work-apps/github';
import { Hono } from 'hono';
import { cors } from 'hono/cors';
Expand Down Expand Up @@ -307,17 +307,9 @@ function createAgentsHono(config: AppConfig) {

app.use('*', async (_c, next) => {
await next();
if (process.env.VERCEL) {
try {
const { waitUntil } = await import('@vercel/functions');
waitUntil(flushBatchProcessor());
} catch (importError) {
logger.debug(
{ error: importError },
'@vercel/functions import failed, flushing synchronously'
);
await flushBatchProcessor();
}
const waitUntil = await getWaitUntil();
if (waitUntil) {
waitUntil(flushBatchProcessor());
} else {
await flushBatchProcessor();
}
Expand Down
19 changes: 1 addition & 18 deletions agents-api/src/domains/manage/routes/scheduledTriggers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
getScheduledTriggerById,
getScheduledTriggerInvocationById,
getScheduledTriggerRunInfoBatch,
getWaitUntil,
interpolateTemplate,
listScheduledTriggerInvocationsPaginated,
listScheduledTriggersPaginated,
Expand Down Expand Up @@ -48,24 +49,6 @@ import {
} from '../../run/services/ScheduledTriggerService';
import { executeAgentAsync } from '../../run/services/TriggerService';

// Lazy-load waitUntil for Vercel serverless environments
let _waitUntil: ((promise: Promise<unknown>) => void) | undefined;
let _waitUntilResolved = false;

async function getWaitUntil(): Promise<((promise: Promise<unknown>) => void) | undefined> {
if (_waitUntilResolved) return _waitUntil;
_waitUntilResolved = true;
if (!process.env.VERCEL) return undefined;
try {
const mod = await import('@vercel/functions');
_waitUntil = mod.waitUntil;
} catch (e) {
const errorMessage = e instanceof Error ? e.message : String(e);
console.error('[ScheduledTriggers] Failed to import @vercel/functions:', errorMessage);
}
return _waitUntil;
}

const logger = getLogger('scheduled-triggers');

const app = new OpenAPIHono<{ Variables: ManageAppVariables }>();
Expand Down
17 changes: 1 addition & 16 deletions agents-api/src/domains/run/services/TriggerService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import {
getCredentialStoreLookupKeyFromRetrievalParams,
getFullProjectWithRelationIds,
getTriggerById,
getWaitUntil,
interpolateTemplate,
JsonTransformer,
setActiveAgentForConversation,
Expand All @@ -45,22 +46,6 @@ import { ExecutionHandler } from '../handlers/executionHandler';
import { createSSEStreamHelper } from '../utils/stream-helpers';
import { tracer } from '../utils/tracer';

let _waitUntil: ((promise: Promise<unknown>) => void) | undefined;
let _waitUntilResolved = false;

async function getWaitUntil(): Promise<((promise: Promise<unknown>) => void) | undefined> {
if (_waitUntilResolved) return _waitUntil;
_waitUntilResolved = true;
if (!process.env.VERCEL) return undefined;
try {
const mod = await import('@vercel/functions');
_waitUntil = mod.waitUntil;
} catch (e) {
console.error('[TriggerService] Failed to import @vercel/functions:', e);
}
return _waitUntil;
}

const logger = getLogger('TriggerService');
const ajv = new Ajv({ allErrors: true });

Expand Down
9 changes: 9 additions & 0 deletions packages/agents-core/knip.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import type { KnipConfig } from 'knip';

export default {
ignoreDependencies: [
// Dynamically imported at runtime from the host application's node_modules
// (agents-api). Not a direct dependency of agents-core by design.
'@vercel/functions',
],
} satisfies KnipConfig;
2 changes: 1 addition & 1 deletion packages/agents-core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,7 @@
"./package.json": "./package.json"
},
"scripts": {
"knip": "knip --directory ../.. --workspace packages/agents-core --dependencies",
"knip": "knip --directory ../.. --workspace packages/agents-core --config packages/agents-core/knip.config.ts --dependencies",
"build": "tsdown",
"dev": "pnpm build --watch",
"test": "vitest --run",
Expand Down
10 changes: 10 additions & 0 deletions packages/agents-core/src/types/@vercel__functions/index.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
/**
* Minimal type declarations for @vercel/functions.
* The actual package is a dependency of agents-api; agents-core uses
* dynamic `import('@vercel/functions')` that resolves at runtime from
* the host application's node_modules.
*/

declare module '@vercel/functions' {
export function waitUntil(promise: Promise<unknown>): void;
}
121 changes: 121 additions & 0 deletions packages/agents-core/src/utils/__tests__/wait-until.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';

const mockWaitUntil = vi.fn();
const mockLogger = {
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
debug: vi.fn(),
};

vi.mock('@vercel/functions', () => ({
waitUntil: mockWaitUntil,
}));

vi.mock('../logger', () => ({
getLogger: () => mockLogger,
}));

describe('getWaitUntil', () => {
let originalVercel: string | undefined;

beforeEach(async () => {
originalVercel = process.env.VERCEL;
delete process.env.VERCEL;
vi.clearAllMocks();
const mod = await import('../wait-until');
mod._resetWaitUntilCache();
});

afterEach(() => {
if (originalVercel !== undefined) {
process.env.VERCEL = originalVercel;
} else {
delete process.env.VERCEL;
}
});

it('should return undefined when process.env.VERCEL is not set', async () => {
delete process.env.VERCEL;
const { getWaitUntil } = await import('../wait-until');
const result = await getWaitUntil();
expect(result).toBeUndefined();
});

it('should return waitUntil function when VERCEL is set and import succeeds', async () => {
process.env.VERCEL = '1';
const { getWaitUntil, _resetWaitUntilCache } = await import('../wait-until');
_resetWaitUntilCache();
const result = await getWaitUntil();
expect(result).toBe(mockWaitUntil);
});

it('should return undefined when VERCEL is set but import fails, and log warning', async () => {
process.env.VERCEL = '1';

// Reset modules to install a throwing mock
vi.resetModules();
vi.doMock('@vercel/functions', () => {
throw new Error('Module not found');
});
vi.doMock('../logger', () => ({
getLogger: () => mockLogger,
}));

const { getWaitUntil } = await import('../wait-until');
const result = await getWaitUntil();
expect(result).toBeUndefined();
expect(mockLogger.warn).toHaveBeenCalledWith(
expect.objectContaining({ error: expect.any(Error) }),
'Failed to import @vercel/functions, waitUntil unavailable'
);

// Restore original mock for subsequent tests
vi.resetModules();
vi.doMock('@vercel/functions', () => ({
waitUntil: mockWaitUntil,
}));
vi.doMock('../logger', () => ({
getLogger: () => mockLogger,
}));
});

it('should cache result after first call (lazy singleton)', async () => {
process.env.VERCEL = '1';
const { getWaitUntil, _resetWaitUntilCache } = await import('../wait-until');
_resetWaitUntilCache();

const result1 = await getWaitUntil();
const result2 = await getWaitUntil();

expect(result1).toBeDefined();
expect(result2).toBeDefined();
// Same reference returned — singleton cached
expect(result1).toBe(result2);
});

it('should re-evaluate after _resetWaitUntilCache is called', async () => {
process.env.VERCEL = '1';
const { getWaitUntil, _resetWaitUntilCache } = await import('../wait-until');
_resetWaitUntilCache();

// First call with VERCEL set → returns a function
const result1 = await getWaitUntil();
expect(result1).toBeDefined();
expect(typeof result1).toBe('function');

// Reset cache and unset VERCEL → should re-evaluate and return undefined
_resetWaitUntilCache();
delete process.env.VERCEL;
const result2 = await getWaitUntil();
expect(result2).toBeUndefined();
});

it('should return undefined when process.env.VERCEL is empty string', async () => {
process.env.VERCEL = '';
const { getWaitUntil, _resetWaitUntilCache } = await import('../wait-until');
_resetWaitUntilCache();
const result = await getWaitUntil();
expect(result).toBeUndefined();
});
});
1 change: 1 addition & 0 deletions packages/agents-core/src/utils/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,3 +22,4 @@ export * from './template-interpolation';
export * from './third-party-mcp-servers';
export * from './tracer-factory';
export * from './trigger-auth';
export * from './wait-until';
42 changes: 42 additions & 0 deletions packages/agents-core/src/utils/wait-until.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { getLogger } from './logger';

const logger = getLogger('wait-until');

type WaitUntilFn = (promise: Promise<unknown>) => void;

let _importPromise: Promise<WaitUntilFn | undefined> | undefined;

/**
* Lazy-load and cache Vercel's `waitUntil` function.
*
* - On Vercel (`process.env.VERCEL` set): dynamically imports `@vercel/functions`
* and returns `waitUntil`, which extends the serverless function lifetime
* past the HTTP response so background work can complete.
* - Outside Vercel: returns `undefined`. Callers should let the promise
* execute naturally via the Node.js event loop (fire-and-forget with
* error handling).
* - Import failure: logs a warning and returns `undefined` (graceful degradation).
* - Result is cached after first call (lazy singleton). Concurrent callers
* share the same import promise to avoid duplicate imports.
*/
export async function getWaitUntil(): Promise<WaitUntilFn | undefined> {
if (_importPromise) return _importPromise;
_importPromise = (async () => {
if (!process.env.VERCEL) return undefined;
try {
const mod = await import('@vercel/functions');
return mod.waitUntil;
} catch (e) {
logger.warn({ error: e }, 'Failed to import @vercel/functions, waitUntil unavailable');
return undefined;
}
})();
return _importPromise;
}

/**
* Reset internal cache. Exposed only for testing.
*/
export function _resetWaitUntilCache(): void {
_importPromise = undefined;
}
Loading
Loading