mirror of
https://github.com/game-ci/unity-builder.git
synced 2026-06-10 16:03:54 -07:00
feat(cache): add child workspace isolation for multi-product CI builds (#777)
Implement two-level workspace isolation pattern for enterprise-scale CI: - Atomic O(1) workspace restore via filesystem move (no tar/download/extract) - Separate Library caching for independent restore - .git preservation for delta operations - Stale workspace cleanup with configurable retention policies - 5 new action inputs: childWorkspacesEnabled, childWorkspaceName, childWorkspaceCacheRoot, childWorkspacePreserveGit, childWorkspaceSeparateLibrary - 28 unit tests covering all service methods This enables enterprise CI where workspaces are 50GB+ and traditional caching via actions/cache is impractical. On NTFS, workspace restore is O(1) via atomic rename when source and destination are on the same volume. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
+36
-8
@@ -316,14 +316,43 @@ inputs:
|
||||
required: false
|
||||
default: 'false'
|
||||
description: 'Cache Git LFS objects for local builds. Only effective when localCacheEnabled is true.'
|
||||
childWorkspacesEnabled:
|
||||
required: false
|
||||
default: 'false'
|
||||
description:
|
||||
'Enable child workspace isolation for multi-product builds. Uses atomic filesystem moves for O(1) workspace
|
||||
restore instead of tar/download/extract. Ideal for 50GB+ workspaces on self-hosted runners.'
|
||||
childWorkspaceName:
|
||||
required: false
|
||||
default: ''
|
||||
description:
|
||||
'Name for this child workspace (e.g., product name like "TurnOfWar"). Used as the cache key for workspace
|
||||
isolation. Required when childWorkspacesEnabled is true.'
|
||||
childWorkspaceCacheRoot:
|
||||
required: false
|
||||
default: ''
|
||||
description:
|
||||
'Parent directory for cached child workspaces. Should be on the same NTFS volume as the build directory for O(1)
|
||||
atomic restore via filesystem rename. Defaults to $RUNNER_TEMP/game-ci-workspaces.'
|
||||
childWorkspacePreserveGit:
|
||||
required: false
|
||||
default: 'true'
|
||||
description:
|
||||
'Preserve .git directory in cached child workspace. Enables delta operations on restore but increases cache size.
|
||||
Set to false to save disk space at the cost of full re-clone on restore.'
|
||||
childWorkspaceSeparateLibrary:
|
||||
required: false
|
||||
default: 'true'
|
||||
description:
|
||||
'Cache Unity Library folder separately from the child workspace. Allows independent Library restore even when
|
||||
workspace cache is invalidated. Recommended for large projects.'
|
||||
lfsTransferAgent:
|
||||
required: false
|
||||
default: ''
|
||||
description:
|
||||
'Custom Git LFS transfer agent. Set to "elastic-git-storage" for built-in support (auto-installs
|
||||
from GitHub releases). Append @version for a specific release (e.g. "elastic-git-storage@v1.0.0").
|
||||
Or provide a path to any custom transfer agent executable. When set, the agent is registered
|
||||
via git config before LFS operations.'
|
||||
'Custom Git LFS transfer agent. Set to "elastic-git-storage" for built-in support (auto-installs from GitHub
|
||||
releases). Append @version for a specific release (e.g. "elastic-git-storage@v1.0.0"). Or provide a path to any
|
||||
custom transfer agent executable. When set, the agent is registered via git config before LFS operations.'
|
||||
lfsTransferAgentArgs:
|
||||
required: false
|
||||
default: ''
|
||||
@@ -349,10 +378,9 @@ inputs:
|
||||
required: false
|
||||
default: ''
|
||||
description:
|
||||
'Comma-separated list of lefthook hook groups to run before the Unity build. Allows CI to
|
||||
trigger checks that normally only run on git events. Example: pre-commit,pre-push. Requires
|
||||
lefthook. Works with Unity Git Hooks (com.frostebite.unitygithooks) when installed as a UPM
|
||||
package — the init script runs automatically.'
|
||||
'Comma-separated list of lefthook hook groups to run before the Unity build. Allows CI to trigger checks that
|
||||
normally only run on git events. Example: pre-commit,pre-push. Requires lefthook. Works with Unity Git Hooks
|
||||
(com.frostebite.unitygithooks) when installed as a UPM package — the init script runs automatically.'
|
||||
providerExecutable:
|
||||
required: false
|
||||
default: ''
|
||||
|
||||
+485
-2
@@ -53,6 +53,26 @@ async function runMain() {
|
||||
let exitCode = -1;
|
||||
if (buildParameters.providerStrategy === 'local') {
|
||||
core.info('Building locally');
|
||||
// Child workspace isolation - restore cached workspace before any other setup
|
||||
let childWorkspaceConfig;
|
||||
if (buildParameters.childWorkspacesEnabled && buildParameters.childWorkspaceName) {
|
||||
const { ChildWorkspaceService } = await Promise.resolve().then(() => __importStar(__nccwpck_require__(93834)));
|
||||
const cacheRoot = buildParameters.childWorkspaceCacheRoot ||
|
||||
node_path_1.default.join(buildParameters.runnerTempPath || process.env.RUNNER_TEMP || '', 'game-ci-workspaces');
|
||||
childWorkspaceConfig = ChildWorkspaceService.buildConfig({
|
||||
childWorkspacesEnabled: buildParameters.childWorkspacesEnabled,
|
||||
childWorkspaceName: buildParameters.childWorkspaceName,
|
||||
childWorkspaceCacheRoot: cacheRoot,
|
||||
childWorkspacePreserveGit: buildParameters.childWorkspacePreserveGit,
|
||||
childWorkspaceSeparateLibrary: buildParameters.childWorkspaceSeparateLibrary,
|
||||
});
|
||||
const projectFullPath = node_path_1.default.join(workspace, buildParameters.projectPath);
|
||||
const restored = ChildWorkspaceService.initializeWorkspace(projectFullPath, childWorkspaceConfig);
|
||||
core.info(`Child workspace "${buildParameters.childWorkspaceName}": ${restored ? 'restored from cache' : 'starting fresh'}`);
|
||||
// Log workspace size for resource tracking
|
||||
const size = ChildWorkspaceService.getWorkspaceSize(projectFullPath);
|
||||
core.info(`Child workspace size after restore: ${size}`);
|
||||
}
|
||||
// Submodule profile initialization
|
||||
if (buildParameters.submoduleProfilePath) {
|
||||
const { SubmoduleProfileService } = await Promise.resolve().then(() => __importStar(__nccwpck_require__(88664)));
|
||||
@@ -86,8 +106,8 @@ async function runMain() {
|
||||
const { GitHooksService } = await Promise.resolve().then(() => __importStar(__nccwpck_require__(9146)));
|
||||
await GitHooksService.installHooks(workspace);
|
||||
if (buildParameters.gitHooksSkipList) {
|
||||
const env = GitHooksService.configureSkipList(buildParameters.gitHooksSkipList.split(','));
|
||||
Object.assign(process.env, env);
|
||||
const environment = GitHooksService.configureSkipList(buildParameters.gitHooksSkipList.split(','));
|
||||
Object.assign(process.env, environment);
|
||||
}
|
||||
}
|
||||
else {
|
||||
@@ -114,6 +134,15 @@ async function runMain() {
|
||||
await LocalCacheService.saveLfsCache(workspace, cacheRoot, cacheKey);
|
||||
}
|
||||
}
|
||||
// Child workspace isolation - save workspace for next run
|
||||
if (childWorkspaceConfig && childWorkspaceConfig.enabled) {
|
||||
const { ChildWorkspaceService } = await Promise.resolve().then(() => __importStar(__nccwpck_require__(93834)));
|
||||
const projectFullPath = node_path_1.default.join(workspace, buildParameters.projectPath);
|
||||
const preSaveSize = ChildWorkspaceService.getWorkspaceSize(projectFullPath);
|
||||
core.info(`Child workspace size before save: ${preSaveSize}`);
|
||||
ChildWorkspaceService.saveWorkspace(projectFullPath, childWorkspaceConfig);
|
||||
core.info(`Child workspace "${buildParameters.childWorkspaceName}" saved to cache`);
|
||||
}
|
||||
}
|
||||
else {
|
||||
await model_1.Orchestrator.run(buildParameters, baseImage.toString());
|
||||
@@ -435,11 +464,17 @@ class BuildParameters {
|
||||
localCacheRoot: input_1.default.localCacheRoot,
|
||||
localCacheLibrary: input_1.default.localCacheLibrary,
|
||||
localCacheLfs: input_1.default.localCacheLfs,
|
||||
childWorkspacesEnabled: input_1.default.childWorkspacesEnabled,
|
||||
childWorkspaceName: input_1.default.childWorkspaceName,
|
||||
childWorkspaceCacheRoot: input_1.default.childWorkspaceCacheRoot,
|
||||
childWorkspacePreserveGit: input_1.default.childWorkspacePreserveGit,
|
||||
childWorkspaceSeparateLibrary: input_1.default.childWorkspaceSeparateLibrary,
|
||||
lfsTransferAgent: input_1.default.lfsTransferAgent,
|
||||
lfsTransferAgentArgs: input_1.default.lfsTransferAgentArgs,
|
||||
lfsStoragePaths: input_1.default.lfsStoragePaths,
|
||||
gitHooksEnabled: input_1.default.gitHooksEnabled,
|
||||
gitHooksSkipList: input_1.default.gitHooksSkipList,
|
||||
gitHooksRunBeforeBuild: input_1.default.gitHooksRunBeforeBuild,
|
||||
providerExecutable: input_1.default.providerExecutable,
|
||||
};
|
||||
}
|
||||
@@ -1944,6 +1979,21 @@ class Input {
|
||||
static get localCacheLfs() {
|
||||
return (Input.getInput('localCacheLfs') ?? 'false') === 'true';
|
||||
}
|
||||
static get childWorkspacesEnabled() {
|
||||
return (Input.getInput('childWorkspacesEnabled') ?? 'false') === 'true';
|
||||
}
|
||||
static get childWorkspaceName() {
|
||||
return Input.getInput('childWorkspaceName') ?? '';
|
||||
}
|
||||
static get childWorkspaceCacheRoot() {
|
||||
return Input.getInput('childWorkspaceCacheRoot') ?? '';
|
||||
}
|
||||
static get childWorkspacePreserveGit() {
|
||||
return (Input.getInput('childWorkspacePreserveGit') ?? 'true') === 'true';
|
||||
}
|
||||
static get childWorkspaceSeparateLibrary() {
|
||||
return (Input.getInput('childWorkspaceSeparateLibrary') ?? 'true') === 'true';
|
||||
}
|
||||
static get lfsTransferAgent() {
|
||||
return Input.getInput('lfsTransferAgent') ?? '';
|
||||
}
|
||||
@@ -1959,6 +2009,9 @@ class Input {
|
||||
static get gitHooksSkipList() {
|
||||
return Input.getInput('gitHooksSkipList') ?? '';
|
||||
}
|
||||
static get gitHooksRunBeforeBuild() {
|
||||
return Input.getInput('gitHooksRunBeforeBuild') ?? '';
|
||||
}
|
||||
static get providerExecutable() {
|
||||
return Input.getInput('providerExecutable') ?? '';
|
||||
}
|
||||
@@ -8782,6 +8835,317 @@ exports.RemoteClientLogger = RemoteClientLogger;
|
||||
RemoteClientLogger.value = '';
|
||||
|
||||
|
||||
/***/ }),
|
||||
|
||||
/***/ 93834:
|
||||
/***/ (function(__unused_webpack_module, exports, __nccwpck_require__) {
|
||||
|
||||
"use strict";
|
||||
|
||||
var __importDefault = (this && this.__importDefault) || function (mod) {
|
||||
return (mod && mod.__esModule) ? mod : { "default": mod };
|
||||
};
|
||||
Object.defineProperty(exports, "__esModule", ({ value: true }));
|
||||
exports.ChildWorkspaceService = void 0;
|
||||
const node_fs_1 = __importDefault(__nccwpck_require__(87561));
|
||||
const node_path_1 = __importDefault(__nccwpck_require__(49411));
|
||||
const orchestrator_logger_1 = __importDefault(__nccwpck_require__(32549));
|
||||
/**
|
||||
* Child workspace isolation service for enterprise-scale CI builds.
|
||||
*
|
||||
* Instead of building in the git checkout directory, this service:
|
||||
* 1. Keeps the root workspace lean (no LFS files in the checkout dir)
|
||||
* 2. Creates isolated child workspaces per product/build-target
|
||||
* 3. Each child workspace gets its own submodule profile, LFS hydration, and Library folder
|
||||
* 4. After build, the child workspace (.git preserved) is moved to a parent-level backup directory
|
||||
* 5. On next CI run, the child workspace is restored via atomic filesystem move (O(1) on NTFS)
|
||||
* 6. Library folders are cached separately for independent restore
|
||||
*
|
||||
* This is orders of magnitude faster than actions/cache for 50GB+ workspaces.
|
||||
*/
|
||||
class ChildWorkspaceService {
|
||||
/**
|
||||
* Initialize child workspace by restoring from cache if available.
|
||||
* Uses atomic filesystem move (rename) for O(1) restore on same volume.
|
||||
*
|
||||
* @param projectPath - Target path where the workspace should live during build
|
||||
* @param config - Child workspace configuration
|
||||
* @returns true if restored from cache, false if starting fresh
|
||||
*/
|
||||
static initializeWorkspace(projectPath, config) {
|
||||
const cachedWorkspacePath = node_path_1.default.join(config.parentCacheRoot, config.workspaceName);
|
||||
try {
|
||||
if (!node_fs_1.default.existsSync(cachedWorkspacePath)) {
|
||||
orchestrator_logger_1.default.log(`[ChildWorkspace] No cached workspace found at ${cachedWorkspacePath}, starting fresh`);
|
||||
return false;
|
||||
}
|
||||
// Verify the cached workspace has content
|
||||
const entries = node_fs_1.default.readdirSync(cachedWorkspacePath);
|
||||
if (entries.length === 0) {
|
||||
orchestrator_logger_1.default.log(`[ChildWorkspace] Cached workspace at ${cachedWorkspacePath} is empty, starting fresh`);
|
||||
node_fs_1.default.rmSync(cachedWorkspacePath, { recursive: true, force: true });
|
||||
return false;
|
||||
}
|
||||
// If the target path already exists, remove it to make way for the move
|
||||
if (node_fs_1.default.existsSync(projectPath)) {
|
||||
orchestrator_logger_1.default.log(`[ChildWorkspace] Removing existing target path: ${projectPath}`);
|
||||
node_fs_1.default.rmSync(projectPath, { recursive: true, force: true });
|
||||
}
|
||||
// Ensure parent directory of project path exists
|
||||
const parentDirectory = node_path_1.default.dirname(projectPath);
|
||||
if (!node_fs_1.default.existsSync(parentDirectory)) {
|
||||
node_fs_1.default.mkdirSync(parentDirectory, { recursive: true });
|
||||
}
|
||||
// Atomic move (rename) - O(1) on NTFS when on same volume
|
||||
orchestrator_logger_1.default.log(`[ChildWorkspace] Restoring workspace: ${cachedWorkspacePath} -> ${projectPath}`);
|
||||
node_fs_1.default.renameSync(cachedWorkspacePath, projectPath);
|
||||
orchestrator_logger_1.default.log(`[ChildWorkspace] Workspace restored via atomic move`);
|
||||
// Restore Library cache separately if configured
|
||||
if (config.separateLibraryCache) {
|
||||
ChildWorkspaceService.restoreLibraryCache(projectPath, config);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
catch (error) {
|
||||
orchestrator_logger_1.default.logWarning(`[ChildWorkspace] Workspace restore failed: ${error.message}. Starting fresh.`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Save child workspace after build for reuse on next CI run.
|
||||
* Moves the entire workspace to the cache directory via atomic filesystem move.
|
||||
*
|
||||
* @param projectPath - Path to the workspace to save
|
||||
* @param config - Child workspace configuration
|
||||
*/
|
||||
static saveWorkspace(projectPath, config) {
|
||||
const cachedWorkspacePath = node_path_1.default.join(config.parentCacheRoot, config.workspaceName);
|
||||
try {
|
||||
if (!node_fs_1.default.existsSync(projectPath)) {
|
||||
orchestrator_logger_1.default.log(`[ChildWorkspace] Project path ${projectPath} does not exist, skipping save`);
|
||||
return;
|
||||
}
|
||||
// Remove .git directory if not preserving it (saves space but loses delta capability)
|
||||
if (!config.preserveGitDirectory) {
|
||||
const gitDirectory = node_path_1.default.join(projectPath, '.git');
|
||||
if (node_fs_1.default.existsSync(gitDirectory)) {
|
||||
orchestrator_logger_1.default.log(`[ChildWorkspace] Removing .git directory (preserveGit=false)`);
|
||||
node_fs_1.default.rmSync(gitDirectory, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
// If separateLibraryCache, move Library/ to its own backup path before saving workspace
|
||||
if (config.separateLibraryCache) {
|
||||
ChildWorkspaceService.saveLibraryCache(projectPath, config);
|
||||
}
|
||||
// Ensure parent cache root exists
|
||||
if (!node_fs_1.default.existsSync(config.parentCacheRoot)) {
|
||||
node_fs_1.default.mkdirSync(config.parentCacheRoot, { recursive: true });
|
||||
}
|
||||
// Remove any existing cached workspace to make room
|
||||
if (node_fs_1.default.existsSync(cachedWorkspacePath)) {
|
||||
orchestrator_logger_1.default.log(`[ChildWorkspace] Removing previous cached workspace: ${cachedWorkspacePath}`);
|
||||
node_fs_1.default.rmSync(cachedWorkspacePath, { recursive: true, force: true });
|
||||
}
|
||||
// Atomic move (rename) - O(1) on NTFS when on same volume
|
||||
orchestrator_logger_1.default.log(`[ChildWorkspace] Saving workspace: ${projectPath} -> ${cachedWorkspacePath}`);
|
||||
node_fs_1.default.renameSync(projectPath, cachedWorkspacePath);
|
||||
orchestrator_logger_1.default.log(`[ChildWorkspace] Workspace saved via atomic move`);
|
||||
}
|
||||
catch (error) {
|
||||
orchestrator_logger_1.default.logWarning(`[ChildWorkspace] Workspace save failed: ${error.message}`);
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Restore Library folder from separate cache location.
|
||||
*
|
||||
* @param projectPath - Path to the workspace where Library should be restored
|
||||
* @param config - Child workspace configuration
|
||||
* @returns true if Library was restored from cache
|
||||
*/
|
||||
static restoreLibraryCache(projectPath, config) {
|
||||
const libraryBackup = ChildWorkspaceService.resolveLibraryBackupPath(config);
|
||||
const libraryDestination = node_path_1.default.join(projectPath, 'Library');
|
||||
try {
|
||||
if (!node_fs_1.default.existsSync(libraryBackup)) {
|
||||
orchestrator_logger_1.default.log(`[ChildWorkspace] No Library cache found at ${libraryBackup}`);
|
||||
return false;
|
||||
}
|
||||
const entries = node_fs_1.default.readdirSync(libraryBackup);
|
||||
if (entries.length === 0) {
|
||||
orchestrator_logger_1.default.log(`[ChildWorkspace] Library cache at ${libraryBackup} is empty`);
|
||||
node_fs_1.default.rmSync(libraryBackup, { recursive: true, force: true });
|
||||
return false;
|
||||
}
|
||||
// Remove existing Library directory if present
|
||||
if (node_fs_1.default.existsSync(libraryDestination)) {
|
||||
node_fs_1.default.rmSync(libraryDestination, { recursive: true, force: true });
|
||||
}
|
||||
// Atomic move
|
||||
orchestrator_logger_1.default.log(`[ChildWorkspace] Restoring Library cache: ${libraryBackup} -> ${libraryDestination}`);
|
||||
node_fs_1.default.renameSync(libraryBackup, libraryDestination);
|
||||
orchestrator_logger_1.default.log(`[ChildWorkspace] Library cache restored`);
|
||||
return true;
|
||||
}
|
||||
catch (error) {
|
||||
orchestrator_logger_1.default.logWarning(`[ChildWorkspace] Library cache restore failed: ${error.message}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Save Library folder to a separate cache location for independent restore.
|
||||
* Moves Library/ out of the workspace before workspace save.
|
||||
*
|
||||
* @param projectPath - Path to the workspace containing Library/
|
||||
* @param config - Child workspace configuration
|
||||
*/
|
||||
static saveLibraryCache(projectPath, config) {
|
||||
const libraryPath = node_path_1.default.join(projectPath, 'Library');
|
||||
const libraryBackup = ChildWorkspaceService.resolveLibraryBackupPath(config);
|
||||
try {
|
||||
if (!node_fs_1.default.existsSync(libraryPath)) {
|
||||
orchestrator_logger_1.default.log(`[ChildWorkspace] No Library folder to cache`);
|
||||
return;
|
||||
}
|
||||
const entries = node_fs_1.default.readdirSync(libraryPath);
|
||||
if (entries.length === 0) {
|
||||
orchestrator_logger_1.default.log(`[ChildWorkspace] Library folder is empty, skipping cache`);
|
||||
return;
|
||||
}
|
||||
// Ensure parent of backup path exists
|
||||
const backupParent = node_path_1.default.dirname(libraryBackup);
|
||||
if (!node_fs_1.default.existsSync(backupParent)) {
|
||||
node_fs_1.default.mkdirSync(backupParent, { recursive: true });
|
||||
}
|
||||
// Remove existing Library backup
|
||||
if (node_fs_1.default.existsSync(libraryBackup)) {
|
||||
node_fs_1.default.rmSync(libraryBackup, { recursive: true, force: true });
|
||||
}
|
||||
// Atomic move
|
||||
orchestrator_logger_1.default.log(`[ChildWorkspace] Caching Library: ${libraryPath} -> ${libraryBackup}`);
|
||||
node_fs_1.default.renameSync(libraryPath, libraryBackup);
|
||||
orchestrator_logger_1.default.log(`[ChildWorkspace] Library cached separately`);
|
||||
}
|
||||
catch (error) {
|
||||
orchestrator_logger_1.default.logWarning(`[ChildWorkspace] Library cache save failed: ${error.message}`);
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Calculate the total size of a directory in human-readable format.
|
||||
*
|
||||
* @param directoryPath - Path to the directory to measure
|
||||
* @returns Human-readable size string (e.g., "1.23 GB", "456.78 MB")
|
||||
*/
|
||||
static getWorkspaceSize(directoryPath) {
|
||||
try {
|
||||
if (!node_fs_1.default.existsSync(directoryPath)) {
|
||||
return '0 B';
|
||||
}
|
||||
const totalBytes = ChildWorkspaceService.calculateDirectorySize(directoryPath);
|
||||
return ChildWorkspaceService.formatBytes(totalBytes);
|
||||
}
|
||||
catch (error) {
|
||||
orchestrator_logger_1.default.logWarning(`[ChildWorkspace] Failed to calculate workspace size: ${error.message}`);
|
||||
return 'unknown';
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Clean stale child workspaces that haven't been used within the retention period.
|
||||
*
|
||||
* @param parentCacheRoot - Root directory containing cached workspaces
|
||||
* @param retentionDays - Maximum age in days before a workspace is considered stale
|
||||
*/
|
||||
static cleanStaleWorkspaces(parentCacheRoot, retentionDays) {
|
||||
try {
|
||||
if (!node_fs_1.default.existsSync(parentCacheRoot)) {
|
||||
orchestrator_logger_1.default.log(`[ChildWorkspace] Cache root ${parentCacheRoot} does not exist, nothing to clean`);
|
||||
return;
|
||||
}
|
||||
const now = Date.now();
|
||||
const maxAgeMs = retentionDays * 24 * 60 * 60 * 1000;
|
||||
const entries = node_fs_1.default.readdirSync(parentCacheRoot);
|
||||
let removedCount = 0;
|
||||
let freedBytes = 0;
|
||||
for (const entry of entries) {
|
||||
const entryPath = node_path_1.default.join(parentCacheRoot, entry);
|
||||
try {
|
||||
const stat = node_fs_1.default.statSync(entryPath);
|
||||
if (stat.isDirectory() && now - stat.mtimeMs > maxAgeMs) {
|
||||
const size = ChildWorkspaceService.calculateDirectorySize(entryPath);
|
||||
node_fs_1.default.rmSync(entryPath, { recursive: true, force: true });
|
||||
removedCount++;
|
||||
freedBytes += size;
|
||||
orchestrator_logger_1.default.log(`[ChildWorkspace] Cleaned stale workspace: ${entry} (age: ${Math.floor((now - stat.mtimeMs) / (24 * 60 * 60 * 1000))} days)`);
|
||||
}
|
||||
}
|
||||
catch (error) {
|
||||
orchestrator_logger_1.default.logWarning(`[ChildWorkspace] Failed to clean ${entryPath}: ${error.message}`);
|
||||
}
|
||||
}
|
||||
orchestrator_logger_1.default.log(`[ChildWorkspace] Cleanup complete: ${removedCount} stale workspaces removed, ${ChildWorkspaceService.formatBytes(freedBytes)} freed`);
|
||||
}
|
||||
catch (error) {
|
||||
orchestrator_logger_1.default.logWarning(`[ChildWorkspace] Stale workspace cleanup failed: ${error.message}`);
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Build a ChildWorkspaceConfig from build parameters and action inputs.
|
||||
*/
|
||||
static buildConfig(parameters) {
|
||||
return {
|
||||
enabled: parameters.childWorkspacesEnabled,
|
||||
workspaceName: parameters.childWorkspaceName,
|
||||
parentCacheRoot: parameters.childWorkspaceCacheRoot,
|
||||
preserveGitDirectory: parameters.childWorkspacePreserveGit,
|
||||
separateLibraryCache: parameters.childWorkspaceSeparateLibrary,
|
||||
};
|
||||
}
|
||||
/**
|
||||
* Resolve the Library backup path from config, using a default if not overridden.
|
||||
*/
|
||||
static resolveLibraryBackupPath(config) {
|
||||
if (config.libraryBackupPath) {
|
||||
return config.libraryBackupPath;
|
||||
}
|
||||
return node_path_1.default.join(config.parentCacheRoot, `${config.workspaceName}-Library`);
|
||||
}
|
||||
/**
|
||||
* Recursively calculate total size of a directory in bytes.
|
||||
*/
|
||||
static calculateDirectorySize(directoryPath) {
|
||||
let totalSize = 0;
|
||||
try {
|
||||
const entries = node_fs_1.default.readdirSync(directoryPath, { withFileTypes: true });
|
||||
for (const entry of entries) {
|
||||
const fullPath = node_path_1.default.join(directoryPath, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
totalSize += ChildWorkspaceService.calculateDirectorySize(fullPath);
|
||||
}
|
||||
else if (entry.isFile()) {
|
||||
totalSize += node_fs_1.default.statSync(fullPath).size;
|
||||
}
|
||||
}
|
||||
}
|
||||
catch {
|
||||
// Permission errors or race conditions — return what we have
|
||||
}
|
||||
return totalSize;
|
||||
}
|
||||
/**
|
||||
* Format bytes into human-readable string.
|
||||
*/
|
||||
static formatBytes(bytes) {
|
||||
if (bytes === 0)
|
||||
return '0 B';
|
||||
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||||
const k = 1024;
|
||||
const index = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
const value = bytes / Math.pow(k, index);
|
||||
return `${value.toFixed(2)} ${units[index]}`;
|
||||
}
|
||||
}
|
||||
exports.ChildWorkspaceService = ChildWorkspaceService;
|
||||
|
||||
|
||||
/***/ }),
|
||||
|
||||
/***/ 68829:
|
||||
@@ -10386,8 +10750,83 @@ class GitHooksService {
|
||||
}
|
||||
return 'none';
|
||||
}
|
||||
/**
|
||||
* Detect if Unity Git Hooks (com.frostebite.unitygithooks) is installed as a UPM package.
|
||||
* Checks Packages/manifest.json for the package dependency.
|
||||
*/
|
||||
static detectUnityGitHooks(repoPath) {
|
||||
const manifestPath = node_path_1.default.join(repoPath, 'Packages', 'manifest.json');
|
||||
if (!node_fs_1.default.existsSync(manifestPath)) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
const content = node_fs_1.default.readFileSync(manifestPath, 'utf8');
|
||||
return content.includes(GitHooksService.UNITY_GIT_HOOKS_PACKAGE);
|
||||
}
|
||||
catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Find the Unity Git Hooks package directory in the Library/PackageCache.
|
||||
* Returns the path to the package directory, or empty string if not found.
|
||||
*/
|
||||
static findUnityGitHooksPackagePath(repoPath) {
|
||||
const packageCacheDir = node_path_1.default.join(repoPath, 'Library', 'PackageCache');
|
||||
if (!node_fs_1.default.existsSync(packageCacheDir)) {
|
||||
return '';
|
||||
}
|
||||
try {
|
||||
const entries = node_fs_1.default.readdirSync(packageCacheDir);
|
||||
const match = entries.find((entry) => entry.startsWith(GitHooksService.UNITY_GIT_HOOKS_PACKAGE));
|
||||
if (match) {
|
||||
return node_path_1.default.join(packageCacheDir, match);
|
||||
}
|
||||
}
|
||||
catch {
|
||||
// PackageCache not available
|
||||
}
|
||||
return '';
|
||||
}
|
||||
/**
|
||||
* Initialize Unity Git Hooks by running its init script.
|
||||
* This installs the required npm modules that the hooks depend on.
|
||||
* Should be called before installHooks() when Unity Git Hooks is detected.
|
||||
*/
|
||||
static async initUnityGitHooks(repoPath) {
|
||||
const packagePath = GitHooksService.findUnityGitHooksPackagePath(repoPath);
|
||||
if (!packagePath) {
|
||||
orchestrator_logger_1.default.log(`[GitHooks] Unity Git Hooks package not found in Library/PackageCache, skipping init`);
|
||||
return;
|
||||
}
|
||||
const initScript = node_path_1.default.join(packagePath, '~js', 'init-unity-lefthook.js');
|
||||
if (!node_fs_1.default.existsSync(initScript)) {
|
||||
orchestrator_logger_1.default.logWarning(`[GitHooks] Unity Git Hooks init script not found at ${initScript}`);
|
||||
return;
|
||||
}
|
||||
orchestrator_logger_1.default.log(`[GitHooks] Initializing Unity Git Hooks from ${packagePath}`);
|
||||
try {
|
||||
await orchestrator_system_1.OrchestratorSystem.Run(`cd "${repoPath}" && node "${initScript}"`, true);
|
||||
orchestrator_logger_1.default.log(`[GitHooks] Unity Git Hooks initialized successfully`);
|
||||
}
|
||||
catch (error) {
|
||||
orchestrator_logger_1.default.logWarning(`[GitHooks] Unity Git Hooks init failed: ${error.message}`);
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Configure CI-friendly environment variables for Unity Git Hooks.
|
||||
* Disables background project mode (CI already has an isolated workspace)
|
||||
* and sets other env vars appropriate for headless CI environments.
|
||||
*/
|
||||
static configureUnityGitHooksCIEnv() {
|
||||
return {
|
||||
UNITY_GITHOOKS_BACKGROUND_PROJECT_ENABLED: 'false',
|
||||
CI: 'true',
|
||||
};
|
||||
}
|
||||
/**
|
||||
* Install git hooks using the detected framework.
|
||||
* If Unity Git Hooks is detected, initializes it first.
|
||||
* Errors are caught and logged as warnings - hook installation should not fail the build.
|
||||
*/
|
||||
static async installHooks(repoPath) {
|
||||
@@ -10397,6 +10836,16 @@ class GitHooksService {
|
||||
return;
|
||||
}
|
||||
orchestrator_logger_1.default.log(`[GitHooks] Detected hook framework: ${framework}`);
|
||||
// If Unity Git Hooks is present, initialize it before installing hooks
|
||||
if (framework === 'lefthook' && GitHooksService.detectUnityGitHooks(repoPath)) {
|
||||
orchestrator_logger_1.default.log(`[GitHooks] Unity Git Hooks (UPM) detected, running init`);
|
||||
// Set CI-friendly env vars
|
||||
const ciEnv = GitHooksService.configureUnityGitHooksCIEnv();
|
||||
for (const [key, value] of Object.entries(ciEnv)) {
|
||||
process.env[key] = value;
|
||||
}
|
||||
await GitHooksService.initUnityGitHooks(repoPath);
|
||||
}
|
||||
try {
|
||||
if (framework === 'lefthook') {
|
||||
await orchestrator_system_1.OrchestratorSystem.Run(`cd "${repoPath}" && npx lefthook install`, true);
|
||||
@@ -10411,6 +10860,39 @@ class GitHooksService {
|
||||
orchestrator_logger_1.default.logWarning(`[GitHooks] Hook installation failed: ${error.message}`);
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Explicitly run specific lefthook hook groups before the build.
|
||||
* This allows CI to trigger pre-commit, pre-push, or other checks
|
||||
* that would normally only run on git events.
|
||||
*
|
||||
* @param repoPath - Path to the repository
|
||||
* @param hookGroups - Lefthook group names to run (e.g., ['pre-commit', 'pre-push'])
|
||||
* @returns Map of group name to success/failure
|
||||
*/
|
||||
static async runHookGroups(repoPath, hookGroups) {
|
||||
const results = {};
|
||||
if (hookGroups.length === 0) {
|
||||
return results;
|
||||
}
|
||||
const framework = GitHooksService.detectHookFramework(repoPath);
|
||||
if (framework !== 'lefthook') {
|
||||
orchestrator_logger_1.default.logWarning(`[GitHooks] runHookGroups requires lefthook, but detected: ${framework}`);
|
||||
return results;
|
||||
}
|
||||
orchestrator_logger_1.default.log(`[GitHooks] Running ${hookGroups.length} hook group(s): ${hookGroups.join(', ')}`);
|
||||
for (const group of hookGroups) {
|
||||
try {
|
||||
await orchestrator_system_1.OrchestratorSystem.Run(`cd "${repoPath}" && npx lefthook run ${group}`, true);
|
||||
orchestrator_logger_1.default.log(`[GitHooks] Hook group '${group}' passed`);
|
||||
results[group] = true;
|
||||
}
|
||||
catch (error) {
|
||||
orchestrator_logger_1.default.logWarning(`[GitHooks] Hook group '${group}' failed: ${error.message}`);
|
||||
results[group] = false;
|
||||
}
|
||||
}
|
||||
return results;
|
||||
}
|
||||
/**
|
||||
* Return environment variables that will skip the listed hooks.
|
||||
* For lefthook: sets LEFTHOOK_EXCLUDE to a comma-separated list.
|
||||
@@ -10449,6 +10931,7 @@ class GitHooksService {
|
||||
}
|
||||
}
|
||||
exports.GitHooksService = GitHooksService;
|
||||
GitHooksService.UNITY_GIT_HOOKS_PACKAGE = 'com.frostebite.unitygithooks';
|
||||
|
||||
|
||||
/***/ }),
|
||||
|
||||
+1
-1
File diff suppressed because one or more lines are too long
+40
-2
@@ -25,6 +25,33 @@ async function runMain() {
|
||||
if (buildParameters.providerStrategy === 'local') {
|
||||
core.info('Building locally');
|
||||
|
||||
// Child workspace isolation - restore cached workspace before any other setup
|
||||
let childWorkspaceConfig: any;
|
||||
if (buildParameters.childWorkspacesEnabled && buildParameters.childWorkspaceName) {
|
||||
const { ChildWorkspaceService } = await import('./model/orchestrator/services/cache/child-workspace-service');
|
||||
const cacheRoot =
|
||||
buildParameters.childWorkspaceCacheRoot ||
|
||||
path.join(buildParameters.runnerTempPath || process.env.RUNNER_TEMP || '', 'game-ci-workspaces');
|
||||
childWorkspaceConfig = ChildWorkspaceService.buildConfig({
|
||||
childWorkspacesEnabled: buildParameters.childWorkspacesEnabled,
|
||||
childWorkspaceName: buildParameters.childWorkspaceName,
|
||||
childWorkspaceCacheRoot: cacheRoot,
|
||||
childWorkspacePreserveGit: buildParameters.childWorkspacePreserveGit,
|
||||
childWorkspaceSeparateLibrary: buildParameters.childWorkspaceSeparateLibrary,
|
||||
});
|
||||
const projectFullPath = path.join(workspace, buildParameters.projectPath);
|
||||
const restored = ChildWorkspaceService.initializeWorkspace(projectFullPath, childWorkspaceConfig);
|
||||
core.info(
|
||||
`Child workspace "${buildParameters.childWorkspaceName}": ${
|
||||
restored ? 'restored from cache' : 'starting fresh'
|
||||
}`,
|
||||
);
|
||||
|
||||
// Log workspace size for resource tracking
|
||||
const size = ChildWorkspaceService.getWorkspaceSize(projectFullPath);
|
||||
core.info(`Child workspace size after restore: ${size}`);
|
||||
}
|
||||
|
||||
// Submodule profile initialization
|
||||
if (buildParameters.submoduleProfilePath) {
|
||||
const { SubmoduleProfileService } = await import(
|
||||
@@ -80,8 +107,8 @@ async function runMain() {
|
||||
const { GitHooksService } = await import('./model/orchestrator/services/hooks/git-hooks-service');
|
||||
await GitHooksService.installHooks(workspace);
|
||||
if (buildParameters.gitHooksSkipList) {
|
||||
const env = GitHooksService.configureSkipList(buildParameters.gitHooksSkipList.split(','));
|
||||
Object.assign(process.env, env);
|
||||
const environment = GitHooksService.configureSkipList(buildParameters.gitHooksSkipList.split(','));
|
||||
Object.assign(process.env, environment);
|
||||
}
|
||||
} else {
|
||||
const { GitHooksService } = await import('./model/orchestrator/services/hooks/git-hooks-service');
|
||||
@@ -109,6 +136,17 @@ async function runMain() {
|
||||
await LocalCacheService.saveLfsCache(workspace, cacheRoot, cacheKey);
|
||||
}
|
||||
}
|
||||
|
||||
// Child workspace isolation - save workspace for next run
|
||||
if (childWorkspaceConfig && childWorkspaceConfig.enabled) {
|
||||
const { ChildWorkspaceService } = await import('./model/orchestrator/services/cache/child-workspace-service');
|
||||
const projectFullPath = path.join(workspace, buildParameters.projectPath);
|
||||
const preSaveSize = ChildWorkspaceService.getWorkspaceSize(projectFullPath);
|
||||
core.info(`Child workspace size before save: ${preSaveSize}`);
|
||||
|
||||
ChildWorkspaceService.saveWorkspace(projectFullPath, childWorkspaceConfig);
|
||||
core.info(`Child workspace "${buildParameters.childWorkspaceName}" saved to cache`);
|
||||
}
|
||||
} else {
|
||||
await Orchestrator.run(buildParameters, baseImage.toString());
|
||||
exitCode = 0;
|
||||
|
||||
@@ -113,6 +113,11 @@ class BuildParameters {
|
||||
public localCacheRoot!: string;
|
||||
public localCacheLibrary!: boolean;
|
||||
public localCacheLfs!: boolean;
|
||||
public childWorkspacesEnabled!: boolean;
|
||||
public childWorkspaceName!: string;
|
||||
public childWorkspaceCacheRoot!: string;
|
||||
public childWorkspacePreserveGit!: boolean;
|
||||
public childWorkspaceSeparateLibrary!: boolean;
|
||||
public lfsTransferAgent!: string;
|
||||
public lfsTransferAgentArgs!: string;
|
||||
public lfsStoragePaths!: string;
|
||||
@@ -263,6 +268,11 @@ class BuildParameters {
|
||||
localCacheRoot: Input.localCacheRoot,
|
||||
localCacheLibrary: Input.localCacheLibrary,
|
||||
localCacheLfs: Input.localCacheLfs,
|
||||
childWorkspacesEnabled: Input.childWorkspacesEnabled,
|
||||
childWorkspaceName: Input.childWorkspaceName,
|
||||
childWorkspaceCacheRoot: Input.childWorkspaceCacheRoot,
|
||||
childWorkspacePreserveGit: Input.childWorkspacePreserveGit,
|
||||
childWorkspaceSeparateLibrary: Input.childWorkspaceSeparateLibrary,
|
||||
lfsTransferAgent: Input.lfsTransferAgent,
|
||||
lfsTransferAgentArgs: Input.lfsTransferAgentArgs,
|
||||
lfsStoragePaths: Input.lfsStoragePaths,
|
||||
|
||||
@@ -310,6 +310,26 @@ class Input {
|
||||
return (Input.getInput('localCacheLfs') ?? 'false') === 'true';
|
||||
}
|
||||
|
||||
static get childWorkspacesEnabled(): boolean {
|
||||
return (Input.getInput('childWorkspacesEnabled') ?? 'false') === 'true';
|
||||
}
|
||||
|
||||
static get childWorkspaceName(): string {
|
||||
return Input.getInput('childWorkspaceName') ?? '';
|
||||
}
|
||||
|
||||
static get childWorkspaceCacheRoot(): string {
|
||||
return Input.getInput('childWorkspaceCacheRoot') ?? '';
|
||||
}
|
||||
|
||||
static get childWorkspacePreserveGit(): boolean {
|
||||
return (Input.getInput('childWorkspacePreserveGit') ?? 'true') === 'true';
|
||||
}
|
||||
|
||||
static get childWorkspaceSeparateLibrary(): boolean {
|
||||
return (Input.getInput('childWorkspaceSeparateLibrary') ?? 'true') === 'true';
|
||||
}
|
||||
|
||||
static get lfsTransferAgent(): string {
|
||||
return Input.getInput('lfsTransferAgent') ?? '';
|
||||
}
|
||||
|
||||
@@ -0,0 +1,458 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { ChildWorkspaceService, ChildWorkspaceConfig } from './child-workspace-service';
|
||||
|
||||
jest.mock('node:fs');
|
||||
jest.mock('../core/orchestrator-logger', () => ({
|
||||
__esModule: true,
|
||||
default: {
|
||||
log: jest.fn(),
|
||||
logWarning: jest.fn(),
|
||||
error: jest.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
const mockFs = fs as jest.Mocked<typeof fs>;
|
||||
|
||||
function createConfig(overrides: Partial<ChildWorkspaceConfig> = {}): ChildWorkspaceConfig {
|
||||
return {
|
||||
enabled: true,
|
||||
workspaceName: 'TurnOfWar',
|
||||
parentCacheRoot: '/cache/workspaces',
|
||||
preserveGitDirectory: true,
|
||||
separateLibraryCache: true,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('ChildWorkspaceService', () => {
|
||||
beforeEach(() => {
|
||||
jest.resetAllMocks();
|
||||
});
|
||||
|
||||
describe('initializeWorkspace', () => {
|
||||
it('should return false when no cached workspace exists', () => {
|
||||
(mockFs.existsSync as jest.Mock).mockReturnValue(false);
|
||||
|
||||
const result = ChildWorkspaceService.initializeWorkspace('/project', createConfig());
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false when cached workspace is empty', () => {
|
||||
(mockFs.existsSync as jest.Mock).mockImplementation(
|
||||
(p: string) => String(p) === path.join('/cache/workspaces', 'TurnOfWar'),
|
||||
);
|
||||
(mockFs.readdirSync as jest.Mock).mockReturnValue([]);
|
||||
|
||||
const result = ChildWorkspaceService.initializeWorkspace('/project', createConfig());
|
||||
|
||||
expect(result).toBe(false);
|
||||
expect(mockFs.rmSync).toHaveBeenCalledWith(path.join('/cache/workspaces', 'TurnOfWar'), {
|
||||
recursive: true,
|
||||
force: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('should restore workspace via atomic move when cache exists', () => {
|
||||
const cachedPath = path.join('/cache/workspaces', 'TurnOfWar');
|
||||
(mockFs.existsSync as jest.Mock).mockImplementation((p: string) => {
|
||||
if (String(p) === cachedPath) return true;
|
||||
if (String(p) === '/project') return false;
|
||||
if (String(p) === '/') return true;
|
||||
|
||||
return false;
|
||||
});
|
||||
(mockFs.readdirSync as jest.Mock).mockReturnValue(['Assets', '.git', 'Library']);
|
||||
|
||||
const config = createConfig({ separateLibraryCache: false });
|
||||
const result = ChildWorkspaceService.initializeWorkspace('/project', config);
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(mockFs.renameSync).toHaveBeenCalledWith(cachedPath, '/project');
|
||||
});
|
||||
|
||||
it('should remove existing target path before restoring', () => {
|
||||
const cachedPath = path.join('/cache/workspaces', 'TurnOfWar');
|
||||
(mockFs.existsSync as jest.Mock).mockImplementation((p: string) => {
|
||||
if (String(p) === cachedPath) return true;
|
||||
if (String(p) === '/project') return true;
|
||||
|
||||
return false;
|
||||
});
|
||||
(mockFs.readdirSync as jest.Mock).mockReturnValue(['Assets']);
|
||||
|
||||
const config = createConfig({ separateLibraryCache: false });
|
||||
const result = ChildWorkspaceService.initializeWorkspace('/project', config);
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(mockFs.rmSync).toHaveBeenCalledWith('/project', { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('should restore Library cache separately when configured', () => {
|
||||
const cachedPath = path.join('/cache/workspaces', 'TurnOfWar');
|
||||
const libraryBackupPath = path.join('/cache/workspaces', 'TurnOfWar-Library');
|
||||
const libraryDestination = path.join('/project', 'Library');
|
||||
|
||||
(mockFs.existsSync as jest.Mock).mockImplementation((p: string) => {
|
||||
if (String(p) === cachedPath) return true;
|
||||
if (String(p) === '/project') return false;
|
||||
if (String(p) === libraryBackupPath) return true;
|
||||
if (String(p) === libraryDestination) return false;
|
||||
|
||||
return true; // parent dirs
|
||||
});
|
||||
(mockFs.readdirSync as jest.Mock).mockReturnValue(['Assets', 'ProjectSettings']);
|
||||
|
||||
const result = ChildWorkspaceService.initializeWorkspace('/project', createConfig());
|
||||
|
||||
expect(result).toBe(true);
|
||||
|
||||
// Should have been called twice: once for workspace, once for Library
|
||||
expect(mockFs.renameSync).toHaveBeenCalledTimes(2);
|
||||
expect(mockFs.renameSync).toHaveBeenCalledWith(cachedPath, '/project');
|
||||
expect(mockFs.renameSync).toHaveBeenCalledWith(libraryBackupPath, libraryDestination);
|
||||
});
|
||||
|
||||
it('should return false and log warning on error', () => {
|
||||
(mockFs.existsSync as jest.Mock).mockImplementation(() => {
|
||||
throw new Error('Access denied');
|
||||
});
|
||||
|
||||
const result = ChildWorkspaceService.initializeWorkspace('/project', createConfig());
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('saveWorkspace', () => {
|
||||
it('should skip save when project path does not exist', () => {
|
||||
(mockFs.existsSync as jest.Mock).mockReturnValue(false);
|
||||
|
||||
ChildWorkspaceService.saveWorkspace('/project', createConfig());
|
||||
|
||||
expect(mockFs.renameSync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should save workspace via atomic move', () => {
|
||||
const cachedPath = path.join('/cache/workspaces', 'TurnOfWar');
|
||||
(mockFs.existsSync as jest.Mock).mockImplementation((p: string) => {
|
||||
if (String(p) === '/project') return true;
|
||||
if (String(p) === path.join('/project', 'Library')) return false;
|
||||
if (String(p) === '/cache/workspaces') return true;
|
||||
if (String(p) === cachedPath) return false;
|
||||
|
||||
return false;
|
||||
});
|
||||
|
||||
const config = createConfig({ separateLibraryCache: false });
|
||||
ChildWorkspaceService.saveWorkspace('/project', config);
|
||||
|
||||
expect(mockFs.renameSync).toHaveBeenCalledWith('/project', cachedPath);
|
||||
});
|
||||
|
||||
it('should remove .git directory when preserveGit is false', () => {
|
||||
const gitDirectory = path.join('/project', '.git');
|
||||
(mockFs.existsSync as jest.Mock).mockImplementation((p: string) => {
|
||||
if (String(p) === '/project') return true;
|
||||
if (String(p) === gitDirectory) return true;
|
||||
if (String(p) === path.join('/project', 'Library')) return false;
|
||||
if (String(p) === '/cache/workspaces') return true;
|
||||
|
||||
return false;
|
||||
});
|
||||
|
||||
const config = createConfig({ preserveGitDirectory: false, separateLibraryCache: false });
|
||||
ChildWorkspaceService.saveWorkspace('/project', config);
|
||||
|
||||
expect(mockFs.rmSync).toHaveBeenCalledWith(gitDirectory, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('should not remove .git directory when preserveGit is true', () => {
|
||||
(mockFs.existsSync as jest.Mock).mockImplementation((p: string) => {
|
||||
if (String(p) === '/project') return true;
|
||||
if (String(p) === path.join('/project', 'Library')) return false;
|
||||
if (String(p) === '/cache/workspaces') return true;
|
||||
|
||||
return false;
|
||||
});
|
||||
|
||||
const config = createConfig({ preserveGitDirectory: true, separateLibraryCache: false });
|
||||
ChildWorkspaceService.saveWorkspace('/project', config);
|
||||
|
||||
// rmSync should not have been called with .git path
|
||||
const rmSyncCalls = (mockFs.rmSync as jest.Mock).mock.calls;
|
||||
const gitRmCalls = rmSyncCalls.filter((call: any[]) => String(call[0]).includes('.git'));
|
||||
expect(gitRmCalls).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should remove existing cached workspace before saving', () => {
|
||||
const cachedPath = path.join('/cache/workspaces', 'TurnOfWar');
|
||||
(mockFs.existsSync as jest.Mock).mockImplementation((p: string) => {
|
||||
if (String(p) === '/project') return true;
|
||||
if (String(p) === path.join('/project', 'Library')) return false;
|
||||
if (String(p) === '/cache/workspaces') return true;
|
||||
if (String(p) === cachedPath) return true;
|
||||
|
||||
return false;
|
||||
});
|
||||
|
||||
const config = createConfig({ separateLibraryCache: false });
|
||||
ChildWorkspaceService.saveWorkspace('/project', config);
|
||||
|
||||
expect(mockFs.rmSync).toHaveBeenCalledWith(cachedPath, { recursive: true, force: true });
|
||||
expect(mockFs.renameSync).toHaveBeenCalledWith('/project', cachedPath);
|
||||
});
|
||||
|
||||
it('should save Library separately when separateLibraryCache is true', () => {
|
||||
const libraryPath = path.join('/project', 'Library');
|
||||
const libraryBackupPath = path.join('/cache/workspaces', 'TurnOfWar-Library');
|
||||
(mockFs.existsSync as jest.Mock).mockImplementation((p: string) => {
|
||||
if (String(p) === '/project') return true;
|
||||
if (String(p) === libraryPath) return true;
|
||||
if (String(p) === libraryBackupPath) return false;
|
||||
if (String(p) === '/cache/workspaces') return true;
|
||||
|
||||
return false;
|
||||
});
|
||||
(mockFs.readdirSync as jest.Mock).mockReturnValue(['ScriptAssemblies', 'ShaderCache']);
|
||||
|
||||
ChildWorkspaceService.saveWorkspace('/project', createConfig());
|
||||
|
||||
expect(mockFs.renameSync).toHaveBeenCalledWith(libraryPath, libraryBackupPath);
|
||||
});
|
||||
|
||||
it('should handle save errors gracefully', () => {
|
||||
(mockFs.existsSync as jest.Mock).mockReturnValue(true);
|
||||
(mockFs.renameSync as jest.Mock).mockImplementation(() => {
|
||||
throw new Error('Cross-device link');
|
||||
});
|
||||
(mockFs.readdirSync as jest.Mock).mockReturnValue([]);
|
||||
|
||||
// Should not throw
|
||||
ChildWorkspaceService.saveWorkspace('/project', createConfig({ separateLibraryCache: false }));
|
||||
});
|
||||
});
|
||||
|
||||
describe('restoreLibraryCache', () => {
|
||||
it('should return false when no Library backup exists', () => {
|
||||
(mockFs.existsSync as jest.Mock).mockReturnValue(false);
|
||||
|
||||
const result = ChildWorkspaceService.restoreLibraryCache('/project', createConfig());
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false when Library backup is empty', () => {
|
||||
const libraryBackup = path.join('/cache/workspaces', 'TurnOfWar-Library');
|
||||
(mockFs.existsSync as jest.Mock).mockImplementation((p: string) => String(p) === libraryBackup);
|
||||
(mockFs.readdirSync as jest.Mock).mockReturnValue([]);
|
||||
|
||||
const result = ChildWorkspaceService.restoreLibraryCache('/project', createConfig());
|
||||
|
||||
expect(result).toBe(false);
|
||||
expect(mockFs.rmSync).toHaveBeenCalledWith(libraryBackup, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('should restore Library via atomic move', () => {
|
||||
const libraryBackup = path.join('/cache/workspaces', 'TurnOfWar-Library');
|
||||
const libraryDestination = path.join('/project', 'Library');
|
||||
(mockFs.existsSync as jest.Mock).mockImplementation((p: string) => {
|
||||
if (String(p) === libraryBackup) return true;
|
||||
if (String(p) === libraryDestination) return false;
|
||||
|
||||
return false;
|
||||
});
|
||||
(mockFs.readdirSync as jest.Mock).mockReturnValue(['ScriptAssemblies']);
|
||||
|
||||
const result = ChildWorkspaceService.restoreLibraryCache('/project', createConfig());
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(mockFs.renameSync).toHaveBeenCalledWith(libraryBackup, libraryDestination);
|
||||
});
|
||||
|
||||
it('should use custom libraryBackupPath when provided', () => {
|
||||
const customBackup = '/custom/library/cache';
|
||||
const libraryDestination = path.join('/project', 'Library');
|
||||
(mockFs.existsSync as jest.Mock).mockImplementation((p: string) => {
|
||||
if (String(p) === customBackup) return true;
|
||||
if (String(p) === libraryDestination) return false;
|
||||
|
||||
return false;
|
||||
});
|
||||
(mockFs.readdirSync as jest.Mock).mockReturnValue(['ScriptAssemblies']);
|
||||
|
||||
const config = createConfig({ libraryBackupPath: customBackup });
|
||||
const result = ChildWorkspaceService.restoreLibraryCache('/project', config);
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(mockFs.renameSync).toHaveBeenCalledWith(customBackup, libraryDestination);
|
||||
});
|
||||
|
||||
it('should remove existing Library directory before restore', () => {
|
||||
const libraryBackup = path.join('/cache/workspaces', 'TurnOfWar-Library');
|
||||
const libraryDestination = path.join('/project', 'Library');
|
||||
(mockFs.existsSync as jest.Mock).mockImplementation((p: string) => {
|
||||
if (String(p) === libraryBackup) return true;
|
||||
if (String(p) === libraryDestination) return true;
|
||||
|
||||
return false;
|
||||
});
|
||||
(mockFs.readdirSync as jest.Mock).mockReturnValue(['ScriptAssemblies']);
|
||||
|
||||
ChildWorkspaceService.restoreLibraryCache('/project', createConfig());
|
||||
|
||||
expect(mockFs.rmSync).toHaveBeenCalledWith(libraryDestination, { recursive: true, force: true });
|
||||
});
|
||||
});
|
||||
|
||||
describe('getWorkspaceSize', () => {
|
||||
it('should return "0 B" for non-existent directory', () => {
|
||||
(mockFs.existsSync as jest.Mock).mockReturnValue(false);
|
||||
|
||||
const result = ChildWorkspaceService.getWorkspaceSize('/nonexistent');
|
||||
|
||||
expect(result).toBe('0 B');
|
||||
});
|
||||
|
||||
it('should calculate and format directory size', () => {
|
||||
(mockFs.existsSync as jest.Mock).mockReturnValue(true);
|
||||
(mockFs.readdirSync as jest.Mock).mockReturnValue([
|
||||
{ name: 'file1.txt', isDirectory: () => false, isFile: () => true },
|
||||
{ name: 'file2.bin', isDirectory: () => false, isFile: () => true },
|
||||
]);
|
||||
(mockFs.statSync as jest.Mock).mockReturnValue({ size: 1024 * 1024 }); // 1 MB each
|
||||
|
||||
const result = ChildWorkspaceService.getWorkspaceSize('/workspace');
|
||||
|
||||
expect(result).toBe('2.00 MB');
|
||||
});
|
||||
|
||||
it('should return "unknown" when existsSync throws', () => {
|
||||
(mockFs.existsSync as jest.Mock).mockImplementation(() => {
|
||||
throw new Error('Permission denied');
|
||||
});
|
||||
|
||||
const result = ChildWorkspaceService.getWorkspaceSize('/workspace');
|
||||
|
||||
expect(result).toBe('unknown');
|
||||
});
|
||||
|
||||
it('should recurse into subdirectories', () => {
|
||||
(mockFs.existsSync as jest.Mock).mockReturnValue(true);
|
||||
let callCount = 0;
|
||||
(mockFs.readdirSync as jest.Mock).mockImplementation(() => {
|
||||
callCount++;
|
||||
if (callCount === 1) {
|
||||
return [
|
||||
{ name: 'subdir', isDirectory: () => true, isFile: () => false },
|
||||
{ name: 'root.txt', isDirectory: () => false, isFile: () => true },
|
||||
];
|
||||
}
|
||||
|
||||
return [{ name: 'nested.txt', isDirectory: () => false, isFile: () => true }];
|
||||
});
|
||||
(mockFs.statSync as jest.Mock).mockReturnValue({ size: 512 });
|
||||
|
||||
const result = ChildWorkspaceService.getWorkspaceSize('/workspace');
|
||||
|
||||
expect(result).toBe('1.00 KB');
|
||||
});
|
||||
});
|
||||
|
||||
describe('cleanStaleWorkspaces', () => {
|
||||
it('should skip when cache root does not exist', () => {
|
||||
(mockFs.existsSync as jest.Mock).mockReturnValue(false);
|
||||
|
||||
ChildWorkspaceService.cleanStaleWorkspaces('/nonexistent', 7);
|
||||
|
||||
expect(mockFs.readdirSync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should remove workspaces older than retention period', () => {
|
||||
const now = Date.now();
|
||||
const tenDaysAgo = now - 10 * 24 * 60 * 60 * 1000;
|
||||
const oneDayAgo = now - 1 * 24 * 60 * 60 * 1000;
|
||||
|
||||
(mockFs.existsSync as jest.Mock).mockReturnValue(true);
|
||||
(mockFs.readdirSync as jest.Mock).mockImplementation((directoryPath: string) => {
|
||||
if (String(directoryPath) === '/cache') {
|
||||
return ['old-workspace', 'recent-workspace'];
|
||||
}
|
||||
|
||||
return [];
|
||||
});
|
||||
(mockFs.statSync as jest.Mock).mockImplementation((filePath: string) => ({
|
||||
isDirectory: () => true,
|
||||
mtimeMs: String(filePath).includes('old') ? tenDaysAgo : oneDayAgo,
|
||||
size: 0,
|
||||
}));
|
||||
|
||||
ChildWorkspaceService.cleanStaleWorkspaces('/cache', 7);
|
||||
|
||||
expect(mockFs.rmSync).toHaveBeenCalledTimes(1);
|
||||
expect(mockFs.rmSync).toHaveBeenCalledWith(path.join('/cache', 'old-workspace'), {
|
||||
recursive: true,
|
||||
force: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('should not remove workspaces newer than retention period', () => {
|
||||
const oneDayAgo = Date.now() - 1 * 24 * 60 * 60 * 1000;
|
||||
|
||||
(mockFs.existsSync as jest.Mock).mockReturnValue(true);
|
||||
(mockFs.readdirSync as jest.Mock).mockReturnValue(['recent-workspace']);
|
||||
(mockFs.statSync as jest.Mock).mockReturnValue({
|
||||
isDirectory: () => true,
|
||||
mtimeMs: oneDayAgo,
|
||||
});
|
||||
|
||||
ChildWorkspaceService.cleanStaleWorkspaces('/cache', 7);
|
||||
|
||||
expect(mockFs.rmSync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should handle errors during cleanup gracefully', () => {
|
||||
(mockFs.existsSync as jest.Mock).mockReturnValue(true);
|
||||
(mockFs.readdirSync as jest.Mock).mockReturnValue(['broken-workspace']);
|
||||
(mockFs.statSync as jest.Mock).mockImplementation(() => {
|
||||
throw new Error('Access denied');
|
||||
});
|
||||
|
||||
// Should not throw
|
||||
ChildWorkspaceService.cleanStaleWorkspaces('/cache', 7);
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildConfig', () => {
|
||||
it('should build config from build parameters', () => {
|
||||
const config = ChildWorkspaceService.buildConfig({
|
||||
childWorkspacesEnabled: true,
|
||||
childWorkspaceName: 'Shell',
|
||||
childWorkspaceCacheRoot: '/d/cache',
|
||||
childWorkspacePreserveGit: false,
|
||||
childWorkspaceSeparateLibrary: true,
|
||||
});
|
||||
|
||||
expect(config).toEqual({
|
||||
enabled: true,
|
||||
workspaceName: 'Shell',
|
||||
parentCacheRoot: '/d/cache',
|
||||
preserveGitDirectory: false,
|
||||
separateLibraryCache: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('should build config with defaults from disabled state', () => {
|
||||
const config = ChildWorkspaceService.buildConfig({
|
||||
childWorkspacesEnabled: false,
|
||||
childWorkspaceName: '',
|
||||
childWorkspaceCacheRoot: '',
|
||||
childWorkspacePreserveGit: true,
|
||||
childWorkspaceSeparateLibrary: true,
|
||||
});
|
||||
|
||||
expect(config.enabled).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,373 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import OrchestratorLogger from '../core/orchestrator-logger';
|
||||
|
||||
export interface ChildWorkspaceConfig {
|
||||
enabled: boolean;
|
||||
|
||||
/** Name for this child workspace, used as cache key (e.g., "TurnOfWar", "Shell") */
|
||||
workspaceName: string;
|
||||
|
||||
/** Parent directory for cached child workspaces. Should be on same NTFS volume for O(1) restore. */
|
||||
parentCacheRoot: string;
|
||||
|
||||
/** Keep .git directory in cached workspace for delta operations (default: true) */
|
||||
preserveGitDirectory: boolean;
|
||||
|
||||
/** Cache Library/ independently from workspace (default: true) */
|
||||
separateLibraryCache: boolean;
|
||||
|
||||
/** Override location for Library cache. Defaults to parentCacheRoot/<workspaceName>/Library-cache */
|
||||
libraryBackupPath?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Child workspace isolation service for enterprise-scale CI builds.
|
||||
*
|
||||
* Instead of building in the git checkout directory, this service:
|
||||
* 1. Keeps the root workspace lean (no LFS files in the checkout dir)
|
||||
* 2. Creates isolated child workspaces per product/build-target
|
||||
* 3. Each child workspace gets its own submodule profile, LFS hydration, and Library folder
|
||||
* 4. After build, the child workspace (.git preserved) is moved to a parent-level backup directory
|
||||
* 5. On next CI run, the child workspace is restored via atomic filesystem move (O(1) on NTFS)
|
||||
* 6. Library folders are cached separately for independent restore
|
||||
*
|
||||
* This is orders of magnitude faster than actions/cache for 50GB+ workspaces.
|
||||
*/
|
||||
export class ChildWorkspaceService {
|
||||
/**
|
||||
* Initialize child workspace by restoring from cache if available.
|
||||
* Uses atomic filesystem move (rename) for O(1) restore on same volume.
|
||||
*
|
||||
* @param projectPath - Target path where the workspace should live during build
|
||||
* @param config - Child workspace configuration
|
||||
* @returns true if restored from cache, false if starting fresh
|
||||
*/
|
||||
static initializeWorkspace(projectPath: string, config: ChildWorkspaceConfig): boolean {
|
||||
const cachedWorkspacePath = path.join(config.parentCacheRoot, config.workspaceName);
|
||||
|
||||
try {
|
||||
if (!fs.existsSync(cachedWorkspacePath)) {
|
||||
OrchestratorLogger.log(`[ChildWorkspace] No cached workspace found at ${cachedWorkspacePath}, starting fresh`);
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
// Verify the cached workspace has content
|
||||
const entries = fs.readdirSync(cachedWorkspacePath);
|
||||
if (entries.length === 0) {
|
||||
OrchestratorLogger.log(`[ChildWorkspace] Cached workspace at ${cachedWorkspacePath} is empty, starting fresh`);
|
||||
fs.rmSync(cachedWorkspacePath, { recursive: true, force: true });
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
// If the target path already exists, remove it to make way for the move
|
||||
if (fs.existsSync(projectPath)) {
|
||||
OrchestratorLogger.log(`[ChildWorkspace] Removing existing target path: ${projectPath}`);
|
||||
fs.rmSync(projectPath, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
// Ensure parent directory of project path exists
|
||||
const parentDirectory = path.dirname(projectPath);
|
||||
if (!fs.existsSync(parentDirectory)) {
|
||||
fs.mkdirSync(parentDirectory, { recursive: true });
|
||||
}
|
||||
|
||||
// Atomic move (rename) - O(1) on NTFS when on same volume
|
||||
OrchestratorLogger.log(`[ChildWorkspace] Restoring workspace: ${cachedWorkspacePath} -> ${projectPath}`);
|
||||
fs.renameSync(cachedWorkspacePath, projectPath);
|
||||
OrchestratorLogger.log(`[ChildWorkspace] Workspace restored via atomic move`);
|
||||
|
||||
// Restore Library cache separately if configured
|
||||
if (config.separateLibraryCache) {
|
||||
ChildWorkspaceService.restoreLibraryCache(projectPath, config);
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch (error: any) {
|
||||
OrchestratorLogger.logWarning(`[ChildWorkspace] Workspace restore failed: ${error.message}. Starting fresh.`);
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Save child workspace after build for reuse on next CI run.
|
||||
* Moves the entire workspace to the cache directory via atomic filesystem move.
|
||||
*
|
||||
* @param projectPath - Path to the workspace to save
|
||||
* @param config - Child workspace configuration
|
||||
*/
|
||||
static saveWorkspace(projectPath: string, config: ChildWorkspaceConfig): void {
|
||||
const cachedWorkspacePath = path.join(config.parentCacheRoot, config.workspaceName);
|
||||
|
||||
try {
|
||||
if (!fs.existsSync(projectPath)) {
|
||||
OrchestratorLogger.log(`[ChildWorkspace] Project path ${projectPath} does not exist, skipping save`);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Remove .git directory if not preserving it (saves space but loses delta capability)
|
||||
if (!config.preserveGitDirectory) {
|
||||
const gitDirectory = path.join(projectPath, '.git');
|
||||
if (fs.existsSync(gitDirectory)) {
|
||||
OrchestratorLogger.log(`[ChildWorkspace] Removing .git directory (preserveGit=false)`);
|
||||
fs.rmSync(gitDirectory, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
// If separateLibraryCache, move Library/ to its own backup path before saving workspace
|
||||
if (config.separateLibraryCache) {
|
||||
ChildWorkspaceService.saveLibraryCache(projectPath, config);
|
||||
}
|
||||
|
||||
// Ensure parent cache root exists
|
||||
if (!fs.existsSync(config.parentCacheRoot)) {
|
||||
fs.mkdirSync(config.parentCacheRoot, { recursive: true });
|
||||
}
|
||||
|
||||
// Remove any existing cached workspace to make room
|
||||
if (fs.existsSync(cachedWorkspacePath)) {
|
||||
OrchestratorLogger.log(`[ChildWorkspace] Removing previous cached workspace: ${cachedWorkspacePath}`);
|
||||
fs.rmSync(cachedWorkspacePath, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
// Atomic move (rename) - O(1) on NTFS when on same volume
|
||||
OrchestratorLogger.log(`[ChildWorkspace] Saving workspace: ${projectPath} -> ${cachedWorkspacePath}`);
|
||||
fs.renameSync(projectPath, cachedWorkspacePath);
|
||||
OrchestratorLogger.log(`[ChildWorkspace] Workspace saved via atomic move`);
|
||||
} catch (error: any) {
|
||||
OrchestratorLogger.logWarning(`[ChildWorkspace] Workspace save failed: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Restore Library folder from separate cache location.
|
||||
*
|
||||
* @param projectPath - Path to the workspace where Library should be restored
|
||||
* @param config - Child workspace configuration
|
||||
* @returns true if Library was restored from cache
|
||||
*/
|
||||
static restoreLibraryCache(projectPath: string, config: ChildWorkspaceConfig): boolean {
|
||||
const libraryBackup = ChildWorkspaceService.resolveLibraryBackupPath(config);
|
||||
const libraryDestination = path.join(projectPath, 'Library');
|
||||
|
||||
try {
|
||||
if (!fs.existsSync(libraryBackup)) {
|
||||
OrchestratorLogger.log(`[ChildWorkspace] No Library cache found at ${libraryBackup}`);
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
const entries = fs.readdirSync(libraryBackup);
|
||||
if (entries.length === 0) {
|
||||
OrchestratorLogger.log(`[ChildWorkspace] Library cache at ${libraryBackup} is empty`);
|
||||
fs.rmSync(libraryBackup, { recursive: true, force: true });
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
// Remove existing Library directory if present
|
||||
if (fs.existsSync(libraryDestination)) {
|
||||
fs.rmSync(libraryDestination, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
// Atomic move
|
||||
OrchestratorLogger.log(`[ChildWorkspace] Restoring Library cache: ${libraryBackup} -> ${libraryDestination}`);
|
||||
fs.renameSync(libraryBackup, libraryDestination);
|
||||
OrchestratorLogger.log(`[ChildWorkspace] Library cache restored`);
|
||||
|
||||
return true;
|
||||
} catch (error: any) {
|
||||
OrchestratorLogger.logWarning(`[ChildWorkspace] Library cache restore failed: ${error.message}`);
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Save Library folder to a separate cache location for independent restore.
|
||||
* Moves Library/ out of the workspace before workspace save.
|
||||
*
|
||||
* @param projectPath - Path to the workspace containing Library/
|
||||
* @param config - Child workspace configuration
|
||||
*/
|
||||
private static saveLibraryCache(projectPath: string, config: ChildWorkspaceConfig): void {
|
||||
const libraryPath = path.join(projectPath, 'Library');
|
||||
const libraryBackup = ChildWorkspaceService.resolveLibraryBackupPath(config);
|
||||
|
||||
try {
|
||||
if (!fs.existsSync(libraryPath)) {
|
||||
OrchestratorLogger.log(`[ChildWorkspace] No Library folder to cache`);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const entries = fs.readdirSync(libraryPath);
|
||||
if (entries.length === 0) {
|
||||
OrchestratorLogger.log(`[ChildWorkspace] Library folder is empty, skipping cache`);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Ensure parent of backup path exists
|
||||
const backupParent = path.dirname(libraryBackup);
|
||||
if (!fs.existsSync(backupParent)) {
|
||||
fs.mkdirSync(backupParent, { recursive: true });
|
||||
}
|
||||
|
||||
// Remove existing Library backup
|
||||
if (fs.existsSync(libraryBackup)) {
|
||||
fs.rmSync(libraryBackup, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
// Atomic move
|
||||
OrchestratorLogger.log(`[ChildWorkspace] Caching Library: ${libraryPath} -> ${libraryBackup}`);
|
||||
fs.renameSync(libraryPath, libraryBackup);
|
||||
OrchestratorLogger.log(`[ChildWorkspace] Library cached separately`);
|
||||
} catch (error: any) {
|
||||
OrchestratorLogger.logWarning(`[ChildWorkspace] Library cache save failed: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate the total size of a directory in human-readable format.
|
||||
*
|
||||
* @param directoryPath - Path to the directory to measure
|
||||
* @returns Human-readable size string (e.g., "1.23 GB", "456.78 MB")
|
||||
*/
|
||||
static getWorkspaceSize(directoryPath: string): string {
|
||||
try {
|
||||
if (!fs.existsSync(directoryPath)) {
|
||||
return '0 B';
|
||||
}
|
||||
|
||||
const totalBytes = ChildWorkspaceService.calculateDirectorySize(directoryPath);
|
||||
|
||||
return ChildWorkspaceService.formatBytes(totalBytes);
|
||||
} catch (error: any) {
|
||||
OrchestratorLogger.logWarning(`[ChildWorkspace] Failed to calculate workspace size: ${error.message}`);
|
||||
|
||||
return 'unknown';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean stale child workspaces that haven't been used within the retention period.
|
||||
*
|
||||
* @param parentCacheRoot - Root directory containing cached workspaces
|
||||
* @param retentionDays - Maximum age in days before a workspace is considered stale
|
||||
*/
|
||||
static cleanStaleWorkspaces(parentCacheRoot: string, retentionDays: number): void {
|
||||
try {
|
||||
if (!fs.existsSync(parentCacheRoot)) {
|
||||
OrchestratorLogger.log(`[ChildWorkspace] Cache root ${parentCacheRoot} does not exist, nothing to clean`);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
const maxAgeMs = retentionDays * 24 * 60 * 60 * 1000;
|
||||
const entries = fs.readdirSync(parentCacheRoot);
|
||||
let removedCount = 0;
|
||||
let freedBytes = 0;
|
||||
|
||||
for (const entry of entries) {
|
||||
const entryPath = path.join(parentCacheRoot, entry);
|
||||
try {
|
||||
const stat = fs.statSync(entryPath);
|
||||
if (stat.isDirectory() && now - stat.mtimeMs > maxAgeMs) {
|
||||
const size = ChildWorkspaceService.calculateDirectorySize(entryPath);
|
||||
fs.rmSync(entryPath, { recursive: true, force: true });
|
||||
removedCount++;
|
||||
freedBytes += size;
|
||||
OrchestratorLogger.log(
|
||||
`[ChildWorkspace] Cleaned stale workspace: ${entry} (age: ${Math.floor(
|
||||
(now - stat.mtimeMs) / (24 * 60 * 60 * 1000),
|
||||
)} days)`,
|
||||
);
|
||||
}
|
||||
} catch (error: any) {
|
||||
OrchestratorLogger.logWarning(`[ChildWorkspace] Failed to clean ${entryPath}: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
OrchestratorLogger.log(
|
||||
`[ChildWorkspace] Cleanup complete: ${removedCount} stale workspaces removed, ${ChildWorkspaceService.formatBytes(
|
||||
freedBytes,
|
||||
)} freed`,
|
||||
);
|
||||
} catch (error: any) {
|
||||
OrchestratorLogger.logWarning(`[ChildWorkspace] Stale workspace cleanup failed: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a ChildWorkspaceConfig from build parameters and action inputs.
|
||||
*/
|
||||
static buildConfig(parameters: {
|
||||
childWorkspacesEnabled: boolean;
|
||||
childWorkspaceName: string;
|
||||
childWorkspaceCacheRoot: string;
|
||||
childWorkspacePreserveGit: boolean;
|
||||
childWorkspaceSeparateLibrary: boolean;
|
||||
}): ChildWorkspaceConfig {
|
||||
return {
|
||||
enabled: parameters.childWorkspacesEnabled,
|
||||
workspaceName: parameters.childWorkspaceName,
|
||||
parentCacheRoot: parameters.childWorkspaceCacheRoot,
|
||||
preserveGitDirectory: parameters.childWorkspacePreserveGit,
|
||||
separateLibraryCache: parameters.childWorkspaceSeparateLibrary,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the Library backup path from config, using a default if not overridden.
|
||||
*/
|
||||
private static resolveLibraryBackupPath(config: ChildWorkspaceConfig): string {
|
||||
if (config.libraryBackupPath) {
|
||||
return config.libraryBackupPath;
|
||||
}
|
||||
|
||||
return path.join(config.parentCacheRoot, `${config.workspaceName}-Library`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively calculate total size of a directory in bytes.
|
||||
*/
|
||||
private static calculateDirectorySize(directoryPath: string): number {
|
||||
let totalSize = 0;
|
||||
|
||||
try {
|
||||
const entries = fs.readdirSync(directoryPath, { withFileTypes: true });
|
||||
for (const entry of entries) {
|
||||
const fullPath = path.join(directoryPath, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
totalSize += ChildWorkspaceService.calculateDirectorySize(fullPath);
|
||||
} else if (entry.isFile()) {
|
||||
totalSize += fs.statSync(fullPath).size;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Permission errors or race conditions — return what we have
|
||||
}
|
||||
|
||||
return totalSize;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format bytes into human-readable string.
|
||||
*/
|
||||
private static formatBytes(bytes: number): string {
|
||||
if (bytes === 0) return '0 B';
|
||||
|
||||
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||||
const k = 1024;
|
||||
const index = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
const value = bytes / Math.pow(k, index);
|
||||
|
||||
return `${value.toFixed(2)} ${units[index]}`;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user