feat: add orchestrator admin action interval and integrate game engine dependency
This commit is contained in:
@@ -3,6 +3,8 @@
|
|||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
"main": "dist/index.js",
|
||||||
|
"types": "dist/index.d.ts",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "tsdown -c ../../tsdown.config.ts -F @sammo-ts/game-engine",
|
"build": "tsdown -c ../../tsdown.config.ts -F @sammo-ts/game-engine",
|
||||||
"dev": "tsdown -c ../../tsdown.config.ts -F @sammo-ts/game-engine --watch",
|
"dev": "tsdown -c ../../tsdown.config.ts -F @sammo-ts/game-engine --watch",
|
||||||
|
|||||||
@@ -26,8 +26,6 @@ import { createReservedTurnHandler } from './reservedTurnHandler.js';
|
|||||||
import { createReservedTurnStore } from './reservedTurnStore.js';
|
import { createReservedTurnStore } from './reservedTurnStore.js';
|
||||||
import { loadTurnCommandProfile } from './turnCommandProfile.js';
|
import { loadTurnCommandProfile } from './turnCommandProfile.js';
|
||||||
import { loadTurnWorldFromDatabase } from './worldLoader.js';
|
import { loadTurnWorldFromDatabase } from './worldLoader.js';
|
||||||
import { seedScenarioToDatabase } from '../scenario/scenarioSeeder.js';
|
|
||||||
import { createGamePostgresConnector, createGatewayPostgresConnector } from '@sammo-ts/infra';
|
|
||||||
|
|
||||||
export interface TurnDaemonRuntimeOptions {
|
export interface TurnDaemonRuntimeOptions {
|
||||||
profile: string;
|
profile: string;
|
||||||
@@ -219,75 +217,6 @@ export const createTurnDaemonRuntime = async (
|
|||||||
{ profile: options.profile, defaultBudget }
|
{ 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) {
|
if (options.profileName) {
|
||||||
adminActionConsumer = await createGatewayAdminActionConsumer({
|
adminActionConsumer = await createGatewayAdminActionConsumer({
|
||||||
@@ -298,48 +227,8 @@ export const createTurnDaemonRuntime = async (
|
|||||||
handler: async (action) => {
|
handler: async (action) => {
|
||||||
const reason = action.reason ?? `admin:${action.action ?? 'action'}`;
|
const reason = action.reason ?? `admin:${action.action ?? 'action'}`;
|
||||||
if (action.action === 'RESET_NOW' || action.action === 'RESET_SCHEDULED') {
|
if (action.action === 'RESET_NOW' || action.action === 'RESET_SCHEDULED') {
|
||||||
if (resetInFlight) {
|
// 리셋은 오케스트레이터에서 빌드+재기동으로 처리한다.
|
||||||
return { status: 'IGNORED', detail: 'reset already in progress' };
|
return { status: 'REQUESTED', detail: 'waiting for orchestrator reset' };
|
||||||
}
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
switch (action.action) {
|
switch (action.action) {
|
||||||
case 'RESUME':
|
case 'RESUME':
|
||||||
|
|||||||
@@ -20,6 +20,7 @@
|
|||||||
"@fastify/cors": "^11.2.0",
|
"@fastify/cors": "^11.2.0",
|
||||||
"@prisma/client": "^7.2.0",
|
"@prisma/client": "^7.2.0",
|
||||||
"@sammo-ts/common": "workspace:*",
|
"@sammo-ts/common": "workspace:*",
|
||||||
|
"@sammo-ts/game-engine": "workspace:*",
|
||||||
"@sammo-ts/infra": "workspace:*",
|
"@sammo-ts/infra": "workspace:*",
|
||||||
"@trpc/server": "^11.4.4",
|
"@trpc/server": "^11.4.4",
|
||||||
"date-fns": "^4.1.0",
|
"date-fns": "^4.1.0",
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ export interface GatewayApiConfig {
|
|||||||
orchestratorReconcileIntervalMs: number;
|
orchestratorReconcileIntervalMs: number;
|
||||||
orchestratorScheduleIntervalMs: number;
|
orchestratorScheduleIntervalMs: number;
|
||||||
orchestratorBuildIntervalMs: number;
|
orchestratorBuildIntervalMs: number;
|
||||||
|
orchestratorAdminIntervalMs: number;
|
||||||
workspaceRootHint: string;
|
workspaceRootHint: string;
|
||||||
worktreeRoot: string;
|
worktreeRoot: string;
|
||||||
}
|
}
|
||||||
@@ -30,6 +31,7 @@ export interface GatewayOrchestratorConfig {
|
|||||||
orchestratorReconcileIntervalMs: number;
|
orchestratorReconcileIntervalMs: number;
|
||||||
orchestratorScheduleIntervalMs: number;
|
orchestratorScheduleIntervalMs: number;
|
||||||
orchestratorBuildIntervalMs: number;
|
orchestratorBuildIntervalMs: number;
|
||||||
|
orchestratorAdminIntervalMs: number;
|
||||||
workspaceRootHint: string;
|
workspaceRootHint: string;
|
||||||
worktreeRoot: string;
|
worktreeRoot: string;
|
||||||
}
|
}
|
||||||
@@ -120,6 +122,11 @@ export const resolveGatewayApiConfigFromEnv = (
|
|||||||
10000,
|
10000,
|
||||||
'GATEWAY_ORCHESTRATOR_BUILD_MS'
|
'GATEWAY_ORCHESTRATOR_BUILD_MS'
|
||||||
),
|
),
|
||||||
|
orchestratorAdminIntervalMs: parseNumber(
|
||||||
|
env.GATEWAY_ORCHESTRATOR_ADMIN_MS,
|
||||||
|
5000,
|
||||||
|
'GATEWAY_ORCHESTRATOR_ADMIN_MS'
|
||||||
|
),
|
||||||
workspaceRootHint: env.GATEWAY_WORKSPACE_ROOT ?? process.cwd(),
|
workspaceRootHint: env.GATEWAY_WORKSPACE_ROOT ?? process.cwd(),
|
||||||
worktreeRoot:
|
worktreeRoot:
|
||||||
env.GATEWAY_WORKTREE_ROOT ??
|
env.GATEWAY_WORKTREE_ROOT ??
|
||||||
@@ -154,6 +161,11 @@ export const resolveGatewayOrchestratorConfigFromEnv = (
|
|||||||
10000,
|
10000,
|
||||||
'GATEWAY_ORCHESTRATOR_BUILD_MS'
|
'GATEWAY_ORCHESTRATOR_BUILD_MS'
|
||||||
),
|
),
|
||||||
|
orchestratorAdminIntervalMs: parseNumber(
|
||||||
|
env.GATEWAY_ORCHESTRATOR_ADMIN_MS,
|
||||||
|
5000,
|
||||||
|
'GATEWAY_ORCHESTRATOR_ADMIN_MS'
|
||||||
|
),
|
||||||
workspaceRootHint: env.GATEWAY_WORKSPACE_ROOT ?? process.cwd(),
|
workspaceRootHint: env.GATEWAY_WORKSPACE_ROOT ?? process.cwd(),
|
||||||
worktreeRoot:
|
worktreeRoot:
|
||||||
env.GATEWAY_WORKTREE_ROOT ??
|
env.GATEWAY_WORKTREE_ROOT ??
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
import path from 'node:path';
|
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 { BuildRunner } from './buildRunner.js';
|
||||||
import type { ProcessManager } from './processManager.js';
|
import type { ProcessManager } from './processManager.js';
|
||||||
import type {
|
import type {
|
||||||
@@ -25,6 +28,7 @@ export interface GatewayOrchestratorOptions {
|
|||||||
reconcileIntervalMs: number;
|
reconcileIntervalMs: number;
|
||||||
scheduleIntervalMs: number;
|
scheduleIntervalMs: number;
|
||||||
buildIntervalMs: number;
|
buildIntervalMs: number;
|
||||||
|
adminActionIntervalMs: number;
|
||||||
now?: () => Date;
|
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 =>
|
const buildProcessName = (profileName: string, role: 'api' | 'daemon'): string =>
|
||||||
`sammo:${profileName}:${role === 'api' ? 'game-api' : 'turn-daemon'}`;
|
`sammo:${profileName}:${role === 'api' ? 'game-api' : 'turn-daemon'}`;
|
||||||
|
|
||||||
@@ -140,13 +199,17 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle {
|
|||||||
private readonly reconcileIntervalMs: number;
|
private readonly reconcileIntervalMs: number;
|
||||||
private readonly scheduleIntervalMs: number;
|
private readonly scheduleIntervalMs: number;
|
||||||
private readonly buildIntervalMs: number;
|
private readonly buildIntervalMs: number;
|
||||||
|
private readonly adminActionIntervalMs: number;
|
||||||
private readonly now: () => Date;
|
private readonly now: () => Date;
|
||||||
private reconcileTimer?: NodeJS.Timeout;
|
private reconcileTimer?: NodeJS.Timeout;
|
||||||
private scheduleTimer?: NodeJS.Timeout;
|
private scheduleTimer?: NodeJS.Timeout;
|
||||||
private buildTimer?: NodeJS.Timeout;
|
private buildTimer?: NodeJS.Timeout;
|
||||||
|
private adminActionTimer?: NodeJS.Timeout;
|
||||||
private reconcileInFlight = false;
|
private reconcileInFlight = false;
|
||||||
private scheduleInFlight = false;
|
private scheduleInFlight = false;
|
||||||
private buildInFlight = false;
|
private buildInFlight = false;
|
||||||
|
private adminActionInFlight = false;
|
||||||
|
private readonly resetInFlight = new Set<string>();
|
||||||
|
|
||||||
constructor(options: GatewayOrchestratorOptions) {
|
constructor(options: GatewayOrchestratorOptions) {
|
||||||
this.repository = options.repository;
|
this.repository = options.repository;
|
||||||
@@ -157,11 +220,13 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle {
|
|||||||
this.reconcileIntervalMs = options.reconcileIntervalMs;
|
this.reconcileIntervalMs = options.reconcileIntervalMs;
|
||||||
this.scheduleIntervalMs = options.scheduleIntervalMs;
|
this.scheduleIntervalMs = options.scheduleIntervalMs;
|
||||||
this.buildIntervalMs = options.buildIntervalMs;
|
this.buildIntervalMs = options.buildIntervalMs;
|
||||||
|
this.adminActionIntervalMs = options.adminActionIntervalMs;
|
||||||
this.now = options.now ?? (() => new Date());
|
this.now = options.now ?? (() => new Date());
|
||||||
}
|
}
|
||||||
|
|
||||||
start(): void {
|
start(): void {
|
||||||
void this.reconcileNow();
|
void this.reconcileNow();
|
||||||
|
void this.runAdminActionsNow();
|
||||||
this.reconcileTimer = setInterval(
|
this.reconcileTimer = setInterval(
|
||||||
() => void this.reconcileNow(),
|
() => void this.reconcileNow(),
|
||||||
this.reconcileIntervalMs
|
this.reconcileIntervalMs
|
||||||
@@ -174,6 +239,10 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle {
|
|||||||
() => void this.runBuildQueueNow(),
|
() => void this.runBuildQueueNow(),
|
||||||
this.buildIntervalMs
|
this.buildIntervalMs
|
||||||
);
|
);
|
||||||
|
this.adminActionTimer = setInterval(
|
||||||
|
() => void this.runAdminActionsNow(),
|
||||||
|
this.adminActionIntervalMs
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async stop(): Promise<void> {
|
async stop(): Promise<void> {
|
||||||
@@ -186,6 +255,9 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle {
|
|||||||
if (this.buildTimer) {
|
if (this.buildTimer) {
|
||||||
clearInterval(this.buildTimer);
|
clearInterval(this.buildTimer);
|
||||||
}
|
}
|
||||||
|
if (this.adminActionTimer) {
|
||||||
|
clearInterval(this.adminActionTimer);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async listRuntimeStates(profileNames: string[]): Promise<ProfileRuntimeSnapshot[]> {
|
async listRuntimeStates(profileNames: string[]): Promise<ProfileRuntimeSnapshot[]> {
|
||||||
@@ -291,42 +363,10 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle {
|
|||||||
startedAt,
|
startedAt,
|
||||||
error: null,
|
error: null,
|
||||||
});
|
});
|
||||||
const workspace = await this.workspaceManager.prepare(queued.buildCommitSha);
|
const result = await this.runBuildCommands(
|
||||||
const lastUsedAt = this.now().toISOString();
|
|
||||||
await this.repository.updateWorkspaceUsage(
|
|
||||||
queued.profileName,
|
queued.profileName,
|
||||||
workspace.root,
|
queued.buildCommitSha
|
||||||
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,
|
|
||||||
}
|
|
||||||
);
|
|
||||||
const result = await this.buildRunner.run(commands);
|
|
||||||
const completedAt = this.now().toISOString();
|
const completedAt = this.now().toISOString();
|
||||||
if (result.ok) {
|
if (result.ok) {
|
||||||
await this.repository.updateBuildStatus(queued.profileName, 'SUCCEEDED', {
|
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[] }> {
|
async cleanupStaleWorkspaces(): Promise<{ removed: string[]; skipped: string[] }> {
|
||||||
const profiles = await this.repository.listProfiles();
|
const profiles = await this.repository.listProfiles();
|
||||||
const cutoff = this.computeCutoffDate(6);
|
const cutoff = this.computeCutoffDate(6);
|
||||||
|
|||||||
@@ -47,6 +47,7 @@ export const createGatewayOrchestrator = (
|
|||||||
reconcileIntervalMs: config.orchestratorReconcileIntervalMs,
|
reconcileIntervalMs: config.orchestratorReconcileIntervalMs,
|
||||||
scheduleIntervalMs: config.orchestratorScheduleIntervalMs,
|
scheduleIntervalMs: config.orchestratorScheduleIntervalMs,
|
||||||
buildIntervalMs: config.orchestratorBuildIntervalMs,
|
buildIntervalMs: config.orchestratorBuildIntervalMs,
|
||||||
|
adminActionIntervalMs: config.orchestratorAdminIntervalMs,
|
||||||
});
|
});
|
||||||
return { orchestrator, profiles };
|
return { orchestrator, profiles };
|
||||||
};
|
};
|
||||||
|
|||||||
Generated
+3
@@ -99,6 +99,9 @@ importers:
|
|||||||
'@sammo-ts/common':
|
'@sammo-ts/common':
|
||||||
specifier: workspace:*
|
specifier: workspace:*
|
||||||
version: link:../../packages/common
|
version: link:../../packages/common
|
||||||
|
'@sammo-ts/game-engine':
|
||||||
|
specifier: workspace:*
|
||||||
|
version: link:../game-engine
|
||||||
'@sammo-ts/infra':
|
'@sammo-ts/infra':
|
||||||
specifier: workspace:*
|
specifier: workspace:*
|
||||||
version: link:../../packages/infra
|
version: link:../../packages/infra
|
||||||
|
|||||||
Reference in New Issue
Block a user