feat: eslint 적용 및 관련 코드 일괄 수정

This commit is contained in:
2026-01-05 15:46:47 +00:00
parent cb312f02b3
commit c965b1120f
387 changed files with 39808 additions and 38800 deletions
+42 -118
View File
@@ -7,10 +7,7 @@ import { procedure, router } from './trpc.js';
import type { UserSanctions, UserServerRestriction } from './auth/userRepository.js';
import type { AdminAuthContext } from './adminAuth.js';
import type { GatewayApiContext } from './context.js';
import {
GATEWAY_BUILD_STATUSES,
GATEWAY_PROFILE_STATUSES,
} from './orchestrator/profileRepository.js';
import { GATEWAY_BUILD_STATUSES, GATEWAY_PROFILE_STATUSES } from './orchestrator/profileRepository.js';
const zProfileStatus = z.enum(GATEWAY_PROFILE_STATUSES);
const zBuildStatus = z.enum(GATEWAY_BUILD_STATUSES);
@@ -37,12 +34,9 @@ const ROLE_RESET_SCHEDULE = 'admin.reset.schedule';
const ROLE_RESUME_WHEN_STOPPED = 'admin.resume.when-stopped';
const ROLE_SURVEY_OPEN = 'admin.survey.open';
const readSessionToken = (
headers: Record<string, string | string[] | undefined>
): string | null => {
const provided =
headers['x-session-token'] ?? headers['authorization'] ?? '';
const raw = Array.isArray(provided) ? provided[0] ?? '' : (provided as string);
const readSessionToken = (headers: Record<string, string | string[] | undefined>): string | null => {
const provided = headers['x-session-token'] ?? headers['authorization'] ?? '';
const raw = Array.isArray(provided) ? (provided[0] ?? '') : (provided as string);
const token = raw.startsWith('Bearer ') ? raw.slice(7) : raw;
const trimmed = token.trim();
return trimmed ? trimmed : null;
@@ -83,9 +77,7 @@ const resolveAdminAuth = async (ctx: GatewayApiContext): Promise<AdminAuthContex
roles.includes(ROLE_SUPERUSER) ||
roles.includes(ADMIN_ROLE_SUPERUSER) ||
(await isFirstUser(ctx, session.userId));
const hasAdminRole =
isSuperuser ||
roles.some((role) => role === 'admin' || role.startsWith(ADMIN_ROLE_PREFIX));
const hasAdminRole = isSuperuser || roles.some((role) => role === 'admin' || role.startsWith(ADMIN_ROLE_PREFIX));
if (!hasAdminRole) {
throw new TRPCError({
code: 'FORBIDDEN',
@@ -110,11 +102,7 @@ const requireAdminAuth = (ctx: { adminAuth?: AdminAuthContext }): AdminAuthConte
return ctx.adminAuth;
};
const roleMatchesScope = (
role: string,
permission: string,
profileName?: string
): boolean => {
const roleMatchesScope = (role: string, permission: string, profileName?: string): boolean => {
if (role === permission || role === `${permission}:*`) {
return true;
}
@@ -124,24 +112,14 @@ const roleMatchesScope = (
return false;
};
const hasScopedPermission = (
adminAuth: AdminAuthContext,
permission: string,
profileName?: string
): boolean => {
const hasScopedPermission = (adminAuth: AdminAuthContext, permission: string, profileName?: string): boolean => {
if (adminAuth.isSuperuser) {
return true;
}
return adminAuth.roles.some((role: string) =>
roleMatchesScope(role, permission, profileName)
);
return adminAuth.roles.some((role: string) => roleMatchesScope(role, permission, profileName));
};
const assertPermission = (
adminAuth: AdminAuthContext,
permission: string,
profileName?: string
): void => {
const assertPermission = (adminAuth: AdminAuthContext, permission: string, profileName?: string): void => {
if (hasScopedPermission(adminAuth, permission, profileName)) {
return;
}
@@ -210,15 +188,9 @@ const zSanctionsPatch = z.object({
type SanctionsPatch = z.infer<typeof zSanctionsPatch>;
// 제재 패치 입력을 현재 제재 상태에 병합한다.
const applySanctionsPatch = (
current: UserSanctions,
patch: SanctionsPatch
): UserSanctions => {
const applySanctionsPatch = (current: UserSanctions, patch: SanctionsPatch): UserSanctions => {
const next: UserSanctions = { ...current };
const applyField = <K extends keyof UserSanctions>(
key: K,
value: UserSanctions[K] | null | undefined
): void => {
const applyField = <K extends keyof UserSanctions>(key: K, value: UserSanctions[K] | null | undefined): void => {
if (value === undefined) {
return;
}
@@ -242,9 +214,7 @@ const applySanctionsPatch = (
delete next.serverRestrictions;
} else {
const existing = { ...(next.serverRestrictions ?? {}) };
for (const [profile, restriction] of Object.entries(
patch.serverRestrictions
)) {
for (const [profile, restriction] of Object.entries(patch.serverRestrictions)) {
if (!restriction) {
delete existing[profile];
} else {
@@ -331,14 +301,13 @@ export const adminRouter = router({
}),
users: router({
lookup: userAdminProcedure.input(zUserLookupInput).query(async ({ ctx, input }) => {
const user =
input.id
? await ctx.users.findById(input.id)
: input.username
? await ctx.users.findByUsername(input.username)
: input.email
? await ctx.users.findByEmail(input.email)
: null;
const user = input.id
? await ctx.users.findById(input.id)
: input.username
? await ctx.users.findByUsername(input.username)
: input.email
? await ctx.users.findByEmail(input.email)
: null;
if (!user) {
return null;
}
@@ -364,10 +333,7 @@ export const adminRouter = router({
.mutation(async ({ ctx, input }) => {
const password = input.newPassword ?? buildAdminPassword();
await ctx.users.updatePassword(input.userId, password);
await ctx.flushPublisher.publishUserFlush(
input.userId,
'admin-password-reset'
);
await ctx.flushPublisher.publishUserFlush(input.userId, 'admin-password-reset');
return { password };
}),
updateRoles: userAdminProcedure
@@ -404,10 +370,7 @@ export const adminRouter = router({
}
const nextRoles = Array.from(roles);
await ctx.users.updateRoles(input.userId, nextRoles);
await ctx.flushPublisher.publishUserFlush(
input.userId,
'admin-roles-updated'
);
await ctx.flushPublisher.publishUserFlush(input.userId, 'admin-roles-updated');
return { roles: nextRoles };
}),
updateSanctions: userAdminProcedure
@@ -427,10 +390,7 @@ export const adminRouter = router({
}
const next = applySanctionsPatch(user.sanctions, input.patch);
await ctx.users.updateSanctions(input.userId, next);
await ctx.flushPublisher.publishUserFlush(
input.userId,
'admin-sanctions-updated'
);
await ctx.flushPublisher.publishUserFlush(input.userId, 'admin-sanctions-updated');
return { sanctions: next };
}),
setServerRestriction: userAdminProcedure
@@ -456,10 +416,7 @@ export const adminRouter = router({
};
const next = applySanctionsPatch(user.sanctions, patch);
await ctx.users.updateSanctions(input.userId, next);
await ctx.flushPublisher.publishUserFlush(
input.userId,
'admin-server-restriction'
);
await ctx.flushPublisher.publishUserFlush(input.userId, 'admin-server-restriction');
return { sanctions: next };
}),
resetProfileIcon: userAdminProcedure
@@ -480,10 +437,7 @@ export const adminRouter = router({
profileIconResetAt: new Date().toISOString(),
});
await ctx.users.updateSanctions(input.userId, next);
await ctx.flushPublisher.publishUserFlush(
input.userId,
'admin-profile-icon-reset'
);
await ctx.flushPublisher.publishUserFlush(input.userId, 'admin-profile-icon-reset');
return { profileIconResetAt: next.profileIconResetAt };
}),
forceDelete: userAdminProcedure
@@ -493,10 +447,7 @@ export const adminRouter = router({
})
)
.mutation(async ({ ctx, input }) => {
await ctx.flushPublisher.publishUserFlush(
input.userId,
'admin-force-withdraw'
);
await ctx.flushPublisher.publishUserFlush(input.userId, 'admin-force-withdraw');
await ctx.users.deleteUser(input.userId);
return { ok: true };
}),
@@ -507,9 +458,7 @@ export const adminRouter = router({
const runtimeStates = await ctx.orchestrator.listRuntimeStates(
profiles.map((profile) => profile.profileName)
);
const runtimeMap = new Map(
runtimeStates.map((state) => [state.profileName, state])
);
const runtimeMap = new Map(runtimeStates.map((state) => [state.profileName, state]));
return profiles.map((profile) => ({
...profile,
runtime: runtimeMap.get(profile.profileName) ?? {
@@ -563,16 +512,11 @@ export const adminRouter = router({
message: 'preopenAt and openAt are required for RESERVED status.',
});
}
const result = await ctx.profiles.updateStatus(
input.profileName,
input.status,
{
preopenAt: input.preopenAt,
openAt: input.openAt,
scheduledStartAt:
input.status === 'RESERVED' ? input.scheduledStartAt : null,
}
);
const result = await ctx.profiles.updateStatus(input.profileName, input.status, {
preopenAt: input.preopenAt,
openAt: input.openAt,
scheduledStartAt: input.status === 'RESERVED' ? input.scheduledStartAt : null,
});
if (input.buildCommitSha) {
await ctx.profiles.updateBuildStatus(input.profileName, 'IDLE', {
commitSha: input.buildCommitSha,
@@ -617,10 +561,7 @@ export const adminRouter = router({
)
.mutation(async ({ ctx, input }) => {
const adminAuth = requireAdminAuth(ctx);
if (
(input.action === 'ACCELERATE' || input.action === 'DELAY') &&
!input.durationMinutes
) {
if ((input.action === 'ACCELERATE' || input.action === 'DELAY') && !input.durationMinutes) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'durationMinutes is required for acceleration or delay.',
@@ -640,24 +581,13 @@ export const adminRouter = router({
});
}
const canManageProfiles = hasScopedPermission(
adminAuth,
ROLE_ADMIN_PROFILES,
profile.profileName
);
const canManageProfiles = hasScopedPermission(adminAuth, ROLE_ADMIN_PROFILES, profile.profileName);
const canResume =
canManageProfiles ||
hasScopedPermission(
adminAuth,
ROLE_RESUME_WHEN_STOPPED,
profile.profileName
);
canManageProfiles || hasScopedPermission(adminAuth, ROLE_RESUME_WHEN_STOPPED, profile.profileName);
const canResetSchedule =
canManageProfiles ||
hasScopedPermission(adminAuth, ROLE_RESET_SCHEDULE, profile.profileName);
canManageProfiles || hasScopedPermission(adminAuth, ROLE_RESET_SCHEDULE, profile.profileName);
const canOpenSurvey =
canManageProfiles ||
hasScopedPermission(adminAuth, ROLE_SURVEY_OPEN, profile.profileName);
canManageProfiles || hasScopedPermission(adminAuth, ROLE_SURVEY_OPEN, profile.profileName);
if (input.action === 'RESUME') {
if (profile.status !== 'STOPPED') {
@@ -739,15 +669,11 @@ export const adminRouter = router({
)
.mutation(async ({ ctx, input }) => {
const requestedAt = new Date().toISOString();
const result = await ctx.profiles.updateBuildStatus(
input.profileName,
'QUEUED',
{
requestedAt,
error: null,
commitSha: input.commitSha,
}
);
const result = await ctx.profiles.updateBuildStatus(input.profileName, 'QUEUED', {
requestedAt,
error: null,
commitSha: input.commitSha,
});
return result;
}),
setBuildStatus: profileAdminProcedure
@@ -757,9 +683,7 @@ export const adminRouter = router({
status: zBuildStatus,
})
)
.mutation(async ({ ctx, input }) =>
ctx.profiles.updateBuildStatus(input.profileName, input.status)
),
.mutation(async ({ ctx, input }) => ctx.profiles.updateBuildStatus(input.profileName, input.status)),
reconcileNow: profileAdminProcedure.mutation(async ({ ctx }) => {
await ctx.orchestrator.reconcileNow();
return { ok: true };
@@ -82,10 +82,7 @@ export class InMemoryGatewaySessionService implements GatewaySessionService {
this.sessions.delete(sessionToken);
}
async createGameSession(
sessionToken: string,
profile: string
): Promise<GameSessionInfo | null> {
async createGameSession(sessionToken: string, profile: string): Promise<GameSessionInfo | null> {
const session = await this.getSession(sessionToken);
if (!session) {
return null;
@@ -4,9 +4,7 @@ import { createSimplePasswordHasher, type PasswordHasher } from './passwordHashe
import type { CreateUserInput, UserRecord, UserRepository } from './userRepository.js';
// 유저 데이터 저장소를 메모리로 대체한 임시 구현.
export const createInMemoryUserRepository = (
hasher: PasswordHasher = createSimplePasswordHasher()
): UserRepository => {
export const createInMemoryUserRepository = (hasher: PasswordHasher = createSimplePasswordHasher()): UserRepository => {
const usersByName = new Map<string, UserRecord>();
const usersByOauthId = new Map<string, UserRecord>();
const usersByEmail = new Map<string, UserRecord>();
+3 -9
View File
@@ -38,9 +38,7 @@ const parseToken = (payload: Record<string, unknown>): KakaoOAuthToken => {
accessToken: String(payload.access_token ?? ''),
refreshToken: payload.refresh_token ? String(payload.refresh_token) : undefined,
accessTokenExpiresIn: Number(payload.expires_in ?? 0),
refreshTokenExpiresIn: payload.refresh_token_expires_in
? Number(payload.refresh_token_expires_in)
: undefined,
refreshTokenExpiresIn: payload.refresh_token_expires_in ? Number(payload.refresh_token_expires_in) : undefined,
};
};
@@ -141,12 +139,8 @@ export class KakaoOAuthClient {
kakaoAccount: {
hasEmail: Boolean(kakaoAccount.has_email ?? false),
email: kakaoAccount.email ? String(kakaoAccount.email) : undefined,
isEmailValid: kakaoAccount.is_email_valid
? Boolean(kakaoAccount.is_email_valid)
: undefined,
isEmailVerified: kakaoAccount.is_email_verified
? Boolean(kakaoAccount.is_email_verified)
: undefined,
isEmailValid: kakaoAccount.is_email_valid ? Boolean(kakaoAccount.is_email_valid) : undefined,
isEmailVerified: kakaoAccount.is_email_verified ? Boolean(kakaoAccount.is_email_verified) : undefined,
},
};
}
+1 -2
View File
@@ -8,6 +8,5 @@ export interface PasswordHasher {
// 비밀번호 해싱은 임시 구현이므로 이후 안전한 KDF로 교체한다.
export const createSimplePasswordHasher = (): PasswordHasher => ({
createSalt: () => randomBytes(16).toString('hex'),
hash: (password: string, salt: string) =>
createHash('sha256').update(`${salt}:${password}`).digest('hex'),
hash: (password: string, salt: string) => createHash('sha256').update(`${salt}:${password}`).digest('hex'),
});
@@ -1,13 +1,7 @@
import { GatewayPrisma, type GatewayPrismaClient } from '@sammo-ts/infra';
import { createSimplePasswordHasher, type PasswordHasher } from './passwordHasher.js';
import type {
CreateUserInput,
UserOAuthInfo,
UserRecord,
UserRepository,
UserSanctions,
} from './userRepository.js';
import type { CreateUserInput, UserOAuthInfo, UserRecord, UserRepository, UserSanctions } from './userRepository.js';
const readStringArray = (value: unknown): string[] => {
if (!Array.isArray(value)) {
+1 -2
View File
@@ -7,6 +7,5 @@ export interface GatewayRedisKeyBuilder {
export const createGatewayRedisKeyBuilder = (prefix: string): GatewayRedisKeyBuilder => ({
sessionKey: (sessionToken: string) => `${prefix}:session:${sessionToken}`,
sessionGameSetKey: (sessionToken: string) => `${prefix}:session-games:${sessionToken}`,
gameSessionKey: (profile: string, gameToken: string) =>
`${prefix}:game-session:${profile}:${gameToken}`,
gameSessionKey: (profile: string, gameToken: string) => `${prefix}:game-session:${profile}:${gameToken}`,
});
@@ -101,10 +101,7 @@ export class RedisGatewaySessionService implements GatewaySessionService {
await this.client.del(key);
}
async createGameSession(
sessionToken: string,
profile: string
): Promise<GameSessionInfo | null> {
async createGameSession(sessionToken: string, profile: string): Promise<GameSessionInfo | null> {
const session = await this.getSession(sessionToken);
if (!session) {
return null;
+5 -17
View File
@@ -69,9 +69,7 @@ const resolveSchemaName = (value: string | undefined): string => {
return trimmed ? trimmed : 'public';
};
export const resolveGatewayApiConfigFromEnv = (
env: NodeJS.ProcessEnv = process.env
): GatewayApiConfig => {
export const resolveGatewayApiConfigFromEnv = (env: NodeJS.ProcessEnv = process.env): GatewayApiConfig => {
const secret = env.GAME_TOKEN_SECRET ?? env.GATEWAY_TOKEN_SECRET ?? '';
if (!secret) {
throw new Error('GAME_TOKEN_SECRET is required for gateway token encryption.');
@@ -91,17 +89,9 @@ export const resolveGatewayApiConfigFromEnv = (
redisKeyPrefix,
flushChannel: `${redisKeyPrefix}:flush`,
sessionTtlSeconds: parseNumber(env.SESSION_TTL_SECONDS, 60 * 60 * 24 * 7, 'SESSION_TTL_SECONDS'),
gameSessionTtlSeconds: parseNumber(
env.GAME_SESSION_TTL_SECONDS,
60 * 60 * 6,
'GAME_SESSION_TTL_SECONDS'
),
gameSessionTtlSeconds: parseNumber(env.GAME_SESSION_TTL_SECONDS, 60 * 60 * 6, 'GAME_SESSION_TTL_SECONDS'),
gameTokenSecret: secret,
oauthSessionTtlSeconds: parseNumber(
env.OAUTH_SESSION_TTL_SECONDS,
10 * 60,
'OAUTH_SESSION_TTL_SECONDS'
),
oauthSessionTtlSeconds: parseNumber(env.OAUTH_SESSION_TTL_SECONDS, 10 * 60, 'OAUTH_SESSION_TTL_SECONDS'),
kakaoRestKey,
kakaoAdminKey: env.KAKAO_ADMIN_KEY,
kakaoRedirectUri,
@@ -129,8 +119,7 @@ export const resolveGatewayApiConfigFromEnv = (
),
workspaceRootHint: env.GATEWAY_WORKSPACE_ROOT ?? process.cwd(),
worktreeRoot:
env.GATEWAY_WORKTREE_ROOT ??
path.resolve(env.GATEWAY_WORKSPACE_ROOT ?? process.cwd(), '.worktrees'),
env.GATEWAY_WORKTREE_ROOT ?? path.resolve(env.GATEWAY_WORKSPACE_ROOT ?? process.cwd(), '.worktrees'),
};
};
@@ -168,7 +157,6 @@ export const resolveGatewayOrchestratorConfigFromEnv = (
),
workspaceRootHint: env.GATEWAY_WORKSPACE_ROOT ?? process.cwd(),
worktreeRoot:
env.GATEWAY_WORKTREE_ROOT ??
path.resolve(env.GATEWAY_WORKSPACE_ROOT ?? process.cwd(), '.worktrees'),
env.GATEWAY_WORKTREE_ROOT ?? path.resolve(env.GATEWAY_WORKSPACE_ROOT ?? process.cwd(), '.worktrees'),
};
};
@@ -61,12 +61,8 @@ export class RepositoryProfileStatusService implements GatewayProfileStatusServi
async listLobbyProfiles(): Promise<LobbyProfileStatus[]> {
const rows = await this.profiles.listProfiles();
const runtimeStates = await this.orchestrator.listRuntimeStates(
rows.map((profile) => profile.profileName)
);
const runtimeMap = new Map(
runtimeStates.map((state) => [state.profileName, state])
);
const runtimeStates = await this.orchestrator.listRuntimeStates(rows.map((profile) => profile.profileName));
const runtimeMap = new Map(runtimeStates.map((state) => [state.profileName, state]));
return rows.map((row) => this.mapProfile(row, runtimeMap));
}
@@ -5,11 +5,7 @@ import { createGamePostgresConnector, resolvePostgresConfigFromEnv } from '@samm
import type { BuildRunner } from './buildRunner.js';
import type { ProcessManager } from './processManager.js';
import type {
GatewayProfileRecord,
GatewayProfileRepository,
GatewayProfileStatus,
} from './profileRepository.js';
import type { GatewayProfileRecord, GatewayProfileRepository, GatewayProfileStatus } from './profileRepository.js';
import type { GitWorkspaceManager } from './workspaceManager.js';
export interface GatewayProcessConfig {
@@ -58,12 +54,7 @@ export const planProfileReconcile = (
status: GatewayProfileStatus,
runtime: ProfileRuntimeState
): { shouldStart: boolean; shouldStop: boolean } => {
if (
status === 'RUNNING' ||
status === 'PREOPEN' ||
status === 'PAUSED' ||
status === 'COMPLETED'
) {
if (status === 'RUNNING' || status === 'PREOPEN' || status === 'PAUSED' || status === 'COMPLETED') {
return {
shouldStart: !(runtime.apiRunning && runtime.daemonRunning),
shouldStop: false,
@@ -97,8 +88,7 @@ interface GatewayAdminActionResult {
const isRecord = (value: unknown): value is Record<string, unknown> =>
Boolean(value) && typeof value === 'object' && !Array.isArray(value);
const normalizeMeta = (value: unknown): Record<string, unknown> =>
isRecord(value) ? value : {};
const normalizeMeta = (value: unknown): Record<string, unknown> => (isRecord(value) ? value : {});
const normalizeStatus = (value: unknown): GatewayAdminActionStatus | null => {
if (typeof value === 'string') {
@@ -108,16 +98,9 @@ const normalizeStatus = (value: unknown): GatewayAdminActionStatus | null => {
};
const buildActionKey = (action: GatewayAdminActionRecord): string =>
[
action.action ?? '',
action.requestedAt ?? '',
action.scheduledAt ?? '',
action.reason ?? '',
].join('|');
[action.action ?? '', action.requestedAt ?? '', action.scheduledAt ?? '', action.reason ?? ''].join('|');
const parseScenarioId = (
value: string | number | null | undefined
): number | null => {
const parseScenarioId = (value: string | number | null | undefined): number | null => {
if (typeof value === 'number' && Number.isFinite(value)) {
return Math.floor(value);
}
@@ -136,8 +119,10 @@ const buildProcessName = (profileName: string, role: 'api' | 'daemon'): string =
const buildProcessDefinitions = (
profile: GatewayProfileRecord,
config: GatewayProcessConfig
): { api: { name: string; script: string; cwd: string; env: Record<string, string> };
daemon: { name: string; script: string; cwd: string; env: Record<string, string> } } => {
): {
api: { name: string; script: string; cwd: string; env: Record<string, string> };
daemon: { name: string; script: string; cwd: string; env: Record<string, string> };
} => {
const baseEnv = { ...(config.baseEnv ?? {}) };
const apiName = buildProcessName(profile.profileName, 'api');
const daemonName = buildProcessName(profile.profileName, 'daemon');
@@ -176,10 +161,7 @@ const buildProcessDefinitions = (
};
};
const mapRuntimeStates = (
profileNames: string[],
processNames: Map<string, boolean>
): ProfileRuntimeSnapshot[] =>
const mapRuntimeStates = (profileNames: string[], processNames: Map<string, boolean>): ProfileRuntimeSnapshot[] =>
profileNames.map((profileName) => {
const apiName = buildProcessName(profileName, 'api');
const daemonName = buildProcessName(profileName, 'daemon');
@@ -227,22 +209,10 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle {
start(): void {
void this.reconcileNow();
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.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);
}
async stop(): Promise<void> {
@@ -313,8 +283,7 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle {
);
continue;
}
const queued =
profile.buildStatus === 'QUEUED' || profile.buildStatus === 'RUNNING';
const queued = profile.buildStatus === 'QUEUED' || profile.buildStatus === 'RUNNING';
if (!queued) {
await this.repository.updateBuildStatus(profile.profileName, 'QUEUED', {
requestedAt: now.toISOString(),
@@ -325,11 +294,7 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle {
}
const profiles = await this.repository.listProfiles();
for (const profile of profiles) {
if (
profile.status === 'PREOPEN' &&
profile.openAt &&
new Date(profile.openAt) <= now
) {
if (profile.status === 'PREOPEN' && profile.openAt && new Date(profile.openAt) <= now) {
await this.repository.updateStatus(profile.profileName, 'RUNNING', {
preopenAt: profile.preopenAt ?? null,
openAt: profile.openAt ?? null,
@@ -363,10 +328,7 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle {
startedAt,
error: null,
});
const result = await this.runBuildCommands(
queued.profileName,
queued.buildCommitSha
);
const result = await this.runBuildCommands(queued.profileName, queued.buildCommitSha);
const completedAt = this.now().toISOString();
if (result.ok) {
await this.repository.updateBuildStatus(queued.profileName, 'SUCCEEDED', {
@@ -376,9 +338,7 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle {
if (queued.status === 'RESERVED') {
await this.repository.updateStatus(
queued.profileName,
queued.openAt && new Date(queued.openAt) <= this.now()
? 'RUNNING'
: 'PREOPEN',
queued.openAt && new Date(queued.openAt) <= this.now() ? 'RUNNING' : 'PREOPEN',
{
preopenAt: queued.preopenAt ?? null,
openAt: queued.openAt ?? null,
@@ -418,13 +378,9 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle {
}
}
private async handleProfileAdminActions(
profile: GatewayProfileRecord
): Promise<void> {
private async handleProfileAdminActions(profile: GatewayProfileRecord): Promise<void> {
const meta = normalizeMeta(profile.meta);
const rawActions = Array.isArray(meta.adminActions)
? meta.adminActions
: [];
const rawActions = Array.isArray(meta.adminActions) ? meta.adminActions : [];
if (!rawActions.length) {
return;
}
@@ -442,10 +398,7 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle {
return;
}
const updates = new Map<
string,
{ status: GatewayAdminActionStatus; detail?: string; handledAt: string }
>();
const updates = new Map<string, { status: GatewayAdminActionStatus; detail?: string; handledAt: string }>();
for (const action of pending) {
if (action.action !== 'RESET_NOW' && action.action !== 'RESET_SCHEDULED') {
@@ -528,9 +481,7 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle {
return { status: 'FAILED', detail: 'scenarioId is missing' };
}
const seedTime =
action.scheduledAt && action.action === 'RESET_SCHEDULED'
? new Date(action.scheduledAt)
: this.now();
action.scheduledAt && action.action === 'RESET_SCHEDULED' ? new Date(action.scheduledAt) : this.now();
const startedAt = this.now().toISOString();
await this.repository.updateStatus(profile.profileName, 'STOPPED');
await this.stopProfile(profile);
@@ -646,10 +597,7 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle {
async cleanupStaleWorkspaces(): Promise<{ removed: string[]; skipped: string[] }> {
const profiles = await this.repository.listProfiles();
const cutoff = this.computeCutoffDate(6);
const workspaceMap = new Map<
string,
{ profileNames: string[]; lastUsedAt?: Date; hasActiveBuild: boolean }
>();
const workspaceMap = new Map<string, { profileNames: string[]; lastUsedAt?: Date; hasActiveBuild: boolean }>();
for (const profile of profiles) {
const workspace = profile.buildWorkspace;
if (!workspace) {
@@ -733,8 +681,7 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle {
const statusMap = new Map<string, boolean>();
for (const process of processes) {
const status = process.status.toLowerCase();
const running =
status === 'online' || status === 'launching' || status === 'stopping';
const running = status === 'online' || status === 'launching' || status === 'stopping';
statusMap.set(process.name, running);
}
return statusMap;
@@ -9,9 +9,7 @@ import { resolveWorkspaceRoot } from './workspaceRoot.js';
import { GitWorkspaceManager } from './workspaceManager.js';
export const buildEnvMap = (env: NodeJS.ProcessEnv): Record<string, string> => {
const entries = Object.entries(env).filter(
(entry): entry is [string, string] => typeof entry[1] === 'string'
);
const entries = Object.entries(env).filter((entry): entry is [string, string] => typeof entry[1] === 'string');
return Object.fromEntries(entries);
};
@@ -9,16 +9,10 @@ import { createGatewayOrchestrator } from './orchestratorFactory.js';
export const runGatewayOrchestrator = async (): Promise<void> => {
const config = resolveGatewayOrchestratorConfigFromEnv();
const postgres = createGatewayPostgresConnector(
resolvePostgresConfigFromEnv({ schema: config.dbSchema })
);
const postgres = createGatewayPostgresConnector(resolvePostgresConfigFromEnv({ schema: config.dbSchema }));
await postgres.connect();
const { orchestrator } = createGatewayOrchestrator(
postgres.prisma as GatewayPrismaClient,
config,
process.env
);
const { orchestrator } = createGatewayOrchestrator(postgres.prisma as GatewayPrismaClient, config, process.env);
const stop = async (reason: string): Promise<void> => {
console.info(`[gateway-orchestrator] stopping: ${reason}`);
@@ -11,13 +11,7 @@ export const GATEWAY_PROFILE_STATUSES = [
] as const;
export type GatewayProfileStatus = (typeof GATEWAY_PROFILE_STATUSES)[number];
export const GATEWAY_BUILD_STATUSES = [
'IDLE',
'QUEUED',
'RUNNING',
'FAILED',
'SUCCEEDED',
] as const;
export const GATEWAY_BUILD_STATUSES = ['IDLE', 'QUEUED', 'RUNNING', 'FAILED', 'SUCCEEDED'] as const;
export type GatewayBuildStatus = (typeof GATEWAY_BUILD_STATUSES)[number];
export interface GatewayProfileRecord {
@@ -81,23 +75,15 @@ export interface GatewayProfileRepository {
lastUsedAt?: string | null;
}
): Promise<GatewayProfileRecord | null>;
updateMeta(
profileName: string,
meta: Record<string, unknown>
): Promise<GatewayProfileRecord | null>;
updateMeta(profileName: string, meta: Record<string, unknown>): Promise<GatewayProfileRecord | null>;
listReservedToStart(now: Date): Promise<GatewayProfileRecord[]>;
findQueuedBuild(): Promise<GatewayProfileRecord | null>;
updateLastError(profileName: string, lastError: string | null): Promise<void>;
updateWorkspaceUsage(
profileName: string,
workspace: string,
lastUsedAt: string
): Promise<void>;
updateWorkspaceUsage(profileName: string, workspace: string, lastUsedAt: string): Promise<void>;
clearWorkspaceUsage(profileNames: string[]): Promise<void>;
}
const toIso = (value: Date | null): string | undefined =>
value ? value.toISOString() : undefined;
const toIso = (value: Date | null): string | undefined => (value ? value.toISOString() : undefined);
type GatewayProfileRow = {
profileName: string;
@@ -145,12 +131,9 @@ const mapProfile = (row: GatewayProfileRow): GatewayProfileRecord => ({
updatedAt: row.updatedAt.toISOString(),
});
const buildProfileName = (profile: string, scenario: string): string =>
`${profile}:${scenario}`;
const buildProfileName = (profile: string, scenario: string): string => `${profile}:${scenario}`;
export const createGatewayProfileRepository = (
prisma: GatewayPrismaClient
): GatewayProfileRepository => ({
export const createGatewayProfileRepository = (prisma: GatewayPrismaClient): GatewayProfileRepository => ({
async listProfiles(): Promise<GatewayProfileRecord[]> {
const rows = await prisma.gatewayProfile.findMany({
orderBy: [{ profile: 'asc' }, { scenario: 'asc' }],
@@ -175,34 +158,21 @@ export const createGatewayProfileRepository = (
status: input.status ?? 'STOPPED',
preopenAt: input.preopenAt ? new Date(input.preopenAt) : null,
openAt: input.openAt ? new Date(input.openAt) : null,
scheduledStartAt: input.scheduledStartAt
? new Date(input.scheduledStartAt)
: null,
scheduledStartAt: input.scheduledStartAt ? new Date(input.scheduledStartAt) : null,
buildCommitSha: input.buildCommitSha ?? null,
meta: (input.meta ?? {}) as GatewayPrisma.JsonObject,
},
update: {
apiPort: input.apiPort,
status: input.status,
preopenAt: input.preopenAt
? new Date(input.preopenAt)
: input.preopenAt === null
? null
: undefined,
openAt: input.openAt
? new Date(input.openAt)
: input.openAt === null
? null
: undefined,
preopenAt: input.preopenAt ? new Date(input.preopenAt) : input.preopenAt === null ? null : undefined,
openAt: input.openAt ? new Date(input.openAt) : input.openAt === null ? null : undefined,
scheduledStartAt: input.scheduledStartAt
? new Date(input.scheduledStartAt)
: input.scheduledStartAt === null
? null
: undefined,
buildCommitSha:
input.buildCommitSha === undefined
? undefined
: input.buildCommitSha,
buildCommitSha: input.buildCommitSha === undefined ? undefined : input.buildCommitSha,
meta: input.meta ? (input.meta as GatewayPrisma.JsonObject) : undefined,
},
});
@@ -229,11 +199,7 @@ export const createGatewayProfileRepository = (
? new Date(schedule.preopenAt)
: null,
openAt:
schedule?.openAt === undefined
? undefined
: schedule?.openAt
? new Date(schedule.openAt)
: null,
schedule?.openAt === undefined ? undefined : schedule?.openAt ? new Date(schedule.openAt) : null,
scheduledStartAt:
schedule?.scheduledStartAt === undefined
? undefined
@@ -262,10 +228,8 @@ export const createGatewayProfileRepository = (
where: { profileName },
data: {
buildStatus: status,
buildCommitSha:
fields?.commitSha === undefined ? undefined : fields.commitSha,
buildWorkspace:
fields?.workspace === undefined ? undefined : fields.workspace,
buildCommitSha: fields?.commitSha === undefined ? undefined : fields.commitSha,
buildWorkspace: fields?.workspace === undefined ? undefined : fields.workspace,
buildLastUsedAt:
fields?.lastUsedAt === undefined
? undefined
@@ -279,11 +243,7 @@ export const createGatewayProfileRepository = (
? new Date(fields.requestedAt)
: null,
buildStartedAt:
fields?.startedAt === undefined
? undefined
: fields?.startedAt
? new Date(fields.startedAt)
: null,
fields?.startedAt === undefined ? undefined : fields?.startedAt ? new Date(fields.startedAt) : null,
buildCompletedAt:
fields?.completedAt === undefined
? undefined
@@ -295,10 +255,7 @@ export const createGatewayProfileRepository = (
});
return row ? mapProfile(row) : null;
},
async updateMeta(
profileName: string,
meta: Record<string, unknown>
): Promise<GatewayProfileRecord | null> {
async updateMeta(profileName: string, meta: Record<string, unknown>): Promise<GatewayProfileRecord | null> {
const gatewayProfile = prisma.gatewayProfile;
const row = await gatewayProfile.update({
where: { profileName },
@@ -335,11 +292,7 @@ export const createGatewayProfileRepository = (
data: { lastError },
});
},
async updateWorkspaceUsage(
profileName: string,
workspace: string,
lastUsedAt: string
): Promise<void> {
async updateWorkspaceUsage(profileName: string, workspace: string, lastUsedAt: string): Promise<void> {
const gatewayProfile = prisma.gatewayProfile;
await gatewayProfile.update({
where: { profileName },
@@ -14,11 +14,7 @@ export interface WorkspaceInfo {
needsInstall: boolean;
}
const runGit = (
args: string[],
cwd: string,
env?: Record<string, string>
): Promise<{ ok: boolean; output: string }> =>
const runGit = (args: string[], cwd: string, env?: Record<string, string>): Promise<{ ok: boolean; output: string }> =>
new Promise((resolve) => {
const child = spawn('git', args, {
cwd,
@@ -43,8 +39,7 @@ const ensureDir = (dir: string): void => {
}
};
const hasInstallMarker = (dir: string): boolean =>
fs.existsSync(path.join(dir, 'node_modules', '.pnpm'));
const hasInstallMarker = (dir: string): boolean => fs.existsSync(path.join(dir, 'node_modules', '.pnpm'));
export class GitWorkspaceManager {
private readonly repoRoot: string;
@@ -63,11 +58,7 @@ export class GitWorkspaceManager {
const exists = fs.existsSync(workspacePath);
if (!exists) {
const hasCommit = await runGit(
['cat-file', '-e', `${commitSha}^{commit}`],
this.repoRoot,
this.baseEnv
);
const hasCommit = await runGit(['cat-file', '-e', `${commitSha}^{commit}`], this.repoRoot, this.baseEnv);
if (!hasCommit.ok) {
await runGit(['fetch', '--all', '--tags'], this.repoRoot, this.baseEnv);
}
@@ -97,11 +88,7 @@ export class GitWorkspaceManager {
if (!fs.existsSync(resolved)) {
return false;
}
const result = await runGit(
['worktree', 'remove', '--force', resolved],
this.repoRoot,
this.baseEnv
);
const result = await runGit(['worktree', 'remove', '--force', resolved], this.repoRoot, this.baseEnv);
if (!result.ok) {
fs.rmSync(resolved, { recursive: true, force: true });
}
@@ -6,10 +6,7 @@ const WORKSPACE_MARKERS = ['pnpm-workspace.yaml', 'package.json'];
const hasWorkspaceMarker = (dir: string): boolean =>
WORKSPACE_MARKERS.some((marker) => fs.existsSync(path.join(dir, marker)));
export const resolveWorkspaceRoot = (
startDir: string,
maxDepth = 5
): string => {
export const resolveWorkspaceRoot = (startDir: string, maxDepth = 5): string => {
let current = path.resolve(startDir);
for (let depth = 0; depth <= maxDepth; depth += 1) {
if (hasWorkspaceMarker(current)) {
+14 -18
View File
@@ -45,15 +45,15 @@ export const appRouter = router({
}),
profiles: procedure
.input(
z.object({
sessionToken: z.string().min(1).optional(),
}).optional()
z
.object({
sessionToken: z.string().min(1).optional(),
})
.optional()
)
.query(async ({ ctx, input }) => {
const sessionToken = input?.sessionToken;
const session = sessionToken
? await ctx.sessions.getSession(sessionToken)
: null;
const session = sessionToken ? await ctx.sessions.getSession(sessionToken) : null;
return ctx.profileStatus.listLobbyProfiles({
userId: session?.userId,
});
@@ -63,10 +63,12 @@ export const appRouter = router({
auth: router({
kakaoStart: procedure
.input(
z.object({
mode: zOAuthMode.optional(),
scopes: z.array(z.string()).optional(),
}).optional()
z
.object({
mode: zOAuthMode.optional(),
scopes: z.array(z.string()).optional(),
})
.optional()
)
.query(async ({ ctx, input }) => {
const mode = input?.mode ?? 'login';
@@ -96,10 +98,7 @@ export const appRouter = router({
}
const token = await ctx.kakaoClient.exchangeCode(input.code);
const tokenIssuedAt = new Date();
const accessTokenValidUntil = addSeconds(
tokenIssuedAt,
token.accessTokenExpiresIn
).toISOString();
const accessTokenValidUntil = addSeconds(tokenIssuedAt, token.accessTokenExpiresIn).toISOString();
const refreshTokenValidUntil = token.refreshTokenExpiresIn
? addSeconds(tokenIssuedAt, token.refreshTokenExpiresIn).toISOString()
: undefined;
@@ -337,10 +336,7 @@ export const appRouter = router({
})
)
.mutation(async ({ ctx, input }) => {
const gameSession = await ctx.sessions.createGameSession(
input.sessionToken,
input.profile
);
const gameSession = await ctx.sessions.createGameSession(input.sessionToken, input.profile);
if (!gameSession) {
throw new TRPCError({
code: 'UNAUTHORIZED',
+2 -6
View File
@@ -22,16 +22,12 @@ import { RepositoryProfileStatusService } from './lobby/profileStatusService.js'
export const createGatewayApiServer = async () => {
const config = resolveGatewayApiConfigFromEnv();
const postgres = createGatewayPostgresConnector(
resolvePostgresConfigFromEnv({ schema: config.dbSchema })
);
const postgres = createGatewayPostgresConnector(resolvePostgresConfigFromEnv({ schema: config.dbSchema }));
const redis = createRedisConnector(resolveRedisConfigFromEnv());
await postgres.connect();
await redis.connect();
const users = createPostgresUserRepository(
postgres.prisma as GatewayPrismaClient
);
const users = createPostgresUserRepository(postgres.prisma as GatewayPrismaClient);
const sessions = new RedisGatewaySessionService(redis.client, {
keyPrefix: config.redisKeyPrefix,
sessionTtlSeconds: config.sessionTtlSeconds,