From 3620f79c6a184641f406e4266c20e8a7f9ddd48a Mon Sep 17 00:00:00 2001 From: hided62 Date: Tue, 11 Aug 2026 15:11:27 +0000 Subject: [PATCH] feat(gateway): persist profile operation build logs --- app/gateway-api/src/adminRouter.ts | 38 +++ .../src/orchestrator/gatewayOrchestrator.ts | 256 +++++++++++++++--- .../src/orchestrator/profileRepository.ts | 125 +++++++-- app/gateway-api/test/adminOperations.test.ts | 109 ++++++++ .../test/adminSecurityTransport.e2e.test.ts | 7 + app/gateway-api/test/authFlow.test.ts | 10 +- .../test/orchestratorOperations.test.ts | 13 +- .../test/profileDeployOperation.test.ts | 35 ++- .../profileOperationLease.integration.test.ts | 22 ++ app/gateway-api/test/releaseManifest.test.ts | 2 +- .../e2e/server-operations.spec.ts | 89 +++++- .../src/views/ServerOperationsView.vue | 209 ++++++++++++-- .../migration.sql | 18 ++ packages/infra/prisma/gateway.prisma | 14 + release-manifest.json | 2 +- 15 files changed, 857 insertions(+), 92 deletions(-) create mode 100644 packages/infra/prisma/gateway-migrations/20260811000000_add_gateway_operation_logs/migration.sql diff --git a/app/gateway-api/src/adminRouter.ts b/app/gateway-api/src/adminRouter.ts index c95c88a3..a4531a90 100644 --- a/app/gateway-api/src/adminRouter.ts +++ b/app/gateway-api/src/adminRouter.ts @@ -1088,6 +1088,44 @@ export const adminRouter = router({ .sort((left, right) => right.createdAt.localeCompare(left.createdAt)); return operations.slice(0, input?.limit ?? 50); }), + logs: adminProcedure + .input( + z.object({ + id: z.string().uuid(), + afterCursor: z.string().regex(/^\d+$/u).optional(), + limit: z.number().int().min(1).max(500).default(200), + timeoutMs: z.number().int().min(0).max(25_000).default(20_000), + }) + ) + .query(async ({ ctx, input }) => { + const adminAuth = requireAdminAuth(ctx); + const initialOperation = await ctx.profiles.getOperation(input.id); + if (!initialOperation) { + throw new TRPCError({ code: 'NOT_FOUND', message: 'Profile operation not found.' }); + } + if (!canReadProfile(adminAuth, initialOperation.profileName)) { + throw new TRPCError({ code: 'FORBIDDEN', message: 'Permission denied.' }); + } + const deadline = Date.now() + input.timeoutMs; + while (true) { + const [operation, entries] = await Promise.all([ + ctx.profiles.getOperation(input.id), + ctx.profiles.listOperationLogs(input.id, input.afterCursor, input.limit), + ]); + if (!operation) { + throw new TRPCError({ code: 'NOT_FOUND', message: 'Profile operation not found.' }); + } + const terminal = ['SUCCEEDED', 'FAILED', 'CANCELLED'].includes(operation.status); + if (entries.length || terminal || Date.now() >= deadline) { + return { + operation, + entries, + nextCursor: entries.at(-1)?.cursor ?? input.afterCursor, + }; + } + await new Promise((resolve) => setTimeout(resolve, 250)); + } + }), requestReset: adminProcedure .input( z.object({ diff --git a/app/gateway-api/src/orchestrator/gatewayOrchestrator.ts b/app/gateway-api/src/orchestrator/gatewayOrchestrator.ts index 3f72c3ee..365d2a16 100644 --- a/app/gateway-api/src/orchestrator/gatewayOrchestrator.ts +++ b/app/gateway-api/src/orchestrator/gatewayOrchestrator.ts @@ -2,6 +2,7 @@ import fs from 'node:fs/promises'; import os from 'node:os'; import path from 'node:path'; import { createHash, randomBytes, randomUUID } from 'node:crypto'; +import { stripVTControlCharacters } from 'node:util'; import type { ScenarioInstallOptions } from '@sammo-ts/game-engine/scenario/scenarioSeeder.js'; import { @@ -12,7 +13,13 @@ import { } from '@sammo-ts/infra'; import { isRecord } from '@sammo-ts/common'; -import { buildTurboReleaseCommand, type BuildCommand, type BuildRunner } from './buildRunner.js'; +import { + buildTurboReleaseCommand, + type BuildCommand, + type BuildProgressEvent, + type BuildProgressObserver, + type BuildRunner, +} from './buildRunner.js'; import { sanitizeManagedProcessEnv, type ProcessManager } from './processManager.js'; import type { GatewayClaimedProfileUpdate, @@ -76,6 +83,8 @@ export interface GatewayOrchestratorHandle { listRuntimeStates(profileNames: string[]): Promise; } +const SENSITIVE_ENV_NAME = /(SECRET|TOKEN|PASSWORD|PASSWD|PRIVATE_KEY|CLIENT_SECRET|DATABASE_URL|REDIS_URL)/iu; + export const planProfileReconcile = ( status: GatewayProfileStatus, runtime: ProfileRuntimeState @@ -589,6 +598,57 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle { ((profileName) => this.clearTournamentRuntimeStateFromRedis(profileName)); } + private sanitizeOperationLogMessage(message: string): string { + let sanitized = stripVTControlCharacters(message); + const sensitiveValues = new Set([ + this.processConfig.gameTokenSecret, + ...Object.entries(this.processConfig.baseEnv ?? {}) + .filter(([name]) => SENSITIVE_ENV_NAME.test(name)) + .map(([, value]) => value), + ]); + for (const secret of sensitiveValues) { + if (secret && secret.length >= 4) sanitized = sanitized.replaceAll(secret, '[REDACTED]'); + } + return sanitized.replace(/(:\/\/[^:\s/@]+:)[^@\s/]+@/gu, '$1[REDACTED]@').slice(0, 4_000); + } + + private async appendOperationLog( + operationId: string, + phase: string, + message: string, + level: 'INFO' | 'OUTPUT' | 'ERROR' = 'INFO' + ): Promise { + try { + await this.repository.appendOperationLog(operationId, { + level, + phase, + message: this.sanitizeOperationLogMessage(message), + }); + } catch { + // Progress logging must not make an otherwise recoverable profile operation fail. + } + } + + private readonly buildProgress = + (operationId: string, phase: string): BuildProgressObserver => + async (event: BuildProgressEvent) => { + if (event.type === 'OUTPUT') { + if (event.message) await this.appendOperationLog(operationId, phase, event.message, 'OUTPUT'); + return; + } + const command = [event.command.command, ...event.command.args].join(' '); + if (event.type === 'COMMAND_START') { + await this.appendOperationLog(operationId, phase, `$ ${command}`); + return; + } + await this.appendOperationLog( + operationId, + phase, + `${command} 종료 (exit ${event.exitCode ?? 'unknown'})`, + event.exitCode === 0 ? 'INFO' : 'ERROR' + ); + }; + start(): void { this.stopping = false; this.trackTask(this.reconcileNow()); @@ -837,8 +897,14 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle { private async handleOperation(operation: GatewayOperationRecord): Promise { const assertLease = () => this.assertOperationLease(operation.id); + await this.appendOperationLog( + operation.id, + 'claim', + `${operation.type} 작업을 시작합니다. 시도 ${operation.attempts ?? 1}회차.` + ); const profile = await this.repository.getProfile(operation.profileName); if (!profile) { + await this.appendOperationLog(operation.id, 'failed', 'Profile not found.', 'ERROR'); await this.repository.completeOperation( operation.id, 'FAILED', @@ -871,6 +937,7 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle { let resolvedCommitSha: string | undefined; try { if (operation.type === 'START') { + await this.appendOperationLog(operation.id, 'runtime', '프로필 process를 시작합니다.'); const updated = await updateOperationProfile( { status: 'RUNNING', @@ -892,10 +959,12 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle { ); throw new Error('Failed to start profile processes.'); } + await this.appendOperationLog(operation.id, 'runtime', '프로필 process 시작을 완료했습니다.'); await updateOperationProfile({ lastError: null }, async () => { await this.repository.updateLastError(profile.profileName, null); return this.repository.getProfile(profile.profileName); }); + await this.appendOperationLog(operation.id, 'complete', 'START 작업이 완료되었습니다.'); await this.repository.completeOperation( operation.id, 'SUCCEEDED', @@ -905,10 +974,12 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle { return; } if (operation.type === 'STOP') { + await this.appendOperationLog(operation.id, 'runtime', '프로필 process를 정지합니다.'); await updateOperationProfile({ status: 'STOPPED' }, () => this.repository.updateStatus(profile.profileName, 'STOPPED') ); await this.stopProfile(profile, assertLease); + await this.appendOperationLog(operation.id, 'complete', 'STOP 작업이 완료되었습니다.'); await this.repository.completeOperation( operation.id, 'SUCCEEDED', @@ -921,6 +992,11 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle { if (!operation.sourceMode || !operation.sourceRef) { throw new Error('Reset source mode and ref are required.'); } + await this.appendOperationLog( + operation.id, + 'resolve', + `${operation.sourceMode} ${operation.sourceRef} 커밋을 해석합니다.` + ); const commitSha = operation.resolvedCommitSha ?? (await this.workspaceManager.resolveCommit(operation.sourceMode, operation.sourceRef)); @@ -935,12 +1011,14 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle { throw new OperationLeaseLostError(`Operation lease lost while pinning commit: ${operation.id}`); } } + await this.appendOperationLog(operation.id, 'resolve', `대상 커밋을 ${commitSha}로 고정했습니다.`); await assertLease(); if (operation.type === 'DEPLOY') { const result = await this.handleProfileDeploy(profile, commitSha, assertLease, operation.id); if (!result.ok) { throw new Error(result.detail); } + await this.appendOperationLog(operation.id, 'complete', 'DB 보존 버전 업데이트가 완료되었습니다.'); await this.repository.completeOperation( operation.id, 'SUCCEEDED', @@ -964,12 +1042,18 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle { const result = await this.handleResetAction(profile, resetAction, commitSha, assertLease, operation.id); if (result.status === 'REQUESTED') { const retryAt = new Date(this.now().getTime() + this.adminActionIntervalMs).toISOString(); + await this.appendOperationLog( + operation.id, + 'wait', + `${result.detail ?? '작업을 다시 시도합니다.'} 다음 시도: ${retryAt}` + ); await this.repository.requeueOperation(operation.id, result.detail, retryAt, this.operationLeaseOwner); return; } if (result.status !== 'APPLIED') { throw new Error(result.detail ?? 'Reset failed.'); } + await this.appendOperationLog(operation.id, 'complete', '시나리오 초기화가 완료되었습니다.'); await this.repository.completeOperation( operation.id, 'SUCCEEDED', @@ -987,6 +1071,7 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle { return; } const detail = error instanceof Error ? error.message : String(error); + await this.appendOperationLog(operation.id, 'failed', detail, 'ERROR'); try { await this.repository.completeOperation( operation.id, @@ -1044,7 +1129,9 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle { buildStartedAt: startedAt, buildError: null, }); + await this.appendOperationLog(operationId, 'workspace', `커밋 ${commitSha}의 worktree를 준비합니다.`); const workspace = await this.workspaceManager.prepare(commitSha); + await this.appendOperationLog(operationId, 'workspace', `worktree 준비 완료: ${workspace.root}`); const manifest = await readReleaseManifest(workspace.root); assertReleaseComponents(manifest, ['game-api', 'game-engine', 'game-frontend']); const commands = [ @@ -1056,7 +1143,8 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle { ), ...buildProfileFrontendCommands(workspace.root, profile, this.processConfig.baseEnv), ]; - const result = await this.buildRunner.run(commands); + await this.appendOperationLog(operationId, 'build', `${profile.profileName} 구성 요소를 빌드합니다.`); + const result = await this.buildRunner.run(commands, this.buildProgress(operationId, 'build')); await assertLease(); if (!result.ok) { const detail = result.output.slice(-4000) || 'selected workspace build failed'; @@ -1068,10 +1156,16 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle { return { ok: false, detail }; } + await this.appendOperationLog(operationId, 'switch', '기존 profile process를 정지합니다.'); await this.stopProfile(profile, assertLease); oldRuntimeStopped = true; const profileDatabaseUrl = this.resolveProfileDatabaseUrl(profile); - const migration = await this.runProfileMigration(workspace.root, profileDatabaseUrl); + await this.appendOperationLog(operationId, 'migration', '선택 버전의 game migration을 적용합니다.'); + const migration = await this.runProfileMigration( + workspace.root, + profileDatabaseUrl, + this.buildProgress(operationId, 'migration') + ); await assertLease(); if (!migration.ok) { const detail = migration.output.slice(-4000) || 'profile database migration failed'; @@ -1086,6 +1180,7 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle { }); return { ok: false, detail }; } + await this.appendOperationLog(operationId, 'migration', 'game migration이 완료되었습니다.'); const completedAt = this.now().toISOString(); const candidate: GatewayProfileRecord = { @@ -1098,7 +1193,9 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle { buildError: undefined, }; if (shouldRun) { + await this.appendOperationLog(operationId, 'switch', '새 버전의 profile process를 시작합니다.'); const started = await this.startProfile(candidate, assertLease); + await this.appendOperationLog(operationId, 'readiness', 'profile process readiness를 확인합니다.'); const ready = started && (await this.waitForProfileReadiness(candidate, assertLease)); if (!ready) { if (started) { @@ -1107,6 +1204,14 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle { const rollbackStarted = (await this.startProfile(profile, assertLease)) && (await this.waitForProfileReadiness(profile, assertLease)); + await this.appendOperationLog( + operationId, + 'rollback', + rollbackStarted + ? '새 버전 readiness 실패 후 이전 runtime을 복구했습니다.' + : '새 버전 readiness 실패 후 이전 runtime 복구도 실패했습니다.', + rollbackStarted ? 'INFO' : 'ERROR' + ); oldRuntimeStopped = !rollbackStarted; const detail = rollbackStarted ? 'new profile release failed readiness; previous runtime restored' @@ -1120,6 +1225,7 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle { }); return { ok: false, detail }; } + await this.appendOperationLog(operationId, 'readiness', 'profile readiness 확인을 통과했습니다.'); } await assertLease(); await updateClaimedProfile({ @@ -1131,6 +1237,11 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle { buildError: null, lastError: null, }); + await this.appendOperationLog( + operationId, + 'publish', + `${commitSha}를 active profile 버전으로 게시했습니다.` + ); oldRuntimeStopped = false; return { ok: true }; } catch (error) { @@ -1246,6 +1357,15 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle { assertLease?: () => Promise, operationId?: string ): Promise { + const appendLog = async ( + phase: string, + message: string, + level: 'INFO' | 'OUTPUT' | 'ERROR' = 'INFO' + ): Promise => { + if (operationId) await this.appendOperationLog(operationId, phase, message, level); + }; + const buildProgress = (phase: string): BuildProgressObserver | undefined => + operationId ? this.buildProgress(operationId, phase) : undefined; // 리셋 요청을 빌드+재기동 흐름으로 처리한다. if (this.resetInFlight.has(profile.profileName)) { return { status: 'REQUESTED', detail: 'reset already in progress' }; @@ -1329,7 +1449,7 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle { commitSha, }) ); - const { result, workspace } = await this.runBuildCommands(commitSha, profile); + const { result, workspace } = await this.runBuildCommands(commitSha, profile, operationId); await assertLease?.(); if (!result.ok) { const completedAt = this.now().toISOString(); @@ -1347,11 +1467,17 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle { ); return { status: 'FAILED', detail: 'selected workspace build failed' }; } + await appendLog('seed', '선택 버전의 profile seed CLI를 확인합니다.'); await this.assertProfileSeedCli(workspace.root); // A newly provisioned profile schema has no world_state row (or table) yet. // Apply the selected release's migrations before reading optional prior-season // metadata; existing profiles still expose the same season/tick values afterward. - const migrationResult = await this.runProfileMigration(workspace.root, profileDatabaseUrl); + await appendLog('migration', '선택 버전의 game migration을 적용합니다.'); + const migrationResult = await this.runProfileMigration( + workspace.root, + profileDatabaseUrl, + buildProgress('migration') + ); await assertLease?.(); if (!migrationResult.ok) { const completedAt = this.now().toISOString(); @@ -1369,6 +1495,8 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle { ); return { status: 'FAILED', detail: 'profile database migration failed' }; } + await appendLog('migration', 'game migration이 완료되었습니다.'); + await appendLog('seed', '기존 season과 tick metadata를 확인합니다.'); const seedInfo = await this.resolveResetSeedInfo( profile, { @@ -1384,27 +1512,33 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle { await updateClaimedProfile({ status: 'STOPPED' }, () => this.repository.updateStatus(profile.profileName, 'STOPPED') ); + await appendLog('switch', '기존 profile process를 정지합니다.'); await this.stopProfile(profile, assertLease); await assertLease?.(); const serverId = buildServerId(profile.profileName, seedTime, installOptions?.installOperationId); - const seedResult = await this.runSelectedProfileSeed({ - workspaceRoot: workspace.root, - databaseUrl: seedInfo.databaseUrl, - scenarioId, - tickSeconds: seedInfo.tickSeconds, - now: seedTime, - installOptions: { - ...(installOptions ?? {}), - season, - serverId, - installCommitSha: commitSha, + await appendLog('seed', `시나리오 ${scenarioId}, 시즌 ${season} 초기 데이터를 생성합니다.`); + const seedResult = await this.runSelectedProfileSeed( + { + workspaceRoot: workspace.root, + databaseUrl: seedInfo.databaseUrl, + scenarioId, + tickSeconds: seedInfo.tickSeconds, + now: seedTime, + installOptions: { + ...(installOptions ?? {}), + season, + serverId, + installCommitSha: commitSha, + }, + adminUser, }, - adminUser, - }); + buildProgress('seed') + ); await assertLease?.(); if (!seedResult.ok) { throw new Error(`Selected profile seed failed: ${seedResult.output.slice(-4000)}`); } + await appendLog('seed', '시나리오 초기 데이터 생성을 완료했습니다.'); await this.clearTournamentRuntimeState(profile.profileName); await assertLease?.(); const completedAt = this.now().toISOString(); @@ -1447,7 +1581,9 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle { 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) { @@ -1461,10 +1597,12 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle { ); return { status: 'FAILED', detail }; } + await appendLog('readiness', 'profile readiness 확인을 통과했습니다.'); await updateClaimedProfile({ lastError: null }, async () => { await this.repository.updateLastError(profile.profileName, null); return this.repository.getProfile(profile.profileName); }); + await appendLog('publish', `${commitSha}와 시나리오 ${scenarioId} 초기화 상태를 게시했습니다.`); return { status: 'APPLIED', detail: 'reset completed via rebuild' }; } catch (error) { if (error instanceof OperationLeaseLostError) { @@ -1537,12 +1675,19 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle { private async runBuildCommands( commitSha: string, - profile?: GatewayProfileRecord + profile?: GatewayProfileRecord, + operationId?: string ): Promise<{ result: Awaited>; workspace: Awaited>; }> { + if (operationId) { + await this.appendOperationLog(operationId, 'workspace', `커밋 ${commitSha}의 worktree를 준비합니다.`); + } const workspace = await this.workspaceManager.prepare(commitSha); + if (operationId) { + await this.appendOperationLog(operationId, 'workspace', `worktree 준비 완료: ${workspace.root}`); + } const commands = [ ...buildWorkspaceCommands( workspace.root, @@ -1552,7 +1697,20 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle { ), ...(profile ? buildProfileFrontendCommands(workspace.root, profile, this.processConfig.baseEnv) : []), ]; - return { result: await this.buildRunner.run(commands), workspace }; + if (operationId) { + await this.appendOperationLog( + operationId, + 'build', + `${profile?.profileName ?? 'profile'} 구성 요소를 빌드합니다.` + ); + } + return { + result: await this.buildRunner.run( + commands, + operationId ? this.buildProgress(operationId, 'build') : undefined + ), + workspace, + }; } private async assertProfileSeedCli(workspaceRoot: string): Promise { @@ -1566,22 +1724,27 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle { private async runProfileMigration( workspaceRoot: string, - profileDatabaseUrl: string + profileDatabaseUrl: string, + onProgress?: BuildProgressObserver ): Promise>> { - return this.buildRunner.run([ - buildProfileMigrationCommand(workspaceRoot, profileDatabaseUrl, this.processConfig.baseEnv), - ]); + return this.buildRunner.run( + [buildProfileMigrationCommand(workspaceRoot, profileDatabaseUrl, this.processConfig.baseEnv)], + onProgress + ); } - private async runSelectedProfileSeed(options: { - workspaceRoot: string; - databaseUrl: string; - scenarioId: number; - tickSeconds?: number; - now: Date; - installOptions?: ScenarioInstallOptions; - adminUser?: AdminSeedUser | null; - }): Promise>> { + private async runSelectedProfileSeed( + options: { + workspaceRoot: string; + databaseUrl: string; + scenarioId: number; + tickSeconds?: number; + now: Date; + installOptions?: ScenarioInstallOptions; + adminUser?: AdminSeedUser | null; + }, + onProgress?: BuildProgressObserver + ): Promise>> { const tempDirectory = await fs.mkdtemp(path.join(os.tmpdir(), 'sammo-profile-seed-')); const requestFile = path.join(tempDirectory, 'request.json'); try { @@ -1601,19 +1764,22 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle { }), { encoding: 'utf8', mode: 0o600 } ); - return await this.buildRunner.run([ - { - command: process.execPath, - args: [path.join(options.workspaceRoot, 'app', 'gateway-api', 'dist', 'index.js')], - cwd: options.workspaceRoot, - env: { - ...(this.processConfig.baseEnv ?? {}), - DATABASE_URL: options.databaseUrl, - GATEWAY_ROLE: 'profile-seed', - PROFILE_SEED_REQUEST_FILE: requestFile, + return await this.buildRunner.run( + [ + { + command: process.execPath, + args: [path.join(options.workspaceRoot, 'app', 'gateway-api', 'dist', 'index.js')], + cwd: options.workspaceRoot, + env: { + ...(this.processConfig.baseEnv ?? {}), + DATABASE_URL: options.databaseUrl, + GATEWAY_ROLE: 'profile-seed', + PROFILE_SEED_REQUEST_FILE: requestFile, + }, }, - }, - ]); + ], + onProgress + ); } finally { await fs.rm(tempDirectory, { recursive: true, force: true }); } diff --git a/app/gateway-api/src/orchestrator/profileRepository.ts b/app/gateway-api/src/orchestrator/profileRepository.ts index 45d9819a..b60b2186 100644 --- a/app/gateway-api/src/orchestrator/profileRepository.ts +++ b/app/gateway-api/src/orchestrator/profileRepository.ts @@ -57,6 +57,24 @@ export interface GatewayOperationCreateInput { scheduledAt?: string; } +export const GATEWAY_OPERATION_LOG_LEVELS = ['INFO', 'OUTPUT', 'ERROR'] as const; +export type GatewayOperationLogLevel = (typeof GATEWAY_OPERATION_LOG_LEVELS)[number]; + +export interface GatewayOperationLogRecord { + cursor: string; + operationId: string; + level: GatewayOperationLogLevel; + phase: string; + message: string; + createdAt: string; +} + +export interface GatewayOperationLogInput { + level: GatewayOperationLogLevel; + phase: string; + message: string; +} + export interface GatewayProfileRecord { profileName: string; profile: string; @@ -145,6 +163,8 @@ export interface GatewayProfileRepository { listOperations(options?: { profileName?: string; limit?: number }): Promise; listActiveOperationProfileNames?(now: Date): Promise; getOperation(id: string): Promise; + listOperationLogs(id: string, afterCursor?: string, limit?: number): Promise; + appendOperationLog(id: string, input: GatewayOperationLogInput): Promise; createOperation(input: GatewayOperationCreateInput): Promise; claimNextOperation( now: Date, @@ -290,6 +310,24 @@ const mapOperation = (row: GatewayOperationRow): GatewayOperationRecord => ({ updatedAt: row.updatedAt.toISOString(), }); +const mapOperationLog = (row: { + id: bigint; + operationId: string; + level: string; + phase: string; + message: string; + createdAt: Date; +}): GatewayOperationLogRecord => ({ + cursor: row.id.toString(), + operationId: row.operationId, + level: GATEWAY_OPERATION_LOG_LEVELS.includes(row.level as GatewayOperationLogLevel) + ? (row.level as GatewayOperationLogLevel) + : 'INFO', + phase: row.phase, + message: row.message, + createdAt: row.createdAt.toISOString(), +}); + export const createGatewayProfileRepository = (prisma: GatewayPrismaClient): GatewayProfileRepository => ({ async listProfiles(): Promise { const rows = await prisma.gatewayProfile.findMany({ @@ -511,18 +549,51 @@ export const createGatewayProfileRepository = (prisma: GatewayPrismaClient): Gat const row = await prisma.gatewayOperation.findUnique({ where: { id } }); return row ? mapOperation(row) : null; }, - async createOperation(input: GatewayOperationCreateInput): Promise { - const row = await prisma.gatewayOperation.create({ - data: { - profileName: input.profileName, - type: input.type, - sourceMode: input.sourceMode, - sourceRef: input.sourceRef, - payload: (input.payload ?? {}) as GatewayPrisma.JsonObject, - reason: input.reason, - requestedBy: input.requestedBy, - scheduledAt: input.scheduledAt ? new Date(input.scheduledAt) : null, + async listOperationLogs(id, afterCursor, limit = 200) { + const rows = await prisma.gatewayOperationLog.findMany({ + where: { + operationId: id, + ...(afterCursor ? { id: { gt: BigInt(afterCursor) } } : {}), }, + orderBy: { id: 'asc' }, + take: Math.min(Math.max(limit, 1), 500), + }); + return rows.map(mapOperationLog); + }, + async appendOperationLog(id, input) { + const row = await prisma.gatewayOperationLog.create({ + data: { + operationId: id, + level: input.level, + phase: input.phase.slice(0, 64), + message: input.message.slice(0, 4_000), + }, + }); + return mapOperationLog(row); + }, + async createOperation(input: GatewayOperationCreateInput): Promise { + const row = await prisma.$transaction(async (tx) => { + const operation = await tx.gatewayOperation.create({ + data: { + profileName: input.profileName, + type: input.type, + sourceMode: input.sourceMode, + sourceRef: input.sourceRef, + payload: (input.payload ?? {}) as GatewayPrisma.JsonObject, + reason: input.reason, + requestedBy: input.requestedBy, + scheduledAt: input.scheduledAt ? new Date(input.scheduledAt) : null, + }, + }); + await tx.gatewayOperationLog.create({ + data: { + operationId: operation.id, + level: 'INFO', + phase: 'queue', + message: `${input.type} 작업이 등록되었습니다.`, + }, + }); + return operation; }); return mapOperation(row); }, @@ -697,11 +768,24 @@ export const createGatewayProfileRepository = (prisma: GatewayPrismaClient): Gat return mapOperation(row); }, async cancelOperation(id: string): Promise { - const result = await prisma.gatewayOperation.updateMany({ - where: { id, status: 'QUEUED' }, - data: { status: 'CANCELLED', completedAt: new Date() }, + const count = await prisma.$transaction(async (tx) => { + const result = await tx.gatewayOperation.updateMany({ + where: { id, status: 'QUEUED' }, + data: { status: 'CANCELLED', completedAt: new Date() }, + }); + if (result.count === 1) { + await tx.gatewayOperationLog.create({ + data: { + operationId: id, + level: 'INFO', + phase: 'cancel', + message: '대기 중인 작업이 취소되었습니다.', + }, + }); + } + return result.count; }); - return result.count === 1; + return count === 1; }, async retryOperation(id: string, requestedBy: string): Promise { const row = await prisma.$transaction(async (tx) => { @@ -711,7 +795,7 @@ export const createGatewayProfileRepository = (prisma: GatewayPrismaClient): Gat } const previousPayload = previous.payload as GatewayPrisma.JsonObject; const retrySource = buildRetryOperationSource(previous); - return tx.gatewayOperation.create({ + const operation = await tx.gatewayOperation.create({ data: { profileName: previous.profileName, type: previous.type, @@ -723,6 +807,15 @@ export const createGatewayProfileRepository = (prisma: GatewayPrismaClient): Gat scheduledAt: null, }, }); + await tx.gatewayOperationLog.create({ + data: { + operationId: operation.id, + level: 'INFO', + phase: 'queue', + message: `작업 ${previous.id}의 재시도가 등록되었습니다.`, + }, + }); + return operation; }); return row ? mapOperation(row) : null; }, diff --git a/app/gateway-api/test/adminOperations.test.ts b/app/gateway-api/test/adminOperations.test.ts index 192ad490..4eb21690 100644 --- a/app/gateway-api/test/adminOperations.test.ts +++ b/app/gateway-api/test/adminOperations.test.ts @@ -6,6 +6,7 @@ import { InMemoryGatewaySessionService } from '../src/auth/inMemorySessionServic import { createInMemoryUserRepository } from '../src/auth/inMemoryUserRepository.js'; import type { GatewayOperationCreateInput, + GatewayOperationRecord, GatewayProfileRecord, GatewayProfileRepository, } from '../src/orchestrator/profileRepository.js'; @@ -30,6 +31,8 @@ const buildCaller = async ( initialProfileStatus?: GatewayProfileRecord['status']; profileScenario?: string; profileMeta?: GatewayProfileRecord['meta']; + initialOperation?: GatewayOperationRecord; + profileLogVisibilityAfterPolls?: number; releaseLogVisibilityAfterPolls?: number; } = {} ) => { @@ -49,6 +52,14 @@ const buildCaller = async ( const createdInputs: GatewayOperationCreateInput[] = []; const createdReleaseInputs: GatewayReleaseOperationCreateInput[] = []; const appendedReleaseLogs: Array<{ operationId: string; phase: string; message: string }> = []; + const profileLogs: Array<{ + cursor: string; + operationId: string; + level: 'INFO' | 'OUTPUT' | 'ERROR'; + phase: string; + message: string; + createdAt: string; + }> = []; const releaseLogs = [ { cursor: '1', @@ -60,7 +71,9 @@ const buildCaller = async ( }, ]; let releaseLogPollCount = 0; + let profileLogPollCount = 0; const operationRecords = new Map>>(); + if (options.initialOperation) operationRecords.set(options.initialOperation.id, options.initialOperation); const createdRuntimeActions: Array> = []; const flushes: Array<{ userId: string; reason?: string; iconRevision?: string }> = []; const updatedStatuses: GatewayProfileRecord['status'][] = []; @@ -102,6 +115,28 @@ const buildCaller = async ( clearWorkspaceUsage: async () => {}, listOperations: async () => [], getOperation: async (id) => operationRecords.get(id) ?? null, + listOperationLogs: async (id, afterCursor) => { + profileLogPollCount += 1; + if ( + options.profileLogVisibilityAfterPolls !== undefined && + profileLogPollCount < options.profileLogVisibilityAfterPolls + ) { + return []; + } + return profileLogs.filter( + (entry) => entry.operationId === id && (!afterCursor || BigInt(entry.cursor) > BigInt(afterCursor)) + ); + }, + appendOperationLog: async (operationId, input) => { + const entry = { + cursor: String(profileLogs.length + 1), + operationId, + createdAt: new Date(Date.UTC(2026, 7, 1, 0, 0, profileLogs.length + 1)).toISOString(), + ...input, + }; + profileLogs.push(entry); + return entry; + }, createOperation: async (input) => { createdInputs.push(input); const operation = await createOperation(input); @@ -295,6 +330,7 @@ const buildCaller = async ( createdInputs, createdReleaseInputs, appendedReleaseLogs, + profileLogs, createdRuntimeActions, users, admin, @@ -306,6 +342,7 @@ const buildCaller = async ( getRuntimeStateListCount: () => runtimeStateListCount, getStoredNotice: () => storedNotice, getReleaseLogPollCount: () => releaseLogPollCount, + getProfileLogPollCount: () => profileLogPollCount, setStoredNotice: (notice: string) => { storedNotice = notice; }, @@ -702,6 +739,78 @@ describe('admin operation API', () => { }); }); +describe('profile operation progress API', () => { + it('long-polls durable build logs with the current profile operation state', async () => { + const operationId = '33333333-3333-4333-8333-333333333333'; + const harness = await buildCaller( + async (input) => ({ + id: operationId, + profileName: input.profileName, + type: 'DEPLOY', + status: 'RUNNING', + sourceMode: input.sourceMode, + sourceRef: input.sourceRef, + payload: {}, + requestedBy: input.requestedBy, + createdAt: '2026-08-11T00:00:00.000Z', + updatedAt: '2026-08-11T00:00:00.000Z', + }), + { profileScenario: '1010', profileLogVisibilityAfterPolls: 2 } + ); + await harness.caller.admin.operations.requestDeploy({ + profileName: 'che:2', + sourceMode: 'COMMIT', + sourceRef: 'HEAD', + }); + harness.profileLogs.push({ + cursor: '1', + operationId, + level: 'OUTPUT', + phase: 'build', + message: 'game-frontend build complete', + createdAt: '2026-08-11T00:00:01.000Z', + }); + + await expect( + harness.caller.admin.operations.logs({ id: operationId, timeoutMs: 1_000 }) + ).resolves.toMatchObject({ + nextCursor: '1', + operation: { status: 'RUNNING', profileName: 'che:2' }, + entries: [{ cursor: '1', phase: 'build', message: 'game-frontend build complete' }], + }); + expect(harness.getProfileLogPollCount()).toBe(2); + }); + + it('does not expose operation logs outside the caller profile scope', async () => { + const operationId = '33333333-3333-4333-8333-333333333333'; + const harness = await buildCaller( + async () => { + throw new Error('not used'); + }, + { + adminRoles: ['admin.scenarios.reset:hwe:1'], + firstUserIsAdmin: false, + initialOperation: { + id: operationId, + profileName: 'che:2', + type: 'RESET', + status: 'RUNNING', + sourceMode: 'COMMIT', + sourceRef: '1111111111111111111111111111111111111111', + payload: {}, + requestedBy: 'admin', + createdAt: '2026-08-11T00:00:00.000Z', + updatedAt: '2026-08-11T00:00:00.000Z', + }, + } + ); + + await expect(harness.caller.admin.operations.logs({ id: operationId, timeoutMs: 0 })).rejects.toMatchObject({ + code: 'FORBIDDEN', + }); + }); +}); + describe('gateway release API', () => { it('waits until a new release log becomes visible', async () => { const harness = await buildCaller( diff --git a/app/gateway-api/test/adminSecurityTransport.e2e.test.ts b/app/gateway-api/test/adminSecurityTransport.e2e.test.ts index eff98648..0b23020f 100644 --- a/app/gateway-api/test/adminSecurityTransport.e2e.test.ts +++ b/app/gateway-api/test/adminSecurityTransport.e2e.test.ts @@ -39,6 +39,13 @@ const profiles: GatewayProfileRepository = { clearWorkspaceUsage: async () => {}, listOperations: async () => [], getOperation: async () => null, + listOperationLogs: async () => [], + appendOperationLog: async (operationId, input) => ({ + cursor: '1', + operationId, + createdAt: '2026-08-11T00:00:00.000Z', + ...input, + }), createOperation: async () => { throw new Error('not used'); }, diff --git a/app/gateway-api/test/authFlow.test.ts b/app/gateway-api/test/authFlow.test.ts index dc4780ed..48f217d6 100644 --- a/app/gateway-api/test/authFlow.test.ts +++ b/app/gateway-api/test/authFlow.test.ts @@ -15,6 +15,7 @@ import { appRouter } from '../src/router.js'; import type { GatewayPrismaClient } from '@sammo-ts/infra'; import { decryptGameSessionToken, type UserSanctions } from '@sammo-ts/common/auth/gameToken'; import { createPasswordEnvelopeService } from '../src/auth/passwordEnvelope.js'; +import type { GatewayProfileRepository } from '../src/orchestrator/profileRepository.js'; const buildCaller = ( options: { @@ -111,7 +112,7 @@ const buildCaller = ( updatedAt: new Date().toISOString(), }, ]; - const profiles = { + const profiles: GatewayProfileRepository = { listProfiles: async () => profileRows, getProfile: async (profileName: string) => profileRows.find((profile) => profile.profileName === profileName) ?? null, @@ -129,6 +130,13 @@ const buildCaller = ( clearWorkspaceUsage: async () => {}, listOperations: async () => [], getOperation: async () => null, + listOperationLogs: async () => [], + appendOperationLog: async (operationId, input) => ({ + cursor: '1', + operationId, + createdAt: '2026-08-11T00:00:00.000Z', + ...input, + }), createOperation: async () => { throw new Error('not implemented'); }, diff --git a/app/gateway-api/test/orchestratorOperations.test.ts b/app/gateway-api/test/orchestratorOperations.test.ts index 816c3e66..01306ecb 100644 --- a/app/gateway-api/test/orchestratorOperations.test.ts +++ b/app/gateway-api/test/orchestratorOperations.test.ts @@ -52,6 +52,7 @@ const createHarness = ( const started: ProcessDefinition[] = []; const stopped: string[] = []; const deleted: string[] = []; + const logs: Array<{ phase: string; message: string; level: string }> = []; const repository: GatewayProfileRepository = { listProfiles: async () => [profile], @@ -72,6 +73,16 @@ const createHarness = ( listOperations: async () => [], listActiveOperationProfileNames: async () => [profile.profileName], getOperation: async () => operation, + listOperationLogs: async () => [], + appendOperationLog: async (operationId, input) => { + logs.push(input); + return { + cursor: String(logs.length), + operationId, + createdAt: '2026-08-11T00:00:00.000Z', + ...input, + }; + }, createOperation: async () => operation, claimNextOperation: async () => { const result = nextOperation; @@ -146,7 +157,7 @@ const createHarness = ( adminActionIntervalMs: 60_000, }); - return { orchestrator, statuses, completions, completionFields, started, stopped, deleted }; + return { orchestrator, statuses, completions, completionFields, started, stopped, deleted, logs }; }; describe('GatewayOrchestrator first-class operations', () => { diff --git a/app/gateway-api/test/profileDeployOperation.test.ts b/app/gateway-api/test/profileDeployOperation.test.ts index f2f86b64..5155b701 100644 --- a/app/gateway-api/test/profileDeployOperation.test.ts +++ b/app/gateway-api/test/profileDeployOperation.test.ts @@ -75,6 +75,7 @@ describe('profile DEPLOY operation', () => { let nextOperation: GatewayOperationRecord | null = operation; const patches: GatewayClaimedProfileUpdate[] = []; const completions: string[] = []; + const logs: Array<{ phase: string; message: string; level: string }> = []; const repository: GatewayProfileRepository = { listProfiles: async () => [profile], getProfile: async () => profile, @@ -90,6 +91,16 @@ describe('profile DEPLOY operation', () => { clearWorkspaceUsage: async () => {}, listOperations: async () => [], getOperation: async () => operation, + listOperationLogs: async () => [], + appendOperationLog: async (operationId, input) => { + logs.push(input); + return { + cursor: String(logs.length), + operationId, + createdAt: '2026-08-11T00:00:00.000Z', + ...input, + }; + }, createOperation: async () => operation, claimNextOperation: async () => { const value = nextOperation; @@ -138,8 +149,17 @@ describe('profile DEPLOY operation', () => { repository, processManager, buildRunner: { - run: async (commands) => { + run: async (commands, onProgress) => { commandGroups.push(commands); + for (const command of commands) { + await onProgress?.({ type: 'COMMAND_START', command }); + await onProgress?.({ + type: 'OUTPUT', + stream: 'stdout', + message: 'built profile with postgresql://user:pass@integration.invalid/sammo', + }); + await onProgress?.({ type: 'COMMAND_END', command, exitCode: 0 }); + } return { ok: true, exitCode: 0, output: '' }; }, }, @@ -149,7 +169,7 @@ describe('profile DEPLOY operation', () => { redisKeyPrefix: 'sammo:test', gameTokenSecret: 'test-secret', gatewayInternalApiUrl: 'http://127.0.0.1:15001', - baseEnv: { DATABASE_URL: 'postgresql://integration.invalid/sammo' }, + baseEnv: { DATABASE_URL: 'postgresql://user:pass@integration.invalid/sammo' }, }, reconcileIntervalMs: 60_000, scheduleIntervalMs: 60_000, @@ -173,6 +193,17 @@ describe('profile DEPLOY operation', () => { buildWorkspace: workspace, }); expect(completions).toEqual(['SUCCEEDED']); + expect(logs).toEqual( + expect.arrayContaining([ + expect.objectContaining({ phase: 'resolve', message: `대상 커밋을 ${SHA}로 고정했습니다.` }), + expect.objectContaining({ phase: 'build', level: 'OUTPUT' }), + expect.objectContaining({ phase: 'migration', level: 'OUTPUT' }), + expect.objectContaining({ phase: 'readiness', message: 'profile readiness 확인을 통과했습니다.' }), + expect.objectContaining({ phase: 'complete', message: 'DB 보존 버전 업데이트가 완료되었습니다.' }), + ]) + ); + expect(logs.map((entry) => entry.message).join('\n')).not.toContain('pass@integration.invalid'); + expect(logs.map((entry) => entry.message).join('\n')).toContain('[REDACTED]'); expect([...running].sort()).toEqual([...processNames].sort()); }); }); diff --git a/app/gateway-api/test/profileOperationLease.integration.test.ts b/app/gateway-api/test/profileOperationLease.integration.test.ts index cc922f6e..35bb32de 100644 --- a/app/gateway-api/test/profileOperationLease.integration.test.ts +++ b/app/gateway-api/test/profileOperationLease.integration.test.ts @@ -69,6 +69,28 @@ describeDatabase('gateway operation lease and profile serialization', () => { await expect(repository.listOperations({ profileName })).resolves.toHaveLength(1); }); + it('stores durable cursor logs for profile operations', async () => { + const operation = await repository.createOperation({ + profileName, + type: 'DEPLOY', + sourceMode: 'BRANCH', + sourceRef: 'main', + requestedBy: 'admin-a', + }); + + const queued = await repository.listOperationLogs(operation.id); + expect(queued).toHaveLength(1); + expect(queued[0]).toMatchObject({ phase: 'queue', level: 'INFO' }); + + const build = await repository.appendOperationLog(operation.id, { + level: 'OUTPUT', + phase: 'build', + message: 'game-frontend build complete', + }); + await expect(repository.listOperationLogs(operation.id, queued[0]?.cursor)).resolves.toEqual([build]); + await expect(repository.listOperationLogs(operation.id, build.cursor)).resolves.toEqual([]); + }); + it('serializes running operations globally across profiles', async () => { const first = await repository.createOperation({ profileName, diff --git a/app/gateway-api/test/releaseManifest.test.ts b/app/gateway-api/test/releaseManifest.test.ts index 3d572495..7b9fcd61 100644 --- a/app/gateway-api/test/releaseManifest.test.ts +++ b/app/gateway-api/test/releaseManifest.test.ts @@ -38,7 +38,7 @@ describe('readReleaseManifest', () => { await expect(readReleaseManifest(workspaceRoot)).resolves.toMatchObject({ controllerProtocol: RELEASE_CONTROLLER_PROTOCOL, - gatewaySchemaHead: '20260809000000_add_gateway_release_logs', + gatewaySchemaHead: '20260811000000_add_gateway_operation_logs', gameSchemaHead: '20260803000000_add_logical_game_clock', }); }); diff --git a/app/gateway-frontend/e2e/server-operations.spec.ts b/app/gateway-frontend/e2e/server-operations.spec.ts index a57b9d1a..df673b9c 100644 --- a/app/gateway-frontend/e2e/server-operations.spec.ts +++ b/app/gateway-frontend/e2e/server-operations.spec.ts @@ -33,6 +33,9 @@ type FixtureState = { }>; runtimeRunning: boolean; requestBodies: Array<{ operation: string; body: unknown }>; + profileLogPollCount?: number; + profileLogProgress?: boolean; + profileLogsEmpty?: boolean; gatewayLogPollCount?: number; gatewayLogsEmpty?: boolean; capabilities?: Array<{ permission: string; scope: 'GLOBAL' | 'PROFILE'; scopes: string[] }>; @@ -136,6 +139,9 @@ const installFixture = async (page: Page, state: FixtureState) => { await route.abort('failed'); return; } + if (names.includes('admin.operations.logs') && !state.profileLogProgress) { + await new Promise((resolve) => setTimeout(resolve, 50)); + } const results = names.map((name) => { if (route.request().method() === 'POST') { state.requestBodies.push({ operation: name, body }); @@ -167,6 +173,54 @@ const installFixture = async (page: Page, state: FixtureState) => { if (name === 'admin.operations.list') { return response(state.operations); } + if (name === 'admin.operations.logs') { + const operation = state.operations[0]; + if (!operation) throw new Error('Profile operation fixture is missing'); + state.profileLogPollCount = (state.profileLogPollCount ?? 0) + 1; + if (['SUCCEEDED', 'FAILED', 'CANCELLED'].includes(operation.status)) { + return response({ operation, entries: [] }); + } + if (!state.profileLogProgress) { + return response({ operation, entries: [] }); + } + if (state.profileLogsEmpty) { + return response({ operation, entries: [] }); + } + const completed = state.profileLogPollCount > 1; + const nextOperation = { + ...operation, + status: completed ? ('SUCCEEDED' as const) : ('RUNNING' as const), + }; + if (completed) state.operations[0] = nextOperation; + return response({ + operation: nextOperation, + entries: completed + ? [ + { + cursor: '2', + operationId: operation.id, + level: 'OUTPUT', + phase: operation.type === 'RESET' ? 'seed' : 'build', + message: + operation.type === 'RESET' + ? '시나리오 초기 데이터 생성을 완료했습니다.' + : 'game-frontend build complete', + createdAt: '2026-08-01T01:00:02.000Z', + }, + ] + : [ + { + cursor: '1', + operationId: operation.id, + level: 'INFO', + phase: 'build', + message: `${operation.profileName} 구성 요소를 빌드합니다.`, + createdAt: '2026-08-01T01:00:01.000Z', + }, + ], + nextCursor: completed ? '2' : '1', + }); + } if (name === 'admin.releases.gatewayState') { return response({ id: 'gateway', @@ -336,7 +390,13 @@ const installFixture = async (page: Page, state: FixtureState) => { test('separates branch and commit semantics and submits a reset from the dedicated page', async ({ page, }, testInfo) => { - const state: FixtureState = { operations: [], gatewayOperations: [], runtimeRunning: false, requestBodies: [] }; + const state: FixtureState = { + operations: [], + gatewayOperations: [], + runtimeRunning: false, + requestBodies: [], + profileLogProgress: true, + }; await installFixture(page, state); page.on('dialog', (dialog) => dialog.accept()); @@ -415,10 +475,15 @@ test('separates branch and commit semantics and submits a reset from the dedicat await expect(page.getByText('초기화 작업을 등록했습니다.').first()).toBeVisible(); await expect(page.getByTestId('operations-table')).toContainText('RESET'); + await expect(page.getByTestId('profile-operation-log-panel')).toBeVisible(); + await expect(page.getByTestId('profile-operation-log')).toContainText('che:2 구성 요소를 빌드합니다.'); + await expect(page.getByTestId('profile-operation-log')).toContainText('시나리오 초기 데이터 생성을 완료했습니다.'); + await expect(page.getByTestId('profile-operation-log-status')).toContainText('SUCCEEDED'); const resetRequest = state.requestBodies.find((entry) => entry.operation === 'admin.operations.requestReset'); expect(JSON.stringify(resetRequest?.body)).toContain('"sourceMode":"COMMIT"'); expect(JSON.stringify(resetRequest?.body)).toContain('0123456789abcdef0123456789abcdef01234567'); expect(JSON.stringify(resetRequest?.body)).toContain('"scenarioId":5'); + await page.screenshot({ path: testInfo.outputPath('reset-operation-log-desktop.png'), fullPage: true }); await page.setViewportSize({ width: 390, height: 844 }); const mobileGeometry = await page @@ -449,7 +514,13 @@ test('separates branch and commit semantics and submits a reset from the dedicat }); test('separates DB-preserving profile deployment from DB reset', async ({ page }) => { - const state: FixtureState = { operations: [], gatewayOperations: [], runtimeRunning: true, requestBodies: [] }; + const state: FixtureState = { + operations: [], + gatewayOperations: [], + runtimeRunning: true, + requestBodies: [], + profileLogProgress: true, + }; await installFixture(page, state); page.on('dialog', (dialog) => dialog.accept()); @@ -464,6 +535,10 @@ test('separates DB-preserving profile deployment from DB reset', async ({ page } await expect(page.getByText('DB 보존 배포 작업을 등록했습니다.').first()).toBeVisible(); await expect(page.getByTestId('operations-table')).toContainText('DEPLOY'); + await expect(page.getByTestId('profile-operation-log-panel')).toBeVisible(); + await expect(page.getByTestId('profile-operation-log')).toContainText('che:2 구성 요소를 빌드합니다.'); + await expect(page.getByTestId('profile-operation-log')).toContainText('game-frontend build complete'); + await expect(page.getByTestId('profile-operation-log-status')).toContainText('SUCCEEDED'); expect(state.requestBodies.some((entry) => entry.operation === 'admin.operations.requestDeploy')).toBe(true); expect(state.requestBodies.some((entry) => entry.operation === 'admin.operations.requestReset')).toBe(false); }); @@ -766,16 +841,16 @@ test('renders a failed reset, retries it as a new operation, and reaches success page.on('dialog', (dialog) => dialog.accept()); await page.goto('admin/servers/che%3A2/scenario'); - await expect(page.getByText('FAILED', { exact: true })).toBeVisible(); + await expect(page.getByTestId('operations-table').getByText('FAILED', { exact: true })).toBeVisible(); await expect(page.getByRole('cell', { name: 'fedcba987654', exact: true })).toBeVisible(); - const failure = page.getByText(longError); + const failure = page.getByTestId('operations-table').getByText(longError); await expect(failure).toBeVisible(); expect(await failure.evaluate((element) => getComputedStyle(element).color)).toBe('oklch(0.704 0.191 22.216)'); await page.getByRole('button', { name: '재시도' }).click(); await expect(page.getByText('재시도 작업을 등록했습니다.').first()).toBeVisible(); - await expect(page.getByText('FAILED', { exact: true })).toBeVisible(); - await expect(page.getByText('QUEUED', { exact: true })).toBeVisible(); + await expect(page.getByTestId('operations-table').getByText('FAILED', { exact: true })).toBeVisible(); + await expect(page.getByTestId('operations-table').getByText('QUEUED', { exact: true })).toBeVisible(); await expect(page.getByTestId('operations-table').locator('tbody tr')).toHaveCount(2); state.operations[0] = { @@ -787,7 +862,7 @@ test('renders a failed reset, retries it as a new operation, and reaches success }; state.runtimeRunning = true; await page.getByTestId('refresh-operations').click(); - await expect(page.getByText('SUCCEEDED', { exact: true })).toBeVisible(); + await expect(page.getByTestId('operations-table').getByText('SUCCEEDED', { exact: true })).toBeVisible(); await expect(page.getByText('운영 프로필', { exact: true })).toHaveCount(0); await page.screenshot({ path: testInfo.outputPath('failed-retry-succeeded-desktop.png'), fullPage: true }); diff --git a/app/gateway-frontend/src/views/ServerOperationsView.vue b/app/gateway-frontend/src/views/ServerOperationsView.vue index a9397260..bf994e74 100644 --- a/app/gateway-frontend/src/views/ServerOperationsView.vue +++ b/app/gateway-frontend/src/views/ServerOperationsView.vue @@ -79,6 +79,12 @@ type GatewayReleaseLog = { }; const scenarios = ref([]); const operations = ref([]); +const selectedProfileOperationId = ref(''); +const profileOperationLogs = ref([]); +const profileOperationLogCursor = ref(); +const profileOperationLogStatus = ref(''); +const profileOperationLogConnection = ref<'idle' | 'connected' | 'reconnecting'>('idle'); +const profileOperationLogViewport = ref(); const gatewayReleaseState = ref(null); const gatewayReleaseOperations = ref([]); const selectedGatewayOperationId = ref(''); @@ -104,6 +110,7 @@ const resetDefaultsSource = ref<'SYSTEM' | 'PROFILE'>('SYSTEM'); let pollTimer: ReturnType | undefined; let stateRequestInFlight = false; let releaseLogLoopGeneration = 0; +let profileLogLoopGeneration = 0; let componentMounted = false; const form = reactive({ @@ -140,6 +147,20 @@ const gatewayForm = reactive({ const selectedGatewayOperation = computed( () => gatewayReleaseOperations.value.find((operation) => operation.id === selectedGatewayOperationId.value) ?? null ); +const selectedProfileOperation = computed( + () => operations.value.find((operation) => operation.id === selectedProfileOperationId.value) ?? null +); +const profileOperationLogEmptyMessage = computed(() => { + const operation = selectedProfileOperation.value; + const status = profileOperationLogStatus.value || operation?.status; + if (!operation || !status || ['QUEUED', 'RUNNING'].includes(status)) { + return '오케스트레이터 로그를 기다리고 있습니다…'; + } + if (operation.error) { + return `이 작업에는 진행 로그가 기록되지 않았습니다. 작업 오류: ${operation.error}`; + } + return '이 작업에는 진행 로그가 기록되지 않았습니다. 로그 기능 적용 전 작업일 수 있습니다.'; +}); const gatewayReleaseLogEmptyMessage = computed(() => { const operation = selectedGatewayOperation.value; const status = gatewayReleaseLogStatus.value || operation?.status; @@ -289,6 +310,15 @@ const loadState = async (quiet = false) => { limit: 100, }); operations.value = operationResult as Operation[]; + const active = operations.value.find((operation) => ['QUEUED', 'RUNNING'].includes(operation.status)); + if (active && selectedProfileOperationId.value !== active.id) { + selectedProfileOperationId.value = active.id; + } else if ( + !selectedProfileOperationId.value || + !operations.value.some((operation) => operation.id === selectedProfileOperationId.value) + ) { + selectedProfileOperationId.value = operations.value[0]?.id ?? ''; + } } } catch (error) { errorMessage.value = error instanceof Error ? error.message : '운영 상태를 불러오지 못했습니다.'; @@ -298,6 +328,61 @@ const loadState = async (quiet = false) => { } }; +const scrollProfileOperationLogToEnd = async () => { + await nextTick(); + const viewport = profileOperationLogViewport.value; + if (viewport) viewport.scrollTop = viewport.scrollHeight; +}; + +const pollProfileOperationLogs = async (operationId: string, generation: number) => { + while ( + componentMounted && + generation === profileLogLoopGeneration && + selectedProfileOperationId.value === operationId + ) { + try { + const result = await adminClient.operations.logs.query({ + id: operationId, + afterCursor: profileOperationLogCursor.value, + limit: 200, + timeoutMs: 20_000, + }); + if (generation !== profileLogLoopGeneration || selectedProfileOperationId.value !== operationId) return; + profileOperationLogConnection.value = 'connected'; + const entries = result.entries as GatewayReleaseLog[]; + if (entries.length) { + const known = new Set(profileOperationLogs.value.map((entry) => entry.cursor)); + profileOperationLogs.value.push(...entries.filter((entry) => !known.has(entry.cursor))); + profileOperationLogs.value = profileOperationLogs.value.slice(-1_000); + profileOperationLogCursor.value = result.nextCursor; + await scrollProfileOperationLogToEnd(); + } + const operation = result.operation as Operation; + profileOperationLogStatus.value = operation.status; + const index = operations.value.findIndex((entry) => entry.id === operation.id); + if (index >= 0) operations.value[index] = operation; + if (['SUCCEEDED', 'FAILED', 'CANCELLED'].includes(operation.status)) return; + } catch { + if (generation !== profileLogLoopGeneration || !componentMounted) return; + profileOperationLogConnection.value = 'reconnecting'; + await new Promise((resolve) => setTimeout(resolve, 1_000)); + } + } +}; + +const selectProfileOperation = (operationId: string) => { + if (selectedProfileOperationId.value === operationId) { + profileLogLoopGeneration += 1; + profileOperationLogs.value = []; + profileOperationLogCursor.value = undefined; + profileOperationLogStatus.value = ''; + profileOperationLogConnection.value = 'idle'; + void pollProfileOperationLogs(operationId, profileLogLoopGeneration); + return; + } + selectedProfileOperationId.value = operationId; +}; + const scrollReleaseLogToEnd = async () => { await nextTick(); const viewport = gatewayReleaseLogViewport.value; @@ -372,12 +457,13 @@ const requestDeploy = async () => { } submitting.value = true; try { - await adminClient.operations.requestDeploy.mutate({ + const operation = await adminClient.operations.requestDeploy.mutate({ profileName: selectedProfileName.value, sourceMode: form.sourceMode, sourceRef: form.sourceRef.trim(), reason: form.reason.trim() || undefined, }); + selectedProfileOperationId.value = operation.id; message.value = 'DB 보존 배포 작업을 등록했습니다.'; await loadState(true); } catch (error) { @@ -491,7 +577,7 @@ const requestReset = async () => { } submitting.value = true; try { - await adminClient.operations.requestReset.mutate({ + const operation = await adminClient.operations.requestReset.mutate({ profileName: selectedProfileName.value, sourceMode: form.sourceMode, sourceRef: form.sourceMode === 'CURRENT' ? undefined : form.sourceRef.trim(), @@ -518,6 +604,7 @@ const requestReset = async () => { preopenAt: toIso(form.preopenAt), }, }); + selectedProfileOperationId.value = operation.id; message.value = form.scheduledAt ? '예약 초기화 작업을 등록했습니다.' : '초기화 작업을 등록했습니다.'; await loadState(true); } catch (error) { @@ -534,6 +621,7 @@ const cancelOperation = async (operation: Operation) => { } try { await adminClient.operations.cancel.mutate({ id: operation.id }); + selectedProfileOperationId.value = operation.id; message.value = '작업을 취소했습니다.'; await loadState(true); } catch (error) { @@ -547,7 +635,8 @@ const retryOperation = async (operation: Operation) => { return; } try { - await adminClient.operations.retry.mutate({ id: operation.id }); + const retried = await adminClient.operations.retry.mutate({ id: operation.id }); + selectedProfileOperationId.value = retried.id; message.value = '재시도 작업을 등록했습니다.'; await loadState(true); } catch (error) { @@ -555,6 +644,15 @@ const retryOperation = async (operation: Operation) => { } }; +watch(selectedProfileOperationId, (operationId) => { + profileLogLoopGeneration += 1; + profileOperationLogs.value = []; + profileOperationLogCursor.value = undefined; + profileOperationLogStatus.value = ''; + profileOperationLogConnection.value = operationId ? 'connected' : 'idle'; + if (operationId && componentMounted) void pollProfileOperationLogs(operationId, profileLogLoopGeneration); +}); + watch(selectedGatewayOperationId, (operationId) => { releaseLogLoopGeneration += 1; gatewayReleaseLogs.value = []; @@ -589,6 +687,7 @@ onMounted(async () => { onBeforeUnmount(() => { componentMounted = false; + profileLogLoopGeneration += 1; releaseLogLoopGeneration += 1; if (pollTimer) { clearInterval(pollTimer); @@ -1073,6 +1172,64 @@ onBeforeUnmount(() => { +
+
+
+

빌드·작업 로그

+

+ {{ selectedProfileOperationId }} +

+
+
+ + + {{ profileOperationLogStatus || selectedProfileOperation?.status || '연결 중' }} + + +
+
+
+
+ {{ profileOperationLogEmptyMessage }} +
+
+ {{ formatLogTime(entry.createdAt) }} + [{{ entry.phase }}] + {{ entry.message }} +
+
+
+

작업 이력

@@ -1091,7 +1248,7 @@ onBeforeUnmount(() => { 해석 커밋 요청자/사유 완료/오류 - + 동작 @@ -1129,20 +1286,36 @@ onBeforeUnmount(() => {
- - +
+ + + +
diff --git a/packages/infra/prisma/gateway-migrations/20260811000000_add_gateway_operation_logs/migration.sql b/packages/infra/prisma/gateway-migrations/20260811000000_add_gateway_operation_logs/migration.sql new file mode 100644 index 00000000..229cec64 --- /dev/null +++ b/packages/infra/prisma/gateway-migrations/20260811000000_add_gateway_operation_logs/migration.sql @@ -0,0 +1,18 @@ +CREATE TABLE "gateway_operation_log" ( + "id" BIGSERIAL NOT NULL, + "operation_id" TEXT NOT NULL, + "level" TEXT NOT NULL, + "phase" TEXT NOT NULL, + "message" TEXT NOT NULL, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "gateway_operation_log_pkey" PRIMARY KEY ("id") +); + +CREATE INDEX "gateway_operation_log_operation_id_id_idx" +ON "gateway_operation_log"("operation_id", "id"); + +ALTER TABLE "gateway_operation_log" +ADD CONSTRAINT "gateway_operation_log_operation_id_fkey" +FOREIGN KEY ("operation_id") REFERENCES "gateway_operation"("id") +ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/packages/infra/prisma/gateway.prisma b/packages/infra/prisma/gateway.prisma index f211ca5e..26250626 100644 --- a/packages/infra/prisma/gateway.prisma +++ b/packages/infra/prisma/gateway.prisma @@ -280,6 +280,7 @@ model GatewayOperation { attempts Int @default(0) createdAt DateTime @default(now()) @map("created_at") updatedAt DateTime @updatedAt @map("updated_at") + logs GatewayOperationLog[] @@index([status, scheduledAt, createdAt]) @@index([status, leaseUntil, createdAt]) @@ -287,6 +288,19 @@ model GatewayOperation { @@map("gateway_operation") } +model GatewayOperationLog { + id BigInt @id @default(autoincrement()) + operationId String @map("operation_id") + level String + phase String + message String @db.Text + createdAt DateTime @default(now()) @map("created_at") + operation GatewayOperation @relation(fields: [operationId], references: [id], onDelete: Cascade) + + @@index([operationId, id]) + @@map("gateway_operation_log") +} + model GatewayReleaseState { id String @id @default("gateway") activeCommitSha String? @map("active_commit_sha") diff --git a/release-manifest.json b/release-manifest.json index 219e5ec9..6c264f64 100644 --- a/release-manifest.json +++ b/release-manifest.json @@ -1,7 +1,7 @@ { "formatVersion": 1, "controllerProtocol": 2, - "gatewaySchemaHead": "20260809000000_add_gateway_release_logs", + "gatewaySchemaHead": "20260811000000_add_gateway_operation_logs", "gameSchemaHead": "20260803000000_add_logical_game_clock", "components": ["gateway-api", "gateway-frontend", "release-controller", "game-api", "game-engine", "game-frontend"] }