merge: 최신 main 변경을 게임 시계 보정 브랜치에 통합한다

This commit is contained in:
2026-08-23 02:00:58 +00:00
9 changed files with 715 additions and 34 deletions
@@ -1,4 +1,5 @@
import { createHash, randomUUID } from 'node:crypto';
import type { Dirent } from 'node:fs';
import fs from 'node:fs/promises';
import path from 'node:path';
@@ -11,6 +12,12 @@ export interface FrontendArtifactManifest {
digest: string;
releaseId: string;
files: number;
dependencies?: FrontendArtifactDependency[];
}
export interface FrontendArtifactDependency {
frontendKey: string;
releaseId: string;
}
export interface StagedFrontendArtifact {
@@ -19,6 +26,21 @@ export interface StagedFrontendArtifact {
manifest: FrontendArtifactManifest;
}
export interface FrontendArtifactCleanupResult {
removed: string[];
retained: string[];
skipped: string[];
}
export interface FrontendArtifactCleanupOptions {
frontendKeys: string[];
protectedCommitShas?: Iterable<string>;
retentionMs: number;
keepNewest: number;
now?: Date;
cleanupProfileWrapperStaging?: boolean;
}
export interface ProfileFrontendRuntimeConfig {
version: 1;
profile: string;
@@ -34,11 +56,15 @@ export interface ProfileFrontendRuntimeConfig {
export const SHARED_GAME_FRONTEND_KEY = 'game-assets';
export const GAME_FRONTEND_RUNTIME_CONFIG_ID = 'sammo-runtime-config';
export const DEFAULT_FRONTEND_ARTIFACT_RETENTION_MS = 24 * 60 * 60 * 1_000;
export const DEFAULT_FRONTEND_ARTIFACT_KEEP_NEWEST = 2;
const MANIFEST_FILE = '.sammo-artifact.json';
const FRONTEND_KEY = /^[a-z0-9][a-z0-9_-]{0,63}$/u;
const COMMIT_SHA = /^[0-9a-f]{40,64}$/iu;
const PUBLIC_ASSET_BASE = /^\/[0-9A-Za-z/_-]*$/u;
const RELEASE_ID = /^[0-9a-f]{40,64}-[0-9a-f]{16}$/iu;
const STAGING_DIRECTORY = /^\.staging-[0-9a-f-]+$/iu;
export const resolveFrontendServeMode = (value: string | undefined): FrontendServeMode => {
const normalized = value?.trim().toLowerCase();
@@ -55,6 +81,33 @@ const assertCommitSha = (value: string): void => {
if (!COMMIT_SHA.test(value)) throw new Error('Frontend artifact commit SHA must be a full hexadecimal SHA.');
};
const assertReleaseId = (value: string): void => {
if (!RELEASE_ID.test(value)) throw new Error(`Invalid frontend artifact release id: ${value}`);
};
const normalizeDependencies = (
dependencies: FrontendArtifactDependency[] | undefined
): FrontendArtifactDependency[] => {
if (!dependencies) return [];
const normalized = dependencies.map((dependency) => {
assertFrontendKey(dependency.frontendKey);
assertReleaseId(dependency.releaseId);
return {
frontendKey: dependency.frontendKey,
releaseId: dependency.releaseId.toLowerCase(),
};
});
normalized.sort((left, right) =>
`${left.frontendKey}/${left.releaseId}`.localeCompare(`${right.frontendKey}/${right.releaseId}`)
);
return normalized.filter(
(dependency, index) =>
index === 0 ||
dependency.frontendKey !== normalized[index - 1].frontendKey ||
dependency.releaseId !== normalized[index - 1].releaseId
);
};
const listSourceFiles = async (sourceRoot: string): Promise<string[]> => {
const rootStat = await fs.lstat(sourceRoot);
if (!rootStat.isDirectory() || rootStat.isSymbolicLink()) {
@@ -110,12 +163,14 @@ const readManifest = async (releasePath: string): Promise<FrontendArtifactManife
typeof raw.commitSha !== 'string' ||
typeof raw.digest !== 'string' ||
typeof raw.releaseId !== 'string' ||
typeof raw.files !== 'number'
typeof raw.files !== 'number' ||
(raw.dependencies !== undefined && !Array.isArray(raw.dependencies))
) {
throw new Error(`Invalid frontend artifact manifest: ${releasePath}`);
}
const dependencies = normalizeDependencies(raw.dependencies);
await fs.access(path.join(releasePath, 'index.html'));
return raw as FrontendArtifactManifest;
return { ...(raw as FrontendArtifactManifest), ...(dependencies.length > 0 ? { dependencies } : {}) };
};
const isMissing = (error: unknown): boolean =>
@@ -171,16 +226,77 @@ export class FrontendArtifactManager {
}
private releasePath(frontendKey: string, releaseId: string): string {
if (!/^[0-9a-f]{40,64}-[0-9a-f]{16}$/iu.test(releaseId)) {
throw new Error(`Invalid frontend artifact release id: ${releaseId}`);
}
assertReleaseId(releaseId);
return path.join(this.frontendRoot(frontendKey), 'releases', releaseId);
}
private async readPointerReleaseId(frontendKey: string, pointer: 'current' | 'previous'): Promise<string | null> {
const frontendRoot = this.frontendRoot(frontendKey);
let target: string;
try {
target = await fs.readlink(path.join(frontendRoot, pointer));
} catch (error) {
if (isMissing(error)) return null;
throw error;
}
const normalized = target.split(path.sep).join('/');
const match = /^releases\/([0-9a-f]{40,64}-[0-9a-f]{16})$/iu.exec(normalized);
if (!match) throw new Error(`Invalid ${pointer} frontend artifact pointer for ${frontendKey}.`);
const manifest = await readManifest(this.releasePath(frontendKey, match[1]));
if (manifest.frontendKey !== frontendKey || manifest.releaseId !== match[1]) {
throw new Error(`Frontend artifact manifest does not match ${frontendKey}/${match[1]}.`);
}
return match[1];
}
private async readReleaseDependencies(
frontendKey: string,
releaseId: string
): Promise<FrontendArtifactDependency[]> {
const releasePath = this.releasePath(frontendKey, releaseId);
const manifest = await readManifest(releasePath);
if (manifest.dependencies?.length) return manifest.dependencies;
const indexHtml = await fs.readFile(path.join(releasePath, 'index.html'), 'utf8');
if (!indexHtml.includes(GAME_FRONTEND_RUNTIME_CONFIG_ID)) return [];
const runtimeScript =
/<script\b(?=[^>]*\bid="sammo-runtime-config")(?=[^>]*\btype="application\/json")[^>]*>([\s\S]*?)<\/script>/iu.exec(
indexHtml
);
if (!runtimeScript) {
throw new Error(`Invalid profile frontend runtime config script: ${releasePath}`);
}
const runtimeConfig = JSON.parse(runtimeScript[1]) as Partial<ProfileFrontendRuntimeConfig>;
if (typeof runtimeConfig.assetReleaseId !== 'string') {
throw new Error(`Profile frontend runtime config has no shared asset release: ${releasePath}`);
}
assertReleaseId(runtimeConfig.assetReleaseId);
return [{ frontendKey: SHARED_GAME_FRONTEND_KEY, releaseId: runtimeConfig.assetReleaseId.toLowerCase() }];
}
private async touchRelease(frontendKey: string, releaseId: string, at: Date): Promise<void> {
const releasePath = this.releasePath(frontendKey, releaseId);
const dependencies = await this.readReleaseDependencies(frontendKey, releaseId);
for (const dependency of dependencies) {
const dependencyPath = this.releasePath(dependency.frontendKey, dependency.releaseId);
const dependencyManifest = await readManifest(dependencyPath);
if (
dependencyManifest.frontendKey !== dependency.frontendKey ||
dependencyManifest.releaseId !== dependency.releaseId
) {
throw new Error(
`Frontend artifact dependency manifest does not match ${dependency.frontendKey}/${dependency.releaseId}.`
);
}
await fs.utimes(dependencyPath, at, at);
}
await fs.utimes(releasePath, at, at);
}
async stage(options: {
frontendKey: string;
sourceRoot: string;
commitSha: string;
dependencies?: FrontendArtifactDependency[];
}): Promise<StagedFrontendArtifact> {
assertFrontendKey(options.frontendKey);
assertCommitSha(options.commitSha);
@@ -190,6 +306,20 @@ export class FrontendArtifactManager {
const digest = await buildDigest(sourceRoot, files);
const releaseId = `${commitSha}-${digest.slice(0, 16)}`;
const releasePath = this.releasePath(options.frontendKey, releaseId);
const dependencies = normalizeDependencies(options.dependencies);
for (const dependency of dependencies) {
const dependencyManifest = await readManifest(
this.releasePath(dependency.frontendKey, dependency.releaseId)
);
if (
dependencyManifest.frontendKey !== dependency.frontendKey ||
dependencyManifest.releaseId !== dependency.releaseId
) {
throw new Error(
`Frontend artifact dependency manifest does not match ${dependency.frontendKey}/${dependency.releaseId}.`
);
}
}
const manifest: FrontendArtifactManifest = {
version: 1,
frontendKey: options.frontendKey,
@@ -197,6 +327,7 @@ export class FrontendArtifactManager {
digest,
releaseId,
files: files.length,
...(dependencies.length > 0 ? { dependencies } : {}),
};
try {
const existing = await readManifest(releasePath);
@@ -283,6 +414,12 @@ export class FrontendArtifactManager {
frontendKey: options.frontendKey,
sourceRoot,
commitSha: options.sharedArtifact.manifest.commitSha,
dependencies: [
{
frontendKey: SHARED_GAME_FRONTEND_KEY,
releaseId: options.sharedArtifact.releaseId,
},
],
});
} finally {
await fs.rm(sourceRoot, { recursive: true, force: true });
@@ -290,18 +427,7 @@ export class FrontendArtifactManager {
}
async readCurrentReleaseId(frontendKey: string): Promise<string | null> {
const frontendRoot = this.frontendRoot(frontendKey);
try {
const target = await fs.readlink(path.join(frontendRoot, 'current'));
const normalized = target.split(path.sep).join('/');
const match = /^releases\/([0-9a-f]{40,64}-[0-9a-f]{16})$/iu.exec(normalized);
if (!match) throw new Error(`Invalid current frontend artifact pointer for ${frontendKey}.`);
await readManifest(this.releasePath(frontendKey, match[1]));
return match[1];
} catch (error) {
if (isMissing(error)) return null;
throw error;
}
return this.readPointerReleaseId(frontendKey, 'current');
}
async activate(frontendKey: string, releaseId: string): Promise<string | null> {
@@ -322,7 +448,10 @@ export class FrontendArtifactManager {
await fs.rm(temporary, { force: true });
}
};
const activatedAt = new Date();
await this.touchRelease(frontendKey, releaseId, activatedAt);
if (previousReleaseId && previousReleaseId !== releaseId) {
await this.touchRelease(frontendKey, previousReleaseId, activatedAt);
await replacePointer('previous', previousReleaseId);
}
await replacePointer('current', releaseId);
@@ -344,4 +473,160 @@ export class FrontendArtifactManager {
await fs.rm(path.join(this.frontendRoot(frontendKey), 'current'), { force: true });
return releaseId;
}
async cleanup(options: FrontendArtifactCleanupOptions): Promise<FrontendArtifactCleanupResult> {
if (!Number.isFinite(options.retentionMs) || options.retentionMs < 0) {
throw new Error('Frontend artifact retention must be a non-negative finite duration.');
}
if (!Number.isInteger(options.keepNewest) || options.keepNewest < 0) {
throw new Error('Frontend artifact keepNewest must be a non-negative integer.');
}
const frontendKeys = [...new Set(options.frontendKeys)];
frontendKeys.forEach(assertFrontendKey);
const protectedCommitShas = new Set(
[...(options.protectedCommitShas ?? [])].map((commitSha) => {
assertCommitSha(commitSha);
return commitSha.toLowerCase();
})
);
const result: FrontendArtifactCleanupResult = { removed: [], retained: [], skipped: [] };
if (frontendKeys.length === 0) return result;
const collectProtectedReleases = async (): Promise<Map<string, Set<string>>> => {
const protectedReleases = new Map(frontendKeys.map((frontendKey) => [frontendKey, new Set<string>()]));
for (const frontendKey of frontendKeys) {
for (const pointer of ['current', 'previous'] as const) {
const releaseId = await this.readPointerReleaseId(frontendKey, pointer);
if (!releaseId) continue;
protectedReleases.get(frontendKey)?.add(releaseId);
for (const dependency of await this.readReleaseDependencies(frontendKey, releaseId)) {
protectedReleases.get(dependency.frontendKey)?.add(dependency.releaseId);
}
}
}
return protectedReleases;
};
let protectedReleases: Map<string, Set<string>>;
try {
protectedReleases = await collectProtectedReleases();
} catch {
for (const frontendKey of frontendKeys) result.skipped.push(this.frontendRoot(frontendKey));
return result;
}
const cutoffMs = (options.now ?? new Date()).getTime() - options.retentionMs;
const candidates: Array<{ frontendKey: string; releaseId: string; releasePath: string }> = [];
const staleStagingPaths: string[] = [];
for (const frontendKey of frontendKeys) {
const releasesRoot = path.join(this.frontendRoot(frontendKey), 'releases');
let entries: Dirent[];
try {
entries = await fs.readdir(releasesRoot, { withFileTypes: true });
} catch (error) {
if (isMissing(error)) continue;
result.skipped.push(releasesRoot);
continue;
}
const unprotected: Array<{ releaseId: string; releasePath: string; mtimeMs: number }> = [];
for (const entry of entries) {
const entryPath = path.join(releasesRoot, entry.name);
if (STAGING_DIRECTORY.test(entry.name)) {
if (!entry.isDirectory()) {
result.skipped.push(entryPath);
continue;
}
const stat = await fs.lstat(entryPath);
if (stat.mtimeMs <= cutoffMs) staleStagingPaths.push(entryPath);
else result.retained.push(entryPath);
continue;
}
if (!RELEASE_ID.test(entry.name) || !entry.isDirectory()) {
result.skipped.push(entryPath);
continue;
}
try {
const manifest = await readManifest(entryPath);
if (manifest.frontendKey !== frontendKey || manifest.releaseId !== entry.name) {
result.skipped.push(entryPath);
continue;
}
const stat = await fs.lstat(entryPath);
if (
protectedReleases.get(frontendKey)?.has(entry.name) ||
protectedCommitShas.has(manifest.commitSha.toLowerCase())
) {
result.retained.push(entryPath);
continue;
}
unprotected.push({ releaseId: entry.name, releasePath: entryPath, mtimeMs: stat.mtimeMs });
} catch {
result.skipped.push(entryPath);
}
}
unprotected.sort(
(left, right) => right.mtimeMs - left.mtimeMs || right.releaseId.localeCompare(left.releaseId)
);
unprotected.forEach((release, index) => {
if (index < options.keepNewest || release.mtimeMs > cutoffMs) {
result.retained.push(release.releasePath);
} else {
candidates.push({ frontendKey, releaseId: release.releaseId, releasePath: release.releasePath });
}
});
}
if (options.cleanupProfileWrapperStaging) {
let entries: Dirent[] = [];
try {
entries = await fs.readdir(this.root, { withFileTypes: true });
} catch (error) {
if (!isMissing(error)) result.skipped.push(this.root);
}
for (const entry of entries) {
if (!entry.name.startsWith('.profile-wrapper-')) continue;
const entryPath = path.join(this.root, entry.name);
if (!entry.isDirectory()) {
result.skipped.push(entryPath);
continue;
}
const stat = await fs.lstat(entryPath);
if (stat.mtimeMs <= cutoffMs) staleStagingPaths.push(entryPath);
else result.retained.push(entryPath);
}
}
for (const candidate of candidates) {
try {
protectedReleases = await collectProtectedReleases();
if (protectedReleases.get(candidate.frontendKey)?.has(candidate.releaseId)) {
result.retained.push(candidate.releasePath);
continue;
}
const stat = await fs.lstat(candidate.releasePath);
if (!stat.isDirectory() || stat.isSymbolicLink()) {
result.skipped.push(candidate.releasePath);
continue;
}
await fs.rm(candidate.releasePath, { recursive: true });
result.removed.push(candidate.releasePath);
} catch (error) {
if (!isMissing(error)) result.skipped.push(candidate.releasePath);
}
}
for (const stagingPath of staleStagingPaths) {
try {
const stat = await fs.lstat(stagingPath);
if (!stat.isDirectory() || stat.isSymbolicLink()) {
result.skipped.push(stagingPath);
continue;
}
await fs.rm(stagingPath, { recursive: true });
result.removed.push(stagingPath);
} catch (error) {
if (!isMissing(error)) result.skipped.push(stagingPath);
}
}
return result;
}
}
@@ -55,9 +55,12 @@ import {
import type { AdminSeedUser } from './seedProfileDatabase.js';
import { assertReleaseComponents, readReleaseManifest } from './releaseManifest.js';
import {
DEFAULT_FRONTEND_ARTIFACT_KEEP_NEWEST,
DEFAULT_FRONTEND_ARTIFACT_RETENTION_MS,
FrontendArtifactManager,
resolveFrontendServeMode,
SHARED_GAME_FRONTEND_KEY,
type FrontendArtifactCleanupResult,
type FrontendServeMode,
type StagedFrontendArtifact,
} from './frontendArtifactManager.js';
@@ -138,6 +141,11 @@ export interface GatewayOrchestratorHandle {
listRuntimeSettings?(profileNames: string[]): Promise<ProfileRuntimeSettingsSnapshot[]>;
}
export interface GatewayManagedCleanupResult {
workspaces: { removed: string[]; skipped: string[] };
artifacts: FrontendArtifactCleanupResult;
}
const SENSITIVE_ENV_NAME = /(SECRET|TOKEN|PASSWORD|PASSWD|PRIVATE_KEY|CLIENT_SECRET|DATABASE_URL|REDIS_URL)/iu;
const managedPostgresPoolMax = (env: Record<string, string>, roleVariable: string, fallback: number): string =>
@@ -2318,15 +2326,21 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle {
}
}
async cleanupStaleWorkspaces(): Promise<{ removed: string[]; skipped: string[] }> {
async cleanupStaleResources(): Promise<GatewayManagedCleanupResult> {
if (this.buildInFlight || this.operationInFlight || this.workspaceCleanupInFlight) {
const managedWorkspaces = await this.workspaceManager.listManagedWorkspaces();
return { removed: [], skipped: managedWorkspaces.map((workspace) => workspace.root) };
return {
workspaces: { removed: [], skipped: managedWorkspaces.map((workspace) => workspace.root) },
artifacts: { removed: [], retained: [], skipped: [] },
};
}
this.workspaceCleanupInFlight = true;
try {
const managedWorkspaces = await this.workspaceManager.listManagedWorkspaces();
const profiles = await this.repository.listProfiles();
const [profiles, operations] = await Promise.all([
this.repository.listProfiles(),
this.repository.listOperations({ limit: 100 }),
]);
const protectedWorkspaces = new Set<string>();
for (const profile of profiles) {
if (profile.buildWorkspace) {
@@ -2353,22 +2367,56 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle {
}
}
return await this.workspaceManager.cleanup({
const workspaces = await this.workspaceManager.cleanup({
protectedPaths: [...protectedWorkspaces],
retentionMs: DEFAULT_MANAGED_WORKSPACE_RETENTION_MS,
keepNewest: DEFAULT_MANAGED_WORKSPACE_KEEP_NEWEST,
});
const artifacts: FrontendArtifactCleanupResult =
this.frontendServeMode === 'static'
? await this.artifactManager.cleanup({
frontendKeys: [
...new Set(profiles.map((profile) => profile.profile)),
SHARED_GAME_FRONTEND_KEY,
],
protectedCommitShas: [
...profiles
.filter(
(profile) =>
profile.buildCommitSha &&
(profile.buildStatus === 'QUEUED' || profile.buildStatus === 'RUNNING')
)
.map((profile) => profile.buildCommitSha as string),
...operations
.filter(
(operation) =>
operation.resolvedCommitSha &&
(operation.status === 'QUEUED' || operation.status === 'RUNNING')
)
.map((operation) => operation.resolvedCommitSha as string),
],
retentionMs: DEFAULT_FRONTEND_ARTIFACT_RETENTION_MS,
keepNewest: DEFAULT_FRONTEND_ARTIFACT_KEEP_NEWEST,
now: this.now(),
cleanupProfileWrapperStaging: true,
})
: { removed: [], retained: [], skipped: [] };
return { workspaces, artifacts };
} finally {
this.workspaceCleanupInFlight = false;
}
}
async cleanupStaleWorkspaces(): Promise<{ removed: string[]; skipped: string[] }> {
return (await this.cleanupStaleResources()).workspaces;
}
private async cleanupWorkspacesScheduled(): Promise<void> {
if (this.stopping || this.buildInFlight || this.operationInFlight || this.workspaceCleanupInFlight) return;
const result = await this.cleanupStaleWorkspaces();
if (result.removed.length > 0) {
console.info(`[gateway-orchestrator] removed ${result.removed.length} stale profile worktrees`);
}
const result = await this.cleanupStaleResources();
console.info(
`[gateway-orchestrator] managed cleanup completed: removed ${result.workspaces.removed.length} profile worktrees and ${result.artifacts.removed.length} frontend artifacts; retained ${result.artifacts.retained.length}, skipped ${result.artifacts.skipped.length}`
);
}
private async stageStaticProfileFrontend(profile: GatewayProfileRecord): Promise<StagedFrontendArtifact> {
@@ -14,6 +14,7 @@ import {
const roots: string[] = [];
const sha = 'a'.repeat(40);
const cleanupNow = new Date('2026-08-23T00:00:00.000Z');
afterEach(async () => {
await Promise.all(roots.splice(0).map((root) => fs.rm(root, { recursive: true, force: true })));
@@ -145,4 +146,162 @@ describe('FrontendArtifactManager', () => {
expect(rendered).not.toContain('trpc?</script>');
expect(rendered).toContain('\\u003c/script\\u003e');
});
it('removes only expired unreferenced releases while preserving pointers, active commits, grace, and caches', async () => {
const { source, artifacts } = await fixture();
const manager = new FrontendArtifactManager(artifacts);
const stage = async (marker: string, commitMarker: string) => {
await fs.writeFile(path.join(source, 'index.html'), `<div>${marker}</div>`);
return manager.stage({
frontendKey: 'gateway',
sourceRoot: source,
commitSha: commitMarker.repeat(40),
});
};
const current = await stage('current', '1');
await manager.activate('gateway', current.releaseId);
const next = await stage('next', '2');
await manager.activate('gateway', next.releaseId);
const pinned = await stage('pinned', '3');
const recent = await stage('recent', '4');
const cached = await stage('cached', '5');
const stale = await stage('stale', '6');
const releasesRoot = path.join(artifacts, 'gateway', 'releases');
const staging = path.join(releasesRoot, '.staging-00000000-0000-0000-0000-000000000000');
const unknownSymlink = path.join(releasesRoot, `${'7'.repeat(40)}-${'7'.repeat(16)}`);
await fs.mkdir(staging);
await fs.symlink(stale.releasePath, unknownSymlink);
const old = new Date(cleanupNow.getTime() - 72 * 60 * 60 * 1_000);
for (const artifact of [current, next, pinned, cached, stale]) {
await fs.utimes(artifact.releasePath, old, old);
}
await fs.utimes(cached.releasePath, new Date(old.getTime() + 1_000), new Date(old.getTime() + 1_000));
await fs.utimes(staging, old, old);
const recentAt = new Date(cleanupNow.getTime() - 60 * 60 * 1_000);
await fs.utimes(recent.releasePath, recentAt, recentAt);
const result = await manager.cleanup({
frontendKeys: ['gateway'],
protectedCommitShas: [pinned.manifest.commitSha],
retentionMs: 24 * 60 * 60 * 1_000,
keepNewest: 2,
now: cleanupNow,
});
expect(result.removed.sort()).toEqual([staging, stale.releasePath].sort());
expect(result.retained).toEqual(
expect.arrayContaining([
current.releasePath,
next.releasePath,
pinned.releasePath,
recent.releasePath,
cached.releasePath,
])
);
expect(result.skipped).toContain(unknownSymlink);
await expect(fs.access(stale.releasePath)).rejects.toMatchObject({ code: 'ENOENT' });
await expect(fs.readFile(path.join(artifacts, 'gateway', 'current', 'index.html'), 'utf8')).resolves.toContain(
'next'
);
await expect(fs.readFile(path.join(artifacts, 'gateway', 'previous', 'index.html'), 'utf8')).resolves.toContain(
'current'
);
});
it('preserves shared assets referenced by current and previous profile wrappers, including old manifests', async () => {
const { source, artifacts } = await fixture();
await fs.writeFile(
path.join(source, 'index.html'),
'<!doctype html><head><script type="module" src="./assets/app-deadbeef.js"></script></head>'
);
await fs.writeFile(path.join(source, 'deployment-version.json'), `${JSON.stringify({ commitSha: sha })}\n`);
const manager = new FrontendArtifactManager(artifacts);
const publish = async (commitMarker: string, script: string) => {
const commitSha = commitMarker.repeat(40);
await fs.writeFile(path.join(source, 'assets', 'app-deadbeef.js'), script);
await fs.writeFile(path.join(source, 'deployment-version.json'), `${JSON.stringify({ commitSha })}\n`);
const shared = await manager.stage({
frontendKey: SHARED_GAME_FRONTEND_KEY,
sourceRoot: source,
commitSha,
});
const wrapper = await manager.stageProfileWrapper({
frontendKey: 'pya',
sharedArtifact: shared,
sharedAssetPublicBase: '/gateway/profile-assets',
runtimeConfig: {
version: 1,
profile: 'pya',
profileName: 'pya:default',
appBasePath: '/pya/',
gameApiUrl: '/pya/api/trpc',
gameSseUrl: '/pya/api/events',
gatewayApiUrl: '/gateway/api/trpc',
gatewayWebUrl: '/gateway/',
},
});
await manager.activate('pya', wrapper.releaseId);
return { shared, wrapper };
};
const first = await publish('1', 'console.log(1)');
const second = await publish('2', 'console.log(2)');
await fs.writeFile(path.join(source, 'assets', 'app-deadbeef.js'), 'console.log(3)');
const unused = await manager.stage({
frontendKey: SHARED_GAME_FRONTEND_KEY,
sourceRoot: source,
commitSha: '3'.repeat(40),
});
const firstManifestPath = path.join(first.wrapper.releasePath, '.sammo-artifact.json');
const firstManifest = JSON.parse(await fs.readFile(firstManifestPath, 'utf8')) as Record<string, unknown>;
delete firstManifest.dependencies;
await fs.writeFile(firstManifestPath, `${JSON.stringify(firstManifest, null, 2)}\n`);
const old = new Date(cleanupNow.getTime() - 72 * 60 * 60 * 1_000);
for (const artifact of [first.shared, first.wrapper, second.shared, second.wrapper, unused]) {
await fs.utimes(artifact.releasePath, old, old);
}
const result = await manager.cleanup({
frontendKeys: ['pya', SHARED_GAME_FRONTEND_KEY],
retentionMs: 24 * 60 * 60 * 1_000,
keepNewest: 0,
now: cleanupNow,
});
expect(result.removed).toEqual([unused.releasePath]);
expect(result.retained).toEqual(
expect.arrayContaining([
first.shared.releasePath,
first.wrapper.releasePath,
second.shared.releasePath,
second.wrapper.releasePath,
])
);
await expect(
fs.readFile(path.join(first.shared.releasePath, 'assets', 'app-deadbeef.js'), 'utf8')
).resolves.toBe('console.log(1)');
await expect(
fs.readFile(path.join(second.shared.releasePath, 'assets', 'app-deadbeef.js'), 'utf8')
).resolves.toBe('console.log(2)');
});
it('fails closed when a live pointer cannot be validated', async () => {
const { source, artifacts } = await fixture();
const manager = new FrontendArtifactManager(artifacts);
const stale = await manager.stage({ frontendKey: 'gateway', sourceRoot: source, commitSha: sha });
const old = new Date(cleanupNow.getTime() - 72 * 60 * 60 * 1_000);
await fs.utimes(stale.releasePath, old, old);
await fs.mkdir(path.join(artifacts, 'gateway'), { recursive: true });
await fs.symlink(`releases/${'f'.repeat(40)}-${'f'.repeat(16)}`, path.join(artifacts, 'gateway', 'current'));
const result = await manager.cleanup({
frontendKeys: ['gateway'],
retentionMs: 24 * 60 * 60 * 1_000,
keepNewest: 0,
now: cleanupNow,
});
expect(result.removed).toEqual([]);
expect(result.skipped).toEqual([path.join(artifacts, 'gateway')]);
await expect(fs.access(stale.releasePath)).resolves.toBeUndefined();
});
});
@@ -1,8 +1,11 @@
import fs from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import { describe, expect, it } from 'vitest';
import { afterEach, describe, expect, it } from 'vitest';
import { GatewayOrchestrator } from '../src/orchestrator/gatewayOrchestrator.js';
import { FrontendArtifactManager } from '../src/orchestrator/frontendArtifactManager.js';
import type { ProcessManager } from '../src/orchestrator/processManager.js';
import type { GatewayProfileRecord, GatewayProfileRepository } from '../src/orchestrator/profileRepository.js';
import {
@@ -14,6 +17,11 @@ import {
const COMMIT_SHA = '0123456789abcdef0123456789abcdef01234567';
const oldUsage = '2025-01-01T00:00:00.000Z';
const temporaryDirectories: string[] = [];
afterEach(async () => {
await Promise.all(temporaryDirectories.splice(0).map((directory) => fs.rm(directory, { recursive: true })));
});
const makeProfile = (
profileName: string,
@@ -40,10 +48,14 @@ const makeProfile = (
const createHarness = (
profiles: GatewayProfileRecord[],
processes: Awaited<ReturnType<ProcessManager['list']>>,
managedPaths: string[]
managedPaths: string[],
frontendArtifactRoot?: string
) => {
const cleanupCalls: ManagedWorkspaceCleanupOptions[] = [];
const repository = { listProfiles: async () => profiles } as unknown as GatewayProfileRepository;
const repository = {
listProfiles: async () => profiles,
listOperations: async () => [],
} as unknown as GatewayProfileRepository;
const processManager: ProcessManager = {
list: async () => processes,
start: async () => {},
@@ -73,11 +85,13 @@ const createHarness = (
redisKeyPrefix: 'sammo:test',
gameTokenSecret: 'test-secret',
gatewayInternalApiUrl: 'http://127.0.0.1:13000',
...(frontendArtifactRoot ? { frontendServeMode: 'static' as const, frontendArtifactRoot } : {}),
},
reconcileIntervalMs: 60_000,
scheduleIntervalMs: 60_000,
buildIntervalMs: 60_000,
adminActionIntervalMs: 60_000,
now: () => new Date('2026-08-23T00:00:00.000Z'),
});
return { orchestrator, cleanupCalls };
};
@@ -141,4 +155,47 @@ describe('GatewayOrchestrator workspace cleanup', () => {
skipped: [],
});
});
it('serializes profile worktree and frontend artifact cleanup under the same managed cycle', async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'sammo-profile-artifact-cleanup-'));
temporaryDirectories.push(root);
const sourceRoot = path.join(root, 'dist');
const artifactRoot = path.join(root, 'artifacts');
await fs.mkdir(sourceRoot, { recursive: true });
const manager = new FrontendArtifactManager(artifactRoot);
const stage = async (marker: string, commitMarker: string) => {
await fs.writeFile(path.join(sourceRoot, 'index.html'), `<div>${marker}</div>`);
return manager.stage({
frontendKey: 'che',
sourceRoot,
commitSha: commitMarker.repeat(40),
});
};
const active = await stage('active', '1');
await manager.activate('che', active.releaseId);
const cacheOne = await stage('cache-one', '2');
const cacheTwo = await stage('cache-two', '3');
const stale = await stage('stale', '4');
const now = new Date('2026-08-23T00:00:00.000Z');
const old = new Date(now.getTime() - 72 * 60 * 60 * 1_000);
for (const artifact of [active, cacheOne, cacheTwo, stale]) {
await fs.utimes(artifact.releasePath, old, old);
}
await fs.utimes(cacheTwo.releasePath, new Date(old.getTime() + 2_000), new Date(old.getTime() + 2_000));
await fs.utimes(cacheOne.releasePath, new Date(old.getTime() + 1_000), new Date(old.getTime() + 1_000));
const harness = createHarness(
[makeProfile('che:default', undefined, { buildCommitSha: active.manifest.commitSha })],
[],
[],
artifactRoot
);
const result = await harness.orchestrator.cleanupStaleResources();
expect(result.workspaces).toEqual({ removed: [], skipped: [] });
expect(result.artifacts.removed).toEqual([stale.releasePath]);
expect(result.artifacts.retained).toEqual(
expect.arrayContaining([active.releasePath, cacheOne.releasePath, cacheTwo.releasePath])
);
});
});
+4 -4
View File
@@ -89,10 +89,10 @@ const main = async (): Promise<void> => {
if (now >= nextWorkspaceCleanupAt) {
nextWorkspaceCleanupAt = now + RELEASE_WORKSPACE_CLEANUP_INTERVAL_MS;
try {
const result = await controller.cleanupStaleWorkspaces();
if (result.removed.length > 0) {
console.info(`[release-controller] removed ${result.removed.length} stale Gateway worktrees`);
}
const result = await controller.cleanupStaleResources();
console.info(
`[release-controller] managed cleanup completed: removed ${result.workspaces.removed.length} Gateway worktrees and ${result.artifacts.removed.length} frontend artifacts; retained ${result.artifacts.retained.length}, skipped ${result.artifacts.skipped.length}`
);
} catch (error) {
console.error('[release-controller] workspace cleanup failed', error);
}
@@ -6,6 +6,8 @@ import {
assertReleaseComponents,
buildTurboReleaseCommand,
buildTurboReleaseTaskCommand,
DEFAULT_FRONTEND_ARTIFACT_KEEP_NEWEST,
DEFAULT_FRONTEND_ARTIFACT_RETENTION_MS,
DEFAULT_MANAGED_WORKSPACE_KEEP_NEWEST,
DEFAULT_MANAGED_WORKSPACE_RETENTION_MS,
type BuildCommand,
@@ -14,6 +16,7 @@ import {
type GatewayReleaseOperationRecord,
type GatewayReleaseRepository,
type GatewayReleaseStateRecord,
type FrontendArtifactCleanupResult,
type GitWorkspaceManager,
type ProcessDefinition,
type ProcessManager,
@@ -34,6 +37,11 @@ const MANAGED_PROCESS_NAMES = ['sammo:gateway-api', 'sammo:gateway-frontend', 's
const SENSITIVE_ENV_NAME = /(SECRET|TOKEN|PASSWORD|PASSWD|PRIVATE_KEY|CLIENT_SECRET|DATABASE_URL|REDIS_URL)/iu;
export const RELEASE_WORKSPACE_CLEANUP_INTERVAL_MS = 24 * 60 * 60 * 1_000;
export interface ReleaseManagedCleanupResult {
workspaces: { removed: string[]; skipped: string[] };
artifacts: FrontendArtifactCleanupResult;
}
const isRuntimeProcessActive = (status: string): boolean =>
['online', 'launching', 'stopping'].includes(status.toLowerCase());
@@ -183,6 +191,36 @@ export class GatewayReleaseController {
});
}
async cleanupStaleResources(): Promise<ReleaseManagedCleanupResult> {
const workspaces = await this.cleanupStaleWorkspaces();
let artifacts: FrontendArtifactCleanupResult = { removed: [], retained: [], skipped: [] };
if (this.config.frontendServeMode === 'static') {
const [state, operations] = await Promise.all([
this.repository.getState(),
this.repository.listOperations(100),
]);
artifacts = await this.artifactManager.cleanup({
frontendKeys: ['gateway'],
protectedCommitShas: [
...[state.activeCommitSha, state.previousCommitSha].filter((commitSha): commitSha is string =>
Boolean(commitSha)
),
...operations
.filter(
(operation) =>
operation.resolvedCommitSha &&
(operation.status === 'QUEUED' || operation.status === 'RUNNING')
)
.map((operation) => operation.resolvedCommitSha as string),
],
retentionMs: DEFAULT_FRONTEND_ARTIFACT_RETENTION_MS,
keepNewest: DEFAULT_FRONTEND_ARTIFACT_KEEP_NEWEST,
now: this.now(),
});
}
return { workspaces, artifacts };
}
private sanitizeLogMessage(message: string): string {
let sanitized = stripVTControlCharacters(message);
const sensitiveValues = new Set([
@@ -3,8 +3,11 @@ import os from 'node:os';
import path from 'node:path';
import {
DEFAULT_FRONTEND_ARTIFACT_KEEP_NEWEST,
DEFAULT_FRONTEND_ARTIFACT_RETENTION_MS,
DEFAULT_MANAGED_WORKSPACE_KEEP_NEWEST,
DEFAULT_MANAGED_WORKSPACE_RETENTION_MS,
FrontendArtifactManager,
type BuildRunner,
type GatewayReleaseOperationRecord,
type GatewayReleaseRepository,
@@ -224,8 +227,7 @@ describe('GatewayReleaseController', () => {
} as unknown as GitWorkspaceManager,
{ run: async () => ({ ok: true, exitCode: 0, output: '' }) },
{
list: async () =>
[...running].map(([name, cwd]) => ({ name, cwd, status: 'online', restartCount: 0 })),
list: async () => [...running].map(([name, cwd]) => ({ name, cwd, status: 'online', restartCount: 0 })),
start: async (definition) => {
running.set(definition.name, definition.cwd);
},
@@ -321,6 +323,61 @@ describe('GatewayReleaseController', () => {
});
});
it('cleans expired unreferenced Gateway frontend releases with the managed daily policy', async () => {
const workspace = await createReleaseWorkspace();
const artifactRoot = path.join(workspace, 'artifact-cleanup-volume');
const sourceRoot = path.join(workspace, 'gateway-cleanup-dist');
await fs.mkdir(sourceRoot, { recursive: true });
const manager = new FrontendArtifactManager(artifactRoot);
const stage = async (marker: string, commitSha: string) => {
await fs.writeFile(path.join(sourceRoot, 'index.html'), `<div>${marker}</div>`);
return manager.stage({ frontendKey: 'gateway', sourceRoot, commitSha });
};
const active = await stage('active', OLD_SHA);
await manager.activate('gateway', active.releaseId);
const cacheOne = await stage('cache-one', '3'.repeat(40));
const cacheTwo = await stage('cache-two', '4'.repeat(40));
const stale = await stage('stale', '5'.repeat(40));
const now = new Date('2026-08-23T00:00:00.000Z');
const old = new Date(now.getTime() - 72 * 60 * 60 * 1_000);
for (const artifact of [active, cacheOne, cacheTwo, stale]) {
await fs.utimes(artifact.releasePath, old, old);
}
await fs.utimes(cacheTwo.releasePath, new Date(old.getTime() + 2_000), new Date(old.getTime() + 2_000));
await fs.utimes(cacheOne.releasePath, new Date(old.getTime() + 1_000), new Date(old.getTime() + 1_000));
const harness = createRepository();
const controller = new GatewayReleaseController(
harness.repository,
{
listManagedWorkspaces: async () => [],
cleanup: async () => ({ removed: [], skipped: [] }),
} as unknown as GitWorkspaceManager,
{ run: async () => ({ ok: true, exitCode: 0, output: '' }) },
{
list: async () => [],
start: async () => {},
stop: async () => {},
delete: async () => {},
},
{
...config,
frontendServeMode: 'static',
frontendArtifactRoot: artifactRoot,
},
() => now
);
const result = await controller.cleanupStaleResources();
expect(result.workspaces).toEqual({ removed: [], skipped: [] });
expect(result.artifacts.removed).toEqual([stale.releasePath]);
expect(result.artifacts.retained).toEqual(
expect.arrayContaining([active.releasePath, cacheOne.releasePath, cacheTwo.releasePath])
);
expect(DEFAULT_FRONTEND_ARTIFACT_RETENTION_MS).toBe(24 * 60 * 60 * 1_000);
expect(DEFAULT_FRONTEND_ARTIFACT_KEEP_NEWEST).toBe(2);
});
it('builds, migrates, switches all gateway roles, verifies readiness, and publishes atomically', async () => {
const workspace = await createReleaseWorkspace();
const harness = createRepository();