feat: add admin scenario operations
This commit is contained in:
@@ -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<typeof zSanctionsPatch>;
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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<void>;
|
||||
runScheduleNow(): Promise<void>;
|
||||
runBuildQueueNow(): Promise<void>;
|
||||
runOperationsNow(): Promise<void>;
|
||||
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<string>();
|
||||
|
||||
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<void> {
|
||||
@@ -548,6 +559,83 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle {
|
||||
}
|
||||
}
|
||||
|
||||
async runOperationsNow(): Promise<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<GatewayAdminActionResult> {
|
||||
// 리셋 요청을 빌드+재기동 흐름으로 처리한다.
|
||||
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<void> {
|
||||
private async startProfile(profile: GatewayProfileRecord): Promise<boolean> {
|
||||
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<void> {
|
||||
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('; ')}`);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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<void>;
|
||||
updateWorkspaceUsage(profileName: string, workspace: string, lastUsedAt: string): Promise<void>;
|
||||
clearWorkspaceUsage(profileNames: string[]): Promise<void>;
|
||||
listOperations(options?: { profileName?: string; limit?: number }): Promise<GatewayOperationRecord[]>;
|
||||
getOperation(id: string): Promise<GatewayOperationRecord | null>;
|
||||
createOperation(input: GatewayOperationCreateInput): Promise<GatewayOperationRecord>;
|
||||
claimNextOperation(now: Date): Promise<GatewayOperationRecord | null>;
|
||||
completeOperation(
|
||||
id: string,
|
||||
status: Extract<GatewayOperationStatus, 'SUCCEEDED' | 'FAILED'>,
|
||||
fields?: { resolvedCommitSha?: string | null; error?: string | null }
|
||||
): Promise<GatewayOperationRecord>;
|
||||
requeueOperation(id: string, detail?: string, retryAt?: string): Promise<GatewayOperationRecord>;
|
||||
cancelOperation(id: string): Promise<boolean>;
|
||||
retryOperation(id: string, requestedBy: string): Promise<GatewayOperationRecord | null>;
|
||||
}
|
||||
|
||||
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<GatewayProfileRecord[]> {
|
||||
const rows = await prisma.gatewayProfile.findMany({
|
||||
@@ -327,4 +416,111 @@ export const createGatewayProfileRepository = (prisma: GatewayPrismaClient): Gat
|
||||
},
|
||||
});
|
||||
},
|
||||
async listOperations(options?: { profileName?: string; limit?: number }): Promise<GatewayOperationRecord[]> {
|
||||
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<GatewayOperationRecord | null> {
|
||||
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,
|
||||
},
|
||||
});
|
||||
return mapOperation(row);
|
||||
},
|
||||
async claimNextOperation(now: Date): Promise<GatewayOperationRecord | null> {
|
||||
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<GatewayOperationStatus, 'SUCCEEDED' | 'FAILED'>,
|
||||
fields?: { resolvedCommitSha?: string | null; error?: string | null }
|
||||
): Promise<GatewayOperationRecord> {
|
||||
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<GatewayOperationRecord> {
|
||||
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<boolean> {
|
||||
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<GatewayOperationRecord | null> {
|
||||
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;
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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<string> {
|
||||
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<WorkspaceInfo> {
|
||||
const workspacePath = path.join(this.worktreeRoot, commitSha);
|
||||
ensureDir(this.worktreeRoot);
|
||||
|
||||
@@ -98,6 +98,22 @@ export const resolveGitCommitSha = async (gitRef: string): Promise<string> => {
|
||||
return commit;
|
||||
};
|
||||
|
||||
export const resolveGitBranchCommitSha = async (branch: string): Promise<string> => {
|
||||
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<string> => {
|
||||
const result = await runGit(['show', `${commitSha}:${relativePath}`]);
|
||||
if (!result.ok) {
|
||||
|
||||
@@ -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' });
|
||||
});
|
||||
});
|
||||
@@ -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: [],
|
||||
|
||||
@@ -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']);
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
@@ -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<string, unknown>;
|
||||
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"');
|
||||
});
|
||||
@@ -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 .",
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
|
||||
@@ -1012,9 +1012,17 @@ onMounted(() => {
|
||||
<template>
|
||||
<DefaultLayout>
|
||||
<div class="max-w-6xl mx-auto px-4 py-10 space-y-10">
|
||||
<div class="space-y-2">
|
||||
<h2 class="text-3xl font-bold text-white">관리자 콘솔</h2>
|
||||
<p class="text-sm text-zinc-400">유저 관리와 서버 운영 제어를 위한 관리자 전용 대시보드입니다.</p>
|
||||
<div class="flex flex-col gap-4 md:flex-row md:items-end md:justify-between">
|
||||
<div class="space-y-2">
|
||||
<h2 class="text-3xl font-bold text-white">관리자 콘솔</h2>
|
||||
<p class="text-sm text-zinc-400">유저 관리와 서버 운영 제어를 위한 관리자 전용 대시보드입니다.</p>
|
||||
</div>
|
||||
<RouterLink
|
||||
to="/admin/server-operations"
|
||||
class="rounded bg-amber-500 px-4 py-2 text-center text-sm font-bold text-black hover:bg-amber-400"
|
||||
>
|
||||
서버 배포 · 시나리오 초기화
|
||||
</RouterLink>
|
||||
</div>
|
||||
|
||||
<section class="bg-zinc-900 border border-zinc-800 rounded-lg p-5 space-y-3">
|
||||
|
||||
@@ -0,0 +1,623 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue';
|
||||
|
||||
import DefaultLayout from '../layouts/DefaultLayout.vue';
|
||||
import { trpc } from '../utils/trpc';
|
||||
|
||||
const adminClient = trpc.admin;
|
||||
|
||||
type Profile = {
|
||||
profileName: string;
|
||||
profile: string;
|
||||
scenario: string;
|
||||
status: string;
|
||||
buildStatus: string;
|
||||
buildCommitSha?: string;
|
||||
buildWorkspace?: string;
|
||||
buildError?: string;
|
||||
lastError?: string;
|
||||
runtime: { apiRunning: boolean; daemonRunning: boolean };
|
||||
};
|
||||
|
||||
type Scenario = {
|
||||
id: number;
|
||||
title: string;
|
||||
year: number | null;
|
||||
npcCount: number;
|
||||
};
|
||||
|
||||
type Operation = {
|
||||
id: string;
|
||||
profileName: string;
|
||||
type: 'RESET' | 'START' | 'STOP';
|
||||
status: 'QUEUED' | 'RUNNING' | 'SUCCEEDED' | 'FAILED' | 'CANCELLED';
|
||||
sourceMode?: 'BRANCH' | 'COMMIT';
|
||||
sourceRef?: string;
|
||||
resolvedCommitSha?: string;
|
||||
reason?: string;
|
||||
requestedBy: string;
|
||||
scheduledAt?: string;
|
||||
startedAt?: string;
|
||||
completedAt?: string;
|
||||
error?: string;
|
||||
createdAt: string;
|
||||
};
|
||||
|
||||
const profiles = ref<Profile[]>([]);
|
||||
const scenarios = ref<Scenario[]>([]);
|
||||
const operations = ref<Operation[]>([]);
|
||||
const selectedProfileName = ref('');
|
||||
const loading = ref(false);
|
||||
const catalogLoading = ref(false);
|
||||
const submitting = ref(false);
|
||||
const message = ref('');
|
||||
const errorMessage = ref('');
|
||||
let pollTimer: ReturnType<typeof setInterval> | undefined;
|
||||
let stateRequestInFlight = false;
|
||||
|
||||
const form = reactive({
|
||||
sourceMode: 'BRANCH' as 'BRANCH' | 'COMMIT',
|
||||
sourceRef: 'main',
|
||||
scenarioId: 0,
|
||||
turnTermMinutes: 60,
|
||||
sync: true,
|
||||
fiction: 1,
|
||||
extend: true,
|
||||
blockGeneralCreate: 0,
|
||||
npcMode: 0,
|
||||
showImgLevel: 3,
|
||||
tournamentTrig: true,
|
||||
joinMode: 'full' as 'full' | 'onlyRandom',
|
||||
autorunEnabled: false,
|
||||
autorunUserMinutes: 1440,
|
||||
autorunDevelop: true,
|
||||
autorunWarp: true,
|
||||
autorunRecruit: true,
|
||||
autorunTrain: true,
|
||||
autorunBattle: true,
|
||||
openAt: '',
|
||||
preopenAt: '',
|
||||
scheduledAt: '',
|
||||
reason: '',
|
||||
});
|
||||
|
||||
const selectedProfile = computed(
|
||||
() => profiles.value.find((profile) => profile.profileName === selectedProfileName.value) ?? null
|
||||
);
|
||||
|
||||
const activeOperation = computed(
|
||||
() =>
|
||||
operations.value.find(
|
||||
(operation) =>
|
||||
operation.profileName === selectedProfileName.value &&
|
||||
(operation.status === 'QUEUED' || operation.status === 'RUNNING')
|
||||
) ?? null
|
||||
);
|
||||
|
||||
const sourceHelp = computed(() =>
|
||||
form.sourceMode === 'BRANCH'
|
||||
? '작업이 실제로 시작될 때 원격 브랜치를 다시 fetch하여 최신 커밋을 사용합니다.'
|
||||
: '요청 시 커밋을 전체 SHA로 고정하므로 이후 브랜치가 이동해도 결과가 바뀌지 않습니다.'
|
||||
);
|
||||
|
||||
const toIso = (value: string): string | undefined => {
|
||||
if (!value) {
|
||||
return undefined;
|
||||
}
|
||||
const parsed = new Date(value);
|
||||
return Number.isNaN(parsed.getTime()) ? undefined : parsed.toISOString();
|
||||
};
|
||||
|
||||
const formatTime = (value?: string): string => (value ? new Date(value).toLocaleString('ko-KR') : '-');
|
||||
const shortSha = (value?: string): string => (value ? value.slice(0, 12) : '-');
|
||||
|
||||
const clearStatus = () => {
|
||||
message.value = '';
|
||||
errorMessage.value = '';
|
||||
};
|
||||
|
||||
const loadState = async (quiet = false) => {
|
||||
if (stateRequestInFlight) {
|
||||
return;
|
||||
}
|
||||
stateRequestInFlight = true;
|
||||
if (!quiet) {
|
||||
loading.value = true;
|
||||
}
|
||||
try {
|
||||
const profileResult = await adminClient.profiles.list.query();
|
||||
const operationResult = await adminClient.operations.list.query({ limit: 100 });
|
||||
profiles.value = profileResult as Profile[];
|
||||
operations.value = operationResult as Operation[];
|
||||
if (!selectedProfileName.value && profiles.value.length > 0) {
|
||||
selectedProfileName.value = profiles.value[0].profileName;
|
||||
}
|
||||
} catch (error) {
|
||||
errorMessage.value = error instanceof Error ? error.message : '운영 상태를 불러오지 못했습니다.';
|
||||
} finally {
|
||||
loading.value = false;
|
||||
stateRequestInFlight = false;
|
||||
}
|
||||
};
|
||||
|
||||
const loadScenarios = async () => {
|
||||
clearStatus();
|
||||
if (!form.sourceRef.trim()) {
|
||||
errorMessage.value = '브랜치 또는 커밋을 입력해주세요.';
|
||||
return;
|
||||
}
|
||||
catalogLoading.value = true;
|
||||
try {
|
||||
const result = await adminClient.profiles.listScenarios.query({
|
||||
gitRef: form.sourceRef.trim(),
|
||||
sourceMode: form.sourceMode,
|
||||
});
|
||||
scenarios.value = result as Scenario[];
|
||||
if (!scenarios.value.some((scenario) => scenario.id === form.scenarioId)) {
|
||||
const profileScenario = Number(selectedProfile.value?.scenario);
|
||||
form.scenarioId =
|
||||
scenarios.value.find((scenario) => scenario.id === profileScenario)?.id ?? scenarios.value[0]?.id ?? 0;
|
||||
}
|
||||
message.value = `${scenarios.value.length}개 시나리오를 확인했습니다.`;
|
||||
} catch (error) {
|
||||
scenarios.value = [];
|
||||
errorMessage.value = error instanceof Error ? error.message : '소스에서 시나리오를 읽지 못했습니다.';
|
||||
} finally {
|
||||
catalogLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const requestRuntime = async (action: 'START' | 'STOP') => {
|
||||
clearStatus();
|
||||
if (!selectedProfile.value || activeOperation.value) {
|
||||
return;
|
||||
}
|
||||
const label = action === 'START' ? '시작' : '정지';
|
||||
if (!window.confirm(`${selectedProfile.value.profileName} 서버를 ${label}하시겠습니까?`)) {
|
||||
return;
|
||||
}
|
||||
submitting.value = true;
|
||||
try {
|
||||
await adminClient.operations.requestRuntime.mutate({
|
||||
profileName: selectedProfile.value.profileName,
|
||||
action,
|
||||
reason: form.reason.trim() || undefined,
|
||||
});
|
||||
message.value = `${label} 작업을 요청했습니다.`;
|
||||
await loadState(true);
|
||||
} catch (error) {
|
||||
errorMessage.value = error instanceof Error ? error.message : `${label} 요청에 실패했습니다.`;
|
||||
} finally {
|
||||
submitting.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const selectedAutorunOptions = (): Array<
|
||||
'develop' | 'warp' | 'recruit' | 'train' | 'battle'
|
||||
> => {
|
||||
const options: Array<'develop' | 'warp' | 'recruit' | 'train' | 'battle'> = [];
|
||||
if (form.autorunDevelop) options.push('develop');
|
||||
if (form.autorunWarp) options.push('warp');
|
||||
if (form.autorunRecruit) options.push('recruit');
|
||||
if (form.autorunTrain) options.push('train');
|
||||
if (form.autorunBattle) options.push('battle');
|
||||
return options;
|
||||
};
|
||||
|
||||
const requestReset = async () => {
|
||||
clearStatus();
|
||||
if (!selectedProfile.value || activeOperation.value) {
|
||||
return;
|
||||
}
|
||||
if (!form.sourceRef.trim() || !form.scenarioId) {
|
||||
errorMessage.value = '소스와 시나리오를 먼저 선택해주세요.';
|
||||
return;
|
||||
}
|
||||
const sourceLabel = form.sourceMode === 'BRANCH' ? '브랜치' : '커밋';
|
||||
if (
|
||||
!window.confirm(
|
||||
`${selectedProfile.value.profileName}의 게임 DB를 초기화합니다.\n${sourceLabel}: ${form.sourceRef}\n시나리오: ${form.scenarioId}`
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
submitting.value = true;
|
||||
try {
|
||||
await adminClient.operations.requestReset.mutate({
|
||||
profileName: selectedProfile.value.profileName,
|
||||
sourceMode: form.sourceMode,
|
||||
sourceRef: form.sourceRef.trim(),
|
||||
scheduledAt: toIso(form.scheduledAt),
|
||||
reason: form.reason.trim() || undefined,
|
||||
install: {
|
||||
scenarioId: form.scenarioId,
|
||||
turnTermMinutes: form.turnTermMinutes,
|
||||
sync: form.sync,
|
||||
fiction: form.fiction,
|
||||
extend: form.extend,
|
||||
blockGeneralCreate: form.blockGeneralCreate,
|
||||
npcMode: form.npcMode,
|
||||
showImgLevel: form.showImgLevel,
|
||||
tournamentTrig: form.tournamentTrig,
|
||||
joinMode: form.joinMode,
|
||||
autorunUser: form.autorunEnabled
|
||||
? {
|
||||
limitMinutes: form.autorunUserMinutes,
|
||||
options: selectedAutorunOptions(),
|
||||
}
|
||||
: null,
|
||||
openAt: toIso(form.openAt),
|
||||
preopenAt: toIso(form.preopenAt),
|
||||
},
|
||||
});
|
||||
message.value = form.scheduledAt ? '예약 초기화 작업을 등록했습니다.' : '초기화 작업을 시작했습니다.';
|
||||
await loadState(true);
|
||||
} catch (error) {
|
||||
errorMessage.value = error instanceof Error ? error.message : '초기화 요청에 실패했습니다.';
|
||||
} finally {
|
||||
submitting.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const cancelOperation = async (operation: Operation) => {
|
||||
clearStatus();
|
||||
if (!window.confirm('대기 중인 작업을 취소하시겠습니까?')) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await adminClient.operations.cancel.mutate({ id: operation.id });
|
||||
message.value = '작업을 취소했습니다.';
|
||||
await loadState(true);
|
||||
} catch (error) {
|
||||
errorMessage.value = error instanceof Error ? error.message : '작업 취소에 실패했습니다.';
|
||||
}
|
||||
};
|
||||
|
||||
const retryOperation = async (operation: Operation) => {
|
||||
clearStatus();
|
||||
if (!window.confirm('같은 입력으로 작업을 다시 실행하시겠습니까?')) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await adminClient.operations.retry.mutate({ id: operation.id });
|
||||
message.value = '재시도 작업을 등록했습니다.';
|
||||
await loadState(true);
|
||||
} catch (error) {
|
||||
errorMessage.value = error instanceof Error ? error.message : '작업 재시도에 실패했습니다.';
|
||||
}
|
||||
};
|
||||
|
||||
watch(selectedProfileName, () => {
|
||||
const scenarioId = Number(selectedProfile.value?.scenario);
|
||||
if (Number.isFinite(scenarioId)) {
|
||||
form.scenarioId = scenarioId;
|
||||
}
|
||||
});
|
||||
|
||||
onMounted(async () => {
|
||||
await loadState();
|
||||
await loadScenarios();
|
||||
pollTimer = setInterval(() => void loadState(true), 3000);
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (pollTimer) {
|
||||
clearInterval(pollTimer);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DefaultLayout>
|
||||
<div class="max-w-7xl mx-auto px-4 py-8 space-y-6" data-testid="server-operations-page">
|
||||
<header class="flex flex-col gap-3 md:flex-row md:items-end md:justify-between">
|
||||
<div>
|
||||
<p class="text-xs uppercase tracking-[0.25em] text-amber-400">Operations console</p>
|
||||
<h2 class="text-2xl font-bold text-white">서버 배포 · 시나리오 초기화</h2>
|
||||
<p class="mt-2 text-sm text-zinc-400">
|
||||
실행 소스, 초기화 옵션, 프로세스 상태와 작업 이력을 한 화면에서 관리합니다.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
class="rounded border border-zinc-700 bg-zinc-900 px-4 py-2 text-sm hover:border-zinc-500 disabled:opacity-50"
|
||||
:disabled="loading"
|
||||
data-testid="refresh-operations"
|
||||
@click="loadState()"
|
||||
>
|
||||
상태 새로고침
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div v-if="errorMessage" class="rounded border border-red-800 bg-red-950/50 px-4 py-3 text-sm text-red-200">
|
||||
{{ errorMessage }}
|
||||
</div>
|
||||
<div v-if="message" class="rounded border border-emerald-800 bg-emerald-950/40 px-4 py-3 text-sm text-emerald-200">
|
||||
{{ message }}
|
||||
</div>
|
||||
|
||||
<section class="grid gap-4 lg:grid-cols-[1.1fr_1.9fr]">
|
||||
<div class="rounded-lg border border-zinc-800 bg-zinc-900 p-5 space-y-4">
|
||||
<div>
|
||||
<label class="text-xs text-zinc-400" for="profile-select">운영 프로필</label>
|
||||
<select
|
||||
id="profile-select"
|
||||
v-model="selectedProfileName"
|
||||
class="mt-2 w-full rounded border border-zinc-700 bg-zinc-950 px-3 py-2 text-white"
|
||||
data-testid="profile-select"
|
||||
>
|
||||
<option v-for="profile in profiles" :key="profile.profileName" :value="profile.profileName">
|
||||
{{ profile.profileName }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div v-if="selectedProfile" class="grid grid-cols-2 gap-3 text-sm">
|
||||
<div class="rounded bg-zinc-950 p-3">
|
||||
<div class="text-xs text-zinc-500">목표 상태</div>
|
||||
<div class="mt-1 font-semibold">{{ selectedProfile.status }}</div>
|
||||
</div>
|
||||
<div class="rounded bg-zinc-950 p-3">
|
||||
<div class="text-xs text-zinc-500">빌드</div>
|
||||
<div class="mt-1 font-semibold">{{ selectedProfile.buildStatus }}</div>
|
||||
</div>
|
||||
<div class="rounded bg-zinc-950 p-3">
|
||||
<div class="text-xs text-zinc-500">Game API</div>
|
||||
<div :class="selectedProfile.runtime.apiRunning ? 'text-emerald-400' : 'text-zinc-500'">
|
||||
{{ selectedProfile.runtime.apiRunning ? 'RUNNING' : 'STOPPED' }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="rounded bg-zinc-950 p-3">
|
||||
<div class="text-xs text-zinc-500">Turn daemon</div>
|
||||
<div :class="selectedProfile.runtime.daemonRunning ? 'text-emerald-400' : 'text-zinc-500'">
|
||||
{{ selectedProfile.runtime.daemonRunning ? 'RUNNING' : 'STOPPED' }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="selectedProfile" class="space-y-1 text-xs text-zinc-500">
|
||||
<div>현재 커밋: <span class="font-mono text-zinc-300">{{ shortSha(selectedProfile.buildCommitSha) }}</span></div>
|
||||
<div class="break-all">worktree: {{ selectedProfile.buildWorkspace ?? '기본 workspace' }}</div>
|
||||
<div v-if="selectedProfile.buildError" class="text-red-400">{{ selectedProfile.buildError }}</div>
|
||||
<div v-if="selectedProfile.lastError" class="text-red-400">{{ selectedProfile.lastError }}</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
<button
|
||||
class="rounded bg-emerald-700 px-3 py-2 font-semibold text-white hover:bg-emerald-600 disabled:opacity-40"
|
||||
:disabled="submitting || Boolean(activeOperation)"
|
||||
data-testid="start-server"
|
||||
@click="requestRuntime('START')"
|
||||
>
|
||||
서버 시작
|
||||
</button>
|
||||
<button
|
||||
class="rounded bg-red-800 px-3 py-2 font-semibold text-white hover:bg-red-700 disabled:opacity-40"
|
||||
:disabled="submitting || Boolean(activeOperation)"
|
||||
data-testid="stop-server"
|
||||
@click="requestRuntime('STOP')"
|
||||
>
|
||||
서버 정지
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form class="rounded-lg border border-zinc-800 bg-zinc-900 p-5 space-y-5" @submit.prevent="requestReset">
|
||||
<div class="flex items-center justify-between">
|
||||
<h3 class="text-lg font-semibold">시나리오 초기화</h3>
|
||||
<span v-if="activeOperation" class="rounded-full bg-amber-500/15 px-3 py-1 text-xs text-amber-300">
|
||||
{{ activeOperation.type }} · {{ activeOperation.status }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<fieldset class="space-y-2">
|
||||
<legend class="text-xs text-zinc-400">소스 종류</legend>
|
||||
<div class="flex gap-5">
|
||||
<label class="flex items-center gap-2">
|
||||
<input v-model="form.sourceMode" type="radio" value="BRANCH" data-testid="source-branch" />
|
||||
브랜치
|
||||
</label>
|
||||
<label class="flex items-center gap-2">
|
||||
<input v-model="form.sourceMode" type="radio" value="COMMIT" data-testid="source-commit" />
|
||||
커밋
|
||||
</label>
|
||||
</div>
|
||||
<p class="text-xs text-amber-200/80" data-testid="source-help">{{ sourceHelp }}</p>
|
||||
</fieldset>
|
||||
|
||||
<div class="grid gap-3 md:grid-cols-[1fr_auto]">
|
||||
<input
|
||||
v-model="form.sourceRef"
|
||||
class="rounded border border-zinc-700 bg-zinc-950 px-3 py-2 font-mono text-sm text-white"
|
||||
:placeholder="form.sourceMode === 'BRANCH' ? '예: main 또는 release/season-12' : '예: 40자리 commit SHA'"
|
||||
data-testid="source-ref"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded border border-zinc-600 bg-zinc-800 px-4 py-2 text-sm hover:bg-zinc-700 disabled:opacity-50"
|
||||
:disabled="catalogLoading"
|
||||
data-testid="load-scenarios"
|
||||
@click="loadScenarios"
|
||||
>
|
||||
시나리오 확인
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-4 md:grid-cols-2">
|
||||
<label class="text-xs text-zinc-400">
|
||||
시나리오
|
||||
<select
|
||||
v-model.number="form.scenarioId"
|
||||
class="mt-1 w-full rounded border border-zinc-700 bg-zinc-950 px-3 py-2 text-sm text-white"
|
||||
data-testid="scenario-select"
|
||||
>
|
||||
<option v-for="scenario in scenarios" :key="scenario.id" :value="scenario.id">
|
||||
{{ scenario.id }} · {{ scenario.title }} (NPC {{ scenario.npcCount }})
|
||||
</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="text-xs text-zinc-400">
|
||||
턴 간격
|
||||
<select
|
||||
v-model.number="form.turnTermMinutes"
|
||||
class="mt-1 w-full rounded border border-zinc-700 bg-zinc-950 px-3 py-2 text-sm text-white"
|
||||
>
|
||||
<option v-for="minutes in [1, 2, 5, 10, 20, 30, 60, 120]" :key="minutes" :value="minutes">
|
||||
{{ minutes }}분
|
||||
</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<details class="rounded border border-zinc-800 bg-zinc-950/50 p-4">
|
||||
<summary class="cursor-pointer text-sm font-semibold">고급 시나리오 옵션</summary>
|
||||
<div class="mt-4 grid gap-4 md:grid-cols-2 text-sm">
|
||||
<label>동기화
|
||||
<select v-model="form.sync" class="ml-2 rounded bg-zinc-900 px-2 py-1">
|
||||
<option :value="true">사용</option><option :value="false">미사용</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>가상 장수
|
||||
<select v-model.number="form.fiction" class="ml-2 rounded bg-zinc-900 px-2 py-1">
|
||||
<option :value="1">허용</option><option :value="0">금지</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>연장
|
||||
<select v-model="form.extend" class="ml-2 rounded bg-zinc-900 px-2 py-1">
|
||||
<option :value="true">사용</option><option :value="false">미사용</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>가입 방식
|
||||
<select v-model="form.joinMode" class="ml-2 rounded bg-zinc-900 px-2 py-1">
|
||||
<option value="full">전체</option><option value="onlyRandom">랜덤만</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>장수 생성 제한
|
||||
<select v-model.number="form.blockGeneralCreate" class="ml-2 rounded bg-zinc-900 px-2 py-1">
|
||||
<option :value="0">없음</option><option :value="1">제한</option><option :value="2">차단</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>NPC 모드
|
||||
<select v-model.number="form.npcMode" class="ml-2 rounded bg-zinc-900 px-2 py-1">
|
||||
<option :value="0">기본</option><option :value="1">확장</option><option :value="2">전체</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>이미지 표시
|
||||
<select v-model.number="form.showImgLevel" class="ml-2 rounded bg-zinc-900 px-2 py-1">
|
||||
<option v-for="level in [0, 1, 2, 3]" :key="level" :value="level">{{ level }}</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="flex items-center gap-2">
|
||||
<input v-model="form.tournamentTrig" type="checkbox" /> 토너먼트 사용
|
||||
</label>
|
||||
<label class="flex items-center gap-2">
|
||||
<input v-model="form.autorunEnabled" type="checkbox" /> 유저 자동턴
|
||||
</label>
|
||||
<input
|
||||
v-if="form.autorunEnabled"
|
||||
v-model.number="form.autorunUserMinutes"
|
||||
type="number"
|
||||
min="1"
|
||||
max="43200"
|
||||
class="rounded border border-zinc-700 bg-zinc-900 px-2 py-1"
|
||||
aria-label="자동턴 제한 분"
|
||||
/>
|
||||
<div v-if="form.autorunEnabled" class="md:col-span-2 flex flex-wrap gap-3 text-xs">
|
||||
<label><input v-model="form.autorunDevelop" type="checkbox" /> 내정</label>
|
||||
<label><input v-model="form.autorunWarp" type="checkbox" /> 이동</label>
|
||||
<label><input v-model="form.autorunRecruit" type="checkbox" /> 징병</label>
|
||||
<label><input v-model="form.autorunTrain" type="checkbox" /> 훈련</label>
|
||||
<label><input v-model="form.autorunBattle" type="checkbox" /> 전투</label>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<div class="grid gap-4 md:grid-cols-3">
|
||||
<label class="text-xs text-zinc-400">작업 예약
|
||||
<input v-model="form.scheduledAt" type="datetime-local" class="mt-1 w-full rounded border border-zinc-700 bg-zinc-950 px-3 py-2 text-white" />
|
||||
</label>
|
||||
<label class="text-xs text-zinc-400">가오픈
|
||||
<input v-model="form.preopenAt" type="datetime-local" class="mt-1 w-full rounded border border-zinc-700 bg-zinc-950 px-3 py-2 text-white" />
|
||||
</label>
|
||||
<label class="text-xs text-zinc-400">정식 오픈
|
||||
<input v-model="form.openAt" type="datetime-local" class="mt-1 w-full rounded border border-zinc-700 bg-zinc-950 px-3 py-2 text-white" />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<input
|
||||
v-model="form.reason"
|
||||
class="w-full rounded border border-zinc-700 bg-zinc-950 px-3 py-2 text-sm text-white"
|
||||
placeholder="작업 사유 또는 운영 메모"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
class="w-full rounded bg-amber-500 px-4 py-3 font-bold text-black hover:bg-amber-400 disabled:cursor-not-allowed disabled:opacity-40"
|
||||
:disabled="submitting || Boolean(activeOperation) || !form.scenarioId"
|
||||
data-testid="request-reset"
|
||||
>
|
||||
{{ form.scheduledAt ? '초기화 예약' : '초기화 시작' }}
|
||||
</button>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section class="rounded-lg border border-zinc-800 bg-zinc-900 p-5">
|
||||
<div class="mb-4 flex items-center justify-between">
|
||||
<h3 class="text-lg font-semibold">작업 이력</h3>
|
||||
<span class="text-xs text-zinc-500">3초마다 상태 갱신</span>
|
||||
</div>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full min-w-[920px] text-left text-sm" data-testid="operations-table">
|
||||
<thead class="border-b border-zinc-700 text-xs text-zinc-500">
|
||||
<tr>
|
||||
<th class="p-2">요청/예약</th><th class="p-2">프로필</th><th class="p-2">작업</th>
|
||||
<th class="p-2">상태</th><th class="p-2">소스</th><th class="p-2">해석 커밋</th>
|
||||
<th class="p-2">요청자/사유</th><th class="p-2">완료/오류</th><th class="p-2"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="operation in operations" :key="operation.id" class="border-b border-zinc-800 align-top">
|
||||
<td class="p-2 text-xs">
|
||||
{{ formatTime(operation.createdAt) }}
|
||||
<div v-if="operation.scheduledAt" class="mt-1 text-amber-300">
|
||||
예약 {{ formatTime(operation.scheduledAt) }}
|
||||
</div>
|
||||
</td>
|
||||
<td class="p-2">{{ operation.profileName }}</td>
|
||||
<td class="p-2">{{ operation.type }}</td>
|
||||
<td class="p-2 font-semibold">{{ operation.status }}</td>
|
||||
<td class="p-2 font-mono text-xs">{{ operation.sourceMode ?? '-' }}<br />{{ operation.sourceRef ?? '' }}</td>
|
||||
<td class="p-2 font-mono text-xs">{{ shortSha(operation.resolvedCommitSha) }}</td>
|
||||
<td class="max-w-xs p-2 text-xs">
|
||||
<div class="font-mono">{{ operation.requestedBy }}</div>
|
||||
<div v-if="operation.reason" class="mt-1 text-zinc-400">{{ operation.reason }}</div>
|
||||
</td>
|
||||
<td class="max-w-xs p-2 text-xs">
|
||||
{{ formatTime(operation.completedAt) }}
|
||||
<div v-if="operation.error" class="mt-1 text-red-400">{{ operation.error }}</div>
|
||||
</td>
|
||||
<td class="p-2">
|
||||
<button
|
||||
v-if="operation.status === 'QUEUED'"
|
||||
class="rounded border border-red-800 px-2 py-1 text-xs text-red-300 hover:bg-red-950"
|
||||
@click="cancelOperation(operation)"
|
||||
>
|
||||
취소
|
||||
</button>
|
||||
<button
|
||||
v-else-if="operation.status === 'FAILED' || operation.status === 'CANCELLED'"
|
||||
class="rounded border border-amber-700 px-2 py-1 text-xs text-amber-300 hover:bg-amber-950"
|
||||
@click="retryOperation(operation)"
|
||||
>
|
||||
재시도
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="operations.length === 0">
|
||||
<td colspan="9" class="p-6 text-center text-zinc-500">작업 이력이 없습니다.</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</DefaultLayout>
|
||||
</template>
|
||||
@@ -68,11 +68,12 @@
|
||||
"src/**/*.d.ts",
|
||||
"src/**/*.tsx",
|
||||
"src/**/*.vue",
|
||||
"test/**/*.ts"
|
||||
"test/**/*.ts",
|
||||
"e2e/**/*.ts"
|
||||
],
|
||||
"references": [
|
||||
{
|
||||
"path": "./tsconfig.node.json"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -80,6 +80,23 @@ Gateway runs a lightweight cron loop (setInterval) that:
|
||||
|
||||
### Build Workflow (Admin)
|
||||
|
||||
- 관리자는 gateway의 `/admin/server-operations` 전용 화면에서 profile별
|
||||
runtime, build, worktree, 오류와 작업 이력을 확인하고 시작·정지·초기화를
|
||||
요청한다.
|
||||
- 운영 요청은 `gateway_operation` 행으로 기록된다. 같은 profile에는
|
||||
`QUEUED` 또는 `RUNNING` 작업이 하나만 존재할 수 있으며, 상태는
|
||||
`QUEUED -> RUNNING -> SUCCEEDED|FAILED` 또는 실행 전 `CANCELLED`로
|
||||
전이된다. 실패·취소 작업은 같은 입력으로 재시도할 수 있다.
|
||||
- `BRANCH` 소스는 요청 시 유효성을 검사하지만 branch 이름을 보존하고,
|
||||
실행 직전에 원격을 다시 fetch하여 최신 commit을 확정한다. `COMMIT`
|
||||
소스는 요청 시 full SHA로 정규화하여 이후 branch 이동과 무관하게
|
||||
동일 commit을 사용한다. 실제 사용한 SHA는 작업의
|
||||
`resolvedCommitSha`와 profile의 `buildCommitSha`에 기록한다.
|
||||
- 초기화 작업은 profile 프로세스를 정지한 뒤 선택 commit worktree에서
|
||||
build하고, 같은 worktree의 scenario/map/unitset으로 profile DB를 seed한
|
||||
후 `PREOPEN` 또는 `RUNNING`으로 전환하여 API와 daemon을 함께 시작한다.
|
||||
대기 중 작업은 취소할 수 있으나 DB 변경이 시작된 `RUNNING` 작업은
|
||||
중간 취소하지 않는다.
|
||||
- Admin triggers a build request for a profile.
|
||||
- Gateway queues a build job with `(profileName, commitSha)` and prepares a
|
||||
per-commit workspace (`/.worktrees/{commitSha}` by default).
|
||||
@@ -96,6 +113,11 @@ Gateway runs a lightweight cron loop (setInterval) that:
|
||||
worktrees. A profile without `buildWorkspace` intentionally falls back to the
|
||||
main workspace; this is the `hwe`/main compatibility path.
|
||||
|
||||
`GatewayProfile.meta.adminActions` polling is retained only for requests
|
||||
created by the older admin page. New operations must use `gateway_operation`
|
||||
so concurrency, audit history, cancellation, retry, source intent, and the
|
||||
resolved commit remain first-class data.
|
||||
|
||||
## Current Implementation Status
|
||||
|
||||
- Turn daemon lifecycle + in-memory state live in `app/game-engine` with DB flush hooks.
|
||||
|
||||
@@ -31,6 +31,25 @@ enum GatewayBuildStatus {
|
||||
SUCCEEDED
|
||||
}
|
||||
|
||||
enum GatewayOperationType {
|
||||
RESET
|
||||
START
|
||||
STOP
|
||||
}
|
||||
|
||||
enum GatewayOperationStatus {
|
||||
QUEUED
|
||||
RUNNING
|
||||
SUCCEEDED
|
||||
FAILED
|
||||
CANCELLED
|
||||
}
|
||||
|
||||
enum GatewaySourceMode {
|
||||
BRANCH
|
||||
COMMIT
|
||||
}
|
||||
|
||||
model AppUser {
|
||||
id String @id @default(uuid())
|
||||
loginId String @unique @map("login_id")
|
||||
@@ -71,11 +90,36 @@ model GatewayProfile {
|
||||
meta Json @default(dbgenerated("'{}'::jsonb"))
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
operations GatewayOperation[]
|
||||
|
||||
@@unique([profile, scenario])
|
||||
@@map("gateway_profile")
|
||||
}
|
||||
|
||||
model GatewayOperation {
|
||||
id String @id @default(uuid())
|
||||
profileName String @map("profile_name")
|
||||
profile GatewayProfile @relation(fields: [profileName], references: [profileName], onDelete: Cascade)
|
||||
type GatewayOperationType
|
||||
status GatewayOperationStatus @default(QUEUED)
|
||||
sourceMode GatewaySourceMode? @map("source_mode")
|
||||
sourceRef String? @map("source_ref")
|
||||
resolvedCommitSha String? @map("resolved_commit_sha")
|
||||
payload Json @default(dbgenerated("'{}'::jsonb"))
|
||||
reason String?
|
||||
requestedBy String @map("requested_by")
|
||||
scheduledAt DateTime? @map("scheduled_at")
|
||||
startedAt DateTime? @map("started_at")
|
||||
completedAt DateTime? @map("completed_at")
|
||||
error String?
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
@@index([status, scheduledAt, createdAt])
|
||||
@@index([profileName, createdAt])
|
||||
@@map("gateway_operation")
|
||||
}
|
||||
|
||||
model SystemSetting {
|
||||
id Int @id @default(1) @map("no")
|
||||
notice String @default("") @map("notice")
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
CREATE TYPE "GatewayOperationType" AS ENUM ('RESET', 'START', 'STOP');
|
||||
CREATE TYPE "GatewayOperationStatus" AS ENUM ('QUEUED', 'RUNNING', 'SUCCEEDED', 'FAILED', 'CANCELLED');
|
||||
CREATE TYPE "GatewaySourceMode" AS ENUM ('BRANCH', 'COMMIT');
|
||||
|
||||
CREATE TABLE "gateway_operation" (
|
||||
"id" TEXT NOT NULL,
|
||||
"profile_name" TEXT NOT NULL,
|
||||
"type" "GatewayOperationType" NOT NULL,
|
||||
"status" "GatewayOperationStatus" NOT NULL DEFAULT 'QUEUED',
|
||||
"source_mode" "GatewaySourceMode",
|
||||
"source_ref" TEXT,
|
||||
"resolved_commit_sha" TEXT,
|
||||
"payload" JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
"reason" TEXT,
|
||||
"requested_by" TEXT NOT NULL,
|
||||
"scheduled_at" TIMESTAMP(3),
|
||||
"started_at" TIMESTAMP(3),
|
||||
"completed_at" TIMESTAMP(3),
|
||||
"error" TEXT,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "gateway_operation_pkey" PRIMARY KEY ("id"),
|
||||
CONSTRAINT "gateway_operation_profile_name_fkey"
|
||||
FOREIGN KEY ("profile_name") REFERENCES "gateway_profile"("profile_name")
|
||||
ON DELETE CASCADE ON UPDATE CASCADE
|
||||
);
|
||||
|
||||
CREATE INDEX "gateway_operation_status_scheduled_at_created_at_idx"
|
||||
ON "gateway_operation"("status", "scheduled_at", "created_at");
|
||||
CREATE INDEX "gateway_operation_profile_name_created_at_idx"
|
||||
ON "gateway_operation"("profile_name", "created_at");
|
||||
CREATE UNIQUE INDEX "gateway_operation_one_active_per_profile_idx"
|
||||
ON "gateway_operation"("profile_name")
|
||||
WHERE "status" IN ('QUEUED', 'RUNNING');
|
||||
Reference in New Issue
Block a user