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
+9
View File
@@ -98,6 +98,7 @@ export const adminRouter = router({
.input(
z.object({
profileName: z.string().min(1),
commitSha: z.string().min(7).max(64),
})
)
.mutation(async ({ ctx, input }) => {
@@ -108,6 +109,7 @@ export const adminRouter = router({
{
requestedAt,
error: null,
commitSha: input.commitSha,
}
);
return result;
@@ -126,5 +128,12 @@ export const adminRouter = router({
await ctx.orchestrator.reconcileNow();
return { ok: true };
}),
cleanupWorkspaces: adminProcedure.mutation(async ({ ctx }) => {
const result = await ctx.orchestrator.cleanupStaleWorkspaces();
return {
removed: result.removed,
skipped: result.skipped,
};
}),
}),
});
+6
View File
@@ -1,3 +1,5 @@
import path from 'node:path';
export interface GatewayApiConfig {
host: string;
port: number;
@@ -18,6 +20,7 @@ export interface GatewayApiConfig {
orchestratorScheduleIntervalMs: number;
orchestratorBuildIntervalMs: number;
workspaceRootHint: string;
worktreeRoot: string;
}
const parseNumber = (value: string | undefined, fallback: number, label: string): number => {
@@ -99,5 +102,8 @@ export const resolveGatewayApiConfigFromEnv = (
'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'),
};
};
@@ -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;
}
}
+7
View File
@@ -21,6 +21,7 @@ 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 { appRouter } from './router.js';
const buildEnvMap = (env: NodeJS.ProcessEnv): Record<string, string> => {
@@ -64,10 +65,16 @@ export const createGatewayApiServer = async () => {
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,
+6
View File
@@ -47,6 +47,8 @@ const buildCaller = () => {
listReservedToStart: async () => [],
findQueuedBuild: async () => null,
updateLastError: async () => {},
updateWorkspaceUsage: async () => {},
clearWorkspaceUsage: async () => {},
};
const orchestrator = {
start: () => {},
@@ -54,6 +56,10 @@ const buildCaller = () => {
reconcileNow: async () => {},
runScheduleNow: async () => {},
runBuildQueueNow: async () => {},
cleanupStaleWorkspaces: async () => ({
removed: [],
skipped: [],
}),
listRuntimeStates: async () => [],
};
const caller = appRouter.createCaller(
+8 -2
View File
@@ -54,8 +54,14 @@ Gateway runs a lightweight cron loop (setInterval) that:
### Build Workflow (Admin)
- Admin triggers a build request for a profile.
- Gateway queues a build job, runs `pnpm --filter @sammo-ts/game-api build`
and `pnpm --filter @sammo-ts/game-engine build`, then marks build success/failure.
- Gateway queues a build job with `(profileName, commitSha)` and prepares a
per-commit workspace (`/var/sammo/workspaces/{commitSha}` recommended).
- Workspace is backed by `git worktree` and is reused across builds for the same commit.
- Each workspace stores `lastUsedAt` in DB so cleanup can remove stale worktrees.
- Cleanup is invoked manually by admin API and removes worktrees unused for 6+ months.
- Build runs `pnpm install` when workspace is created, then executes
`pnpm --filter @sammo-ts/game-api build` and
`pnpm --filter @sammo-ts/game-engine build`, then marks build success/failure.
- On success, profile status remains `COMPLETED` (or stays `RUNNING` if already on).
## Current Implementation Status
@@ -0,0 +1,42 @@
-- Create enums for gateway profile tracking
CREATE TYPE "GatewayProfileStatus" AS ENUM (
'COMPLETED',
'RESERVED',
'RUNNING',
'STOPPED',
'DISABLED'
);
CREATE TYPE "GatewayBuildStatus" AS ENUM (
'IDLE',
'QUEUED',
'RUNNING',
'FAILED',
'SUCCEEDED'
);
-- Create gateway profile table
CREATE TABLE "gateway_profile" (
"profile_name" TEXT NOT NULL,
"profile" TEXT NOT NULL,
"scenario" TEXT NOT NULL,
"api_port" INTEGER NOT NULL,
"status" "GatewayProfileStatus" NOT NULL,
"build_status" "GatewayBuildStatus" NOT NULL DEFAULT 'IDLE',
"build_commit_sha" TEXT,
"build_workspace" TEXT,
"build_last_used_at" TIMESTAMP(3),
"scheduled_start_at" TIMESTAMP(3),
"build_requested_at" TIMESTAMP(3),
"build_started_at" TIMESTAMP(3),
"build_completed_at" TIMESTAMP(3),
"build_error" TEXT,
"last_error" TEXT,
"meta" JSONB NOT NULL DEFAULT '{}'::jsonb,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "gateway_profile_pkey" PRIMARY KEY ("profile_name"),
CONSTRAINT "gateway_profile_profile_scenario_key" UNIQUE ("profile", "scenario")
);
+3
View File
@@ -69,6 +69,9 @@ model GatewayProfile {
apiPort Int @map("api_port")
status GatewayProfileStatus
buildStatus GatewayBuildStatus @default(IDLE) @map("build_status")
buildCommitSha String? @map("build_commit_sha")
buildWorkspace String? @map("build_workspace")
buildLastUsedAt DateTime? @map("build_last_used_at")
scheduledStartAt DateTime? @map("scheduled_start_at")
buildRequestedAt DateTime? @map("build_requested_at")
buildStartedAt DateTime? @map("build_started_at")