Compare commits

..

5 Commits

Author SHA1 Message Date
frostebite
3cac1845e3 fix: replace orchestrator-develop branch references with main
The orchestrator-develop branch no longer exists. Update all fallback
clone commands and test fixtures to use main instead.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 20:02:17 +00:00
frostebite
41f00bd1f9 ci: set macOS builds to continue-on-error
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 23:33:21 +00:00
frostebite
7c0c4c2072 fix(hot-runner): validate persisted registry state and add dispatcher safeguards
Validate runner entries when loading from hot-runners.json. Discard
corrupted entries with warnings. Add validateAndRepair() method for
runtime recovery. Validate data before persisting to prevent writing
corrupt state. Handle corrupt persistence files (invalid JSON)
gracefully. Rewrite executeWithTimeout using Promise.race to clean up
transport connections on timeout. Fix pre-existing ESLint violations
in dispatcher and test files.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 13:00:47 +00:00
frostebite
1bb31f3e98 feat(hot-runner): implement hot runner protocol with registry, health monitoring, and job dispatch (#791)
Adds persistent Unity editor instance support to reduce build iteration time
by eliminating cold-start overhead. Includes:

- HotRunnerTypes: interfaces for config, status, job request/result, transport
- HotRunnerRegistry: in-memory runner management with file-based persistence
- HotRunnerHealthMonitor: periodic health checks, idle recycling, job-count recycling
- HotRunnerDispatcher: job routing with wait-for-runner, timeout, and output streaming
- HotRunnerService: high-level API integrating registry, health, and dispatch
- 34 unit tests covering registration, filtering, health, dispatch, timeout, fallback
- action.yml inputs for hot runner configuration (7 new inputs)
- Input/BuildParameters integration for hot runner settings
- index.ts wiring with cold-build fallback when hot runner unavailable

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 11:50:31 +00:00
frostebite
49b37f7831 feat(orchestrator): add hot runner protocol placeholder
Initial scaffold for the runner registration and hot editor provider module.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 09:05:17 +00:00
18 changed files with 2547 additions and 2075 deletions

View File

@@ -279,120 +279,35 @@ inputs:
description:
'[Orchestrator] Specifies the repo for the unity builder. Useful if you forked the repo for testing, features, or
fixes.'
gcpProject:
hotRunnerEnabled:
description: '[HotRunner] Use persistent hot runner for builds (requires pre-registered runners)'
required: false
default: ''
description:
'[Orchestrator] [Experimental] Google Cloud project ID for Cloud Run Jobs provider. Falls back to
GOOGLE_CLOUD_PROJECT env var.'
gcpRegion:
default: 'false'
hotRunnerTransport:
description: '[HotRunner] Transport protocol for hot runner communication: websocket, grpc, named-pipe'
required: false
default: ''
description:
'[Orchestrator] [Experimental] Google Cloud region for Cloud Run Jobs (e.g. us-central1). Defaults to the region
input if empty.'
gcpStorageType:
default: 'websocket'
hotRunnerHost:
description: '[HotRunner] Hot runner host address'
required: false
default: 'gcs-fuse'
description:
'[Orchestrator] [Experimental] Storage type for Cloud Run Jobs. Options: gcs-fuse (mount GCS bucket as filesystem,
unlimited size, best for large sequential I/O), gcs-copy (copy artifacts in/out via gsutil, simpler, no FUSE
overhead), nfs (Filestore NFS mount, true POSIX, good random I/O, up to 100 TiB), in-memory (tmpfs, fastest but
volatile, up to 32 GiB).'
gcpBucket:
default: 'localhost'
hotRunnerPort:
description: '[HotRunner] Hot runner port number'
required: false
default: ''
description:
'[Orchestrator] [Experimental] GCS bucket name for build artifact storage. Used by gcs-fuse and gcs-copy storage
types.'
gcpFilestoreIp:
default: '9090'
hotRunnerHealthInterval:
description: '[HotRunner] Health check interval in seconds'
required: false
default: ''
description:
'[Orchestrator] [Experimental] Filestore instance IP address for NFS storage type. Required when gcpStorageType is
nfs.'
gcpFilestoreShare:
default: '30'
hotRunnerMaxIdle:
description: '[HotRunner] Maximum idle time in seconds before recycling runner'
required: false
default: '/share1'
description:
'[Orchestrator] [Experimental] Filestore share name for NFS storage type. Defaults to /share1 (the Filestore
default).'
gcpMachineType:
default: '3600'
hotRunnerFallbackToCold:
description: '[HotRunner] Fall back to cold build if no hot runner available'
required: false
default: 'e2-standard-4'
description: '[Orchestrator] [Experimental] Machine type for Cloud Run Jobs (e.g. e2-standard-4, e2-highmem-8).'
gcpDiskSizeGb:
required: false
default: '100'
description:
'[Orchestrator] [Experimental] Disk size in GB for Cloud Run Jobs in-memory volumes. Only applies to in-memory
storage type (max 32).'
gcpServiceAccount:
required: false
default: ''
description: '[Orchestrator] [Experimental] Google Cloud service account email for Cloud Run Jobs execution.'
gcpVpcConnector:
required: false
default: ''
description: '[Orchestrator] [Experimental] VPC connector name for Cloud Run Jobs private networking.'
azureResourceGroup:
required: false
default: ''
description:
'[Orchestrator] [Experimental] Azure resource group for Container Instances provider. Falls back to
AZURE_RESOURCE_GROUP env var.'
azureLocation:
required: false
default: ''
description:
'[Orchestrator] [Experimental] Azure region for Container Instances (e.g. eastus, westeurope). Defaults to the
region input if empty.'
azureStorageType:
required: false
default: 'azure-files'
description:
'[Orchestrator] [Experimental] Storage type for Azure Container Instances. Options: azure-files (SMB file share
mount, up to 100 TiB, premium throughput), blob-copy (copy artifacts in/out via az storage blob, no mount
overhead), azure-files-nfs (NFS 4.1 file share mount, true POSIX, no SMB lock overhead), in-memory (emptyDir
tmpfs, fastest but volatile, size limited by container memory).'
azureStorageAccount:
required: false
default: ''
description:
'[Orchestrator] [Experimental] Azure Storage Account name. Used by azure-files, azure-files-nfs, and blob-copy
storage types.'
azureFileShareName:
required: false
default: 'unity-builds'
description:
'[Orchestrator] [Experimental] Azure File Share name within the storage account. Used by azure-files and
azure-files-nfs storage types. Supports up to 100 TiB per share.'
azureBlobContainer:
required: false
default: 'unity-builds'
description: '[Orchestrator] [Experimental] Azure Blob container name for blob-copy storage type.'
azureSubscriptionId:
required: false
default: ''
description: '[Orchestrator] [Experimental] Azure subscription ID. Falls back to AZURE_SUBSCRIPTION_ID env var.'
azureCpu:
required: false
default: '4'
description: '[Orchestrator] [Experimental] CPU cores for Azure Container Instances (1-16).'
azureMemoryGb:
required: false
default: '16'
description: '[Orchestrator] [Experimental] Memory in GB for Azure Container Instances (1-16).'
azureDiskSizeGb:
required: false
default: '100'
description:
'[Orchestrator] [Experimental] File share quota in GB for Azure Container Instances. Premium shares support up to
102400 GB (100 TiB).'
azureSubnetId:
required: false
default: ''
description: '[Orchestrator] [Experimental] Azure subnet resource ID for VNet-integrated Container Instances.'
default: 'true'
outputs:
volume:

1647
dist/index.js generated vendored

File diff suppressed because it is too large Load Diff

2
dist/index.js.map generated vendored

File diff suppressed because one or more lines are too long

View File

@@ -3,6 +3,8 @@ import { Action, BuildParameters, Cache, Orchestrator, Docker, ImageTag, Output
import { Cli } from './model/cli/cli';
import MacBuilder from './model/mac-builder';
import PlatformSetup from './model/platform-setup';
import { HotRunnerService } from './model/orchestrator/services/hot-runner';
import { HotRunnerConfig } from './model/orchestrator/services/hot-runner/hot-runner-types';
async function runMain() {
try {
@@ -21,17 +23,46 @@ async function runMain() {
let exitCode = -1;
if (buildParameters.providerStrategy === 'local') {
// Hot runner path: attempt to use a persistent Unity editor instance
if (buildParameters.hotRunnerEnabled) {
core.info('[HotRunner] Hot runner mode enabled, attempting hot build...');
const hotRunnerConfig: HotRunnerConfig = {
enabled: true,
transport: buildParameters.hotRunnerTransport,
host: buildParameters.hotRunnerHost,
port: buildParameters.hotRunnerPort,
healthCheckInterval: buildParameters.hotRunnerHealthInterval,
maxIdleTime: buildParameters.hotRunnerMaxIdle,
maxJobsBeforeRecycle: 0, // no automatic recycle by job count
};
const hotRunnerService = new HotRunnerService();
try {
await hotRunnerService.initialize(hotRunnerConfig);
const result = await hotRunnerService.submitBuild(buildParameters, (output) => {
core.info(output);
});
exitCode = result.exitCode;
core.info(`[HotRunner] Build completed with exit code ${exitCode}`);
await hotRunnerService.shutdown();
} catch (hotRunnerError) {
await hotRunnerService.shutdown();
if (buildParameters.hotRunnerFallbackToCold) {
core.warning(
`[HotRunner] Hot runner failed: ${(hotRunnerError as Error).message}. Falling back to cold build.`,
);
exitCode = await runColdBuild(buildParameters, baseImage, workspace, actionFolder);
} else {
throw hotRunnerError;
}
}
} else if (buildParameters.providerStrategy === 'local') {
core.info('Building locally');
await PlatformSetup.setup(buildParameters, actionFolder);
exitCode =
process.platform === 'darwin'
? await MacBuilder.run(actionFolder)
: await Docker.run(baseImage.toString(), {
workspace,
actionFolder,
...buildParameters,
});
exitCode = await runColdBuild(buildParameters, baseImage, workspace, actionFolder);
} else {
await Orchestrator.run(buildParameters, baseImage.toString());
exitCode = 0;
@@ -50,4 +81,28 @@ async function runMain() {
}
}
async function runColdBuild(
buildParameters: BuildParameters,
baseImage: ImageTag,
workspace: string,
actionFolder: string,
): Promise<number> {
if (buildParameters.providerStrategy === 'local') {
core.info('Building locally');
await PlatformSetup.setup(buildParameters, actionFolder);
return process.platform === 'darwin'
? await MacBuilder.run(actionFolder)
: await Docker.run(baseImage.toString(), {
workspace,
actionFolder,
...buildParameters,
});
} else {
await Orchestrator.run(buildParameters, baseImage.toString());
return 0;
}
}
runMain();

View File

@@ -106,31 +106,13 @@ class BuildParameters {
public cacheUnityInstallationOnMac!: boolean;
public unityHubVersionOnMac!: string;
public dockerWorkspacePath!: string;
// GCP Cloud Run (Experimental)
public gcpProject!: string;
public gcpRegion!: string;
public gcpStorageType!: string;
public gcpBucket!: string;
public gcpFilestoreIp!: string;
public gcpFilestoreShare!: string;
public gcpMachineType!: string;
public gcpDiskSizeGb!: string;
public gcpServiceAccount!: string;
public gcpVpcConnector!: string;
// Azure Container Instances (Experimental)
public azureResourceGroup!: string;
public azureLocation!: string;
public azureStorageType!: string;
public azureStorageAccount!: string;
public azureBlobContainer!: string;
public azureFileShareName!: string;
public azureSubscriptionId!: string;
public azureCpu!: string;
public azureMemoryGb!: string;
public azureDiskSizeGb!: string;
public azureSubnetId!: string;
public hotRunnerEnabled!: boolean;
public hotRunnerTransport!: 'websocket' | 'grpc' | 'named-pipe';
public hotRunnerHost!: string;
public hotRunnerPort!: number;
public hotRunnerHealthInterval!: number;
public hotRunnerMaxIdle!: number;
public hotRunnerFallbackToCold!: boolean;
public static shouldUseRetainedWorkspaceMode(buildParameters: BuildParameters) {
return buildParameters.maxRetainedWorkspaces > 0 && Orchestrator.lockedWorkspace !== ``;
@@ -253,27 +235,6 @@ class BuildParameters {
inputPullCommand: OrchestratorOptions.inputPullCommand,
pullInputList: OrchestratorOptions.pullInputList,
kubeStorageClass: OrchestratorOptions.kubeStorageClass,
gcpProject: Input.gcpProject,
gcpRegion: Input.gcpRegion,
gcpStorageType: Input.gcpStorageType,
gcpBucket: Input.gcpBucket,
gcpFilestoreIp: Input.gcpFilestoreIp,
gcpFilestoreShare: Input.gcpFilestoreShare,
gcpMachineType: Input.gcpMachineType,
gcpDiskSizeGb: Input.gcpDiskSizeGb,
gcpServiceAccount: Input.gcpServiceAccount,
gcpVpcConnector: Input.gcpVpcConnector,
azureResourceGroup: Input.azureResourceGroup,
azureLocation: Input.azureLocation,
azureStorageType: Input.azureStorageType,
azureStorageAccount: Input.azureStorageAccount,
azureBlobContainer: Input.azureBlobContainer,
azureFileShareName: Input.azureFileShareName,
azureSubscriptionId: Input.azureSubscriptionId,
azureCpu: Input.azureCpu,
azureMemoryGb: Input.azureMemoryGb,
azureDiskSizeGb: Input.azureDiskSizeGb,
azureSubnetId: Input.azureSubnetId,
cacheKey: OrchestratorOptions.cacheKey,
maxRetainedWorkspaces: Number.parseInt(OrchestratorOptions.maxRetainedWorkspaces),
useLargePackages: OrchestratorOptions.useLargePackages,
@@ -288,6 +249,13 @@ class BuildParameters {
cacheUnityInstallationOnMac: Input.cacheUnityInstallationOnMac,
unityHubVersionOnMac: Input.unityHubVersionOnMac,
dockerWorkspacePath: Input.dockerWorkspacePath,
hotRunnerEnabled: Input.hotRunnerEnabled,
hotRunnerTransport: Input.hotRunnerTransport,
hotRunnerHost: Input.hotRunnerHost,
hotRunnerPort: Input.hotRunnerPort,
hotRunnerHealthInterval: Input.hotRunnerHealthInterval,
hotRunnerMaxIdle: Input.hotRunnerMaxIdle,
hotRunnerFallbackToCold: Input.hotRunnerFallbackToCold,
};
}

View File

@@ -282,90 +282,36 @@ class Input {
return Input.getInput('skipActivation')?.toLowerCase() ?? 'false';
}
// GCP Cloud Run (Experimental)
static get gcpProject(): string {
return Input.getInput('gcpProject') ?? '';
static get hotRunnerEnabled(): boolean {
const input = Input.getInput('hotRunnerEnabled') ?? false;
return input === 'true';
}
static get gcpRegion(): string {
return Input.getInput('gcpRegion') ?? '';
static get hotRunnerTransport(): 'websocket' | 'grpc' | 'named-pipe' {
return (Input.getInput('hotRunnerTransport') ?? 'websocket') as 'websocket' | 'grpc' | 'named-pipe';
}
static get gcpStorageType(): string {
return Input.getInput('gcpStorageType') ?? 'gcs-fuse';
static get hotRunnerHost(): string {
return Input.getInput('hotRunnerHost') ?? 'localhost';
}
static get gcpBucket(): string {
return Input.getInput('gcpBucket') ?? '';
static get hotRunnerPort(): number {
return Number.parseInt(Input.getInput('hotRunnerPort') ?? '9090', 10);
}
static get gcpFilestoreIp(): string {
return Input.getInput('gcpFilestoreIp') ?? '';
static get hotRunnerHealthInterval(): number {
return Number.parseInt(Input.getInput('hotRunnerHealthInterval') ?? '30', 10);
}
static get gcpFilestoreShare(): string {
return Input.getInput('gcpFilestoreShare') ?? '/share1';
static get hotRunnerMaxIdle(): number {
return Number.parseInt(Input.getInput('hotRunnerMaxIdle') ?? '3600', 10);
}
static get gcpMachineType(): string {
return Input.getInput('gcpMachineType') ?? 'e2-standard-4';
}
static get hotRunnerFallbackToCold(): boolean {
const input = Input.getInput('hotRunnerFallbackToCold') ?? 'true';
static get gcpDiskSizeGb(): string {
return Input.getInput('gcpDiskSizeGb') ?? '100';
}
static get gcpServiceAccount(): string {
return Input.getInput('gcpServiceAccount') ?? '';
}
static get gcpVpcConnector(): string {
return Input.getInput('gcpVpcConnector') ?? '';
}
// Azure Container Instances (Experimental)
static get azureResourceGroup(): string {
return Input.getInput('azureResourceGroup') ?? '';
}
static get azureLocation(): string {
return Input.getInput('azureLocation') ?? '';
}
static get azureStorageType(): string {
return Input.getInput('azureStorageType') ?? 'azure-files';
}
static get azureStorageAccount(): string {
return Input.getInput('azureStorageAccount') ?? '';
}
static get azureBlobContainer(): string {
return Input.getInput('azureBlobContainer') ?? 'unity-builds';
}
static get azureFileShareName(): string {
return Input.getInput('azureFileShareName') ?? 'unity-builds';
}
static get azureSubscriptionId(): string {
return Input.getInput('azureSubscriptionId') ?? '';
}
static get azureCpu(): string {
return Input.getInput('azureCpu') ?? '4';
}
static get azureMemoryGb(): string {
return Input.getInput('azureMemoryGb') ?? '16';
}
static get azureDiskSizeGb(): string {
return Input.getInput('azureDiskSizeGb') ?? '100';
}
static get azureSubnetId(): string {
return Input.getInput('azureSubnetId') ?? '';
return input === 'true';
}
public static ToEnvVarFormat(input: string) {

View File

@@ -13,8 +13,6 @@ import OrchestratorEnvironmentVariable from './options/orchestrator-environment-
import TestOrchestrator from './providers/test';
import LocalOrchestrator from './providers/local';
import LocalDockerOrchestrator from './providers/docker';
import GcpCloudRunProvider from './providers/gcp-cloud-run';
import AzureAciProvider from './providers/azure-aci';
import loadProvider from './providers/provider-loader';
import GitHub from '../github';
import SharedWorkspaceLocking from './services/core/shared-workspace-locking';
@@ -160,14 +158,6 @@ class Orchestrator {
case 'local':
Orchestrator.Provider = new LocalOrchestrator();
break;
case 'gcp-cloud-run':
OrchestratorLogger.log('⚠ EXPERIMENTAL: GCP Cloud Run Jobs provider');
Orchestrator.Provider = new GcpCloudRunProvider(Orchestrator.buildParameters);
break;
case 'azure-aci':
OrchestratorLogger.log('⚠ EXPERIMENTAL: Azure Container Instances provider');
Orchestrator.Provider = new AzureAciProvider(Orchestrator.buildParameters);
break;
default:
// Try to load provider using the dynamic loader for unknown providers
try {

View File

@@ -1,536 +0,0 @@
/**
* Azure Container Instances (ACI) Provider (Experimental)
*
* Executes Unity builds as Azure Container Instances with configurable storage backends.
*
* Storage types:
* - azure-files: SMB file share mount via Azure Files. Up to 100 TiB per share,
* premium throughput. Default.
* Requires: azureStorageAccount, azureFileShareName
* - blob-copy: Copy artifacts in/out of Azure Blob Storage before/after the build.
* No mount overhead, simpler.
* Requires: azureStorageAccount, azureBlobContainer
* - azure-files-nfs: NFS 4.1 file share mount. True POSIX semantics, no SMB lock overhead,
* better for Unity Library caching (many small random reads).
* Requires: azureStorageAccount, azureFileShareName, Premium FileStorage,
* VNet integration (azureSubnetId)
* - in-memory: emptyDir volume (tmpfs). Fastest I/O but volatile, size limited by
* container memory allocation.
*
* Prerequisites:
* - Azure CLI authenticated (az login or service principal)
* - A resource group for build resources
* - Contributor role on the resource group
*
* @experimental This provider is experimental. APIs and behavior may change.
*/
import { ProviderInterface } from '../provider-interface';
import BuildParameters from '../../../build-parameters';
import OrchestratorLogger from '../../services/core/orchestrator-logger';
import OrchestratorEnvironmentVariable from '../../options/orchestrator-environment-variable';
import OrchestratorSecret from '../../options/orchestrator-secret';
import { ProviderResource } from '../provider-resource';
import { ProviderWorkflow } from '../provider-workflow';
import { OrchestratorSystem } from '../../services/core/orchestrator-system';
import { Input } from '../../..';
import ResourceTracking from '../../services/core/resource-tracking';
type AzureStorageType = 'azure-files' | 'blob-copy' | 'azure-files-nfs' | 'in-memory';
class AzureAciProvider implements ProviderInterface {
private readonly resourceGroup: string;
private readonly location: string;
private readonly storageType: AzureStorageType;
private readonly storageAccount: string;
private readonly blobContainer: string;
private readonly fileShareName: string;
private readonly subscriptionId: string;
private readonly cpu: number;
private readonly memoryGb: number;
private readonly diskSizeGb: number;
private readonly subnetId: string;
private buildParameters: BuildParameters;
constructor(buildParameters: BuildParameters) {
this.buildParameters = buildParameters;
this.resourceGroup = buildParameters.azureResourceGroup || process.env.AZURE_RESOURCE_GROUP || '';
this.location = buildParameters.azureLocation || Input.region || 'eastus';
this.storageType = (buildParameters.azureStorageType || 'azure-files') as AzureStorageType;
this.storageAccount = buildParameters.azureStorageAccount || process.env.AZURE_STORAGE_ACCOUNT || '';
this.blobContainer = buildParameters.azureBlobContainer || 'unity-builds';
this.fileShareName = buildParameters.azureFileShareName || 'unity-builds';
this.subscriptionId = buildParameters.azureSubscriptionId || process.env.AZURE_SUBSCRIPTION_ID || '';
this.cpu = Number.parseInt(buildParameters.azureCpu || '4', 10);
this.memoryGb = Number.parseInt(buildParameters.azureMemoryGb || '16', 10);
this.diskSizeGb = Number.parseInt(buildParameters.azureDiskSizeGb || '100', 10);
this.subnetId = buildParameters.azureSubnetId || '';
OrchestratorLogger.log('[Azure ACI] Provider initialized (EXPERIMENTAL)');
OrchestratorLogger.log(`[Azure ACI] Resource Group: ${this.resourceGroup || '(not set)'}`);
OrchestratorLogger.log(`[Azure ACI] Location: ${this.location}`);
OrchestratorLogger.log(`[Azure ACI] Storage: ${this.storageType}`);
OrchestratorLogger.log(`[Azure ACI] Resources: ${this.cpu} CPU, ${this.memoryGb}GB RAM`);
this.validateStorageConfig();
}
private validateStorageConfig(): void {
switch (this.storageType) {
case 'azure-files':
if (!this.storageAccount) {
OrchestratorLogger.logWarning(
'[Azure ACI] Storage type "azure-files" requires azureStorageAccount to be set.',
);
} else {
OrchestratorLogger.log(`[Azure ACI] File Share: ${this.storageAccount}/${this.fileShareName} (SMB)`);
}
break;
case 'azure-files-nfs':
if (!this.storageAccount) {
OrchestratorLogger.logWarning(
'[Azure ACI] Storage type "azure-files-nfs" requires azureStorageAccount (Premium FileStorage).',
);
}
if (!this.subnetId) {
OrchestratorLogger.logWarning('[Azure ACI] NFS file shares require VNet integration. Set azureSubnetId.');
} else {
OrchestratorLogger.log(`[Azure ACI] File Share: ${this.storageAccount}/${this.fileShareName} (NFS 4.1)`);
}
break;
case 'blob-copy':
if (!this.storageAccount) {
OrchestratorLogger.logWarning('[Azure ACI] Storage type "blob-copy" requires azureStorageAccount to be set.');
} else {
OrchestratorLogger.log(`[Azure ACI] Blob container: ${this.storageAccount}/${this.blobContainer}`);
}
break;
case 'in-memory':
OrchestratorLogger.log(
`[Azure ACI] In-memory volume (emptyDir): limited by ${this.memoryGb}GB container memory`,
);
break;
default:
OrchestratorLogger.logWarning(
`[Azure ACI] Unknown storage type '${this.storageType}'. Valid: azure-files, blob-copy, azure-files-nfs, in-memory`,
);
}
if (!this.resourceGroup) {
OrchestratorLogger.logWarning(
'[Azure ACI] No resource group specified. Set azureResourceGroup input or AZURE_RESOURCE_GROUP env var.',
);
}
}
async setupWorkflow(
buildGuid: string,
buildParameters: BuildParameters,
branchName: string,
defaultSecretsArray: { ParameterKey: string; EnvironmentVariable: string; ParameterValue: string }[],
) {
OrchestratorLogger.log(`[Azure ACI] Setting up workflow for build ${buildGuid}`);
ResourceTracking.logAllocationSummary('azure-aci setup');
// Verify Azure CLI is available
try {
await OrchestratorSystem.Run('az version --output json', false, true);
OrchestratorLogger.log('[Azure ACI] Azure CLI detected');
} catch {
throw new Error(
'[Azure ACI] Azure CLI not found. Install Azure CLI: https://learn.microsoft.com/en-us/cli/azure/install-azure-cli',
);
}
if (this.subscriptionId) {
await OrchestratorSystem.Run(`az account set --subscription="${this.subscriptionId}"`);
}
// Ensure resource group exists
if (this.resourceGroup) {
try {
await OrchestratorSystem.Run(`az group show --name "${this.resourceGroup}" --output json`, false, true);
OrchestratorLogger.log(`[Azure ACI] Resource group ${this.resourceGroup} exists`);
} catch {
OrchestratorLogger.log(`[Azure ACI] Creating resource group ${this.resourceGroup}`);
await OrchestratorSystem.Run(`az group create --name "${this.resourceGroup}" --location "${this.location}"`);
}
}
// Storage-specific setup
switch (this.storageType) {
case 'azure-files':
await this.setupStorageAccount('Standard_LRS', 'StorageV2');
await this.setupFileShare();
break;
case 'azure-files-nfs':
await this.setupStorageAccount('Premium_LRS', 'FileStorage');
await this.setupNfsFileShare();
break;
case 'blob-copy':
await this.setupStorageAccount('Standard_LRS', 'StorageV2');
await this.setupBlobContainer();
break;
case 'in-memory':
// No storage setup needed
break;
}
}
private async setupStorageAccount(sku: string, kind: string): Promise<void> {
if (!this.storageAccount || !this.resourceGroup) return;
try {
await OrchestratorSystem.Run(
`az storage account show --name "${this.storageAccount}" --resource-group "${this.resourceGroup}" --output json`,
false,
true,
);
OrchestratorLogger.log(`[Azure ACI] Storage account ${this.storageAccount} exists`);
} catch {
OrchestratorLogger.log(`[Azure ACI] Creating storage account ${this.storageAccount} (${sku}, ${kind})`);
await OrchestratorSystem.Run(
`az storage account create --name "${this.storageAccount}" --resource-group "${this.resourceGroup}" --location "${this.location}" --sku ${sku} --kind ${kind}`,
);
}
}
private async setupFileShare(): Promise<void> {
if (!this.storageAccount || !this.resourceGroup) return;
try {
await OrchestratorSystem.Run(
`az storage share-rm show --storage-account "${this.storageAccount}" --name "${this.fileShareName}" --resource-group "${this.resourceGroup}" --output json`,
false,
true,
);
} catch {
OrchestratorLogger.log(`[Azure ACI] Creating file share ${this.fileShareName} (${this.diskSizeGb}GB)`);
await OrchestratorSystem.Run(
`az storage share-rm create --storage-account "${this.storageAccount}" --name "${this.fileShareName}" --resource-group "${this.resourceGroup}" --quota ${this.diskSizeGb}`,
);
}
}
private async setupNfsFileShare(): Promise<void> {
if (!this.storageAccount || !this.resourceGroup) return;
try {
await OrchestratorSystem.Run(
`az storage share-rm show --storage-account "${this.storageAccount}" --name "${this.fileShareName}" --resource-group "${this.resourceGroup}" --output json`,
false,
true,
);
} catch {
OrchestratorLogger.log(`[Azure ACI] Creating NFS file share ${this.fileShareName} (${this.diskSizeGb}GB)`);
await OrchestratorSystem.Run(
`az storage share-rm create --storage-account "${this.storageAccount}" --name "${this.fileShareName}" --resource-group "${this.resourceGroup}" --quota ${this.diskSizeGb} --enabled-protocols NFS`,
);
}
}
private async setupBlobContainer(): Promise<void> {
if (!this.storageAccount || !this.resourceGroup) return;
try {
await OrchestratorSystem.Run(
`az storage container show --name "${this.blobContainer}" --account-name "${this.storageAccount}" --output json`,
false,
true,
);
} catch {
OrchestratorLogger.log(`[Azure ACI] Creating blob container ${this.blobContainer}`);
await OrchestratorSystem.Run(
`az storage container create --name "${this.blobContainer}" --account-name "${this.storageAccount}"`,
);
}
}
private async getStorageKey(): Promise<string> {
if (!this.storageAccount || !this.resourceGroup) return '';
try {
const keyJson = await OrchestratorSystem.Run(
`az storage account keys list --account-name "${this.storageAccount}" --resource-group "${this.resourceGroup}" --output json`,
false,
true,
);
const keys = JSON.parse(keyJson);
return keys[0]?.value || '';
} catch (error: any) {
OrchestratorLogger.logWarning(`[Azure ACI] Could not get storage key: ${error.message}`);
return '';
}
}
private async buildVolumeFlags(mountdir: string): Promise<string> {
switch (this.storageType) {
case 'azure-files': {
const storageKey = await this.getStorageKey();
if (!storageKey) return '';
return [
`--azure-file-volume-account-name "${this.storageAccount}"`,
`--azure-file-volume-account-key "${storageKey}"`,
`--azure-file-volume-share-name "${this.fileShareName}"`,
`--azure-file-volume-mount-path "${mountdir}"`,
].join(' ');
}
case 'azure-files-nfs': {
// ACI NFS mount uses a YAML deployment template; for CLI we use the same
// azure-file-volume flags but the share must be NFS-enabled and
// the container must be in a VNet
const storageKey = await this.getStorageKey();
if (!storageKey) return '';
return [
`--azure-file-volume-account-name "${this.storageAccount}"`,
`--azure-file-volume-account-key "${storageKey}"`,
`--azure-file-volume-share-name "${this.fileShareName}"`,
`--azure-file-volume-mount-path "${mountdir}"`,
].join(' ');
}
case 'in-memory':
// ACI emptyDir volumes require YAML deployment; for simplicity we skip
// the volume mount and let the container use its own filesystem
OrchestratorLogger.log('[Azure ACI] In-memory mode: using container filesystem (no persistent mount)');
return '';
case 'blob-copy':
// No volume mount — artifacts are copied in/out via az storage blob commands
return '';
default:
return '';
}
}
async runTaskInWorkflow(
buildGuid: string,
image: string,
commands: string,
mountdir: string,
workingdir: string,
environment: OrchestratorEnvironmentVariable[],
secrets: OrchestratorSecret[],
): Promise<string> {
OrchestratorLogger.log(`[Azure ACI] Running task for build ${buildGuid}`);
ResourceTracking.logAllocationSummary('azure-aci task');
const containerName = `unity-build-${buildGuid}`
.toLowerCase()
.replace(/[^a-z0-9-]/g, '-')
.slice(0, 63);
// Build environment variable flags
const allEnvVars = [
...environment.map((env) => `${env.name}=${env.value}`),
...secrets.map((s) => `${s.EnvironmentVariable}=${s.ParameterValue}`),
];
const envFlag = allEnvVars.length > 0 ? `--environment-variables ${allEnvVars.map((e) => `"${e}"`).join(' ')}` : '';
// Build volume flags based on storage type
const volumeFlags = await this.buildVolumeFlags(mountdir);
const subnetFlag = this.subnetId ? `--subnet "${this.subnetId}"` : '';
// For blob-copy, wrap the user command with copy-in/copy-out steps
let effectiveCommands = commands;
if (this.storageType === 'blob-copy' && this.storageAccount && commands) {
effectiveCommands = [
`az storage blob download-batch --destination "${mountdir}" --source "${this.blobContainer}" --account-name "${this.storageAccount}" 2>/dev/null || true`,
commands,
`az storage blob upload-batch --source "${mountdir}" --destination "${this.blobContainer}" --account-name "${this.storageAccount}" --overwrite`,
].join(' && ');
}
const commandFlag = effectiveCommands
? `--command-line "/bin/sh -c '${effectiveCommands.replace(/'/g, "'\\''")}'"`
: '';
const createCmd = [
'az container create',
`--resource-group "${this.resourceGroup}"`,
`--name "${containerName}"`,
`--image "${image}"`,
`--location "${this.location}"`,
`--cpu ${this.cpu}`,
`--memory ${this.memoryGb}`,
'--restart-policy Never',
'--os-type Linux',
volumeFlags,
envFlag,
subnetFlag,
commandFlag,
'--output json',
]
.filter(Boolean)
.join(' ');
try {
await OrchestratorSystem.Run(createCmd);
OrchestratorLogger.log(
`[Azure ACI] Container ${containerName} created (storage: ${this.storageType}), waiting for completion...`,
);
} catch (error: any) {
throw new Error(`[Azure ACI] Failed to create container: ${error.message}`);
}
const output = await this.waitForContainerCompletion(containerName);
return output;
}
private async waitForContainerCompletion(containerName: string): Promise<string> {
const maxWaitMs = 24 * 60 * 60 * 1000;
const pollIntervalMs = 15_000;
const startTime = Date.now();
let lastLogLength = 0;
while (Date.now() - startTime < maxWaitMs) {
try {
const stateJson = await OrchestratorSystem.Run(
`az container show --resource-group "${this.resourceGroup}" --name "${containerName}" --output json`,
false,
true,
);
const state = JSON.parse(stateJson);
const containerState =
state.containers?.[0]?.instanceView?.currentState?.state || state.instanceView?.state || 'Unknown';
const provisioningState = state.provisioningState || 'Unknown';
// Stream logs incrementally
try {
const logs = await OrchestratorSystem.Run(
`az container logs --resource-group "${this.resourceGroup}" --name "${containerName}"`,
false,
true,
);
if (logs && logs.length > lastLogLength) {
const newLogs = logs.slice(lastLogLength);
for (const line of newLogs.split('\n')) {
if (line.trim()) {
OrchestratorLogger.log(`[Build] ${line}`);
}
}
lastLogLength = logs.length;
}
} catch {
// Logs may not be available yet
}
if (containerState === 'Terminated' || provisioningState === 'Succeeded') {
const exitCode = state.containers?.[0]?.instanceView?.currentState?.exitCode;
if (exitCode !== undefined && exitCode !== 0) {
throw new Error(`[Azure ACI] Container exited with code ${exitCode}`);
}
OrchestratorLogger.log('[Azure ACI] Container completed successfully');
try {
return await OrchestratorSystem.Run(
`az container logs --resource-group "${this.resourceGroup}" --name "${containerName}"`,
false,
true,
);
} catch {
return '';
}
}
if (provisioningState === 'Failed') {
const detail =
state.containers?.[0]?.instanceView?.currentState?.detailStatus ||
state.containers?.[0]?.instanceView?.events?.map((e: any) => e.message).join('; ') ||
'Unknown error';
throw new Error(`[Azure ACI] Container provisioning failed: ${detail}`);
}
} catch (error: any) {
if (error.message?.includes('Container provisioning failed') || error.message?.includes('exited with code')) {
throw error;
}
OrchestratorLogger.logWarning(`[Azure ACI] Polling error: ${error.message}`);
}
await new Promise((resolve) => setTimeout(resolve, pollIntervalMs));
}
throw new Error('[Azure ACI] Container execution timed out after 24 hours');
}
async cleanupWorkflow(
buildParameters: BuildParameters,
branchName: string,
defaultSecretsArray: { ParameterKey: string; EnvironmentVariable: string; ParameterValue: string }[],
) {
OrchestratorLogger.log('[Azure ACI] Cleaning up workflow');
}
async garbageCollect(
filter: string,
previewOnly: boolean,
olderThan: Number,
fullCache: boolean,
baseDependencies: boolean,
): Promise<string> {
OrchestratorLogger.log('[Azure ACI] Garbage collecting old container groups');
try {
const containersJson = await OrchestratorSystem.Run(
`az container list --resource-group "${this.resourceGroup}" --output json`,
false,
true,
);
const containers = JSON.parse(containersJson || '[]');
const cutoffDate = new Date();
cutoffDate.setDate(cutoffDate.getDate() - Number(olderThan));
let deletedCount = 0;
for (const container of containers) {
const name = container.name || '';
if (!name.startsWith('unity-build-')) continue;
const createdAt = new Date(container.tags?.createdAt || container.properties?.provisioningState || 0);
const state = container.containers?.[0]?.instanceView?.currentState?.state || '';
if (state === 'Terminated' || createdAt < cutoffDate) {
if (previewOnly) {
OrchestratorLogger.log(`[Azure ACI] Would delete: ${name}`);
} else {
await OrchestratorSystem.Run(
`az container delete --resource-group "${this.resourceGroup}" --name "${name}" --yes`,
);
deletedCount++;
}
}
}
return `Garbage collected ${deletedCount} Azure container instances`;
} catch (error: any) {
OrchestratorLogger.logWarning(`[Azure ACI] Garbage collection failed: ${error.message}`);
return '';
}
}
async listResources(): Promise<ProviderResource[]> {
try {
const containersJson = await OrchestratorSystem.Run(
`az container list --resource-group "${this.resourceGroup}" --output json`,
false,
true,
);
const containers = JSON.parse(containersJson || '[]');
return containers
.filter((c: any) => (c.name || '').startsWith('unity-build-'))
.map((c: any) => ({ Name: c.name || '' }));
} catch {
return [];
}
}
listWorkflow(): Promise<ProviderWorkflow[]> {
throw new Error('[Azure ACI] listWorkflow not implemented for this experimental provider');
}
async watchWorkflow(): Promise<string> {
throw new Error('[Azure ACI] watchWorkflow not implemented for this experimental provider');
}
}
export default AzureAciProvider;

View File

@@ -1,435 +0,0 @@
/**
* Google Cloud Run Jobs Provider (Experimental)
*
* Executes Unity builds as Cloud Run Jobs with configurable storage backends.
*
* Storage types:
* - gcs-fuse: Mount a GCS bucket as a POSIX filesystem via GCS FUSE sidecar.
* Unlimited size, best for large sequential reads/writes.
* Requires: gcpBucket
* - gcs-copy: Copy artifacts in/out of GCS before/after the build via gsutil.
* No mount overhead, simpler, works everywhere.
* Requires: gcpBucket
* - nfs: Mount a Filestore NFS share. True POSIX semantics, good random I/O,
* up to 100 TiB. Best for Library caching (many small random reads).
* Requires: gcpFilestoreIp, gcpFilestoreShare
* - in-memory: tmpfs volume (emptyDir). Fastest I/O but volatile and limited to 32 GiB.
* Good for scratch/temp space during builds.
*
* Prerequisites:
* - Google Cloud SDK authenticated (GOOGLE_APPLICATION_CREDENTIALS or gcloud auth)
* - Cloud Run Jobs API enabled
* - Service account with roles: Cloud Run Admin, Storage Admin, Logs Viewer
*
* @experimental This provider is experimental. APIs and behavior may change.
*/
import { ProviderInterface } from '../provider-interface';
import BuildParameters from '../../../build-parameters';
import OrchestratorLogger from '../../services/core/orchestrator-logger';
import OrchestratorEnvironmentVariable from '../../options/orchestrator-environment-variable';
import OrchestratorSecret from '../../options/orchestrator-secret';
import { ProviderResource } from '../provider-resource';
import { ProviderWorkflow } from '../provider-workflow';
import { OrchestratorSystem } from '../../services/core/orchestrator-system';
import { Input } from '../../..';
import ResourceTracking from '../../services/core/resource-tracking';
type GcpStorageType = 'gcs-fuse' | 'gcs-copy' | 'nfs' | 'in-memory';
class GcpCloudRunProvider implements ProviderInterface {
private readonly project: string;
private readonly region: string;
private readonly storageType: GcpStorageType;
private readonly bucket: string;
private readonly filestoreIp: string;
private readonly filestoreShare: string;
private readonly machineType: string;
private readonly diskSizeGb: number;
private readonly serviceAccount: string;
private readonly vpcConnector: string;
private buildParameters: BuildParameters;
constructor(buildParameters: BuildParameters) {
this.buildParameters = buildParameters;
this.project = buildParameters.gcpProject || process.env.GOOGLE_CLOUD_PROJECT || process.env.GCLOUD_PROJECT || '';
this.region = buildParameters.gcpRegion || Input.region || 'us-central1';
this.storageType = (buildParameters.gcpStorageType || 'gcs-fuse') as GcpStorageType;
this.bucket = buildParameters.gcpBucket || '';
this.filestoreIp = buildParameters.gcpFilestoreIp || '';
this.filestoreShare = buildParameters.gcpFilestoreShare || '/share1';
this.machineType = buildParameters.gcpMachineType || 'e2-standard-4';
this.diskSizeGb = Number.parseInt(buildParameters.gcpDiskSizeGb || '100', 10);
this.serviceAccount = buildParameters.gcpServiceAccount || '';
this.vpcConnector = buildParameters.gcpVpcConnector || '';
OrchestratorLogger.log('[GCP Cloud Run] Provider initialized (EXPERIMENTAL)');
OrchestratorLogger.log(`[GCP Cloud Run] Project: ${this.project || '(auto-detect)'}`);
OrchestratorLogger.log(`[GCP Cloud Run] Region: ${this.region}`);
OrchestratorLogger.log(`[GCP Cloud Run] Storage: ${this.storageType}`);
this.validateStorageConfig();
}
private validateStorageConfig(): void {
switch (this.storageType) {
case 'gcs-fuse':
case 'gcs-copy':
if (!this.bucket) {
OrchestratorLogger.logWarning(
`[GCP Cloud Run] Storage type '${this.storageType}' requires gcpBucket to be set.`,
);
} else {
OrchestratorLogger.log(`[GCP Cloud Run] Bucket: gs://${this.bucket}`);
}
break;
case 'nfs':
if (!this.filestoreIp) {
OrchestratorLogger.logWarning('[GCP Cloud Run] Storage type "nfs" requires gcpFilestoreIp to be set.');
} else {
OrchestratorLogger.log(`[GCP Cloud Run] Filestore: ${this.filestoreIp}:${this.filestoreShare}`);
}
if (!this.vpcConnector) {
OrchestratorLogger.logWarning(
'[GCP Cloud Run] NFS storage usually requires gcpVpcConnector for private network access to Filestore.',
);
}
break;
case 'in-memory':
OrchestratorLogger.log(`[GCP Cloud Run] In-memory volume: ${Math.min(this.diskSizeGb, 32)} GiB (max 32)`);
break;
default:
OrchestratorLogger.logWarning(
`[GCP Cloud Run] Unknown storage type '${this.storageType}'. Valid: gcs-fuse, gcs-copy, nfs, in-memory`,
);
}
if (!this.project) {
OrchestratorLogger.logWarning(
'[GCP Cloud Run] No project specified. Set gcpProject input or GOOGLE_CLOUD_PROJECT env var.',
);
}
}
async setupWorkflow(
buildGuid: string,
buildParameters: BuildParameters,
branchName: string,
defaultSecretsArray: { ParameterKey: string; EnvironmentVariable: string; ParameterValue: string }[],
) {
OrchestratorLogger.log(`[GCP Cloud Run] Setting up workflow for build ${buildGuid}`);
ResourceTracking.logAllocationSummary('gcp-cloud-run setup');
// Verify gcloud CLI is available
try {
await OrchestratorSystem.Run('gcloud --version', false, true);
OrchestratorLogger.log('[GCP Cloud Run] gcloud CLI detected');
} catch {
throw new Error(
'[GCP Cloud Run] gcloud CLI not found. Install Google Cloud SDK: https://cloud.google.com/sdk/docs/install',
);
}
// Verify Cloud Run Jobs API is enabled
try {
const projectFlag = this.project ? `--project=${this.project}` : '';
await OrchestratorSystem.Run(
`gcloud services list --enabled --filter="name:run.googleapis.com" ${projectFlag} --format="value(name)"`,
false,
true,
);
} catch {
OrchestratorLogger.logWarning(
'[GCP Cloud Run] Could not verify Cloud Run API status. Ensure run.googleapis.com is enabled.',
);
}
// Storage-specific setup
if ((this.storageType === 'gcs-fuse' || this.storageType === 'gcs-copy') && this.bucket) {
await this.ensureBucketExists();
}
}
private async ensureBucketExists(): Promise<void> {
try {
await OrchestratorSystem.Run(
`gcloud storage buckets describe gs://${this.bucket} --format="value(name)"`,
false,
true,
);
OrchestratorLogger.log(`[GCP Cloud Run] Bucket gs://${this.bucket} exists`);
} catch {
OrchestratorLogger.log(`[GCP Cloud Run] Creating bucket gs://${this.bucket}`);
const projectFlag = this.project ? `--project=${this.project}` : '';
await OrchestratorSystem.Run(
`gcloud storage buckets create gs://${this.bucket} --location=${this.region} ${projectFlag}`,
);
}
}
private buildVolumeFlags(mountdir: string): { volumeFlags: string; mountFlags: string } {
switch (this.storageType) {
case 'gcs-fuse':
if (!this.bucket) return { volumeFlags: '', mountFlags: '' };
return {
volumeFlags: `--add-volume=name=gcs-fuse,type=cloud-storage,bucket=${this.bucket}`,
mountFlags: `--add-volume-mount=volume=gcs-fuse,mount-path=${mountdir}`,
};
case 'nfs':
if (!this.filestoreIp) return { volumeFlags: '', mountFlags: '' };
return {
volumeFlags: `--add-volume=name=nfs-vol,type=nfs,location=${this.filestoreIp}:${this.filestoreShare}`,
mountFlags: `--add-volume-mount=volume=nfs-vol,mount-path=${mountdir}`,
};
case 'in-memory': {
const sizeGib = Math.min(this.diskSizeGb, 32);
return {
volumeFlags: `--add-volume=name=tmpfs-vol,type=in-memory,size-limit=${sizeGib}Gi`,
mountFlags: `--add-volume-mount=volume=tmpfs-vol,mount-path=${mountdir}`,
};
}
case 'gcs-copy':
// No volume mount — artifacts are copied in/out via gsutil commands
return { volumeFlags: '', mountFlags: '' };
default:
return { volumeFlags: '', mountFlags: '' };
}
}
private async copyArtifactsIn(mountdir: string): Promise<void> {
if (this.storageType !== 'gcs-copy' || !this.bucket) return;
OrchestratorLogger.log(`[GCP Cloud Run] Copying artifacts from gs://${this.bucket} to ${mountdir}`);
try {
await OrchestratorSystem.Run(`gcloud storage cp -r "gs://${this.bucket}/*" "${mountdir}/" || true`, false, true);
} catch {
OrchestratorLogger.log('[GCP Cloud Run] No existing artifacts to restore (bucket may be empty)');
}
}
private async copyArtifactsOut(mountdir: string): Promise<void> {
if (this.storageType !== 'gcs-copy' || !this.bucket) return;
OrchestratorLogger.log(`[GCP Cloud Run] Uploading artifacts from ${mountdir} to gs://${this.bucket}`);
await OrchestratorSystem.Run(`gcloud storage cp -r "${mountdir}/*" "gs://${this.bucket}/"`, false, true);
}
async runTaskInWorkflow(
buildGuid: string,
image: string,
commands: string,
mountdir: string,
workingdir: string,
environment: OrchestratorEnvironmentVariable[],
secrets: OrchestratorSecret[],
): Promise<string> {
OrchestratorLogger.log(`[GCP Cloud Run] Running task for build ${buildGuid}`);
ResourceTracking.logAllocationSummary('gcp-cloud-run task');
const jobName = `unity-build-${buildGuid}`
.toLowerCase()
.replace(/[^a-z0-9-]/g, '-')
.slice(0, 63);
const projectFlag = this.project ? `--project=${this.project}` : '';
// Build environment variable flags
const envFlags = environment
.map((env) => `${env.name}=${env.value}`)
.concat(secrets.map((s) => `${s.EnvironmentVariable}=${s.ParameterValue}`));
const envString = envFlags.length > 0 ? `--set-env-vars="${envFlags.join(',')}"` : '';
// Build storage volume flags
const { volumeFlags, mountFlags } = this.buildVolumeFlags(mountdir);
// For gcs-copy, wrap the user command with copy-in/copy-out steps
let effectiveCommands = commands;
if (this.storageType === 'gcs-copy' && this.bucket && commands) {
effectiveCommands = [
`gcloud storage cp -r "gs://${this.bucket}/*" "${mountdir}/" 2>/dev/null || true`,
commands,
`gcloud storage cp -r "${mountdir}/*" "gs://${this.bucket}/"`,
].join(' && ');
}
const saFlag = this.serviceAccount ? `--service-account=${this.serviceAccount}` : '';
const vpcFlag = this.vpcConnector ? `--vpc-connector=${this.vpcConnector}` : '';
// Create the Cloud Run Job
const createCmd = [
'gcloud run jobs create',
jobName,
`--image=${image}`,
`--region=${this.region}`,
'--task-timeout=86400s',
'--max-retries=0',
'--cpu=4',
'--memory=16Gi',
volumeFlags,
mountFlags,
envString,
saFlag,
vpcFlag,
projectFlag,
'--format=json',
'--quiet',
]
.filter(Boolean)
.join(' ');
try {
await OrchestratorSystem.Run(createCmd);
OrchestratorLogger.log(`[GCP Cloud Run] Job ${jobName} created`);
} catch (error: any) {
if (error.message?.includes('already exists')) {
OrchestratorLogger.log(`[GCP Cloud Run] Job ${jobName} already exists, updating...`);
const updateCmd = createCmd.replace('jobs create', 'jobs update');
await OrchestratorSystem.Run(updateCmd);
} else {
throw error;
}
}
// Override the command if provided
if (effectiveCommands) {
const updateCmd = [
'gcloud run jobs update',
jobName,
`--region=${this.region}`,
'--command="/bin/sh"',
`--args="-c,${effectiveCommands}"`,
projectFlag,
'--quiet',
]
.filter(Boolean)
.join(' ');
await OrchestratorSystem.Run(updateCmd);
}
// Execute the job
OrchestratorLogger.log(`[GCP Cloud Run] Executing job ${jobName} (storage: ${this.storageType})...`);
const executeCmd = [
'gcloud run jobs execute',
jobName,
`--region=${this.region}`,
projectFlag,
'--wait',
'--format=json',
'--quiet',
]
.filter(Boolean)
.join(' ');
let output = '';
try {
output = await OrchestratorSystem.Run(executeCmd);
OrchestratorLogger.log('[GCP Cloud Run] Job execution completed');
} catch (error: any) {
await this.streamJobLogs(jobName);
throw new Error(`[GCP Cloud Run] Job execution failed: ${error.message}`);
}
await this.streamJobLogs(jobName);
return output;
}
private async streamJobLogs(jobName: string): Promise<void> {
const projectFlag = this.project ? `--project=${this.project}` : '';
try {
const logs = await OrchestratorSystem.Run(
`gcloud logging read "resource.type=cloud_run_job AND resource.labels.job_name=${jobName}" ${projectFlag} --limit=1000 --format="value(textPayload)" --order=asc`,
false,
true,
);
if (logs) {
for (const line of logs.split('\n')) {
if (line.trim()) {
OrchestratorLogger.log(`[Build] ${line}`);
}
}
}
} catch {
OrchestratorLogger.logWarning('[GCP Cloud Run] Could not retrieve job logs');
}
}
async cleanupWorkflow(
buildParameters: BuildParameters,
branchName: string,
defaultSecretsArray: { ParameterKey: string; EnvironmentVariable: string; ParameterValue: string }[],
) {
OrchestratorLogger.log('[GCP Cloud Run] Cleaning up workflow');
}
async garbageCollect(
filter: string,
previewOnly: boolean,
olderThan: Number,
fullCache: boolean,
baseDependencies: boolean,
): Promise<string> {
OrchestratorLogger.log('[GCP Cloud Run] Garbage collecting old jobs');
const projectFlag = this.project ? `--project=${this.project}` : '';
try {
const jobsJson = await OrchestratorSystem.Run(
`gcloud run jobs list --region=${this.region} ${projectFlag} --filter="metadata.name~unity-build-" --format="json(metadata.name,metadata.creationTimestamp)"`,
false,
true,
);
const jobs = JSON.parse(jobsJson || '[]');
const cutoffDate = new Date();
cutoffDate.setDate(cutoffDate.getDate() - Number(olderThan));
let deletedCount = 0;
for (const job of jobs) {
const createdAt = new Date(job.metadata?.creationTimestamp || 0);
if (createdAt < cutoffDate) {
const name = job.metadata?.name;
if (previewOnly) {
OrchestratorLogger.log(`[GCP Cloud Run] Would delete: ${name}`);
} else {
await OrchestratorSystem.Run(
`gcloud run jobs delete ${name} --region=${this.region} ${projectFlag} --quiet`,
);
deletedCount++;
}
}
}
return `Garbage collected ${deletedCount} Cloud Run jobs`;
} catch (error: any) {
OrchestratorLogger.logWarning(`[GCP Cloud Run] Garbage collection failed: ${error.message}`);
return '';
}
}
async listResources(): Promise<ProviderResource[]> {
const projectFlag = this.project ? `--project=${this.project}` : '';
try {
const jobsJson = await OrchestratorSystem.Run(
`gcloud run jobs list --region=${this.region} ${projectFlag} --filter="metadata.name~unity-build-" --format="json(metadata.name)"`,
false,
true,
);
const jobs = JSON.parse(jobsJson || '[]');
return jobs.map((job: any) => ({ Name: job.metadata?.name || '' }));
} catch {
return [];
}
}
listWorkflow(): Promise<ProviderWorkflow[]> {
throw new Error('[GCP Cloud Run] listWorkflow not implemented for this experimental provider');
}
async watchWorkflow(): Promise<string> {
throw new Error('[GCP Cloud Run] watchWorkflow not implemented for this experimental provider');
}
}
export default GcpCloudRunProvider;

View File

@@ -62,8 +62,6 @@ export default async function loadProvider(
'local-docker': './docker',
'local-system': './local',
local: './local',
'gcp-cloud-run': './gcp-cloud-run',
'azure-aci': './azure-aci',
};
modulePath = providerModuleMap[providerSource] || providerSource;
@@ -138,7 +136,7 @@ export class ProviderLoader {
* @returns string[] - Array of available provider names
*/
static getAvailableProviders(): string[] {
return ['aws', 'k8s', 'test', 'local-docker', 'local-system', 'local', 'gcp-cloud-run', 'azure-aci'];
return ['aws', 'k8s', 'test', 'local-docker', 'local-system', 'local'];
}
/**

View File

@@ -0,0 +1,5 @@
# Hot Runner Protocol
Extensible runner registration and persistent Unity editor provider protocol.
See GitHub Issue for full specification.

View File

@@ -0,0 +1,159 @@
import OrchestratorLogger from '../core/orchestrator-logger';
import { HotRunnerRegistry } from './hot-runner-registry';
import { HotRunnerJobRequest, HotRunnerJobResult, HotRunnerStatus, HotRunnerTransport } from './hot-runner-types';
const POLL_INTERVAL_MS = 1000;
// eslint-disable-next-line no-unused-vars
export type OutputCallback = (output: string) => void;
export class HotRunnerDispatcher {
private transports: Map<string, HotRunnerTransport>;
constructor(transports: Map<string, HotRunnerTransport>) {
this.transports = transports;
}
/**
* Dispatch a job to an available hot runner matching the request's build target.
* If no runner is immediately available, waits up to the request timeout.
* Returns the job result, or throws if no runner becomes available in time.
*/
async dispatchJob(
request: HotRunnerJobRequest,
registry: HotRunnerRegistry,
unityVersion: string,
onOutput?: OutputCallback,
): Promise<HotRunnerJobResult> {
OrchestratorLogger.log(`[HotRunner] Dispatching job ${request.jobId} (target: ${request.buildTarget})`);
// Find or wait for an available runner
let runner = registry.findAvailableRunner({
unityVersion,
platform: request.buildTarget,
});
if (!runner) {
OrchestratorLogger.log(
`[HotRunner] No idle runner available for ${unityVersion}/${request.buildTarget}, waiting...`,
);
runner = await this.waitForRunner({ unityVersion, platform: request.buildTarget }, request.timeout, registry);
}
// Mark runner as busy
registry.updateRunner(runner.id, {
state: 'busy',
currentJob: request.jobId,
});
const transport = this.transports.get(runner.id);
if (!transport) {
registry.updateRunner(runner.id, { state: 'idle', currentJob: undefined });
throw new Error(`[HotRunner] No transport available for runner ${runner.id}`);
}
OrchestratorLogger.log(`[HotRunner] Sending job ${request.jobId} to runner ${runner.id}`);
const startTime = Date.now();
try {
const result = await this.executeWithTimeout(transport, request);
const duration = Date.now() - startTime;
OrchestratorLogger.log(
`[HotRunner] Job ${request.jobId} completed on runner ${runner.id} in ${duration}ms (exit: ${result.exitCode})`,
);
if (onOutput && result.output) {
onOutput(result.output);
}
// Mark runner as idle and increment job count
const currentStatus = registry.getRunner(runner.id);
registry.updateRunner(runner.id, {
state: 'idle',
currentJob: undefined,
lastJobCompleted: request.jobId,
jobsCompleted: (currentStatus?.jobsCompleted ?? 0) + 1,
});
return result;
} catch (error: any) {
OrchestratorLogger.logWarning(`[HotRunner] Job ${request.jobId} failed on runner ${runner.id}: ${error.message}`);
// Mark runner as idle despite failure -- the health monitor will recycle if needed
registry.updateRunner(runner.id, {
state: 'idle',
currentJob: undefined,
});
throw error;
}
}
/**
* Wait for an available runner matching the requirements.
* Polls the registry at a fixed interval until one becomes available or timeout expires.
*/
async waitForRunner(
requirements: { unityVersion: string; platform: string },
timeoutMs: number,
registry: HotRunnerRegistry,
): Promise<HotRunnerStatus> {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
const runner = registry.findAvailableRunner(requirements);
if (runner) {
OrchestratorLogger.log(`[HotRunner] Runner ${runner.id} became available`);
return runner;
}
await this.sleep(Math.min(POLL_INTERVAL_MS, deadline - Date.now()));
}
throw new Error(
`[HotRunner] Timed out waiting for available runner (${requirements.unityVersion}/${requirements.platform}) after ${timeoutMs}ms`,
);
}
/**
* Execute a job on a transport with a timeout guard.
* On timeout, disconnects the transport to release the connection
* and prevent the orphaned sendJob promise from holding resources.
*/
private async executeWithTimeout(
transport: HotRunnerTransport,
request: HotRunnerJobRequest,
): Promise<HotRunnerJobResult> {
const TIMEOUT_SENTINEL = Symbol('timeout');
const timeoutPromise = new Promise<typeof TIMEOUT_SENTINEL>((resolve) => {
setTimeout(() => {
resolve(TIMEOUT_SENTINEL);
}, request.timeout);
});
const result = await Promise.race([transport.sendJob(request), timeoutPromise]);
if (result === TIMEOUT_SENTINEL) {
// Disconnect the transport to clean up the orphaned sendJob call
try {
await transport.disconnect();
} catch (disconnectError: any) {
OrchestratorLogger.logWarning(
`[HotRunner] Error disconnecting transport after timeout for job ${request.jobId}: ${disconnectError.message}`,
);
}
throw new Error(`[HotRunner] Job ${request.jobId} timed out after ${request.timeout}ms`);
}
return result;
}
private sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
}

View File

@@ -0,0 +1,186 @@
import OrchestratorLogger from '../core/orchestrator-logger';
import { HotRunnerRegistry } from './hot-runner-registry';
import { HotRunnerTransport } from './hot-runner-types';
export class HotRunnerHealthMonitor {
private intervalHandle: ReturnType<typeof setInterval> | undefined;
private registry: HotRunnerRegistry | undefined;
private transports: Map<string, HotRunnerTransport> = new Map();
/**
* Start periodic health monitoring for all registered runners.
*/
startMonitoring(registry: HotRunnerRegistry, interval: number, transports: Map<string, HotRunnerTransport>): void {
if (this.intervalHandle) {
this.stopMonitoring();
}
this.registry = registry;
this.transports = transports;
OrchestratorLogger.log(`[HotRunner] Starting health monitoring (interval: ${interval}s)`);
this.intervalHandle = setInterval(() => {
this.runHealthChecks().catch((error: any) => {
OrchestratorLogger.logWarning(`[HotRunner] Health check cycle failed: ${error.message}`);
});
}, interval * 1000);
}
/**
* Stop periodic health monitoring.
*/
stopMonitoring(): void {
if (this.intervalHandle) {
clearInterval(this.intervalHandle);
this.intervalHandle = undefined;
OrchestratorLogger.log(`[HotRunner] Health monitoring stopped`);
}
}
/**
* Check health of a specific runner by ID. Returns true if healthy.
*/
async checkHealth(runnerId: string): Promise<boolean> {
if (!this.registry) {
return false;
}
const transport = this.transports.get(runnerId);
if (!transport) {
OrchestratorLogger.logWarning(`[HotRunner] No transport for runner ${runnerId}`);
this.registry.updateRunner(runnerId, {
state: 'unhealthy',
lastHealthCheck: new Date().toISOString(),
});
return false;
}
try {
const healthy = await transport.healthCheck();
if (healthy) {
const status = await transport.getStatus();
this.registry.updateRunner(runnerId, {
lastHealthCheck: new Date().toISOString(),
memoryUsageMB: status.memoryUsageMB,
uptime: status.uptime,
libraryHash: status.libraryHash,
});
return true;
}
OrchestratorLogger.logWarning(`[HotRunner] Runner ${runnerId} health check returned false`);
this.registry.updateRunner(runnerId, {
state: 'unhealthy',
lastHealthCheck: new Date().toISOString(),
});
return false;
} catch (error: any) {
OrchestratorLogger.logWarning(`[HotRunner] Runner ${runnerId} health check failed: ${error.message}`);
this.registry.updateRunner(runnerId, {
state: 'unhealthy',
lastHealthCheck: new Date().toISOString(),
});
return false;
}
}
/**
* Mark an unhealthy runner for cleanup and disconnect its transport.
*/
async recycleUnhealthyRunner(runnerId: string): Promise<void> {
if (!this.registry) {
return;
}
OrchestratorLogger.log(`[HotRunner] Recycling unhealthy runner ${runnerId}`);
this.registry.updateRunner(runnerId, { state: 'stopping' });
const transport = this.transports.get(runnerId);
if (transport) {
try {
await transport.disconnect();
} catch (error: any) {
OrchestratorLogger.logWarning(`[HotRunner] Error disconnecting runner ${runnerId}: ${error.message}`);
}
this.transports.delete(runnerId);
}
this.registry.unregisterRunner(runnerId);
OrchestratorLogger.log(`[HotRunner] Runner ${runnerId} recycled and removed`);
}
/**
* Recycle a runner that has been idle longer than the maximum idle time.
*/
async recycleIdleRunner(runnerId: string, maxIdleTime: number): Promise<void> {
if (!this.registry) {
return;
}
const runner = this.registry.getRunner(runnerId);
if (!runner || runner.state !== 'idle') {
return;
}
const lastCheckTime = new Date(runner.lastHealthCheck).getTime();
const now = Date.now();
const idleSeconds = (now - lastCheckTime) / 1000;
if (idleSeconds >= maxIdleTime) {
OrchestratorLogger.log(
`[HotRunner] Runner ${runnerId} idle for ${Math.floor(idleSeconds)}s (max: ${maxIdleTime}s), recycling`,
);
await this.recycleUnhealthyRunner(runnerId);
}
}
/**
* Run health checks and idle-recycle checks for all registered runners.
*/
private async runHealthChecks(): Promise<void> {
if (!this.registry) {
return;
}
const runners = this.registry.listRunners();
for (const runner of runners) {
if (runner.state === 'stopping') {
continue;
}
const healthy = await this.checkHealth(runner.id);
if (!healthy && runner.state !== 'starting') {
await this.recycleUnhealthyRunner(runner.id);
continue;
}
// Check for idle timeout
const config = this.registry.getConfig(runner.id);
if (config && runner.state === 'idle') {
await this.recycleIdleRunner(runner.id, config.maxIdleTime);
}
// Check for max jobs before recycle
if (config && config.maxJobsBeforeRecycle > 0 && runner.jobsCompleted >= config.maxJobsBeforeRecycle) {
OrchestratorLogger.log(
`[HotRunner] Runner ${runner.id} reached max jobs (${runner.jobsCompleted}/${config.maxJobsBeforeRecycle}), recycling`,
);
await this.recycleUnhealthyRunner(runner.id);
}
}
}
/**
* Whether health monitoring is currently active.
*/
get isMonitoring(): boolean {
return this.intervalHandle !== undefined;
}
}

View File

@@ -0,0 +1,315 @@
import fs from 'node:fs';
import path from 'node:path';
import { customAlphabet } from 'nanoid';
import OrchestratorLogger from '../core/orchestrator-logger';
import { HotRunnerConfig, HotRunnerStatus } from './hot-runner-types';
const generateId = customAlphabet('abcdefghijklmnopqrstuvwxyz0123456789', 12);
const PERSISTENCE_FILENAME = 'hot-runners.json';
const VALID_RUNNER_STATES: ReadonlySet<string> = new Set(['idle', 'busy', 'starting', 'stopping', 'unhealthy']);
export interface HotRunnerFilter {
platform?: string;
state?: string;
unityVersion?: string;
}
/**
* Validate that a restored runner entry has all required fields with correct types.
* Returns true if the entry is a valid HotRunnerStatus, false otherwise.
*/
function isValidRunnerStatus(entry: unknown): entry is HotRunnerStatus {
if (typeof entry !== 'object' || entry === null) {
return false;
}
const record = entry as Record<string, unknown>;
return (
typeof record.id === 'string' &&
record.id.length > 0 &&
typeof record.state === 'string' &&
VALID_RUNNER_STATES.has(record.state) &&
typeof record.unityVersion === 'string' &&
typeof record.platform === 'string' &&
typeof record.uptime === 'number' &&
typeof record.jobsCompleted === 'number' &&
typeof record.lastHealthCheck === 'string' &&
typeof record.memoryUsageMB === 'number'
);
}
/**
* Validate that a restored config entry has all required fields with correct types.
* Returns true if the entry is a valid HotRunnerConfig, false otherwise.
*/
function isValidRunnerConfig(entry: unknown): entry is HotRunnerConfig {
if (typeof entry !== 'object' || entry === null) {
return false;
}
const record = entry as Record<string, unknown>;
return (
typeof record.enabled === 'boolean' &&
typeof record.transport === 'string' &&
['websocket', 'grpc', 'named-pipe'].includes(record.transport) &&
typeof record.host === 'string' &&
typeof record.port === 'number' &&
typeof record.healthCheckInterval === 'number' &&
typeof record.maxIdleTime === 'number' &&
typeof record.maxJobsBeforeRecycle === 'number'
);
}
export class HotRunnerRegistry {
private runners: Map<string, HotRunnerStatus> = new Map();
private configs: Map<string, HotRunnerConfig> = new Map();
private persistencePath: string;
constructor(persistenceDirectory?: string) {
this.persistencePath = persistenceDirectory ? path.join(persistenceDirectory, PERSISTENCE_FILENAME) : '';
}
/**
* Register a new hot runner. Returns the generated runner ID.
*/
registerRunner(config: HotRunnerConfig): string {
const id = `hr-${generateId()}`;
const status: HotRunnerStatus = {
id,
state: 'starting',
unityVersion: config.unityVersion ?? 'unknown',
platform: config.platform ?? 'unknown',
uptime: 0,
jobsCompleted: 0,
lastHealthCheck: new Date().toISOString(),
memoryUsageMB: 0,
};
this.runners.set(id, status);
this.configs.set(id, config);
OrchestratorLogger.log(`[HotRunner] Registered runner ${id} (${status.unityVersion}/${status.platform})`);
this.persist();
return id;
}
/**
* Remove a runner from the registry.
*/
unregisterRunner(id: string): void {
const existed = this.runners.delete(id);
this.configs.delete(id);
if (existed) {
OrchestratorLogger.log(`[HotRunner] Unregistered runner ${id}`);
this.persist();
}
}
/**
* Get a runner's current status by ID.
*/
getRunner(id: string): HotRunnerStatus | undefined {
return this.runners.get(id);
}
/**
* Get a runner's config by ID.
*/
getConfig(id: string): HotRunnerConfig | undefined {
return this.configs.get(id);
}
/**
* List all runners, optionally filtered by platform, state, or Unity version.
*/
listRunners(filter?: HotRunnerFilter): HotRunnerStatus[] {
let results = [...this.runners.values()];
if (filter?.platform) {
results = results.filter((runner) => runner.platform === filter.platform);
}
if (filter?.state) {
results = results.filter((runner) => runner.state === filter.state);
}
if (filter?.unityVersion) {
results = results.filter((runner) => runner.unityVersion === filter.unityVersion);
}
return results;
}
/**
* Find an idle runner matching the given Unity version and platform requirements.
*/
findAvailableRunner(requirements: { unityVersion: string; platform: string }): HotRunnerStatus | undefined {
return this.listRunners({
state: 'idle',
unityVersion: requirements.unityVersion,
platform: requirements.platform,
})[0];
}
/**
* Update a runner's status fields. Merges partial updates into existing status.
*/
updateRunner(id: string, update: Partial<HotRunnerStatus>): void {
const existing = this.runners.get(id);
if (!existing) {
return;
}
this.runners.set(id, { ...existing, ...update, id });
this.persist();
}
/**
* Get the total number of registered runners.
*/
get size(): number {
return this.runners.size;
}
/**
* Validate all runners in the registry and reset invalid ones to 'unhealthy'.
* Returns the number of runners that were repaired.
*/
validateAndRepair(): number {
let repaired = 0;
for (const [id, status] of this.runners) {
// Cast to unknown to bypass the type guard narrowing to 'never',
// since the Map is typed as HotRunnerStatus but entries may have
// been corrupted via direct deserialization or unsafe casts.
const entry = status as unknown as Record<string, unknown>;
if (!isValidRunnerStatus(entry)) {
OrchestratorLogger.logWarning(`[HotRunner] Runner ${id} has invalid state, marking as unhealthy`);
this.runners.set(id, {
id,
state: 'unhealthy',
unityVersion: typeof entry.unityVersion === 'string' ? entry.unityVersion : 'unknown',
platform: typeof entry.platform === 'string' ? entry.platform : 'unknown',
uptime: typeof entry.uptime === 'number' ? entry.uptime : 0,
jobsCompleted: typeof entry.jobsCompleted === 'number' ? entry.jobsCompleted : 0,
lastHealthCheck: typeof entry.lastHealthCheck === 'string' ? entry.lastHealthCheck : new Date().toISOString(),
memoryUsageMB: typeof entry.memoryUsageMB === 'number' ? entry.memoryUsageMB : 0,
});
repaired++;
}
}
if (repaired > 0) {
this.persist();
}
return repaired;
}
/**
* Persist current registry state to disk for crash recovery.
* Validates data before writing to prevent persisting corrupt state.
*/
private persist(): void {
if (!this.persistencePath) {
return;
}
try {
// Validate data before persisting
for (const [id, status] of this.runners) {
if (!isValidRunnerStatus(status)) {
OrchestratorLogger.logWarning(`[HotRunner] Skipping persistence -- runner ${id} has invalid state`);
return;
}
}
const data = {
runners: Object.fromEntries(this.runners),
configs: Object.fromEntries(this.configs),
};
const directory = path.dirname(this.persistencePath);
if (!fs.existsSync(directory)) {
fs.mkdirSync(directory, { recursive: true });
}
fs.writeFileSync(this.persistencePath, JSON.stringify(data, undefined, 2));
} catch (error: any) {
OrchestratorLogger.logWarning(`[HotRunner] Failed to persist registry: ${error.message}`);
}
}
/**
* Load registry state from disk. Returns the number of runners restored.
* Validates each restored entry and discards corrupt entries with warnings.
* If the persistence file itself is corrupt (invalid JSON), starts with
* an empty registry.
*/
loadFromDisk(): number {
if (!this.persistencePath || !fs.existsSync(this.persistencePath)) {
return 0;
}
let data: any;
try {
const raw = fs.readFileSync(this.persistencePath, 'utf8');
data = JSON.parse(raw);
} catch (error: any) {
OrchestratorLogger.logWarning(
`[HotRunner] Persistence file is corrupt, starting with empty registry: ${error.message}`,
);
return 0;
}
if (typeof data !== 'object' || data === null) {
OrchestratorLogger.logWarning('[HotRunner] Persistence file has invalid structure, starting with empty registry');
return 0;
}
let discarded = 0;
if (data.runners && typeof data.runners === 'object') {
for (const [id, status] of Object.entries(data.runners)) {
if (isValidRunnerStatus(status)) {
this.runners.set(id, status);
} else {
OrchestratorLogger.logWarning(`[HotRunner] Discarding invalid runner entry '${id}' from persistence file`);
discarded++;
}
}
}
if (data.configs && typeof data.configs === 'object') {
for (const [id, config] of Object.entries(data.configs)) {
// Only restore configs for runners that were successfully restored
if (this.runners.has(id)) {
if (isValidRunnerConfig(config)) {
this.configs.set(id, config);
} else {
OrchestratorLogger.logWarning(`[HotRunner] Discarding invalid config entry '${id}' from persistence file`);
}
}
}
}
if (discarded > 0) {
OrchestratorLogger.logWarning(`[HotRunner] Discarded ${discarded} invalid runner(s) from persistence file`);
}
OrchestratorLogger.log(`[HotRunner] Restored ${this.runners.size} runner(s) from disk`);
return this.runners.size;
}
}

View File

@@ -0,0 +1,166 @@
import OrchestratorLogger from '../core/orchestrator-logger';
import { HotRunnerRegistry } from './hot-runner-registry';
import { HotRunnerHealthMonitor } from './hot-runner-health-monitor';
import { HotRunnerDispatcher, OutputCallback } from './hot-runner-dispatcher';
import {
HotRunnerConfig,
HotRunnerJobRequest,
HotRunnerJobResult,
HotRunnerStatus,
HotRunnerTransport,
} from './hot-runner-types';
import BuildParameters from '../../../build-parameters';
export class HotRunnerService {
private registry: HotRunnerRegistry;
private healthMonitor: HotRunnerHealthMonitor;
private dispatcher: HotRunnerDispatcher;
private transports: Map<string, HotRunnerTransport> = new Map();
private config: HotRunnerConfig | undefined;
constructor(persistenceDirectory?: string) {
this.registry = new HotRunnerRegistry(persistenceDirectory);
this.healthMonitor = new HotRunnerHealthMonitor();
this.dispatcher = new HotRunnerDispatcher(this.transports);
}
/**
* Initialize the hot runner service: load persisted state, start health monitoring.
*/
async initialize(config: HotRunnerConfig): Promise<void> {
this.config = config;
OrchestratorLogger.log(
`[HotRunner] Initializing service (transport: ${config.transport}, ${config.host}:${config.port})`,
);
// Attempt to restore previously registered runners from disk
const restored = this.registry.loadFromDisk();
if (restored > 0) {
OrchestratorLogger.log(`[HotRunner] Restored ${restored} runner(s) from persistence`);
}
// Start health monitoring
this.healthMonitor.startMonitoring(this.registry, config.healthCheckInterval, this.transports);
OrchestratorLogger.log(`[HotRunner] Service initialized`);
}
/**
* Register a runner with a transport implementation.
* Returns the runner ID.
*/
registerRunner(config: HotRunnerConfig, transport: HotRunnerTransport): string {
const id = this.registry.registerRunner(config);
this.transports.set(id, transport);
return id;
}
/**
* Submit a build job to an available hot runner.
* Converts BuildParameters to a HotRunnerJobRequest and dispatches.
*/
async submitBuild(params: BuildParameters, onOutput?: OutputCallback): Promise<HotRunnerJobResult> {
const request: HotRunnerJobRequest = {
jobId: params.buildGuid || `build-${Date.now()}`,
buildMethod: params.buildMethod || undefined,
buildTarget: params.targetPlatform,
buildPath: params.buildPath,
customParameters: params.customParameters ? this.parseCustomParameters(params.customParameters) : undefined,
timeout: 30 * 60 * 1000, // 30 minutes default
};
OrchestratorLogger.log(`[HotRunner] Submitting build: ${request.jobId} (target: ${request.buildTarget})`);
return this.dispatcher.dispatchJob(request, this.registry, params.editorVersion, onOutput);
}
/**
* Submit a test job to an available hot runner.
* Converts BuildParameters and optional suite config to a test-mode HotRunnerJobRequest.
*/
async submitTest(
params: BuildParameters,
suiteConfig?: { testMode?: 'editmode' | 'playmode'; testSuitePath?: string },
onOutput?: OutputCallback,
): Promise<HotRunnerJobResult> {
const request: HotRunnerJobRequest = {
jobId: params.buildGuid || `test-${Date.now()}`,
buildTarget: params.targetPlatform,
customParameters: params.customParameters ? this.parseCustomParameters(params.customParameters) : undefined,
timeout: 30 * 60 * 1000, // 30 minutes default
testMode: suiteConfig?.testMode ?? 'editmode',
testSuitePath: suiteConfig?.testSuitePath,
};
OrchestratorLogger.log(`[HotRunner] Submitting test: ${request.jobId} (mode: ${request.testMode})`);
return this.dispatcher.dispatchJob(request, this.registry, params.editorVersion, onOutput);
}
/**
* Shut down the service: stop health monitoring, disconnect all transports,
* and unregister all runners.
*/
async shutdown(): Promise<void> {
OrchestratorLogger.log(`[HotRunner] Shutting down service`);
this.healthMonitor.stopMonitoring();
const disconnectPromises: Promise<void>[] = [];
for (const [id, transport] of this.transports.entries()) {
disconnectPromises.push(
transport.disconnect().catch((error: any) => {
OrchestratorLogger.logWarning(`[HotRunner] Error disconnecting runner ${id}: ${error.message}`);
}),
);
}
await Promise.all(disconnectPromises);
this.transports.clear();
OrchestratorLogger.log(`[HotRunner] Service shut down`);
}
/**
* Get the status of all registered runners.
*/
getStatus(): HotRunnerStatus[] {
return this.registry.listRunners();
}
/**
* Get the underlying registry (for testing or advanced use).
*/
getRegistry(): HotRunnerRegistry {
return this.registry;
}
/**
* Parse a space-separated custom parameters string into a key-value map.
* Handles `-key value` and `-key=value` formats.
*/
private parseCustomParameters(raw: string): Record<string, string> {
const result: Record<string, string> = {};
const parts = raw.trim().split(/\s+/);
for (let i = 0; i < parts.length; i++) {
const part = parts[i];
if (part.startsWith('-')) {
const key = part.replace(/^-+/, '');
if (key.includes('=')) {
const [k, ...v] = key.split('=');
result[k] = v.join('=');
} else if (i + 1 < parts.length && !parts[i + 1].startsWith('-')) {
result[key] = parts[i + 1];
i++;
} else {
result[key] = 'true';
}
}
}
return result;
}
}

View File

@@ -0,0 +1,54 @@
export interface HotRunnerConfig {
enabled: boolean;
transport: 'websocket' | 'grpc' | 'named-pipe';
host: string;
port: number;
healthCheckInterval: number; // seconds
maxIdleTime: number; // seconds before recycling
maxJobsBeforeRecycle: number;
unityVersion?: string;
platform?: string;
}
export interface HotRunnerStatus {
id: string;
state: 'idle' | 'busy' | 'starting' | 'stopping' | 'unhealthy';
unityVersion: string;
platform: string;
currentJob?: string;
lastJobCompleted?: string;
uptime: number;
jobsCompleted: number;
lastHealthCheck: string;
memoryUsageMB: number;
libraryHash?: string;
}
export interface HotRunnerJobRequest {
jobId: string;
buildMethod?: string;
buildTarget: string;
buildPath?: string;
customParameters?: Record<string, string>;
timeout: number;
testMode?: 'editmode' | 'playmode';
testSuitePath?: string;
}
export interface HotRunnerJobResult {
jobId: string;
success: boolean;
exitCode: number;
duration: number;
output: string;
artifacts?: string[];
testResults?: string; // path to test result file
}
export interface HotRunnerTransport {
connect(config: HotRunnerConfig): Promise<void>;
disconnect(): Promise<void>;
sendJob(request: HotRunnerJobRequest): Promise<HotRunnerJobResult>;
getStatus(): Promise<HotRunnerStatus>;
healthCheck(): Promise<boolean>;
}

View File

@@ -0,0 +1,740 @@
import fs from 'node:fs';
import { HotRunnerRegistry } from './hot-runner-registry';
import { HotRunnerHealthMonitor } from './hot-runner-health-monitor';
import { HotRunnerDispatcher } from './hot-runner-dispatcher';
import { HotRunnerService } from './hot-runner-service';
import {
HotRunnerConfig,
HotRunnerJobRequest,
HotRunnerJobResult,
HotRunnerStatus,
HotRunnerTransport,
} from './hot-runner-types';
// Mock dependencies
jest.mock('node:fs');
jest.mock('../core/orchestrator-logger');
const mockFs = fs as jest.Mocked<typeof fs>;
function createMockConfig(overrides?: Partial<HotRunnerConfig>): HotRunnerConfig {
return {
enabled: true,
transport: 'websocket',
host: 'localhost',
port: 9090,
healthCheckInterval: 30,
maxIdleTime: 3600,
maxJobsBeforeRecycle: 100,
unityVersion: '2022.3.0f1',
platform: 'StandaloneWindows64',
...overrides,
};
}
function createMockTransport(overrides?: Partial<HotRunnerTransport>): HotRunnerTransport {
return {
// eslint-disable-next-line unicorn/no-useless-undefined
connect: jest.fn().mockResolvedValue(undefined),
// eslint-disable-next-line unicorn/no-useless-undefined
disconnect: jest.fn().mockResolvedValue(undefined),
sendJob: jest.fn().mockResolvedValue({
jobId: 'test-job',
success: true,
exitCode: 0,
duration: 5000,
output: 'Build succeeded',
artifacts: ['build/output.exe'],
} as HotRunnerJobResult),
getStatus: jest.fn().mockResolvedValue({
id: 'mock-runner',
state: 'idle',
unityVersion: '2022.3.0f1',
platform: 'StandaloneWindows64',
uptime: 3600,
jobsCompleted: 5,
lastHealthCheck: new Date().toISOString(),
memoryUsageMB: 1024,
} as HotRunnerStatus),
healthCheck: jest.fn().mockResolvedValue(true),
...overrides,
};
}
function createMockJobRequest(overrides?: Partial<HotRunnerJobRequest>): HotRunnerJobRequest {
return {
jobId: 'job-001',
buildTarget: 'StandaloneWindows64',
timeout: 60000,
...overrides,
};
}
// --- Registry Tests ---
describe('HotRunnerRegistry', () => {
let registry: HotRunnerRegistry;
beforeEach(() => {
jest.clearAllMocks();
registry = new HotRunnerRegistry();
});
it('should register a runner and return an ID', () => {
const config = createMockConfig();
const id = registry.registerRunner(config);
expect(id).toMatch(/^hr-/);
expect(registry.size).toBe(1);
});
it('should retrieve a registered runner by ID', () => {
const config = createMockConfig();
const id = registry.registerRunner(config);
const runner = registry.getRunner(id);
expect(runner).toBeDefined();
expect(runner!.id).toBe(id);
expect(runner!.state).toBe('starting');
expect(runner!.unityVersion).toBe('2022.3.0f1');
expect(runner!.platform).toBe('StandaloneWindows64');
});
it('should return undefined for unknown runner ID', () => {
const runner = registry.getRunner('nonexistent');
expect(runner).toBeUndefined();
});
it('should unregister a runner', () => {
const id = registry.registerRunner(createMockConfig());
expect(registry.size).toBe(1);
registry.unregisterRunner(id);
expect(registry.size).toBe(0);
expect(registry.getRunner(id)).toBeUndefined();
});
it('should handle unregistering a nonexistent runner gracefully', () => {
registry.unregisterRunner('nonexistent');
expect(registry.size).toBe(0);
});
it('should list all runners without filter', () => {
registry.registerRunner(createMockConfig({ platform: 'StandaloneWindows64' }));
registry.registerRunner(createMockConfig({ platform: 'StandaloneLinux64' }));
registry.registerRunner(createMockConfig({ platform: 'StandaloneOSX' }));
const all = registry.listRunners();
expect(all).toHaveLength(3);
});
it('should filter runners by platform', () => {
registry.registerRunner(createMockConfig({ platform: 'StandaloneWindows64' }));
registry.registerRunner(createMockConfig({ platform: 'StandaloneLinux64' }));
registry.registerRunner(createMockConfig({ platform: 'StandaloneWindows64' }));
const windows = registry.listRunners({ platform: 'StandaloneWindows64' });
expect(windows).toHaveLength(2);
const linux = registry.listRunners({ platform: 'StandaloneLinux64' });
expect(linux).toHaveLength(1);
});
it('should filter runners by state', () => {
const id1 = registry.registerRunner(createMockConfig());
registry.registerRunner(createMockConfig());
registry.updateRunner(id1, { state: 'idle' });
// second runner remains in 'starting' state
const idle = registry.listRunners({ state: 'idle' });
expect(idle).toHaveLength(1);
expect(idle[0].id).toBe(id1);
});
it('should filter runners by Unity version', () => {
registry.registerRunner(createMockConfig({ unityVersion: '2022.3.0f1' }));
registry.registerRunner(createMockConfig({ unityVersion: '2023.1.0f1' }));
registry.registerRunner(createMockConfig({ unityVersion: '2022.3.0f1' }));
const v2022 = registry.listRunners({ unityVersion: '2022.3.0f1' });
expect(v2022).toHaveLength(2);
});
it('should find an available idle runner matching requirements', () => {
const id1 = registry.registerRunner(
createMockConfig({ unityVersion: '2022.3.0f1', platform: 'StandaloneWindows64' }),
);
registry.updateRunner(id1, { state: 'idle' });
const id2 = registry.registerRunner(
createMockConfig({ unityVersion: '2023.1.0f1', platform: 'StandaloneLinux64' }),
);
registry.updateRunner(id2, { state: 'idle' });
const found = registry.findAvailableRunner({
unityVersion: '2022.3.0f1',
platform: 'StandaloneWindows64',
});
expect(found).toBeDefined();
expect(found!.id).toBe(id1);
});
it('should return undefined when no runner matches requirements', () => {
const id = registry.registerRunner(
createMockConfig({ unityVersion: '2022.3.0f1', platform: 'StandaloneWindows64' }),
);
registry.updateRunner(id, { state: 'idle' });
const found = registry.findAvailableRunner({
unityVersion: '2023.1.0f1',
platform: 'StandaloneLinux64',
});
expect(found).toBeUndefined();
});
it('should update runner status fields', () => {
const id = registry.registerRunner(createMockConfig());
registry.updateRunner(id, { state: 'idle', memoryUsageMB: 2048 });
const runner = registry.getRunner(id);
expect(runner!.state).toBe('idle');
expect(runner!.memoryUsageMB).toBe(2048);
// ID should not be overridden by the update
expect(runner!.id).toBe(id);
});
it('should persist and load registry from disk', () => {
const persistenceRegistry = new HotRunnerRegistry('/tmp/test');
mockFs.existsSync.mockReturnValue(true);
mockFs.writeFileSync.mockImplementation(() => {});
mockFs.mkdirSync.mockImplementation(() => '' as any);
const id = persistenceRegistry.registerRunner(createMockConfig());
// Verify writeFileSync was called for persistence
expect(mockFs.writeFileSync).toHaveBeenCalled();
const writtenData = JSON.parse((mockFs.writeFileSync as jest.Mock).mock.calls[0][1] as string);
expect(writtenData.runners).toBeDefined();
expect(writtenData.runners[id]).toBeDefined();
});
it('should load runners from disk on loadFromDisk', () => {
const persistenceRegistry = new HotRunnerRegistry('/tmp/test');
const storedData = {
runners: {
'hr-restored': {
id: 'hr-restored',
state: 'idle',
unityVersion: '2022.3.0f1',
platform: 'StandaloneWindows64',
uptime: 100,
jobsCompleted: 3,
lastHealthCheck: new Date().toISOString(),
memoryUsageMB: 512,
},
},
configs: {
'hr-restored': createMockConfig(),
},
};
mockFs.existsSync.mockReturnValue(true);
mockFs.readFileSync.mockReturnValue(JSON.stringify(storedData));
const count = persistenceRegistry.loadFromDisk();
expect(count).toBe(1);
expect(persistenceRegistry.getRunner('hr-restored')).toBeDefined();
});
it('should discard invalid runner entries when loading from disk', () => {
const persistenceRegistry = new HotRunnerRegistry('/tmp/test');
const storedData = {
runners: {
'hr-valid': {
id: 'hr-valid',
state: 'idle',
unityVersion: '2022.3.0f1',
platform: 'StandaloneWindows64',
uptime: 100,
jobsCompleted: 3,
lastHealthCheck: new Date().toISOString(),
memoryUsageMB: 512,
},
'hr-invalid': {
// Missing required fields like state, unityVersion
id: 'hr-invalid',
},
'hr-bad-state': {
id: 'hr-bad-state',
state: 'nonexistent-state',
unityVersion: '2022.3.0f1',
platform: 'StandaloneWindows64',
uptime: 0,
jobsCompleted: 0,
lastHealthCheck: new Date().toISOString(),
memoryUsageMB: 0,
},
},
configs: {
'hr-valid': createMockConfig(),
'hr-invalid': createMockConfig(),
'hr-bad-state': createMockConfig(),
},
};
mockFs.existsSync.mockReturnValue(true);
mockFs.readFileSync.mockReturnValue(JSON.stringify(storedData));
const count = persistenceRegistry.loadFromDisk();
expect(count).toBe(1);
expect(persistenceRegistry.getRunner('hr-valid')).toBeDefined();
expect(persistenceRegistry.getRunner('hr-invalid')).toBeUndefined();
expect(persistenceRegistry.getRunner('hr-bad-state')).toBeUndefined();
});
it('should handle corrupt JSON persistence file gracefully', () => {
const persistenceRegistry = new HotRunnerRegistry('/tmp/test');
mockFs.existsSync.mockReturnValue(true);
mockFs.readFileSync.mockReturnValue('{ invalid json !!!');
const count = persistenceRegistry.loadFromDisk();
expect(count).toBe(0);
expect(persistenceRegistry.size).toBe(0);
});
it('should handle persistence file with invalid top-level structure', () => {
const persistenceRegistry = new HotRunnerRegistry('/tmp/test');
mockFs.existsSync.mockReturnValue(true);
mockFs.readFileSync.mockReturnValue('"just a string"');
const count = persistenceRegistry.loadFromDisk();
expect(count).toBe(0);
});
it('should handle persistence file with null runners', () => {
const persistenceRegistry = new HotRunnerRegistry('/tmp/test');
mockFs.existsSync.mockReturnValue(true);
mockFs.readFileSync.mockReturnValue('{"runners": null, "configs": null}');
const count = persistenceRegistry.loadFromDisk();
expect(count).toBe(0);
});
it('should validate and repair invalid runners', () => {
const persistenceRegistry = new HotRunnerRegistry('/tmp/test');
mockFs.existsSync.mockReturnValue(true);
mockFs.writeFileSync.mockImplementation(() => {});
mockFs.mkdirSync.mockImplementation(() => '' as any);
// Register a valid runner first
const id = persistenceRegistry.registerRunner(createMockConfig());
persistenceRegistry.updateRunner(id, { state: 'idle' });
// Manually corrupt the runner's state by setting an invalid state
// (we access via the public API -- updateRunner with a cast)
persistenceRegistry.updateRunner(id, { state: 'invalid-state' as any });
const repaired = persistenceRegistry.validateAndRepair();
expect(repaired).toBe(1);
const runner = persistenceRegistry.getRunner(id);
expect(runner!.state).toBe('unhealthy');
});
it('should not discard configs for valid runners when loading from disk', () => {
const persistenceRegistry = new HotRunnerRegistry('/tmp/test');
const storedData = {
runners: {
'hr-valid': {
id: 'hr-valid',
state: 'idle',
unityVersion: '2022.3.0f1',
platform: 'StandaloneWindows64',
uptime: 100,
jobsCompleted: 3,
lastHealthCheck: new Date().toISOString(),
memoryUsageMB: 512,
},
},
configs: {
'hr-valid': createMockConfig(),
},
};
mockFs.existsSync.mockReturnValue(true);
mockFs.readFileSync.mockReturnValue(JSON.stringify(storedData));
persistenceRegistry.loadFromDisk();
expect(persistenceRegistry.getConfig('hr-valid')).toBeDefined();
});
});
// --- Health Monitor Tests ---
describe('HotRunnerHealthMonitor', () => {
let monitor: HotRunnerHealthMonitor;
let registry: HotRunnerRegistry;
let transports: Map<string, HotRunnerTransport>;
beforeEach(() => {
jest.clearAllMocks();
jest.useFakeTimers();
monitor = new HotRunnerHealthMonitor();
registry = new HotRunnerRegistry();
transports = new Map();
});
afterEach(() => {
monitor.stopMonitoring();
jest.useRealTimers();
});
it('should start and stop monitoring', () => {
monitor.startMonitoring(registry, 30, transports);
expect(monitor.isMonitoring).toBe(true);
monitor.stopMonitoring();
expect(monitor.isMonitoring).toBe(false);
});
it('should report healthy when transport health check passes', async () => {
const id = registry.registerRunner(createMockConfig());
registry.updateRunner(id, { state: 'idle' });
const transport = createMockTransport();
transports.set(id, transport);
monitor.startMonitoring(registry, 30, transports);
const healthy = await monitor.checkHealth(id);
expect(healthy).toBe(true);
expect(transport.healthCheck).toHaveBeenCalled();
});
it('should mark runner as unhealthy when health check fails', async () => {
const id = registry.registerRunner(createMockConfig());
registry.updateRunner(id, { state: 'idle' });
const transport = createMockTransport({
healthCheck: jest.fn().mockResolvedValue(false),
});
transports.set(id, transport);
monitor.startMonitoring(registry, 30, transports);
const healthy = await monitor.checkHealth(id);
expect(healthy).toBe(false);
const runner = registry.getRunner(id);
expect(runner!.state).toBe('unhealthy');
});
it('should mark runner as unhealthy when health check throws', async () => {
const id = registry.registerRunner(createMockConfig());
registry.updateRunner(id, { state: 'idle' });
const transport = createMockTransport({
healthCheck: jest.fn().mockRejectedValue(new Error('Connection refused')),
});
transports.set(id, transport);
monitor.startMonitoring(registry, 30, transports);
const healthy = await monitor.checkHealth(id);
expect(healthy).toBe(false);
});
it('should recycle unhealthy runner and remove from registry', async () => {
const id = registry.registerRunner(createMockConfig());
const transport = createMockTransport();
transports.set(id, transport);
monitor.startMonitoring(registry, 30, transports);
await monitor.recycleUnhealthyRunner(id);
expect(registry.getRunner(id)).toBeUndefined();
expect(transport.disconnect).toHaveBeenCalled();
expect(transports.has(id)).toBe(false);
});
it('should recycle idle runner when max idle time exceeded', async () => {
const id = registry.registerRunner(createMockConfig({ maxIdleTime: 60 }));
// Set lastHealthCheck to 120 seconds ago
const oldDate = new Date(Date.now() - 120 * 1000).toISOString();
registry.updateRunner(id, { state: 'idle', lastHealthCheck: oldDate });
const transport = createMockTransport();
transports.set(id, transport);
monitor.startMonitoring(registry, 30, transports);
await monitor.recycleIdleRunner(id, 60);
expect(registry.getRunner(id)).toBeUndefined();
});
it('should not recycle idle runner when within max idle time', async () => {
const id = registry.registerRunner(createMockConfig({ maxIdleTime: 3600 }));
registry.updateRunner(id, {
state: 'idle',
lastHealthCheck: new Date().toISOString(),
});
const transport = createMockTransport();
transports.set(id, transport);
monitor.startMonitoring(registry, 30, transports);
await monitor.recycleIdleRunner(id, 3600);
// Runner should still exist
expect(registry.getRunner(id)).toBeDefined();
});
it('should return false when no transport exists for runner', async () => {
const id = registry.registerRunner(createMockConfig());
// Do not set any transport for this runner
monitor.startMonitoring(registry, 30, transports);
const healthy = await monitor.checkHealth(id);
expect(healthy).toBe(false);
});
});
// --- Dispatcher Tests ---
describe('HotRunnerDispatcher', () => {
let registry: HotRunnerRegistry;
let transports: Map<string, HotRunnerTransport>;
let dispatcher: HotRunnerDispatcher;
beforeEach(() => {
jest.clearAllMocks();
registry = new HotRunnerRegistry();
transports = new Map();
dispatcher = new HotRunnerDispatcher(transports);
});
it('should dispatch a job to an available runner', async () => {
const id = registry.registerRunner(createMockConfig());
registry.updateRunner(id, { state: 'idle' });
const transport = createMockTransport();
transports.set(id, transport);
const request = createMockJobRequest();
const result = await dispatcher.dispatchJob(request, registry, '2022.3.0f1');
expect(result.success).toBe(true);
expect(result.exitCode).toBe(0);
expect(transport.sendJob).toHaveBeenCalledWith(request);
});
it('should mark runner as busy during job execution', async () => {
const id = registry.registerRunner(createMockConfig());
registry.updateRunner(id, { state: 'idle' });
const statesDuringJob: string[] = [];
const transport = createMockTransport({
sendJob: jest.fn().mockImplementation(async () => {
const runner = registry.getRunner(id);
if (runner) statesDuringJob.push(runner.state);
return {
jobId: 'job-001',
success: true,
exitCode: 0,
duration: 1000,
output: 'ok',
};
}),
});
transports.set(id, transport);
await dispatcher.dispatchJob(createMockJobRequest(), registry, '2022.3.0f1');
expect(statesDuringJob).toContain('busy');
// After completion, should be idle again
const runner = registry.getRunner(id);
expect(runner!.state).toBe('idle');
});
it('should increment jobsCompleted after successful dispatch', async () => {
const id = registry.registerRunner(createMockConfig());
registry.updateRunner(id, { state: 'idle', jobsCompleted: 5 });
const transport = createMockTransport();
transports.set(id, transport);
await dispatcher.dispatchJob(createMockJobRequest(), registry, '2022.3.0f1');
const runner = registry.getRunner(id);
expect(runner!.jobsCompleted).toBe(6);
});
it('should throw when no runner is available and wait times out', async () => {
// No runners registered at all
const request = createMockJobRequest({ timeout: 100 });
await expect(dispatcher.dispatchJob(request, registry, '2022.3.0f1')).rejects.toThrow(/Timed out waiting/);
});
it('should throw when runner has no transport', async () => {
const id = registry.registerRunner(createMockConfig());
registry.updateRunner(id, { state: 'idle' });
// No transport set for this runner
const request = createMockJobRequest();
await expect(dispatcher.dispatchJob(request, registry, '2022.3.0f1')).rejects.toThrow(/No transport available/);
});
it('should handle job failure and return runner to idle', async () => {
const id = registry.registerRunner(createMockConfig());
registry.updateRunner(id, { state: 'idle' });
const transport = createMockTransport({
sendJob: jest.fn().mockRejectedValue(new Error('Unity crashed')),
});
transports.set(id, transport);
await expect(dispatcher.dispatchJob(createMockJobRequest(), registry, '2022.3.0f1')).rejects.toThrow(
'Unity crashed',
);
// Runner should be back to idle despite failure
const runner = registry.getRunner(id);
expect(runner!.state).toBe('idle');
});
it('should handle job timeout', async () => {
const id = registry.registerRunner(createMockConfig());
registry.updateRunner(id, { state: 'idle' });
const transport = createMockTransport({
sendJob: jest.fn().mockImplementation(
() => new Promise((resolve) => setTimeout(resolve, 60000)), // never resolves within timeout
),
});
transports.set(id, transport);
const request = createMockJobRequest({ timeout: 50 });
await expect(dispatcher.dispatchJob(request, registry, '2022.3.0f1')).rejects.toThrow(/timed out/);
});
it('should disconnect transport on job timeout', async () => {
const id = registry.registerRunner(createMockConfig());
registry.updateRunner(id, { state: 'idle' });
const transport = createMockTransport({
sendJob: jest.fn().mockImplementation(
() => new Promise((resolve) => setTimeout(resolve, 60000)), // never resolves within timeout
),
});
transports.set(id, transport);
const request = createMockJobRequest({ timeout: 50 });
await expect(dispatcher.dispatchJob(request, registry, '2022.3.0f1')).rejects.toThrow(/timed out/);
// Transport should have been disconnected to clean up orphaned connection
expect(transport.disconnect).toHaveBeenCalled();
});
it('should call output callback with job output', async () => {
const id = registry.registerRunner(createMockConfig());
registry.updateRunner(id, { state: 'idle' });
const transport = createMockTransport();
transports.set(id, transport);
const outputCallback = jest.fn();
await dispatcher.dispatchJob(createMockJobRequest(), registry, '2022.3.0f1', outputCallback);
expect(outputCallback).toHaveBeenCalledWith('Build succeeded');
});
it('should wait for runner to become available', async () => {
const id = registry.registerRunner(createMockConfig());
// Runner starts in 'starting' state, not idle
const transport = createMockTransport();
transports.set(id, transport);
// Simulate runner becoming idle after a short delay
setTimeout(() => {
registry.updateRunner(id, { state: 'idle' });
}, 50);
const request = createMockJobRequest({ timeout: 5000 });
const result = await dispatcher.dispatchJob(request, registry, '2022.3.0f1');
expect(result.success).toBe(true);
});
});
// --- Service Integration Tests ---
describe('HotRunnerService', () => {
let service: HotRunnerService;
beforeEach(() => {
jest.clearAllMocks();
mockFs.existsSync.mockReturnValue(false);
service = new HotRunnerService();
});
afterEach(async () => {
await service.shutdown();
});
it('should initialize and shut down cleanly', async () => {
const config = createMockConfig();
await service.initialize(config);
const status = service.getStatus();
expect(status).toEqual([]);
await service.shutdown();
});
it('should register a runner with transport', async () => {
await service.initialize(createMockConfig());
const transport = createMockTransport();
const id = service.registerRunner(createMockConfig(), transport);
expect(id).toMatch(/^hr-/);
expect(service.getStatus()).toHaveLength(1);
});
it('should disconnect all transports on shutdown', async () => {
await service.initialize(createMockConfig());
const transport1 = createMockTransport();
const transport2 = createMockTransport();
service.registerRunner(createMockConfig(), transport1);
service.registerRunner(createMockConfig(), transport2);
await service.shutdown();
expect(transport1.disconnect).toHaveBeenCalled();
expect(transport2.disconnect).toHaveBeenCalled();
});
it('should expose the underlying registry', async () => {
await service.initialize(createMockConfig());
const registry = service.getRegistry();
expect(registry).toBeInstanceOf(HotRunnerRegistry);
});
});

View File

@@ -0,0 +1,11 @@
export { HotRunnerService } from './hot-runner-service';
export { HotRunnerRegistry } from './hot-runner-registry';
export { HotRunnerHealthMonitor } from './hot-runner-health-monitor';
export { HotRunnerDispatcher } from './hot-runner-dispatcher';
export type {
HotRunnerConfig,
HotRunnerStatus,
HotRunnerJobRequest,
HotRunnerJobResult,
HotRunnerTransport,
} from './hot-runner-types';