UNPKG

@struktoai/mirage-core

Version:

A Unified Virtual Filesystem For AI Agents

514 lines • 21.4 kB
// ========= 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 { currentCaller } from "../../../context/session_context.js"; import { classify } from "../../../errors/classify.js"; import { enoent, isWalkError, strerrorOf } from "../../../errors/fs.js"; import { fsErrorLine } from "../../../errors/render.js"; import { chunks } from "../../../io/cooperative.js"; import { ByteSpool, spooledPair } from "../../../io/spool.js"; import { drain } from "../../../io/stream.js"; import { ByteWriter } from "../../../io/writer.js"; import { openStream, serveRequests } from "../../../rs/stream.js"; import { byteView, textView } from "../../../shell/bytes.js"; import { shellJoin } from "../../../shell/join.js"; import { FileType, PathSpec } from "../../../types.js"; import { quoteText } from "../../quote.js"; import { callerStream } from "../generic_bind/dispatch.js"; import { dispatchOf } from "./links.js"; import { isStdin, resolveSource } from "./stream.js"; const STDIN_NAMES = new Set(['-', '/dev/stdin']); async function* spooled(spool) { try { yield* spool.stream(); } finally { await spool.close(); } } async function close(source) { await source.return?.(); } /** A failed request's reply: the failure's words and the condition it names, when the vocabulary names one. */ function failure(err) { const errno = classify(err); const error = byteView(strerrorOf(err)); return errno === null ? { error } : { error, errno }; } /** * The files and commands a mirage-rs stream reaches, through the workspace. * An input opens on its first read. An operand still holding its * command-line spelling reads through the mount's own reader, the way it * was resolved, unless another mount serves it; every other name reads * through the dispatcher, as writes go through it. Every stdin reader (`-`, * `/dev/stdin` and a command run without input of its own) shares one * cursor, so none replays what another read. A write is held until the * stream flushes it. `dash`: an operand `-` names stdin (see `isStdin`); * `command`: the command a failed operand's line names, worded as * `fsErrorLine` words it, and that may create no file one of its operands * names, empty leaving both to the stream; * `local`: whether the mount serves an operand, every one by default, as it * resolved them. Mirrors Python's StreamHost. */ export class StreamHost { operands; stream; ctx; dash; command; local; errors = new ByteSpool(); stdin; caller = currentCaller(); inputs = new Map(); named = new Map(); spools = new Map(); outputs = new Map(); constructor(operands, stream, ctx, dash = true, command = '', local = () => true) { this.operands = operands; this.stream = stream; this.ctx = ctx; this.dash = dash; this.command = command; this.local = local; this.stdin = resolveSource(ctx.stdin)[Symbol.asyncIterator](); } async *stdinView() { for (;;) { const next = await this.stdin.next(); if (next.done === true) return; yield next.value; } } /** Read a file; `local`: an operand this mount's backend serves. */ async *readPath(name, local = false) { const dispatch = dispatchOf(this.ctx); if (dispatch === undefined && !local) throw enoent(name); const path = PathSpec.fromStrPath(name, undefined, this.ctx.cwd); yield* local || dispatch === undefined ? this.stream(path) : callerStream(dispatch, this.caller, path); } /** * Open an input by name: `-` or `/dev/stdin` is stdin; `operand` is the * operand, from 1, the name came from; `file` is a file a script names, * which no spelling of stdin stands for; `asOperand` is a name standing in * for an operand (unzip's `X.zip` for `X`), read as an operand is. */ openInput(name, operand, file = false, asOperand = false) { if (file) return this.readPath(name); const path = this.operand(name, operand) ?? (asOperand ? PathSpec.fromStrPath(name, undefined, this.ctx.cwd) : undefined); if (path !== undefined) { if (isStdin(path, this.dash)) return this.stdinView(); return this.readPath(path, this.local(path)); } if (STDIN_NAMES.has(name)) return this.stdinView(); return this.readPath(name); } /** The operand a name is, when it still holds its spelling. */ operand(name, operand) { if (operand === null || operand <= 0 || operand > this.operands.length) return undefined; const path = this.operands[operand - 1]; return path?.rawPath === name ? path : undefined; } /** Commit one file's writes, or every file's; the first failure stops it, and its reply, naming the file, is returned. */ async flushOutput(name) { const names = name === undefined ? [...this.outputs.keys()] : [name]; for (const key of names) { const writer = this.outputs.get(key); this.outputs.delete(key); if (writer === undefined) continue; try { await writer.finish(); } catch (error) { if (!isWalkError(error)) throw error; return { ...failure(error), path: key }; } } return null; } /** Do one request a stream makes. */ async serve(request, data) { const fd = typeof request.fd === 'number' ? request.fd : null; switch (request.op) { case 'read': if (fd !== null) return this.read(fd, request); break; case 'close': { const source = fd === null ? undefined : this.inputs.get(fd); const spool = fd === null ? undefined : this.spools.get(fd); if (fd !== null) { this.inputs.delete(fd); this.spools.delete(fd); } if (source !== undefined) await close(source); if (spool !== undefined) await spool.close(); return { reply: {} }; } case 'write': return this.write(request, data); case 'unlink': case 'mkdir': case 'stat': return this.pathOp(request.op, typeof request.path === 'string' ? request.path : ''); case 'fill': if (typeof request.into === 'number') return this.fill(request.into, request); break; case 'flush': { const failed = await this.flushOutput(typeof request.path === 'string' ? request.path : undefined); return { reply: failed ?? {} }; } case 'spool': if (fd !== null) { let spool = this.spools.get(fd); if (spool === undefined) { spool = new ByteSpool(); this.spools.set(fd, spool); } await spool.append(data); return { reply: {} }; } break; case 'emit': if (fd !== null && typeof request.skip === 'number' && typeof request.size === 'number') { const held = this.spools.get(fd); return { reply: {}, output: held?.stream(request.skip, request.size) ?? null }; } if (fd !== null) { const spool = this.spools.get(fd); this.spools.delete(fd); return { reply: {}, output: spool === undefined ? null : spooled(spool) }; } break; case 'run': return this.run(request); } throw new Error(`unknown stream request: ${JSON.stringify(request.op)}`); } /** * The input a read or a fill opens: an operand, a name, or a spool past its * first `skip` bytes, where nothing spooled reads as empty. */ open(fd, request) { if (typeof request.spool === 'number') { const skip = typeof request.skip === 'number' ? request.skip : 0; return this.spools.get(request.spool)?.stream(skip); } if (typeof request.path !== 'string') return undefined; const operand = typeof request.operand === 'number' ? request.operand : null; const name = textView(request.path); const named = this.command === '' ? undefined : this.operand(name, operand); if (named !== undefined) this.named.set(fd, named); // Each pull loses to the abort, and a producer left mid-pull is closed // without a wait. const opened = this.openInput(name, operand, request.file === true, request.as_operand === true); return chunks(opened, this.ctx.signal, false); } /** * A failed input's reply, or a throw for what is not the file's own: the * failure's words, and the line the command words for an operand. */ failed(fd, err) { if (classify(err) === null) throw err; const reply = failure(err); const named = this.named.get(fd); this.named.delete(fd); if (named !== undefined) reply.line = byteView(fsErrorLine(this.command, named, err)); return { reply }; } /** Read an input whole into spool `into` and answer its length. */ async fill(into, request) { let spool = this.spools.get(into); if (spool === undefined) { spool = new ByteSpool(); this.spools.set(into, spool); } const source = this.open(into, request); try { if (source !== undefined) await spool.fill(source); } catch (err) { return this.failed(into, err); } this.named.delete(into); return { reply: { size: spool.size } }; } async read(fd, request) { if (typeof request.spool === 'number' && typeof request.size === 'number') { // A window comes whole, and nothing stays open. const held = this.spools.get(request.spool); const skip = typeof request.skip === 'number' ? request.skip : 0; if (held === undefined || skip >= held.size) return { reply: {} }; return { reply: {}, data: await held.read(skip, Math.min(request.size, held.size - skip)) }; } let source = this.inputs.get(fd); if (source === undefined) { source = this.open(fd, request); if (source !== undefined) this.inputs.set(fd, source); } if (source === undefined) return { reply: { end: true } }; let next; const parts = []; try { next = await source.next(); // A file read whole gathers chunks to `least` bytes a reply, the last // of them sent with the end of the input. const least = typeof request.least === 'number' ? request.least : 0; for (let size = 0; next.done !== true; next = await source.next()) { parts.push(next.value); size += next.value.byteLength; if (size >= least) break; } } catch (err) { this.inputs.delete(fd); return this.failed(fd, err); } const reply = next.done === true ? { end: true } : {}; if (next.done === true) { this.inputs.delete(fd); this.named.delete(fd); } if (parts.length === 0) return { reply }; const [only, ...more] = parts; return { reply, data: only !== undefined && more.length === 0 ? only : parts }; } /** * Write to a file: `append` adds to it, otherwise it is emptied first, * every held write committed before; `atomic` replaces it whole, once * flushed, with what this and later writes carry, and `create` writes it * whole as they carry it, `whole` from this write's bytes alone; `spool` * writes that spool's bytes past its first `skip` and drops it, or, with * `size`, that many of them and keeps it, with `create` the file whole * from them. */ async write(request, data) { const dispatch = dispatchOf(this.ctx); if (dispatch === undefined) return { reply: { fatal: 'file output requires a workspace' } }; const name = typeof request.path === 'string' ? request.path : ''; const path = PathSpec.fromStrPath(textView(name), undefined, this.ctx.cwd); // GNU split refuses an output that is its input before it empties it; // csplit is refused alike, where GNU's empties its input. if (request.create === true && this.command !== '') { if (this.operands.some((spec) => spec.virtual === path.virtual)) { const line = `'${quoteText(textView(name))}' would overwrite input`; const refused = byteView(`${this.command}: ${line}; aborting\n`); return { reply: { error: refused, line: refused } }; } } if (request.whole === true) { try { await dispatch('write', path, [data]); } catch (error) { if (!isWalkError(error)) throw error; return { reply: failure(error) }; } return { reply: {} }; } const skip = typeof request.skip === 'number' ? request.skip : 0; const size = typeof request.size === 'number' ? request.size : undefined; if (request.create === true && typeof request.spool === 'number' && size !== undefined) { // A window of a spool is the whole file, written as one call. const held = this.spools.get(request.spool); try { await dispatch('write', path, [held?.stream(skip, size) ?? new Uint8Array()]); } catch (error) { if (!isWalkError(error)) throw error; return { reply: failure(error) }; } return { reply: {} }; } const spool = typeof request.spool === 'number' ? this.spools.get(request.spool) : undefined; if (typeof request.spool === 'number' && size === undefined) this.spools.delete(request.spool); try { const atomic = request.atomic === true; if (atomic || request.create === true) { await this.outputs.get(name)?.abort(); this.outputs.delete(name); // The replacement opens now, so a refusal is reported before the // stream reads on. const replacement = new ByteWriter(async (source) => { await dispatch('write', path, [source], atomic ? { atomic } : {}); }); try { await replacement.opened(); } catch (error) { await replacement.abort(); throw error; } this.outputs.set(name, replacement); } else if (request.append !== true) { const failed = await this.flushOutput(); if (failed !== null) return { reply: failed }; await dispatch('write', path, [new Uint8Array()]); } let writer = this.outputs.get(name); if (writer === undefined && (data.byteLength > 0 || spool !== undefined)) { writer = new ByteWriter(async (source) => { await dispatch('append', path, [source]); }); this.outputs.set(name, writer); } if (writer !== undefined && data.byteLength > 0) await writer.write(data); if (writer !== undefined && spool !== undefined) { for await (const chunk of spool.stream(skip, size)) await writer.write(chunk); } } catch (error) { if (!isWalkError(error)) throw error; return { reply: failure(error) }; } finally { if (size === undefined) await spool?.close(); } return { reply: {} }; } /** Unlink, make (one level) or stat a path; a stat answers what stands there, `dir` or `file`, or why it found nothing. */ async pathOp(op, name) { const dispatch = dispatchOf(this.ctx); if (dispatch === undefined) return { reply: { fatal: 'file output requires a workspace' } }; let found; try { ; [found] = await dispatch(op, PathSpec.fromStrPath(textView(name), undefined, this.ctx.cwd)); } catch (error) { if (!isWalkError(error)) throw error; return { reply: failure(error) }; } if (op !== 'stat' || found === null || found === undefined) return { reply: {} }; return { reply: { kind: found.type === FileType.DIRECTORY ? 'dir' : 'file' } }; } /** * Run a command line in a subshell of the session, as sh -c would; `eval` * takes the line whole, so an empty one, a comment or a line ending in a * backslash runs as `sh -c` would run it. */ async run(request) { const shell = this.ctx.shell; if (shell === undefined) return { reply: { fatal: 'running a command requires a workspace' } }; // A pipe nothing was printed to has no spool, and is still its command's whole input. const spool = typeof request.stdin === 'number' ? (this.spools.get(request.stdin) ?? new ByteSpool()) : undefined; if (typeof request.stdin === 'number') this.spools.delete(request.stdin); const source = spool === undefined ? this.stdinView() : spool.stream(); const command = textView(typeof request.command === 'string' ? request.command : ''); let stdout; let stderr; let status; try { const io = await shell(`( ${shellJoin(['eval', command])} )`, source); [stdout, stderr] = await spooledPair(io.stdout, io.stderr); status = io.exitCode; } finally { await spool?.close(); } await this.errors.fill(stderr ?? new Uint8Array()); if (typeof request.stdout === 'number') { this.inputs.set(request.stdout, chunks(stdout ?? new Uint8Array())[Symbol.asyncIterator]()); return { reply: { status } }; } return { reply: { status }, output: stdout }; } /** Let go of every input, spool and uncommitted write. */ async close() { const writers = [...this.outputs.values()]; this.outputs.clear(); for (const writer of writers) await writer.abort(); const inputs = [...this.inputs.values()]; this.inputs.clear(); for (const source of inputs) await close(source); const spools = [...this.spools.values()]; this.spools.clear(); for (const spool of spools) await spool.close(); } } /** * Run a stream that reaches files through `host`, yielding its output as it * settles; output closed early still lets what it wrote to its files land. * Mirrors Python's `hosted_stream`. */ export async function* hostedStream(kind, config, host, io, signal) { // The engine opens on the first pull, so output closed before one holds // no Wasm handle. const engine = await openStream(kind, config); let closed = true; try { yield* serveRequests(engine, host, signal); closed = false; } catch (err) { closed = false; throw err; } finally { try { if (closed && engine.cancel()) await drain(serveRequests(engine, host, signal)); io.exitCode = engine.status(); } finally { io.stderr = await host.errors.output(); await host.close(); engine.close(); } } } //# sourceMappingURL=stream_host.js.map