diff --git a/app/gateway-api/src/orchestrator/gatewayOrchestrator.ts b/app/gateway-api/src/orchestrator/gatewayOrchestrator.ts index 4cd21d8a..32d1b86c 100644 --- a/app/gateway-api/src/orchestrator/gatewayOrchestrator.ts +++ b/app/gateway-api/src/orchestrator/gatewayOrchestrator.ts @@ -158,6 +158,20 @@ export const planProfileReconcile = ( }; }; +export const resolveResetLifecycleStatus = ( + now: Date, + preopenAt: Date | null, + openAt: Date | null +): Extract => { + if (preopenAt && preopenAt.getTime() > now.getTime()) { + return 'RESERVED'; + } + if (openAt && openAt.getTime() > now.getTime()) { + return 'PREOPEN'; + } + return 'RUNNING'; +}; + type GatewayAdminActionStatus = 'REQUESTED' | 'APPLIED' | 'FAILED' | 'IGNORED'; interface GatewayAdminActionRecord { @@ -880,7 +894,9 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle { const now = this.now(); const due = await this.repository.listReservedToStart(now); for (const profile of due) { - if (!profile.preopenAt || !profile.openAt) { + const preopenAt = parseDateTime(profile.preopenAt); + const openAt = parseDateTime(profile.openAt); + if (!preopenAt || !openAt) { await this.repository.updateLastError( profile.profileName, 'Reserved profile is missing preopen/open schedule.' @@ -894,6 +910,18 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle { ); continue; } + if (profile.currentScenario !== null && profile.buildStatus === 'SUCCEEDED' && profile.buildWorkspace) { + await this.repository.updateStatus( + profile.profileName, + resolveResetLifecycleStatus(now, preopenAt, openAt), + { + preopenAt: profile.preopenAt, + openAt: profile.openAt, + } + ); + await this.repository.updateLastError(profile.profileName, null); + continue; + } const queued = profile.buildStatus === 'QUEUED' || profile.buildStatus === 'RUNNING'; if (!queued) { await this.repository.updateBuildStatus(profile.profileName, 'QUEUED', { @@ -1884,8 +1912,7 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle { await assertLease?.(); const completedAt = this.now().toISOString(); const now = this.now(); - const shouldPreopen = openAt ? openAt.getTime() > now.getTime() : false; - const desiredStatus = shouldPreopen ? 'PREOPEN' : 'RUNNING'; + const desiredStatus = resolveResetLifecycleStatus(now, preopenAt, openAt); const publishedProfile = await updateClaimedProfile( { currentScenario: String(scenarioId), @@ -1917,30 +1944,37 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle { } ); releasePrepared = true; - const builtProfile = publishedProfile ?? { - ...profile, - currentScenario: String(scenarioId), - scenario: String(scenarioId), - status: desiredStatus, - buildWorkspace: workspace.root, - }; - await appendLog('switch', '초기화된 profile process를 시작합니다.'); - const started = await this.startProfile(builtProfile, assertLease); - await appendLog('readiness', 'profile process readiness를 확인합니다.'); - const ready = started && (await this.waitForProfileReadiness(builtProfile, assertLease)); - if (!ready) { - if (started) { - await this.stopProfile(builtProfile, assertLease); - } - const detail = started - ? 'reset completed but profile processes failed readiness' - : 'reset completed but profile processes failed to start'; - await updateClaimedProfile({ status: 'STOPPED', lastError: detail }, () => - this.repository.updateStatus(profile.profileName, 'STOPPED') + if (desiredStatus === 'RESERVED') { + await appendLog( + 'schedule', + `${preopenAt?.toISOString() ?? '가오픈 시각'}까지 RESERVED 상태로 접속을 차단합니다.` ); - return { status: 'FAILED', detail }; + } else { + const builtProfile = publishedProfile ?? { + ...profile, + currentScenario: String(scenarioId), + scenario: String(scenarioId), + status: desiredStatus, + buildWorkspace: workspace.root, + }; + await appendLog('switch', '초기화된 profile process를 시작합니다.'); + const started = await this.startProfile(builtProfile, assertLease); + await appendLog('readiness', 'profile process readiness를 확인합니다.'); + const ready = started && (await this.waitForProfileReadiness(builtProfile, assertLease)); + if (!ready) { + if (started) { + await this.stopProfile(builtProfile, assertLease); + } + const detail = started + ? 'reset completed but profile processes failed readiness' + : 'reset completed but profile processes failed to start'; + await updateClaimedProfile({ status: 'STOPPED', lastError: detail }, () => + this.repository.updateStatus(profile.profileName, 'STOPPED') + ); + return { status: 'FAILED', detail }; + } + await appendLog('readiness', 'profile readiness 확인을 통과했습니다.'); } - await appendLog('readiness', 'profile readiness 확인을 통과했습니다.'); await updateClaimedProfile({ lastError: null }, async () => { await this.repository.updateLastError(profile.profileName, null); return this.repository.getProfile(profile.profileName); diff --git a/app/gateway-api/test/adminOperations.test.ts b/app/gateway-api/test/adminOperations.test.ts index e5a484b2..0d5402fd 100644 --- a/app/gateway-api/test/adminOperations.test.ts +++ b/app/gateway-api/test/adminOperations.test.ts @@ -700,6 +700,63 @@ describe('admin operation API', () => { }); }); + it('keeps reset start, preopen, and formal open as an ordered lifecycle', async () => { + const harness = await buildCaller(async (input) => ({ + id: '77777777-7777-4777-8777-777777777777', + profileName: input.profileName, + type: 'RESET', + status: 'QUEUED', + sourceMode: input.sourceMode, + sourceRef: input.sourceRef, + payload: input.payload ?? {}, + requestedBy: input.requestedBy, + scheduledAt: input.scheduledAt, + createdAt: '2026-08-08T00:00:00.000Z', + updatedAt: '2026-08-08T00:00:00.000Z', + })); + const install = { + scenarioId: 1010, + turnTermMinutes: 60, + sync: false, + fiction: 1 as const, + extend: false, + blockGeneralCreate: 0 as const, + npcMode: 0 as const, + showImgLevel: 0 as const, + tournamentTrig: false, + joinMode: 'full' as const, + preopenAt: '2099-01-01T01:00:00.000Z', + openAt: '2099-01-01T02:00:00.000Z', + }; + + await harness.caller.admin.operations.requestReset({ + profileName: 'che:2', + sourceMode: 'COMMIT', + sourceRef: 'HEAD', + scheduledAt: '2099-01-01T00:00:00.000Z', + install, + }); + + expect(harness.createdInputs[0]).toMatchObject({ + type: 'RESET', + scheduledAt: '2099-01-01T00:00:00.000Z', + payload: { install }, + }); + + await expect( + harness.caller.admin.operations.requestReset({ + profileName: 'che:2', + sourceMode: 'COMMIT', + sourceRef: 'HEAD', + scheduledAt: '2099-01-01T01:30:00.000Z', + install, + }) + ).rejects.toMatchObject({ + code: 'BAD_REQUEST', + message: 'preopenAt cannot be earlier than scheduledAt.', + }); + }); + it('returns validated profile reset defaults to a scenario-only operator', async () => { const harness = await buildCaller( async () => { diff --git a/app/gateway-api/test/orchestratorOperations.test.ts b/app/gateway-api/test/orchestratorOperations.test.ts index bd84fd50..aab4f046 100644 --- a/app/gateway-api/test/orchestratorOperations.test.ts +++ b/app/gateway-api/test/orchestratorOperations.test.ts @@ -48,6 +48,9 @@ const createHarness = ( startGate?: Promise, options: { profile?: GatewayProfileRecord; + profiles?: GatewayProfileRecord[]; + reservedToStart?: GatewayProfileRecord[]; + now?: () => Date; cancelGame?: GatewayOrchestratorOptions['cancelGame']; } = {} ) => { @@ -59,10 +62,11 @@ const createHarness = ( const started: ProcessDefinition[] = []; const stopped: string[] = []; const deleted: string[] = []; + const buildStatuses: string[] = []; const logs: Array<{ phase: string; message: string; level: string }> = []; const repository: GatewayProfileRepository = { - listProfiles: async () => [harnessProfile], + listProfiles: async () => options.profiles ?? [harnessProfile], getProfile: async () => harnessProfile, upsertProfile: async () => harnessProfile, updateCurrentScenario: async () => harnessProfile, @@ -70,9 +74,12 @@ const createHarness = ( statuses.push(status); return { ...harnessProfile, status }; }, - updateBuildStatus: async () => harnessProfile, + updateBuildStatus: async (_profileName, status) => { + buildStatuses.push(status); + return { ...harnessProfile, buildStatus: status }; + }, updateMeta: async () => harnessProfile, - listReservedToStart: async () => [], + listReservedToStart: async () => options.reservedToStart ?? [], findQueuedBuild: async () => null, updateLastError: async () => {}, updateWorkspaceUsage: async () => {}, @@ -167,10 +174,11 @@ const createHarness = ( scheduleIntervalMs: 60_000, buildIntervalMs: 60_000, adminActionIntervalMs: 60_000, + now: options.now, cancelGame: options.cancelGame, }); - return { orchestrator, statuses, completions, completionFields, started, stopped, deleted, logs }; + return { orchestrator, statuses, buildStatuses, completions, completionFields, started, stopped, deleted, logs }; }; describe('GatewayOrchestrator first-class operations', () => { @@ -267,6 +275,81 @@ describe('GatewayOrchestrator first-class operations', () => { expect(harness.deleted).toEqual([]); }); + it('opens a prepared reserved profile without rebuilding it again', async () => { + const now = new Date('2030-01-01T01:00:00.000Z'); + const reservedProfile: GatewayProfileRecord = { + ...profile, + status: 'RESERVED', + currentScenario: '1010', + scenario: '1010', + buildStatus: 'SUCCEEDED', + buildWorkspace: '/srv/sammo/worktrees/0123456789abcdef', + preopenAt: now.toISOString(), + openAt: '2030-01-01T02:00:00.000Z', + }; + const harness = createHarness(buildOperation('START'), false, false, false, false, undefined, undefined, { + profile: reservedProfile, + profiles: [], + reservedToStart: [reservedProfile], + now: () => now, + }); + + await harness.orchestrator.runScheduleNow(); + + expect(harness.statuses).toEqual(['PREOPEN']); + expect(harness.buildStatuses).toEqual([]); + }); + + it('starts turns when a prepared reserved profile is handled after formal open', async () => { + const now = new Date('2030-01-01T02:00:00.000Z'); + const reservedProfile: GatewayProfileRecord = { + ...profile, + status: 'RESERVED', + currentScenario: '1010', + scenario: '1010', + buildStatus: 'SUCCEEDED', + buildWorkspace: '/srv/sammo/worktrees/0123456789abcdef', + preopenAt: '2030-01-01T01:00:00.000Z', + openAt: now.toISOString(), + }; + const harness = createHarness(buildOperation('START'), false, false, false, false, undefined, undefined, { + profile: reservedProfile, + profiles: [], + reservedToStart: [reservedProfile], + now: () => now, + }); + + await harness.orchestrator.runScheduleNow(); + + expect(harness.statuses).toEqual(['RUNNING']); + expect(harness.buildStatuses).toEqual([]); + }); + + it('retains the legacy build queue for an unprepared reserved profile', async () => { + const now = new Date('2030-01-01T01:00:00.000Z'); + const reservedProfile: GatewayProfileRecord = { + ...profile, + status: 'RESERVED', + currentScenario: null, + scenario: 'default', + buildStatus: 'IDLE', + buildWorkspace: undefined, + preopenAt: now.toISOString(), + openAt: '2030-01-01T02:00:00.000Z', + }; + const harness = createHarness(buildOperation('START'), false, false, false, false, undefined, undefined, { + profile: reservedProfile, + profiles: [], + reservedToStart: [reservedProfile], + now: () => now, + }); + + await harness.orchestrator.runScheduleNow(); + + expect(harness.statuses).toEqual([]); + expect(harness.buildStatuses).toEqual(['QUEUED']); + }); + it('starts every profile process and records success', async () => { const harness = createHarness(buildOperation('START')); diff --git a/app/gateway-api/test/orchestratorPlan.test.ts b/app/gateway-api/test/orchestratorPlan.test.ts index 11d013cd..6fdbda66 100644 --- a/app/gateway-api/test/orchestratorPlan.test.ts +++ b/app/gateway-api/test/orchestratorPlan.test.ts @@ -8,6 +8,7 @@ import { buildProcessDefinitions, buildWorkspaceCommands, planProfileReconcile, + resolveResetLifecycleStatus, } from '../src/orchestrator/gatewayOrchestrator.js'; import { sanitizeManagedProcessEnv } from '../src/orchestrator/processManager.js'; import type { GatewayProfileRecord } from '../src/orchestrator/profileRepository.js'; @@ -108,6 +109,29 @@ describe('planProfileReconcile', () => { }); }); +describe('resolveResetLifecycleStatus', () => { + const now = new Date('2030-01-01T00:00:00.000Z'); + + it('keeps an initialized profile reserved until the configured preopen time', () => { + expect( + resolveResetLifecycleStatus(now, new Date('2030-01-01T01:00:00.000Z'), new Date('2030-01-01T02:00:00.000Z')) + ).toBe('RESERVED'); + }); + + it('moves through preopen before the formal open time', () => { + expect( + resolveResetLifecycleStatus(now, new Date('2029-12-31T23:00:00.000Z'), new Date('2030-01-01T02:00:00.000Z')) + ).toBe('PREOPEN'); + }); + + it('runs immediately when no future lifecycle boundary remains', () => { + expect(resolveResetLifecycleStatus(now, null, null)).toBe('RUNNING'); + expect( + resolveResetLifecycleStatus(now, new Date('2029-12-31T22:00:00.000Z'), new Date('2029-12-31T23:00:00.000Z')) + ).toBe('RUNNING'); + }); +}); + describe('buildProcessDefinitions', () => { const processConfig = { workspaceRoot: '/srv/sammo/main', diff --git a/app/gateway-frontend/e2e/server-operations.spec.ts b/app/gateway-frontend/e2e/server-operations.spec.ts index 86369af4..2fb6f730 100644 --- a/app/gateway-frontend/e2e/server-operations.spec.ts +++ b/app/gateway-frontend/e2e/server-operations.spec.ts @@ -600,7 +600,15 @@ test('separates branch and commit semantics and submits a reset from the dedicat await page.getByTestId('source-ref').fill('0123456789abcdef0123456789abcdef01234567'); await page.getByTestId('load-scenarios').click(); await page.getByTestId('scenario-select').selectOption('5'); - await page.getByLabel('작업 예약 (서버 시간 UTC+9)').fill('2026-08-13T09:30'); + await expect(page.getByText('초기화 시작 → 가오픈 시작 → 정식 오픈 순서입니다.')).toBeVisible(); + await page.getByTestId('reset-scheduled-at').fill('2030-08-13T09:30'); + await page.getByTestId('reset-preopen-at').fill('2030-08-13T10:00'); + await page.getByTestId('reset-open-at').fill('2030-08-13T11:00'); + const scheduledHelp = page.getByTestId('reset-help-scheduled-at'); + await scheduledHelp.hover(); + await expect(page.getByTestId('reset-help-scheduled-at-tooltip')).toContainText( + '완료되어도 가오픈 전에는 접속을 차단합니다.' + ); await page.getByTestId('request-reset').hover(); await page.getByTestId('request-reset').click(); @@ -655,7 +663,9 @@ test('separates branch and commit semantics and submits a reset from the dedicat expect(JSON.stringify(resetRequest?.body)).toContain('"sourceMode":"COMMIT"'); expect(JSON.stringify(resetRequest?.body)).toContain('0123456789abcdef0123456789abcdef01234567'); expect(JSON.stringify(resetRequest?.body)).toContain('"scenarioId":5'); - expect(JSON.stringify(resetRequest?.body)).toContain('"scheduledAt":"2026-08-13T00:30:00.000Z"'); + expect(JSON.stringify(resetRequest?.body)).toContain('"scheduledAt":"2030-08-13T00:30:00.000Z"'); + expect(JSON.stringify(resetRequest?.body)).toContain('"preopenAt":"2030-08-13T01:00:00.000Z"'); + expect(JSON.stringify(resetRequest?.body)).toContain('"openAt":"2030-08-13T02:00:00.000Z"'); await page.screenshot({ path: testInfo.outputPath('reset-operation-log-desktop.png'), fullPage: true }); await page.setViewportSize({ width: 390, height: 844 }); @@ -1046,7 +1056,7 @@ test('uses ref reset terms with compact hover, focus, and mobile help', async ({ ]); const helpButtons = page.getByRole('button', { name: /도움말$/ }); - await expect(helpButtons).toHaveCount(10); + await expect(helpButtons).toHaveCount(13); const fictionHelp = page.getByTestId('reset-help-fiction'); const fictionTooltip = page.getByTestId('reset-help-fiction-tooltip'); await expect(fictionTooltip).toBeHidden(); diff --git a/app/gateway-frontend/src/views/ServerOperationsView.vue b/app/gateway-frontend/src/views/ServerOperationsView.vue index 9249d86f..6044c425 100644 --- a/app/gateway-frontend/src/views/ServerOperationsView.vue +++ b/app/gateway-frontend/src/views/ServerOperationsView.vue @@ -162,6 +162,20 @@ const RESET_AUTORUN_FORM_KEYS = { battle: 'autorunBattle', chief: 'autorunChief', } as const satisfies Record; +const RESET_SCHEDULE_COPY = { + scheduledAt: { + label: '초기화 시작', + help: 'Gateway가 빌드, DB 초기화와 시나리오 생성을 시작합니다. 비우면 즉시 시작하며, 완료되어도 가오픈 전에는 접속을 차단합니다.', + }, + preopenAt: { + label: '가오픈 시작', + help: '게임 접속과 장수 생성, 예약턴 입력을 허용하지만 턴은 진행하지 않습니다. 가오픈을 비우고 정식 오픈만 지정하면 초기화 완료 후 바로 가오픈합니다.', + }, + openAt: { + label: '정식 오픈', + help: '턴 진행을 시작합니다. 비우면 초기화가 완료되는 즉시 정식 오픈합니다.', + }, +} as const; const gatewayForm = reactive({ sourceMode: 'BRANCH' as 'BRANCH' | 'COMMIT', sourceRef: 'main', @@ -1313,31 +1327,66 @@ onBeforeUnmount(() => { -
- - - +
+

+ 초기화 시작 → 가오픈 시작 → 정식 오픈 순서입니다. 초기화 시작을 비우면 바로 작업합니다. +

+
+
+
+ + + (선택 · UTC+9) +
+ +
+
+
+ + + (선택 · UTC+9) +
+ +
+
+
+ + + (선택 · UTC+9) +
+ +
+