Skip to content
Draft
16 changes: 16 additions & 0 deletions .github/workflows/deploy-reusable.yml
Original file line number Diff line number Diff line change
Expand Up @@ -674,6 +674,22 @@ jobs:
REQUIRE_APPROVAL: ${{ vars.REQUIRE_APPROVAL }}
HETZNER_BASE_IMAGE: ${{ vars.HETZNER_BASE_IMAGE }}
ARTIFACTS_BINDING_ENABLED: ${{ vars.ARTIFACTS_BINDING_ENABLED }}
CF_CONTAINER_ENABLED: ${{ vars.CF_CONTAINER_ENABLED }}
CF_CONTAINER_SLEEP_AFTER: ${{ vars.CF_CONTAINER_SLEEP_AFTER }}
CF_CONTAINER_PORT_READY_TIMEOUT_MS: ${{ vars.CF_CONTAINER_PORT_READY_TIMEOUT_MS }}
CF_CONTAINER_WAKE_TIMEOUT_MS: ${{ vars.CF_CONTAINER_WAKE_TIMEOUT_MS }}
CF_CONTAINER_CREATE_WORKSPACE_TIMEOUT_MS: ${{ vars.CF_CONTAINER_CREATE_WORKSPACE_TIMEOUT_MS }}
CF_CONTAINER_CLONE_FILTER: ${{ vars.CF_CONTAINER_CLONE_FILTER }}
CF_CONTAINER_VM_AGENT_PORT: ${{ vars.CF_CONTAINER_VM_AGENT_PORT }}
SANDBOX_ENABLED: ${{ vars.SANDBOX_ENABLED }}
MAX_CONCURRENT_SETUP_SESSIONS: ${{ vars.MAX_CONCURRENT_SETUP_SESSIONS }}
SETUP_SESSION_TTL_MS: ${{ vars.SETUP_SESSION_TTL_MS }}
SETUP_SESSION_CAPTURE_POLL_MS: ${{ vars.SETUP_SESSION_CAPTURE_POLL_MS }}
CODEX_DEVICE_AUTH_REQUEST_TIMEOUT_MS: ${{ vars.CODEX_DEVICE_AUTH_REQUEST_TIMEOUT_MS }}
SETUP_SESSION_SWEEP_MAX_CANDIDATES: ${{ vars.SETUP_SESSION_SWEEP_MAX_CANDIDATES }}
POOL_LEASE_BUFFER_MS: ${{ vars.POOL_LEASE_BUFFER_MS }}
SANDBOX_EXEC_TIMEOUT_MS: ${{ vars.SANDBOX_EXEC_TIMEOUT_MS }}
SANDBOX_VM_AGENT_PORT: ${{ vars.SANDBOX_VM_AGENT_PORT }}

- name: Re-deploy API Worker (with tail_consumers)
if: ${{ inputs.dry_run != true && steps.first_deploy.outputs.is_first == 'true' }}
Expand Down
2 changes: 2 additions & 0 deletions apps/api/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -377,6 +377,7 @@ INFOMANIAK_IP_POLL_INTERVAL_MS=3000

# Workspace idle timeout (ProjectData DO)
# WORKSPACE_IDLE_TIMEOUT_MS=7200000 # 2 hours — global default idle timeout before workspace is stopped (overridable per-project)
# WORKSPACE_IDLE_CHECK_INTERVAL_MS=300000 # 5 minutes — interval for ProjectData DO workspace idle checks
# WORKSPACE_STOPPED_TTL_MS=300000 # 5 minutes — auto-delete stopped workspaces after this TTL

# Durable Object RPC retry configuration (transient reset/overload errors)
Expand Down Expand Up @@ -635,6 +636,7 @@ INFOMANIAK_IP_POLL_INTERVAL_MS=3000
# MAX_NOTIFICATIONS_PER_USER=500 # Max stored notifications per user before oldest auto-deleted
# NOTIFICATION_AUTO_DELETE_AGE_MS=7776000000 # Auto-delete age (90 days)
# NOTIFICATION_PAGE_SIZE=50 # Default page size for list requests
# MAX_NOTIFICATION_PAGE_SIZE=100 # Max allowed page size for notification list requests
# NOTIFICATION_PROGRESS_BATCH_WINDOW_MS=300000 # Batch window for progress notifications (5 min)
# NOTIFICATION_DEDUP_WINDOW_MS=60000 # Dedup window for task_complete notifications (60s)
# NOTIFICATION_FULL_BODY_LENGTH=5000 # Max chars stored as fullMessage in notification metadata
Expand Down
16 changes: 13 additions & 3 deletions apps/api/src/durable-objects/node-lifecycle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@
DATABASE: D1Database;
NODE_WARM_TIMEOUT_MS?: string;
WORKSPACE_STOPPED_TTL_MS?: string;
NODE_LIFECYCLE_ALARM_RETRY_MS?: string;
};

interface StoredState {
Expand Down Expand Up @@ -266,7 +267,7 @@
if (state.status === 'destroying') {
// Already destroying — retry: schedule another alarm in case destruction
// hasn't been picked up by cron yet
await this.ctx.storage.setAlarm(Date.now() + DEFAULT_NODE_LIFECYCLE_ALARM_RETRY_MS);
await this.ctx.storage.setAlarm(Date.now() + this.getAlarmRetryMs());
return;
}

Expand Down Expand Up @@ -304,7 +305,7 @@
error: err instanceof Error ? err.message : String(err),
});
// Schedule retry (use recalculateAlarm to not delay pending workspace deletions)
await this.recalculateAlarm(Date.now() + DEFAULT_NODE_LIFECYCLE_ALARM_RETRY_MS);
await this.recalculateAlarm(Date.now() + this.getAlarmRetryMs());
}
}

Expand Down Expand Up @@ -336,6 +337,15 @@
}
}

private getAlarmRetryMs(): number {
const envValue = this.env.NODE_LIFECYCLE_ALARM_RETRY_MS;
if (envValue) {
const parsed = parseInt(envValue, 10);

Check warning on line 343 in apps/api/src/durable-objects/node-lifecycle.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Prefer `Number.parseInt` over `parseInt`.

See more on https://sonarcloud.io/project/issues?id=raphaeltm_simple-agent-manager&issues=AZ-YovE62Q-006oRxokS&open=AZ-YovE62Q-006oRxokS&pullRequest=1677
if (Number.isFinite(parsed) && parsed > 0) return parsed;
}
return DEFAULT_NODE_LIFECYCLE_ALARM_RETRY_MS;
}

private getWarmTimeoutMs(): number {
const envValue = this.env.NODE_WARM_TIMEOUT_MS;
if (envValue) {
Expand Down Expand Up @@ -415,7 +425,7 @@
});
// Leave the entry for retry on next alarm. Push deleteAt forward slightly
// to avoid tight retry loops.
entry.deleteAt = now + DEFAULT_NODE_LIFECYCLE_ALARM_RETRY_MS;
entry.deleteAt = now + this.getAlarmRetryMs();
await this.ctx.storage.put(key, entry);
}
}
Expand Down
7 changes: 5 additions & 2 deletions apps/api/src/durable-objects/notification.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,12 +14,12 @@
NotificationWsMessage,
} from '@simple-agent-manager/shared';
import {
DEFAULT_MAX_NOTIFICATION_PAGE_SIZE,
DEFAULT_MAX_NOTIFICATIONS_PER_USER,
DEFAULT_NOTIFICATION_AUTO_DELETE_AGE_MS,
DEFAULT_NOTIFICATION_DEDUP_WINDOW_MS,
DEFAULT_NOTIFICATION_PAGE_SIZE,
DEFAULT_NOTIFICATION_PROGRESS_BATCH_WINDOW_MS,
MAX_NOTIFICATION_PAGE_SIZE,
} from '@simple-agent-manager/shared';
import { DurableObject } from 'cloudflare:workers';

Expand All @@ -36,6 +36,7 @@
MAX_NOTIFICATIONS_PER_USER?: string;
NOTIFICATION_AUTO_DELETE_AGE_MS?: string;
NOTIFICATION_PAGE_SIZE?: string;
MAX_NOTIFICATION_PAGE_SIZE?: string;
NOTIFICATION_PROGRESS_BATCH_WINDOW_MS?: string;
NOTIFICATION_DEDUP_WINDOW_MS?: string;
};
Expand Down Expand Up @@ -218,9 +219,11 @@
unreadCount: number;
nextCursor: string | null;
}> {
const parsedMax = parseInt(this.env.MAX_NOTIFICATION_PAGE_SIZE || '', 10);

Check warning on line 222 in apps/api/src/durable-objects/notification.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Prefer `Number.parseInt` over `parseInt`.

See more on https://sonarcloud.io/project/issues?id=raphaeltm_simple-agent-manager&issues=AZ-YovEP2Q-006oRxokR&open=AZ-YovEP2Q-006oRxokR&pullRequest=1677
const maxPageSize = Number.isFinite(parsedMax) && parsedMax > 0 ? parsedMax : DEFAULT_MAX_NOTIFICATION_PAGE_SIZE;
const pageSize = Math.min(
options.limit || parseInt(this.env.NOTIFICATION_PAGE_SIZE || '') || DEFAULT_NOTIFICATION_PAGE_SIZE,
MAX_NOTIFICATION_PAGE_SIZE
maxPageSize
);

let query = `SELECT * FROM notifications WHERE user_id = ? AND dismissed_at IS NULL`;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import * as reconciliation from './reconciliation';
import type { Env } from './types';

export function computeProjectDataAlarmTime(sql: SqlStorage, env: Env): number | null {
const { idleCleanupTime, workspaceIdleCheckTime } = idleCleanup.computeIdleAlarmTimes(sql);
const { idleCleanupTime, workspaceIdleCheckTime } = idleCleanup.computeIdleAlarmTimes(sql, env);
const heartbeatTime = acpSessions.computeHeartbeatAlarmTime(sql, env);
const pollIntervalMs = Number.parseInt(env.MAILBOX_DELIVERY_POLL_INTERVAL_MS ?? '30000', 10);
const mailboxTime = mailbox.computeMailboxAlarmTime(sql, pollIntervalMs);
Expand Down
14 changes: 11 additions & 3 deletions apps/api/src/durable-objects/project-data/idle-cleanup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@
* Idle cleanup scheduling and workspace idle timeout management.
*/
import {
DEFAULT_WORKSPACE_IDLE_CHECK_INTERVAL_MS,
DEFAULT_WORKSPACE_IDLE_TIMEOUT_MS,
WORKSPACE_IDLE_CHECK_INTERVAL_MS,
} from '@simple-agent-manager/shared';

import { createModuleLogger, serializeError } from '../../lib/logger';
Expand Down Expand Up @@ -313,7 +313,10 @@
/**
* Compute the alarm time for idle cleanup and workspace idle checks.
*/
export function computeIdleAlarmTimes(sql: SqlStorage): {
export function computeIdleAlarmTimes(
sql: SqlStorage,
env?: { WORKSPACE_IDLE_CHECK_INTERVAL_MS?: string }
): {
idleCleanupTime: number | null;
workspaceIdleCheckTime: number | null;
} {
Expand All @@ -333,7 +336,12 @@
.toArray()[0];
const earliestActivity = earliestActivityRow ? parseMinEarliest(earliestActivityRow, 'idle_cleanup.min_activity') : null;
if (earliestActivity !== null) {
const nextCheck = earliestActivity + WORKSPACE_IDLE_CHECK_INTERVAL_MS;
const rawInterval = env?.WORKSPACE_IDLE_CHECK_INTERVAL_MS;
const parsedInterval = rawInterval ? parseInt(rawInterval, 10) : NaN;

Check warning on line 340 in apps/api/src/durable-objects/project-data/idle-cleanup.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Prefer `Number.parseInt` over `parseInt`.

See more on https://sonarcloud.io/project/issues?id=raphaeltm_simple-agent-manager&issues=AZ-YovBz2Q-006oRxokP&open=AZ-YovBz2Q-006oRxokP&pullRequest=1677

Check warning on line 340 in apps/api/src/durable-objects/project-data/idle-cleanup.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Prefer `Number.NaN` over `NaN`.

See more on https://sonarcloud.io/project/issues?id=raphaeltm_simple-agent-manager&issues=AZ-YovBz2Q-006oRxokQ&open=AZ-YovBz2Q-006oRxokQ&pullRequest=1677
const checkInterval = Number.isFinite(parsedInterval) && parsedInterval > 0
? parsedInterval
: DEFAULT_WORKSPACE_IDLE_CHECK_INTERVAL_MS;
const nextCheck = earliestActivity + checkInterval;
workspaceIdleCheckTime = Math.max(nextCheck, Date.now() + 60_000);
}

Expand Down
1 change: 1 addition & 0 deletions apps/api/src/durable-objects/project-data/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ export type Env = {
ACP_SESSION_DETECTION_WINDOW_MS?: string;
ACP_SESSION_MAX_FORK_DEPTH?: string;
WORKSPACE_IDLE_TIMEOUT_MS?: string;
WORKSPACE_IDLE_CHECK_INTERVAL_MS?: string;
KNOWLEDGE_MAX_ENTITIES_PER_PROJECT?: string;
KNOWLEDGE_MAX_OBSERVATIONS_PER_ENTITY?: string;
MAILBOX_ACK_TIMEOUT_MS?: string;
Expand Down
9 changes: 5 additions & 4 deletions apps/api/src/durable-objects/vm-agent-container.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,9 @@
import { Container, switchPort } from '@cloudflare/containers';
import {
DEFAULT_CF_CONTAINER_ACTIVE_WORK_MAX_MS,
DEFAULT_CF_CONTAINER_KEEPALIVE_RENEW_INTERVAL_MS,
DEFAULT_CF_CONTAINER_SLEEP_AFTER,
} from '@simple-agent-manager/shared';
import { desc, eq } from 'drizzle-orm';
import { drizzle } from 'drizzle-orm/d1';

Expand All @@ -7,10 +12,6 @@ import type { Env } from '../env';
import { log } from '../lib/logger';
import { signCallbackToken, signNodeCallbackToken, signNodeManagementToken } from '../services/jwt';

export const DEFAULT_CF_CONTAINER_SLEEP_AFTER = '1h';
export const DEFAULT_CF_CONTAINER_ACTIVE_WORK_MAX_MS = 2 * 60 * 60 * 1000;
export const DEFAULT_CF_CONTAINER_KEEPALIVE_RENEW_INTERVAL_MS = 5 * 60 * 1000;

export interface VmAgentContainerLaunchConfig {
nodeId: string;
workspaceId: string;
Expand Down
3 changes: 3 additions & 0 deletions apps/api/src/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -225,6 +225,8 @@ export interface Env extends WebhookTriggerEnv, TaskRecoveryEnv {
NODE_WARM_GRACE_PERIOD_MS?: string;
ORPHANED_WORKSPACE_GRACE_PERIOD_MS?: string;
CF_CONTAINER_TERMINAL_TASK_SWEEP_LIMIT?: string; // Max terminal cf-container nodes to destroy per cron run (default: 25)
NODE_LIFECYCLE_ALARM_RETRY_MS?: string; // Retry delay for DO alarm failures (default: 60000)
WORKSPACE_IDLE_CHECK_INTERVAL_MS?: string; // Interval for ProjectData DO workspace idle checks (default: 300000)
// Workspace idle timeout (global default, overridable per-project)
WORKSPACE_IDLE_TIMEOUT_MS?: string;
// Auto-delete stopped workspaces after this TTL (default: 300000 = 5 minutes)
Expand Down Expand Up @@ -551,6 +553,7 @@ export interface Env extends WebhookTriggerEnv, TaskRecoveryEnv {
MAX_NOTIFICATIONS_PER_USER?: string;
NOTIFICATION_AUTO_DELETE_AGE_MS?: string;
NOTIFICATION_PAGE_SIZE?: string;
MAX_NOTIFICATION_PAGE_SIZE?: string; // Max allowed page size for notification list requests (default: 100)
NOTIFICATION_PROGRESS_BATCH_WINDOW_MS?: string;
NOTIFICATION_DEDUP_WINDOW_MS?: string;
NOTIFICATION_FULL_BODY_LENGTH?: string;
Expand Down
2 changes: 1 addition & 1 deletion apps/api/src/services/vm-agent-container.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import { DEFAULT_CF_CONTAINER_SLEEP_AFTER } from '@simple-agent-manager/shared';
import { eq } from 'drizzle-orm';
import { drizzle } from 'drizzle-orm/d1';

import * as schema from '../db/schema';
import {
DEFAULT_CF_CONTAINER_SLEEP_AFTER,
type VmAgentContainer,
type VmAgentContainerLaunchConfig,
type VmAgentContainerLaunchSecrets,
Expand Down
2 changes: 1 addition & 1 deletion apps/api/tests/unit/cf-container-runtime-contract.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -123,7 +123,7 @@ describe('cf-container runtime spike contracts', () => {
const activityCallback = read('routes/projects/agent-activity-callback.ts');
const acpSessionsRoute = read('routes/projects/acp-sessions.ts');

expect(containerDo).toContain("export const DEFAULT_CF_CONTAINER_SLEEP_AFTER = '1h'");
expect(containerDo).toContain('DEFAULT_CF_CONTAINER_SLEEP_AFTER');
expect(containerService).toContain('DEFAULT_CF_CONTAINER_SLEEP_AFTER');
expect(containerDo).toContain('DEFAULT_CF_CONTAINER_ACTIVE_WORK_MAX_MS');
expect(containerDo).toContain('DEFAULT_CF_CONTAINER_KEEPALIVE_RENEW_INTERVAL_MS');
Expand Down
151 changes: 151 additions & 0 deletions apps/api/tests/unit/config-env-resolution.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
/**
* Tests verifying that configuration constants resolve from env vars
* with correct fallback to DEFAULT_* values.
*
* Covers:
* - NODE_LIFECYCLE_ALARM_RETRY_MS → DEFAULT_NODE_LIFECYCLE_ALARM_RETRY_MS
* - WORKSPACE_IDLE_CHECK_INTERVAL_MS → DEFAULT_WORKSPACE_IDLE_CHECK_INTERVAL_MS
* - MAX_NOTIFICATION_PAGE_SIZE → DEFAULT_MAX_NOTIFICATION_PAGE_SIZE
* - CF container constants moved to shared package
*/
import {
DEFAULT_CF_CONTAINER_ACTIVE_WORK_MAX_MS,
DEFAULT_CF_CONTAINER_KEEPALIVE_RENEW_INTERVAL_MS,
DEFAULT_CF_CONTAINER_SLEEP_AFTER,
DEFAULT_MAX_NOTIFICATION_PAGE_SIZE,
DEFAULT_NODE_LIFECYCLE_ALARM_RETRY_MS,
DEFAULT_WORKSPACE_IDLE_CHECK_INTERVAL_MS,
} from '@simple-agent-manager/shared';
import { describe, expect, it } from 'vitest';

describe('config env var resolution', () => {
describe('shared constants have expected default values', () => {
it('DEFAULT_NODE_LIFECYCLE_ALARM_RETRY_MS is 60 seconds', () => {
expect(DEFAULT_NODE_LIFECYCLE_ALARM_RETRY_MS).toBe(60_000);
});

it('DEFAULT_WORKSPACE_IDLE_CHECK_INTERVAL_MS is 5 minutes', () => {
expect(DEFAULT_WORKSPACE_IDLE_CHECK_INTERVAL_MS).toBe(5 * 60 * 1000);
});

it('DEFAULT_MAX_NOTIFICATION_PAGE_SIZE is 100', () => {
expect(DEFAULT_MAX_NOTIFICATION_PAGE_SIZE).toBe(100);
});

it('DEFAULT_CF_CONTAINER_SLEEP_AFTER is 1h', () => {
expect(DEFAULT_CF_CONTAINER_SLEEP_AFTER).toBe('1h');
});

it('DEFAULT_CF_CONTAINER_ACTIVE_WORK_MAX_MS is 2 hours', () => {
expect(DEFAULT_CF_CONTAINER_ACTIVE_WORK_MAX_MS).toBe(2 * 60 * 60 * 1000);
});

it('DEFAULT_CF_CONTAINER_KEEPALIVE_RENEW_INTERVAL_MS is 5 minutes', () => {
expect(DEFAULT_CF_CONTAINER_KEEPALIVE_RENEW_INTERVAL_MS).toBe(5 * 60 * 1000);
});
});

describe('computeIdleAlarmTimes env resolution edge cases', () => {
async function getIdleCheckTime(envValue?: string): Promise<number | null> {
const { computeIdleAlarmTimes } = await import(
'../../src/durable-objects/project-data/idle-cleanup'
);
const baseTime = Date.now() - 60_000;
const mockSql = {
exec: (query: string) => ({
toArray: () => {
if (query.includes('idle_cleanup_schedule')) return [{ earliest: null }];
if (query.includes('workspace_activity')) return [{ earliest: baseTime }];
return [];
},
}),
} as unknown as SqlStorage;
const env = envValue !== undefined ? { WORKSPACE_IDLE_CHECK_INTERVAL_MS: envValue } : undefined;
return computeIdleAlarmTimes(mockSql, env).workspaceIdleCheckTime;
}

it('falls back to default when env is empty string', async () => {
const result = await getIdleCheckTime('');
expect(result).not.toBeNull();
});

it('uses parsed value when env is a valid number', async () => {
const result = await getIdleCheckTime('600000');
expect(result).not.toBeNull();
// 600_000ms override differs from 300_000ms default — discriminating
const expectedWithDefault = await getIdleCheckTime(undefined);
expect(result).not.toBe(expectedWithDefault);
});

it('falls back to default when env is NaN', async () => {
const withNaN = await getIdleCheckTime('abc');
const withDefault = await getIdleCheckTime(undefined);
expect(withNaN).toBe(withDefault);
});

it('falls back to default when env is zero', async () => {
const withZero = await getIdleCheckTime('0');
const withDefault = await getIdleCheckTime(undefined);
expect(withZero).toBe(withDefault);
});

it('falls back to default when env is negative', async () => {
const withNeg = await getIdleCheckTime('-1000');
const withDefault = await getIdleCheckTime(undefined);
expect(withNeg).toBe(withDefault);
});
});

describe('computeIdleAlarmTimes respects WORKSPACE_IDLE_CHECK_INTERVAL_MS env', () => {
it('uses env value when provided (discriminating: differs from default)', async () => {
const { computeIdleAlarmTimes } = await import(
'../../src/durable-objects/project-data/idle-cleanup'
);

const baseTime = Date.now() - 60_000;
const mockSql = {
exec: (query: string) => ({
toArray: () => {
if (query.includes('idle_cleanup_schedule')) return [{ earliest: null }];
if (query.includes('workspace_activity')) return [{ earliest: baseTime }];
return [];
},
}),
} as unknown as SqlStorage;

const envOverrideMs = 600_000; // 10 min — differs from 5 min default
const result = computeIdleAlarmTimes(mockSql, {
WORKSPACE_IDLE_CHECK_INTERVAL_MS: String(envOverrideMs),
});

expect(result.workspaceIdleCheckTime).not.toBeNull();
const expectedCheck = baseTime + envOverrideMs;
const nowPlus60s = Date.now() + 60_000;
expect(result.workspaceIdleCheckTime).toBe(Math.max(expectedCheck, nowPlus60s));
});

it('falls back to default when env is not provided', async () => {
const { computeIdleAlarmTimes } = await import(
'../../src/durable-objects/project-data/idle-cleanup'
);

const baseTime = Date.now() - 60_000;
const mockSql = {
exec: (query: string) => ({
toArray: () => {
if (query.includes('idle_cleanup_schedule')) return [{ earliest: null }];
if (query.includes('workspace_activity')) return [{ earliest: baseTime }];
return [];
},
}),
} as unknown as SqlStorage;

const result = computeIdleAlarmTimes(mockSql);

expect(result.workspaceIdleCheckTime).not.toBeNull();
const expectedCheck = baseTime + DEFAULT_WORKSPACE_IDLE_CHECK_INTERVAL_MS;
const nowPlus60s = Date.now() + 60_000;
expect(result.workspaceIdleCheckTime).toBe(Math.max(expectedCheck, nowPlus60s));
});
});
});
Loading
Loading