feat(appstore,workspace): add App Store upgrade progress tracking and interactive workspace processes

This commit is contained in:
2026-05-25 06:24:29 +00:00
parent 3e68e875ac
commit d2c1bed82c
16 changed files with 1069 additions and 122 deletions
+216 -32
View File
@@ -3,15 +3,205 @@ import { logger } from '../../logging.ts';
import type { OpsServer } from '../classes.opsserver.ts';
import * as interfaces from '../../../ts_interfaces/index.ts';
import { requireAdminIdentity } from '../helpers/guards.ts';
import { getErrorMessage } from '../../utils/error.ts';
type IAppStoreUpgradeOperation = interfaces.requests.IAppStoreUpgradeOperation;
type TAppStoreUpgradeStep = interfaces.requests.TAppStoreUpgradeStep;
export class AppStoreHandler {
public typedrouter = new plugins.typedrequest.TypedRouter();
private upgradeOperations = new Map<string, IAppStoreUpgradeOperation>();
constructor(private opsServerRef: OpsServer) {
this.opsServerRef.typedrouter.addTypedRouter(this.typedrouter);
this.registerHandlers();
}
private getUpgradeOperations(): IAppStoreUpgradeOperation[] {
return Array.from(this.upgradeOperations.values())
.sort((a, b) => b.startedAt - a.startedAt)
.slice(0, 25);
}
private getRunningUpgrade(serviceNameArg: string): IAppStoreUpgradeOperation | null {
for (const operation of this.upgradeOperations.values()) {
if (operation.serviceName === serviceNameArg && operation.status === 'running') {
return operation;
}
}
return null;
}
private async createUpgradeOperation(
serviceNameArg: string,
targetVersionArg: string,
): Promise<IAppStoreUpgradeOperation> {
const existingRunning = this.getRunningUpgrade(serviceNameArg);
if (existingRunning) {
throw new plugins.typedrequest.TypedResponseError(
`An upgrade is already running for ${serviceNameArg}`,
);
}
const existingService = this.opsServerRef.oneboxRef.database.getServiceByName(serviceNameArg);
if (!existingService) {
throw new plugins.typedrequest.TypedResponseError(`Service not found: ${serviceNameArg}`);
}
if (!existingService.appTemplateId) {
throw new plugins.typedrequest.TypedResponseError('Service was not deployed from an app template');
}
if (!existingService.appTemplateVersion) {
throw new plugins.typedrequest.TypedResponseError('Service has no tracked template version');
}
const now = Date.now();
const operation: IAppStoreUpgradeOperation = {
id: crypto.randomUUID(),
serviceName: existingService.name,
appTemplateId: existingService.appTemplateId,
fromVersion: existingService.appTemplateVersion,
targetVersion: targetVersionArg,
status: 'running',
step: 'queued',
progressLines: [`Queued upgrade ${existingService.appTemplateVersion} -> ${targetVersionArg}`],
warnings: [],
startedAt: now,
updatedAt: now,
};
this.upgradeOperations.set(operation.id, operation);
await this.pushUpgradeProgress(operation);
return operation;
}
private async updateUpgradeOperation(
operationIdArg: string,
stepArg: TAppStoreUpgradeStep,
messageArg: string,
updatesArg: Partial<IAppStoreUpgradeOperation> = {},
): Promise<IAppStoreUpgradeOperation> {
const existing = this.upgradeOperations.get(operationIdArg);
if (!existing) {
throw new Error(`Upgrade operation not found: ${operationIdArg}`);
}
const nextOperation: IAppStoreUpgradeOperation = {
...existing,
...updatesArg,
step: stepArg,
updatedAt: Date.now(),
progressLines: [...existing.progressLines, messageArg].slice(-200),
};
this.upgradeOperations.set(operationIdArg, nextOperation);
await this.pushUpgradeProgress(nextOperation);
return nextOperation;
}
private async pushUpgradeProgress(operationArg: IAppStoreUpgradeOperation): Promise<void> {
await this.opsServerRef.pushDashboardEvent('pushAppStoreUpgradeProgress', {
operation: operationArg,
});
}
private async performUpgrade(operationIdArg: string): Promise<interfaces.data.IService> {
let operation = this.upgradeOperations.get(operationIdArg);
if (!operation) {
throw new Error(`Upgrade operation not found: ${operationIdArg}`);
}
try {
operation = await this.updateUpgradeOperation(
operation.id,
'validating',
`Validating ${operation.serviceName} for App Store upgrade`,
);
const existingService = this.opsServerRef.oneboxRef.database.getServiceByName(operation.serviceName);
if (!existingService) {
throw new Error(`Service not found: ${operation.serviceName}`);
}
if (!existingService.appTemplateId || !existingService.appTemplateVersion) {
throw new Error('Service is missing App Store template metadata');
}
logger.info(
`Upgrading service '${operation.serviceName}' from v${operation.fromVersion} to v${operation.targetVersion}`,
);
await this.updateUpgradeOperation(
operation.id,
'migration',
`Resolving migration for ${operation.appTemplateId} ${operation.fromVersion} -> ${operation.targetVersion}`,
);
const migrationResult = await this.opsServerRef.oneboxRef.appStore.executeMigration(
existingService,
operation.fromVersion,
operation.targetVersion,
);
if (!migrationResult.success) {
throw new Error(`Migration failed: ${migrationResult.warnings.join('; ')}`);
}
if (migrationResult.warnings.length > 0) {
operation = await this.updateUpgradeOperation(
operation.id,
'migration',
`Migration completed with ${migrationResult.warnings.length} warning(s)`,
{ warnings: migrationResult.warnings },
);
}
await this.updateUpgradeOperation(
operation.id,
'applying',
`Applying upgrade to ${operation.serviceName}`,
);
const updatedService = await this.opsServerRef.oneboxRef.appStore.applyUpgrade(
operation.serviceName,
migrationResult,
operation.targetVersion,
{
onProgress: async (progressArg) => {
await this.updateUpgradeOperation(
operation!.id,
progressArg.step as TAppStoreUpgradeStep,
progressArg.message,
);
},
},
);
await this.updateUpgradeOperation(
operation.id,
'complete',
`Upgrade completed for ${operation.serviceName}`,
{
status: 'success',
completedAt: Date.now(),
service: updatedService,
warnings: migrationResult.warnings,
},
);
return updatedService;
} catch (error) {
await this.updateUpgradeOperation(
operation.id,
'failed',
`Upgrade failed: ${getErrorMessage(error)}`,
{
status: 'failed',
completedAt: Date.now(),
error: getErrorMessage(error),
},
);
throw error;
}
}
private registerHandlers(): void {
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetAppStoreTemplates>(
@@ -66,44 +256,38 @@ export class AppStoreHandler {
'upgradeAppStoreService',
async (dataArg) => {
await requireAdminIdentity(this.opsServerRef.adminHandler, dataArg);
const existingService = this.opsServerRef.oneboxRef.database.getServiceByName(dataArg.serviceName);
if (!existingService) {
throw new plugins.typedrequest.TypedResponseError(`Service not found: ${dataArg.serviceName}`);
}
if (!existingService.appTemplateId) {
throw new plugins.typedrequest.TypedResponseError('Service was not deployed from an app template');
}
if (!existingService.appTemplateVersion) {
throw new plugins.typedrequest.TypedResponseError('Service has no tracked template version');
}
logger.info(`Upgrading service '${dataArg.serviceName}' from v${existingService.appTemplateVersion} to v${dataArg.targetVersion}`);
const migrationResult = await this.opsServerRef.oneboxRef.appStore.executeMigration(
existingService,
existingService.appTemplateVersion,
dataArg.targetVersion,
);
if (!migrationResult.success) {
throw new plugins.typedrequest.TypedResponseError(
`Migration failed: ${migrationResult.warnings.join('; ')}`,
);
}
const updatedService = await this.opsServerRef.oneboxRef.appStore.applyUpgrade(
dataArg.serviceName,
migrationResult,
dataArg.targetVersion,
);
const operation = await this.createUpgradeOperation(dataArg.serviceName, dataArg.targetVersion);
const updatedService = await this.performUpgrade(operation.id);
const completedOperation = this.upgradeOperations.get(operation.id)!;
return {
service: updatedService,
warnings: migrationResult.warnings,
warnings: completedOperation.warnings,
};
},
),
);
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_StartAppStoreServiceUpgrade>(
'startAppStoreServiceUpgrade',
async (dataArg) => {
await requireAdminIdentity(this.opsServerRef.adminHandler, dataArg);
const operation = await this.createUpgradeOperation(dataArg.serviceName, dataArg.targetVersion);
void this.performUpgrade(operation.id).catch(() => {});
return { operation };
},
),
);
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetAppStoreUpgradeOperations>(
'getAppStoreUpgradeOperations',
async (dataArg) => {
await requireAdminIdentity(this.opsServerRef.adminHandler, dataArg);
return { operations: this.getUpgradeOperations() };
},
),
);
}
}