diff --git a/.github/workflows/deploy-reusable.yml b/.github/workflows/deploy-reusable.yml index 7c8bbe7028..baddeea376 100644 --- a/.github/workflows/deploy-reusable.yml +++ b/.github/workflows/deploy-reusable.yml @@ -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' }} diff --git a/apps/api/.env.example b/apps/api/.env.example index 78a72cfd25..1756695a83 100644 --- a/apps/api/.env.example +++ b/apps/api/.env.example @@ -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) @@ -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 diff --git a/apps/api/src/durable-objects/node-lifecycle.ts b/apps/api/src/durable-objects/node-lifecycle.ts index a506d3aaba..39381117fe 100644 --- a/apps/api/src/durable-objects/node-lifecycle.ts +++ b/apps/api/src/durable-objects/node-lifecycle.ts @@ -43,6 +43,7 @@ type NodeLifecycleEnv = { DATABASE: D1Database; NODE_WARM_TIMEOUT_MS?: string; WORKSPACE_STOPPED_TTL_MS?: string; + NODE_LIFECYCLE_ALARM_RETRY_MS?: string; }; interface StoredState { @@ -266,7 +267,7 @@ export class NodeLifecycle extends DurableObject { 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; } @@ -304,7 +305,7 @@ export class NodeLifecycle extends DurableObject { 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()); } } @@ -336,6 +337,15 @@ export class NodeLifecycle extends DurableObject { } } + private getAlarmRetryMs(): number { + const envValue = this.env.NODE_LIFECYCLE_ALARM_RETRY_MS; + if (envValue) { + const parsed = parseInt(envValue, 10); + 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) { @@ -415,7 +425,7 @@ export class NodeLifecycle extends DurableObject { }); // 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); } } diff --git a/apps/api/src/durable-objects/notification.ts b/apps/api/src/durable-objects/notification.ts index 1e9bafc8a5..d373419b81 100644 --- a/apps/api/src/durable-objects/notification.ts +++ b/apps/api/src/durable-objects/notification.ts @@ -14,12 +14,12 @@ import type { 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'; @@ -36,6 +36,7 @@ type Env = { 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; }; @@ -218,9 +219,11 @@ export class NotificationService extends DurableObject { unreadCount: number; nextCursor: string | null; }> { + const parsedMax = parseInt(this.env.MAX_NOTIFICATION_PAGE_SIZE || '', 10); + 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`; diff --git a/apps/api/src/durable-objects/project-data/alarm-schedule.ts b/apps/api/src/durable-objects/project-data/alarm-schedule.ts index 8377ea5a8a..d718c8a02b 100644 --- a/apps/api/src/durable-objects/project-data/alarm-schedule.ts +++ b/apps/api/src/durable-objects/project-data/alarm-schedule.ts @@ -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); diff --git a/apps/api/src/durable-objects/project-data/idle-cleanup.ts b/apps/api/src/durable-objects/project-data/idle-cleanup.ts index 8ee012126d..7c312c7dc3 100644 --- a/apps/api/src/durable-objects/project-data/idle-cleanup.ts +++ b/apps/api/src/durable-objects/project-data/idle-cleanup.ts @@ -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'; @@ -313,7 +313,10 @@ export async function checkWorkspaceIdleTimeouts( /** * 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; } { @@ -333,7 +336,12 @@ export function computeIdleAlarmTimes(sql: SqlStorage): { .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; + 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); } diff --git a/apps/api/src/durable-objects/project-data/types.ts b/apps/api/src/durable-objects/project-data/types.ts index 4e014f83d0..58efffd0f7 100644 --- a/apps/api/src/durable-objects/project-data/types.ts +++ b/apps/api/src/durable-objects/project-data/types.ts @@ -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; diff --git a/apps/api/src/durable-objects/vm-agent-container.ts b/apps/api/src/durable-objects/vm-agent-container.ts index 49a06aef7e..4c17e2fe0c 100644 --- a/apps/api/src/durable-objects/vm-agent-container.ts +++ b/apps/api/src/durable-objects/vm-agent-container.ts @@ -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'; @@ -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; diff --git a/apps/api/src/env.ts b/apps/api/src/env.ts index f0b06806a3..ec38c20ee9 100644 --- a/apps/api/src/env.ts +++ b/apps/api/src/env.ts @@ -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) @@ -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; diff --git a/apps/api/src/services/vm-agent-container.ts b/apps/api/src/services/vm-agent-container.ts index 4b6cc0c8f8..dafdce27e0 100644 --- a/apps/api/src/services/vm-agent-container.ts +++ b/apps/api/src/services/vm-agent-container.ts @@ -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, diff --git a/apps/api/tests/unit/cf-container-runtime-contract.test.ts b/apps/api/tests/unit/cf-container-runtime-contract.test.ts index 2f0024ddf4..c7bc81c5a0 100644 --- a/apps/api/tests/unit/cf-container-runtime-contract.test.ts +++ b/apps/api/tests/unit/cf-container-runtime-contract.test.ts @@ -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'); diff --git a/apps/api/tests/unit/config-env-resolution.test.ts b/apps/api/tests/unit/config-env-resolution.test.ts new file mode 100644 index 0000000000..8ef1ae3c8b --- /dev/null +++ b/apps/api/tests/unit/config-env-resolution.test.ts @@ -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 { + 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)); + }); + }); +}); diff --git a/apps/api/tests/unit/deploy-resync-env-parity.test.ts b/apps/api/tests/unit/deploy-resync-env-parity.test.ts new file mode 100644 index 0000000000..8476ce536d --- /dev/null +++ b/apps/api/tests/unit/deploy-resync-env-parity.test.ts @@ -0,0 +1,107 @@ +/** + * Regression test: the re-sync step in deploy-reusable.yml must forward + * every optional env var that the initial sync step forwards. + * + * Before this fix, 16 optional env vars (container/sandbox config) were + * present in the initial sync but missing from the re-sync on first deploy, + * silently losing operator-configured overrides. + */ +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; + +import { describe, expect, it } from 'vitest'; + +describe('deploy-reusable.yml sync step env parity', () => { + const workflowPath = resolve(__dirname, '../../../../.github/workflows/deploy-reusable.yml'); + const workflow = readFileSync(workflowPath, 'utf-8'); + + /** + * Extract the env: block from a GitHub Actions step by its exact name. + * Steps may have `if:`, `run:`, `working-directory:` etc between `name:` and `env:`. + */ + function extractEnvBlock(stepName: string): Set { + const lines = workflow.split('\n'); + let inStep = false; + let inEnv = false; + let envIndent = 0; + const vars = new Set(); + + for (const line of lines) { + // Detect the start of the target step + if (line.includes(`- name: ${stepName}`)) { + inStep = true; + inEnv = false; + continue; + } + + if (inStep) { + // Detect start of a new step (ends this step's scope) + if (line.match(/^\s+- name: /) && !line.includes(stepName)) { + break; + } + + // Detect `env:` key within this step + const envMatch = line.match(/^(\s+)env:\s*$/); + if (envMatch) { + inEnv = true; + envIndent = envMatch[1].length; + continue; + } + + if (inEnv) { + // Lines inside the env block are indented deeper than `env:` + const varMatch = line.match(/^(\s+)(\w+):/); + if (varMatch && varMatch[1].length > envIndent) { + vars.add(varMatch[2]); + } else if (line.trim() !== '' && !line.match(/^\s{1,}#/)) { + // Non-empty, non-comment line at same or lesser indent => env block ended + const indent = line.match(/^(\s*)/)?.[1].length ?? 0; + if (indent <= envIndent) { + inEnv = false; + } + } + } + } + } + + return vars; + } + + it('re-sync step forwards every optional env var from the initial sync step', () => { + const initialSyncVars = extractEnvBlock('Sync Wrangler Config (API + Tail Worker)'); + const resyncVars = extractEnvBlock('Re-sync Wrangler Config (add tail_consumers)'); + + expect(initialSyncVars.size).toBeGreaterThan(0); + expect(resyncVars.size).toBeGreaterThan(0); + + const missingFromResync = [...initialSyncVars].filter((v) => !resyncVars.has(v)); + expect(missingFromResync).toEqual([]); + }); + + it('both sync steps include container/sandbox optional env vars', () => { + const requiredOptionalVars = [ + 'CF_CONTAINER_ENABLED', + 'CF_CONTAINER_SLEEP_AFTER', + 'CF_CONTAINER_PORT_READY_TIMEOUT_MS', + 'CF_CONTAINER_WAKE_TIMEOUT_MS', + 'CF_CONTAINER_CREATE_WORKSPACE_TIMEOUT_MS', + 'CF_CONTAINER_CLONE_FILTER', + 'CF_CONTAINER_VM_AGENT_PORT', + 'SANDBOX_ENABLED', + 'SANDBOX_EXEC_TIMEOUT_MS', + 'SANDBOX_VM_AGENT_PORT', + 'MAX_CONCURRENT_SETUP_SESSIONS', + 'SETUP_SESSION_TTL_MS', + 'SETUP_SESSION_CAPTURE_POLL_MS', + 'CODEX_DEVICE_AUTH_REQUEST_TIMEOUT_MS', + 'SETUP_SESSION_SWEEP_MAX_CANDIDATES', + 'POOL_LEASE_BUFFER_MS', + ]; + + const resyncVars = extractEnvBlock('Re-sync Wrangler Config (add tail_consumers)'); + + for (const v of requiredOptionalVars) { + expect(resyncVars.has(v), `re-sync step must include ${v}`).toBe(true); + } + }); +}); diff --git a/apps/api/tests/unit/durable-objects/project-data-session-validation.test.ts b/apps/api/tests/unit/durable-objects/project-data-session-validation.test.ts index 237259ff15..b11907f41e 100644 --- a/apps/api/tests/unit/durable-objects/project-data-session-validation.test.ts +++ b/apps/api/tests/unit/durable-objects/project-data-session-validation.test.ts @@ -33,7 +33,7 @@ vi.mock('@simple-agent-manager/shared', () => ({ DEFAULT_WORKSPACE_IDLE_TIMEOUT_MS: 30 * 60 * 1000, DEFAULT_WORKSPACE_PROFILE: 'default', PROVIDER_LOCATIONS: {}, - WORKSPACE_IDLE_CHECK_INTERVAL_MS: 60 * 1000, + DEFAULT_WORKSPACE_IDLE_CHECK_INTERVAL_MS: 60 * 1000, })); const { ProjectData } = await import('../../../src/durable-objects/project-data'); diff --git a/apps/api/tests/workers/node-lifecycle-do.test.ts b/apps/api/tests/workers/node-lifecycle-do.test.ts index 7fb83fa888..589b6f3cc5 100644 --- a/apps/api/tests/workers/node-lifecycle-do.test.ts +++ b/apps/api/tests/workers/node-lifecycle-do.test.ts @@ -430,6 +430,50 @@ describe('NodeLifecycle DO — warm pool state machine', () => { expect(stored?.warmTimeoutOverrideMs).toBe(60_000); }); + it('alarm on destroying state schedules retry at DEFAULT_NODE_LIFECYCLE_ALARM_RETRY_MS', async () => { + // Behavioral test: verifies the real DO alarm handler uses the default retry interval + // when NODE_LIFECYCLE_ALARM_RETRY_MS env var is not set. This catches regressions where + // the alarm retry reverts to a compile-time constant without env var resolution. + const nodeId = 'nl-test-destroying-retry-interval-001'; + await seedTestNode(nodeId); + + const stub = getStub(nodeId); + + // Set destroying state + await runInDurableObject(stub, async (instance) => { + await instance.ctx.storage.put('state', { + nodeId, + userId: TEST_USER_ID, + status: 'destroying', + warmSince: null, + claimedByTask: null, + }); + }); + + const beforeAlarm = Date.now(); + + // Trigger alarm — should reschedule at now + retry interval + await runInDurableObject(stub, async (instance) => { + await instance.alarm(); + }); + + const afterAlarm = Date.now(); + const alarm = await getAlarm(stub); + expect(alarm).not.toBeNull(); + + // The default retry is 60_000ms. Verify the alarm is within the expected range. + // The alarm should be at least beforeAlarm + 60_000 and at most afterAlarm + 60_000. + const { DEFAULT_NODE_LIFECYCLE_ALARM_RETRY_MS } = await import( + '@simple-agent-manager/shared' + ); + expect(alarm!).toBeGreaterThanOrEqual(beforeAlarm + DEFAULT_NODE_LIFECYCLE_ALARM_RETRY_MS); + expect(alarm!).toBeLessThanOrEqual(afterAlarm + DEFAULT_NODE_LIFECYCLE_ALARM_RETRY_MS); + + // State should still be destroying + const status = await stub.getStatus(); + expect(status.status).toBe('destroying'); + }); + it('warm timeout override controls the alarm transition to destroying', async () => { const nodeId = 'nl-test-override-transition-001'; await seedTestNode(nodeId); diff --git a/apps/www/src/content/docs/docs/reference/configuration.md b/apps/www/src/content/docs/docs/reference/configuration.md index e7d35ac9d7..c821a4edc0 100644 --- a/apps/www/src/content/docs/docs/reference/configuration.md +++ b/apps/www/src/content/docs/docs/reference/configuration.md @@ -166,13 +166,14 @@ SAM loads OpenCode Zen and OpenCode Go model choices through the authenticated m ## Warm Node Pooling -| Variable | Default | Description | -| ------------------------------- | ------------------ | ----------------------------------------------------- | -| `NODE_WARM_TIMEOUT_MS` | `1800000` (30 min) | Time a node stays warm after idea execution completes | -| `MAX_AUTO_NODE_LIFETIME_MS` | `14400000` (4 hr) | Absolute max lifetime for auto-provisioned nodes | -| `NODE_WARM_GRACE_PERIOD_MS` | `2100000` (35 min) | Cron sweep grace period (must be > warm timeout) | -| `NODE_LIFECYCLE_ALARM_RETRY_MS` | `60000` (1 min) | Retry delay for DO alarm failures | -| `DEFAULT_TASK_AGENT_TYPE` | `opencode` | Default agent for autonomous idea execution | +| Variable | Default | Description | +| ---------------------------------- | ------------------ | ---------------------------------------------------------------- | +| `NODE_WARM_TIMEOUT_MS` | `1800000` (30 min) | Time a node stays warm after idea execution completes | +| `MAX_AUTO_NODE_LIFETIME_MS` | `14400000` (4 hr) | Absolute max lifetime for auto-provisioned nodes | +| `NODE_WARM_GRACE_PERIOD_MS` | `2100000` (35 min) | Cron sweep grace period (must be > warm timeout) | +| `NODE_LIFECYCLE_ALARM_RETRY_MS` | `60000` (1 min) | Retry delay for DO alarm failures | +| `WORKSPACE_IDLE_CHECK_INTERVAL_MS` | `300000` (5 min) | Interval at which the ProjectData DO checks workspace idle state | +| `DEFAULT_TASK_AGENT_TYPE` | `opencode` | Default agent for autonomous idea execution | ## Project Invites diff --git a/packages/shared/src/constants/cf-container.ts b/packages/shared/src/constants/cf-container.ts new file mode 100644 index 0000000000..83354abc64 --- /dev/null +++ b/packages/shared/src/constants/cf-container.ts @@ -0,0 +1,12 @@ +// ============================================================================= +// Cloudflare Container Runtime Defaults +// ============================================================================= + +/** Default sleep-after duration for CF containers. Override via CF_CONTAINER_SLEEP_AFTER env var. */ +export const DEFAULT_CF_CONTAINER_SLEEP_AFTER = '1h'; + +/** Maximum active-work keepalive duration (ms) before defensive expiry. Override via CF_CONTAINER_ACTIVE_WORK_MAX_MS env var. */ +export const DEFAULT_CF_CONTAINER_ACTIVE_WORK_MAX_MS = 2 * 60 * 60 * 1000; // 2 hours + +/** Active-work renewActivityTimeout interval (ms). Override via CF_CONTAINER_KEEPALIVE_RENEW_INTERVAL_MS env var. */ +export const DEFAULT_CF_CONTAINER_KEEPALIVE_RENEW_INTERVAL_MS = 5 * 60 * 1000; // 5 minutes diff --git a/packages/shared/src/constants/index.ts b/packages/shared/src/constants/index.ts index 60c6341ee1..3352cda3c9 100644 --- a/packages/shared/src/constants/index.ts +++ b/packages/shared/src/constants/index.ts @@ -1,5 +1,12 @@ // Constants barrel — named re-exports only (no `export *`) +// Cloudflare Container Runtime +export { + DEFAULT_CF_CONTAINER_ACTIVE_WORK_MAX_MS, + DEFAULT_CF_CONTAINER_KEEPALIVE_RENEW_INTERVAL_MS, + DEFAULT_CF_CONTAINER_SLEEP_AFTER, +} from './cf-container'; + // VM Sizes export { canSatisfyVmSize, @@ -105,6 +112,7 @@ export { DEFAULT_NODE_WARM_GRACE_PERIOD_MS, DEFAULT_NODE_WARM_TIMEOUT_MS, DEFAULT_ORPHANED_WORKSPACE_GRACE_PERIOD_MS, + DEFAULT_WORKSPACE_IDLE_CHECK_INTERVAL_MS, DEFAULT_WORKSPACE_IDLE_TIMEOUT_MS, DEFAULT_WORKSPACE_STOPPED_TTL_MS, MAX_NODE_IDLE_TIMEOUT_MS, @@ -112,7 +120,6 @@ export { MIN_NODE_IDLE_TIMEOUT_MS, MIN_WORKSPACE_IDLE_TIMEOUT_MS, TERMINAL_ACTIVITY_THROTTLE_MS, - WORKSPACE_IDLE_CHECK_INTERVAL_MS, } from './node-pooling'; // Scaling Parameters @@ -380,6 +387,7 @@ export { // Notifications export type { HumanInputCategory } from './notifications'; export { + DEFAULT_MAX_NOTIFICATION_PAGE_SIZE, DEFAULT_MAX_NOTIFICATIONS_PER_USER, DEFAULT_NOTIFICATION_AUTO_DELETE_AGE_MS, DEFAULT_NOTIFICATION_DEDUP_WINDOW_MS, @@ -391,7 +399,6 @@ export { MAX_HUMAN_INPUT_OPTION_LENGTH, MAX_HUMAN_INPUT_OPTIONS_COUNT, MAX_NOTIFICATION_BODY_LENGTH, - MAX_NOTIFICATION_PAGE_SIZE, MAX_NOTIFICATION_TITLE_LENGTH, MAX_NOTIFICATION_TITLE_LENGTH_NEEDS_INPUT, NOTIFICATION_PREVIEW_LENGTH, diff --git a/packages/shared/src/constants/node-pooling.ts b/packages/shared/src/constants/node-pooling.ts index d4560ade63..89c9121612 100644 --- a/packages/shared/src/constants/node-pooling.ts +++ b/packages/shared/src/constants/node-pooling.ts @@ -14,7 +14,7 @@ export const DEFAULT_NODE_WARM_GRACE_PERIOD_MS = 35 * 60 * 1000; // 35 minutes ( /** Default grace period (ms) before stopping orphaned task workspaces. Override via ORPHANED_WORKSPACE_GRACE_PERIOD_MS env var. */ export const DEFAULT_ORPHANED_WORKSPACE_GRACE_PERIOD_MS = 10 * 60 * 1000; // 10 minutes -/** Default alarm retry delay (ms) when node destruction fails. */ +/** Default alarm retry delay (ms) when node destruction fails. Override via NODE_LIFECYCLE_ALARM_RETRY_MS env var. */ export const DEFAULT_NODE_LIFECYCLE_ALARM_RETRY_MS = 60 * 1000; // 1 minute // ============================================================================= @@ -45,8 +45,8 @@ export const MIN_NODE_IDLE_TIMEOUT_MS = 5 * 60 * 1000; // 5 minutes /** Maximum node idle timeout (ms). */ export const MAX_NODE_IDLE_TIMEOUT_MS = 4 * 60 * 60 * 1000; // 4 hours -/** Interval (ms) at which the ProjectData DO checks workspace idle state. */ -export const WORKSPACE_IDLE_CHECK_INTERVAL_MS = 5 * 60 * 1000; // 5 minutes +/** Default interval (ms) at which the ProjectData DO checks workspace idle state. Override via WORKSPACE_IDLE_CHECK_INTERVAL_MS env var. */ +export const DEFAULT_WORKSPACE_IDLE_CHECK_INTERVAL_MS = 5 * 60 * 1000; // 5 minutes /** Minimum interval (ms) between terminal activity updates to the DO to avoid write amplification. * Intended for frontend heartbeat interval — not yet enforced server-side. */ diff --git a/packages/shared/src/constants/notifications.ts b/packages/shared/src/constants/notifications.ts index 49c9e91d6c..a9b6c19378 100644 --- a/packages/shared/src/constants/notifications.ts +++ b/packages/shared/src/constants/notifications.ts @@ -13,8 +13,8 @@ export const DEFAULT_NOTIFICATION_AUTO_DELETE_AGE_MS = 90 * 24 * 60 * 60 * 1000; /** Maximum notifications returned in a single list request */ export const DEFAULT_NOTIFICATION_PAGE_SIZE = 50; -/** Maximum page size for notification list requests */ -export const MAX_NOTIFICATION_PAGE_SIZE = 100; +/** Maximum page size for notification list requests. Override via MAX_NOTIFICATION_PAGE_SIZE env var. */ +export const DEFAULT_MAX_NOTIFICATION_PAGE_SIZE = 100; /** Default urgency mapping for each notification type */ export const NOTIFICATION_TYPE_URGENCY: Record = { diff --git a/tasks/active/2026-07-25-config-limits-deploy-env-remediation.md b/tasks/active/2026-07-25-config-limits-deploy-env-remediation.md new file mode 100644 index 0000000000..ac29ff8741 --- /dev/null +++ b/tasks/active/2026-07-25-config-limits-deploy-env-remediation.md @@ -0,0 +1,66 @@ +# Config Limits & Deploy Env Propagation Remediation + +## Problem + +A deep codebase review found that several configuration constants violate Constitution Principle XI (No Hardcoded Values) and that the deployment pipeline has an env var propagation gap: + +1. **Deploy pipeline re-sync gap**: `deploy-reusable.yml` has two `sync-wrangler-config` steps — the initial sync passes 16 optional env vars (container/sandbox overrides), but the re-sync on first deploy passes only 3. First deploys silently lose operator-configured overrides. +2. **Docs-code mismatch**: `configuration.md` documents `NODE_LIFECYCLE_ALARM_RETRY_MS` and `MAX_NOTIFICATION_PAGE_SIZE` as configurable env vars, but both are compile-time constants with no actual env var resolution. +3. **Missing env overrides**: `WORKSPACE_IDLE_CHECK_INTERVAL_MS` has no env var override despite all sibling constants in the same file having them. +4. **Constants in wrong location**: `DEFAULT_CF_CONTAINER_*` constants are defined locally in `vm-agent-container.ts` instead of in the shared constants package. + +## Research Findings + +### Deploy Pipeline Gap (`deploy-reusable.yml`) + +- **Initial sync** (lines 345-375): passes 16 optional env vars including `CF_CONTAINER_ENABLED`, `CF_CONTAINER_SLEEP_AFTER`, `CF_CONTAINER_PORT_READY_TIMEOUT_MS`, etc. +- **Re-sync on first deploy** (lines 660-676): passes ONLY `REQUIRE_APPROVAL`, `HETZNER_BASE_IMAGE`, `ARTIFACTS_BINDING_ENABLED` — all 16 optional vars missing +- Impact: first-ever deploy to a new environment silently loses container/sandbox configuration + +### Hardcoded Constants Documented as Configurable + +- `DEFAULT_NODE_LIFECYCLE_ALARM_RETRY_MS` (`packages/shared/src/constants/node-pooling.ts:18`): JSDoc says only "Default alarm retry delay" — no "Override via" clause unlike all siblings. Not in `Env` interface. +- `MAX_NOTIFICATION_PAGE_SIZE` (`packages/shared/src/constants/notifications.ts:17`): compile-time `const = 100`, not in `Env` interface. `configuration.md` line 195 documents it as an env var. + +### Missing Env Override + +- `WORKSPACE_IDLE_CHECK_INTERVAL_MS` (`node-pooling.ts:49`): hardcoded at 5 minutes. Every sibling constant (lines 5-15, 24, 33) has an "Override via" JSDoc + env var resolution. This one does not. + +### Constants in Wrong Location + +- `DEFAULT_CF_CONTAINER_SLEEP_AFTER`, `DEFAULT_CF_CONTAINER_ACTIVE_WORK_MAX_MS`, `DEFAULT_CF_CONTAINER_KEEPALIVE_RENEW_INTERVAL_MS` are defined in `apps/api/src/durable-objects/vm-agent-container.ts:10-12` instead of `packages/shared/src/constants/`. + +## Implementation Checklist + +- [ ] **1. Fix deploy pipeline re-sync gap**: Add the 16 missing optional env vars to the re-sync step in `deploy-reusable.yml` to match the initial sync step +- [ ] **2. Make `DEFAULT_NODE_LIFECYCLE_ALARM_RETRY_MS` env-configurable**: Add `NODE_LIFECYCLE_ALARM_RETRY_MS` to `Env` interface, add env var resolution at usage site(s), add "Override via" JSDoc +- [ ] **3. Make `MAX_NOTIFICATION_PAGE_SIZE` env-configurable**: Add to `Env` interface, add env var resolution at usage site(s), update JSDoc +- [ ] **4. Make `WORKSPACE_IDLE_CHECK_INTERVAL_MS` env-configurable**: Add `WORKSPACE_IDLE_CHECK_INTERVAL_MS` to `Env` interface, add "Override via" JSDoc, add env var resolution at usage site +- [ ] **5. Move CF container constants to shared package**: Move `DEFAULT_CF_CONTAINER_SLEEP_AFTER`, `DEFAULT_CF_CONTAINER_ACTIVE_WORK_MAX_MS`, `DEFAULT_CF_CONTAINER_KEEPALIVE_RENEW_INTERVAL_MS` from `vm-agent-container.ts` to `packages/shared/src/constants/` +- [ ] **6. Fix `configuration.md`**: Correct documented env vars to match actual behavior +- [ ] **7. Add tests**: Write unit tests verifying env var resolution for newly configurable constants +- [ ] **8. Verify build**: Run full `pnpm lint && pnpm typecheck && pnpm test && pnpm build` + +## Acceptance Criteria + +- [ ] Deploy pipeline re-sync step forwards the same env vars as the initial sync step +- [ ] `NODE_LIFECYCLE_ALARM_RETRY_MS` is resolvable from env at runtime with fallback to `DEFAULT_NODE_LIFECYCLE_ALARM_RETRY_MS` +- [ ] `MAX_NOTIFICATION_PAGE_SIZE` has env var resolution with fallback to compile-time default +- [ ] `WORKSPACE_IDLE_CHECK_INTERVAL_MS` has env var resolution matching its sibling constants +- [ ] CF container constants live in `packages/shared/src/constants/` and are imported from there +- [ ] `configuration.md` accurately reflects which env vars are actually configurable and where they apply +- [ ] All changes are backward-compatible (existing defaults unchanged) +- [ ] Tests verify env var override behavior for each newly configurable constant +- [ ] CI passes cleanly + +## References + +- Constitution Principle XI: `.specify/memory/constitution.md` +- Env interface: `apps/api/src/env.ts` +- Node pooling constants: `packages/shared/src/constants/node-pooling.ts` +- Notification constants: `packages/shared/src/constants/notifications.ts` +- CF container DO: `apps/api/src/durable-objects/vm-agent-container.ts` +- Deploy workflow: `.github/workflows/deploy-reusable.yml` +- Configuration docs: `apps/www/src/content/docs/docs/reference/configuration.md` +- Task ID: `01KYC73DQFH22WH4AF09R9861C` +- PR: #1677 (draft)