import { describe, expect, it } from 'vitest'; import { GatewayOrchestrator } from '../src/orchestrator/gatewayOrchestrator.js'; import type { ProcessDefinition, ProcessManager } from '../src/orchestrator/processManager.js'; import type { GatewayOperationRecord, GatewayOperationStatus, GatewayProfileRecord, GatewayProfileRepository, } from '../src/orchestrator/profileRepository.js'; import { GitWorkspaceManager } from '../src/orchestrator/workspaceManager.js'; const profile: GatewayProfileRecord = { profileName: 'che:2', profile: 'che', scenario: '2', apiPort: 15003, status: 'STOPPED', buildStatus: 'SUCCEEDED', buildCommitSha: '0123456789abcdef0123456789abcdef01234567', buildWorkspace: '/srv/sammo/worktrees/0123456789abcdef', meta: {}, createdAt: '2026-07-25T00:00:00.000Z', updatedAt: '2026-07-25T00:00:00.000Z', }; const buildOperation = (type: 'START' | 'STOP'): GatewayOperationRecord => ({ id: '11111111-1111-4111-8111-111111111111', profileName: profile.profileName, type, status: 'RUNNING', payload: {}, requestedBy: 'admin', createdAt: '2026-07-25T01:00:00.000Z', startedAt: '2026-07-25T01:00:00.000Z', updatedAt: '2026-07-25T01:00:00.000Z', }); const createHarness = ( operation: GatewayOperationRecord, failStart = false, failStop = false, processesPresent = true, missingOnDelete = false, workspaceManager?: GitWorkspaceManager, startGate?: Promise ) => { let nextOperation: GatewayOperationRecord | null = operation; const statuses: string[] = []; const completions: GatewayOperationStatus[] = []; const completionFields: Array<{ resolvedCommitSha?: string | null; error?: string | null } | undefined> = []; const started: ProcessDefinition[] = []; const stopped: string[] = []; const deleted: string[] = []; const repository: GatewayProfileRepository = { listProfiles: async () => [profile], getProfile: async () => profile, upsertProfile: async () => profile, updateScenario: async () => profile, updateStatus: async (_profileName, status) => { statuses.push(status); return { ...profile, status }; }, updateBuildStatus: async () => profile, updateMeta: async () => profile, listReservedToStart: async () => [], findQueuedBuild: async () => null, updateLastError: async () => {}, updateWorkspaceUsage: async () => {}, clearWorkspaceUsage: async () => {}, listOperations: async () => [], listActiveOperationProfileNames: async () => [profile.profileName], getOperation: async () => operation, createOperation: async () => operation, claimNextOperation: async () => { const result = nextOperation; nextOperation = null; return result; }, completeOperation: async (_id, status, fields) => { completions.push(status); completionFields.push(fields); return { ...operation, status }; }, requeueOperation: async () => ({ ...operation, status: 'QUEUED' }), cancelOperation: async () => false, retryOperation: async () => null, }; const processManager: ProcessManager = { list: async () => processesPresent ? [ { name: 'sammo:che:2:game-frontend', status: 'online' }, { name: 'sammo:che:2:game-api', status: 'online' }, { name: 'sammo:che:2:turn-daemon', status: 'online' }, { name: 'sammo:che:2:auction-worker', status: 'online' }, { name: 'sammo:che:2:battle-sim-worker', status: 'online' }, { name: 'sammo:che:2:tournament-worker', status: 'online' }, ] : [], start: async (definition) => { await startGate; if (failStart && started.length === 2) { throw new Error('pm2 unavailable'); } started.push(definition); }, stop: async (name) => { stopped.push(name); if (failStop) { throw new Error('pm2 stop failed'); } }, delete: async (name) => { deleted.push(name); if (missingOnDelete) { throw new Error('process or namespace not found'); } if (failStop) { throw new Error('pm2 delete failed'); } }, }; const orchestrator = new GatewayOrchestrator({ repository, processManager, buildRunner: { run: async () => ({ ok: true, exitCode: 0, output: '' }), }, workspaceManager: workspaceManager ?? new GitWorkspaceManager({ repoRoot: '/tmp/not-used', worktreeRoot: '/tmp/not-used-worktrees', }), processConfig: { workspaceRoot: '/srv/sammo', redisKeyPrefix: 'sammo:test', gameTokenSecret: 'test-secret', gatewayInternalApiUrl: 'http://127.0.0.1:13000', }, reconcileIntervalMs: 60_000, scheduleIntervalMs: 60_000, buildIntervalMs: 60_000, adminActionIntervalMs: 60_000, }); return { orchestrator, statuses, completions, completionFields, started, stopped, deleted }; }; describe('GatewayOrchestrator first-class operations', () => { it('does not reconcile a profile while a durable operation is active', async () => { const harness = createHarness(buildOperation('START')); await harness.orchestrator.reconcileNow(); expect(harness.started).toEqual([]); expect(harness.stopped).toEqual([]); expect(harness.deleted).toEqual([]); }); it('starts every profile process and records success', async () => { const harness = createHarness(buildOperation('START')); await harness.orchestrator.runOperationsNow(); expect(harness.statuses).toEqual(['RUNNING']); expect(harness.started.map((definition) => definition.name)).toEqual([ 'sammo:che:2:game-frontend', 'sammo:che:2:game-api', 'sammo:che:2:turn-daemon', 'sammo:che:2:auction-worker', 'sammo:che:2:battle-sim-worker', 'sammo:che:2:tournament-worker', ]); expect(harness.completions).toEqual(['SUCCEEDED']); }); it('stops every profile process and records success', async () => { const harness = createHarness(buildOperation('STOP')); await harness.orchestrator.runOperationsNow(); expect(harness.statuses).toEqual(['STOPPED']); expect(harness.stopped).toEqual([ 'sammo:che:2:game-frontend', 'sammo:che:2:game-api', 'sammo:che:2:turn-daemon', 'sammo:che:2:auction-worker', 'sammo:che:2:battle-sim-worker', 'sammo:che:2:tournament-worker', ]); expect(harness.deleted).toEqual([ 'sammo:che:2:game-frontend', 'sammo:che:2:game-api', 'sammo:che:2:turn-daemon', 'sammo:che:2:auction-worker', 'sammo:che:2:battle-sim-worker', 'sammo:che:2:tournament-worker', ]); expect(harness.completions).toEqual(['SUCCEEDED']); }); it('treats an already stopped profile as a successful idempotent stop', async () => { const harness = createHarness(buildOperation('STOP'), false, false, false); await harness.orchestrator.runOperationsNow(); expect(harness.statuses).toEqual(['STOPPED']); expect(harness.stopped).toEqual([]); expect(harness.deleted).toEqual([]); expect(harness.completions).toEqual(['SUCCEEDED']); }); it('treats a process removed concurrently as a successful stop', async () => { const harness = createHarness(buildOperation('STOP'), false, false, true, true); await harness.orchestrator.runOperationsNow(); expect(harness.deleted).toEqual([ 'sammo:che:2:game-frontend', 'sammo:che:2:game-api', 'sammo:che:2:turn-daemon', 'sammo:che:2:auction-worker', 'sammo:che:2:battle-sim-worker', 'sammo:che:2:tournament-worker', ]); expect(harness.completions).toEqual(['SUCCEEDED']); }); it('records a failed start instead of reporting a false success', async () => { const harness = createHarness(buildOperation('START'), true); await harness.orchestrator.runOperationsNow(); expect(harness.completions).toEqual(['FAILED']); expect(harness.deleted).toEqual([ 'sammo:che:2:turn-daemon', 'sammo:che:2:game-api', 'sammo:che:2:game-frontend', ]); }); it('attempts to stop every role before reporting a partial PM2 failure', async () => { const harness = createHarness(buildOperation('STOP'), false, true); await harness.orchestrator.runOperationsNow(); expect(harness.stopped).toEqual([ 'sammo:che:2:game-frontend', 'sammo:che:2:game-api', 'sammo:che:2:turn-daemon', 'sammo:che:2:auction-worker', 'sammo:che:2:battle-sim-worker', 'sammo:che:2:tournament-worker', ]); expect(harness.deleted).toEqual([ 'sammo:che:2:game-frontend', 'sammo:che:2:game-api', 'sammo:che:2:turn-daemon', 'sammo:che:2:auction-worker', 'sammo:che:2:battle-sim-worker', 'sammo:che:2:tournament-worker', ]); expect(harness.completions).toEqual(['FAILED']); }); it('records the resolved commit even when reset workspace preparation fails', async () => { const resolvedCommitSha = 'abcdef0123456789abcdef0123456789abcdef01'; const resetOperation: GatewayOperationRecord = { id: '33333333-3333-4333-8333-333333333333', profileName: profile.profileName, type: 'RESET', status: 'RUNNING', sourceMode: 'COMMIT', sourceRef: 'requested-ref', payload: { install: { scenarioId: 1010, turnTermMinutes: 60 } }, requestedBy: 'admin', createdAt: '2026-07-31T00:00:00.000Z', startedAt: '2026-07-31T00:00:00.000Z', updatedAt: '2026-07-31T00:00:00.000Z', }; const workspaceManager = { resolveCommit: async () => resolvedCommitSha, prepare: async () => { throw new Error('injected workspace preparation failure'); }, remove: async () => {}, } as unknown as GitWorkspaceManager; const harness = createHarness(resetOperation, false, false, true, false, workspaceManager); await harness.orchestrator.runOperationsNow(); expect(harness.completions).toEqual(['FAILED']); expect(harness.completionFields).toEqual([ { resolvedCommitSha, error: 'injected workspace preparation failure', }, ]); }); it('drains an in-flight operation before shutdown completes', async () => { let releaseStart: (() => void) | undefined; const startGate = new Promise((resolve) => { releaseStart = resolve; }); const harness = createHarness(buildOperation('START'), false, false, true, false, undefined, startGate); harness.orchestrator.start(); await new Promise((resolve) => setImmediate(resolve)); let stopped = false; const stopPromise = harness.orchestrator.stop().then(() => { stopped = true; }); await new Promise((resolve) => setImmediate(resolve)); expect(stopped).toBe(false); releaseStart?.(); await stopPromise; expect(harness.completions).toEqual(['SUCCEEDED']); }); });