merge: Gateway 초기화 예약 수명주기 정합화

This commit is contained in:
2026-08-21 01:19:52 +00:00
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'; type GatewayAdminActionStatus = 'REQUESTED' | 'APPLIED' | 'FAILED' | 'IGNORED';
interface GatewayAdminActionRecord { interface GatewayAdminActionRecord {
@@ -880,7 +894,9 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle {
const now = this.now(); const now = this.now();
const due = await this.repository.listReservedToStart(now); const due = await this.repository.listReservedToStart(now);
for (const profile of due) { 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( await this.repository.updateLastError(
profile.profileName, profile.profileName,
'Reserved profile is missing preopen/open schedule.' 'Reserved profile is missing preopen/open schedule.'
@@ -894,6 +910,18 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle {
); );
continue; 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'; const queued = profile.buildStatus === 'QUEUED' || profile.buildStatus === 'RUNNING';
if (!queued) { if (!queued) {
await this.repository.updateBuildStatus(profile.profileName, 'QUEUED', { await this.repository.updateBuildStatus(profile.profileName, 'QUEUED', {
@@ -1884,8 +1912,7 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle {
await assertLease?.(); await assertLease?.();
const completedAt = this.now().toISOString(); const completedAt = this.now().toISOString();
const now = this.now(); const now = this.now();
const shouldPreopen = openAt ? openAt.getTime() > now.getTime() : false; const desiredStatus = resolveResetLifecycleStatus(now, preopenAt, openAt);
const desiredStatus = shouldPreopen ? 'PREOPEN' : 'RUNNING';
const publishedProfile = await updateClaimedProfile( const publishedProfile = await updateClaimedProfile(
{ {
currentScenario: String(scenarioId), currentScenario: String(scenarioId),
@@ -1917,30 +1944,37 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle {
} }
); );
releasePrepared = true; releasePrepared = true;
const builtProfile = publishedProfile ?? { if (desiredStatus === 'RESERVED') {
...profile, await appendLog(
currentScenario: String(scenarioId), 'schedule',
scenario: String(scenarioId), `${preopenAt?.toISOString() ?? '가오픈 시각'}까지 RESERVED 상태로 접속을 차단합니다.`
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 }; } 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 updateClaimedProfile({ lastError: null }, async () => {
await this.repository.updateLastError(profile.profileName, null); await this.repository.updateLastError(profile.profileName, null);
return this.repository.getProfile(profile.profileName); 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 () => { it('returns validated profile reset defaults to a scenario-only operator', async () => {
const harness = await buildCaller( const harness = await buildCaller(
async () => { async () => {
@@ -48,6 +48,9 @@ const createHarness = (
startGate?: Promise<void>, startGate?: Promise<void>,
options: { options: {
profile?: GatewayProfileRecord; profile?: GatewayProfileRecord;
profiles?: GatewayProfileRecord[];
reservedToStart?: GatewayProfileRecord[];
now?: () => Date;
cancelGame?: GatewayOrchestratorOptions['cancelGame']; cancelGame?: GatewayOrchestratorOptions['cancelGame'];
} = {} } = {}
) => { ) => {
@@ -59,10 +62,11 @@ const createHarness = (
const started: ProcessDefinition[] = []; const started: ProcessDefinition[] = [];
const stopped: string[] = []; const stopped: string[] = [];
const deleted: string[] = []; const deleted: string[] = [];
const buildStatuses: string[] = [];
const logs: Array<{ phase: string; message: string; level: string }> = []; const logs: Array<{ phase: string; message: string; level: string }> = [];
const repository: GatewayProfileRepository = { const repository: GatewayProfileRepository = {
listProfiles: async () => [harnessProfile], listProfiles: async () => options.profiles ?? [harnessProfile],
getProfile: async () => harnessProfile, getProfile: async () => harnessProfile,
upsertProfile: async () => harnessProfile, upsertProfile: async () => harnessProfile,
updateCurrentScenario: async () => harnessProfile, updateCurrentScenario: async () => harnessProfile,
@@ -70,9 +74,12 @@ const createHarness = (
statuses.push(status); statuses.push(status);
return { ...harnessProfile, status }; return { ...harnessProfile, status };
}, },
updateBuildStatus: async () => harnessProfile, updateBuildStatus: async (_profileName, status) => {
buildStatuses.push(status);
return { ...harnessProfile, buildStatus: status };
},
updateMeta: async () => harnessProfile, updateMeta: async () => harnessProfile,
listReservedToStart: async () => [], listReservedToStart: async () => options.reservedToStart ?? [],
findQueuedBuild: async () => null, findQueuedBuild: async () => null,
updateLastError: async () => {}, updateLastError: async () => {},
updateWorkspaceUsage: async () => {}, updateWorkspaceUsage: async () => {},
@@ -167,10 +174,11 @@ const createHarness = (
scheduleIntervalMs: 60_000, scheduleIntervalMs: 60_000,
buildIntervalMs: 60_000, buildIntervalMs: 60_000,
adminActionIntervalMs: 60_000, adminActionIntervalMs: 60_000,
now: options.now,
cancelGame: options.cancelGame, 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', () => { describe('GatewayOrchestrator first-class operations', () => {
@@ -267,6 +275,81 @@ describe('GatewayOrchestrator first-class operations', () => {
expect(harness.deleted).toEqual([]); 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 () => { it('starts every profile process and records success', async () => {
const harness = createHarness(buildOperation('START')); const harness = createHarness(buildOperation('START'));
@@ -8,6 +8,7 @@ import {
buildProcessDefinitions, buildProcessDefinitions,
buildWorkspaceCommands, buildWorkspaceCommands,
planProfileReconcile, planProfileReconcile,
resolveResetLifecycleStatus,
} from '../src/orchestrator/gatewayOrchestrator.js'; } from '../src/orchestrator/gatewayOrchestrator.js';
import { sanitizeManagedProcessEnv } from '../src/orchestrator/processManager.js'; import { sanitizeManagedProcessEnv } from '../src/orchestrator/processManager.js';
import type { GatewayProfileRecord } from '../src/orchestrator/profileRepository.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', () => { describe('buildProcessDefinitions', () => {
const processConfig = { const processConfig = {
workspaceRoot: '/srv/sammo/main', 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('source-ref').fill('0123456789abcdef0123456789abcdef01234567');
await page.getByTestId('load-scenarios').click(); await page.getByTestId('load-scenarios').click();
await page.getByTestId('scenario-select').selectOption('5'); 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').hover();
await page.getByTestId('request-reset').click(); 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('"sourceMode":"COMMIT"');
expect(JSON.stringify(resetRequest?.body)).toContain('0123456789abcdef0123456789abcdef01234567'); expect(JSON.stringify(resetRequest?.body)).toContain('0123456789abcdef0123456789abcdef01234567');
expect(JSON.stringify(resetRequest?.body)).toContain('"scenarioId":5'); 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.screenshot({ path: testInfo.outputPath('reset-operation-log-desktop.png'), fullPage: true });
await page.setViewportSize({ width: 390, height: 844 }); 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: /도움말$/ }); const helpButtons = page.getByRole('button', { name: /도움말$/ });
await expect(helpButtons).toHaveCount(10); await expect(helpButtons).toHaveCount(13);
const fictionHelp = page.getByTestId('reset-help-fiction'); const fictionHelp = page.getByTestId('reset-help-fiction');
const fictionTooltip = page.getByTestId('reset-help-fiction-tooltip'); const fictionTooltip = page.getByTestId('reset-help-fiction-tooltip');
await expect(fictionTooltip).toBeHidden(); await expect(fictionTooltip).toBeHidden();
@@ -162,6 +162,20 @@ const RESET_AUTORUN_FORM_KEYS = {
battle: 'autorunBattle', battle: 'autorunBattle',
chief: 'autorunChief', chief: 'autorunChief',
} as const satisfies Record<ResetAutorunOption, keyof typeof form>; } 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({ const gatewayForm = reactive({
sourceMode: 'BRANCH' as 'BRANCH' | 'COMMIT', sourceMode: 'BRANCH' as 'BRANCH' | 'COMMIT',
sourceRef: 'main', sourceRef: 'main',
@@ -1313,31 +1327,66 @@ onBeforeUnmount(() => {
</div> </div>
</details> </details>
<div v-if="mode === 'scenario'" class="grid gap-4 md:grid-cols-3"> <div v-if="mode === 'scenario'" class="space-y-2 rounded border border-zinc-800 p-3">
<label class="text-xs text-zinc-400" <p class="text-xs leading-5 text-zinc-400">
>작업 예약 (서버 시간 UTC+9) 초기화 시작 가오픈 시작 정식 오픈 순서입니다. 초기화 시작을 비우면 바로 작업합니다.
<input </p>
v-model="form.scheduledAt" <div class="grid gap-4 md:grid-cols-3">
type="datetime-local" <div class="space-y-1">
class="mt-1 w-full rounded border border-zinc-700 bg-zinc-950 px-3 py-2 text-white" <div class="flex items-center gap-1.5 text-xs text-zinc-400">
/> <label for="reset-scheduled-at">{{ RESET_SCHEDULE_COPY.scheduledAt.label }}</label>
</label> <CompactHelp
<label class="text-xs text-zinc-400" :label="RESET_SCHEDULE_COPY.scheduledAt.label"
>가오픈 (서버 시간 UTC+9) :text="RESET_SCHEDULE_COPY.scheduledAt.help"
<input test-id="reset-help-scheduled-at"
v-model="form.preopenAt" />
type="datetime-local" <span>(선택 · UTC+9)</span>
class="mt-1 w-full rounded border border-zinc-700 bg-zinc-950 px-3 py-2 text-white" </div>
/> <input
</label> id="reset-scheduled-at"
<label class="text-xs text-zinc-400" v-model="form.scheduledAt"
>정식 오픈 (서버 시간 UTC+9) type="datetime-local"
<input class="w-full rounded border border-zinc-700 bg-zinc-950 px-3 py-2 text-white"
v-model="form.openAt" data-testid="reset-scheduled-at"
type="datetime-local" />
class="mt-1 w-full rounded border border-zinc-700 bg-zinc-950 px-3 py-2 text-white" </div>
/> <div class="space-y-1">
</label> <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> </div>
<input <input