From 020028fc74f97d00f98a1810b5ff4596a32ac7a6 Mon Sep 17 00:00:00 2001 From: Hide_D Date: Sat, 3 Jan 2026 16:35:48 +0000 Subject: [PATCH] feat: add orchestrator admin action interval and integrate game engine dependency --- app/game-engine/package.json | 2 + app/game-engine/src/turn/turnDaemon.ts | 115 +----- app/gateway-api/package.json | 1 + app/gateway-api/src/config.ts | 12 + .../src/orchestrator/gatewayOrchestrator.ts | 356 ++++++++++++++++-- .../src/orchestrator/orchestratorFactory.ts | 1 + pnpm-lock.yaml | 3 + 7 files changed, 343 insertions(+), 147 deletions(-) diff --git a/app/game-engine/package.json b/app/game-engine/package.json index 79457e6..dbe67ce 100644 --- a/app/game-engine/package.json +++ b/app/game-engine/package.json @@ -3,6 +3,8 @@ "private": true, "version": "0.0.0", "type": "module", + "main": "dist/index.js", + "types": "dist/index.d.ts", "scripts": { "build": "tsdown -c ../../tsdown.config.ts -F @sammo-ts/game-engine", "dev": "tsdown -c ../../tsdown.config.ts -F @sammo-ts/game-engine --watch", diff --git a/app/game-engine/src/turn/turnDaemon.ts b/app/game-engine/src/turn/turnDaemon.ts index 4f90d7b..dc220f7 100644 --- a/app/game-engine/src/turn/turnDaemon.ts +++ b/app/game-engine/src/turn/turnDaemon.ts @@ -26,8 +26,6 @@ import { createReservedTurnHandler } from './reservedTurnHandler.js'; import { createReservedTurnStore } from './reservedTurnStore.js'; import { loadTurnCommandProfile } from './turnCommandProfile.js'; import { loadTurnWorldFromDatabase } from './worldLoader.js'; -import { seedScenarioToDatabase } from '../scenario/scenarioSeeder.js'; -import { createGamePostgresConnector, createGatewayPostgresConnector } from '@sammo-ts/infra'; export interface TurnDaemonRuntimeOptions { profile: string; @@ -219,75 +217,6 @@ export const createTurnDaemonRuntime = async ( { profile: options.profile, defaultBudget } ); - const resolveScenarioId = async (): Promise => { - const meta = world.getState().meta as Record; - const raw = meta.scenarioId; - if (typeof raw === 'number' && Number.isFinite(raw)) { - return Math.floor(raw); - } - if (typeof raw === 'string') { - const parsed = Number(raw); - if (Number.isFinite(parsed)) { - return Math.floor(parsed); - } - } - const connector = createGamePostgresConnector({ url: options.databaseUrl }); - await connector.connect(); - try { - const prisma = connector.prisma as unknown as { - worldState: { findFirst: (args: unknown) => Promise<{ scenarioCode: string } | null> }; - }; - const row = await prisma.worldState.findFirst({ - select: { scenarioCode: true }, - }); - if (!row) { - return null; - } - const parsed = Number(row.scenarioCode); - return Number.isFinite(parsed) ? Math.floor(parsed) : null; - } finally { - await connector.disconnect(); - } - }; - - const updateGatewayProfileStatus = async ( - nextStatus: 'RUNNING' | 'STOPPED' - ): Promise<{ previous?: string; updated: boolean }> => { - const connector = createGatewayPostgresConnector({ - url: options.gatewayDatabaseUrl ?? options.databaseUrl, - }); - await connector.connect(); - try { - const prisma = connector.prisma as unknown as { - gatewayProfile: { - findUnique: (args: unknown) => Promise<{ status: string } | null>; - update: (args: unknown) => Promise; - }; - }; - const profile = await prisma.gatewayProfile.findUnique({ - where: { profileName: options.profileName }, - select: { status: true }, - }); - if (!profile) { - return { updated: false }; - } - if (profile.status === 'DISABLED') { - return { previous: profile.status, updated: false }; - } - await prisma.gatewayProfile.update({ - where: { profileName: options.profileName }, - data: { - status: nextStatus, - lastError: null, - }, - }); - return { previous: profile.status, updated: true }; - } finally { - await connector.disconnect(); - } - }; - - let resetInFlight = false; if (options.profileName) { adminActionConsumer = await createGatewayAdminActionConsumer({ @@ -298,48 +227,8 @@ export const createTurnDaemonRuntime = async ( handler: async (action) => { const reason = action.reason ?? `admin:${action.action ?? 'action'}`; if (action.action === 'RESET_NOW' || action.action === 'RESET_SCHEDULED') { - if (resetInFlight) { - return { status: 'IGNORED', detail: 'reset already in progress' }; - } - if (action.action === 'RESET_SCHEDULED') { - if (!action.scheduledAt) { - return { status: 'FAILED', detail: 'scheduledAt is required' }; - } - const scheduledAt = new Date(action.scheduledAt); - if (Number.isNaN(scheduledAt.getTime())) { - return { status: 'FAILED', detail: 'scheduledAt is invalid' }; - } - if (scheduledAt.getTime() > Date.now()) { - return { status: 'REQUESTED', detail: 'waiting for schedule' }; - } - } - resetInFlight = true; - try { - await lifecycle.stop('admin reset'); - const scenarioId = await resolveScenarioId(); - if (!scenarioId) { - return { status: 'FAILED', detail: 'scenarioId is missing' }; - } - const seedTime = - action.scheduledAt && action.action === 'RESET_SCHEDULED' - ? new Date(action.scheduledAt) - : new Date(); - await seedScenarioToDatabase({ - databaseUrl: options.databaseUrl, - scenarioId, - tickSeconds: world.getState().tickSeconds, - now: seedTime, - }); - const statusUpdate = await updateGatewayProfileStatus('RUNNING'); - return { - status: 'APPLIED', - detail: statusUpdate.updated - ? `seeded scenario ${scenarioId}; status RUNNING` - : `seeded scenario ${scenarioId}; status unchanged`, - }; - } finally { - resetInFlight = false; - } + // 리셋은 오케스트레이터에서 빌드+재기동으로 처리한다. + return { status: 'REQUESTED', detail: 'waiting for orchestrator reset' }; } switch (action.action) { case 'RESUME': diff --git a/app/gateway-api/package.json b/app/gateway-api/package.json index f764812..6588130 100644 --- a/app/gateway-api/package.json +++ b/app/gateway-api/package.json @@ -20,6 +20,7 @@ "@fastify/cors": "^11.2.0", "@prisma/client": "^7.2.0", "@sammo-ts/common": "workspace:*", + "@sammo-ts/game-engine": "workspace:*", "@sammo-ts/infra": "workspace:*", "@trpc/server": "^11.4.4", "date-fns": "^4.1.0", diff --git a/app/gateway-api/src/config.ts b/app/gateway-api/src/config.ts index fa2b231..525d1f4 100644 --- a/app/gateway-api/src/config.ts +++ b/app/gateway-api/src/config.ts @@ -19,6 +19,7 @@ export interface GatewayApiConfig { orchestratorReconcileIntervalMs: number; orchestratorScheduleIntervalMs: number; orchestratorBuildIntervalMs: number; + orchestratorAdminIntervalMs: number; workspaceRootHint: string; worktreeRoot: string; } @@ -30,6 +31,7 @@ export interface GatewayOrchestratorConfig { orchestratorReconcileIntervalMs: number; orchestratorScheduleIntervalMs: number; orchestratorBuildIntervalMs: number; + orchestratorAdminIntervalMs: number; workspaceRootHint: string; worktreeRoot: string; } @@ -120,6 +122,11 @@ export const resolveGatewayApiConfigFromEnv = ( 10000, 'GATEWAY_ORCHESTRATOR_BUILD_MS' ), + orchestratorAdminIntervalMs: parseNumber( + env.GATEWAY_ORCHESTRATOR_ADMIN_MS, + 5000, + 'GATEWAY_ORCHESTRATOR_ADMIN_MS' + ), workspaceRootHint: env.GATEWAY_WORKSPACE_ROOT ?? process.cwd(), worktreeRoot: env.GATEWAY_WORKTREE_ROOT ?? @@ -154,6 +161,11 @@ export const resolveGatewayOrchestratorConfigFromEnv = ( 10000, 'GATEWAY_ORCHESTRATOR_BUILD_MS' ), + orchestratorAdminIntervalMs: parseNumber( + env.GATEWAY_ORCHESTRATOR_ADMIN_MS, + 5000, + 'GATEWAY_ORCHESTRATOR_ADMIN_MS' + ), workspaceRootHint: env.GATEWAY_WORKSPACE_ROOT ?? process.cwd(), worktreeRoot: env.GATEWAY_WORKTREE_ROOT ?? diff --git a/app/gateway-api/src/orchestrator/gatewayOrchestrator.ts b/app/gateway-api/src/orchestrator/gatewayOrchestrator.ts index e646e7a..3121670 100644 --- a/app/gateway-api/src/orchestrator/gatewayOrchestrator.ts +++ b/app/gateway-api/src/orchestrator/gatewayOrchestrator.ts @@ -1,5 +1,8 @@ import path from 'node:path'; +import { seedScenarioToDatabase } from '@sammo-ts/game-engine'; +import { createGamePostgresConnector, resolvePostgresConfigFromEnv } from '@sammo-ts/infra'; + import type { BuildRunner } from './buildRunner.js'; import type { ProcessManager } from './processManager.js'; import type { @@ -25,6 +28,7 @@ export interface GatewayOrchestratorOptions { reconcileIntervalMs: number; scheduleIntervalMs: number; buildIntervalMs: number; + adminActionIntervalMs: number; now?: () => Date; } @@ -71,6 +75,61 @@ export const planProfileReconcile = ( }; }; +type GatewayAdminActionStatus = 'REQUESTED' | 'APPLIED' | 'FAILED' | 'IGNORED'; + +interface GatewayAdminActionRecord { + action?: string; + requestedAt?: string; + durationMinutes?: number | null; + scheduledAt?: string | null; + reason?: string | null; + status?: GatewayAdminActionStatus | string | null; + handledAt?: string | null; + handler?: string | null; + detail?: string | null; +} + +interface GatewayAdminActionResult { + status: GatewayAdminActionStatus; + detail?: string; +} + +const isRecord = (value: unknown): value is Record => + Boolean(value) && typeof value === 'object' && !Array.isArray(value); + +const normalizeMeta = (value: unknown): Record => + isRecord(value) ? value : {}; + +const normalizeStatus = (value: unknown): GatewayAdminActionStatus | null => { + if (typeof value === 'string') { + return value as GatewayAdminActionStatus; + } + return null; +}; + +const buildActionKey = (action: GatewayAdminActionRecord): string => + [ + action.action ?? '', + action.requestedAt ?? '', + action.scheduledAt ?? '', + action.reason ?? '', + ].join('|'); + +const parseScenarioId = ( + value: string | number | null | undefined +): number | null => { + if (typeof value === 'number' && Number.isFinite(value)) { + return Math.floor(value); + } + if (typeof value === 'string') { + const parsed = Number(value); + if (Number.isFinite(parsed)) { + return Math.floor(parsed); + } + } + return null; +}; + const buildProcessName = (profileName: string, role: 'api' | 'daemon'): string => `sammo:${profileName}:${role === 'api' ? 'game-api' : 'turn-daemon'}`; @@ -140,13 +199,17 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle { private readonly reconcileIntervalMs: number; private readonly scheduleIntervalMs: number; private readonly buildIntervalMs: number; + private readonly adminActionIntervalMs: number; private readonly now: () => Date; private reconcileTimer?: NodeJS.Timeout; private scheduleTimer?: NodeJS.Timeout; private buildTimer?: NodeJS.Timeout; + private adminActionTimer?: NodeJS.Timeout; private reconcileInFlight = false; private scheduleInFlight = false; private buildInFlight = false; + private adminActionInFlight = false; + private readonly resetInFlight = new Set(); constructor(options: GatewayOrchestratorOptions) { this.repository = options.repository; @@ -157,11 +220,13 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle { this.reconcileIntervalMs = options.reconcileIntervalMs; this.scheduleIntervalMs = options.scheduleIntervalMs; this.buildIntervalMs = options.buildIntervalMs; + this.adminActionIntervalMs = options.adminActionIntervalMs; this.now = options.now ?? (() => new Date()); } start(): void { void this.reconcileNow(); + void this.runAdminActionsNow(); this.reconcileTimer = setInterval( () => void this.reconcileNow(), this.reconcileIntervalMs @@ -174,6 +239,10 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle { () => void this.runBuildQueueNow(), this.buildIntervalMs ); + this.adminActionTimer = setInterval( + () => void this.runAdminActionsNow(), + this.adminActionIntervalMs + ); } async stop(): Promise { @@ -186,6 +255,9 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle { if (this.buildTimer) { clearInterval(this.buildTimer); } + if (this.adminActionTimer) { + clearInterval(this.adminActionTimer); + } } async listRuntimeStates(profileNames: string[]): Promise { @@ -291,42 +363,10 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle { startedAt, error: null, }); - const workspace = await this.workspaceManager.prepare(queued.buildCommitSha); - const lastUsedAt = this.now().toISOString(); - await this.repository.updateWorkspaceUsage( + const result = await this.runBuildCommands( queued.profileName, - workspace.root, - lastUsedAt + queued.buildCommitSha ); - const commands: Array<{ - command: string; - args: string[]; - cwd: string; - env?: Record; - }> = []; - if (workspace.needsInstall) { - commands.push({ - command: 'pnpm', - args: ['install'], - cwd: workspace.root, - env: this.processConfig.baseEnv, - }); - } - commands.push( - { - command: 'pnpm', - args: ['--filter', '@sammo-ts/game-api', 'build'], - cwd: workspace.root, - env: this.processConfig.baseEnv, - }, - { - command: 'pnpm', - args: ['--filter', '@sammo-ts/game-engine', 'build'], - cwd: workspace.root, - env: this.processConfig.baseEnv, - } - ); - const result = await this.buildRunner.run(commands); const completedAt = this.now().toISOString(); if (result.ok) { await this.repository.updateBuildStatus(queued.profileName, 'SUCCEEDED', { @@ -363,6 +403,254 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle { } } + private async runAdminActionsNow(): Promise { + if (this.adminActionInFlight) { + return; + } + this.adminActionInFlight = true; + try { + const profiles = await this.repository.listProfiles(); + for (const profile of profiles) { + await this.handleProfileAdminActions(profile); + } + } finally { + this.adminActionInFlight = false; + } + } + + private async handleProfileAdminActions( + profile: GatewayProfileRecord + ): Promise { + const meta = normalizeMeta(profile.meta); + const rawActions = Array.isArray(meta.adminActions) + ? meta.adminActions + : []; + if (!rawActions.length) { + return; + } + const pending = rawActions.filter((entry): entry is GatewayAdminActionRecord => { + if (!isRecord(entry)) { + return false; + } + if (!entry.action || typeof entry.action !== 'string') { + return false; + } + const status = normalizeStatus(entry.status) ?? 'REQUESTED'; + return status === 'REQUESTED'; + }); + if (!pending.length) { + return; + } + + 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') { + continue; + } + const key = buildActionKey(action); + const result = await this.handleResetAction(profile, action); + if (result.status !== 'REQUESTED') { + updates.set(key, { + status: result.status, + detail: result.detail, + handledAt: this.now().toISOString(), + }); + } + } + + if (!updates.size) { + return; + } + + const nextActions = rawActions.map((entry) => { + if (!isRecord(entry)) { + return entry; + } + const action = entry as GatewayAdminActionRecord; + const key = buildActionKey(action); + const update = updates.get(key); + if (!update) { + return entry; + } + return { + ...action, + status: update.status, + handledAt: update.handledAt, + handler: action.handler ?? 'orchestrator', + detail: update.detail ?? action.detail ?? null, + }; + }); + + await this.repository.updateMeta(profile.profileName, { + ...meta, + adminActions: nextActions, + adminActionsUpdatedAt: this.now().toISOString(), + }); + } + + private async handleResetAction( + profile: GatewayProfileRecord, + action: GatewayAdminActionRecord + ): Promise { + // 리셋 요청을 빌드+재기동 흐름으로 처리한다. + if (this.resetInFlight.has(profile.profileName)) { + return { status: 'REQUESTED', detail: 'reset already in progress' }; + } + if (action.action === 'RESET_SCHEDULED') { + if (!action.scheduledAt) { + return { status: 'FAILED', detail: 'scheduledAt is required' }; + } + const scheduledAt = new Date(action.scheduledAt); + if (Number.isNaN(scheduledAt.getTime())) { + return { status: 'FAILED', detail: 'scheduledAt is invalid' }; + } + if (scheduledAt.getTime() > this.now().getTime()) { + return { status: 'REQUESTED', detail: 'waiting for schedule' }; + } + } + + const commitSha = profile.buildCommitSha; + if (!commitSha) { + return { status: 'FAILED', detail: 'buildCommitSha is missing' }; + } + if (this.buildInFlight) { + return { status: 'REQUESTED', detail: 'build already in progress' }; + } + this.buildInFlight = true; + this.resetInFlight.add(profile.profileName); + try { + const seedInfo = await this.resolveResetSeedInfo(profile); + if (!seedInfo.scenarioId) { + return { status: 'FAILED', detail: 'scenarioId is missing' }; + } + const seedTime = + 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); + await this.repository.updateBuildStatus(profile.profileName, 'RUNNING', { + requestedAt: startedAt, + startedAt, + error: null, + commitSha, + }); + const result = await this.runBuildCommands(profile.profileName, commitSha); + const completedAt = this.now().toISOString(); + if (!result.ok) { + await this.repository.updateBuildStatus(profile.profileName, 'FAILED', { + completedAt, + error: result.output.slice(-4000), + }); + return { status: 'FAILED', detail: 'build failed' }; + } + await seedScenarioToDatabase({ + databaseUrl: seedInfo.databaseUrl, + scenarioId: seedInfo.scenarioId, + tickSeconds: seedInfo.tickSeconds, + now: seedTime, + }); + await this.repository.updateBuildStatus(profile.profileName, 'SUCCEEDED', { + completedAt, + error: null, + }); + await this.repository.updateStatus(profile.profileName, 'RUNNING'); + await this.startProfile(profile); + return { status: 'APPLIED', detail: 'reset completed via rebuild' }; + } catch (error) { + const detail = error instanceof Error ? error.message : String(error); + await this.repository.updateBuildStatus(profile.profileName, 'FAILED', { + completedAt: this.now().toISOString(), + error: detail, + }); + return { status: 'FAILED', detail }; + } finally { + this.buildInFlight = false; + this.resetInFlight.delete(profile.profileName); + } + } + + private async resolveResetSeedInfo( + profile: GatewayProfileRecord + ): Promise<{ databaseUrl: string; scenarioId: number | null; tickSeconds?: number }> { + const databaseUrl = resolvePostgresConfigFromEnv({ + env: this.processConfig.baseEnv ?? process.env, + schema: profile.profile, + }).url; + let scenarioId = parseScenarioId(profile.scenario); + let tickSeconds: number | undefined; + const connector = createGamePostgresConnector({ url: databaseUrl }); + await connector.connect(); + try { + const prisma = connector.prisma as unknown as { + worldState: { + findFirst: (args: unknown) => Promise<{ + scenarioCode: string | null; + tickSeconds: number | null; + } | null>; + }; + }; + const row = await prisma.worldState.findFirst({ + select: { scenarioCode: true, tickSeconds: true }, + }); + if (row) { + const resolvedScenario = parseScenarioId(row.scenarioCode); + if (resolvedScenario !== null) { + scenarioId = resolvedScenario; + } + if (typeof row.tickSeconds === 'number' && Number.isFinite(row.tickSeconds)) { + tickSeconds = row.tickSeconds; + } + } + } finally { + await connector.disconnect(); + } + return { databaseUrl, scenarioId, tickSeconds }; + } + + private async runBuildCommands( + profileName: string, + commitSha: string + ): Promise>> { + const workspace = await this.workspaceManager.prepare(commitSha); + const lastUsedAt = this.now().toISOString(); + await this.repository.updateWorkspaceUsage(profileName, workspace.root, lastUsedAt); + const commands: Array<{ + command: string; + args: string[]; + cwd: string; + env?: Record; + }> = []; + if (workspace.needsInstall) { + commands.push({ + command: 'pnpm', + args: ['install'], + cwd: workspace.root, + env: this.processConfig.baseEnv, + }); + } + commands.push( + { + command: 'pnpm', + args: ['--filter', '@sammo-ts/game-api', 'build'], + cwd: workspace.root, + env: this.processConfig.baseEnv, + }, + { + command: 'pnpm', + args: ['--filter', '@sammo-ts/game-engine', 'build'], + cwd: workspace.root, + env: this.processConfig.baseEnv, + } + ); + return this.buildRunner.run(commands); + } + async cleanupStaleWorkspaces(): Promise<{ removed: string[]; skipped: string[] }> { const profiles = await this.repository.listProfiles(); const cutoff = this.computeCutoffDate(6); diff --git a/app/gateway-api/src/orchestrator/orchestratorFactory.ts b/app/gateway-api/src/orchestrator/orchestratorFactory.ts index 36a274e..0a0623b 100644 --- a/app/gateway-api/src/orchestrator/orchestratorFactory.ts +++ b/app/gateway-api/src/orchestrator/orchestratorFactory.ts @@ -47,6 +47,7 @@ export const createGatewayOrchestrator = ( reconcileIntervalMs: config.orchestratorReconcileIntervalMs, scheduleIntervalMs: config.orchestratorScheduleIntervalMs, buildIntervalMs: config.orchestratorBuildIntervalMs, + adminActionIntervalMs: config.orchestratorAdminIntervalMs, }); return { orchestrator, profiles }; }; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index dfada0a..84b902b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -99,6 +99,9 @@ importers: '@sammo-ts/common': specifier: workspace:* version: link:../../packages/common + '@sammo-ts/game-engine': + specifier: workspace:* + version: link:../game-engine '@sammo-ts/infra': specifier: workspace:* version: link:../../packages/infra