fix(gateway): 초기화 예약 수명주기를 정합화

초기화 시작과 가오픈·정식 오픈 시각의 의미를 분리하고, 미래 가오픈 전에는 준비된 프로필을 RESERVED로 유지합니다.\n\n가오픈 시각에는 재빌드 없이 PREOPEN으로 전환하며 관리자 화면 도움말과 API·오케스트레이터·Chromium 회귀 검증을 보강합니다.
This commit is contained in:
2026-08-21 01:18:07 +00:00
parent 428d907cdb
commit c5a41b23a8
6 changed files with 314 additions and 57 deletions
@@ -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 () => {
@@ -48,6 +48,9 @@ const createHarness = (
startGate?: Promise<void>,
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'));
@@ -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',