feat: Enhance Gateway Orchestrator with preopen and open scheduling, add error handling for paused states, and implement new profile management features

This commit is contained in:
2026-01-01 11:27:10 +00:00
parent e64962c3fd
commit 8c9f61700e
20 changed files with 470 additions and 86 deletions
+1
View File
@@ -6,6 +6,7 @@
"scripts": {
"build": "tsdown -c ../../tsdown.config.ts -F @sammo-ts/gateway-api",
"dev": "tsdown -c ../../tsdown.config.ts -F @sammo-ts/gateway-api --watch",
"orchestrator": "GATEWAY_ROLE=orchestrator node dist/index.js",
"lint": "node -e \"console.log('lint not configured')\"",
"test": "vitest run --config vitest.config.ts",
"typecheck": "tsc --noEmit"
+22 -3
View File
@@ -58,7 +58,10 @@ export const adminRouter = router({
scenario: z.string().min(1).max(64),
apiPort: z.number().int().min(1).max(65535),
status: zProfileStatus.optional(),
preopenAt: z.string().datetime().optional(),
openAt: z.string().datetime().optional(),
scheduledStartAt: z.string().datetime().optional(),
buildCommitSha: z.string().min(7).max(64).optional(),
})
)
.mutation(async ({ ctx, input }) => {
@@ -68,7 +71,10 @@ export const adminRouter = router({
scenario: input.scenario,
apiPort: input.apiPort,
status,
preopenAt: input.preopenAt,
openAt: input.openAt,
scheduledStartAt: input.scheduledStartAt,
buildCommitSha: input.buildCommitSha,
});
}),
setStatus: adminProcedure
@@ -76,21 +82,34 @@ export const adminRouter = router({
z.object({
profileName: z.string().min(1),
status: zProfileStatus,
preopenAt: z.string().datetime().optional(),
openAt: z.string().datetime().optional(),
scheduledStartAt: z.string().datetime().optional(),
buildCommitSha: z.string().min(7).max(64).optional(),
})
)
.mutation(async ({ ctx, input }) => {
if (input.status === 'RESERVED' && !input.scheduledStartAt) {
if (input.status === 'RESERVED' && (!input.preopenAt || !input.openAt)) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'scheduledStartAt is required for RESERVED status.',
message: 'preopenAt and openAt are required for RESERVED status.',
});
}
const result = await ctx.profiles.updateStatus(
input.profileName,
input.status,
input.status === 'RESERVED' ? input.scheduledStartAt : null
{
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,
});
}
await ctx.orchestrator.reconcileNow();
return result;
}),
+44 -1
View File
@@ -23,6 +23,16 @@ export interface GatewayApiConfig {
worktreeRoot: string;
}
export interface GatewayOrchestratorConfig {
redisKeyPrefix: string;
gameTokenSecret: string;
orchestratorReconcileIntervalMs: number;
orchestratorScheduleIntervalMs: number;
orchestratorBuildIntervalMs: number;
workspaceRootHint: string;
worktreeRoot: string;
}
const parseNumber = (value: string | undefined, fallback: number, label: string): number => {
if (!value) {
return fallback;
@@ -85,7 +95,40 @@ export const resolveGatewayApiConfigFromEnv = (
kakaoRedirectUri,
publicBaseUrl,
adminToken: env.GATEWAY_ADMIN_TOKEN,
orchestratorEnabled: parseBoolean(env.GATEWAY_ORCHESTRATOR_ENABLED, true),
orchestratorEnabled: parseBoolean(env.GATEWAY_ORCHESTRATOR_ENABLED, false),
orchestratorReconcileIntervalMs: parseNumber(
env.GATEWAY_ORCHESTRATOR_RECONCILE_MS,
15000,
'GATEWAY_ORCHESTRATOR_RECONCILE_MS'
),
orchestratorScheduleIntervalMs: parseNumber(
env.GATEWAY_ORCHESTRATOR_SCHEDULE_MS,
5000,
'GATEWAY_ORCHESTRATOR_SCHEDULE_MS'
),
orchestratorBuildIntervalMs: parseNumber(
env.GATEWAY_ORCHESTRATOR_BUILD_MS,
10000,
'GATEWAY_ORCHESTRATOR_BUILD_MS'
),
workspaceRootHint: env.GATEWAY_WORKSPACE_ROOT ?? process.cwd(),
worktreeRoot:
env.GATEWAY_WORKTREE_ROOT ??
path.resolve(env.GATEWAY_WORKSPACE_ROOT ?? process.cwd(), '.worktrees'),
};
};
export const resolveGatewayOrchestratorConfigFromEnv = (
env: NodeJS.ProcessEnv = process.env
): GatewayOrchestratorConfig => {
const secret = env.GAME_TOKEN_SECRET ?? env.GATEWAY_TOKEN_SECRET ?? '';
if (!secret) {
throw new Error('GAME_TOKEN_SECRET is required for game server processes.');
}
const redisKeyPrefix = env.GATEWAY_REDIS_PREFIX ?? 'sammo:gateway';
return {
redisKeyPrefix,
gameTokenSecret: secret,
orchestratorReconcileIntervalMs: parseNumber(
env.GATEWAY_ORCHESTRATOR_RECONCILE_MS,
15000,
+6 -2
View File
@@ -2,6 +2,7 @@ import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { runGatewayApiServer } from './server.js';
import { runGatewayOrchestrator } from './orchestrator/orchestratorServer.js';
export * from './config.js';
export * from './context.js';
@@ -27,8 +28,11 @@ const isMain = (): boolean => {
};
if (isMain()) {
runGatewayApiServer().catch((error) => {
console.error('[gateway-api] failed to start', error);
const role = process.env.GATEWAY_ROLE ?? 'api';
const run = role === 'orchestrator' ? runGatewayOrchestrator : runGatewayApiServer;
run().catch((error) => {
const prefix = role === 'orchestrator' ? 'gateway-orchestrator' : 'gateway-api';
console.error(`[${prefix}] failed to start`, error);
process.exitCode = 1;
});
}
@@ -54,7 +54,12 @@ export const planProfileReconcile = (
status: GatewayProfileStatus,
runtime: ProfileRuntimeState
): { shouldStart: boolean; shouldStop: boolean } => {
if (status === 'RUNNING') {
if (
status === 'RUNNING' ||
status === 'PREOPEN' ||
status === 'PAUSED' ||
status === 'COMPLETED'
) {
return {
shouldStart: !(runtime.apiRunning && runtime.daemonRunning),
shouldStop: false,
@@ -94,6 +99,7 @@ const buildProcessDefinitions = (
TURN_PROFILE: profile.profile,
PROFILE: profile.profile,
SCENARIO: profile.scenario,
TURN_PROFILE_NAME: profile.profileName,
};
return {
api: {
@@ -221,18 +227,41 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle {
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) {
if (!profile.preopenAt || !profile.openAt) {
await this.repository.updateLastError(
profile.profileName,
error instanceof Error ? error.message : 'Failed to start scheduled profile.'
'Reserved profile is missing preopen/open schedule.'
);
continue;
}
if (!profile.buildCommitSha) {
await this.repository.updateLastError(
profile.profileName,
'Reserved profile is missing build commit SHA.'
);
continue;
}
const queued =
profile.buildStatus === 'QUEUED' || profile.buildStatus === 'RUNNING';
if (!queued) {
await this.repository.updateBuildStatus(profile.profileName, 'QUEUED', {
requestedAt: now.toISOString(),
error: null,
commitSha: profile.buildCommitSha,
});
}
}
const profiles = await this.repository.listProfiles();
for (const profile of profiles) {
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,
});
}
}
} finally {
@@ -304,12 +333,24 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle {
completedAt,
error: null,
});
if (queued.status !== 'RUNNING' && queued.status !== 'DISABLED') {
if (queued.status === 'RESERVED') {
await this.repository.updateStatus(
queued.profileName,
'COMPLETED',
queued.scheduledStartAt ? queued.scheduledStartAt : null
queued.openAt && new Date(queued.openAt) <= this.now()
? 'RUNNING'
: 'PREOPEN',
{
preopenAt: queued.preopenAt ?? null,
openAt: queued.openAt ?? null,
}
);
} else if (queued.status === 'PREOPEN' && queued.openAt) {
if (new Date(queued.openAt) <= this.now()) {
await this.repository.updateStatus(queued.profileName, 'RUNNING', {
preopenAt: queued.preopenAt ?? null,
openAt: queued.openAt ?? null,
});
}
}
} else {
await this.repository.updateBuildStatus(queued.profileName, 'FAILED', {
@@ -0,0 +1,52 @@
import type { PrismaClient } from '@prisma/client';
import type { GatewayOrchestratorConfig } from '../config.js';
import { createGatewayProfileRepository } from './profileRepository.js';
import { GatewayOrchestrator } from './gatewayOrchestrator.js';
import { Pm2ProcessManager } from './pm2ProcessManager.js';
import { PnpmBuildRunner } from './buildRunner.js';
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'
);
return Object.fromEntries(entries);
};
export const createGatewayOrchestrator = (
prisma: PrismaClient,
config: GatewayOrchestratorConfig,
env: NodeJS.ProcessEnv = process.env
): {
orchestrator: GatewayOrchestrator;
profiles: ReturnType<typeof createGatewayProfileRepository>;
} => {
const profiles = createGatewayProfileRepository(prisma);
const workspaceRoot = resolveWorkspaceRoot(config.workspaceRootHint);
const processManager = new Pm2ProcessManager();
const buildRunner = new PnpmBuildRunner();
const baseEnv = buildEnvMap(env);
const workspaceManager = new GitWorkspaceManager({
repoRoot: workspaceRoot,
worktreeRoot: config.worktreeRoot,
baseEnv,
});
const orchestrator = new GatewayOrchestrator({
repository: profiles,
processManager,
buildRunner,
workspaceManager,
processConfig: {
workspaceRoot,
redisKeyPrefix: config.redisKeyPrefix,
gameTokenSecret: config.gameTokenSecret,
baseEnv,
},
reconcileIntervalMs: config.orchestratorReconcileIntervalMs,
scheduleIntervalMs: config.orchestratorScheduleIntervalMs,
buildIntervalMs: config.orchestratorBuildIntervalMs,
});
return { orchestrator, profiles };
};
@@ -0,0 +1,32 @@
import type { PrismaClient } from '@prisma/client';
import {
createPostgresConnector,
resolvePostgresConfigFromEnv,
} from '@sammo-ts/infra';
import { resolveGatewayOrchestratorConfigFromEnv } from '../config.js';
import { createGatewayOrchestrator } from './orchestratorFactory.js';
export const runGatewayOrchestrator = async (): Promise<void> => {
const config = resolveGatewayOrchestratorConfigFromEnv();
const postgres = createPostgresConnector(resolvePostgresConfigFromEnv());
await postgres.connect();
const { orchestrator } = createGatewayOrchestrator(
postgres.prisma as PrismaClient,
config,
process.env
);
const stop = async (reason: string): Promise<void> => {
console.info(`[gateway-orchestrator] stopping: ${reason}`);
await orchestrator.stop();
await postgres.disconnect();
};
process.on('SIGINT', () => void stop('SIGINT'));
process.on('SIGTERM', () => void stop('SIGTERM'));
orchestrator.start();
console.info('[gateway-orchestrator] started');
};
@@ -1,9 +1,11 @@
import { Prisma, type PrismaClient } from '@prisma/client';
export const GATEWAY_PROFILE_STATUSES = [
'COMPLETED',
'RESERVED',
'PREOPEN',
'RUNNING',
'PAUSED',
'COMPLETED',
'STOPPED',
'DISABLED',
] as const;
@@ -28,6 +30,8 @@ export interface GatewayProfileRecord {
buildCommitSha?: string;
buildWorkspace?: string;
buildLastUsedAt?: string;
preopenAt?: string;
openAt?: string;
scheduledStartAt?: string;
buildRequestedAt?: string;
buildStartedAt?: string;
@@ -44,7 +48,10 @@ export interface GatewayProfileUpsertInput {
scenario: string;
apiPort: number;
status?: GatewayProfileStatus;
preopenAt?: string;
openAt?: string;
scheduledStartAt?: string;
buildCommitSha?: string;
meta?: Prisma.JsonObject;
}
@@ -55,7 +62,11 @@ export interface GatewayProfileRepository {
updateStatus(
profileName: string,
status: GatewayProfileStatus,
scheduledStartAt?: string | null
schedule?: {
preopenAt?: string | null;
openAt?: string | null;
scheduledStartAt?: string | null;
}
): Promise<GatewayProfileRecord | null>;
updateBuildStatus(
profileName: string,
@@ -94,6 +105,8 @@ const mapProfile = (row: {
buildCommitSha: string | null;
buildWorkspace: string | null;
buildLastUsedAt: Date | null;
preopenAt: Date | null;
openAt: Date | null;
scheduledStartAt: Date | null;
buildRequestedAt: Date | null;
buildStartedAt: Date | null;
@@ -113,6 +126,8 @@ const mapProfile = (row: {
buildCommitSha: row.buildCommitSha ?? undefined,
buildWorkspace: row.buildWorkspace ?? undefined,
buildLastUsedAt: toIso(row.buildLastUsedAt),
preopenAt: toIso(row.preopenAt),
openAt: toIso(row.openAt),
scheduledStartAt: toIso(row.scheduledStartAt),
buildRequestedAt: toIso(row.buildRequestedAt),
buildStartedAt: toIso(row.buildStartedAt),
@@ -152,19 +167,36 @@ export const createGatewayProfileRepository = (
scenario: input.scenario,
apiPort: input.apiPort,
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,
buildCommitSha: input.buildCommitSha ?? null,
meta: (input.meta ?? {}) as Prisma.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,
scheduledStartAt: input.scheduledStartAt
? new Date(input.scheduledStartAt)
: input.scheduledStartAt === null
? null
: undefined,
buildCommitSha:
input.buildCommitSha === undefined
? undefined
: input.buildCommitSha,
meta: input.meta ? (input.meta as Prisma.JsonObject) : undefined,
},
});
@@ -173,17 +205,33 @@ export const createGatewayProfileRepository = (
async updateStatus(
profileName: string,
status: GatewayProfileStatus,
scheduledStartAt?: string | null
schedule?: {
preopenAt?: string | null;
openAt?: string | null;
scheduledStartAt?: string | null;
}
): Promise<GatewayProfileRecord | null> {
const row = await prisma.gatewayProfile.update({
where: { profileName },
data: {
status,
scheduledStartAt:
scheduledStartAt === undefined
preopenAt:
schedule?.preopenAt === undefined
? undefined
: scheduledStartAt
? new Date(scheduledStartAt)
: schedule?.preopenAt
? new Date(schedule.preopenAt)
: null,
openAt:
schedule?.openAt === undefined
? undefined
: schedule?.openAt
? new Date(schedule.openAt)
: null,
scheduledStartAt:
schedule?.scheduledStartAt === undefined
? undefined
: schedule?.scheduledStartAt
? new Date(schedule.scheduledStartAt)
: null,
},
});
@@ -240,7 +288,7 @@ export const createGatewayProfileRepository = (
const rows = await prisma.gatewayProfile.findMany({
where: {
status: 'RESERVED',
scheduledStartAt: {
preopenAt: {
lte: now,
},
},
+5 -43
View File
@@ -16,21 +16,9 @@ import { KakaoOAuthClient } from './auth/kakaoClient.js';
import { RedisOAuthSessionStore } from './auth/oauthSessionStore.js';
import { createPostgresUserRepository } from './auth/postgresUserRepository.js';
import { RedisGatewaySessionService } from './auth/redisSessionService.js';
import { createGatewayProfileRepository } from './orchestrator/profileRepository.js';
import { GatewayOrchestrator } from './orchestrator/gatewayOrchestrator.js';
import { Pm2ProcessManager } from './orchestrator/pm2ProcessManager.js';
import { PnpmBuildRunner } from './orchestrator/buildRunner.js';
import { resolveWorkspaceRoot } from './orchestrator/workspaceRoot.js';
import { GitWorkspaceManager } from './orchestrator/workspaceManager.js';
import { createGatewayOrchestrator } from './orchestrator/orchestratorFactory.js';
import { appRouter } from './router.js';
const buildEnvMap = (env: NodeJS.ProcessEnv): Record<string, string> => {
const entries = Object.entries(env).filter(
(entry): entry is [string, string] => typeof entry[1] === 'string'
);
return Object.fromEntries(entries);
};
export const createGatewayApiServer = async () => {
const config = resolveGatewayApiConfigFromEnv();
const postgres = createPostgresConnector(resolvePostgresConfigFromEnv());
@@ -58,33 +46,11 @@ export const createGatewayApiServer = async () => {
config.oauthSessionTtlSeconds
);
const profiles = createGatewayProfileRepository(
postgres.prisma as PrismaClient
const { orchestrator, profiles } = createGatewayOrchestrator(
postgres.prisma as PrismaClient,
config,
process.env
);
const workspaceRoot = resolveWorkspaceRoot(config.workspaceRootHint);
const processManager = new Pm2ProcessManager();
const buildRunner = new PnpmBuildRunner();
const baseEnv = buildEnvMap(process.env);
const workspaceManager = new GitWorkspaceManager({
repoRoot: workspaceRoot,
worktreeRoot: config.worktreeRoot,
baseEnv,
});
const orchestrator = new GatewayOrchestrator({
repository: profiles,
processManager,
buildRunner,
workspaceManager,
processConfig: {
workspaceRoot,
redisKeyPrefix: config.redisKeyPrefix,
gameTokenSecret: config.gameTokenSecret,
baseEnv,
},
reconcileIntervalMs: config.orchestratorReconcileIntervalMs,
scheduleIntervalMs: config.orchestratorScheduleIntervalMs,
buildIntervalMs: config.orchestratorBuildIntervalMs,
});
const app = fastify({
logger: true,
@@ -121,10 +87,6 @@ export const createGatewayApiServer = async () => {
ok: true,
}));
if (config.orchestratorEnabled) {
orchestrator.start();
}
app.addHook('onClose', async () => {
await orchestrator.stop();
await redis.disconnect();
@@ -12,6 +12,15 @@ describe('planProfileReconcile', () => {
).toEqual({ shouldStart: true, shouldStop: false });
});
it('starts processes for preopen profiles', () => {
expect(
planProfileReconcile('PREOPEN', {
apiRunning: false,
daemonRunning: false,
})
).toEqual({ shouldStart: true, shouldStop: false });
});
it('does nothing when running profile is healthy', () => {
expect(
planProfileReconcile('RUNNING', {