From e64962c3fd4cb431b2aca7cf364edbabc2b76c52 Mon Sep 17 00:00:00 2001 From: Hide_D Date: Thu, 1 Jan 2026 10:55:07 +0000 Subject: [PATCH] feat: Add Git workspace management for build profiles and implement workspace cleanup --- app/gateway-api/src/adminRouter.ts | 9 ++ app/gateway-api/src/config.ts | 6 + .../src/orchestrator/gatewayOrchestrator.ts | 103 +++++++++++++++- .../src/orchestrator/profileRepository.ts | 55 +++++++++ .../src/orchestrator/workspaceManager.ts | 110 ++++++++++++++++++ app/gateway-api/src/server.ts | 7 ++ app/gateway-api/test/authFlow.test.ts | 6 + docs/architecture/runtime.md | 10 +- .../migration.sql | 42 +++++++ packages/infra/prisma/schema.prisma | 3 + 10 files changed, 344 insertions(+), 7 deletions(-) create mode 100644 app/gateway-api/src/orchestrator/workspaceManager.ts create mode 100644 packages/infra/prisma/migrations/20250105000000_add_gateway_profile/migration.sql diff --git a/app/gateway-api/src/adminRouter.ts b/app/gateway-api/src/adminRouter.ts index 476c120..733fab5 100644 --- a/app/gateway-api/src/adminRouter.ts +++ b/app/gateway-api/src/adminRouter.ts @@ -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, + }; + }), }), }); diff --git a/app/gateway-api/src/config.ts b/app/gateway-api/src/config.ts index 95aeac5..53e4fe6 100644 --- a/app/gateway-api/src/config.ts +++ b/app/gateway-api/src/config.ts @@ -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'), }; }; diff --git a/app/gateway-api/src/orchestrator/gatewayOrchestrator.ts b/app/gateway-api/src/orchestrator/gatewayOrchestrator.ts index 3ed19e4..56df369 100644 --- a/app/gateway-api/src/orchestrator/gatewayOrchestrator.ts +++ b/app/gateway-api/src/orchestrator/gatewayOrchestrator.ts @@ -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; runScheduleNow(): Promise; runBuildQueueNow(): Promise; + cleanupStaleWorkspaces(): Promise<{ + removed: string[]; + skipped: string[]; + }>; listRuntimeStates(profileNames: string[]): Promise; } @@ -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; + }> = []; + 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 { const definitions = buildProcessDefinitions(profile, this.processConfig); try { diff --git a/app/gateway-api/src/orchestrator/profileRepository.ts b/app/gateway-api/src/orchestrator/profileRepository.ts index 9b96499..2a728a2 100644 --- a/app/gateway-api/src/orchestrator/profileRepository.ts +++ b/app/gateway-api/src/orchestrator/profileRepository.ts @@ -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; listReservedToStart(now: Date): Promise; findQueuedBuild(): Promise; updateLastError(profileName: string, lastError: string | null): Promise; + updateWorkspaceUsage( + profileName: string, + workspace: string, + lastUsedAt: string + ): Promise; + clearWorkspaceUsage(profileNames: string[]): Promise; } 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 { + await prisma.gatewayProfile.update({ + where: { profileName }, + data: { + buildWorkspace: workspace, + buildLastUsedAt: new Date(lastUsedAt), + }, + }); + }, + async clearWorkspaceUsage(profileNames: string[]): Promise { + if (!profileNames.length) { + return; + } + await prisma.gatewayProfile.updateMany({ + where: { + profileName: { in: profileNames }, + }, + data: { + buildWorkspace: null, + buildLastUsedAt: null, + }, + }); + }, }); diff --git a/app/gateway-api/src/orchestrator/workspaceManager.ts b/app/gateway-api/src/orchestrator/workspaceManager.ts new file mode 100644 index 0000000..a14e2c7 --- /dev/null +++ b/app/gateway-api/src/orchestrator/workspaceManager.ts @@ -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; +} + +export interface WorkspaceInfo { + root: string; + created: boolean; + needsInstall: boolean; +} + +const runGit = ( + args: string[], + cwd: string, + env?: Record +): 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; + + constructor(options: WorkspaceManagerOptions) { + this.repoRoot = options.repoRoot; + this.worktreeRoot = options.worktreeRoot; + this.baseEnv = options.baseEnv; + } + + async prepare(commitSha: string): Promise { + 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 { + 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; + } +} diff --git a/app/gateway-api/src/server.ts b/app/gateway-api/src/server.ts index 55437d8..0eacec3 100644 --- a/app/gateway-api/src/server.ts +++ b/app/gateway-api/src/server.ts @@ -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 => { @@ -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, diff --git a/app/gateway-api/test/authFlow.test.ts b/app/gateway-api/test/authFlow.test.ts index 2a8cbeb..8349aaa 100644 --- a/app/gateway-api/test/authFlow.test.ts +++ b/app/gateway-api/test/authFlow.test.ts @@ -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( diff --git a/docs/architecture/runtime.md b/docs/architecture/runtime.md index 3a56cac..1804e2a 100644 --- a/docs/architecture/runtime.md +++ b/docs/architecture/runtime.md @@ -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 diff --git a/packages/infra/prisma/migrations/20250105000000_add_gateway_profile/migration.sql b/packages/infra/prisma/migrations/20250105000000_add_gateway_profile/migration.sql new file mode 100644 index 0000000..16676e1 --- /dev/null +++ b/packages/infra/prisma/migrations/20250105000000_add_gateway_profile/migration.sql @@ -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") +); + diff --git a/packages/infra/prisma/schema.prisma b/packages/infra/prisma/schema.prisma index d725eb1..aaca480 100644 --- a/packages/infra/prisma/schema.prisma +++ b/packages/infra/prisma/schema.prisma @@ -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")