feat(appstore,workspace): add App Store upgrade progress tracking and interactive workspace processes
This commit is contained in:
@@ -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
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user