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(
|
||||
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,
|
||||
};
|
||||
}),
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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")
|
||||
);
|
||||
|
||||
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user