fix(gateway): split profile identity from scenario

This commit is contained in:
2026-08-13 16:03:41 +00:00
parent 826d17da81
commit 6478a9f478
26 changed files with 399 additions and 83 deletions
+17 -4
View File
@@ -1280,7 +1280,10 @@ export const adminRouter = router({
sourceRef = resolved;
}
const scenarios = await listScenarioPreviews({ gitRef: resolved });
if (!scenarios.some((scenario) => String(scenario.id) === profile.scenario)) {
if (
profile.currentScenario === null ||
!scenarios.some((scenario) => String(scenario.id) === profile.currentScenario)
) {
throw new Error('Current scenario is not available at source.');
}
} catch {
@@ -1579,6 +1582,8 @@ export const adminRouter = router({
.map((profile) => ({
profileName: profile.profileName,
profile: profile.profile,
instanceKey: profile.instanceKey,
currentScenario: profile.currentScenario,
meta: {
...(typeof profile.meta.korName === 'string' ? { korName: profile.meta.korName } : {}),
},
@@ -1661,7 +1666,8 @@ export const adminRouter = router({
);
const profile = await ctx.profiles.getProfile(input.profileName);
if (!profile) throw new TRPCError({ code: 'NOT_FOUND', message: 'Profile not found.' });
const parsedScenarioId = Number(profile.scenario);
const parsedScenarioId =
profile.currentScenario === null ? Number.NaN : Number(profile.currentScenario);
currentScenarioId = Number.isInteger(parsedScenarioId) ? parsedScenarioId : null;
gitRef = profile.buildCommitSha?.trim();
if (!gitRef) {
@@ -1692,8 +1698,13 @@ export const adminRouter = router({
upsert: profileAdminProcedure
.input(
z.object({
profile: z.string().min(1).max(32),
scenario: z.string().min(1).max(64),
profile: z.string().regex(/^[a-z0-9-]{1,32}$/),
instanceKey: z
.string()
.regex(/^[a-z0-9-]{1,64}$/)
.optional(),
currentScenario: z.string().min(1).max(64).nullable().optional(),
scenario: z.string().min(1).max(64).optional(),
apiPort: z.number().int().min(1).max(65535),
status: zProfileStatus.optional(),
preopenAt: z.string().datetime().optional(),
@@ -1706,6 +1717,8 @@ export const adminRouter = router({
const status = input.status ?? 'STOPPED';
return ctx.profiles.upsertProfile({
profile: input.profile,
instanceKey: input.instanceKey,
currentScenario: input.currentScenario,
scenario: input.scenario,
apiPort: input.apiPort,
status,
@@ -21,6 +21,9 @@ export type LobbyGeneralStatus = {
export type LobbyProfileStatus = {
profileName: string;
profile: string;
instanceKey: string;
currentScenario: string | null;
/** @deprecated Rollback-compatible mirror of currentScenario. */
scenario: string;
status: GatewayProfileStatus;
apiPort: number;
@@ -87,6 +90,8 @@ export class RepositoryProfileStatusService implements GatewayProfileStatusServi
return {
profileName: row.profileName,
profile: row.profile,
instanceKey: row.instanceKey,
currentScenario: row.currentScenario,
scenario: row.scenario,
status: row.status,
apiPort: row.apiPort,
@@ -396,7 +396,7 @@ export const buildProcessDefinitions = (
...baseEnv,
GAME_API_ROLE: 'server',
PROFILE: profile.profile,
SCENARIO: profile.scenario,
SCENARIO: profile.currentScenario ?? 'default',
GAME_PROFILE_NAME: profile.profileName,
GAME_API_PORT: String(profile.apiPort),
GAME_TRPC_PATH: `/${profile.profile}/api/trpc`,
@@ -411,7 +411,7 @@ export const buildProcessDefinitions = (
GAME_ENGINE_ROLE: 'turn-daemon',
TURN_PROFILE: profile.profile,
PROFILE: profile.profile,
SCENARIO: profile.scenario,
SCENARIO: profile.currentScenario ?? 'default',
TURN_PROFILE_NAME: profile.profileName,
};
return {
@@ -1422,8 +1422,8 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle {
} = parseInstallOptions(action);
const tickOverride =
installOptions?.turnTermMinutes !== undefined ? installOptions.turnTermMinutes * 60 : undefined;
const scenarioId = installScenarioId ?? parseScenarioId(profile.scenario);
if (!scenarioId) {
const scenarioId = installScenarioId ?? parseScenarioId(profile.currentScenario);
if (scenarioId === null) {
return { status: 'FAILED', detail: 'scenarioId is missing' };
}
const profileDatabaseUrl = this.resolveProfileDatabaseUrl(profile);
@@ -1547,7 +1547,7 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle {
const desiredStatus = shouldPreopen ? 'PREOPEN' : 'RUNNING';
const publishedProfile = await updateClaimedProfile(
{
scenario: String(scenarioId),
currentScenario: String(scenarioId),
status: desiredStatus,
buildStatus: 'SUCCEEDED',
buildWorkspace: workspace.root,
@@ -1564,8 +1564,8 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle {
completedAt,
error: null,
});
if (String(scenarioId) !== profile.scenario) {
await this.repository.updateScenario(profile.profileName, String(scenarioId));
if (String(scenarioId) !== profile.currentScenario) {
await this.repository.updateCurrentScenario(profile.profileName, String(scenarioId));
}
return this.repository.updateStatus(profile.profileName, desiredStatus, {
preopenAt: preopenAt ? preopenAt.toISOString() : openAt ? openAt.toISOString() : null,
@@ -1577,6 +1577,7 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle {
releasePrepared = true;
const builtProfile = publishedProfile ?? {
...profile,
currentScenario: String(scenarioId),
scenario: String(scenarioId),
status: desiredStatus,
buildWorkspace: workspace.root,
@@ -1642,7 +1643,7 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle {
meta: Record<string, unknown>;
}> {
const databaseUrl = databaseUrlOverride ?? this.resolveProfileDatabaseUrl(profile);
let scenarioId = overrides?.scenarioId ?? parseScenarioId(profile.scenario);
let scenarioId = overrides?.scenarioId ?? parseScenarioId(profile.currentScenario);
let tickSeconds: number | undefined = overrides?.tickSeconds;
let meta: Record<string, unknown> = {};
const connector = createGamePostgresConnector({ url: databaseUrl });
@@ -78,6 +78,9 @@ export interface GatewayOperationLogInput {
export interface GatewayProfileRecord {
profileName: string;
profile: string;
instanceKey: string;
currentScenario: string | null;
/** @deprecated Rollback-compatible mirror of currentScenario. */
scenario: string;
apiPort: number;
status: GatewayProfileStatus;
@@ -100,7 +103,10 @@ export interface GatewayProfileRecord {
export interface GatewayProfileUpsertInput {
profile: string;
scenario: string;
instanceKey?: string;
currentScenario?: string | null;
/** @deprecated Accepted while older bootstrap clients are still supported. */
scenario?: string;
apiPort: number;
status?: GatewayProfileStatus;
preopenAt?: string;
@@ -111,7 +117,7 @@ export interface GatewayProfileUpsertInput {
}
export interface GatewayClaimedProfileUpdate {
scenario?: string;
currentScenario?: string | null;
status?: GatewayProfileStatus;
buildStatus?: GatewayBuildStatus;
buildCommitSha?: string | null;
@@ -131,7 +137,7 @@ export interface GatewayProfileRepository {
listProfiles(): Promise<GatewayProfileRecord[]>;
getProfile(profileName: string): Promise<GatewayProfileRecord | null>;
upsertProfile(input: GatewayProfileUpsertInput): Promise<GatewayProfileRecord>;
updateScenario(profileName: string, scenario: string): Promise<GatewayProfileRecord | null>;
updateCurrentScenario(profileName: string, scenario: string | null): Promise<GatewayProfileRecord | null>;
updateStatus(
profileName: string,
status: GatewayProfileStatus,
@@ -219,6 +225,8 @@ export const buildRetryOperationSource = (previous: {
type GatewayProfileRow = {
profileName: string;
profile: string;
instanceKey: string;
currentScenario: string | null;
scenario: string;
apiPort: number;
status: GatewayProfileStatus;
@@ -265,6 +273,8 @@ type GatewayOperationRow = {
const mapProfile = (row: GatewayProfileRow): GatewayProfileRecord => ({
profileName: row.profileName,
profile: row.profile,
instanceKey: row.instanceKey,
currentScenario: row.currentScenario,
scenario: row.scenario,
apiPort: row.apiPort,
status: row.status,
@@ -285,7 +295,24 @@ const mapProfile = (row: GatewayProfileRow): GatewayProfileRecord => ({
updatedAt: row.updatedAt.toISOString(),
});
const buildProfileName = (profile: string, scenario: string): string => `${profile}:${scenario}`;
export const buildGatewayProfileName = (profile: string, instanceKey: string): string => `${profile}:${instanceKey}`;
export const resolveGatewayProfileIdentity = (
input: GatewayProfileUpsertInput
): {
instanceKey: string;
currentScenario: string | null;
shouldUpdateCurrentScenario: boolean;
} => {
const instanceKey = input.instanceKey ?? input.scenario ?? 'default';
if (input.currentScenario !== undefined) {
return { instanceKey, currentScenario: input.currentScenario, shouldUpdateCurrentScenario: true };
}
if (input.instanceKey === undefined && input.scenario !== undefined && input.scenario !== 'default') {
return { instanceKey, currentScenario: input.scenario, shouldUpdateCurrentScenario: true };
}
return { instanceKey, currentScenario: null, shouldUpdateCurrentScenario: false };
};
const mapOperation = (row: GatewayOperationRow): GatewayOperationRecord => ({
id: row.id,
@@ -331,7 +358,7 @@ const mapOperationLog = (row: {
export const createGatewayProfileRepository = (prisma: GatewayPrismaClient): GatewayProfileRepository => ({
async listProfiles(): Promise<GatewayProfileRecord[]> {
const rows = await prisma.gatewayProfile.findMany({
orderBy: [{ profile: 'asc' }, { scenario: 'asc' }],
orderBy: [{ profile: 'asc' }, { instanceKey: 'asc' }],
});
return rows.map(mapProfile);
},
@@ -342,13 +369,16 @@ export const createGatewayProfileRepository = (prisma: GatewayPrismaClient): Gat
return row ? mapProfile(row) : null;
},
async upsertProfile(input: GatewayProfileUpsertInput): Promise<GatewayProfileRecord> {
const profileName = buildProfileName(input.profile, input.scenario);
const { instanceKey, currentScenario, shouldUpdateCurrentScenario } = resolveGatewayProfileIdentity(input);
const profileName = buildGatewayProfileName(input.profile, instanceKey);
const row = await prisma.gatewayProfile.upsert({
where: { profileName },
create: {
profileName,
profile: input.profile,
scenario: input.scenario,
instanceKey,
currentScenario,
scenario: currentScenario ?? 'default',
apiPort: input.apiPort,
status: input.status ?? 'STOPPED',
preopenAt: input.preopenAt ? new Date(input.preopenAt) : null,
@@ -358,6 +388,8 @@ export const createGatewayProfileRepository = (prisma: GatewayPrismaClient): Gat
meta: (input.meta ?? {}) as GatewayPrisma.JsonObject,
},
update: {
currentScenario: shouldUpdateCurrentScenario ? currentScenario : undefined,
scenario: shouldUpdateCurrentScenario ? (currentScenario ?? 'default') : undefined,
apiPort: input.apiPort,
status: input.status,
preopenAt: input.preopenAt ? new Date(input.preopenAt) : input.preopenAt === null ? null : undefined,
@@ -373,11 +405,12 @@ export const createGatewayProfileRepository = (prisma: GatewayPrismaClient): Gat
});
return mapProfile(row);
},
async updateScenario(profileName: string, scenario: string): Promise<GatewayProfileRecord | null> {
async updateCurrentScenario(profileName: string, scenario: string | null): Promise<GatewayProfileRecord | null> {
const row = await prisma.gatewayProfile.update({
where: { profileName },
data: {
scenario,
currentScenario: scenario,
scenario: scenario ?? 'default',
},
});
return row ? mapProfile(row) : null;
@@ -700,7 +733,8 @@ export const createGatewayProfileRepository = (prisma: GatewayPrismaClient): Gat
return tx.gatewayProfile.update({
where: { profileName },
data: {
scenario: patch.scenario,
currentScenario: patch.currentScenario,
scenario: patch.currentScenario === undefined ? undefined : (patch.currentScenario ?? 'default'),
status: patch.status,
buildStatus: patch.buildStatus,
buildCommitSha: patch.buildCommitSha,
+4 -4
View File
@@ -3,8 +3,8 @@ export const GATEWAY_PROFILE_ORDER = ['che', 'kwe', 'pwe', 'twe', 'nya', 'pya',
const gatewayProfileOrder = new Map<string, number>(GATEWAY_PROFILE_ORDER.map((profile, index) => [profile, index]));
export const compareGatewayProfiles = (
left: { profile: string; scenario: string },
right: { profile: string; scenario: string }
left: { profile: string; instanceKey: string },
right: { profile: string; instanceKey: string }
): number => {
const unknownRank = GATEWAY_PROFILE_ORDER.length;
const profileOrder =
@@ -14,8 +14,8 @@ export const compareGatewayProfiles = (
const profileNameOrder = left.profile.localeCompare(right.profile);
if (profileNameOrder !== 0) return profileNameOrder;
return left.scenario.localeCompare(right.scenario);
return left.instanceKey.localeCompare(right.instanceKey);
};
export const orderGatewayProfiles = <T extends { profile: string; scenario: string }>(profiles: readonly T[]): T[] =>
export const orderGatewayProfiles = <T extends { profile: string; instanceKey: string }>(profiles: readonly T[]): T[] =>
[...profiles].sort(compareGatewayProfiles);
+5 -1
View File
@@ -85,6 +85,8 @@ const buildCaller = async (
const profile = {
profileName: 'che:2',
profile: 'che',
instanceKey: '2',
currentScenario: options.profileScenario ?? '2',
scenario: options.profileScenario ?? '2',
apiPort: 15003,
status: options.initialProfileStatus ?? ('STOPPED' as const),
@@ -98,7 +100,7 @@ const buildCaller = async (
listProfiles: async () => [profile],
getProfile: async () => profile,
upsertProfile: async () => profile,
updateScenario: async () => profile,
updateCurrentScenario: async () => profile,
updateStatus: async (_profileName, status) => {
updatedStatuses.push(status);
return { ...profile, status };
@@ -362,6 +364,8 @@ describe('admin profile navigation API', () => {
{
profileName: 'che:2',
profile: 'che',
instanceKey: '2',
currentScenario: '2',
meta: {},
},
]);
@@ -15,6 +15,8 @@ import { appRouter } from '../src/router.js';
const profile = {
profileName: 'che:default',
profile: 'che',
instanceKey: 'default',
currentScenario: null,
scenario: 'default',
apiPort: 15003,
status: 'RUNNING' as const,
@@ -28,7 +30,7 @@ const profiles: GatewayProfileRepository = {
listProfiles: async () => [profile],
getProfile: async (profileName) => (profileName === profile.profileName ? profile : null),
upsertProfile: async () => profile,
updateScenario: async () => profile,
updateCurrentScenario: async () => profile,
updateStatus: async () => profile,
updateBuildStatus: async () => profile,
updateMeta: async () => profile,
+7 -1
View File
@@ -92,6 +92,8 @@ const buildCaller = (
{
profileName: 'che:default',
profile: 'che',
instanceKey: 'default',
currentScenario: null,
scenario: 'default',
apiPort: 15003,
status: 'RUNNING' as const,
@@ -103,6 +105,8 @@ const buildCaller = (
{
profileName: 'hwe:default',
profile: 'hwe',
instanceKey: 'default',
currentScenario: null,
scenario: 'default',
apiPort: 15015,
status: 'RUNNING' as const,
@@ -119,7 +123,7 @@ const buildCaller = (
upsertProfile: async () => {
throw new Error('not used');
},
updateScenario: async () => null,
updateCurrentScenario: async () => null,
updateStatus: async () => null,
updateBuildStatus: async () => null,
updateMeta: async () => null,
@@ -167,6 +171,8 @@ const buildCaller = (
profileRows.map((profile) => ({
profileName: profile.profileName,
profile: profile.profile,
instanceKey: profile.instanceKey,
currentScenario: profile.currentScenario,
scenario: profile.scenario,
status: profile.status,
apiPort: profile.apiPort,
@@ -13,6 +13,8 @@ import { GitWorkspaceManager } from '../src/orchestrator/workspaceManager.js';
const profile: GatewayProfileRecord = {
profileName: 'che:2',
profile: 'che',
instanceKey: '2',
currentScenario: '2',
scenario: '2',
apiPort: 15003,
status: 'STOPPED',
@@ -58,7 +60,7 @@ const createHarness = (
listProfiles: async () => [profile],
getProfile: async () => profile,
upsertProfile: async () => profile,
updateScenario: async () => profile,
updateCurrentScenario: async () => profile,
updateStatus: async (_profileName, status) => {
statuses.push(status);
return { ...profile, status };
@@ -14,6 +14,8 @@ import type { GatewayProfileRecord } from '../src/orchestrator/profileRepository
const buildProfile = (buildWorkspace?: string): GatewayProfileRecord => ({
profileName: 'che:2',
profile: 'che',
instanceKey: '2',
currentScenario: '2',
scenario: '2',
apiPort: 15003,
status: 'RUNNING',
@@ -172,6 +174,39 @@ describe('buildProcessDefinitions', () => {
expect(definitions.tournament.cwd).toBe(path.join(processConfig.workspaceRoot, 'app', 'game-api'));
});
it('keeps the instance identity stable while passing the mutable current scenario', () => {
const definitions = buildProcessDefinitions(
{
...buildProfile(),
profileName: 'che:default',
instanceKey: 'default',
currentScenario: '1010',
scenario: '1010',
},
processConfig
);
expect(definitions.api.name).toBe('sammo:che:default:game-api');
expect(definitions.api.env).toMatchObject({
GAME_PROFILE_NAME: 'che:default',
SCENARIO: '1010',
});
expect(definitions.daemon.env).toMatchObject({
TURN_PROFILE_NAME: 'che:default',
SCENARIO: '1010',
});
});
it('uses the legacy default scenario marker only for an uninitialized instance runtime', () => {
const definitions = buildProcessDefinitions(
{ ...buildProfile(), currentScenario: null, scenario: 'default' },
processConfig
);
expect(definitions.api.env.SCENARIO).toBe('default');
expect(definitions.daemon.env.SCENARIO).toBe('default');
});
it('does not forward PM2 identity or parent runtime roles to profile processes', () => {
const definitions = buildProcessDefinitions(buildProfile(), {
...processConfig,
@@ -14,6 +14,8 @@ const makeProfile = (
): GatewayProfileRecord => ({
profileName,
profile: profileName.split(':')[0] ?? 'che',
instanceKey: profileName.split(':')[1] ?? 'default',
currentScenario: null,
scenario: profileName.split(':')[1] ?? 'default',
apiPort: 15_003,
status: 'RUNNING',
@@ -50,6 +50,8 @@ describe('profile DEPLOY operation', () => {
const profile: GatewayProfileRecord = {
profileName: 'che:1010',
profile: 'che',
instanceKey: '1010',
currentScenario: '1010',
scenario: '1010',
apiPort: 15003,
status: 'RUNNING',
@@ -80,7 +82,7 @@ describe('profile DEPLOY operation', () => {
listProfiles: async () => [profile],
getProfile: async () => profile,
upsertProfile: async () => profile,
updateScenario: async () => profile,
updateCurrentScenario: async () => profile,
updateStatus: async () => profile,
updateBuildStatus: async () => profile,
updateMeta: async () => profile,
@@ -0,0 +1,52 @@
import { describe, expect, it } from 'vitest';
import {
buildGatewayProfileName,
resolveGatewayProfileIdentity,
type GatewayProfileUpsertInput,
} from '../src/orchestrator/profileRepository.js';
const resolve = (input: Partial<GatewayProfileUpsertInput>) =>
resolveGatewayProfileIdentity({
profile: 'che',
apiPort: 15003,
...input,
});
describe('Gateway profile identity', () => {
it('builds the immutable technical id from profile and instance key', () => {
expect(buildGatewayProfileName('che', 'default')).toBe('che:default');
});
it('does not treat a new default instance as an initialized scenario', () => {
expect(resolve({ instanceKey: 'default' })).toEqual({
instanceKey: 'default',
currentScenario: null,
shouldUpdateCurrentScenario: false,
});
});
it('accepts the old bootstrap default marker without clearing an existing scenario on upsert', () => {
expect(resolve({ scenario: 'default' })).toEqual({
instanceKey: 'default',
currentScenario: null,
shouldUpdateCurrentScenario: false,
});
});
it('maps a legacy non-default scenario to both identity and current state', () => {
expect(resolve({ scenario: '2' })).toEqual({
instanceKey: '2',
currentScenario: '2',
shouldUpdateCurrentScenario: true,
});
});
it('keeps a default instance stable when its current scenario changes', () => {
expect(resolve({ instanceKey: 'default', currentScenario: '1010' })).toEqual({
instanceKey: 'default',
currentScenario: '1010',
shouldUpdateCurrentScenario: true,
});
});
});
+10 -10
View File
@@ -6,25 +6,25 @@ describe('orderGatewayProfiles', () => {
it('uses the public server order instead of alphabetical profile order', () => {
const profiles = ['hwe', 'pya', 'che', 'nya', 'twe', 'pwe', 'kwe'].map((profile) => ({
profile,
scenario: 'default',
instanceKey: 'default',
}));
expect(orderGatewayProfiles(profiles).map(({ profile }) => profile)).toEqual(GATEWAY_PROFILE_ORDER);
});
it('orders scenarios within a profile and places unknown profiles afterward', () => {
it('orders instance keys within a profile and places unknown profiles afterward', () => {
const profiles = [
{ profile: 'zeta', scenario: 'default' },
{ profile: 'che', scenario: '20' },
{ profile: 'alpha', scenario: 'default' },
{ profile: 'che', scenario: '10' },
{ profile: 'zeta', instanceKey: 'default' },
{ profile: 'che', instanceKey: '20' },
{ profile: 'alpha', instanceKey: 'default' },
{ profile: 'che', instanceKey: '10' },
];
expect(orderGatewayProfiles(profiles)).toEqual([
{ profile: 'che', scenario: '10' },
{ profile: 'che', scenario: '20' },
{ profile: 'alpha', scenario: 'default' },
{ profile: 'zeta', scenario: 'default' },
{ profile: 'che', instanceKey: '10' },
{ profile: 'che', instanceKey: '20' },
{ profile: 'alpha', instanceKey: 'default' },
{ profile: 'zeta', instanceKey: 'default' },
]);
expect(profiles[0]?.profile).toBe('zeta');
});
+1 -1
View File
@@ -38,7 +38,7 @@ describe('readReleaseManifest', () => {
await expect(readReleaseManifest(workspaceRoot)).resolves.toMatchObject({
controllerProtocol: RELEASE_CONTROLLER_PROTOCOL,
gatewaySchemaHead: '20260811000000_add_gateway_operation_logs',
gatewaySchemaHead: '20260813000000_split_gateway_profile_identity',
gameSchemaHead: '20260803000000_add_logical_game_clock',
});
});