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
@@ -158,6 +158,20 @@ export const planProfileReconcile = (
};
};
export const resolveResetLifecycleStatus = (
now: Date,
preopenAt: Date | null,
openAt: Date | null
): Extract<GatewayProfileStatus, 'RESERVED' | 'PREOPEN' | 'RUNNING'> => {
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);
@@ -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',
@@ -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();
@@ -162,6 +162,20 @@ const RESET_AUTORUN_FORM_KEYS = {
battle: 'autorunBattle',
chief: 'autorunChief',
} as const satisfies Record<ResetAutorunOption, keyof typeof form>;
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(() => {
</div>
</details>
<div v-if="mode === 'scenario'" class="grid gap-4 md:grid-cols-3">
<label class="text-xs text-zinc-400"
>작업 예약 (서버 시간 UTC+9)
<input
v-model="form.scheduledAt"
type="datetime-local"
class="mt-1 w-full rounded border border-zinc-700 bg-zinc-950 px-3 py-2 text-white"
/>
</label>
<label class="text-xs text-zinc-400"
>가오픈 (서버 시간 UTC+9)
<input
v-model="form.preopenAt"
type="datetime-local"
class="mt-1 w-full rounded border border-zinc-700 bg-zinc-950 px-3 py-2 text-white"
/>
</label>
<label class="text-xs text-zinc-400"
>정식 오픈 (서버 시간 UTC+9)
<input
v-model="form.openAt"
type="datetime-local"
class="mt-1 w-full rounded border border-zinc-700 bg-zinc-950 px-3 py-2 text-white"
/>
</label>
<div v-if="mode === 'scenario'" class="space-y-2 rounded border border-zinc-800 p-3">
<p class="text-xs leading-5 text-zinc-400">
초기화 시작 가오픈 시작 정식 오픈 순서입니다. 초기화 시작을 비우면 바로 작업합니다.
</p>
<div class="grid gap-4 md:grid-cols-3">
<div class="space-y-1">
<div class="flex items-center gap-1.5 text-xs text-zinc-400">
<label for="reset-scheduled-at">{{ RESET_SCHEDULE_COPY.scheduledAt.label }}</label>
<CompactHelp
:label="RESET_SCHEDULE_COPY.scheduledAt.label"
:text="RESET_SCHEDULE_COPY.scheduledAt.help"
test-id="reset-help-scheduled-at"
/>
<span>(선택 · UTC+9)</span>
</div>
<input
id="reset-scheduled-at"
v-model="form.scheduledAt"
type="datetime-local"
class="w-full rounded border border-zinc-700 bg-zinc-950 px-3 py-2 text-white"
data-testid="reset-scheduled-at"
/>
</div>
<div class="space-y-1">
<div class="flex items-center gap-1.5 text-xs text-zinc-400">
<label for="reset-preopen-at">{{ RESET_SCHEDULE_COPY.preopenAt.label }}</label>
<CompactHelp
:label="RESET_SCHEDULE_COPY.preopenAt.label"
:text="RESET_SCHEDULE_COPY.preopenAt.help"
test-id="reset-help-preopen-at"
/>
<span>(선택 · UTC+9)</span>
</div>
<input
id="reset-preopen-at"
v-model="form.preopenAt"
type="datetime-local"
class="w-full rounded border border-zinc-700 bg-zinc-950 px-3 py-2 text-white"
data-testid="reset-preopen-at"
/>
</div>
<div class="space-y-1">
<div class="flex items-center gap-1.5 text-xs text-zinc-400">
<label for="reset-open-at">{{ RESET_SCHEDULE_COPY.openAt.label }}</label>
<CompactHelp
:label="RESET_SCHEDULE_COPY.openAt.label"
:text="RESET_SCHEDULE_COPY.openAt.help"
test-id="reset-help-open-at"
/>
<span>(선택 · UTC+9)</span>
</div>
<input
id="reset-open-at"
v-model="form.openAt"
type="datetime-local"
class="w-full rounded border border-zinc-700 bg-zinc-950 px-3 py-2 text-white"
data-testid="reset-open-at"
/>
</div>
</div>
</div>
<input