@mondaydotcomorg/atp-runtime
Version:
Runtime SDK injected into sandbox for Agent Tool Protocol
393 lines (347 loc) • 10.5 kB
text/typescript
import { AsyncLocalStorage } from 'async_hooks';
import { log } from '../log/index.js';
/**
* Execution-scoped state
*/
interface APICallRecord {
type: string;
operation: string;
payload: unknown;
result: unknown;
timestamp: number;
sequenceNumber: number;
}
interface ExecutionState {
shouldPauseForClient: boolean;
replayResults: Map<number, unknown> | undefined;
callSequenceNumber: number;
apiCallResults: APICallRecord[];
apiResultCache: Map<string, unknown> | undefined;
createdAt: number;
}
/**
* Map of executionId -> ExecutionState
* Each execution has its own isolated state
*/
const executionStates = new Map<string, ExecutionState>();
/**
* Maximum number of execution states to keep in memory
* After this limit, old states are automatically cleaned up
*/
const MAX_EXECUTION_STATES = 100;
/**
* Automatic cleanup check counter
* Every N operations, we check if cleanup is needed
*/
let operationCounter = 0;
const CLEANUP_CHECK_INTERVAL = 50;
/**
* AsyncLocalStorage for execution ID - provides proper async context isolation
* This ensures each async execution chain has its own isolated execution ID
*/
const executionContext = new AsyncLocalStorage<string>();
/**
* Current execution ID - set by runtime API wrappers
* This is a thread-local variable that's set before each runtime API call
* and cleared after, providing isolation even when AsyncLocalStorage fails
*/
let currentExecutionId: string | null = null;
/**
* Sets the current execution ID for this call
* Called by executor before each runtime API invocation
*/
export function setCurrentExecutionId(executionId: string): void {
currentExecutionId = executionId;
}
/**
* Clears the current execution ID after a call
* Called by executor after each runtime API invocation
*/
export function clearCurrentExecutionId(): void {
currentExecutionId = null;
}
/**
* Gets the current execution state
* Note: State must be initialized before calling this. Use initializeExecutionState() first.
*/
function getCurrentState(): ExecutionState {
let executionId = currentExecutionId;
if (!executionId) {
executionId = executionContext.getStore() || null;
}
if (!executionId) {
throw new Error(
'No execution context set. Executor must call setCurrentExecutionId() before runtime API calls.'
);
}
// Automatic cleanup check (every N operations)
operationCounter++;
if (operationCounter >= CLEANUP_CHECK_INTERVAL) {
operationCounter = 0;
autoCleanup();
}
let state = executionStates.get(executionId);
if (!state) {
// State should have been initialized explicitly at execution start
// Create it now with a safe default to prevent crashes
log.warn('State not initialized, creating with default. This should not happen.', {
executionId,
});
state = {
shouldPauseForClient: false,
replayResults: undefined,
callSequenceNumber: 0,
apiCallResults: [],
apiResultCache: undefined,
createdAt: Date.now(),
};
executionStates.set(executionId, state);
}
return state;
}
/**
* Initialize execution state with correct values at execution start
* This must be called before any state access to ensure correct pause mode
*/
export function initializeExecutionState(shouldPause: boolean): void {
const executionId = currentExecutionId || executionContext.getStore();
if (!executionId) {
throw new Error(
'No execution context set. Executor must call setCurrentExecutionId() before initializeExecutionState().'
);
}
const existingState = executionStates.get(executionId);
if (existingState) {
existingState.shouldPauseForClient = shouldPause;
if (!existingState.apiCallResults) {
existingState.apiCallResults = [];
}
if (!existingState.apiResultCache) {
existingState.apiResultCache = undefined;
}
return;
}
const state: ExecutionState = {
shouldPauseForClient: shouldPause,
replayResults: undefined,
callSequenceNumber: 0,
apiCallResults: [],
apiResultCache: undefined,
createdAt: Date.now(),
};
executionStates.set(executionId, state);
}
/**
* Runs a function within an execution context
* @param executionId - Unique ID for this execution
* @param fn - Function to run within the context
*/
export function runInExecutionContext<T>(executionId: string, fn: () => T): T {
return executionContext.run(executionId, fn);
}
/**
* Configures whether to pause execution for client services
* @param pause - If true, throws PauseExecutionError instead of calling callback
*/
export function setPauseForClient(pause: boolean): void {
const executionId = currentExecutionId || executionContext.getStore();
if (!executionId) {
throw new Error(
'No execution context set. Executor must call setCurrentExecutionId() before setPauseForClient().'
);
}
const state = executionStates.get(executionId);
if (!state) {
throw new Error('Execution state not initialized. Call initializeExecutionState() first.');
}
state.shouldPauseForClient = pause;
}
/**
* Checks if should pause for client
*/
export function shouldPauseForClient(): boolean {
const state = getCurrentState();
return state.shouldPauseForClient;
}
/**
* Sets up replay mode for resumption
* @param results - Map of sequence number to result for replaying callbacks
*/
export function setReplayMode(results: Map<number, unknown> | undefined): void {
const state = getCurrentState();
// Store replay results
state.replayResults = results;
// Always reset counter when setting replay mode
// - When entering replay mode with cached results: start from 0 to match first call
// - When clearing replay mode (results=undefined): reset to 0 for clean state
state.callSequenceNumber = 0;
}
/**
* Gets current call sequence number
*/
export function getCallSequenceNumber(): number {
const state = getCurrentState();
return state.callSequenceNumber;
}
/**
* Increments and returns the next sequence number
*/
export function nextSequenceNumber(): number {
const state = getCurrentState();
const current = state.callSequenceNumber;
state.callSequenceNumber++;
return current;
}
/**
* Check if we have a cached result for the current sequence
*/
export function getCachedResult(sequenceNumber: number): unknown | undefined {
const state = getCurrentState();
if (state.replayResults && state.replayResults.has(sequenceNumber)) {
return state.replayResults.get(sequenceNumber);
}
return undefined;
}
/**
* Check if we're in replay mode
*/
export function isReplayMode(): boolean {
return getCurrentState().replayResults !== undefined;
}
/**
* Store an API call result during execution
* This is used to track server-side API calls so they can be cached on resume
*/
export function storeAPICallResult(record: {
type: string;
operation: string;
payload: unknown;
result: unknown;
timestamp: number;
sequenceNumber: number;
}): void {
const state = getCurrentState();
state.apiCallResults.push(record);
}
/**
* Get all API call results tracked during this execution
* Used when building callback history on pause
*/
export function getAPICallResults(): APICallRecord[] {
const state = getCurrentState();
return state.apiCallResults;
}
/**
* Clear API call results (used when execution completes or fails)
*/
export function clearAPICallResults(): void {
const state = getCurrentState();
state.apiCallResults = [];
}
/**
* Set up API result cache for resume (operation-based, not sequence-based)
* This allows API calls to find their cached results even if execution order changes
*/
export function setAPIResultCache(cache: Map<string, unknown> | undefined): void {
const state = getCurrentState();
state.apiResultCache = cache;
}
/**
* Get API result from cache by operation name
*/
export function getAPIResultFromCache(operation: string): unknown | undefined {
const state = getCurrentState();
return state.apiResultCache?.get(operation);
}
/**
* Store API result in cache by operation name (for initial execution)
*/
export function storeAPIResultInCache(operation: string, result: unknown): void {
const state = getCurrentState();
if (!state.apiResultCache) {
state.apiResultCache = new Map();
}
state.apiResultCache.set(operation, result);
}
/**
* Cleanup a specific execution's state
* This should be called when an execution completes, fails, or is no longer needed
*/
export function cleanupExecutionState(executionId: string): void {
executionStates.delete(executionId);
if (currentExecutionId === executionId) {
currentExecutionId = null;
}
}
/**
* Automatic cleanup when state count exceeds maximum
* Removes oldest states to keep memory usage bounded
*/
function autoCleanup(): void {
if (executionStates.size <= MAX_EXECUTION_STATES) {
return;
}
const entries = Array.from(executionStates.entries()).sort(
(a, b) => a[1].createdAt - b[1].createdAt
);
const toRemove = executionStates.size - MAX_EXECUTION_STATES;
for (let i = 0; i < toRemove; i++) {
const entry = entries[i];
if (entry) {
executionStates.delete(entry[0]);
}
}
}
/**
* Cleanup old execution states to prevent memory leaks
* Removes states older than the specified max age (default: 1 hour)
*/
export function cleanupOldExecutionStates(maxAgeMs: number = 3600000): number {
const now = Date.now();
let cleaned = 0;
for (const [executionId, state] of executionStates.entries()) {
const age = now - state.createdAt;
if (age > maxAgeMs) {
executionStates.delete(executionId);
cleaned++;
}
}
return cleaned;
}
/**
* Reset ALL execution state - for testing purposes only
* WARNING: This will clear all execution states, breaking any in-flight executions
*/
export function resetAllExecutionState(): void {
executionStates.clear();
currentExecutionId = null;
}
/**
* Get execution state statistics - for monitoring/debugging
*/
export function getExecutionStateStats(): {
totalStates: number;
oldestStateAge: number | null;
newestStateAge: number | null;
executionIds: string[];
} {
const now = Date.now();
const executionIds = Array.from(executionStates.keys());
let oldestAge: number | null = null;
let newestAge: number | null = null;
for (const state of executionStates.values()) {
const age = now - state.createdAt;
if (oldestAge === null || age > oldestAge) {
oldestAge = age;
}
if (newestAge === null || age < newestAge) {
newestAge = age;
}
}
return {
totalStates: executionStates.size,
oldestStateAge: oldestAge,
newestStateAge: newestAge,
executionIds,
};
}