@struktoai/mirage-core
Version:
A Unified Virtual Filesystem For AI Agents
228 lines • 8.96 kB
JavaScript
// ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
// ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
import { DevicePipe } from "../../core/dev/pipe.js";
import { PipeClosed } from "../../io/errors.js";
import { materialize } from "../../io/types.js";
import { Channel, JobConsole, Terminal } from "../../shell/console/index.js";
import { getProcessSubBody, getProcessSubDirection } from "../../shell/helpers.js";
import { ProcessSubDirection } from "../../shell/types.js";
import { runCancellable, makeAbortError, mergeSignals } from "../../utils/abort.js";
import { concat } from "../../utils/bytes.js";
import { DevVFS } from "../../vfs/dev/dev.js";
class SubstitutionConsole extends JobConsole {
endpoint;
target;
abort;
constructor(endpoint, target, abort) {
super();
this.endpoint = endpoint;
this.target = target;
this.abort = abort;
}
async emit(channel, data) {
if (this.abort.signal.aborted)
return;
if (channel === Channel.STDOUT && this.endpoint.readable) {
try {
await this.endpoint.pipe.write(data);
}
catch (error) {
if (!(error instanceof PipeClosed))
throw error;
this.abort.abort();
throw makeAbortError(this.abort.signal);
}
}
else
await this.target.emit(channel, data);
}
}
/**
* Close the streams readers opened on a pipe that ends with its command: a
* later read finds the end of input either way, and nothing is left open.
* Mirrors Python's _close_readers.
*/
async function closeReaders(endpoint) {
const readers = [...endpoint.readers];
endpoint.readers.clear();
for (const reader of readers)
await reader.deref()?.close();
}
/** Own the virtual descriptors and auxiliary children of one command node. */
export class ProcessSubScope {
context;
registry;
sink;
signal;
held = [];
capture = new Terminal();
closing;
constructor(context, registry, sink, signal) {
this.context = context;
this.registry = registry;
this.sink = sink;
this.signal = signal;
}
open = (node, executeLine) => {
const [root] = this.registry.resolve('/dev/null');
if (!(root instanceof DevVFS))
throw new Error('missing device filesystem');
const dev = root.forSession(this.context.session.sessionId);
const endpoint = new DevicePipe(getProcessSubDirection(node) !== ProcessSubDirection.OUTPUT);
const [path, allocation] = dev.allocateInput(endpoint);
const abort = new AbortController();
const signal = mergeSignals(this.signal ?? this.context.frame.abortSignal ?? undefined, abort.signal) ??
abort.signal;
const child = {
dev,
path,
allocation,
endpoint,
abort,
signal,
task: Promise.resolve(),
};
const output = new SubstitutionConsole(endpoint, this.sink ?? this.capture, abort);
// The line starts once the command naming it has, as bash's forked child
// does and Python's task does: a consumer that reads nothing must not
// close its pipe before the command's first write lands.
child.task = new Promise((resolve) => setTimeout(resolve, 0))
.then(() => executeLine(getProcessSubBody(node), {
sink: output,
signal,
...(endpoint.readable ? {} : { stdin: endpoint.pipe.stream() }),
}))
.then(async (io) => {
await output.emit(Channel.STDOUT, await materialize(io.stdout));
await output.emit(Channel.STDERR, await materialize(io.stderr));
})
.catch((error) => {
// Closing the command's reader intentionally interrupts its producer.
if (signal.aborted && error instanceof Error && error.name === 'AbortError')
return;
if (error instanceof PipeClosed && endpoint.pipe.closedReader)
return;
child.failure = error instanceof Error ? error : new Error(String(error), { cause: error });
if (signal.aborted)
console.error('process substitution failed after cancellation', child.failure);
})
.finally(() => {
if (endpoint.readable)
endpoint.pipe.end(child.failure);
else
endpoint.pipe.closeReader();
});
this.held.push(child);
return Promise.resolve(path);
};
close(failed = false) {
this.closing ??= this.finish(failed);
return this.closing;
}
async finish(failed) {
const held = this.heldInputs();
const kept = failed ? new Map() : held;
for (const child of this.held) {
if (kept.has(child.path))
continue;
if (failed || child.endpoint.readable) {
child.endpoint.pipe.closeReader();
child.abort.abort();
}
else
child.endpoint.pipe.end();
}
try {
await Promise.all([
...[...kept.values()].flat().map((lines) => lines.loadRest()),
...this.held.map(async (child) => {
try {
await runCancellable(child.task, child.signal);
}
catch (error) {
// A join the caller cut short leaves the child to settle on its
// own; its late output must not reach a console the caller left.
child.abort.abort();
if (!child.signal.aborted)
throw error;
}
if (child.failure !== undefined)
throw child.failure;
}),
]);
}
finally {
for (const child of this.held) {
await closeReaders(child.endpoint);
child.dev.releaseInput(child.path, child.allocation);
}
}
}
/**
* This scope's input pipes that a shell descriptor still reads after the
* command (`exec 3<"$1"`, or a dup), with the line buffers reading each,
* which load the rest. The descriptors stop naming them, as the paths are
* free once this command ends. Mirrors Python's _held_inputs.
*/
heldInputs() {
const inputs = new Set(this.held.filter((c) => c.endpoint.readable).map((c) => c.path));
const held = new Map();
for (const descriptor of this.context.session.descriptors.values()) {
const source = descriptor.source;
if (source?.pipe == null || !inputs.has(source.pipe.virtual))
continue;
const readers = held.get(source.pipe.virtual) ?? [];
if (!readers.includes(source.lines))
readers.push(source.lines);
held.set(source.pipe.virtual, readers);
source.pipe = null;
}
return held;
}
/**
* End the substitutions as a finished command does, for a node `exit`,
* `return` or `break` leaves, and return what they wrote to the capture,
* stdout then stderr. Mirrors Python's settle.
*/
async settle() {
await this.close();
return this.capture.take();
}
own(stdout, io, node) {
return this.held.length === 0 ? stdout : this.output(stdout, io, node);
}
async *output(stdout, io, node) {
let completed = false;
try {
if (stdout instanceof Uint8Array) {
if (stdout.byteLength > 0)
yield stdout;
}
else if (stdout !== null)
yield* stdout;
completed = true;
}
finally {
await this.close(!completed);
const [out, err] = this.capture.take();
if (err.byteLength > 0) {
io.stderr = concat([await materialize(io.stderr), err]);
node.stderr = io.stderr;
}
if (completed && out.byteLength > 0)
yield out;
}
}
}
//# sourceMappingURL=process_sub.js.map