feat: Add Git workspace management for build profiles and implement workspace cleanup

This commit is contained in:
2026-01-01 10:55:07 +00:00
parent b46249dcbc
commit e64962c3fd
10 changed files with 344 additions and 7 deletions
@@ -7,6 +7,7 @@ import type {
GatewayProfileRepository,
GatewayProfileStatus,
} from './profileRepository.js';
import type { GitWorkspaceManager } from './workspaceManager.js';
export interface GatewayProcessConfig {
workspaceRoot: string;
@@ -19,6 +20,7 @@ export interface GatewayOrchestratorOptions {
repository: GatewayProfileRepository;
processManager: ProcessManager;
buildRunner: BuildRunner;
workspaceManager: GitWorkspaceManager;
processConfig: GatewayProcessConfig;
reconcileIntervalMs: number;
scheduleIntervalMs: number;
@@ -41,6 +43,10 @@ export interface GatewayOrchestratorHandle {
reconcileNow(): Promise<void>;
runScheduleNow(): Promise<void>;
runBuildQueueNow(): Promise<void>;
cleanupStaleWorkspaces(): Promise<{
removed: string[];
skipped: string[];
}>;
listRuntimeStates(profileNames: string[]): Promise<ProfileRuntimeSnapshot[]>;
}
@@ -123,6 +129,7 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle {
private readonly repository: GatewayProfileRepository;
private readonly processManager: ProcessManager;
private readonly buildRunner: BuildRunner;
private readonly workspaceManager: GitWorkspaceManager;
private readonly processConfig: GatewayProcessConfig;
private readonly reconcileIntervalMs: number;
private readonly scheduleIntervalMs: number;
@@ -139,6 +146,7 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle {
this.repository = options.repository;
this.processManager = options.processManager;
this.buildRunner = options.buildRunner;
this.workspaceManager = options.workspaceManager;
this.processConfig = options.processConfig;
this.reconcileIntervalMs = options.reconcileIntervalMs;
this.scheduleIntervalMs = options.scheduleIntervalMs;
@@ -242,25 +250,54 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle {
if (!queued) {
return;
}
if (!queued.buildCommitSha) {
await this.repository.updateBuildStatus(queued.profileName, 'FAILED', {
completedAt: this.now().toISOString(),
error: 'Missing build commit SHA.',
});
return;
}
const startedAt = this.now().toISOString();
await this.repository.updateBuildStatus(queued.profileName, 'RUNNING', {
startedAt,
error: null,
});
const result = await this.buildRunner.run([
const workspace = await this.workspaceManager.prepare(queued.buildCommitSha);
const lastUsedAt = this.now().toISOString();
await this.repository.updateWorkspaceUsage(
queued.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: this.processConfig.workspaceRoot,
cwd: workspace.root,
env: this.processConfig.baseEnv,
},
{
command: 'pnpm',
args: ['--filter', '@sammo-ts/game-engine', 'build'],
cwd: this.processConfig.workspaceRoot,
cwd: workspace.root,
env: this.processConfig.baseEnv,
},
]);
}
);
const result = await this.buildRunner.run(commands);
const completedAt = this.now().toISOString();
if (result.ok) {
await this.repository.updateBuildStatus(queued.profileName, 'SUCCEEDED', {
@@ -285,6 +322,62 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle {
}
}
async cleanupStaleWorkspaces(): Promise<{ removed: string[]; skipped: string[] }> {
const profiles = await this.repository.listProfiles();
const cutoff = this.computeCutoffDate(6);
const workspaceMap = new Map<
string,
{ profileNames: string[]; lastUsedAt?: Date; hasActiveBuild: boolean }
>();
for (const profile of profiles) {
const workspace = profile.buildWorkspace;
if (!workspace) {
continue;
}
const entry = workspaceMap.get(workspace) ?? {
profileNames: [],
lastUsedAt: undefined,
hasActiveBuild: false,
};
entry.profileNames.push(profile.profileName);
if (profile.buildLastUsedAt) {
const usedAt = new Date(profile.buildLastUsedAt);
if (!entry.lastUsedAt || usedAt > entry.lastUsedAt) {
entry.lastUsedAt = usedAt;
}
}
if (profile.buildStatus === 'RUNNING' || profile.buildStatus === 'QUEUED') {
entry.hasActiveBuild = true;
}
workspaceMap.set(workspace, entry);
}
const removed: string[] = [];
const skipped: string[] = [];
for (const [workspace, entry] of workspaceMap.entries()) {
if (!entry.lastUsedAt || entry.hasActiveBuild) {
skipped.push(workspace);
continue;
}
if (entry.lastUsedAt > cutoff) {
skipped.push(workspace);
continue;
}
await this.workspaceManager.remove(workspace);
await this.repository.clearWorkspaceUsage(entry.profileNames);
removed.push(workspace);
}
return { removed, skipped };
}
private computeCutoffDate(months: number): Date {
const date = this.now();
const cutoff = new Date(date);
cutoff.setMonth(cutoff.getMonth() - months);
return cutoff;
}
private async startProfile(profile: GatewayProfileRecord): Promise<void> {
const definitions = buildProcessDefinitions(profile, this.processConfig);
try {
@@ -25,6 +25,9 @@ export interface GatewayProfileRecord {
apiPort: number;
status: GatewayProfileStatus;
buildStatus: GatewayBuildStatus;
buildCommitSha?: string;
buildWorkspace?: string;
buildLastUsedAt?: string;
scheduledStartAt?: string;
buildRequestedAt?: string;
buildStartedAt?: string;
@@ -62,11 +65,20 @@ export interface GatewayProfileRepository {
startedAt?: string | null;
completedAt?: string | null;
error?: string | null;
commitSha?: string | null;
workspace?: string | null;
lastUsedAt?: string | null;
}
): Promise<GatewayProfileRecord | null>;
listReservedToStart(now: Date): Promise<GatewayProfileRecord[]>;
findQueuedBuild(): Promise<GatewayProfileRecord | null>;
updateLastError(profileName: string, lastError: string | null): Promise<void>;
updateWorkspaceUsage(
profileName: string,
workspace: string,
lastUsedAt: string
): Promise<void>;
clearWorkspaceUsage(profileNames: string[]): Promise<void>;
}
const toIso = (value: Date | null): string | undefined =>
@@ -79,6 +91,9 @@ const mapProfile = (row: {
apiPort: number;
status: GatewayProfileStatus;
buildStatus: GatewayBuildStatus;
buildCommitSha: string | null;
buildWorkspace: string | null;
buildLastUsedAt: Date | null;
scheduledStartAt: Date | null;
buildRequestedAt: Date | null;
buildStartedAt: Date | null;
@@ -95,6 +110,9 @@ const mapProfile = (row: {
apiPort: row.apiPort,
status: row.status,
buildStatus: row.buildStatus,
buildCommitSha: row.buildCommitSha ?? undefined,
buildWorkspace: row.buildWorkspace ?? undefined,
buildLastUsedAt: toIso(row.buildLastUsedAt),
scheduledStartAt: toIso(row.scheduledStartAt),
buildRequestedAt: toIso(row.buildRequestedAt),
buildStartedAt: toIso(row.buildStartedAt),
@@ -185,6 +203,16 @@ export const createGatewayProfileRepository = (
where: { profileName },
data: {
buildStatus: status,
buildCommitSha:
fields?.commitSha === undefined ? undefined : fields.commitSha,
buildWorkspace:
fields?.workspace === undefined ? undefined : fields.workspace,
buildLastUsedAt:
fields?.lastUsedAt === undefined
? undefined
: fields?.lastUsedAt
? new Date(fields.lastUsedAt)
: null,
buildRequestedAt:
fields?.requestedAt === undefined
? undefined
@@ -232,4 +260,31 @@ export const createGatewayProfileRepository = (
data: { lastError },
});
},
async updateWorkspaceUsage(
profileName: string,
workspace: string,
lastUsedAt: string
): Promise<void> {
await prisma.gatewayProfile.update({
where: { profileName },
data: {
buildWorkspace: workspace,
buildLastUsedAt: new Date(lastUsedAt),
},
});
},
async clearWorkspaceUsage(profileNames: string[]): Promise<void> {
if (!profileNames.length) {
return;
}
await prisma.gatewayProfile.updateMany({
where: {
profileName: { in: profileNames },
},
data: {
buildWorkspace: null,
buildLastUsedAt: null,
},
});
},
});
@@ -0,0 +1,110 @@
import fs from 'node:fs';
import path from 'node:path';
import { spawn } from 'node:child_process';
export interface WorkspaceManagerOptions {
repoRoot: string;
worktreeRoot: string;
baseEnv?: Record<string, string>;
}
export interface WorkspaceInfo {
root: string;
created: boolean;
needsInstall: boolean;
}
const runGit = (
args: string[],
cwd: string,
env?: Record<string, string>
): Promise<{ ok: boolean; output: string }> =>
new Promise((resolve) => {
const child = spawn('git', args, {
cwd,
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, output });
});
});
const ensureDir = (dir: string): void => {
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
}
};
const hasInstallMarker = (dir: string): boolean =>
fs.existsSync(path.join(dir, 'node_modules', '.pnpm'));
export class GitWorkspaceManager {
private readonly repoRoot: string;
private readonly worktreeRoot: string;
private readonly baseEnv?: Record<string, string>;
constructor(options: WorkspaceManagerOptions) {
this.repoRoot = options.repoRoot;
this.worktreeRoot = options.worktreeRoot;
this.baseEnv = options.baseEnv;
}
async prepare(commitSha: string): Promise<WorkspaceInfo> {
const workspacePath = path.join(this.worktreeRoot, commitSha);
ensureDir(this.worktreeRoot);
const exists = fs.existsSync(workspacePath);
if (!exists) {
const hasCommit = await runGit(
['cat-file', '-e', `${commitSha}^{commit}`],
this.repoRoot,
this.baseEnv
);
if (!hasCommit.ok) {
await runGit(['fetch', '--all', '--tags'], this.repoRoot, this.baseEnv);
}
const result = await runGit(
['worktree', 'add', '--detach', workspacePath, commitSha],
this.repoRoot,
this.baseEnv
);
if (!result.ok) {
throw new Error(result.output || 'Failed to create git worktree.');
}
}
return {
root: workspacePath,
created: !exists,
needsInstall: !hasInstallMarker(workspacePath),
};
}
async remove(workspacePath: string): Promise<boolean> {
const resolved = path.resolve(workspacePath);
const root = path.resolve(this.worktreeRoot);
if (!resolved.startsWith(root)) {
throw new Error('Workspace path is outside the configured worktree root.');
}
if (!fs.existsSync(resolved)) {
return false;
}
const result = await runGit(
['worktree', 'remove', '--force', resolved],
this.repoRoot,
this.baseEnv
);
if (!result.ok) {
fs.rmSync(resolved, { recursive: true, force: true });
}
return true;
}
}