merge: 최신 main을 게임 버전 표시에 통합
This commit is contained in:
@@ -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 {
|
||||
@@ -885,7 +899,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.'
|
||||
@@ -899,6 +915,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', {
|
||||
@@ -1890,8 +1918,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),
|
||||
@@ -1923,30 +1950,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',
|
||||
|
||||
Reference in New Issue
Block a user