diff --git a/app/gateway-api/src/adminRouter.ts b/app/gateway-api/src/adminRouter.ts index c1f1fe0c..4489e507 100644 --- a/app/gateway-api/src/adminRouter.ts +++ b/app/gateway-api/src/adminRouter.ts @@ -21,6 +21,7 @@ import { import type { GatewayApiContext } from './context.js'; import { resolveLocalAccountProfilePolicy } from './auth/localAccountPolicy.js'; import { GATEWAY_BUILD_STATUSES, GATEWAY_PROFILE_STATUSES } from './orchestrator/profileRepository.js'; +import { readProfileReleaseSource } from './orchestrator/profileReleaseSource.js'; import { orderGatewayProfiles, resolveGatewayProfileKoreanName } from './profileOrder.js'; import { purifyGatewayNoticeHtml } from './security/gatewayNoticeHtml.js'; @@ -1210,9 +1211,10 @@ export const adminRouter = router({ }); } - const sourceMode: 'BRANCH' | 'COMMIT' = input.sourceMode === 'CURRENT' ? 'COMMIT' : input.sourceMode; - let sourceRef = - input.sourceMode === 'CURRENT' ? profile.buildCommitSha?.trim() : input.sourceRef?.trim(); + const configuredSource = input.sourceMode === 'CURRENT' ? readProfileReleaseSource(profile) : null; + const sourceMode: 'BRANCH' | 'COMMIT' = + input.sourceMode === 'CURRENT' ? (configuredSource?.mode ?? 'COMMIT') : input.sourceMode; + let sourceRef = configuredSource?.ref ?? input.sourceRef?.trim(); if (!sourceRef) { throw new TRPCError({ code: 'BAD_REQUEST', @@ -1253,6 +1255,7 @@ export const adminRouter = router({ payload: { install: input.install, requestedSource: input.sourceMode, + releaseSource: { mode: sourceMode, ref: sourceRef }, } as GatewayPrisma.JsonObject, reason: input.reason, requestedBy: adminAuth.user.id, @@ -1372,6 +1375,7 @@ export const adminRouter = router({ type: 'DEPLOY', sourceMode: input.sourceMode, sourceRef, + payload: { releaseSource: { mode: input.sourceMode, ref: sourceRef } }, reason: input.reason, requestedBy: adminAuth.user.id, }); @@ -1740,6 +1744,8 @@ export const adminRouter = router({ .query(async ({ ctx, input }) => { const adminAuth = requireAdminAuth(ctx); const sourceMode = input?.sourceMode ?? 'CURRENT'; + let resolvedSourceMode: 'BRANCH' | 'COMMIT' | undefined = + sourceMode === 'CURRENT' ? undefined : sourceMode; let gitRef = input?.gitRef?.trim(); let currentScenarioId: number | null = null; if (sourceMode === 'CURRENT') { @@ -1754,8 +1760,10 @@ export const adminRouter = router({ const parsedScenarioId = profile.currentScenario === null ? Number.NaN : Number(profile.currentScenario); currentScenarioId = Number.isInteger(parsedScenarioId) ? parsedScenarioId : null; - gitRef = profile.buildCommitSha?.trim(); - if (!gitRef) { + const configuredSource = readProfileReleaseSource(profile); + gitRef = configuredSource?.ref; + resolvedSourceMode = configuredSource?.mode; + if (!configuredSource || !gitRef) { throw new TRPCError({ code: 'BAD_REQUEST', message: 'The profile has no active build commit.', @@ -1771,7 +1779,7 @@ export const adminRouter = router({ ? await listScenarioPreviews() : await listScenarioPreviews({ gitRef: - sourceMode === 'BRANCH' + resolvedSourceMode === 'BRANCH' ? await resolveGitBranchCommitSha(gitRef) : await resolveGitCommitSha(gitRef), }); diff --git a/app/gateway-api/src/orchestrator/gatewayOrchestrator.ts b/app/gateway-api/src/orchestrator/gatewayOrchestrator.ts index 6150fda5..519694d3 100644 --- a/app/gateway-api/src/orchestrator/gatewayOrchestrator.ts +++ b/app/gateway-api/src/orchestrator/gatewayOrchestrator.ts @@ -38,6 +38,11 @@ import type { GatewayProfileRepository, GatewayProfileStatus, } from './profileRepository.js'; +import { + canReuseActiveProfileWorkspace, + writeProfileReleaseSource, + type ProfileReleaseSource, +} from './profileReleaseSource.js'; import type { GitWorkspaceManager } from './workspaceManager.js'; import type { AdminSeedUser } from './seedProfileDatabase.js'; import { assertReleaseComponents, readReleaseManifest } from './releaseManifest.js'; @@ -197,6 +202,16 @@ class OperationLeaseLostError extends Error {} const normalizeMeta = (value: unknown): Record => (isRecord(value) ? value : {}); +const readOperationReleaseSource = (operation: GatewayOperationRecord): ProfileReleaseSource => { + const stored = normalizeMeta(normalizeMeta(operation.payload).releaseSource); + const mode = stored.mode; + const ref = typeof stored.ref === 'string' ? stored.ref.trim() : ''; + if ((mode === 'BRANCH' || mode === 'COMMIT') && ref) { + return { mode, ref }; + } + return { mode: operation.sourceMode!, ref: operation.sourceRef! }; +}; + export const buildTournamentRuntimeKeys = (profileName: string): string[] => [ `sammo:${profileName}:tournament:state`, `sammo:${profileName}:tournament:participants`, @@ -1167,7 +1182,13 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle { return; } if (operation.type === 'DEPLOY') { - const result = await this.handleProfileDeploy(profile, commitSha, assertLease, operation.id); + const result = await this.handleProfileDeploy( + profile, + commitSha, + assertLease, + operation.id, + readOperationReleaseSource(operation) + ); if (!result.ok) { throw new Error(result.detail); } @@ -1192,7 +1213,14 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle { installOperationId, install, }; - const result = await this.handleResetAction(profile, resetAction, commitSha, assertLease, operation.id); + const result = await this.handleResetAction( + profile, + resetAction, + commitSha, + assertLease, + operation.id, + readOperationReleaseSource(operation) + ); if (result.status === 'REQUESTED') { const retryAt = new Date(this.now().getTime() + this.adminActionIntervalMs).toISOString(); await this.appendOperationLog( @@ -1360,7 +1388,8 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle { profile: GatewayProfileRecord, commitSha: string, assertLease: () => Promise, - operationId: string + operationId: string, + releaseSource: ProfileReleaseSource ): Promise<{ ok: true } | { ok: false; detail: string }> { if (this.buildInFlight) { return { ok: false, detail: 'build already in progress' }; @@ -1498,6 +1527,7 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle { buildCompletedAt: completedAt, buildError: null, lastError: null, + meta: writeProfileReleaseSource(profile.meta, releaseSource), }); await this.appendOperationLog( operationId, @@ -1617,7 +1647,8 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle { action: GatewayAdminActionRecord, commitShaOverride?: string, assertLease?: () => Promise, - operationId?: string + operationId?: string, + releaseSource?: ProfileReleaseSource ): Promise { const appendLog = async ( phase: string, @@ -1819,6 +1850,7 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle { preopenAt: preopenAt ? preopenAt.toISOString() : openAt ? openAt.toISOString() : null, openAt: openAt ? openAt.toISOString() : null, scheduledStartAt: action.scheduledAt ?? null, + ...(releaseSource ? { meta: writeProfileReleaseSource(profile.meta, releaseSource) } : {}), }, async () => { await this.repository.updateWorkspaceUsage(profile.profileName, workspace.root, completedAt); @@ -1951,6 +1983,20 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle { if (operationId) { await this.appendOperationLog(operationId, 'workspace', `worktree 준비 완료: ${workspace.root}`); } + const activeWorkspaceReusable = canReuseActiveProfileWorkspace(profile, commitSha, workspace); + if (activeWorkspaceReusable) { + if (operationId) { + await this.appendOperationLog( + operationId, + 'build', + '이미 최신 커밋의 빌드 산출물이 준비되어 있어 빌드를 생략합니다.' + ); + } + return { + result: { ok: true, exitCode: 0, output: '' }, + workspace, + }; + } const commands = [ ...buildWorkspaceCommands( workspace.root, diff --git a/app/gateway-api/src/orchestrator/profileReleaseSource.ts b/app/gateway-api/src/orchestrator/profileReleaseSource.ts new file mode 100644 index 00000000..4f956cd7 --- /dev/null +++ b/app/gateway-api/src/orchestrator/profileReleaseSource.ts @@ -0,0 +1,46 @@ +import path from 'node:path'; + +import type { GatewayPrisma } from '@sammo-ts/infra'; + +import type { GatewayProfileRecord, GatewaySourceMode } from './profileRepository.js'; +import type { WorkspaceInfo } from './workspaceManager.js'; + +export interface ProfileReleaseSource { + mode: GatewaySourceMode; + ref: string; +} + +const isRecord = (value: unknown): value is Record => + Boolean(value && typeof value === 'object' && !Array.isArray(value)); + +export const readProfileReleaseSource = (profile: GatewayProfileRecord): ProfileReleaseSource | null => { + const stored = isRecord(profile.meta) && isRecord(profile.meta.releaseSource) ? profile.meta.releaseSource : null; + const mode = stored?.mode; + const ref = typeof stored?.ref === 'string' ? stored.ref.trim() : ''; + if ((mode === 'BRANCH' || mode === 'COMMIT') && ref) { + return { mode, ref }; + } + const activeCommit = profile.buildCommitSha?.trim(); + return activeCommit ? { mode: 'COMMIT', ref: activeCommit } : null; +}; + +export const writeProfileReleaseSource = ( + meta: GatewayPrisma.JsonObject, + source: ProfileReleaseSource +): GatewayPrisma.JsonObject => ({ + ...meta, + releaseSource: { + mode: source.mode, + ref: source.ref, + }, +}); + +export const canReuseActiveProfileWorkspace = ( + profile: GatewayProfileRecord | undefined, + commitSha: string, + workspace: WorkspaceInfo +): boolean => + profile?.buildCommitSha === commitSha && + typeof profile.buildWorkspace === 'string' && + path.resolve(profile.buildWorkspace) === path.resolve(workspace.root) && + !workspace.needsInstall; diff --git a/app/gateway-api/src/orchestrator/profileRepository.ts b/app/gateway-api/src/orchestrator/profileRepository.ts index 1fa900a6..2fab771e 100644 --- a/app/gateway-api/src/orchestrator/profileRepository.ts +++ b/app/gateway-api/src/orchestrator/profileRepository.ts @@ -122,6 +122,7 @@ export interface GatewayClaimedProfileUpdate { buildCompletedAt?: string | null; buildError?: string | null; lastError?: string | null; + meta?: GatewayPrisma.JsonObject; } export interface GatewayProfileRepository { @@ -748,6 +749,7 @@ export const createGatewayProfileRepository = (prisma: GatewayPrismaClient): Gat buildCompletedAt: toDate(patch.buildCompletedAt), buildError: patch.buildError, lastError: patch.lastError, + meta: patch.meta, }, }); }); diff --git a/app/gateway-api/test/adminOperations.test.ts b/app/gateway-api/test/adminOperations.test.ts index 7cef041a..e5a484b2 100644 --- a/app/gateway-api/test/adminOperations.test.ts +++ b/app/gateway-api/test/adminOperations.test.ts @@ -613,7 +613,7 @@ describe('admin operation API', () => { ).rejects.toMatchObject({ code: 'CONFLICT' }); }); - it('queues a DB-preserving profile deployment without reset payload', async () => { + it('queues a DB-preserving profile deployment with a durable release policy and no reset payload', async () => { const harness = await buildCaller( async (input) => ({ id: '33333333-3333-4333-8333-333333333333', @@ -642,11 +642,17 @@ describe('admin operation API', () => { type: 'DEPLOY', sourceMode: 'COMMIT', reason: 'preserve live season', + payload: { + releaseSource: { + mode: 'COMMIT', + ref: expect.stringMatching(/^[0-9a-f]{40}$/u), + }, + }, }); - expect(harness.createdInputs[0]).not.toHaveProperty('payload'); + expect(harness.createdInputs[0]?.payload).not.toHaveProperty('install'); }); - it('lets a scenario-only operator reset from the active commit without selecting Git', async () => { + it('lets a scenario-only operator reset from the configured branch latest without selecting Git', async () => { const harness = await buildCaller( async (input) => ({ id: '55555555-5555-4555-8555-555555555555', @@ -664,6 +670,7 @@ describe('admin operation API', () => { adminRoles: ['admin.scenarios.reset:che:2'], firstUserIsAdmin: false, profileScenario: '1010', + profileMeta: { releaseSource: { mode: 'BRANCH', ref: 'main' } }, } ); @@ -687,8 +694,8 @@ describe('admin operation API', () => { expect(harness.createdInputs[0]).toMatchObject({ type: 'RESET', - sourceMode: 'COMMIT', - sourceRef: expect.stringMatching(/^[0-9a-f]{40}$/u), + sourceMode: 'BRANCH', + sourceRef: 'main', reason: 'new season only', }); }); diff --git a/app/gateway-api/test/profileDeployOperation.test.ts b/app/gateway-api/test/profileDeployOperation.test.ts index 5b74ba6d..d61ceae7 100644 --- a/app/gateway-api/test/profileDeployOperation.test.ts +++ b/app/gateway-api/test/profileDeployOperation.test.ts @@ -193,6 +193,7 @@ describe('profile DEPLOY operation', () => { buildStatus: 'SUCCEEDED', buildCommitSha: SHA, buildWorkspace: workspace, + meta: { releaseSource: { mode: 'COMMIT', ref: SHA } }, }); expect(completions).toEqual(['SUCCEEDED']); expect(logs).toEqual( diff --git a/app/gateway-api/test/profileReleaseSource.test.ts b/app/gateway-api/test/profileReleaseSource.test.ts new file mode 100644 index 00000000..4d760796 --- /dev/null +++ b/app/gateway-api/test/profileReleaseSource.test.ts @@ -0,0 +1,68 @@ +import { describe, expect, it } from 'vitest'; + +import { + canReuseActiveProfileWorkspace, + readProfileReleaseSource, + writeProfileReleaseSource, +} from '../src/orchestrator/profileReleaseSource.js'; +import type { GatewayProfileRecord } from '../src/orchestrator/profileRepository.js'; + +const profile = (overrides: Partial = {}): GatewayProfileRecord => ({ + profileName: 'che:2', + profile: 'che', + instanceKey: '2', + currentScenario: '1010', + scenario: '1010', + apiPort: 15003, + status: 'RUNNING', + buildStatus: 'SUCCEEDED', + buildCommitSha: 'a'.repeat(40), + buildWorkspace: `/srv/sammo/worktrees/${'a'.repeat(40)}`, + meta: {}, + createdAt: '2026-08-19T00:00:00.000Z', + updatedAt: '2026-08-19T00:00:00.000Z', + ...overrides, +}); + +describe('profile release source policy', () => { + it('follows the stored branch instead of pinning the active commit', () => { + const current = profile({ meta: { releaseSource: { mode: 'BRANCH', ref: 'main' } } }); + expect(readProfileReleaseSource(current)).toEqual({ mode: 'BRANCH', ref: 'main' }); + }); + + it('falls back to the active commit for profiles without a stored policy', () => { + expect(readProfileReleaseSource(profile())).toEqual({ mode: 'COMMIT', ref: 'a'.repeat(40) }); + }); + + it('preserves unrelated metadata while changing the privileged release policy', () => { + expect(writeProfileReleaseSource({ nextSeasonIdx: 3 }, { mode: 'COMMIT', ref: 'b'.repeat(40) })).toEqual({ + nextSeasonIdx: 3, + releaseSource: { mode: 'COMMIT', ref: 'b'.repeat(40) }, + }); + }); + + it('reuses installed artifacts only for the same active commit and workspace', () => { + const current = profile(); + expect( + canReuseActiveProfileWorkspace(current, 'a'.repeat(40), { + root: current.buildWorkspace!, + created: false, + needsInstall: false, + }) + ).toBe(true); + expect( + canReuseActiveProfileWorkspace(current, 'b'.repeat(40), { + root: current.buildWorkspace!, + created: false, + needsInstall: false, + }) + ).toBe(false); + expect( + canReuseActiveProfileWorkspace(current, 'a'.repeat(40), { + root: current.buildWorkspace!, + created: false, + needsInstall: true, + }) + ).toBe(false); + }); +}); diff --git a/app/gateway-api/test/profileRepository.test.ts b/app/gateway-api/test/profileRepository.test.ts index 39155e28..8d996e47 100644 --- a/app/gateway-api/test/profileRepository.test.ts +++ b/app/gateway-api/test/profileRepository.test.ts @@ -18,12 +18,14 @@ describe('buildRetryOperationPayload', () => { { install: { scenarioId: 903 }, installOperationId: 'original-install-generation', + releaseSource: { mode: 'BRANCH', ref: 'main' }, }, 'newer-failed-operation' ) ).toEqual({ install: { scenarioId: 903 }, installOperationId: 'original-install-generation', + releaseSource: { mode: 'BRANCH', ref: 'main' }, }); }); }); diff --git a/app/gateway-frontend/e2e/server-operations.spec.ts b/app/gateway-frontend/e2e/server-operations.spec.ts index efd9920d..c8ed572f 100644 --- a/app/gateway-frontend/e2e/server-operations.spec.ts +++ b/app/gateway-frontend/e2e/server-operations.spec.ts @@ -507,7 +507,7 @@ test('separates branch and commit semantics and submits a reset from the dedicat await expect(page.getByTestId('server-operations-page')).toBeVisible(); await expect(page).toHaveURL(/\/gateway\/admin\/servers\/che%3Adefault\/scenario$/); await expect(page.getByTestId('source-current')).toBeChecked(); - await expect(page.getByTestId('source-help')).toContainText('현재 서버에 배포된 커밋'); + await expect(page.getByTestId('source-help')).toContainText('브랜치를 추적하면 작업 시작 시 최신 커밋'); await expect(page.getByTestId('scenario-select')).toHaveValue('2'); await expect(page.getByTestId('request-reset')).toBeEnabled(); await expect(page.getByTestId('scenario-select').locator('option:checked')).toContainText('현재 시나리오'); @@ -1176,7 +1176,7 @@ test('renders the stable server identity without exposing the default suffix as expect(state.profileNavigationRequests).toBe(1); }); -test('scenario-only operator resets the current version without Git or Gateway controls', async ({ page }) => { +test('scenario-only operator resets the server-selected version without Git or Gateway controls', async ({ page }) => { const state: FixtureState = { operations: [], gatewayOperations: [], diff --git a/app/gateway-frontend/src/views/ServerOperationsView.vue b/app/gateway-frontend/src/views/ServerOperationsView.vue index b28c780f..618ad3ec 100644 --- a/app/gateway-frontend/src/views/ServerOperationsView.vue +++ b/app/gateway-frontend/src/views/ServerOperationsView.vue @@ -211,7 +211,7 @@ const pageDescription = computed(() => { return '진행 중 게임을 닫고 정식 기수에서 제외하며 장수 기록과 유산 포인트 보전 범위를 선택합니다.'; } if (props.mode === 'scenario') { - return '현재 배포 버전으로 시나리오만 초기화하거나, 배포 권한이 있을 때 새 버전과 함께 초기화합니다.'; + return '서버에 지정된 브랜치의 최신 버전 또는 고정 커밋으로 시나리오를 초기화합니다.'; } return '현재 게임 DB를 유지한 채 코드와 forward migration을 배포합니다.'; }); @@ -227,7 +227,7 @@ const activeOperation = computed( const sourceHelp = computed(() => form.sourceMode === 'CURRENT' - ? '현재 서버에 배포된 커밋의 시나리오 리소스를 사용합니다.' + ? '서버가 브랜치를 추적하면 작업 시작 시 최신 커밋을 사용하고, 커밋 고정 상태면 그 버전을 유지합니다.' : form.sourceMode === 'BRANCH' ? '작업이 실제로 시작될 때 원격 브랜치를 다시 fetch하여 최신 커밋을 사용합니다.' : '요청 시 커밋을 전체 SHA로 고정하므로 이후 브랜치가 이동해도 결과가 바뀌지 않습니다.' @@ -597,7 +597,7 @@ const requestReset = async () => { } const scenarioId = form.scenarioId; const sourceLabel = - form.sourceMode === 'CURRENT' ? '현재 배포 버전' : form.sourceMode === 'BRANCH' ? '브랜치' : '커밋'; + form.sourceMode === 'CURRENT' ? '서버 지정 버전' : form.sourceMode === 'BRANCH' ? '브랜치' : '커밋'; if ( !window.confirm( `${selectedProfileName.value}의 게임 DB를 초기화합니다.\n${sourceLabel}${form.sourceMode === 'CURRENT' ? '' : `: ${form.sourceRef}`}\n시나리오: ${scenarioId}` @@ -925,7 +925,7 @@ onBeforeUnmount(() => { value="CURRENT" data-testid="source-current" /> - 현재 배포 버전 + 서버 지정 버전