fix(gateway): 일시정지와 서버 중지 상태 계약 분리

This commit is contained in:
2026-08-15 17:12:13 +00:00
parent dc27766dd6
commit 4f841ad82f
16 changed files with 320 additions and 41 deletions
+17
View File
@@ -3,6 +3,7 @@ import { randomBytes } from 'node:crypto';
import { TRPCError } from '@trpc/server';
import { z } from 'zod';
import { gatewayProfileCapabilities } from '@sammo-ts/common';
import type { GatewayPrisma } from '@sammo-ts/infra';
import { procedure, router } from './trpc.js';
@@ -2068,6 +2069,22 @@ export const adminRouter = router({
message: 'Resume permission is required.',
});
}
if (profile.currentScenario === null) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'An uninitialized profile must be reset before it can be resumed.',
});
}
} else if (input.action === 'PAUSE' && profile.status !== 'RUNNING') {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'Pause is allowed only for RUNNING profiles.',
});
} else if (input.action === 'STOP' && !gatewayProfileCapabilities(profile.status).runtimeExpected) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'Stop is allowed only while the profile runtime is available.',
});
} else if (input.action === 'OPEN_SURVEY') {
if (!canOpenSurvey) {
throw new TRPCError({
@@ -1,3 +1,4 @@
import { gatewayProfileCapabilities, type GatewayProfileCapabilities } from '@sammo-ts/common';
import type { GatewayOrchestratorHandle } from '../orchestrator/gatewayOrchestrator.js';
import type {
GatewayProfileRecord,
@@ -26,6 +27,9 @@ export type LobbyProfileStatus = {
/** @deprecated Rollback-compatible mirror of currentScenario. */
scenario: string;
status: GatewayProfileStatus;
lifecycle: GatewayProfileCapabilities & {
dataInitialized: boolean;
};
apiPort: number;
runtime: {
apiRunning: boolean;
@@ -94,6 +98,10 @@ export class RepositoryProfileStatusService implements GatewayProfileStatusServi
currentScenario: row.currentScenario,
scenario: row.scenario,
status: row.status,
lifecycle: {
...gatewayProfileCapabilities(row.status),
dataInitialized: row.currentScenario !== null,
},
apiPort: row.apiPort,
runtime: runtimeMap.get(row.profileName) ?? {
apiRunning: false,
@@ -5,6 +5,7 @@ import { createHash, randomBytes, randomUUID } from 'node:crypto';
import { stripVTControlCharacters } from 'node:util';
import type { ScenarioInstallOptions } from '@sammo-ts/game-engine/scenario/scenarioSeeder.js';
import { gatewayProfileCapabilities } from '@sammo-ts/common';
import {
createGamePostgresConnector,
createRedisConnector,
@@ -89,7 +90,7 @@ export const planProfileReconcile = (
status: GatewayProfileStatus,
runtime: ProfileRuntimeState
): { shouldStart: boolean; shouldStop: boolean } => {
if (status === 'RUNNING' || status === 'PREOPEN' || status === 'PAUSED' || status === 'COMPLETED') {
if (gatewayProfileCapabilities(status).runtimeExpected) {
return {
shouldStart: !(
runtime.frontendRunning &&
@@ -1104,7 +1105,7 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle {
return { ok: false, detail: 'build already in progress' };
}
this.buildInFlight = true;
const shouldRun = ['RUNNING', 'PREOPEN', 'PAUSED', 'COMPLETED'].includes(profile.status);
const shouldRun = gatewayProfileCapabilities(profile.status).runtimeExpected;
const updateClaimedProfile = async (patch: GatewayClaimedProfileUpdate): Promise<GatewayProfileRecord> => {
if (!this.repository.updateProfileForOperation) {
throw new Error('Profile deploy requires lease-fenced profile updates.');
@@ -1,15 +1,7 @@
import { GATEWAY_PROFILE_STATUSES, type GatewayProfileStatus } from '@sammo-ts/common';
import type { GatewayPrisma, GatewayPrismaClient } from '@sammo-ts/infra';
export const GATEWAY_PROFILE_STATUSES = [
'RESERVED',
'PREOPEN',
'RUNNING',
'PAUSED',
'COMPLETED',
'STOPPED',
'DISABLED',
] as const;
export type GatewayProfileStatus = (typeof GATEWAY_PROFILE_STATUSES)[number];
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];
+50 -2
View File
@@ -29,7 +29,7 @@ const buildCaller = async (
runtimeActionCreateError?: unknown;
initialNotice?: string;
initialProfileStatus?: GatewayProfileRecord['status'];
profileScenario?: string;
profileScenario?: string | null;
profileMeta?: GatewayProfileRecord['meta'];
initialOperation?: GatewayOperationRecord;
profileLogVisibilityAfterPolls?: number;
@@ -86,7 +86,7 @@ const buildCaller = async (
profileName: 'che:2',
profile: 'che',
instanceKey: '2',
currentScenario: options.profileScenario ?? '2',
currentScenario: Object.hasOwn(options, 'profileScenario') ? (options.profileScenario ?? null) : '2',
scenario: options.profileScenario ?? '2',
apiPort: 15003,
status: options.initialProfileStatus ?? ('STOPPED' as const),
@@ -1061,6 +1061,54 @@ describe('admin runtime clock action API', () => {
expect(harness.getReconcileCount()).toBe(0);
});
it('does not turn a stopped profile into an accessible paused runtime', async () => {
const harness = await buildCaller(unusedCreateOperation, { initialProfileStatus: 'STOPPED' });
await expect(
harness.caller.admin.profiles.requestAction({
profileName: 'che:2',
action: 'PAUSE',
})
).rejects.toMatchObject({
code: 'BAD_REQUEST',
message: 'Pause is allowed only for RUNNING profiles.',
});
expect(harness.updatedStatuses).toEqual([]);
expect(harness.getReconcileCount()).toBe(0);
});
it('requires reset before an uninitialized stopped profile can be resumed', async () => {
const harness = await buildCaller(unusedCreateOperation, {
initialProfileStatus: 'STOPPED',
profileScenario: null,
});
await expect(
harness.caller.admin.profiles.requestAction({
profileName: 'che:2',
action: 'RESUME',
})
).rejects.toMatchObject({
code: 'BAD_REQUEST',
message: 'An uninitialized profile must be reset before it can be resumed.',
});
expect(harness.updatedStatuses).toEqual([]);
expect(harness.getReconcileCount()).toBe(0);
});
it('allows stopping an accessible paused profile', async () => {
const harness = await buildCaller(unusedCreateOperation, { initialProfileStatus: 'PAUSED' });
await expect(
harness.caller.admin.profiles.requestAction({
profileName: 'che:2',
action: 'STOP',
})
).resolves.toMatchObject({ ok: true });
expect(harness.updatedStatuses).toEqual(['STOPPED']);
expect(harness.getReconcileCount()).toBe(1);
});
it('creates a first-class clock action owned by the authenticated administrator', async () => {
const harness = await buildCaller(unusedCreateOperation);
+7
View File
@@ -175,6 +175,13 @@ const buildCaller = (
currentScenario: profile.currentScenario,
scenario: profile.scenario,
status: profile.status,
lifecycle: {
runtimeExpected: true,
userAccessible: true,
turnsRunning: true,
operatorResumable: false,
dataInitialized: profile.currentScenario !== null,
},
apiPort: profile.apiPort,
runtime: {
apiRunning: true,