feat: 오픈 게임 취소와 유산 정산 경로 추가
별도 최고위험 권한으로 실행되는 원자적 취소 작업을 추가한다. 버려진 게임과 장수 기록의 보존·삭제 옵션, 오픈 원금 전액 환급 및 획득 포인트 보전율, 취소 상태와 관리자·과거기록 UI를 함께 반영한다.
This commit is contained in:
@@ -66,6 +66,13 @@ export const ADMIN_CAPABILITIES: readonly AdminCapabilityDefinition[] = [
|
||||
risk: 'CRITICAL',
|
||||
scope: 'PROFILE',
|
||||
},
|
||||
{
|
||||
permission: 'admin.games.cancel',
|
||||
label: '진행 게임 취소',
|
||||
description: '지정 profile의 진행 중 게임을 취소하고 기록과 유산 포인트를 정산합니다.',
|
||||
risk: 'CRITICAL',
|
||||
scope: 'PROFILE',
|
||||
},
|
||||
{
|
||||
permission: 'admin.reset.schedule',
|
||||
label: 'Profile 초기화 예약',
|
||||
@@ -123,6 +130,7 @@ export const resolveAdminActionCapability = (path: string, rawInput?: unknown):
|
||||
}
|
||||
if (path.endsWith('.operations.requestDeploy')) return 'admin.profiles.deploy';
|
||||
if (path.endsWith('.operations.requestReset')) return 'admin.scenarios.reset';
|
||||
if (path.endsWith('.operations.requestGameCancellation')) return 'admin.games.cancel';
|
||||
if (path.endsWith('.operations.requestRuntime')) return 'admin.profiles.runtime';
|
||||
if (path.endsWith('.profiles.upsert') || path.endsWith('.profiles.updateMeta')) return 'admin.profiles.settings';
|
||||
if (path.endsWith('.profiles.setStatus') || path.endsWith('.profiles.reconcileNow')) {
|
||||
|
||||
@@ -60,6 +60,7 @@ const ROLE_ADMIN_PROFILE_RUNTIME = 'admin.profiles.runtime';
|
||||
const ROLE_ADMIN_PROFILE_SETTINGS = 'admin.profiles.settings';
|
||||
const ROLE_ADMIN_PROFILE_DEPLOY = 'admin.profiles.deploy';
|
||||
const ROLE_ADMIN_SCENARIO_RESET = 'admin.scenarios.reset';
|
||||
const ROLE_ADMIN_GAME_CANCEL = 'admin.games.cancel';
|
||||
const ROLE_ADMIN_RELEASES = 'admin.releases.manage';
|
||||
const ROLE_ADMIN_NOTICE = 'admin.notice.manage';
|
||||
const ROLE_ADMIN_AUDIT = 'admin.audit.read';
|
||||
@@ -1268,6 +1269,65 @@ export const adminRouter = router({
|
||||
});
|
||||
}
|
||||
}),
|
||||
requestGameCancellation: adminProcedure
|
||||
.input(
|
||||
z.object({
|
||||
profileName: z.string().min(1),
|
||||
historyMode: z.enum(['RETAIN_ABANDONED', 'DELETE']),
|
||||
generalMode: z.enum(['RETAIN', 'DELETE']),
|
||||
earnedPointRetentionPercent: z.number().int().min(0).max(100),
|
||||
reason: z.string().trim().min(5).max(500),
|
||||
})
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const adminAuth = requireAdminAuth(ctx);
|
||||
assertPermission(adminAuth, ROLE_ADMIN_GAME_CANCEL, input.profileName);
|
||||
const profile = await ctx.profiles.getProfile(input.profileName);
|
||||
if (!profile) {
|
||||
throw new TRPCError({ code: 'NOT_FOUND', message: 'Profile not found.' });
|
||||
}
|
||||
if (!['PREOPEN', 'RUNNING', 'PAUSED'].includes(profile.status)) {
|
||||
throw new TRPCError({
|
||||
code: 'BAD_REQUEST',
|
||||
message: 'Only a PREOPEN, RUNNING, or PAUSED game can be cancelled.',
|
||||
});
|
||||
}
|
||||
const releaseState = await ctx.releases.getState();
|
||||
const sourceRef = releaseState.activeCommitSha?.trim();
|
||||
if (!sourceRef) {
|
||||
throw new TRPCError({
|
||||
code: 'BAD_REQUEST',
|
||||
message: 'The Gateway has no active release commit for the cancellation migration boundary.',
|
||||
});
|
||||
}
|
||||
try {
|
||||
const resolvedCommitSha = await resolveGitCommitSha(sourceRef);
|
||||
return await ctx.profiles.createOperation({
|
||||
profileName: input.profileName,
|
||||
type: 'CANCEL_GAME',
|
||||
sourceMode: 'COMMIT',
|
||||
sourceRef: resolvedCommitSha,
|
||||
payload: {
|
||||
historyMode: input.historyMode,
|
||||
generalMode: input.generalMode,
|
||||
earnedPointRetentionPercent: input.earnedPointRetentionPercent,
|
||||
} as GatewayPrisma.JsonObject,
|
||||
reason: input.reason,
|
||||
requestedBy: adminAuth.user.id,
|
||||
});
|
||||
} catch (error) {
|
||||
if (!isUniqueConstraintError(error)) {
|
||||
throw new TRPCError({
|
||||
code: 'BAD_REQUEST',
|
||||
message: 'The active Gateway release commit cannot be resolved for game cancellation.',
|
||||
});
|
||||
}
|
||||
throw new TRPCError({
|
||||
code: 'CONFLICT',
|
||||
message: 'This profile already has a queued or running operation.',
|
||||
});
|
||||
}
|
||||
}),
|
||||
requestDeploy: adminProcedure
|
||||
.input(
|
||||
z.object({
|
||||
@@ -1340,6 +1400,18 @@ export const adminRouter = router({
|
||||
if (!profile) {
|
||||
throw new TRPCError({ code: 'NOT_FOUND', message: 'Profile not found.' });
|
||||
}
|
||||
if (input.action === 'START' && !gatewayProfileCapabilities(profile.status).operatorResumable) {
|
||||
throw new TRPCError({
|
||||
code: 'BAD_REQUEST',
|
||||
message: 'This profile must be reset before it can be started.',
|
||||
});
|
||||
}
|
||||
if (input.action === 'STOP' && !gatewayProfileCapabilities(profile.status).runtimeExpected) {
|
||||
throw new TRPCError({
|
||||
code: 'BAD_REQUEST',
|
||||
message: 'Only a running profile can be stopped.',
|
||||
});
|
||||
}
|
||||
try {
|
||||
const operation = await ctx.profiles.createOperation({
|
||||
profileName: input.profileName,
|
||||
@@ -1367,9 +1439,11 @@ export const adminRouter = router({
|
||||
const permission =
|
||||
previous.type === 'RESET'
|
||||
? ROLE_ADMIN_SCENARIO_RESET
|
||||
: previous.type === 'DEPLOY'
|
||||
? ROLE_ADMIN_PROFILE_DEPLOY
|
||||
: ROLE_ADMIN_PROFILE_RUNTIME;
|
||||
: previous.type === 'CANCEL_GAME'
|
||||
? ROLE_ADMIN_GAME_CANCEL
|
||||
: previous.type === 'DEPLOY'
|
||||
? ROLE_ADMIN_PROFILE_DEPLOY
|
||||
: ROLE_ADMIN_PROFILE_RUNTIME;
|
||||
assertPermission(adminAuth, permission, previous.profileName);
|
||||
const cancelled = await ctx.profiles.cancelOperation(input.id);
|
||||
if (!cancelled) {
|
||||
@@ -1389,9 +1463,11 @@ export const adminRouter = router({
|
||||
const permission =
|
||||
previous.type === 'RESET'
|
||||
? ROLE_ADMIN_SCENARIO_RESET
|
||||
: previous.type === 'DEPLOY'
|
||||
? ROLE_ADMIN_PROFILE_DEPLOY
|
||||
: ROLE_ADMIN_PROFILE_RUNTIME;
|
||||
: previous.type === 'CANCEL_GAME'
|
||||
? ROLE_ADMIN_GAME_CANCEL
|
||||
: previous.type === 'DEPLOY'
|
||||
? ROLE_ADMIN_PROFILE_DEPLOY
|
||||
: ROLE_ADMIN_PROFILE_RUNTIME;
|
||||
assertPermission(adminAuth, permission, previous.profileName);
|
||||
if (previous.type === 'RESET') {
|
||||
const payload = readMetaObject(previous.payload);
|
||||
|
||||
@@ -5,6 +5,14 @@ import { createHash, randomBytes, randomUUID } from 'node:crypto';
|
||||
import { stripVTControlCharacters } from 'node:util';
|
||||
|
||||
import type { ScenarioInstallOptions } from '@sammo-ts/game-engine/scenario/scenarioSeeder.js';
|
||||
import {
|
||||
cancelGame as defaultCancelGame,
|
||||
GAME_CANCELLATION_GENERAL_MODES,
|
||||
GAME_CANCELLATION_HISTORY_MODES,
|
||||
type GameCancellationGeneralMode,
|
||||
type GameCancellationHistoryMode,
|
||||
type GameCancellationResult,
|
||||
} from '@sammo-ts/game-engine/scenario/gameCancellation.js';
|
||||
import { gatewayProfileCapabilities } from '@sammo-ts/common';
|
||||
import {
|
||||
createGamePostgresConnector,
|
||||
@@ -56,6 +64,7 @@ export interface GatewayOrchestratorOptions {
|
||||
now?: () => Date;
|
||||
fetchImpl?: typeof fetch;
|
||||
clearTournamentRuntimeState?: (profileName: string) => Promise<void>;
|
||||
cancelGame?: typeof defaultCancelGame;
|
||||
}
|
||||
|
||||
export interface ProfileRuntimeState {
|
||||
@@ -596,6 +605,7 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle {
|
||||
private readonly now: () => Date;
|
||||
private readonly fetchImpl: typeof fetch;
|
||||
private readonly clearTournamentRuntimeState: (profileName: string) => Promise<void>;
|
||||
private readonly cancelGame: typeof defaultCancelGame;
|
||||
private reconcileTimer?: NodeJS.Timeout;
|
||||
private scheduleTimer?: NodeJS.Timeout;
|
||||
private buildTimer?: NodeJS.Timeout;
|
||||
@@ -627,6 +637,7 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle {
|
||||
this.clearTournamentRuntimeState =
|
||||
options.clearTournamentRuntimeState ??
|
||||
((profileName) => this.clearTournamentRuntimeStateFromRedis(profileName));
|
||||
this.cancelGame = options.cancelGame ?? defaultCancelGame;
|
||||
}
|
||||
|
||||
private sanitizeOperationLogMessage(message: string): string {
|
||||
@@ -1029,6 +1040,9 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle {
|
||||
let resolvedCommitSha: string | undefined;
|
||||
try {
|
||||
if (operation.type === 'START') {
|
||||
if (!gatewayProfileCapabilities(profile.status).operatorResumable) {
|
||||
throw new Error(`Profile status ${profile.status} cannot be started by an operator.`);
|
||||
}
|
||||
await this.appendOperationLog(operation.id, 'runtime', '프로필 process를 시작합니다.');
|
||||
const updated = await updateOperationProfile(
|
||||
{
|
||||
@@ -1066,6 +1080,9 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle {
|
||||
return;
|
||||
}
|
||||
if (operation.type === 'STOP') {
|
||||
if (!gatewayProfileCapabilities(profile.status).runtimeExpected && profile.status !== 'STOPPED') {
|
||||
throw new Error(`Profile status ${profile.status} cannot be stopped by an operator.`);
|
||||
}
|
||||
await this.appendOperationLog(operation.id, 'runtime', '프로필 process를 정지합니다.');
|
||||
await updateOperationProfile({ status: 'STOPPED' }, () =>
|
||||
this.repository.updateStatus(profile.profileName, 'STOPPED')
|
||||
@@ -1082,7 +1099,7 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle {
|
||||
}
|
||||
|
||||
if (!operation.sourceMode || !operation.sourceRef) {
|
||||
throw new Error('Reset source mode and ref are required.');
|
||||
throw new Error('Operation source mode and ref are required.');
|
||||
}
|
||||
await this.appendOperationLog(
|
||||
operation.id,
|
||||
@@ -1105,6 +1122,48 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle {
|
||||
}
|
||||
await this.appendOperationLog(operation.id, 'resolve', `대상 커밋을 ${commitSha}로 고정했습니다.`);
|
||||
await assertLease();
|
||||
if (operation.type === 'CANCEL_GAME') {
|
||||
const payload = normalizeMeta(operation.payload);
|
||||
const historyMode = payload.historyMode;
|
||||
const generalMode = payload.generalMode;
|
||||
const retention = payload.earnedPointRetentionPercent;
|
||||
if (
|
||||
typeof historyMode !== 'string' ||
|
||||
!GAME_CANCELLATION_HISTORY_MODES.includes(historyMode as GameCancellationHistoryMode) ||
|
||||
typeof generalMode !== 'string' ||
|
||||
!GAME_CANCELLATION_GENERAL_MODES.includes(generalMode as GameCancellationGeneralMode) ||
|
||||
typeof retention !== 'number' ||
|
||||
!Number.isInteger(retention) ||
|
||||
retention < 0 ||
|
||||
retention > 100 ||
|
||||
!operation.reason
|
||||
) {
|
||||
throw new Error('Game cancellation payload is invalid.');
|
||||
}
|
||||
const result = await this.handleGameCancellation(
|
||||
profile,
|
||||
operation,
|
||||
commitSha,
|
||||
{
|
||||
historyMode: historyMode as GameCancellationHistoryMode,
|
||||
generalMode: generalMode as GameCancellationGeneralMode,
|
||||
earnedPointRetentionPercent: retention,
|
||||
},
|
||||
assertLease
|
||||
);
|
||||
await this.appendOperationLog(
|
||||
operation.id,
|
||||
'complete',
|
||||
`게임 취소가 완료되었습니다. 참여자 ${result.participantCount}명, 보존 장수 ${result.preservedGeneralCount}명.`
|
||||
);
|
||||
await this.repository.completeOperation(
|
||||
operation.id,
|
||||
'SUCCEEDED',
|
||||
{ resolvedCommitSha: commitSha, error: null },
|
||||
this.operationLeaseOwner
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (operation.type === 'DEPLOY') {
|
||||
const result = await this.handleProfileDeploy(profile, commitSha, assertLease, operation.id);
|
||||
if (!result.ok) {
|
||||
@@ -1186,6 +1245,115 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle {
|
||||
}
|
||||
}
|
||||
|
||||
private async handleGameCancellation(
|
||||
profile: GatewayProfileRecord,
|
||||
operation: GatewayOperationRecord,
|
||||
commitSha: string,
|
||||
options: {
|
||||
historyMode: GameCancellationHistoryMode;
|
||||
generalMode: GameCancellationGeneralMode;
|
||||
earnedPointRetentionPercent: number;
|
||||
},
|
||||
assertLease: () => Promise<void>
|
||||
): Promise<GameCancellationResult> {
|
||||
if (!['PREOPEN', 'RUNNING', 'PAUSED', 'STOPPED'].includes(profile.status)) {
|
||||
throw new Error(`Profile status ${profile.status} cannot be cancelled.`);
|
||||
}
|
||||
if (this.buildInFlight) throw new Error('build already in progress');
|
||||
this.buildInFlight = true;
|
||||
let runtimeStopped = profile.status === 'STOPPED';
|
||||
let cancellationCommitted = false;
|
||||
const updateClaimedProfile = async (patch: GatewayClaimedProfileUpdate): Promise<GatewayProfileRecord> => {
|
||||
if (!this.repository.updateProfileForOperation) {
|
||||
throw new Error('Game cancellation requires lease-fenced profile updates.');
|
||||
}
|
||||
const updated = await this.repository.updateProfileForOperation(
|
||||
operation.id,
|
||||
this.operationLeaseOwner,
|
||||
profile.profileName,
|
||||
patch
|
||||
);
|
||||
if (!updated) {
|
||||
throw new OperationLeaseLostError(`Operation lease lost while cancelling game: ${operation.id}`);
|
||||
}
|
||||
return updated;
|
||||
};
|
||||
try {
|
||||
await this.appendOperationLog(
|
||||
operation.id,
|
||||
'build',
|
||||
'현재 profile 커밋의 취소 도구와 migration을 준비합니다.'
|
||||
);
|
||||
const { result: buildResult, workspace } = await this.runBuildCommands(commitSha, profile, operation.id);
|
||||
await assertLease();
|
||||
if (!buildResult.ok) throw new Error(buildResult.output.slice(-4000) || 'profile build failed');
|
||||
|
||||
const databaseUrl = this.resolveProfileDatabaseUrl(profile);
|
||||
await this.appendOperationLog(operation.id, 'migration', '게임 취소 schema migration을 적용합니다.');
|
||||
const migration = await this.runProfileMigration(
|
||||
workspace.root,
|
||||
databaseUrl,
|
||||
this.buildProgress(operation.id, 'migration')
|
||||
);
|
||||
await assertLease();
|
||||
if (!migration.ok) throw new Error(migration.output.slice(-4000) || 'profile database migration failed');
|
||||
|
||||
if (!runtimeStopped) {
|
||||
await updateClaimedProfile({ status: 'STOPPED' });
|
||||
await this.appendOperationLog(
|
||||
operation.id,
|
||||
'runtime',
|
||||
'쓰기 차단을 위해 profile process를 정지합니다.'
|
||||
);
|
||||
await this.stopProfile(profile, assertLease);
|
||||
runtimeStopped = true;
|
||||
}
|
||||
await assertLease();
|
||||
await this.appendOperationLog(
|
||||
operation.id,
|
||||
'settlement',
|
||||
'기수·장수 기록과 유산 포인트를 원자적으로 정산합니다.'
|
||||
);
|
||||
const result = await this.cancelGame({
|
||||
cancellationId: operation.id,
|
||||
databaseUrl,
|
||||
cancelledBy: operation.requestedBy,
|
||||
reason: operation.reason ?? '',
|
||||
...options,
|
||||
cancelledAt: this.now(),
|
||||
});
|
||||
cancellationCommitted = true;
|
||||
await assertLease();
|
||||
await updateClaimedProfile({
|
||||
status: 'CANCELLED',
|
||||
preopenAt: null,
|
||||
openAt: null,
|
||||
scheduledStartAt: null,
|
||||
lastError: null,
|
||||
});
|
||||
await this.appendOperationLog(
|
||||
operation.id,
|
||||
'publish',
|
||||
`profile을 재개할 수 없는 CANCELLED 상태로 전환했습니다. 취소 ID: ${result.cancellationId}`
|
||||
);
|
||||
return result;
|
||||
} catch (error) {
|
||||
if (error instanceof OperationLeaseLostError) throw error;
|
||||
if (runtimeStopped && !cancellationCommitted && profile.status !== 'STOPPED') {
|
||||
try {
|
||||
await updateClaimedProfile({ status: profile.status, lastError: null });
|
||||
await this.startProfile(profile, assertLease);
|
||||
runtimeStopped = false;
|
||||
} catch {
|
||||
// The original cancellation failure remains authoritative.
|
||||
}
|
||||
}
|
||||
throw error;
|
||||
} finally {
|
||||
this.buildInFlight = false;
|
||||
}
|
||||
}
|
||||
|
||||
private async handleProfileDeploy(
|
||||
profile: GatewayProfileRecord,
|
||||
commitSha: string,
|
||||
|
||||
@@ -8,7 +8,7 @@ export { GATEWAY_PROFILE_STATUSES, type GatewayProfileStatus };
|
||||
export const GATEWAY_BUILD_STATUSES = ['IDLE', 'QUEUED', 'RUNNING', 'FAILED', 'SUCCEEDED'] as const;
|
||||
export type GatewayBuildStatus = (typeof GATEWAY_BUILD_STATUSES)[number];
|
||||
|
||||
export type GatewayOperationType = 'RESET' | 'DEPLOY' | 'START' | 'STOP';
|
||||
export type GatewayOperationType = 'RESET' | 'DEPLOY' | 'CANCEL_GAME' | 'START' | 'STOP';
|
||||
|
||||
export type GatewayOperationStatus = 'QUEUED' | 'RUNNING' | 'SUCCEEDED' | 'FAILED' | 'CANCELLED';
|
||||
|
||||
|
||||
@@ -31,6 +31,7 @@ const buildCaller = async (
|
||||
initialProfileStatus?: GatewayProfileRecord['status'];
|
||||
profileScenario?: string | null;
|
||||
profileMeta?: GatewayProfileRecord['meta'];
|
||||
releaseCommitSha?: string;
|
||||
initialOperation?: GatewayOperationRecord;
|
||||
profileLogVisibilityAfterPolls?: number;
|
||||
releaseLogVisibilityAfterPolls?: number;
|
||||
@@ -158,7 +159,7 @@ const buildCaller = async (
|
||||
const releases: GatewayReleaseRepository = {
|
||||
getState: async () => ({
|
||||
id: 'gateway',
|
||||
activeCommitSha: '1111111111111111111111111111111111111111',
|
||||
activeCommitSha: options.releaseCommitSha ?? '1111111111111111111111111111111111111111',
|
||||
activeWorkspace: '/srv/sammo/current',
|
||||
previousCommitSha: '2222222222222222222222222222222222222222',
|
||||
previousWorkspace: '/srv/sammo/previous',
|
||||
@@ -476,6 +477,99 @@ describe('gateway notice API', () => {
|
||||
});
|
||||
|
||||
describe('admin operation API', () => {
|
||||
it('queues game cancellation only with its dedicated scoped capability', async () => {
|
||||
const harness = await buildCaller(
|
||||
async (input) => ({
|
||||
id: '77777777-7777-4777-8777-777777777777',
|
||||
profileName: input.profileName,
|
||||
type: input.type,
|
||||
status: 'QUEUED',
|
||||
sourceMode: input.sourceMode,
|
||||
sourceRef: input.sourceRef,
|
||||
payload: input.payload ?? {},
|
||||
reason: input.reason,
|
||||
requestedBy: input.requestedBy,
|
||||
createdAt: '2026-08-18T00:00:00.000Z',
|
||||
updatedAt: '2026-08-18T00:00:00.000Z',
|
||||
}),
|
||||
{
|
||||
adminRoles: ['admin.games.cancel:che:2'],
|
||||
firstUserIsAdmin: false,
|
||||
initialProfileStatus: 'RUNNING',
|
||||
releaseCommitSha: 'HEAD',
|
||||
}
|
||||
);
|
||||
|
||||
await harness.caller.admin.operations.requestGameCancellation({
|
||||
profileName: 'che:2',
|
||||
historyMode: 'RETAIN_ABANDONED',
|
||||
generalMode: 'DELETE',
|
||||
earnedPointRetentionPercent: 35,
|
||||
reason: '잘못 연 게임 취소',
|
||||
});
|
||||
|
||||
expect(harness.createdInputs[0]).toMatchObject({
|
||||
profileName: 'che:2',
|
||||
type: 'CANCEL_GAME',
|
||||
sourceMode: 'COMMIT',
|
||||
sourceRef: expect.stringMatching(/^[0-9a-f]{40}$/u),
|
||||
payload: {
|
||||
historyMode: 'RETAIN_ABANDONED',
|
||||
generalMode: 'DELETE',
|
||||
earnedPointRetentionPercent: 35,
|
||||
},
|
||||
reason: '잘못 연 게임 취소',
|
||||
});
|
||||
});
|
||||
|
||||
it('does not treat scenario reset permission as game cancellation permission', async () => {
|
||||
const harness = await buildCaller(
|
||||
async () => {
|
||||
throw new Error('not used');
|
||||
},
|
||||
{
|
||||
adminRoles: ['admin.scenarios.reset:che:2'],
|
||||
firstUserIsAdmin: false,
|
||||
initialProfileStatus: 'RUNNING',
|
||||
releaseCommitSha: 'HEAD',
|
||||
}
|
||||
);
|
||||
|
||||
await expect(
|
||||
harness.caller.admin.operations.requestGameCancellation({
|
||||
profileName: 'che:2',
|
||||
historyMode: 'DELETE',
|
||||
generalMode: 'DELETE',
|
||||
earnedPointRetentionPercent: 0,
|
||||
reason: '잘못 연 게임 취소',
|
||||
})
|
||||
).rejects.toMatchObject({ code: 'FORBIDDEN' });
|
||||
});
|
||||
|
||||
it('rejects cancellation after the profile is already terminal', async () => {
|
||||
const harness = await buildCaller(
|
||||
async () => {
|
||||
throw new Error('not used');
|
||||
},
|
||||
{
|
||||
adminRoles: ['admin.games.cancel:che:2'],
|
||||
firstUserIsAdmin: false,
|
||||
initialProfileStatus: 'COMPLETED',
|
||||
releaseCommitSha: 'HEAD',
|
||||
}
|
||||
);
|
||||
|
||||
await expect(
|
||||
harness.caller.admin.operations.requestGameCancellation({
|
||||
profileName: 'che:2',
|
||||
historyMode: 'RETAIN_ABANDONED',
|
||||
generalMode: 'RETAIN',
|
||||
earnedPointRetentionPercent: 0,
|
||||
reason: '완료 게임 취소 시도',
|
||||
})
|
||||
).rejects.toMatchObject({ code: 'BAD_REQUEST' });
|
||||
});
|
||||
|
||||
it('queues a start operation with the authenticated requester', async () => {
|
||||
const operation = {
|
||||
id: '11111111-1111-4111-8111-111111111111',
|
||||
@@ -504,9 +598,12 @@ describe('admin operation API', () => {
|
||||
});
|
||||
|
||||
it('reports an active-operation uniqueness conflict', async () => {
|
||||
const harness = await buildCaller(async () => {
|
||||
throw { code: 'P2002' };
|
||||
});
|
||||
const harness = await buildCaller(
|
||||
async () => {
|
||||
throw { code: 'P2002' };
|
||||
},
|
||||
{ initialProfileStatus: 'RUNNING' }
|
||||
);
|
||||
|
||||
await expect(
|
||||
harness.caller.admin.operations.requestRuntime({
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { GatewayOrchestrator } from '../src/orchestrator/gatewayOrchestrator.js';
|
||||
import { GatewayOrchestrator, type GatewayOrchestratorOptions } from '../src/orchestrator/gatewayOrchestrator.js';
|
||||
import type { ProcessDefinition, ProcessManager } from '../src/orchestrator/processManager.js';
|
||||
import type {
|
||||
GatewayOperationRecord,
|
||||
@@ -45,8 +45,13 @@ const createHarness = (
|
||||
processesPresent = true,
|
||||
missingOnDelete = false,
|
||||
workspaceManager?: GitWorkspaceManager,
|
||||
startGate?: Promise<void>
|
||||
startGate?: Promise<void>,
|
||||
options: {
|
||||
profile?: GatewayProfileRecord;
|
||||
cancelGame?: GatewayOrchestratorOptions['cancelGame'];
|
||||
} = {}
|
||||
) => {
|
||||
const harnessProfile = options.profile ?? profile;
|
||||
let nextOperation: GatewayOperationRecord | null = operation;
|
||||
const statuses: string[] = [];
|
||||
const completions: GatewayOperationStatus[] = [];
|
||||
@@ -57,23 +62,23 @@ const createHarness = (
|
||||
const logs: Array<{ phase: string; message: string; level: string }> = [];
|
||||
|
||||
const repository: GatewayProfileRepository = {
|
||||
listProfiles: async () => [profile],
|
||||
getProfile: async () => profile,
|
||||
upsertProfile: async () => profile,
|
||||
updateCurrentScenario: async () => profile,
|
||||
listProfiles: async () => [harnessProfile],
|
||||
getProfile: async () => harnessProfile,
|
||||
upsertProfile: async () => harnessProfile,
|
||||
updateCurrentScenario: async () => harnessProfile,
|
||||
updateStatus: async (_profileName, status) => {
|
||||
statuses.push(status);
|
||||
return { ...profile, status };
|
||||
return { ...harnessProfile, status };
|
||||
},
|
||||
updateBuildStatus: async () => profile,
|
||||
updateMeta: async () => profile,
|
||||
updateBuildStatus: async () => harnessProfile,
|
||||
updateMeta: async () => harnessProfile,
|
||||
listReservedToStart: async () => [],
|
||||
findQueuedBuild: async () => null,
|
||||
updateLastError: async () => {},
|
||||
updateWorkspaceUsage: async () => {},
|
||||
clearWorkspaceUsage: async () => {},
|
||||
listOperations: async () => [],
|
||||
listActiveOperationProfileNames: async () => [profile.profileName],
|
||||
listActiveOperationProfileNames: async () => [harnessProfile.profileName],
|
||||
getOperation: async () => operation,
|
||||
listOperationLogs: async () => [],
|
||||
appendOperationLog: async (operationId, input) => {
|
||||
@@ -99,6 +104,10 @@ const createHarness = (
|
||||
requeueOperation: async () => ({ ...operation, status: 'QUEUED' }),
|
||||
cancelOperation: async () => false,
|
||||
retryOperation: async () => null,
|
||||
updateProfileForOperation: async (_id, _ownerId, _profileName, patch) => {
|
||||
if (patch.status) statuses.push(patch.status);
|
||||
return { ...harnessProfile, status: patch.status ?? harnessProfile.status };
|
||||
},
|
||||
};
|
||||
const processManager: ProcessManager = {
|
||||
list: async () =>
|
||||
@@ -152,17 +161,102 @@ const createHarness = (
|
||||
redisKeyPrefix: 'sammo:test',
|
||||
gameTokenSecret: 'test-secret',
|
||||
gatewayInternalApiUrl: 'http://127.0.0.1:13000',
|
||||
baseEnv: { DATABASE_URL: 'postgresql://test:test@127.0.0.1:15432/test' },
|
||||
},
|
||||
reconcileIntervalMs: 60_000,
|
||||
scheduleIntervalMs: 60_000,
|
||||
buildIntervalMs: 60_000,
|
||||
adminActionIntervalMs: 60_000,
|
||||
cancelGame: options.cancelGame,
|
||||
});
|
||||
|
||||
return { orchestrator, statuses, completions, completionFields, started, stopped, deleted, logs };
|
||||
};
|
||||
|
||||
describe('GatewayOrchestrator first-class operations', () => {
|
||||
it('stops runtime, settles once, and seals a cancelled profile', async () => {
|
||||
const operation: GatewayOperationRecord = {
|
||||
id: '88888888-8888-4888-8888-888888888888',
|
||||
profileName: profile.profileName,
|
||||
type: 'CANCEL_GAME',
|
||||
status: 'RUNNING',
|
||||
sourceMode: 'COMMIT',
|
||||
sourceRef: profile.buildCommitSha,
|
||||
resolvedCommitSha: profile.buildCommitSha,
|
||||
payload: {
|
||||
historyMode: 'RETAIN_ABANDONED',
|
||||
generalMode: 'RETAIN',
|
||||
earnedPointRetentionPercent: 40,
|
||||
},
|
||||
reason: '잘못 연 게임 취소',
|
||||
requestedBy: 'admin',
|
||||
createdAt: '2026-08-18T00:00:00.000Z',
|
||||
startedAt: '2026-08-18T00:00:00.000Z',
|
||||
updatedAt: '2026-08-18T00:00:00.000Z',
|
||||
};
|
||||
const workspaceManager = {
|
||||
resolveCommit: async () => profile.buildCommitSha!,
|
||||
prepare: async () => ({ root: process.cwd(), needsInstall: false }),
|
||||
remove: async () => {},
|
||||
} as unknown as GitWorkspaceManager;
|
||||
const settlementRequests: Array<Record<string, unknown>> = [];
|
||||
const cancellationProfile = { ...profile, status: 'RUNNING' as const };
|
||||
const cancelGame: NonNullable<GatewayOrchestratorOptions['cancelGame']> = async (request) => {
|
||||
settlementRequests.push(request as unknown as Record<string, unknown>);
|
||||
return {
|
||||
cancellationId: operation.id,
|
||||
serverId: 'che_260818_fixture',
|
||||
originalSeason: 7,
|
||||
participantCount: 2,
|
||||
preservedGeneralCount: 2,
|
||||
historyMode: 'RETAIN_ABANDONED',
|
||||
generalMode: 'RETAIN',
|
||||
earnedPointRetentionPercent: 40,
|
||||
alreadyApplied: false,
|
||||
settlements: {},
|
||||
};
|
||||
};
|
||||
const harness = createHarness(operation, false, false, true, false, workspaceManager, undefined, {
|
||||
profile: cancellationProfile,
|
||||
cancelGame,
|
||||
});
|
||||
|
||||
await harness.orchestrator.runOperationsNow();
|
||||
|
||||
expect(settlementRequests).toHaveLength(1);
|
||||
expect(settlementRequests[0]).toMatchObject({
|
||||
cancellationId: operation.id,
|
||||
cancelledBy: 'admin',
|
||||
reason: '잘못 연 게임 취소',
|
||||
historyMode: 'RETAIN_ABANDONED',
|
||||
generalMode: 'RETAIN',
|
||||
earnedPointRetentionPercent: 40,
|
||||
});
|
||||
expect(harness.statuses).toEqual(['STOPPED', 'CANCELLED']);
|
||||
expect(harness.stopped).toHaveLength(6);
|
||||
expect(harness.completions).toEqual(['SUCCEEDED']);
|
||||
expect(harness.logs).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ phase: 'settlement' }),
|
||||
expect.objectContaining({ phase: 'publish', message: expect.stringContaining('CANCELLED') }),
|
||||
])
|
||||
);
|
||||
});
|
||||
|
||||
it('does not let a stale START operation reopen a cancelled profile', async () => {
|
||||
const cancelledProfile = { ...profile, status: 'CANCELLED' as const };
|
||||
const operation = buildOperation('START');
|
||||
const harness = createHarness(operation, false, false, true, false, undefined, undefined, {
|
||||
profile: cancelledProfile,
|
||||
});
|
||||
|
||||
await harness.orchestrator.runOperationsNow();
|
||||
|
||||
expect(harness.started).toEqual([]);
|
||||
expect(harness.completions).toEqual(['FAILED']);
|
||||
expect(harness.completionFields[0]?.error).toContain('CANCELLED');
|
||||
});
|
||||
|
||||
it('does not reconcile a profile while a durable operation is active', async () => {
|
||||
const harness = createHarness(buildOperation('START'));
|
||||
|
||||
|
||||
@@ -38,7 +38,7 @@ describe('readReleaseManifest', () => {
|
||||
|
||||
await expect(readReleaseManifest(workspaceRoot)).resolves.toMatchObject({
|
||||
controllerProtocol: RELEASE_CONTROLLER_PROTOCOL,
|
||||
gatewaySchemaHead: '20260818000000_add_legacy_import_checkpoints',
|
||||
gatewaySchemaHead: '20260818001000_add_game_cancellation_operation',
|
||||
gameSchemaHead: '20260818010000_add_legacy_battle_result_logs',
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user