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
+142 -21
View File
@@ -12,19 +12,30 @@ type IExecutionEnvironment = import('@design.estate/dees-catalog').IExecutionEnv
type IFileEntry = import('@design.estate/dees-catalog').IFileEntry;
type IFileWatcher = import('@design.estate/dees-catalog').IFileWatcher;
type IProcessHandle = import('@design.estate/dees-catalog').IProcessHandle;
type IWorkspaceShellCommand = interfaces.requests.IWorkspaceShellCommand;
const domtools = plugins.deesElement.domtools;
interface IWorkspaceProcessState {
outputController: ReadableStreamDefaultController<string>;
resolveExit: (exitCodeArg: number) => void;
}
export class BackendExecutionEnvironment implements IExecutionEnvironment {
readonly type = 'backend' as const;
private _ready = false;
private identity: interfaces.data.IIdentity;
private processRouter = new plugins.domtools.plugins.typedrequest.TypedRouter();
private processSocket: InstanceType<typeof plugins.typedsocket.TypedSocket> | null = null;
private processSocketPromise: Promise<InstanceType<typeof plugins.typedsocket.TypedSocket>> | null = null;
private processStates = new Map<string, IWorkspaceProcessState>();
constructor(
private serviceName: string,
identity: interfaces.data.IIdentity,
) {
this.identity = identity;
this.registerProcessSocketHandlers();
}
get ready(): boolean {
@@ -44,6 +55,12 @@ export class BackendExecutionEnvironment implements IExecutionEnvironment {
}
async destroy(): Promise<void> {
for (const processId of Array.from(this.processStates.keys())) {
await this.killProcess(processId).catch(() => {});
}
await this.processSocket?.stop().catch(() => {});
this.processSocket = null;
this.processSocketPromise = null;
this._ready = false;
}
@@ -103,38 +120,142 @@ export class BackendExecutionEnvironment implements IExecutionEnvironment {
}
async spawn(command: string, args?: string[]): Promise<IProcessHandle> {
// For interactive shell: execute the command via the workspace exec API
// and return a process handle that bridges stdin/stdout
const cmd = args ? [command, ...args] : [command];
const fullCommand = cmd.join(' ');
const socket = await this.ensureProcessSocket();
const processId = crypto.randomUUID();
await socket.setTag(`workspaceProcess:${processId}`, true);
// Use a non-interactive exec for now — full interactive shell would need
// TypedSocket bidirectional streaming (to be implemented)
const result = await this.fireRequest<interfaces.requests.IReq_WorkspaceExec>(
'workspaceExec',
{ command: cmd[0], args: cmd.slice(1) },
);
// Create a ReadableStream from the exec output
let resolveExit: (exitCodeArg: number) => void = () => {};
const exit = new Promise<number>((resolve) => {
resolveExit = resolve;
});
const output = new ReadableStream<string>({
start(controller) {
if (result.stdout) controller.enqueue(result.stdout);
if (result.stderr) controller.enqueue(result.stderr);
controller.close();
start: (controller) => {
this.processStates.set(processId, {
outputController: controller,
resolveExit,
});
},
cancel: async () => {
await this.killProcess(processId).catch(() => {});
},
});
// Create a writable stream (no-op for non-interactive)
const inputStream = new WritableStream<string>();
try {
await socket.createTypedRequest<interfaces.requests.IReq_WorkspaceStartProcess>(
'workspaceStartProcess',
).fire({
identity: this.identity,
serviceName: this.serviceName,
processId,
command,
args,
});
} catch (error) {
const processState = this.processStates.get(processId);
this.processStates.delete(processId);
await socket.removeTag(`workspaceProcess:${processId}`).catch(() => {});
try {
processState?.outputController.error(error);
} catch {
// The stream may already have been cancelled by the terminal.
}
throw error;
}
const input = new WritableStream<string>({
write: async (chunkArg) => {
await socket.createTypedRequest<interfaces.requests.IReq_WorkspaceProcessInput>(
'workspaceProcessInput',
).fire({
identity: this.identity,
processId,
input: chunkArg,
});
},
abort: async () => {
await this.killProcess(processId).catch(() => {});
},
});
return {
output,
input: inputStream,
exit: Promise.resolve(result.exitCode),
kill: () => {},
input,
exit,
kill: () => {
void this.killProcess(processId);
},
};
}
async getShellCommand(): Promise<IWorkspaceShellCommand> {
const result = await this.fireRequest<interfaces.requests.IReq_WorkspaceGetShellCommand>(
'workspaceGetShellCommand',
{},
);
return result.shellCommand;
}
private registerProcessSocketHandlers(): void {
this.processRouter.addTypedHandler(
new plugins.domtools.plugins.typedrequest.TypedHandler<interfaces.requests.IReq_PushWorkspaceProcessOutput>(
'pushWorkspaceProcessOutput',
async (dataArg: interfaces.requests.IReq_PushWorkspaceProcessOutput['request']) => {
this.processStates.get(dataArg.processId)?.outputController.enqueue(dataArg.output);
return {};
},
),
);
this.processRouter.addTypedHandler(
new plugins.domtools.plugins.typedrequest.TypedHandler<interfaces.requests.IReq_PushWorkspaceProcessExit>(
'pushWorkspaceProcessExit',
async (dataArg: interfaces.requests.IReq_PushWorkspaceProcessExit['request']) => {
this.completeProcessState(dataArg.processId, dataArg.exitCode);
await this.processSocket?.removeTag(`workspaceProcess:${dataArg.processId}`).catch(() => {});
return {};
},
),
);
}
private async ensureProcessSocket(): Promise<InstanceType<typeof plugins.typedsocket.TypedSocket>> {
if (this.processSocket) return this.processSocket;
if (!this.processSocketPromise) {
this.processSocketPromise = plugins.typedsocket.TypedSocket.createClient(
this.processRouter,
plugins.typedsocket.TypedSocket.useWindowLocationOriginUrl(),
{ autoReconnect: true },
);
}
this.processSocket = await this.processSocketPromise;
return this.processSocket;
}
private completeProcessState(processIdArg: string, exitCodeArg: number): void {
const processState = this.processStates.get(processIdArg);
if (!processState) return;
try {
processState.outputController.close();
} catch {
// The terminal may already have cancelled the stream.
}
processState.resolveExit(exitCodeArg);
this.processStates.delete(processIdArg);
}
private async killProcess(processIdArg: string): Promise<void> {
const socket = this.processSocket;
if (!socket) return;
await socket.createTypedRequest<interfaces.requests.IReq_WorkspaceKillProcess>(
'workspaceKillProcess',
).fire({
identity: this.identity,
processId: processIdArg,
}).catch(() => {});
this.completeProcessState(processIdArg, -1);
await socket.removeTag(`workspaceProcess:${processIdArg}`).catch(() => {});
}
/**
* Helper to fire TypedRequests to the workspace API
*/