feat: Add Git workspace management for build profiles and implement workspace cleanup
This commit is contained in:
@@ -98,6 +98,7 @@ export const adminRouter = router({
|
|||||||
.input(
|
.input(
|
||||||
z.object({
|
z.object({
|
||||||
profileName: z.string().min(1),
|
profileName: z.string().min(1),
|
||||||
|
commitSha: z.string().min(7).max(64),
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
.mutation(async ({ ctx, input }) => {
|
.mutation(async ({ ctx, input }) => {
|
||||||
@@ -108,6 +109,7 @@ export const adminRouter = router({
|
|||||||
{
|
{
|
||||||
requestedAt,
|
requestedAt,
|
||||||
error: null,
|
error: null,
|
||||||
|
commitSha: input.commitSha,
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
return result;
|
return result;
|
||||||
@@ -126,5 +128,12 @@ export const adminRouter = router({
|
|||||||
await ctx.orchestrator.reconcileNow();
|
await ctx.orchestrator.reconcileNow();
|
||||||
return { ok: true };
|
return { ok: true };
|
||||||
}),
|
}),
|
||||||
|
cleanupWorkspaces: adminProcedure.mutation(async ({ ctx }) => {
|
||||||
|
const result = await ctx.orchestrator.cleanupStaleWorkspaces();
|
||||||
|
return {
|
||||||
|
removed: result.removed,
|
||||||
|
skipped: result.skipped,
|
||||||
|
};
|
||||||
|
}),
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import path from 'node:path';
|
||||||
|
|
||||||
export interface GatewayApiConfig {
|
export interface GatewayApiConfig {
|
||||||
host: string;
|
host: string;
|
||||||
port: number;
|
port: number;
|
||||||
@@ -18,6 +20,7 @@ export interface GatewayApiConfig {
|
|||||||
orchestratorScheduleIntervalMs: number;
|
orchestratorScheduleIntervalMs: number;
|
||||||
orchestratorBuildIntervalMs: number;
|
orchestratorBuildIntervalMs: number;
|
||||||
workspaceRootHint: string;
|
workspaceRootHint: string;
|
||||||
|
worktreeRoot: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const parseNumber = (value: string | undefined, fallback: number, label: string): number => {
|
const parseNumber = (value: string | undefined, fallback: number, label: string): number => {
|
||||||
@@ -99,5 +102,8 @@ export const resolveGatewayApiConfigFromEnv = (
|
|||||||
'GATEWAY_ORCHESTRATOR_BUILD_MS'
|
'GATEWAY_ORCHESTRATOR_BUILD_MS'
|
||||||
),
|
),
|
||||||
workspaceRootHint: env.GATEWAY_WORKSPACE_ROOT ?? process.cwd(),
|
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,
|
GatewayProfileRepository,
|
||||||
GatewayProfileStatus,
|
GatewayProfileStatus,
|
||||||
} from './profileRepository.js';
|
} from './profileRepository.js';
|
||||||
|
import type { GitWorkspaceManager } from './workspaceManager.js';
|
||||||
|
|
||||||
export interface GatewayProcessConfig {
|
export interface GatewayProcessConfig {
|
||||||
workspaceRoot: string;
|
workspaceRoot: string;
|
||||||
@@ -19,6 +20,7 @@ export interface GatewayOrchestratorOptions {
|
|||||||
repository: GatewayProfileRepository;
|
repository: GatewayProfileRepository;
|
||||||
processManager: ProcessManager;
|
processManager: ProcessManager;
|
||||||
buildRunner: BuildRunner;
|
buildRunner: BuildRunner;
|
||||||
|
workspaceManager: GitWorkspaceManager;
|
||||||
processConfig: GatewayProcessConfig;
|
processConfig: GatewayProcessConfig;
|
||||||
reconcileIntervalMs: number;
|
reconcileIntervalMs: number;
|
||||||
scheduleIntervalMs: number;
|
scheduleIntervalMs: number;
|
||||||
@@ -41,6 +43,10 @@ export interface GatewayOrchestratorHandle {
|
|||||||
reconcileNow(): Promise<void>;
|
reconcileNow(): Promise<void>;
|
||||||
runScheduleNow(): Promise<void>;
|
runScheduleNow(): Promise<void>;
|
||||||
runBuildQueueNow(): Promise<void>;
|
runBuildQueueNow(): Promise<void>;
|
||||||
|
cleanupStaleWorkspaces(): Promise<{
|
||||||
|
removed: string[];
|
||||||
|
skipped: string[];
|
||||||
|
}>;
|
||||||
listRuntimeStates(profileNames: string[]): Promise<ProfileRuntimeSnapshot[]>;
|
listRuntimeStates(profileNames: string[]): Promise<ProfileRuntimeSnapshot[]>;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -123,6 +129,7 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle {
|
|||||||
private readonly repository: GatewayProfileRepository;
|
private readonly repository: GatewayProfileRepository;
|
||||||
private readonly processManager: ProcessManager;
|
private readonly processManager: ProcessManager;
|
||||||
private readonly buildRunner: BuildRunner;
|
private readonly buildRunner: BuildRunner;
|
||||||
|
private readonly workspaceManager: GitWorkspaceManager;
|
||||||
private readonly processConfig: GatewayProcessConfig;
|
private readonly processConfig: GatewayProcessConfig;
|
||||||
private readonly reconcileIntervalMs: number;
|
private readonly reconcileIntervalMs: number;
|
||||||
private readonly scheduleIntervalMs: number;
|
private readonly scheduleIntervalMs: number;
|
||||||
@@ -139,6 +146,7 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle {
|
|||||||
this.repository = options.repository;
|
this.repository = options.repository;
|
||||||
this.processManager = options.processManager;
|
this.processManager = options.processManager;
|
||||||
this.buildRunner = options.buildRunner;
|
this.buildRunner = options.buildRunner;
|
||||||
|
this.workspaceManager = options.workspaceManager;
|
||||||
this.processConfig = options.processConfig;
|
this.processConfig = options.processConfig;
|
||||||
this.reconcileIntervalMs = options.reconcileIntervalMs;
|
this.reconcileIntervalMs = options.reconcileIntervalMs;
|
||||||
this.scheduleIntervalMs = options.scheduleIntervalMs;
|
this.scheduleIntervalMs = options.scheduleIntervalMs;
|
||||||
@@ -242,25 +250,54 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle {
|
|||||||
if (!queued) {
|
if (!queued) {
|
||||||
return;
|
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();
|
const startedAt = this.now().toISOString();
|
||||||
await this.repository.updateBuildStatus(queued.profileName, 'RUNNING', {
|
await this.repository.updateBuildStatus(queued.profileName, 'RUNNING', {
|
||||||
startedAt,
|
startedAt,
|
||||||
error: null,
|
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',
|
command: 'pnpm',
|
||||||
args: ['--filter', '@sammo-ts/game-api', 'build'],
|
args: ['--filter', '@sammo-ts/game-api', 'build'],
|
||||||
cwd: this.processConfig.workspaceRoot,
|
cwd: workspace.root,
|
||||||
env: this.processConfig.baseEnv,
|
env: this.processConfig.baseEnv,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
command: 'pnpm',
|
command: 'pnpm',
|
||||||
args: ['--filter', '@sammo-ts/game-engine', 'build'],
|
args: ['--filter', '@sammo-ts/game-engine', 'build'],
|
||||||
cwd: this.processConfig.workspaceRoot,
|
cwd: workspace.root,
|
||||||
env: this.processConfig.baseEnv,
|
env: this.processConfig.baseEnv,
|
||||||
},
|
}
|
||||||
]);
|
);
|
||||||
|
const result = await this.buildRunner.run(commands);
|
||||||
const completedAt = this.now().toISOString();
|
const completedAt = this.now().toISOString();
|
||||||
if (result.ok) {
|
if (result.ok) {
|
||||||
await this.repository.updateBuildStatus(queued.profileName, 'SUCCEEDED', {
|
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> {
|
private async startProfile(profile: GatewayProfileRecord): Promise<void> {
|
||||||
const definitions = buildProcessDefinitions(profile, this.processConfig);
|
const definitions = buildProcessDefinitions(profile, this.processConfig);
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -25,6 +25,9 @@ export interface GatewayProfileRecord {
|
|||||||
apiPort: number;
|
apiPort: number;
|
||||||
status: GatewayProfileStatus;
|
status: GatewayProfileStatus;
|
||||||
buildStatus: GatewayBuildStatus;
|
buildStatus: GatewayBuildStatus;
|
||||||
|
buildCommitSha?: string;
|
||||||
|
buildWorkspace?: string;
|
||||||
|
buildLastUsedAt?: string;
|
||||||
scheduledStartAt?: string;
|
scheduledStartAt?: string;
|
||||||
buildRequestedAt?: string;
|
buildRequestedAt?: string;
|
||||||
buildStartedAt?: string;
|
buildStartedAt?: string;
|
||||||
@@ -62,11 +65,20 @@ export interface GatewayProfileRepository {
|
|||||||
startedAt?: string | null;
|
startedAt?: string | null;
|
||||||
completedAt?: string | null;
|
completedAt?: string | null;
|
||||||
error?: string | null;
|
error?: string | null;
|
||||||
|
commitSha?: string | null;
|
||||||
|
workspace?: string | null;
|
||||||
|
lastUsedAt?: string | null;
|
||||||
}
|
}
|
||||||
): Promise<GatewayProfileRecord | null>;
|
): Promise<GatewayProfileRecord | null>;
|
||||||
listReservedToStart(now: Date): Promise<GatewayProfileRecord[]>;
|
listReservedToStart(now: Date): Promise<GatewayProfileRecord[]>;
|
||||||
findQueuedBuild(): Promise<GatewayProfileRecord | null>;
|
findQueuedBuild(): Promise<GatewayProfileRecord | null>;
|
||||||
updateLastError(profileName: string, lastError: string | null): Promise<void>;
|
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 =>
|
const toIso = (value: Date | null): string | undefined =>
|
||||||
@@ -79,6 +91,9 @@ const mapProfile = (row: {
|
|||||||
apiPort: number;
|
apiPort: number;
|
||||||
status: GatewayProfileStatus;
|
status: GatewayProfileStatus;
|
||||||
buildStatus: GatewayBuildStatus;
|
buildStatus: GatewayBuildStatus;
|
||||||
|
buildCommitSha: string | null;
|
||||||
|
buildWorkspace: string | null;
|
||||||
|
buildLastUsedAt: Date | null;
|
||||||
scheduledStartAt: Date | null;
|
scheduledStartAt: Date | null;
|
||||||
buildRequestedAt: Date | null;
|
buildRequestedAt: Date | null;
|
||||||
buildStartedAt: Date | null;
|
buildStartedAt: Date | null;
|
||||||
@@ -95,6 +110,9 @@ const mapProfile = (row: {
|
|||||||
apiPort: row.apiPort,
|
apiPort: row.apiPort,
|
||||||
status: row.status,
|
status: row.status,
|
||||||
buildStatus: row.buildStatus,
|
buildStatus: row.buildStatus,
|
||||||
|
buildCommitSha: row.buildCommitSha ?? undefined,
|
||||||
|
buildWorkspace: row.buildWorkspace ?? undefined,
|
||||||
|
buildLastUsedAt: toIso(row.buildLastUsedAt),
|
||||||
scheduledStartAt: toIso(row.scheduledStartAt),
|
scheduledStartAt: toIso(row.scheduledStartAt),
|
||||||
buildRequestedAt: toIso(row.buildRequestedAt),
|
buildRequestedAt: toIso(row.buildRequestedAt),
|
||||||
buildStartedAt: toIso(row.buildStartedAt),
|
buildStartedAt: toIso(row.buildStartedAt),
|
||||||
@@ -185,6 +203,16 @@ export const createGatewayProfileRepository = (
|
|||||||
where: { profileName },
|
where: { profileName },
|
||||||
data: {
|
data: {
|
||||||
buildStatus: status,
|
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:
|
buildRequestedAt:
|
||||||
fields?.requestedAt === undefined
|
fields?.requestedAt === undefined
|
||||||
? undefined
|
? undefined
|
||||||
@@ -232,4 +260,31 @@ export const createGatewayProfileRepository = (
|
|||||||
data: { lastError },
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -21,6 +21,7 @@ import { GatewayOrchestrator } from './orchestrator/gatewayOrchestrator.js';
|
|||||||
import { Pm2ProcessManager } from './orchestrator/pm2ProcessManager.js';
|
import { Pm2ProcessManager } from './orchestrator/pm2ProcessManager.js';
|
||||||
import { PnpmBuildRunner } from './orchestrator/buildRunner.js';
|
import { PnpmBuildRunner } from './orchestrator/buildRunner.js';
|
||||||
import { resolveWorkspaceRoot } from './orchestrator/workspaceRoot.js';
|
import { resolveWorkspaceRoot } from './orchestrator/workspaceRoot.js';
|
||||||
|
import { GitWorkspaceManager } from './orchestrator/workspaceManager.js';
|
||||||
import { appRouter } from './router.js';
|
import { appRouter } from './router.js';
|
||||||
|
|
||||||
const buildEnvMap = (env: NodeJS.ProcessEnv): Record<string, string> => {
|
const buildEnvMap = (env: NodeJS.ProcessEnv): Record<string, string> => {
|
||||||
@@ -64,10 +65,16 @@ export const createGatewayApiServer = async () => {
|
|||||||
const processManager = new Pm2ProcessManager();
|
const processManager = new Pm2ProcessManager();
|
||||||
const buildRunner = new PnpmBuildRunner();
|
const buildRunner = new PnpmBuildRunner();
|
||||||
const baseEnv = buildEnvMap(process.env);
|
const baseEnv = buildEnvMap(process.env);
|
||||||
|
const workspaceManager = new GitWorkspaceManager({
|
||||||
|
repoRoot: workspaceRoot,
|
||||||
|
worktreeRoot: config.worktreeRoot,
|
||||||
|
baseEnv,
|
||||||
|
});
|
||||||
const orchestrator = new GatewayOrchestrator({
|
const orchestrator = new GatewayOrchestrator({
|
||||||
repository: profiles,
|
repository: profiles,
|
||||||
processManager,
|
processManager,
|
||||||
buildRunner,
|
buildRunner,
|
||||||
|
workspaceManager,
|
||||||
processConfig: {
|
processConfig: {
|
||||||
workspaceRoot,
|
workspaceRoot,
|
||||||
redisKeyPrefix: config.redisKeyPrefix,
|
redisKeyPrefix: config.redisKeyPrefix,
|
||||||
|
|||||||
@@ -47,6 +47,8 @@ const buildCaller = () => {
|
|||||||
listReservedToStart: async () => [],
|
listReservedToStart: async () => [],
|
||||||
findQueuedBuild: async () => null,
|
findQueuedBuild: async () => null,
|
||||||
updateLastError: async () => {},
|
updateLastError: async () => {},
|
||||||
|
updateWorkspaceUsage: async () => {},
|
||||||
|
clearWorkspaceUsage: async () => {},
|
||||||
};
|
};
|
||||||
const orchestrator = {
|
const orchestrator = {
|
||||||
start: () => {},
|
start: () => {},
|
||||||
@@ -54,6 +56,10 @@ const buildCaller = () => {
|
|||||||
reconcileNow: async () => {},
|
reconcileNow: async () => {},
|
||||||
runScheduleNow: async () => {},
|
runScheduleNow: async () => {},
|
||||||
runBuildQueueNow: async () => {},
|
runBuildQueueNow: async () => {},
|
||||||
|
cleanupStaleWorkspaces: async () => ({
|
||||||
|
removed: [],
|
||||||
|
skipped: [],
|
||||||
|
}),
|
||||||
listRuntimeStates: async () => [],
|
listRuntimeStates: async () => [],
|
||||||
};
|
};
|
||||||
const caller = appRouter.createCaller(
|
const caller = appRouter.createCaller(
|
||||||
|
|||||||
@@ -54,8 +54,14 @@ Gateway runs a lightweight cron loop (setInterval) that:
|
|||||||
### Build Workflow (Admin)
|
### Build Workflow (Admin)
|
||||||
|
|
||||||
- Admin triggers a build request for a profile.
|
- Admin triggers a build request for a profile.
|
||||||
- Gateway queues a build job, runs `pnpm --filter @sammo-ts/game-api build`
|
- Gateway queues a build job with `(profileName, commitSha)` and prepares a
|
||||||
and `pnpm --filter @sammo-ts/game-engine build`, then marks build success/failure.
|
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).
|
- On success, profile status remains `COMPLETED` (or stays `RUNNING` if already on).
|
||||||
|
|
||||||
## Current Implementation Status
|
## 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")
|
||||||
|
);
|
||||||
|
|
||||||
@@ -69,6 +69,9 @@ model GatewayProfile {
|
|||||||
apiPort Int @map("api_port")
|
apiPort Int @map("api_port")
|
||||||
status GatewayProfileStatus
|
status GatewayProfileStatus
|
||||||
buildStatus GatewayBuildStatus @default(IDLE) @map("build_status")
|
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")
|
scheduledStartAt DateTime? @map("scheduled_start_at")
|
||||||
buildRequestedAt DateTime? @map("build_requested_at")
|
buildRequestedAt DateTime? @map("build_requested_at")
|
||||||
buildStartedAt DateTime? @map("build_started_at")
|
buildStartedAt DateTime? @map("build_started_at")
|
||||||
|
|||||||
Reference in New Issue
Block a user