feat: Implement admin router and orchestrator for managing profiles and builds

- Added `adminRouter` for handling profile management, including listing, upserting, and updating statuses.
- Introduced `BuildRunner` interface and `PnpmBuildRunner` class for executing build commands.
- Created `GatewayOrchestrator` to manage profile states, reconcile processes, and handle build queues.
- Implemented `Pm2ProcessManager` for managing processes using PM2.
- Developed `GatewayProfileRepository` for interacting with the database to manage profiles.
- Added utility functions for resolving workspace roots and managing process definitions.
- Included tests for profile reconciliation logic.
This commit is contained in:
2026-01-01 10:38:28 +00:00
parent 79819c4a1b
commit b46249dcbc
18 changed files with 2151 additions and 7 deletions
@@ -0,0 +1,63 @@
import { spawn } from 'node:child_process';
export interface BuildCommand {
command: string;
args: string[];
cwd: string;
env?: Record<string, string>;
}
export interface BuildResult {
ok: boolean;
exitCode: number | null;
output: string;
}
export interface BuildRunner {
run(commands: BuildCommand[]): Promise<BuildResult>;
}
const runCommand = (command: BuildCommand): Promise<BuildResult> =>
new Promise((resolve) => {
const child = spawn(command.command, command.args, {
cwd: command.cwd,
env: command.env,
stdio: ['ignore', 'pipe', 'pipe'],
});
let output = '';
child.stdout.on('data', (chunk) => {
output += chunk.toString();
});
child.stderr.on('data', (chunk) => {
output += chunk.toString();
});
child.on('close', (code) => {
resolve({
ok: code === 0,
exitCode: code,
output,
});
});
});
export class PnpmBuildRunner implements BuildRunner {
async run(commands: BuildCommand[]): Promise<BuildResult> {
let mergedOutput = '';
for (const command of commands) {
const result = await runCommand(command);
mergedOutput += result.output;
if (!result.ok) {
return {
ok: false,
exitCode: result.exitCode,
output: mergedOutput,
};
}
}
return {
ok: true,
exitCode: 0,
output: mergedOutput,
};
}
}
@@ -0,0 +1,328 @@
import path from 'node:path';
import type { BuildRunner } from './buildRunner.js';
import type { ProcessManager } from './processManager.js';
import type {
GatewayProfileRecord,
GatewayProfileRepository,
GatewayProfileStatus,
} from './profileRepository.js';
export interface GatewayProcessConfig {
workspaceRoot: string;
redisKeyPrefix: string;
gameTokenSecret: string;
baseEnv?: Record<string, string>;
}
export interface GatewayOrchestratorOptions {
repository: GatewayProfileRepository;
processManager: ProcessManager;
buildRunner: BuildRunner;
processConfig: GatewayProcessConfig;
reconcileIntervalMs: number;
scheduleIntervalMs: number;
buildIntervalMs: number;
now?: () => Date;
}
export interface ProfileRuntimeState {
apiRunning: boolean;
daemonRunning: boolean;
}
export interface ProfileRuntimeSnapshot extends ProfileRuntimeState {
profileName: string;
}
export interface GatewayOrchestratorHandle {
start(): void;
stop(): Promise<void>;
reconcileNow(): Promise<void>;
runScheduleNow(): Promise<void>;
runBuildQueueNow(): Promise<void>;
listRuntimeStates(profileNames: string[]): Promise<ProfileRuntimeSnapshot[]>;
}
export const planProfileReconcile = (
status: GatewayProfileStatus,
runtime: ProfileRuntimeState
): { shouldStart: boolean; shouldStop: boolean } => {
if (status === 'RUNNING') {
return {
shouldStart: !(runtime.apiRunning && runtime.daemonRunning),
shouldStop: false,
};
}
return {
shouldStart: false,
shouldStop: runtime.apiRunning || runtime.daemonRunning,
};
};
const buildProcessName = (profileName: string, role: 'api' | 'daemon'): string =>
`sammo:${profileName}:${role === 'api' ? 'game-api' : 'turn-daemon'}`;
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> } } => {
const baseEnv = { ...(config.baseEnv ?? {}) };
const apiName = buildProcessName(profile.profileName, 'api');
const daemonName = buildProcessName(profile.profileName, 'daemon');
const apiCwd = path.join(config.workspaceRoot, 'app', 'game-api');
const daemonCwd = path.join(config.workspaceRoot, 'app', 'game-engine');
const apiScript = path.join(apiCwd, 'dist', 'index.js');
const daemonScript = path.join(daemonCwd, 'dist', 'index.js');
const apiEnv = {
...baseEnv,
PROFILE: profile.profile,
SCENARIO: profile.scenario,
GAME_API_PORT: String(profile.apiPort),
GATEWAY_REDIS_PREFIX: config.redisKeyPrefix,
GAME_TOKEN_SECRET: config.gameTokenSecret,
};
const daemonEnv = {
...baseEnv,
TURN_PROFILE: profile.profile,
PROFILE: profile.profile,
SCENARIO: profile.scenario,
};
return {
api: {
name: apiName,
script: apiScript,
cwd: apiCwd,
env: apiEnv,
},
daemon: {
name: daemonName,
script: daemonScript,
cwd: daemonCwd,
env: daemonEnv,
},
};
};
const mapRuntimeStates = (
profileNames: string[],
processNames: Map<string, boolean>
): ProfileRuntimeSnapshot[] =>
profileNames.map((profileName) => {
const apiName = buildProcessName(profileName, 'api');
const daemonName = buildProcessName(profileName, 'daemon');
return {
profileName,
apiRunning: processNames.get(apiName) ?? false,
daemonRunning: processNames.get(daemonName) ?? false,
};
});
export class GatewayOrchestrator implements GatewayOrchestratorHandle {
private readonly repository: GatewayProfileRepository;
private readonly processManager: ProcessManager;
private readonly buildRunner: BuildRunner;
private readonly processConfig: GatewayProcessConfig;
private readonly reconcileIntervalMs: number;
private readonly scheduleIntervalMs: number;
private readonly buildIntervalMs: number;
private readonly now: () => Date;
private reconcileTimer?: NodeJS.Timeout;
private scheduleTimer?: NodeJS.Timeout;
private buildTimer?: NodeJS.Timeout;
private reconcileInFlight = false;
private scheduleInFlight = false;
private buildInFlight = false;
constructor(options: GatewayOrchestratorOptions) {
this.repository = options.repository;
this.processManager = options.processManager;
this.buildRunner = options.buildRunner;
this.processConfig = options.processConfig;
this.reconcileIntervalMs = options.reconcileIntervalMs;
this.scheduleIntervalMs = options.scheduleIntervalMs;
this.buildIntervalMs = options.buildIntervalMs;
this.now = options.now ?? (() => new Date());
}
start(): void {
void this.reconcileNow();
this.reconcileTimer = setInterval(
() => void this.reconcileNow(),
this.reconcileIntervalMs
);
this.scheduleTimer = setInterval(
() => void this.runScheduleNow(),
this.scheduleIntervalMs
);
this.buildTimer = setInterval(
() => void this.runBuildQueueNow(),
this.buildIntervalMs
);
}
async stop(): Promise<void> {
if (this.reconcileTimer) {
clearInterval(this.reconcileTimer);
}
if (this.scheduleTimer) {
clearInterval(this.scheduleTimer);
}
if (this.buildTimer) {
clearInterval(this.buildTimer);
}
}
async listRuntimeStates(profileNames: string[]): Promise<ProfileRuntimeSnapshot[]> {
const processStates = await this.loadProcessStatusMap();
return mapRuntimeStates(profileNames, processStates);
}
async reconcileNow(): Promise<void> {
if (this.reconcileInFlight) {
return;
}
this.reconcileInFlight = true;
try {
const profiles = await this.repository.listProfiles();
if (!profiles.length) {
return;
}
const processStates = await this.loadProcessStatusMap();
for (const profile of profiles) {
const runtime = mapRuntimeStates([profile.profileName], processStates)[0];
const plan = planProfileReconcile(profile.status, runtime);
if (plan.shouldStart) {
await this.startProfile(profile);
} else if (plan.shouldStop) {
await this.stopProfile(profile);
}
}
} finally {
this.reconcileInFlight = false;
}
}
async runScheduleNow(): Promise<void> {
if (this.scheduleInFlight) {
return;
}
this.scheduleInFlight = true;
try {
const now = this.now();
const due = await this.repository.listReservedToStart(now);
for (const profile of due) {
try {
await this.repository.updateStatus(
profile.profileName,
'RUNNING',
null
);
await this.startProfile(profile);
} catch (error) {
await this.repository.updateLastError(
profile.profileName,
error instanceof Error ? error.message : 'Failed to start scheduled profile.'
);
}
}
} finally {
this.scheduleInFlight = false;
}
}
async runBuildQueueNow(): Promise<void> {
if (this.buildInFlight) {
return;
}
this.buildInFlight = true;
try {
const queued = await this.repository.findQueuedBuild();
if (!queued) {
return;
}
const startedAt = this.now().toISOString();
await this.repository.updateBuildStatus(queued.profileName, 'RUNNING', {
startedAt,
error: null,
});
const result = await this.buildRunner.run([
{
command: 'pnpm',
args: ['--filter', '@sammo-ts/game-api', 'build'],
cwd: this.processConfig.workspaceRoot,
env: this.processConfig.baseEnv,
},
{
command: 'pnpm',
args: ['--filter', '@sammo-ts/game-engine', 'build'],
cwd: this.processConfig.workspaceRoot,
env: this.processConfig.baseEnv,
},
]);
const completedAt = this.now().toISOString();
if (result.ok) {
await this.repository.updateBuildStatus(queued.profileName, 'SUCCEEDED', {
completedAt,
error: null,
});
if (queued.status !== 'RUNNING' && queued.status !== 'DISABLED') {
await this.repository.updateStatus(
queued.profileName,
'COMPLETED',
queued.scheduledStartAt ? queued.scheduledStartAt : null
);
}
} else {
await this.repository.updateBuildStatus(queued.profileName, 'FAILED', {
completedAt,
error: result.output.slice(-4000),
});
}
} finally {
this.buildInFlight = false;
}
}
private async startProfile(profile: GatewayProfileRecord): Promise<void> {
const definitions = buildProcessDefinitions(profile, this.processConfig);
try {
await this.processManager.start(definitions.api);
await this.processManager.start(definitions.daemon);
await this.repository.updateLastError(profile.profileName, null);
} catch (error) {
await this.repository.updateLastError(
profile.profileName,
error instanceof Error ? error.message : 'Failed to start processes.'
);
}
}
private async stopProfile(profile: GatewayProfileRecord): Promise<void> {
const apiName = buildProcessName(profile.profileName, 'api');
const daemonName = buildProcessName(profile.profileName, 'daemon');
try {
await this.processManager.stop(apiName);
} catch {
await this.processManager.delete(apiName);
}
try {
await this.processManager.stop(daemonName);
} catch {
await this.processManager.delete(daemonName);
}
}
private async loadProcessStatusMap(): Promise<Map<string, boolean>> {
const processes = await this.processManager.list();
const statusMap = new Map<string, boolean>();
for (const process of processes) {
const status = process.status.toLowerCase();
const running =
status === 'online' || status === 'launching' || status === 'stopping';
statusMap.set(process.name, running);
}
return statusMap;
}
}
@@ -0,0 +1,106 @@
import { createRequire } from 'node:module';
import type { ProcessManager, ManagedProcessInfo, ProcessDefinition } from './processManager.js';
type Pm2Module = typeof import('pm2');
const require = createRequire(import.meta.url);
const loadPm2 = (): Pm2Module => require('pm2') as Pm2Module;
const withPm2 = async <T>(handler: (pm2: Pm2Module) => Promise<T>): Promise<T> => {
const pm2 = loadPm2();
await new Promise<void>((resolve, reject) => {
pm2.connect((error) => {
if (error) {
reject(error);
return;
}
resolve();
});
});
try {
return await handler(pm2);
} finally {
pm2.disconnect();
}
};
export class Pm2ProcessManager implements ProcessManager {
async list(): Promise<ManagedProcessInfo[]> {
return withPm2(
(pm2) =>
new Promise<ManagedProcessInfo[]>((resolve, reject) => {
pm2.list((error, list) => {
if (error) {
reject(error);
return;
}
const normalized =
list?.map((item) => ({
name: item.name ?? 'unknown',
status: item.pm2_env?.status ?? 'unknown',
pid: item.pid ?? undefined,
})) ?? [];
resolve(normalized);
});
})
);
}
async start(definition: ProcessDefinition): Promise<void> {
await withPm2(
(pm2) =>
new Promise<void>((resolve, reject) => {
pm2.start(
{
name: definition.name,
script: definition.script,
cwd: definition.cwd,
args: definition.args,
env: definition.env,
autorestart: true,
time: true,
},
(error) => {
if (error) {
reject(error);
return;
}
resolve();
}
);
})
);
}
async stop(name: string): Promise<void> {
await withPm2(
(pm2) =>
new Promise<void>((resolve, reject) => {
pm2.stop(name, (error) => {
if (error) {
reject(error);
return;
}
resolve();
});
})
);
}
async delete(name: string): Promise<void> {
await withPm2(
(pm2) =>
new Promise<void>((resolve, reject) => {
pm2.delete(name, (error) => {
if (error) {
reject(error);
return;
}
resolve();
});
})
);
}
}
@@ -0,0 +1,20 @@
export interface ManagedProcessInfo {
name: string;
status: string;
pid?: number;
}
export interface ProcessDefinition {
name: string;
script: string;
cwd: string;
args?: string[];
env?: Record<string, string>;
}
export interface ProcessManager {
list(): Promise<ManagedProcessInfo[]>;
start(definition: ProcessDefinition): Promise<void>;
stop(name: string): Promise<void>;
delete(name: string): Promise<void>;
}
@@ -0,0 +1,235 @@
import { Prisma, type PrismaClient } from '@prisma/client';
export const GATEWAY_PROFILE_STATUSES = [
'COMPLETED',
'RESERVED',
'RUNNING',
'STOPPED',
'DISABLED',
] as const;
export type GatewayProfileStatus = (typeof GATEWAY_PROFILE_STATUSES)[number];
export const GATEWAY_BUILD_STATUSES = [
'IDLE',
'QUEUED',
'RUNNING',
'FAILED',
'SUCCEEDED',
] as const;
export type GatewayBuildStatus = (typeof GATEWAY_BUILD_STATUSES)[number];
export interface GatewayProfileRecord {
profileName: string;
profile: string;
scenario: string;
apiPort: number;
status: GatewayProfileStatus;
buildStatus: GatewayBuildStatus;
scheduledStartAt?: string;
buildRequestedAt?: string;
buildStartedAt?: string;
buildCompletedAt?: string;
buildError?: string;
lastError?: string;
meta: Prisma.JsonObject;
createdAt: string;
updatedAt: string;
}
export interface GatewayProfileUpsertInput {
profile: string;
scenario: string;
apiPort: number;
status?: GatewayProfileStatus;
scheduledStartAt?: string;
meta?: Prisma.JsonObject;
}
export interface GatewayProfileRepository {
listProfiles(): Promise<GatewayProfileRecord[]>;
getProfile(profileName: string): Promise<GatewayProfileRecord | null>;
upsertProfile(input: GatewayProfileUpsertInput): Promise<GatewayProfileRecord>;
updateStatus(
profileName: string,
status: GatewayProfileStatus,
scheduledStartAt?: string | null
): Promise<GatewayProfileRecord | null>;
updateBuildStatus(
profileName: string,
status: GatewayBuildStatus,
fields?: {
requestedAt?: string | null;
startedAt?: string | null;
completedAt?: string | null;
error?: string | null;
}
): Promise<GatewayProfileRecord | null>;
listReservedToStart(now: Date): Promise<GatewayProfileRecord[]>;
findQueuedBuild(): Promise<GatewayProfileRecord | null>;
updateLastError(profileName: string, lastError: string | null): Promise<void>;
}
const toIso = (value: Date | null): string | undefined =>
value ? value.toISOString() : undefined;
const mapProfile = (row: {
profileName: string;
profile: string;
scenario: string;
apiPort: number;
status: GatewayProfileStatus;
buildStatus: GatewayBuildStatus;
scheduledStartAt: Date | null;
buildRequestedAt: Date | null;
buildStartedAt: Date | null;
buildCompletedAt: Date | null;
buildError: string | null;
lastError: string | null;
meta: Prisma.JsonValue;
createdAt: Date;
updatedAt: Date;
}): GatewayProfileRecord => ({
profileName: row.profileName,
profile: row.profile,
scenario: row.scenario,
apiPort: row.apiPort,
status: row.status,
buildStatus: row.buildStatus,
scheduledStartAt: toIso(row.scheduledStartAt),
buildRequestedAt: toIso(row.buildRequestedAt),
buildStartedAt: toIso(row.buildStartedAt),
buildCompletedAt: toIso(row.buildCompletedAt),
buildError: row.buildError ?? undefined,
lastError: row.lastError ?? undefined,
meta: (row.meta ?? {}) as Prisma.JsonObject,
createdAt: row.createdAt.toISOString(),
updatedAt: row.updatedAt.toISOString(),
});
const buildProfileName = (profile: string, scenario: string): string =>
`${profile}:${scenario}`;
export const createGatewayProfileRepository = (
prisma: PrismaClient
): GatewayProfileRepository => ({
async listProfiles(): Promise<GatewayProfileRecord[]> {
const rows = await prisma.gatewayProfile.findMany({
orderBy: [{ profile: 'asc' }, { scenario: 'asc' }],
});
return rows.map(mapProfile);
},
async getProfile(profileName: string): Promise<GatewayProfileRecord | null> {
const row = await prisma.gatewayProfile.findUnique({
where: { profileName },
});
return row ? mapProfile(row) : null;
},
async upsertProfile(input: GatewayProfileUpsertInput): Promise<GatewayProfileRecord> {
const profileName = buildProfileName(input.profile, input.scenario);
const row = await prisma.gatewayProfile.upsert({
where: { profileName },
create: {
profileName,
profile: input.profile,
scenario: input.scenario,
apiPort: input.apiPort,
status: input.status ?? 'STOPPED',
scheduledStartAt: input.scheduledStartAt
? new Date(input.scheduledStartAt)
: null,
meta: (input.meta ?? {}) as Prisma.JsonObject,
},
update: {
apiPort: input.apiPort,
status: input.status,
scheduledStartAt: input.scheduledStartAt
? new Date(input.scheduledStartAt)
: input.scheduledStartAt === null
? null
: undefined,
meta: input.meta ? (input.meta as Prisma.JsonObject) : undefined,
},
});
return mapProfile(row);
},
async updateStatus(
profileName: string,
status: GatewayProfileStatus,
scheduledStartAt?: string | null
): Promise<GatewayProfileRecord | null> {
const row = await prisma.gatewayProfile.update({
where: { profileName },
data: {
status,
scheduledStartAt:
scheduledStartAt === undefined
? undefined
: scheduledStartAt
? new Date(scheduledStartAt)
: null,
},
});
return row ? mapProfile(row) : null;
},
async updateBuildStatus(
profileName: string,
status: GatewayBuildStatus,
fields?: {
requestedAt?: string | null;
startedAt?: string | null;
completedAt?: string | null;
error?: string | null;
}
): Promise<GatewayProfileRecord | null> {
const row = await prisma.gatewayProfile.update({
where: { profileName },
data: {
buildStatus: status,
buildRequestedAt:
fields?.requestedAt === undefined
? undefined
: fields?.requestedAt
? new Date(fields.requestedAt)
: null,
buildStartedAt:
fields?.startedAt === undefined
? undefined
: fields?.startedAt
? new Date(fields.startedAt)
: null,
buildCompletedAt:
fields?.completedAt === undefined
? undefined
: fields?.completedAt
? new Date(fields.completedAt)
: null,
buildError: fields?.error === undefined ? undefined : fields.error,
},
});
return row ? mapProfile(row) : null;
},
async listReservedToStart(now: Date): Promise<GatewayProfileRecord[]> {
const rows = await prisma.gatewayProfile.findMany({
where: {
status: 'RESERVED',
scheduledStartAt: {
lte: now,
},
},
});
return rows.map(mapProfile);
},
async findQueuedBuild(): Promise<GatewayProfileRecord | null> {
const row = await prisma.gatewayProfile.findFirst({
where: { buildStatus: 'QUEUED' },
orderBy: { buildRequestedAt: 'asc' },
});
return row ? mapProfile(row) : null;
},
async updateLastError(profileName: string, lastError: string | null): Promise<void> {
await prisma.gatewayProfile.update({
where: { profileName },
data: { lastError },
});
},
});
@@ -0,0 +1,25 @@
import fs from 'node:fs';
import path from 'node:path';
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 => {
let current = path.resolve(startDir);
for (let depth = 0; depth <= maxDepth; depth += 1) {
if (hasWorkspaceMarker(current)) {
return current;
}
const parent = path.dirname(current);
if (parent === current) {
break;
}
current = parent;
}
return path.resolve(startDir);
};