feat: add orchestrator admin action interval and integrate game engine dependency

This commit is contained in:
2026-01-03 16:35:48 +00:00
parent 822acebca0
commit 020028fc74
7 changed files with 343 additions and 147 deletions
+2
View File
@@ -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",
+2 -113
View File
@@ -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<number | null> => {
const meta = world.getState().meta as Record<string, unknown>;
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<void>;
};
};
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':
+1
View File
@@ -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",
+12
View File
@@ -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 ??
@@ -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<string, unknown> =>
Boolean(value) && typeof value === 'object' && !Array.isArray(value);
const normalizeMeta = (value: unknown): Record<string, unknown> =>
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<string>();
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<void> {
@@ -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<ProfileRuntimeSnapshot[]> {
@@ -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<string, string>;
}> = [];
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<void> {
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<void> {
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<GatewayAdminActionResult> {
// 리셋 요청을 빌드+재기동 흐름으로 처리한다.
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<Awaited<ReturnType<BuildRunner['run']>>> {
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<string, string>;
}> = [];
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);
@@ -47,6 +47,7 @@ export const createGatewayOrchestrator = (
reconcileIntervalMs: config.orchestratorReconcileIntervalMs,
scheduleIntervalMs: config.orchestratorScheduleIntervalMs,
buildIntervalMs: config.orchestratorBuildIntervalMs,
adminActionIntervalMs: config.orchestratorAdminIntervalMs,
});
return { orchestrator, profiles };
};
+3
View File
@@ -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