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
+130
View File
@@ -0,0 +1,130 @@
import { TRPCError } from '@trpc/server';
import { z } from 'zod';
import { procedure, router } from './trpc.js';
import {
GATEWAY_BUILD_STATUSES,
GATEWAY_PROFILE_STATUSES,
} from './orchestrator/profileRepository.js';
const zProfileStatus = z.enum(GATEWAY_PROFILE_STATUSES);
const zBuildStatus = z.enum(GATEWAY_BUILD_STATUSES);
const adminProcedure = procedure.use(({ ctx, next }) => {
if (!ctx.adminToken) {
throw new TRPCError({
code: 'FORBIDDEN',
message: 'Admin token is not configured.',
});
}
const provided =
ctx.requestHeaders['x-admin-token'] ??
ctx.requestHeaders['authorization'] ??
'';
const token =
Array.isArray(provided) ? provided[0] ?? '' : (provided as string);
if (!token || token !== ctx.adminToken) {
throw new TRPCError({
code: 'UNAUTHORIZED',
message: 'Invalid admin token.',
});
}
return next();
});
export const adminRouter = router({
profiles: router({
list: adminProcedure.query(async ({ ctx }) => {
const profiles = await ctx.profiles.listProfiles();
const runtimeStates = await ctx.orchestrator.listRuntimeStates(
profiles.map((profile) => profile.profileName)
);
const runtimeMap = new Map(
runtimeStates.map((state) => [state.profileName, state])
);
return profiles.map((profile) => ({
...profile,
runtime: runtimeMap.get(profile.profileName) ?? {
profileName: profile.profileName,
apiRunning: false,
daemonRunning: false,
},
}));
}),
upsert: adminProcedure
.input(
z.object({
profile: z.string().min(1).max(32),
scenario: z.string().min(1).max(64),
apiPort: z.number().int().min(1).max(65535),
status: zProfileStatus.optional(),
scheduledStartAt: z.string().datetime().optional(),
})
)
.mutation(async ({ ctx, input }) => {
const status = input.status ?? 'STOPPED';
return ctx.profiles.upsertProfile({
profile: input.profile,
scenario: input.scenario,
apiPort: input.apiPort,
status,
scheduledStartAt: input.scheduledStartAt,
});
}),
setStatus: adminProcedure
.input(
z.object({
profileName: z.string().min(1),
status: zProfileStatus,
scheduledStartAt: z.string().datetime().optional(),
})
)
.mutation(async ({ ctx, input }) => {
if (input.status === 'RESERVED' && !input.scheduledStartAt) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'scheduledStartAt is required for RESERVED status.',
});
}
const result = await ctx.profiles.updateStatus(
input.profileName,
input.status,
input.status === 'RESERVED' ? input.scheduledStartAt : null
);
await ctx.orchestrator.reconcileNow();
return result;
}),
requestBuild: adminProcedure
.input(
z.object({
profileName: z.string().min(1),
})
)
.mutation(async ({ ctx, input }) => {
const requestedAt = new Date().toISOString();
const result = await ctx.profiles.updateBuildStatus(
input.profileName,
'QUEUED',
{
requestedAt,
error: null,
}
);
return result;
}),
setBuildStatus: adminProcedure
.input(
z.object({
profileName: z.string().min(1),
status: zBuildStatus,
})
)
.mutation(async ({ ctx, input }) =>
ctx.profiles.updateBuildStatus(input.profileName, input.status)
),
reconcileNow: adminProcedure.mutation(async ({ ctx }) => {
await ctx.orchestrator.reconcileNow();
return { ok: true };
}),
}),
});