feat(gateway): persist profile operation build logs

This commit is contained in:
2026-08-11 15:11:27 +00:00
parent d1c35cc380
commit 3620f79c6a
15 changed files with 857 additions and 92 deletions
+38
View File
@@ -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<void>((resolve) => setTimeout(resolve, 250));
}
}),
requestReset: adminProcedure
.input(
z.object({
@@ -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<ProfileRuntimeSnapshot[]>;
}
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<void> {
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<void> {
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<void>,
operationId?: string
): Promise<GatewayAdminActionResult> {
const appendLog = async (
phase: string,
message: string,
level: 'INFO' | 'OUTPUT' | 'ERROR' = 'INFO'
): Promise<void> => {
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<ReturnType<BuildRunner['run']>>;
workspace: Awaited<ReturnType<GitWorkspaceManager['prepare']>>;
}> {
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<void> {
@@ -1566,22 +1724,27 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle {
private async runProfileMigration(
workspaceRoot: string,
profileDatabaseUrl: string
profileDatabaseUrl: string,
onProgress?: BuildProgressObserver
): Promise<Awaited<ReturnType<BuildRunner['run']>>> {
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<Awaited<ReturnType<BuildRunner['run']>>> {
private async runSelectedProfileSeed(
options: {
workspaceRoot: string;
databaseUrl: string;
scenarioId: number;
tickSeconds?: number;
now: Date;
installOptions?: ScenarioInstallOptions;
adminUser?: AdminSeedUser | null;
},
onProgress?: BuildProgressObserver
): Promise<Awaited<ReturnType<BuildRunner['run']>>> {
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 });
}
@@ -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<GatewayOperationRecord[]>;
listActiveOperationProfileNames?(now: Date): Promise<string[]>;
getOperation(id: string): Promise<GatewayOperationRecord | null>;
listOperationLogs(id: string, afterCursor?: string, limit?: number): Promise<GatewayOperationLogRecord[]>;
appendOperationLog(id: string, input: GatewayOperationLogInput): Promise<GatewayOperationLogRecord>;
createOperation(input: GatewayOperationCreateInput): Promise<GatewayOperationRecord>;
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<GatewayProfileRecord[]> {
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<GatewayOperationRecord> {
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<GatewayOperationRecord> {
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<boolean> {
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<GatewayOperationRecord | null> {
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;
},
@@ -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<string, Awaited<ReturnType<GatewayProfileRepository['createOperation']>>>();
if (options.initialOperation) operationRecords.set(options.initialOperation.id, options.initialOperation);
const createdRuntimeActions: Array<Record<string, unknown>> = [];
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(
@@ -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');
},
+9 -1
View File
@@ -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');
},
@@ -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', () => {
@@ -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());
});
});
@@ -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,
+1 -1
View File
@@ -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',
});
});