diff --git a/app/gateway-api/src/adminRouter.ts b/app/gateway-api/src/adminRouter.ts index 2e80deb..471a922 100644 --- a/app/gateway-api/src/adminRouter.ts +++ b/app/gateway-api/src/adminRouter.ts @@ -4,10 +4,18 @@ import path from 'node:path'; import { TRPCError } from '@trpc/server'; import { z } from 'zod'; -import { createGamePostgresConnector, resolvePostgresConfigFromEnv } from '@sammo-ts/infra'; +import { + createGamePostgresConnector, + resolvePostgresConfigFromEnv, + type GatewayPrisma, +} from '@sammo-ts/infra'; import { procedure, router } from './trpc.js'; -import { listScenarioPreviews, resolveGitCommitSha } from './scenario/scenarioCatalog.js'; +import { + listScenarioPreviews, + resolveGitBranchCommitSha, + resolveGitCommitSha, +} from './scenario/scenarioCatalog.js'; import type { UserSanctions, UserServerRestriction } from './auth/userRepository.js'; import { toPublicUser } from './auth/userRepository.js'; import type { AdminAuthContext } from './adminAuth.js'; @@ -241,6 +249,8 @@ const zInstallAutorun = z.object({ }); const isAllowedTurnTerm = (value: number): boolean => TURN_TERM_MINUTES.some((term) => term === value); +const isUniqueConstraintError = (error: unknown): boolean => + Boolean(error && typeof error === 'object' && 'code' in error && error.code === 'P2002'); const zInstallOptions = z.object({ scenarioId: z.number().int().min(0), @@ -260,6 +270,8 @@ const zInstallOptions = z.object({ preopenAt: z.string().datetime().optional(), gitRef: z.string().min(1).max(128).optional(), }); +const zOperationInstallOptions = zInstallOptions.omit({ gitRef: true }); +const zSourceMode = z.enum(['BRANCH', 'COMMIT']); type SanctionsPatch = z.infer; @@ -578,6 +590,237 @@ export const adminRouter = router({ return { ok: true }; }), }), + operations: router({ + list: adminProcedure + .input( + z + .object({ + profileName: z.string().min(1).optional(), + limit: z.number().int().min(1).max(200).optional(), + }) + .optional() + ) + .query(async ({ ctx, input }) => { + const adminAuth = requireAdminAuth(ctx); + if (input?.profileName) { + assertPermission(adminAuth, ROLE_ADMIN_PROFILES, input.profileName); + return ctx.profiles.listOperations({ + profileName: input.profileName, + limit: input.limit, + }); + } + if (hasScopedPermission(adminAuth, ROLE_ADMIN_PROFILES)) { + return ctx.profiles.listOperations({ limit: input?.limit }); + } + const profiles = await ctx.profiles.listProfiles(); + const allowed = profiles.filter((profile) => + hasScopedPermission(adminAuth, ROLE_ADMIN_PROFILES, profile.profileName) + ); + const operations = ( + await Promise.all( + allowed.map((profile) => + ctx.profiles.listOperations({ + profileName: profile.profileName, + limit: input?.limit, + }) + ) + ) + ) + .flat() + .sort((left, right) => right.createdAt.localeCompare(left.createdAt)); + return operations.slice(0, input?.limit ?? 50); + }), + requestReset: adminProcedure + .input( + z.object({ + profileName: z.string().min(1), + sourceMode: zSourceMode, + sourceRef: z.string().min(1).max(128), + install: zOperationInstallOptions, + scheduledAt: z.string().datetime().optional(), + reason: z.string().max(200).optional(), + }) + ) + .mutation(async ({ ctx, input }) => { + const adminAuth = requireAdminAuth(ctx); + assertPermission(adminAuth, ROLE_ADMIN_PROFILES, input.profileName); + const profile = await ctx.profiles.getProfile(input.profileName); + if (!profile) { + throw new TRPCError({ code: 'NOT_FOUND', message: 'Profile not found.' }); + } + if (input.scheduledAt && new Date(input.scheduledAt).getTime() <= Date.now()) { + throw new TRPCError({ + code: 'BAD_REQUEST', + message: 'scheduledAt must be in the future.', + }); + } + const scheduledAt = input.scheduledAt ? new Date(input.scheduledAt) : null; + const openAt = input.install.openAt ? new Date(input.install.openAt) : null; + const preopenAt = input.install.preopenAt ? new Date(input.install.preopenAt) : null; + if (preopenAt && !openAt) { + throw new TRPCError({ + code: 'BAD_REQUEST', + message: 'openAt is required when preopenAt is set.', + }); + } + if (preopenAt && openAt && preopenAt.getTime() >= openAt.getTime()) { + throw new TRPCError({ + code: 'BAD_REQUEST', + message: 'preopenAt must be earlier than openAt.', + }); + } + if (openAt && openAt.getTime() <= (scheduledAt?.getTime() ?? Date.now())) { + throw new TRPCError({ + code: 'BAD_REQUEST', + message: 'openAt must be later than the reset start.', + }); + } + if (preopenAt && scheduledAt && preopenAt.getTime() < scheduledAt.getTime()) { + throw new TRPCError({ + code: 'BAD_REQUEST', + message: 'preopenAt cannot be earlier than scheduledAt.', + }); + } + const autorunUser = input.install.autorunUser; + if ( + autorunUser && + ((autorunUser.limitMinutes <= 0 && autorunUser.options.length > 0) || + (autorunUser.limitMinutes > 0 && autorunUser.options.length === 0)) + ) { + throw new TRPCError({ + code: 'BAD_REQUEST', + message: 'autorunUser minutes and options must be configured together.', + }); + } + + let sourceRef = input.sourceRef.trim(); + try { + const resolved = + input.sourceMode === 'BRANCH' + ? await resolveGitBranchCommitSha(sourceRef) + : await resolveGitCommitSha(sourceRef); + if (input.sourceMode === 'COMMIT') { + sourceRef = resolved; + } + const scenarios = await listScenarioPreviews({ gitRef: resolved }); + if (!scenarios.some((scenario) => scenario.id === input.install.scenarioId)) { + throw new Error('Scenario not found at source.'); + } + } catch (error) { + throw new TRPCError({ + code: 'BAD_REQUEST', + message: + input.sourceMode === 'BRANCH' + ? 'Branch is invalid or does not contain the scenario.' + : 'Commit is invalid or does not contain the scenario.', + }); + } + + try { + const operation = await ctx.profiles.createOperation({ + profileName: input.profileName, + type: 'RESET', + sourceMode: input.sourceMode, + sourceRef, + payload: { install: input.install } as GatewayPrisma.JsonObject, + reason: input.reason, + requestedBy: adminAuth.user.id, + scheduledAt: input.scheduledAt, + }); + return operation; + } catch (error) { + if (!isUniqueConstraintError(error)) { + throw error; + } + throw new TRPCError({ + code: 'CONFLICT', + message: 'This profile already has a queued or running operation.', + }); + } + }), + requestRuntime: adminProcedure + .input( + z.object({ + profileName: z.string().min(1), + action: z.enum(['START', 'STOP']), + reason: z.string().max(200).optional(), + }) + ) + .mutation(async ({ ctx, input }) => { + const adminAuth = requireAdminAuth(ctx); + assertPermission(adminAuth, ROLE_ADMIN_PROFILES, input.profileName); + const profile = await ctx.profiles.getProfile(input.profileName); + if (!profile) { + throw new TRPCError({ code: 'NOT_FOUND', message: 'Profile not found.' }); + } + try { + const operation = await ctx.profiles.createOperation({ + profileName: input.profileName, + type: input.action, + reason: input.reason, + requestedBy: adminAuth.user.id, + }); + return operation; + } catch (error) { + if (!isUniqueConstraintError(error)) { + throw error; + } + throw new TRPCError({ + code: 'CONFLICT', + message: 'This profile already has a queued or running operation.', + }); + } + }), + cancel: adminProcedure + .input(z.object({ id: z.string().uuid() })) + .mutation(async ({ ctx, input }) => { + const adminAuth = requireAdminAuth(ctx); + const previous = await ctx.profiles.getOperation(input.id); + if (!previous) { + throw new TRPCError({ code: 'NOT_FOUND', message: 'Operation not found.' }); + } + assertPermission(adminAuth, ROLE_ADMIN_PROFILES, previous.profileName); + const cancelled = await ctx.profiles.cancelOperation(input.id); + if (!cancelled) { + throw new TRPCError({ + code: 'CONFLICT', + message: 'Only queued operations can be cancelled.', + }); + } + return { ok: true }; + }), + retry: adminProcedure + .input(z.object({ id: z.string().uuid() })) + .mutation(async ({ ctx, input }) => { + const adminAuth = requireAdminAuth(ctx); + const previous = await ctx.profiles.getOperation(input.id); + if (!previous) { + throw new TRPCError({ code: 'NOT_FOUND', message: 'Operation not found.' }); + } + assertPermission(adminAuth, ROLE_ADMIN_PROFILES, previous.profileName); + try { + const operation = await ctx.profiles.retryOperation(input.id, adminAuth.user.id); + if (!operation) { + throw new TRPCError({ + code: 'CONFLICT', + message: 'Only failed or cancelled operations can be retried.', + }); + } + return operation; + } catch (error) { + if (error instanceof TRPCError) { + throw error; + } + if (!isUniqueConstraintError(error)) { + throw error; + } + throw new TRPCError({ + code: 'CONFLICT', + message: 'This profile already has a queued or running operation.', + }); + } + }), + }), profiles: router({ list: adminProcedure.query(async ({ ctx }) => { const profiles = await ctx.profiles.listProfiles(); @@ -599,12 +842,20 @@ export const adminRouter = router({ z .object({ gitRef: z.string().min(1).max(128).optional(), + sourceMode: zSourceMode.optional(), }) .optional() ) .query(async ({ input }) => { const gitRef = input?.gitRef?.trim(); - return listScenarioPreviews({ gitRef: gitRef || null }); + if (!gitRef) { + return listScenarioPreviews(); + } + const resolved = + input?.sourceMode === 'BRANCH' + ? await resolveGitBranchCommitSha(gitRef) + : await resolveGitCommitSha(gitRef); + return listScenarioPreviews({ gitRef: resolved }); }), upsert: profileAdminProcedure .input( diff --git a/app/gateway-api/src/orchestrator/gatewayOrchestrator.ts b/app/gateway-api/src/orchestrator/gatewayOrchestrator.ts index 316980b..1e10e98 100644 --- a/app/gateway-api/src/orchestrator/gatewayOrchestrator.ts +++ b/app/gateway-api/src/orchestrator/gatewayOrchestrator.ts @@ -7,7 +7,12 @@ import { isRecord } from '@sammo-ts/common'; import type { BuildCommand, BuildRunner } from './buildRunner.js'; import type { ProcessManager } from './processManager.js'; -import type { GatewayProfileRecord, GatewayProfileRepository, GatewayProfileStatus } from './profileRepository.js'; +import type { + GatewayOperationRecord, + GatewayProfileRecord, + GatewayProfileRepository, + GatewayProfileStatus, +} from './profileRepository.js'; import type { GitWorkspaceManager } from './workspaceManager.js'; import { seedProfileDatabase, type AdminSeedUser } from './seedProfileDatabase.js'; @@ -46,6 +51,7 @@ export interface GatewayOrchestratorHandle { reconcileNow(): Promise; runScheduleNow(): Promise; runBuildQueueNow(): Promise; + runOperationsNow(): Promise; cleanupStaleWorkspaces(): Promise<{ removed: string[]; skipped: string[]; @@ -376,6 +382,7 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle { private scheduleInFlight = false; private buildInFlight = false; private adminActionInFlight = false; + private operationInFlight = false; private readonly resetInFlight = new Set(); constructor(options: GatewayOrchestratorOptions) { @@ -393,11 +400,15 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle { start(): void { void this.reconcileNow(); + void this.runOperationsNow(); void this.runAdminActionsNow(); this.reconcileTimer = setInterval(() => void this.reconcileNow(), this.reconcileIntervalMs); this.scheduleTimer = setInterval(() => void this.runScheduleNow(), this.scheduleIntervalMs); this.buildTimer = setInterval(() => void this.runBuildQueueNow(), this.buildIntervalMs); - this.adminActionTimer = setInterval(() => void this.runAdminActionsNow(), this.adminActionIntervalMs); + this.adminActionTimer = setInterval(() => { + void this.runOperationsNow(); + void this.runAdminActionsNow(); + }, this.adminActionIntervalMs); } async stop(): Promise { @@ -548,6 +559,83 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle { } } + async runOperationsNow(): Promise { + if (this.operationInFlight || this.buildInFlight) { + return; + } + this.operationInFlight = true; + try { + const operation = await this.repository.claimNextOperation(this.now()); + if (!operation) { + return; + } + await this.handleOperation(operation); + } finally { + this.operationInFlight = false; + } + } + + private async handleOperation(operation: GatewayOperationRecord): Promise { + const profile = await this.repository.getProfile(operation.profileName); + if (!profile) { + await this.repository.completeOperation(operation.id, 'FAILED', { + error: 'Profile not found.', + }); + return; + } + try { + if (operation.type === 'START') { + const updated = await this.repository.updateStatus(profile.profileName, 'RUNNING', { + preopenAt: null, + openAt: null, + scheduledStartAt: null, + }); + const started = await this.startProfile(updated ?? profile); + if (!started) { + throw new Error('Failed to start profile processes.'); + } + await this.repository.completeOperation(operation.id, 'SUCCEEDED', { error: null }); + return; + } + if (operation.type === 'STOP') { + await this.repository.updateStatus(profile.profileName, 'STOPPED'); + await this.stopProfile(profile); + await this.repository.completeOperation(operation.id, 'SUCCEEDED', { error: null }); + return; + } + + if (!operation.sourceMode || !operation.sourceRef) { + throw new Error('Reset source mode and ref are required.'); + } + const commitSha = await this.workspaceManager.resolveCommit(operation.sourceMode, operation.sourceRef); + const payload = normalizeMeta(operation.payload); + const install = isRecord(payload.install) ? payload.install : {}; + const resetAction: GatewayAdminActionRecord = { + action: operation.scheduledAt ? 'RESET_SCHEDULED' : 'RESET_NOW', + requestedAt: operation.createdAt, + scheduledAt: operation.scheduledAt ?? null, + reason: operation.reason ?? null, + install, + }; + const result = await this.handleResetAction(profile, resetAction, commitSha); + if (result.status === 'REQUESTED') { + const retryAt = new Date(this.now().getTime() + this.adminActionIntervalMs).toISOString(); + await this.repository.requeueOperation(operation.id, result.detail, retryAt); + return; + } + if (result.status !== 'APPLIED') { + throw new Error(result.detail ?? 'Reset failed.'); + } + await this.repository.completeOperation(operation.id, 'SUCCEEDED', { + resolvedCommitSha: commitSha, + error: null, + }); + } catch (error) { + const detail = error instanceof Error ? error.message : String(error); + await this.repository.completeOperation(operation.id, 'FAILED', { error: detail }); + } + } + private async runAdminActionsNow(): Promise { if (this.adminActionInFlight) { return; @@ -632,7 +720,8 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle { private async handleResetAction( profile: GatewayProfileRecord, - action: GatewayAdminActionRecord + action: GatewayAdminActionRecord, + commitShaOverride?: string ): Promise { // 리셋 요청을 빌드+재기동 흐름으로 처리한다. if (this.resetInFlight.has(profile.profileName)) { @@ -651,7 +740,7 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle { } } - const commitSha = profile.buildCommitSha; + const commitSha = commitShaOverride ?? profile.buildCommitSha; if (!commitSha) { return { status: 'FAILED', detail: 'buildCommitSha is missing' }; } @@ -734,7 +823,10 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle { scheduledStartAt: action.scheduledAt ?? null, }); const builtProfile = (await this.repository.getProfile(profile.profileName)) ?? activeProfile; - await this.startProfile(builtProfile); + const started = await this.startProfile(builtProfile); + if (!started) { + return { status: 'FAILED', detail: 'reset completed but profile processes failed to start' }; + } return { status: 'APPLIED', detail: 'reset completed via rebuild' }; } catch (error) { const detail = error instanceof Error ? error.message : String(error); @@ -852,32 +944,39 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle { return cutoff; } - private async startProfile(profile: GatewayProfileRecord): Promise { + private async startProfile(profile: GatewayProfileRecord): Promise { const definitions = buildProcessDefinitions(profile, this.processConfig); try { await this.processManager.start(definitions.api); await this.processManager.start(definitions.daemon); await this.repository.updateLastError(profile.profileName, null); + return true; } catch (error) { await this.repository.updateLastError( profile.profileName, error instanceof Error ? error.message : 'Failed to start processes.' ); + return false; } } private async stopProfile(profile: GatewayProfileRecord): Promise { const apiName = buildProcessName(profile.profileName, 'api'); const daemonName = buildProcessName(profile.profileName, 'daemon'); - try { - await this.processManager.stop(apiName); - } catch { - await this.processManager.delete(apiName); + const failures: string[] = []; + for (const name of [apiName, daemonName]) { + try { + await this.processManager.stop(name); + } catch { + try { + await this.processManager.delete(name); + } catch (error) { + failures.push(`${name}: ${error instanceof Error ? error.message : String(error)}`); + } + } } - try { - await this.processManager.stop(daemonName); - } catch { - await this.processManager.delete(daemonName); + if (failures.length > 0) { + throw new Error(`Failed to stop profile processes: ${failures.join('; ')}`); } } diff --git a/app/gateway-api/src/orchestrator/profileRepository.ts b/app/gateway-api/src/orchestrator/profileRepository.ts index 95fbb3b..bf9b10b 100644 --- a/app/gateway-api/src/orchestrator/profileRepository.ts +++ b/app/gateway-api/src/orchestrator/profileRepository.ts @@ -14,6 +14,45 @@ export type GatewayProfileStatus = (typeof GATEWAY_PROFILE_STATUSES)[number]; export const GATEWAY_BUILD_STATUSES = ['IDLE', 'QUEUED', 'RUNNING', 'FAILED', 'SUCCEEDED'] as const; export type GatewayBuildStatus = (typeof GATEWAY_BUILD_STATUSES)[number]; +export const GATEWAY_OPERATION_TYPES = ['RESET', 'START', 'STOP'] as const; +export type GatewayOperationType = (typeof GATEWAY_OPERATION_TYPES)[number]; + +export const GATEWAY_OPERATION_STATUSES = ['QUEUED', 'RUNNING', 'SUCCEEDED', 'FAILED', 'CANCELLED'] as const; +export type GatewayOperationStatus = (typeof GATEWAY_OPERATION_STATUSES)[number]; + +export const GATEWAY_SOURCE_MODES = ['BRANCH', 'COMMIT'] as const; +export type GatewaySourceMode = (typeof GATEWAY_SOURCE_MODES)[number]; + +export interface GatewayOperationRecord { + id: string; + profileName: string; + type: GatewayOperationType; + status: GatewayOperationStatus; + sourceMode?: GatewaySourceMode; + sourceRef?: string; + resolvedCommitSha?: string; + payload: GatewayPrisma.JsonObject; + reason?: string; + requestedBy: string; + scheduledAt?: string; + startedAt?: string; + completedAt?: string; + error?: string; + createdAt: string; + updatedAt: string; +} + +export interface GatewayOperationCreateInput { + profileName: string; + type: GatewayOperationType; + sourceMode?: GatewaySourceMode; + sourceRef?: string; + payload?: GatewayPrisma.JsonObject; + reason?: string; + requestedBy: string; + scheduledAt?: string; +} + export interface GatewayProfileRecord { profileName: string; profile: string; @@ -82,6 +121,18 @@ export interface GatewayProfileRepository { updateLastError(profileName: string, lastError: string | null): Promise; updateWorkspaceUsage(profileName: string, workspace: string, lastUsedAt: string): Promise; clearWorkspaceUsage(profileNames: string[]): Promise; + listOperations(options?: { profileName?: string; limit?: number }): Promise; + getOperation(id: string): Promise; + createOperation(input: GatewayOperationCreateInput): Promise; + claimNextOperation(now: Date): Promise; + completeOperation( + id: string, + status: Extract, + fields?: { resolvedCommitSha?: string | null; error?: string | null } + ): Promise; + requeueOperation(id: string, detail?: string, retryAt?: string): Promise; + cancelOperation(id: string): Promise; + retryOperation(id: string, requestedBy: string): Promise; } const toIso = (value: Date | null): string | undefined => (value ? value.toISOString() : undefined); @@ -109,6 +160,25 @@ type GatewayProfileRow = { updatedAt: Date; }; +type GatewayOperationRow = { + id: string; + profileName: string; + type: GatewayOperationType; + status: GatewayOperationStatus; + sourceMode: GatewaySourceMode | null; + sourceRef: string | null; + resolvedCommitSha: string | null; + payload: GatewayPrisma.JsonValue; + reason: string | null; + requestedBy: string; + scheduledAt: Date | null; + startedAt: Date | null; + completedAt: Date | null; + error: string | null; + createdAt: Date; + updatedAt: Date; +}; + const mapProfile = (row: GatewayProfileRow): GatewayProfileRecord => ({ profileName: row.profileName, profile: row.profile, @@ -134,6 +204,25 @@ const mapProfile = (row: GatewayProfileRow): GatewayProfileRecord => ({ const buildProfileName = (profile: string, scenario: string): string => `${profile}:${scenario}`; +const mapOperation = (row: GatewayOperationRow): GatewayOperationRecord => ({ + id: row.id, + profileName: row.profileName, + type: row.type, + status: row.status, + sourceMode: row.sourceMode ?? undefined, + sourceRef: row.sourceRef ?? undefined, + resolvedCommitSha: row.resolvedCommitSha ?? undefined, + payload: (row.payload ?? {}) as GatewayPrisma.JsonObject, + reason: row.reason ?? undefined, + requestedBy: row.requestedBy, + scheduledAt: toIso(row.scheduledAt), + startedAt: toIso(row.startedAt), + completedAt: toIso(row.completedAt), + error: row.error ?? undefined, + createdAt: row.createdAt.toISOString(), + updatedAt: row.updatedAt.toISOString(), +}); + export const createGatewayProfileRepository = (prisma: GatewayPrismaClient): GatewayProfileRepository => ({ async listProfiles(): Promise { const rows = await prisma.gatewayProfile.findMany({ @@ -327,4 +416,111 @@ export const createGatewayProfileRepository = (prisma: GatewayPrismaClient): Gat }, }); }, + async listOperations(options?: { profileName?: string; limit?: number }): Promise { + const rows = await prisma.gatewayOperation.findMany({ + where: options?.profileName ? { profileName: options.profileName } : undefined, + orderBy: { createdAt: 'desc' }, + take: Math.min(Math.max(options?.limit ?? 50, 1), 200), + }); + return rows.map(mapOperation); + }, + async getOperation(id: string): Promise { + 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, + }, + }); + return mapOperation(row); + }, + async claimNextOperation(now: Date): Promise { + const row = await prisma.$transaction(async (tx) => { + const candidate = await tx.gatewayOperation.findFirst({ + where: { + status: 'QUEUED', + OR: [{ scheduledAt: null }, { scheduledAt: { lte: now } }], + }, + orderBy: { createdAt: 'asc' }, + }); + if (!candidate) { + return null; + } + const claimed = await tx.gatewayOperation.updateMany({ + where: { id: candidate.id, status: 'QUEUED' }, + data: { status: 'RUNNING', startedAt: now, error: null }, + }); + if (claimed.count !== 1) { + return null; + } + return tx.gatewayOperation.findUnique({ where: { id: candidate.id } }); + }); + return row ? mapOperation(row) : null; + }, + async completeOperation( + id: string, + status: Extract, + fields?: { resolvedCommitSha?: string | null; error?: string | null } + ): Promise { + const row = await prisma.gatewayOperation.update({ + where: { id }, + data: { + status, + completedAt: new Date(), + resolvedCommitSha: + fields?.resolvedCommitSha === undefined ? undefined : fields.resolvedCommitSha, + error: fields?.error === undefined ? undefined : fields.error, + }, + }); + return mapOperation(row); + }, + async requeueOperation(id: string, detail?: string, retryAt?: string): Promise { + const row = await prisma.gatewayOperation.update({ + where: { id }, + data: { + status: 'QUEUED', + startedAt: null, + error: detail, + scheduledAt: retryAt ? new Date(retryAt) : undefined, + }, + }); + return mapOperation(row); + }, + async cancelOperation(id: string): Promise { + const result = await prisma.gatewayOperation.updateMany({ + where: { id, status: 'QUEUED' }, + data: { status: 'CANCELLED', completedAt: new Date() }, + }); + return result.count === 1; + }, + async retryOperation(id: string, requestedBy: string): Promise { + const row = await prisma.$transaction(async (tx) => { + const previous = await tx.gatewayOperation.findUnique({ where: { id } }); + if (!previous || (previous.status !== 'FAILED' && previous.status !== 'CANCELLED')) { + return null; + } + return tx.gatewayOperation.create({ + data: { + profileName: previous.profileName, + type: previous.type, + sourceMode: previous.sourceMode, + sourceRef: previous.sourceRef, + payload: previous.payload as GatewayPrisma.JsonObject, + reason: previous.reason, + requestedBy, + scheduledAt: null, + }, + }); + }); + return row ? mapOperation(row) : null; + }, }); diff --git a/app/gateway-api/src/orchestrator/workspaceManager.ts b/app/gateway-api/src/orchestrator/workspaceManager.ts index 18174a6..d9782d7 100644 --- a/app/gateway-api/src/orchestrator/workspaceManager.ts +++ b/app/gateway-api/src/orchestrator/workspaceManager.ts @@ -40,6 +40,15 @@ const ensureDir = (dir: string): void => { }; const hasInstallMarker = (dir: string): boolean => fs.existsSync(path.join(dir, 'node_modules', '.pnpm')); +const GIT_REF_PATTERN = /^[0-9A-Za-z._/-]+$/; + +const assertGitRef = (value: string): string => { + const ref = value.trim(); + if (!ref || ref.startsWith('-') || ref.includes('..') || !GIT_REF_PATTERN.test(ref)) { + throw new Error('Invalid git ref.'); + } + return ref; +}; export class GitWorkspaceManager { private readonly repoRoot: string; @@ -52,6 +61,28 @@ export class GitWorkspaceManager { this.baseEnv = options.baseEnv; } + async resolveCommit(sourceMode: 'BRANCH' | 'COMMIT', sourceRef: string): Promise { + const ref = assertGitRef(sourceRef); + if (sourceMode === 'BRANCH') { + const fetched = await runGit(['fetch', '--all', '--prune'], this.repoRoot, this.baseEnv); + if (!fetched.ok) { + throw new Error(fetched.output || 'Failed to fetch git branches.'); + } + } + const candidates = + sourceMode === 'BRANCH' + ? [`refs/remotes/origin/${ref}^{commit}`, `refs/heads/${ref}^{commit}`] + : [`${ref}^{commit}`]; + for (const candidate of candidates) { + const result = await runGit(['rev-parse', '--verify', candidate], this.repoRoot, this.baseEnv); + const commitSha = result.output.trim().split('\n')[0]; + if (result.ok && /^[0-9a-f]{40}$/i.test(commitSha)) { + return commitSha; + } + } + throw new Error(`${sourceMode === 'BRANCH' ? 'Branch' : 'Commit'} not found.`); + } + async prepare(commitSha: string): Promise { const workspacePath = path.join(this.worktreeRoot, commitSha); ensureDir(this.worktreeRoot); diff --git a/app/gateway-api/src/scenario/scenarioCatalog.ts b/app/gateway-api/src/scenario/scenarioCatalog.ts index 2e5da54..a76599c 100644 --- a/app/gateway-api/src/scenario/scenarioCatalog.ts +++ b/app/gateway-api/src/scenario/scenarioCatalog.ts @@ -98,6 +98,22 @@ export const resolveGitCommitSha = async (gitRef: string): Promise => { return commit; }; +export const resolveGitBranchCommitSha = async (branch: string): Promise => { + const normalized = normalizeGitRef(branch); + if (!normalized) { + throw new Error('git branch is invalid.'); + } + await runGit(['fetch', '--all', '--prune']); + for (const candidate of [`refs/remotes/origin/${normalized}`, `refs/heads/${normalized}`]) { + const result = await runGit(['rev-parse', '--verify', `${candidate}^{commit}`]); + const commit = result.output.trim().split('\n')[0]; + if (result.ok && /^[0-9a-f]{40}$/i.test(commit)) { + return commit; + } + } + throw new Error('git branch not found.'); +}; + const readGitFile = async (commitSha: string, relativePath: string): Promise => { const result = await runGit(['show', `${commitSha}:${relativePath}`]); if (!result.ok) { diff --git a/app/gateway-api/test/adminOperations.test.ts b/app/gateway-api/test/adminOperations.test.ts new file mode 100644 index 0000000..1158993 --- /dev/null +++ b/app/gateway-api/test/adminOperations.test.ts @@ -0,0 +1,140 @@ +import { describe, expect, it } from 'vitest'; + +import type { GatewayPrismaClient } from '@sammo-ts/infra'; + +import { InMemoryGatewaySessionService } from '../src/auth/inMemorySessionService.js'; +import { createInMemoryUserRepository } from '../src/auth/inMemoryUserRepository.js'; +import type { GatewayOperationCreateInput, GatewayProfileRepository } from '../src/orchestrator/profileRepository.js'; +import { createGatewayApiContext } from '../src/context.js'; +import { InMemoryProfileStatusService } from '../src/lobby/profileStatusService.js'; +import { appRouter } from '../src/router.js'; + +const buildCaller = async (createOperation: GatewayProfileRepository['createOperation']) => { + const users = createInMemoryUserRepository(); + const admin = await users.createUser({ + username: 'admin', + password: 'secretpass', + displayName: 'Admin', + }); + await users.updateRoles(admin.id, ['superuser']); + const sessions = new InMemoryGatewaySessionService({ + sessionTtlSeconds: 600, + gameSessionTtlSeconds: 600, + }); + const session = await sessions.createSession({ ...admin, roles: ['superuser'] }); + const createdInputs: GatewayOperationCreateInput[] = []; + const profile = { + profileName: 'che:2', + profile: 'che', + scenario: '2', + apiPort: 15003, + status: 'STOPPED' as const, + buildStatus: 'SUCCEEDED' as const, + meta: {}, + createdAt: '2026-07-25T00:00:00.000Z', + updatedAt: '2026-07-25T00:00:00.000Z', + }; + const profiles: GatewayProfileRepository = { + listProfiles: async () => [profile], + getProfile: async () => profile, + upsertProfile: async () => profile, + updateScenario: async () => profile, + updateStatus: async () => profile, + updateBuildStatus: async () => profile, + updateMeta: async () => profile, + listReservedToStart: async () => [], + findQueuedBuild: async () => null, + updateLastError: async () => {}, + updateWorkspaceUsage: async () => {}, + clearWorkspaceUsage: async () => {}, + listOperations: async () => [], + getOperation: async () => null, + createOperation: async (input) => { + createdInputs.push(input); + return createOperation(input); + }, + claimNextOperation: async () => null, + completeOperation: async () => { + throw new Error('not used'); + }, + requeueOperation: async () => { + throw new Error('not used'); + }, + cancelOperation: async () => false, + retryOperation: async () => null, + }; + const caller = appRouter.createCaller( + createGatewayApiContext({ + users, + sessions, + flushPublisher: { publishUserFlush: async () => {} }, + gameTokenSecret: 'test-secret', + gameSessionTtlSeconds: 600, + kakaoClient: {} as never, + oauthSessions: {} as never, + publicBaseUrl: 'http://localhost', + adminLocalAccountEnabled: false, + profiles, + orchestrator: { + start: () => {}, + stop: async () => {}, + reconcileNow: async () => {}, + runScheduleNow: async () => {}, + runBuildQueueNow: async () => {}, + runOperationsNow: async () => {}, + cleanupStaleWorkspaces: async () => ({ removed: [], skipped: [] }), + listRuntimeStates: async () => [], + }, + profileStatus: new InMemoryProfileStatusService(), + requestHeaders: { 'x-session-token': session.sessionToken }, + prisma: { + appUser: { + findFirst: async () => ({ id: admin.id }), + }, + } as unknown as GatewayPrismaClient, + }) + ); + return { caller, createdInputs }; +}; + +describe('admin operation API', () => { + it('queues a start operation with the authenticated requester', async () => { + const operation = { + id: '11111111-1111-4111-8111-111111111111', + profileName: 'che:2', + type: 'START' as const, + status: 'QUEUED' as const, + payload: {}, + requestedBy: 'admin-id', + createdAt: '2026-07-25T00:00:00.000Z', + updatedAt: '2026-07-25T00:00:00.000Z', + }; + const harness = await buildCaller(async () => operation); + + const result = await harness.caller.admin.operations.requestRuntime({ + profileName: 'che:2', + action: 'START', + reason: 'maintenance complete', + }); + + expect(result.type).toBe('START'); + expect(harness.createdInputs[0]).toMatchObject({ + profileName: 'che:2', + type: 'START', + reason: 'maintenance complete', + }); + }); + + it('reports an active-operation uniqueness conflict', async () => { + const harness = await buildCaller(async () => { + throw { code: 'P2002' }; + }); + + await expect( + harness.caller.admin.operations.requestRuntime({ + profileName: 'che:2', + action: 'STOP', + }) + ).rejects.toMatchObject({ code: 'CONFLICT' }); + }); +}); diff --git a/app/gateway-api/test/authFlow.test.ts b/app/gateway-api/test/authFlow.test.ts index c02bc38..55f7898 100644 --- a/app/gateway-api/test/authFlow.test.ts +++ b/app/gateway-api/test/authFlow.test.ts @@ -59,6 +59,20 @@ const buildCaller = () => { updateLastError: async () => {}, updateWorkspaceUsage: async () => {}, clearWorkspaceUsage: async () => {}, + listOperations: async () => [], + getOperation: async () => null, + createOperation: async () => { + throw new Error('not implemented'); + }, + claimNextOperation: async () => null, + completeOperation: async () => { + throw new Error('not implemented'); + }, + requeueOperation: async () => { + throw new Error('not implemented'); + }, + cancelOperation: async () => false, + retryOperation: async () => null, }; const orchestrator = { start: () => {}, @@ -66,6 +80,7 @@ const buildCaller = () => { reconcileNow: async () => {}, runScheduleNow: async () => {}, runBuildQueueNow: async () => {}, + runOperationsNow: async () => {}, cleanupStaleWorkspaces: async () => ({ removed: [], skipped: [], diff --git a/app/gateway-api/test/orchestratorOperations.test.ts b/app/gateway-api/test/orchestratorOperations.test.ts new file mode 100644 index 0000000..d539395 --- /dev/null +++ b/app/gateway-api/test/orchestratorOperations.test.ts @@ -0,0 +1,165 @@ +import { describe, expect, it } from 'vitest'; + +import { GatewayOrchestrator } from '../src/orchestrator/gatewayOrchestrator.js'; +import type { ProcessDefinition, ProcessManager } from '../src/orchestrator/processManager.js'; +import type { + GatewayOperationRecord, + GatewayOperationStatus, + GatewayProfileRecord, + GatewayProfileRepository, +} from '../src/orchestrator/profileRepository.js'; +import { GitWorkspaceManager } from '../src/orchestrator/workspaceManager.js'; + +const profile: GatewayProfileRecord = { + profileName: 'che:2', + profile: 'che', + scenario: '2', + apiPort: 15003, + status: 'STOPPED', + buildStatus: 'SUCCEEDED', + buildCommitSha: '0123456789abcdef0123456789abcdef01234567', + buildWorkspace: '/srv/sammo/worktrees/0123456789abcdef', + meta: {}, + createdAt: '2026-07-25T00:00:00.000Z', + updatedAt: '2026-07-25T00:00:00.000Z', +}; + +const buildOperation = (type: 'START' | 'STOP'): GatewayOperationRecord => ({ + id: '11111111-1111-4111-8111-111111111111', + profileName: profile.profileName, + type, + status: 'RUNNING', + payload: {}, + requestedBy: 'admin', + createdAt: '2026-07-25T01:00:00.000Z', + startedAt: '2026-07-25T01:00:00.000Z', + updatedAt: '2026-07-25T01:00:00.000Z', +}); + +const createHarness = (operation: GatewayOperationRecord, failStart = false, failStop = false) => { + let nextOperation: GatewayOperationRecord | null = operation; + const statuses: string[] = []; + const completions: GatewayOperationStatus[] = []; + const started: ProcessDefinition[] = []; + const stopped: string[] = []; + const deleted: string[] = []; + + const repository: GatewayProfileRepository = { + listProfiles: async () => [profile], + getProfile: async () => profile, + upsertProfile: async () => profile, + updateScenario: async () => profile, + updateStatus: async (_profileName, status) => { + statuses.push(status); + return { ...profile, status }; + }, + updateBuildStatus: async () => profile, + updateMeta: async () => profile, + listReservedToStart: async () => [], + findQueuedBuild: async () => null, + updateLastError: async () => {}, + updateWorkspaceUsage: async () => {}, + clearWorkspaceUsage: async () => {}, + listOperations: async () => [], + getOperation: async () => operation, + createOperation: async () => operation, + claimNextOperation: async () => { + const result = nextOperation; + nextOperation = null; + return result; + }, + completeOperation: async (_id, status) => { + completions.push(status); + return { ...operation, status }; + }, + requeueOperation: async () => ({ ...operation, status: 'QUEUED' }), + cancelOperation: async () => false, + retryOperation: async () => null, + }; + const processManager: ProcessManager = { + list: async () => [], + start: async (definition) => { + if (failStart) { + throw new Error('pm2 unavailable'); + } + started.push(definition); + }, + stop: async (name) => { + stopped.push(name); + if (failStop) { + throw new Error('pm2 stop failed'); + } + }, + delete: async (name) => { + deleted.push(name); + if (failStop) { + throw new Error('pm2 delete failed'); + } + }, + }; + const orchestrator = new GatewayOrchestrator({ + repository, + processManager, + buildRunner: { + run: async () => ({ ok: true, exitCode: 0, output: '' }), + }, + workspaceManager: new GitWorkspaceManager({ + repoRoot: '/tmp/not-used', + worktreeRoot: '/tmp/not-used-worktrees', + }), + processConfig: { + workspaceRoot: '/srv/sammo', + redisKeyPrefix: 'sammo:test', + gameTokenSecret: 'test-secret', + }, + reconcileIntervalMs: 60_000, + scheduleIntervalMs: 60_000, + buildIntervalMs: 60_000, + adminActionIntervalMs: 60_000, + }); + + return { orchestrator, statuses, completions, started, stopped, deleted }; +}; + +describe('GatewayOrchestrator first-class operations', () => { + it('starts both profile processes and records success', async () => { + const harness = createHarness(buildOperation('START')); + + await harness.orchestrator.runOperationsNow(); + + expect(harness.statuses).toEqual(['RUNNING']); + expect(harness.started.map((definition) => definition.name)).toEqual([ + 'sammo:che:2:game-api', + 'sammo:che:2:turn-daemon', + ]); + expect(harness.completions).toEqual(['SUCCEEDED']); + }); + + it('stops both profile processes and records success', async () => { + const harness = createHarness(buildOperation('STOP')); + + await harness.orchestrator.runOperationsNow(); + + expect(harness.statuses).toEqual(['STOPPED']); + expect(harness.stopped).toEqual(['sammo:che:2:game-api', 'sammo:che:2:turn-daemon']); + expect(harness.completions).toEqual(['SUCCEEDED']); + }); + + it('records a failed start instead of reporting a false success', async () => { + const harness = createHarness(buildOperation('START'), true); + + await harness.orchestrator.runOperationsNow(); + + expect(harness.completions).toEqual(['FAILED']); + }); + + it('attempts to stop both roles before reporting a partial PM2 failure', async () => { + const harness = createHarness(buildOperation('STOP'), false, true); + + await harness.orchestrator.runOperationsNow(); + + expect(harness.stopped).toEqual(['sammo:che:2:game-api', 'sammo:che:2:turn-daemon']); + expect(harness.deleted).toEqual(['sammo:che:2:game-api', 'sammo:che:2:turn-daemon']); + expect(harness.completions).toEqual(['FAILED']); + }); +}); diff --git a/app/gateway-api/test/workspaceManager.test.ts b/app/gateway-api/test/workspaceManager.test.ts new file mode 100644 index 0000000..989ec01 --- /dev/null +++ b/app/gateway-api/test/workspaceManager.test.ts @@ -0,0 +1,88 @@ +import { execFileSync } from 'node:child_process'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +import { afterEach, describe, expect, it } from 'vitest'; + +import { GitWorkspaceManager } from '../src/orchestrator/workspaceManager.js'; + +const temporaryRoots: string[] = []; + +const git = (cwd: string, ...args: string[]): string => + execFileSync('git', args, { + cwd, + encoding: 'utf8', + env: { + ...process.env, + GIT_AUTHOR_NAME: 'Sammo Test', + GIT_AUTHOR_EMAIL: 'sammo-test@example.invalid', + GIT_COMMITTER_NAME: 'Sammo Test', + GIT_COMMITTER_EMAIL: 'sammo-test@example.invalid', + }, + }).trim(); + +const createRepositoryFixture = (): { + source: string; + checkout: string; + worktrees: string; + firstCommit: string; +} => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'sammo-workspace-manager-')); + temporaryRoots.push(root); + const remote = path.join(root, 'remote.git'); + const source = path.join(root, 'source'); + const checkout = path.join(root, 'checkout'); + const worktrees = path.join(root, 'worktrees'); + + fs.mkdirSync(source); + git(root, 'init', '--bare', remote); + git(source, 'init', '-b', 'main'); + fs.writeFileSync(path.join(source, 'version.txt'), 'first\n'); + git(source, 'add', 'version.txt'); + git(source, 'commit', '-m', 'first'); + const firstCommit = git(source, 'rev-parse', 'HEAD'); + git(source, 'remote', 'add', 'origin', remote); + git(source, 'push', '-u', 'origin', 'main'); + git(root, 'clone', '--branch', 'main', remote, checkout); + return { source, checkout, worktrees, firstCommit }; +}; + +afterEach(() => { + for (const root of temporaryRoots.splice(0)) { + fs.rmSync(root, { recursive: true, force: true }); + } +}); + +describe('GitWorkspaceManager source resolution', () => { + it('keeps COMMIT pinned while BRANCH follows the latest remote head', async () => { + const fixture = createRepositoryFixture(); + const manager = new GitWorkspaceManager({ + repoRoot: fixture.checkout, + worktreeRoot: fixture.worktrees, + }); + + expect(await manager.resolveCommit('COMMIT', fixture.firstCommit)).toBe(fixture.firstCommit); + expect(await manager.resolveCommit('BRANCH', 'main')).toBe(fixture.firstCommit); + + fs.writeFileSync(path.join(fixture.source, 'version.txt'), 'second\n'); + git(fixture.source, 'add', 'version.txt'); + git(fixture.source, 'commit', '-m', 'second'); + const secondCommit = git(fixture.source, 'rev-parse', 'HEAD'); + git(fixture.source, 'push', 'origin', 'main'); + + expect(await manager.resolveCommit('COMMIT', fixture.firstCommit)).toBe(fixture.firstCommit); + expect(await manager.resolveCommit('BRANCH', 'main')).toBe(secondCommit); + }); + + it('rejects option-like and range refs', async () => { + const fixture = createRepositoryFixture(); + const manager = new GitWorkspaceManager({ + repoRoot: fixture.checkout, + worktreeRoot: fixture.worktrees, + }); + + await expect(manager.resolveCommit('BRANCH', '--upload-pack=bad')).rejects.toThrow('Invalid git ref'); + await expect(manager.resolveCommit('COMMIT', 'HEAD..main')).rejects.toThrow('Invalid git ref'); + }); +}); diff --git a/app/gateway-frontend/e2e/playwright.config.mjs b/app/gateway-frontend/e2e/playwright.config.mjs new file mode 100644 index 0000000..95fb8c4 --- /dev/null +++ b/app/gateway-frontend/e2e/playwright.config.mjs @@ -0,0 +1,34 @@ +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { defineConfig, devices } from '@playwright/test'; + +const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), '../../..'); + +export default defineConfig({ + testDir: '.', + testMatch: 'server-operations.spec.ts', + fullyParallel: false, + workers: 1, + timeout: 30_000, + expect: { + timeout: 5_000, + }, + reporter: [['list']], + outputDir: resolve(repositoryRoot, 'test-results/server-operations'), + use: { + baseURL: 'http://127.0.0.1:15130/gateway/', + ...devices['Desktop Chrome'], + deviceScaleFactor: 1, + colorScheme: 'dark', + trace: 'retain-on-failure', + screenshot: 'only-on-failure', + }, + webServer: { + command: + 'VITE_APP_BASE_PATH=/gateway pnpm --filter @sammo-ts/gateway-frontend preview --host 127.0.0.1 --port 15130', + cwd: repositoryRoot, + url: 'http://127.0.0.1:15130/gateway/', + reuseExistingServer: false, + timeout: 120_000, + }, +}); diff --git a/app/gateway-frontend/e2e/server-operations.spec.ts b/app/gateway-frontend/e2e/server-operations.spec.ts new file mode 100644 index 0000000..4c9744b --- /dev/null +++ b/app/gateway-frontend/e2e/server-operations.spec.ts @@ -0,0 +1,223 @@ +import { expect, test, type Page, type Route } from '@playwright/test'; +import { writeFile } from 'node:fs/promises'; + +type OperationStatus = 'QUEUED' | 'RUNNING' | 'SUCCEEDED' | 'FAILED' | 'CANCELLED'; +type Operation = { + id: string; + profileName: string; + type: 'RESET' | 'START' | 'STOP'; + status: OperationStatus; + sourceMode?: 'BRANCH' | 'COMMIT'; + sourceRef?: string; + resolvedCommitSha?: string; + payload: Record; + requestedBy: string; + createdAt: string; + updatedAt: string; +}; + +type FixtureState = { + operations: Operation[]; + runtimeRunning: boolean; + requestBodies: Array<{ operation: string; body: unknown }>; +}; + +const profile = (runtimeRunning: boolean) => ({ + profileName: 'che:2', + profile: 'che', + scenario: '2', + apiPort: 15003, + status: runtimeRunning ? 'RUNNING' : 'STOPPED', + buildStatus: 'SUCCEEDED', + buildCommitSha: '0123456789abcdef0123456789abcdef01234567', + buildWorkspace: '/srv/sammo/worktrees/0123456789abcdef0123456789abcdef01234567', + meta: {}, + createdAt: '2026-07-25T00:00:00.000Z', + updatedAt: '2026-07-25T00:00:00.000Z', + runtime: { + profileName: 'che:2', + apiRunning: runtimeRunning, + daemonRunning: runtimeRunning, + }, +}); + +const scenarios = [ + { + id: 2, + title: '【테스트】황건의 난', + year: 184, + npcCount: 42, + npcExCount: 0, + npcNeutralCount: 0, + nations: [], + }, + { + id: 5, + title: '【테스트】군웅할거', + year: 190, + npcCount: 55, + npcExCount: 0, + npcNeutralCount: 0, + nations: [], + }, +]; + +const response = (data: unknown) => ({ result: { data } }); +const operationNames = (route: Route): string[] => { + const url = new URL(route.request().url()); + return decodeURIComponent(url.pathname.slice(url.pathname.lastIndexOf('/trpc/') + 6)).split(','); +}; + +const installFixture = async (page: Page, state: FixtureState) => { + await page.addInitScript(() => { + window.localStorage.setItem('sammo-session-token', 'playwright-admin-session'); + }); + await page.route('**/gateway/api/trpc/**', async (route) => { + const names = operationNames(route); + const body = route.request().postDataJSON() as unknown; + const results = names.map((name) => { + if (route.request().method() === 'POST') { + state.requestBodies.push({ operation: name, body }); + } + if (name === 'admin.profiles.list') { + return response([profile(state.runtimeRunning)]); + } + if (name === 'admin.operations.list') { + return response(state.operations); + } + if (name === 'admin.profiles.listScenarios') { + return response(scenarios); + } + if (name === 'admin.operations.requestReset') { + const operation: Operation = { + id: '11111111-1111-4111-8111-111111111111', + profileName: 'che:2', + type: 'RESET', + status: 'QUEUED', + sourceMode: 'COMMIT', + sourceRef: '0123456789abcdef0123456789abcdef01234567', + payload: {}, + requestedBy: 'admin', + createdAt: '2026-07-25T02:00:00.000Z', + updatedAt: '2026-07-25T02:00:00.000Z', + }; + state.operations = [operation]; + return response(operation); + } + if (name === 'admin.operations.requestRuntime') { + const serialized = JSON.stringify(body); + const type = serialized.includes('"STOP"') ? 'STOP' : 'START'; + state.runtimeRunning = type === 'START'; + const operation: Operation = { + id: + type === 'START' + ? '22222222-2222-4222-8222-222222222222' + : '33333333-3333-4333-8333-333333333333', + profileName: 'che:2', + type, + status: 'SUCCEEDED', + payload: {}, + requestedBy: 'admin', + createdAt: '2026-07-25T03:00:00.000Z', + updatedAt: '2026-07-25T03:00:00.000Z', + }; + state.operations = [operation]; + return response(operation); + } + throw new Error(`Unhandled tRPC operation: ${name}`); + }); + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify(results), + }); + }); +}; + +test('separates branch and commit semantics and submits a reset from the dedicated page', async ({ + page, +}, testInfo) => { + const state: FixtureState = { operations: [], runtimeRunning: false, requestBodies: [] }; + await installFixture(page, state); + page.on('dialog', (dialog) => dialog.accept()); + + await page.goto('admin/server-operations'); + await expect(page.getByTestId('server-operations-page')).toBeVisible(); + await expect(page).toHaveURL(/\/gateway\/admin\/server-operations$/); + await expect(page.getByTestId('source-help')).toContainText('실제로 시작될 때'); + await expect(page.getByTestId('scenario-select')).toHaveValue('2'); + + const desktopGeometry = await page.getByTestId('server-operations-page').locator('section').first().evaluate((section) => { + const children = Array.from(section.children).map((child) => { + const rect = child.getBoundingClientRect(); + return { x: rect.x, y: rect.y, width: rect.width, height: rect.height }; + }); + return children; + }); + expect(desktopGeometry).toHaveLength(2); + expect(desktopGeometry[1]!.x).toBeGreaterThan(desktopGeometry[0]!.x); + const sourceInput = page.getByTestId('source-ref'); + await sourceInput.focus(); + const focusedInputStyle = await sourceInput.evaluate((element) => { + const style = getComputedStyle(element); + return { + borderColor: style.borderColor, + backgroundColor: style.backgroundColor, + color: style.color, + fontSize: style.fontSize, + lineHeight: style.lineHeight, + outlineStyle: style.outlineStyle, + }; + }); + await writeFile( + testInfo.outputPath('layout-metrics.json'), + JSON.stringify({ desktopGeometry, focusedInputStyle }, null, 2) + ); + await page.screenshot({ path: testInfo.outputPath('desktop-operations.png'), fullPage: true }); + + await page.getByTestId('source-commit').check(); + await expect(page.getByTestId('source-help')).toContainText('전체 SHA로 고정'); + await page.getByTestId('source-ref').fill('0123456789abcdef0123456789abcdef01234567'); + await page.getByTestId('load-scenarios').click(); + await page.getByTestId('scenario-select').selectOption('5'); + await page.getByTestId('request-reset').hover(); + await page.getByTestId('request-reset').click(); + + await expect(page.getByText('초기화 작업을 시작했습니다.')).toBeVisible(); + await expect(page.getByTestId('operations-table')).toContainText('RESET'); + 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.setViewportSize({ width: 390, height: 844 }); + const mobileGeometry = await page.getByTestId('server-operations-page').locator('section').first().evaluate((section) => { + const children = Array.from(section.children).map((child) => { + const rect = child.getBoundingClientRect(); + return { x: rect.x, y: rect.y, width: rect.width }; + }); + return children; + }); + expect(mobileGeometry[1]!.y).toBeGreaterThan(mobileGeometry[0]!.y); + expect(mobileGeometry[0]!.width).toBeLessThanOrEqual(390); + await page.screenshot({ path: testInfo.outputPath('mobile-operations.png'), fullPage: true }); +}); + +test('starts and stops both runtime roles through the operation controls', async ({ page }) => { + const state: FixtureState = { operations: [], runtimeRunning: false, requestBodies: [] }; + await installFixture(page, state); + page.on('dialog', (dialog) => dialog.accept()); + + await page.goto('admin/server-operations'); + await page.getByTestId('start-server').click(); + await expect(page.getByText('시작 작업을 요청했습니다.')).toBeVisible(); + await expect(page.getByText('RUNNING', { exact: true }).first()).toBeVisible(); + + await page.getByTestId('stop-server').click(); + await expect(page.getByText('정지 작업을 요청했습니다.')).toBeVisible(); + await expect(page.getByText('STOPPED', { exact: true }).first()).toBeVisible(); + + const serializedRequests = state.requestBodies.map((entry) => JSON.stringify(entry.body)).join('\n'); + expect(serializedRequests).toContain('"action":"START"'); + expect(serializedRequests).toContain('"action":"STOP"'); +}); diff --git a/app/gateway-frontend/package.json b/app/gateway-frontend/package.json index 2cb9e4b..73a64b0 100644 --- a/app/gateway-frontend/package.json +++ b/app/gateway-frontend/package.json @@ -5,6 +5,7 @@ "type": "module", "scripts": { "dev": "vite", + "test:e2e:operations": "VITE_APP_BASE_PATH=/gateway VITE_GATEWAY_API_URL=/gateway/api/trpc pnpm build && playwright test --config e2e/playwright.config.mjs", "build": "vue-tsc && vite build", "preview": "vite preview", "lint": "eslint .", diff --git a/app/gateway-frontend/src/router/index.ts b/app/gateway-frontend/src/router/index.ts index 47df41f..f234b44 100644 --- a/app/gateway-frontend/src/router/index.ts +++ b/app/gateway-frontend/src/router/index.ts @@ -2,6 +2,7 @@ import { createRouter, createWebHistory } from 'vue-router'; import HomeView from '../views/HomeView.vue'; import LobbyView from '../views/LobbyView.vue'; import AdminView from '../views/AdminView.vue'; +import ServerOperationsView from '../views/ServerOperationsView.vue'; const router = createRouter({ history: createWebHistory(import.meta.env.BASE_URL), @@ -21,6 +22,11 @@ const router = createRouter({ name: 'admin', component: AdminView, }, + { + path: '/admin/server-operations', + name: 'server-operations', + component: ServerOperationsView, + }, ], }); diff --git a/app/gateway-frontend/src/views/AdminView.vue b/app/gateway-frontend/src/views/AdminView.vue index 2b9fe6b..0403de4 100644 --- a/app/gateway-frontend/src/views/AdminView.vue +++ b/app/gateway-frontend/src/views/AdminView.vue @@ -1012,9 +1012,17 @@ onMounted(() => {