UNPKG

@struktoai/mirage-core

Version:

A Unified Virtual Filesystem For AI Agents

247 lines • 10.7 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 { UsageError } from "../../../commands/errors.js"; import { StreamRefusal } from "../../../rs/errors.js"; import { readFailExitCode } from "../../../commands/spec/usage.js"; import { errorVirtualPath, fsStrerror } from "../../../errors/fs.js"; import { formatFsError } from "../../../errors/render.js"; import { CommandTimeoutError, GlobLimitError } from "../../../errors/types.js"; import { chunks } from "../../../io/cooperative.js"; import { PipeClosed } from "../../../io/errors.js"; import { OutputStream, closeQuietly } from "../../../io/stream.js"; import { IOResult } from "../../../io/types.js"; import { encodeText } from "../../../shell/bytes.js"; import { STOP_WAIT_SECONDS } from "../../../shell/job_table/constants.js"; import { ABORT_JOIN_MS, runCancellable, stopSignal } from "../../../utils/abort.js"; import { concat } from "../../../utils/bytes.js"; import { readerOf } from "../../files.js"; import { CANNOT_EXECUTE_STATUS } from "../../../shell/constants.js"; /** * What a failed leaf says after its name: a file error stamped with its * path as the path and phrase, the way the shell's own commands say it, * anything else in its own words. Mirrors Python's `_leaf_message`. */ function leafMessage(err) { const strerror = fsStrerror(err); if (strerror !== null && typeof err.virtualPath === 'string') return `${errorVirtualPath(err)}: ${strerror}`; return err instanceof Error ? err.message : String(err); } /** Whether `err` is an abort, which the caller that aborted answers. */ function aborted(err) { return err instanceof Error && err.name === 'AbortError'; } /** * What a command given a glob past its mount's `walkLimit` says: exec(2)'s * refusal of an argument list too long, which bash reports with exit 126, then * the limit's own line. Mirrors Python's `argument_list_too_long`. */ export function argumentListTooLong(command, reason) { return encodeText(`${command}: Argument list too long\n${command}: ${reason}\n`); } /** * A mount command's failure, worded like GNU (`prog: message`), a file * error with the path as typed. Mirrors Python's `_mount_failure`. */ function mountFailure(name, paths, err) { if (err instanceof GlobLimitError) return [null, argumentListTooLong(name, err.message), CANNOT_EXECUTE_STATUS]; const code = err instanceof UsageError ? err.exitCode : readFailExitCode(name, err); return [null, formatFsError(name, err, paths), code]; } /** * A CLI leaf's failure: a usage error in its own words and exit code, * anything else after the program's name with exit 1, and what the leaf * printed before it failed kept. Mirrors Python's `_leaf_failure`. */ function leafFailure(prog, err) { const printed = err instanceof StreamRefusal ? err.output : null; if (err instanceof UsageError) return [printed, encodeText(`${err.message}\n`), err.exitCode]; return [printed, encodeText(`${prog}: ${leafMessage(err)}\n`), 1]; } /** The voice of a command a mount runs; `paths` are the operands as typed. */ export function mountVoice(name, paths) { return { fail: (err) => mountFailure(name, paths, err), passes: (err) => err instanceof CommandTimeoutError || err instanceof PipeClosed || aborted(err), splits: false, }; } /** The voice of a CLI leaf, installed or on a mount. */ export function cliVoice(prog) { return { fail: (err) => leafFailure(prog, err), passes: (err) => err instanceof CommandTimeoutError || aborted(err), splits: true, }; } /** * The caller has gone, so output the handler hands back later has no * reader: close it, and log a failure no caller is left to see. */ function abandon(body, err, name) { void body.then(async (late) => { if (late === null) return; await closeQuietly(late[0]); await closeQuietly(late[1].stderr); }, (late) => { if (late !== err && !aborted(late)) console.error(`${name}: failed after its caller left: ${String(late)}`); }); } /** * A leaf's answer, an empty one where it returned null. A mount command's * null declines, so its mount tries the next handler; a leaf's null is its * whole answer, an empty one. Mirrors Python's `answered`. */ export async function answered(body) { return (await body) ?? [null, new IOResult()]; } /** * Run one handler body: a mount command's, or a CLI leaf's. * * The body runs until it answers or the caller's signal aborts (the line's * deadline among the reasons). Racing a promise does not stop its work, so * output the body hands back after its caller left is closed. * A usage error is the handler's own words and exit code; any other failure * is worded in the caller's voice, so the rest of the line keeps running. A * handler that may have written past the dispatcher drops caches afterwards, * whether it answered, failed (in its own words too), timed out or was * cancelled, again when a body (or the renderer of the rows it handed over) * still running past its abort settles, since its write may land then, and * again once an answer that streams has ended or * been closed, since a stream does its work as it is pulled. An answer * carries the producer the output limits resolve from and * has its stream framed so a failure while it streams is worded too; a * failure the runner framed carries no producer. Null is the handler * declining, which a mount's cascade reads as "try the next handler". * Rows a handler hands over in place of stdout (`IOResult.rows`) lose those * the session cannot see and are rendered here, inside the run, so this * is the one place a command's output meets the session's hides. * Mirrors Python's `run_handler`. */ export async function runHandler(body, name, producer, voice, opts = {}) { let out; // The rendered answer is what a caller that left abandons: a renderer that // finishes late hands back output nobody reads. const answer = rendered(body, opts.vfs ?? null, name); if (opts.settle !== undefined) void answer.then(opts.settle, opts.settle); // A `kill` names a signal, and the program the command started gets the // stop bound to answer it (a handler, a sandbox reap), as Python joins // the cancelled handler whole; any other abort releases the caller soon. const runner = currentCaller().process; const grace = () => (runner?.signal ?? stopSignal(opts.signal)) === null ? ABORT_JOIN_MS : STOP_WAIT_SECONDS * 1000; try { out = await runCancellable(answer, opts.signal, grace); } catch (err) { abandon(answer, err, name); const drop = opts.drop; if (drop !== undefined) { await drop(); if (err instanceof CommandTimeoutError || opts.signal?.aborted === true) { const settle = () => drop(); void answer.then(settle, settle).catch((dropErr) => { // The command already returned, so a drop that fails here (a // workspace torn down under it) has no stream to land on; it is // reported rather than left as an unhandled rejection. const reason = dropErr instanceof Error ? dropErr.message : String(dropErr); console.warn(`${name}: cache drop after timeout failed: ${reason}`); }); } } if (err instanceof UsageError) { return [ null, new IOResult({ exitCode: err.exitCode, stderr: encodeText(`${err.message}\n`) }), ]; } if (voice.passes(err)) throw err; const [printed, stderr, exitCode] = voice.fail(err); return [printed, new IOResult({ exitCode, stderr })]; } const drop = opts.drop; if (drop !== undefined) await drop(); if (out === null) return null; const source = out[0]; out[1].producer = producer; const [stream, io] = opts.scope !== undefined ? opts.scope(out) : out; if (stream === null || (stream instanceof Uint8Array && !voice.splits)) return [stream, io]; const pulled = framed(stream, io, voice); const lazy = !(stream instanceof Uint8Array); return [ new OutputStream(pulled, async () => { await closeQuietly(source); await closeQuietly(stream); if (drop !== undefined && lazy) await drop(); }), io, ]; } /** * A handler's answer with the rows it handed over rendered: those the * session cannot see dropped first. Mirrors Python's `_rendered`. */ async function rendered(body, vfs, name) { const out = await body; if (out === null) return out; const [stdout, io] = out; const rows = io.rows; if (rows === null) return out; if (stdout !== null) { await closeQuietly(stdout); throw new Error(`${name}: hands over both rows and stdout`); } io.rows = null; let items = [...rows.items]; if (vfs !== null) { const reader = readerOf(vfs); items = items.filter((row) => reader.visible(row.path)); } return [await rows.render(items), io]; } /** Keep a deferred failure on its command, after any emitted bytes. */ async function* framed(source, io, voice) { try { if (voice.splits || source instanceof Uint8Array) yield* chunks(source); else yield* source; } catch (err) { if (voice.passes(err)) throw err; const [printed, stderr, exitCode] = voice.fail(err); if (printed !== null) yield* chunks(printed); io.stderr = concat([await io.materializeStderr(), stderr]); io.exitCode = exitCode; io.output?.finish(); } finally { await closeQuietly(source); } } //# sourceMappingURL=handler.js.map