feat: add admin scenario operations

This commit is contained in:
2026-07-25 11:38:58 +00:00
parent a83f7a44e3
commit 02d22de72c
19 changed files with 2020 additions and 22 deletions
+254 -3
View File
@@ -4,10 +4,18 @@ import path from 'node:path';
import { TRPCError } from '@trpc/server';
import { z } from 'zod';
import { createGamePostgresConnector, resolvePostgresConfigFromEnv } from '@sammo-ts/infra';
import {
createGamePostgresConnector,
resolvePostgresConfigFromEnv,
type GatewayPrisma,
} from '@sammo-ts/infra';
import { procedure, router } from './trpc.js';
import { listScenarioPreviews, resolveGitCommitSha } from './scenario/scenarioCatalog.js';
import {
listScenarioPreviews,
resolveGitBranchCommitSha,
resolveGitCommitSha,
} from './scenario/scenarioCatalog.js';
import type { UserSanctions, UserServerRestriction } from './auth/userRepository.js';
import { toPublicUser } from './auth/userRepository.js';
import type { AdminAuthContext } from './adminAuth.js';
@@ -241,6 +249,8 @@ const zInstallAutorun = z.object({
});
const isAllowedTurnTerm = (value: number): boolean => TURN_TERM_MINUTES.some((term) => term === value);
const isUniqueConstraintError = (error: unknown): boolean =>
Boolean(error && typeof error === 'object' && 'code' in error && error.code === 'P2002');
const zInstallOptions = z.object({
scenarioId: z.number().int().min(0),
@@ -260,6 +270,8 @@ const zInstallOptions = z.object({
preopenAt: z.string().datetime().optional(),
gitRef: z.string().min(1).max(128).optional(),
});
const zOperationInstallOptions = zInstallOptions.omit({ gitRef: true });
const zSourceMode = z.enum(['BRANCH', 'COMMIT']);
type SanctionsPatch = z.infer<typeof zSanctionsPatch>;
@@ -578,6 +590,237 @@ export const adminRouter = router({
return { ok: true };
}),
}),
operations: router({
list: adminProcedure
.input(
z
.object({
profileName: z.string().min(1).optional(),
limit: z.number().int().min(1).max(200).optional(),
})
.optional()
)
.query(async ({ ctx, input }) => {
const adminAuth = requireAdminAuth(ctx);
if (input?.profileName) {
assertPermission(adminAuth, ROLE_ADMIN_PROFILES, input.profileName);
return ctx.profiles.listOperations({
profileName: input.profileName,
limit: input.limit,
});
}
if (hasScopedPermission(adminAuth, ROLE_ADMIN_PROFILES)) {
return ctx.profiles.listOperations({ limit: input?.limit });
}
const profiles = await ctx.profiles.listProfiles();
const allowed = profiles.filter((profile) =>
hasScopedPermission(adminAuth, ROLE_ADMIN_PROFILES, profile.profileName)
);
const operations = (
await Promise.all(
allowed.map((profile) =>
ctx.profiles.listOperations({
profileName: profile.profileName,
limit: input?.limit,
})
)
)
)
.flat()
.sort((left, right) => right.createdAt.localeCompare(left.createdAt));
return operations.slice(0, input?.limit ?? 50);
}),
requestReset: adminProcedure
.input(
z.object({
profileName: z.string().min(1),
sourceMode: zSourceMode,
sourceRef: z.string().min(1).max(128),
install: zOperationInstallOptions,
scheduledAt: z.string().datetime().optional(),
reason: z.string().max(200).optional(),
})
)
.mutation(async ({ ctx, input }) => {
const adminAuth = requireAdminAuth(ctx);
assertPermission(adminAuth, ROLE_ADMIN_PROFILES, input.profileName);
const profile = await ctx.profiles.getProfile(input.profileName);
if (!profile) {
throw new TRPCError({ code: 'NOT_FOUND', message: 'Profile not found.' });
}
if (input.scheduledAt && new Date(input.scheduledAt).getTime() <= Date.now()) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'scheduledAt must be in the future.',
});
}
const scheduledAt = input.scheduledAt ? new Date(input.scheduledAt) : null;
const openAt = input.install.openAt ? new Date(input.install.openAt) : null;
const preopenAt = input.install.preopenAt ? new Date(input.install.preopenAt) : null;
if (preopenAt && !openAt) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'openAt is required when preopenAt is set.',
});
}
if (preopenAt && openAt && preopenAt.getTime() >= openAt.getTime()) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'preopenAt must be earlier than openAt.',
});
}
if (openAt && openAt.getTime() <= (scheduledAt?.getTime() ?? Date.now())) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'openAt must be later than the reset start.',
});
}
if (preopenAt && scheduledAt && preopenAt.getTime() < scheduledAt.getTime()) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'preopenAt cannot be earlier than scheduledAt.',
});
}
const autorunUser = input.install.autorunUser;
if (
autorunUser &&
((autorunUser.limitMinutes <= 0 && autorunUser.options.length > 0) ||
(autorunUser.limitMinutes > 0 && autorunUser.options.length === 0))
) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'autorunUser minutes and options must be configured together.',
});
}
let sourceRef = input.sourceRef.trim();
try {
const resolved =
input.sourceMode === 'BRANCH'
? await resolveGitBranchCommitSha(sourceRef)
: await resolveGitCommitSha(sourceRef);
if (input.sourceMode === 'COMMIT') {
sourceRef = resolved;
}
const scenarios = await listScenarioPreviews({ gitRef: resolved });
if (!scenarios.some((scenario) => scenario.id === input.install.scenarioId)) {
throw new Error('Scenario not found at source.');
}
} catch (error) {
throw new TRPCError({
code: 'BAD_REQUEST',
message:
input.sourceMode === 'BRANCH'
? 'Branch is invalid or does not contain the scenario.'
: 'Commit is invalid or does not contain the scenario.',
});
}
try {
const operation = await ctx.profiles.createOperation({
profileName: input.profileName,
type: 'RESET',
sourceMode: input.sourceMode,
sourceRef,
payload: { install: input.install } as GatewayPrisma.JsonObject,
reason: input.reason,
requestedBy: adminAuth.user.id,
scheduledAt: input.scheduledAt,
});
return operation;
} catch (error) {
if (!isUniqueConstraintError(error)) {
throw error;
}
throw new TRPCError({
code: 'CONFLICT',
message: 'This profile already has a queued or running operation.',
});
}
}),
requestRuntime: adminProcedure
.input(
z.object({
profileName: z.string().min(1),
action: z.enum(['START', 'STOP']),
reason: z.string().max(200).optional(),
})
)
.mutation(async ({ ctx, input }) => {
const adminAuth = requireAdminAuth(ctx);
assertPermission(adminAuth, ROLE_ADMIN_PROFILES, input.profileName);
const profile = await ctx.profiles.getProfile(input.profileName);
if (!profile) {
throw new TRPCError({ code: 'NOT_FOUND', message: 'Profile not found.' });
}
try {
const operation = await ctx.profiles.createOperation({
profileName: input.profileName,
type: input.action,
reason: input.reason,
requestedBy: adminAuth.user.id,
});
return operation;
} catch (error) {
if (!isUniqueConstraintError(error)) {
throw error;
}
throw new TRPCError({
code: 'CONFLICT',
message: 'This profile already has a queued or running operation.',
});
}
}),
cancel: adminProcedure
.input(z.object({ id: z.string().uuid() }))
.mutation(async ({ ctx, input }) => {
const adminAuth = requireAdminAuth(ctx);
const previous = await ctx.profiles.getOperation(input.id);
if (!previous) {
throw new TRPCError({ code: 'NOT_FOUND', message: 'Operation not found.' });
}
assertPermission(adminAuth, ROLE_ADMIN_PROFILES, previous.profileName);
const cancelled = await ctx.profiles.cancelOperation(input.id);
if (!cancelled) {
throw new TRPCError({
code: 'CONFLICT',
message: 'Only queued operations can be cancelled.',
});
}
return { ok: true };
}),
retry: adminProcedure
.input(z.object({ id: z.string().uuid() }))
.mutation(async ({ ctx, input }) => {
const adminAuth = requireAdminAuth(ctx);
const previous = await ctx.profiles.getOperation(input.id);
if (!previous) {
throw new TRPCError({ code: 'NOT_FOUND', message: 'Operation not found.' });
}
assertPermission(adminAuth, ROLE_ADMIN_PROFILES, previous.profileName);
try {
const operation = await ctx.profiles.retryOperation(input.id, adminAuth.user.id);
if (!operation) {
throw new TRPCError({
code: 'CONFLICT',
message: 'Only failed or cancelled operations can be retried.',
});
}
return operation;
} catch (error) {
if (error instanceof TRPCError) {
throw error;
}
if (!isUniqueConstraintError(error)) {
throw error;
}
throw new TRPCError({
code: 'CONFLICT',
message: 'This profile already has a queued or running operation.',
});
}
}),
}),
profiles: router({
list: adminProcedure.query(async ({ ctx }) => {
const profiles = await ctx.profiles.listProfiles();
@@ -599,12 +842,20 @@ export const adminRouter = router({
z
.object({
gitRef: z.string().min(1).max(128).optional(),
sourceMode: zSourceMode.optional(),
})
.optional()
)
.query(async ({ input }) => {
const gitRef = input?.gitRef?.trim();
return listScenarioPreviews({ gitRef: gitRef || null });
if (!gitRef) {
return listScenarioPreviews();
}
const resolved =
input?.sourceMode === 'BRANCH'
? await resolveGitBranchCommitSha(gitRef)
: await resolveGitCommitSha(gitRef);
return listScenarioPreviews({ gitRef: resolved });
}),
upsert: profileAdminProcedure
.input(
@@ -7,7 +7,12 @@ import { isRecord } from '@sammo-ts/common';
import type { BuildCommand, BuildRunner } from './buildRunner.js';
import type { ProcessManager } from './processManager.js';
import type { GatewayProfileRecord, GatewayProfileRepository, GatewayProfileStatus } from './profileRepository.js';
import type {
GatewayOperationRecord,
GatewayProfileRecord,
GatewayProfileRepository,
GatewayProfileStatus,
} from './profileRepository.js';
import type { GitWorkspaceManager } from './workspaceManager.js';
import { seedProfileDatabase, type AdminSeedUser } from './seedProfileDatabase.js';
@@ -46,6 +51,7 @@ export interface GatewayOrchestratorHandle {
reconcileNow(): Promise<void>;
runScheduleNow(): Promise<void>;
runBuildQueueNow(): Promise<void>;
runOperationsNow(): Promise<void>;
cleanupStaleWorkspaces(): Promise<{
removed: string[];
skipped: string[];
@@ -376,6 +382,7 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle {
private scheduleInFlight = false;
private buildInFlight = false;
private adminActionInFlight = false;
private operationInFlight = false;
private readonly resetInFlight = new Set<string>();
constructor(options: GatewayOrchestratorOptions) {
@@ -393,11 +400,15 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle {
start(): void {
void this.reconcileNow();
void this.runOperationsNow();
void this.runAdminActionsNow();
this.reconcileTimer = setInterval(() => void this.reconcileNow(), this.reconcileIntervalMs);
this.scheduleTimer = setInterval(() => void this.runScheduleNow(), this.scheduleIntervalMs);
this.buildTimer = setInterval(() => void this.runBuildQueueNow(), this.buildIntervalMs);
this.adminActionTimer = setInterval(() => void this.runAdminActionsNow(), this.adminActionIntervalMs);
this.adminActionTimer = setInterval(() => {
void this.runOperationsNow();
void this.runAdminActionsNow();
}, this.adminActionIntervalMs);
}
async stop(): Promise<void> {
@@ -548,6 +559,83 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle {
}
}
async runOperationsNow(): Promise<void> {
if (this.operationInFlight || this.buildInFlight) {
return;
}
this.operationInFlight = true;
try {
const operation = await this.repository.claimNextOperation(this.now());
if (!operation) {
return;
}
await this.handleOperation(operation);
} finally {
this.operationInFlight = false;
}
}
private async handleOperation(operation: GatewayOperationRecord): Promise<void> {
const profile = await this.repository.getProfile(operation.profileName);
if (!profile) {
await this.repository.completeOperation(operation.id, 'FAILED', {
error: 'Profile not found.',
});
return;
}
try {
if (operation.type === 'START') {
const updated = await this.repository.updateStatus(profile.profileName, 'RUNNING', {
preopenAt: null,
openAt: null,
scheduledStartAt: null,
});
const started = await this.startProfile(updated ?? profile);
if (!started) {
throw new Error('Failed to start profile processes.');
}
await this.repository.completeOperation(operation.id, 'SUCCEEDED', { error: null });
return;
}
if (operation.type === 'STOP') {
await this.repository.updateStatus(profile.profileName, 'STOPPED');
await this.stopProfile(profile);
await this.repository.completeOperation(operation.id, 'SUCCEEDED', { error: null });
return;
}
if (!operation.sourceMode || !operation.sourceRef) {
throw new Error('Reset source mode and ref are required.');
}
const commitSha = await this.workspaceManager.resolveCommit(operation.sourceMode, operation.sourceRef);
const payload = normalizeMeta(operation.payload);
const install = isRecord(payload.install) ? payload.install : {};
const resetAction: GatewayAdminActionRecord = {
action: operation.scheduledAt ? 'RESET_SCHEDULED' : 'RESET_NOW',
requestedAt: operation.createdAt,
scheduledAt: operation.scheduledAt ?? null,
reason: operation.reason ?? null,
install,
};
const result = await this.handleResetAction(profile, resetAction, commitSha);
if (result.status === 'REQUESTED') {
const retryAt = new Date(this.now().getTime() + this.adminActionIntervalMs).toISOString();
await this.repository.requeueOperation(operation.id, result.detail, retryAt);
return;
}
if (result.status !== 'APPLIED') {
throw new Error(result.detail ?? 'Reset failed.');
}
await this.repository.completeOperation(operation.id, 'SUCCEEDED', {
resolvedCommitSha: commitSha,
error: null,
});
} catch (error) {
const detail = error instanceof Error ? error.message : String(error);
await this.repository.completeOperation(operation.id, 'FAILED', { error: detail });
}
}
private async runAdminActionsNow(): Promise<void> {
if (this.adminActionInFlight) {
return;
@@ -632,7 +720,8 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle {
private async handleResetAction(
profile: GatewayProfileRecord,
action: GatewayAdminActionRecord
action: GatewayAdminActionRecord,
commitShaOverride?: string
): Promise<GatewayAdminActionResult> {
// 리셋 요청을 빌드+재기동 흐름으로 처리한다.
if (this.resetInFlight.has(profile.profileName)) {
@@ -651,7 +740,7 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle {
}
}
const commitSha = profile.buildCommitSha;
const commitSha = commitShaOverride ?? profile.buildCommitSha;
if (!commitSha) {
return { status: 'FAILED', detail: 'buildCommitSha is missing' };
}
@@ -734,7 +823,10 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle {
scheduledStartAt: action.scheduledAt ?? null,
});
const builtProfile = (await this.repository.getProfile(profile.profileName)) ?? activeProfile;
await this.startProfile(builtProfile);
const started = await this.startProfile(builtProfile);
if (!started) {
return { status: 'FAILED', detail: 'reset completed but profile processes failed to start' };
}
return { status: 'APPLIED', detail: 'reset completed via rebuild' };
} catch (error) {
const detail = error instanceof Error ? error.message : String(error);
@@ -852,32 +944,39 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle {
return cutoff;
}
private async startProfile(profile: GatewayProfileRecord): Promise<void> {
private async startProfile(profile: GatewayProfileRecord): Promise<boolean> {
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);
return true;
} catch (error) {
await this.repository.updateLastError(
profile.profileName,
error instanceof Error ? error.message : 'Failed to start processes.'
);
return false;
}
}
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);
const failures: string[] = [];
for (const name of [apiName, daemonName]) {
try {
await this.processManager.stop(name);
} catch {
try {
await this.processManager.delete(name);
} catch (error) {
failures.push(`${name}: ${error instanceof Error ? error.message : String(error)}`);
}
}
}
try {
await this.processManager.stop(daemonName);
} catch {
await this.processManager.delete(daemonName);
if (failures.length > 0) {
throw new Error(`Failed to stop profile processes: ${failures.join('; ')}`);
}
}
@@ -14,6 +14,45 @@ 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 const GATEWAY_OPERATION_TYPES = ['RESET', 'START', 'STOP'] as const;
export type GatewayOperationType = (typeof GATEWAY_OPERATION_TYPES)[number];
export const GATEWAY_OPERATION_STATUSES = ['QUEUED', 'RUNNING', 'SUCCEEDED', 'FAILED', 'CANCELLED'] as const;
export type GatewayOperationStatus = (typeof GATEWAY_OPERATION_STATUSES)[number];
export const GATEWAY_SOURCE_MODES = ['BRANCH', 'COMMIT'] as const;
export type GatewaySourceMode = (typeof GATEWAY_SOURCE_MODES)[number];
export interface GatewayOperationRecord {
id: string;
profileName: string;
type: GatewayOperationType;
status: GatewayOperationStatus;
sourceMode?: GatewaySourceMode;
sourceRef?: string;
resolvedCommitSha?: string;
payload: GatewayPrisma.JsonObject;
reason?: string;
requestedBy: string;
scheduledAt?: string;
startedAt?: string;
completedAt?: string;
error?: string;
createdAt: string;
updatedAt: string;
}
export interface GatewayOperationCreateInput {
profileName: string;
type: GatewayOperationType;
sourceMode?: GatewaySourceMode;
sourceRef?: string;
payload?: GatewayPrisma.JsonObject;
reason?: string;
requestedBy: string;
scheduledAt?: string;
}
export interface GatewayProfileRecord {
profileName: string;
profile: string;
@@ -82,6 +121,18 @@ export interface GatewayProfileRepository {
updateLastError(profileName: string, lastError: string | null): Promise<void>;
updateWorkspaceUsage(profileName: string, workspace: string, lastUsedAt: string): Promise<void>;
clearWorkspaceUsage(profileNames: string[]): Promise<void>;
listOperations(options?: { profileName?: string; limit?: number }): Promise<GatewayOperationRecord[]>;
getOperation(id: string): Promise<GatewayOperationRecord | null>;
createOperation(input: GatewayOperationCreateInput): Promise<GatewayOperationRecord>;
claimNextOperation(now: Date): Promise<GatewayOperationRecord | null>;
completeOperation(
id: string,
status: Extract<GatewayOperationStatus, 'SUCCEEDED' | 'FAILED'>,
fields?: { resolvedCommitSha?: string | null; error?: string | null }
): Promise<GatewayOperationRecord>;
requeueOperation(id: string, detail?: string, retryAt?: string): Promise<GatewayOperationRecord>;
cancelOperation(id: string): Promise<boolean>;
retryOperation(id: string, requestedBy: string): Promise<GatewayOperationRecord | null>;
}
const toIso = (value: Date | null): string | undefined => (value ? value.toISOString() : undefined);
@@ -109,6 +160,25 @@ type GatewayProfileRow = {
updatedAt: Date;
};
type GatewayOperationRow = {
id: string;
profileName: string;
type: GatewayOperationType;
status: GatewayOperationStatus;
sourceMode: GatewaySourceMode | null;
sourceRef: string | null;
resolvedCommitSha: string | null;
payload: GatewayPrisma.JsonValue;
reason: string | null;
requestedBy: string;
scheduledAt: Date | null;
startedAt: Date | null;
completedAt: Date | null;
error: string | null;
createdAt: Date;
updatedAt: Date;
};
const mapProfile = (row: GatewayProfileRow): GatewayProfileRecord => ({
profileName: row.profileName,
profile: row.profile,
@@ -134,6 +204,25 @@ const mapProfile = (row: GatewayProfileRow): GatewayProfileRecord => ({
const buildProfileName = (profile: string, scenario: string): string => `${profile}:${scenario}`;
const mapOperation = (row: GatewayOperationRow): GatewayOperationRecord => ({
id: row.id,
profileName: row.profileName,
type: row.type,
status: row.status,
sourceMode: row.sourceMode ?? undefined,
sourceRef: row.sourceRef ?? undefined,
resolvedCommitSha: row.resolvedCommitSha ?? undefined,
payload: (row.payload ?? {}) as GatewayPrisma.JsonObject,
reason: row.reason ?? undefined,
requestedBy: row.requestedBy,
scheduledAt: toIso(row.scheduledAt),
startedAt: toIso(row.startedAt),
completedAt: toIso(row.completedAt),
error: row.error ?? undefined,
createdAt: row.createdAt.toISOString(),
updatedAt: row.updatedAt.toISOString(),
});
export const createGatewayProfileRepository = (prisma: GatewayPrismaClient): GatewayProfileRepository => ({
async listProfiles(): Promise<GatewayProfileRecord[]> {
const rows = await prisma.gatewayProfile.findMany({
@@ -327,4 +416,111 @@ export const createGatewayProfileRepository = (prisma: GatewayPrismaClient): Gat
},
});
},
async listOperations(options?: { profileName?: string; limit?: number }): Promise<GatewayOperationRecord[]> {
const rows = await prisma.gatewayOperation.findMany({
where: options?.profileName ? { profileName: options.profileName } : undefined,
orderBy: { createdAt: 'desc' },
take: Math.min(Math.max(options?.limit ?? 50, 1), 200),
});
return rows.map(mapOperation);
},
async getOperation(id: string): Promise<GatewayOperationRecord | null> {
const row = await prisma.gatewayOperation.findUnique({ where: { id } });
return row ? mapOperation(row) : null;
},
async createOperation(input: GatewayOperationCreateInput): Promise<GatewayOperationRecord> {
const row = await prisma.gatewayOperation.create({
data: {
profileName: input.profileName,
type: input.type,
sourceMode: input.sourceMode,
sourceRef: input.sourceRef,
payload: (input.payload ?? {}) as GatewayPrisma.JsonObject,
reason: input.reason,
requestedBy: input.requestedBy,
scheduledAt: input.scheduledAt ? new Date(input.scheduledAt) : null,
},
});
return mapOperation(row);
},
async claimNextOperation(now: Date): Promise<GatewayOperationRecord | null> {
const row = await prisma.$transaction(async (tx) => {
const candidate = await tx.gatewayOperation.findFirst({
where: {
status: 'QUEUED',
OR: [{ scheduledAt: null }, { scheduledAt: { lte: now } }],
},
orderBy: { createdAt: 'asc' },
});
if (!candidate) {
return null;
}
const claimed = await tx.gatewayOperation.updateMany({
where: { id: candidate.id, status: 'QUEUED' },
data: { status: 'RUNNING', startedAt: now, error: null },
});
if (claimed.count !== 1) {
return null;
}
return tx.gatewayOperation.findUnique({ where: { id: candidate.id } });
});
return row ? mapOperation(row) : null;
},
async completeOperation(
id: string,
status: Extract<GatewayOperationStatus, 'SUCCEEDED' | 'FAILED'>,
fields?: { resolvedCommitSha?: string | null; error?: string | null }
): Promise<GatewayOperationRecord> {
const row = await prisma.gatewayOperation.update({
where: { id },
data: {
status,
completedAt: new Date(),
resolvedCommitSha:
fields?.resolvedCommitSha === undefined ? undefined : fields.resolvedCommitSha,
error: fields?.error === undefined ? undefined : fields.error,
},
});
return mapOperation(row);
},
async requeueOperation(id: string, detail?: string, retryAt?: string): Promise<GatewayOperationRecord> {
const row = await prisma.gatewayOperation.update({
where: { id },
data: {
status: 'QUEUED',
startedAt: null,
error: detail,
scheduledAt: retryAt ? new Date(retryAt) : undefined,
},
});
return mapOperation(row);
},
async cancelOperation(id: string): Promise<boolean> {
const result = await prisma.gatewayOperation.updateMany({
where: { id, status: 'QUEUED' },
data: { status: 'CANCELLED', completedAt: new Date() },
});
return result.count === 1;
},
async retryOperation(id: string, requestedBy: string): Promise<GatewayOperationRecord | null> {
const row = await prisma.$transaction(async (tx) => {
const previous = await tx.gatewayOperation.findUnique({ where: { id } });
if (!previous || (previous.status !== 'FAILED' && previous.status !== 'CANCELLED')) {
return null;
}
return tx.gatewayOperation.create({
data: {
profileName: previous.profileName,
type: previous.type,
sourceMode: previous.sourceMode,
sourceRef: previous.sourceRef,
payload: previous.payload as GatewayPrisma.JsonObject,
reason: previous.reason,
requestedBy,
scheduledAt: null,
},
});
});
return row ? mapOperation(row) : null;
},
});
@@ -40,6 +40,15 @@ const ensureDir = (dir: string): void => {
};
const hasInstallMarker = (dir: string): boolean => fs.existsSync(path.join(dir, 'node_modules', '.pnpm'));
const GIT_REF_PATTERN = /^[0-9A-Za-z._/-]+$/;
const assertGitRef = (value: string): string => {
const ref = value.trim();
if (!ref || ref.startsWith('-') || ref.includes('..') || !GIT_REF_PATTERN.test(ref)) {
throw new Error('Invalid git ref.');
}
return ref;
};
export class GitWorkspaceManager {
private readonly repoRoot: string;
@@ -52,6 +61,28 @@ export class GitWorkspaceManager {
this.baseEnv = options.baseEnv;
}
async resolveCommit(sourceMode: 'BRANCH' | 'COMMIT', sourceRef: string): Promise<string> {
const ref = assertGitRef(sourceRef);
if (sourceMode === 'BRANCH') {
const fetched = await runGit(['fetch', '--all', '--prune'], this.repoRoot, this.baseEnv);
if (!fetched.ok) {
throw new Error(fetched.output || 'Failed to fetch git branches.');
}
}
const candidates =
sourceMode === 'BRANCH'
? [`refs/remotes/origin/${ref}^{commit}`, `refs/heads/${ref}^{commit}`]
: [`${ref}^{commit}`];
for (const candidate of candidates) {
const result = await runGit(['rev-parse', '--verify', candidate], this.repoRoot, this.baseEnv);
const commitSha = result.output.trim().split('\n')[0];
if (result.ok && /^[0-9a-f]{40}$/i.test(commitSha)) {
return commitSha;
}
}
throw new Error(`${sourceMode === 'BRANCH' ? 'Branch' : 'Commit'} not found.`);
}
async prepare(commitSha: string): Promise<WorkspaceInfo> {
const workspacePath = path.join(this.worktreeRoot, commitSha);
ensureDir(this.worktreeRoot);
@@ -98,6 +98,22 @@ export const resolveGitCommitSha = async (gitRef: string): Promise<string> => {
return commit;
};
export const resolveGitBranchCommitSha = async (branch: string): Promise<string> => {
const normalized = normalizeGitRef(branch);
if (!normalized) {
throw new Error('git branch is invalid.');
}
await runGit(['fetch', '--all', '--prune']);
for (const candidate of [`refs/remotes/origin/${normalized}`, `refs/heads/${normalized}`]) {
const result = await runGit(['rev-parse', '--verify', `${candidate}^{commit}`]);
const commit = result.output.trim().split('\n')[0];
if (result.ok && /^[0-9a-f]{40}$/i.test(commit)) {
return commit;
}
}
throw new Error('git branch not found.');
};
const readGitFile = async (commitSha: string, relativePath: string): Promise<string> => {
const result = await runGit(['show', `${commitSha}:${relativePath}`]);
if (!result.ok) {