import { randomUUID } from 'node:crypto'; import { EventEmitter } from 'node:events'; import { resolve } from 'node:path'; import { prepareProjectRelease, ProjectReleaseBuildError, type PreparedRelease, type ReleaseBuildProgress, type ReleaseBuildRuntimeContext, } from './project-release-builder'; type ReleaseJobPackageSummary = Omit; export type ReleaseJobState = 'queued' | 'running' | 'complete' | 'failed' | 'cancelled'; export type ReleaseJobStatus = { jobId: string; projectId: string; state: ReleaseJobState; phase: ReleaseBuildProgress['phase'] | 'queued'; percent: number; errorCode?: string; errorMessage?: string; result?: { package: ReleaseJobPackageSummary; contract: PreparedRelease['contract']; }; }; /** * A utility-process build only needs to return the immutable package summary * and contract to Main. Archive bytes stay in the worker and are discarded * before it exits, so a release status request cannot duplicate a large build * buffer in the browser process. */ export type ReleaseJobPreparation = { sourceArchive: Pick; contract: PreparedRelease['contract']; dispose(): Promise; }; export type ReleaseJobPrepare = (input: { projectPath: string; clientVersion: string; signal?: AbortSignal; onProgress?: (progress: ReleaseBuildProgress) => void; runtimeContext?: ReleaseBuildRuntimeContext; }) => Promise; type ReleaseJobRecord = ReleaseJobStatus & { controller: AbortController; projectPath: string; projectKey: string; }; export type ReleaseJobManagerOptions = { prepare?: ReleaseJobPrepare; acquireLease?: (lease: { id: string; kind: string }) => () => void; dispose?: () => void; }; /** * Owns the asynchronous local release build contract. The manager is kept * independent of HTTP so Main can expose start/progress/status/cancel over * either Host API or IPC without putting project state in the Renderer. */ export class ReleaseJobManager extends EventEmitter { private readonly jobs = new Map(); private readonly activeProjects = new Map(); private readonly prepare: ReleaseJobPrepare; constructor(private readonly options: ReleaseJobManagerOptions = {}) { super(); this.prepare = options.prepare ?? prepareProjectRelease; } start(input: { projectId: string; projectPath: string; clientVersion: string }): ReleaseJobStatus { const projectKey = resolve(input.projectPath); if (this.activeProjects.has(projectKey)) { throw new ProjectReleaseBuildError('LOCAL_BUILD_BUSY'); } const jobId = randomUUID(); const record: ReleaseJobRecord = { jobId, projectId: input.projectId, projectPath: input.projectPath, projectKey, state: 'queued', phase: 'queued', percent: 0, controller: new AbortController(), }; this.jobs.set(jobId, record); this.activeProjects.set(projectKey, jobId); void this.run(record, input.clientVersion); return this.snapshot(record); } get(jobId: string): ReleaseJobStatus | null { const record = this.jobs.get(jobId); return record ? this.snapshot(record) : null; } cancel(jobId: string): ReleaseJobStatus | null { const record = this.jobs.get(jobId); if (!record) return null; if (record.state === 'queued' || record.state === 'running') { record.controller.abort(); this.update(record, { state: 'cancelled', errorCode: 'LOCAL_BUILD_CANCELLED', errorMessage: '构建已取消。', }); } return this.snapshot(record); } dispose(): void { for (const record of this.jobs.values()) record.controller.abort(); this.jobs.clear(); this.activeProjects.clear(); this.options.dispose?.(); this.removeAllListeners(); } private async run(record: ReleaseJobRecord, clientVersion: string): Promise { const releaseLease = this.options.acquireLease?.({ id: `release-job:${record.jobId}`, kind: 'release-build' }); let prepared: PreparedRelease | null = null; this.update(record, { state: 'running', phase: 'packaging', percent: 1 }); try { prepared = await this.prepare({ projectPath: record.projectPath, clientVersion, signal: record.controller.signal, onProgress: (progress) => this.update(record, { state: record.controller.signal.aborted ? 'cancelled' : 'running', phase: progress.phase, percent: progress.percent, }), }); if (record.controller.signal.aborted) { this.update(record, { state: 'cancelled', errorCode: 'LOCAL_BUILD_CANCELLED', errorMessage: '构建已取消。', }); return; } this.update(record, { state: 'complete', phase: 'complete', percent: 100, result: { package: (() => { const { archivePath: _archivePath, ...summary } = prepared.sourceArchive.summary; return summary; })(), contract: prepared.contract, }, }); } catch (error) { const code = error instanceof ProjectReleaseBuildError ? error.code : 'LOCAL_BUILD_FAILED'; const cancelled = code === 'LOCAL_BUILD_CANCELLED' || record.controller.signal.aborted; this.update(record, { state: cancelled ? 'cancelled' : 'failed', errorCode: cancelled ? 'LOCAL_BUILD_CANCELLED' : code, errorMessage: cancelled ? '构建已取消。' : (error instanceof Error ? error.message : String(error)), }); } finally { releaseLease?.(); await prepared?.dispose().catch(() => undefined); if (this.activeProjects.get(record.projectKey) === record.jobId) { this.activeProjects.delete(record.projectKey); } } } private update(record: ReleaseJobRecord, patch: Partial): void { Object.assign(record, patch); const status = this.snapshot(record); this.emit('status', status); this.emit(`status:${record.jobId}`, status); } private snapshot(record: ReleaseJobRecord): ReleaseJobStatus { const { controller: _controller, projectKey: _projectKey, projectPath: _projectPath, ...status } = record; return structuredClone(status); } }