@struktoai/mirage-core
Version:
A Unified Virtual Filesystem For AI Agents
408 lines • 16.6 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. =========
// The program file and the exit statuses follow jq 1.8.2's src/main.c (MIT;
// see licenses/third_party/jq.txt).
import { specOf } from "../../spec/builtins.js";
import { OPERAND, REFUSED } from "../../spec/constants.js";
import { FlagView, specFlagNames } from "../../spec/flag_view.js";
import { STDIN_NAME, localZone } from "../../../core/jq/index.js";
import { COMPILE_REPORT } from "../../../core/jq/eval.js";
import { READ_FAILURES } from "../../../errors/constants.js";
import { StreamRefusal } from "../../../rs/errors.js";
import { refused } from "../../../rs/scan.js";
import { closeStream, openStream, serveRequests, } from "../../../rs/stream.js";
import { MemoryExhausted } from "../../../io/errors.js";
import { memoryRoom } from "../../../io/memory.js";
import { ByteSpool } from "../../../io/spool.js";
import { chunks } from "../../../io/cooperative.js";
import { closeQuietly } from "../../../io/stream.js";
import { IOResult, materialize } from "../../../io/types.js";
import { PathSpec } from "../../../types.js";
import { fsStrerror, isEisdir, isFsError } from "../../../errors/fs.js";
import { byteView } from "../../../shell/bytes.js";
import { parent } from "../../../utils/path.js";
import {} from "../../config.js";
import { helpPage, versionLine } from "../../spec/standard.js";
import { isStdin, stdinStream } from "../utils/stream.js";
import { programFileRefusal, readProgramFile } from "./program.js";
import { systemNow } from "../../../utils/clock.js";
// Text jq writes as it is, a leading byte order mark included.
const DEC = new TextDecoder('utf-8', { ignoreBOM: true });
const ENC = new TextEncoder();
// The bindings, whose name and value the bag keeps as typed, and those whose
// value is a file the option loop asks for.
const BINDINGS = new Set(['arg', 'argjson', 'rawfile', 'slurpfile']);
const FILE_BINDINGS = new Set(['rawfile', 'slurpfile']);
// jq's exit status when it refuses the program itself, and when the program
// file holds a NUL byte.
const ERROR_COMPILE = 3;
const ERROR_SYSTEM = 2;
/**
* The words one binding option holds, in the order typed: each name, then
* the value it binds, or for a file binding the file. Mirrors Python's
* _bound_words.
*/
function* boundWords(fl, dest) {
const raw = fl.raw(dest);
const words = Array.isArray(raw) ? raw : [];
for (const [at, word] of words.entries()) {
yield at % 2 === 1 && FILE_BINDINGS.has(dest) ? fileOf(word) : word;
}
}
/**
* The file a flag names: the PathSpec of the word typed, or one built from the
* resolved path the flag arrived as. Mirrors Python's _file_of.
*/
function fileOf(word) {
if (word instanceof PathSpec)
return word;
if (typeof word === 'string')
return PathSpec.fromStrPath(word);
throw new Error(`jq: a flag names no file: ${JSON.stringify(word)}`);
}
/**
* jq's words in the order typed, as mirage-rs's option loop reads them, and
* the file at each place on them that names one. The bag's tape keeps where
* each word was typed, and the bag what it was: a binding's name and value,
* and the file -f names, which the tape holds resolved. A file is named as
* the word typed. Operands the bag has no tape for (a plain record) come
* after every option.
* Mirrors Python's option_tape.
*/
export function optionTape(fl, texts) {
const bound = new Map([...BINDINGS].map((dest) => [dest, boundWords(fl, dest)]));
const tape = [];
const files = new Map();
for (const [dest, value] of fl.occurrences(...specFlagNames(specOf('jq')), OPERAND, REFUSED)) {
const step = bound.get(dest)?.next();
let word = value;
if (step !== undefined)
word = step.done === true ? '' : step.value;
else if (dest === 'from_file')
word = fileOf(fl.raw(dest));
if (word instanceof PathSpec) {
files.set(tape.length, word);
tape.push([dest, flagFileName(word)]);
}
else if (typeof word === 'string') {
tape.push([dest, word]);
}
else if (word === true) {
tape.push([dest, '']);
}
else if (word !== false) {
throw new Error(`jq: ${dest} holds no word: ${JSON.stringify(word)}`);
}
}
if (!tape.some(([dest]) => dest === OPERAND)) {
for (const word of texts)
tape.push([OPERAND, word]);
}
return [tape, files];
}
/**
* Serves the files the option loop asks for: each --rawfile and --slurpfile
* it reaches, read the way jq reads its inputs. `files` holds the file at
* each place on the tape (optionTape). Mirrors Python's FlagFiles.
*/
class FlagFiles {
files;
read;
errors = new ByteSpool();
constructor(files, read) {
this.files = files;
this.read = read;
}
/** Read one file, or say why it could not be read. */
async serve(request) {
const file = typeof request.at === 'number' ? this.files.get(request.at) : undefined;
if (request.op !== 'read' || file === undefined)
throw new Error(`jq: unknown option loop request ${JSON.stringify(request)}`);
try {
return { reply: { kind: 'data' }, data: await this.read(file) };
}
catch (error) {
if (!isFsError(error))
throw error;
return {
reply: { kind: 'error', message: fsStrerror(error) ?? '', directory: isEisdir(error) },
};
}
}
}
/**
* jq's option loop in mirage-rs: the run it decides, what --help or
* --version prints where the loop reaches one, or the first word it refuses
* as the command's result, in jq's words. `tape` and `files` are
* optionTape's, and `read` the byte reader for a --rawfile or --slurpfile.
* Throws for a reply that lacks what the loop always answers. Mirrors
* Python's read_options.
*/
export async function readOptions(tape, files, read) {
let stream;
try {
stream = await openStream('jq_options', {
options: tape.map(([name, text]) => [name, byteView(text)]),
});
}
catch (error) {
if (error instanceof StreamRefusal)
return refused(error);
throw error;
}
const host = new FlagFiles(files, read);
let reply;
try {
reply = await materialize(serveRequests(stream, host));
}
catch (error) {
if (error instanceof StreamRefusal)
return refused(error);
throw error;
}
finally {
await host.errors.close();
await closeStream(stream);
}
const plan = JSON.parse(DEC.decode(reply));
if (plan.answer === 'help')
return ENC.encode(helpPage('jq', specOf('jq')));
if (plan.answer === 'version')
return ENC.encode(versionLine('jq'));
const { config, program_file: at } = plan;
const answered = new Error(`jq: the option loop answered ${JSON.stringify(plan)}`);
if (typeof config !== 'object' || config === null || Array.isArray(config))
throw answered;
if (at === null && typeof config.program === 'string')
return { config, program: config.program };
const file = typeof at === 'number' ? files.get(at) : undefined;
if (file === undefined)
throw answered;
return { config, program: file };
}
/**
* A flag's file as jq's reports name it: the word typed, `-` included, since
* jq opens that one as a file too.
*/
export function flagFileName(path) {
return path.rawPath === '' ? path.virtual : path.rawPath;
}
/** An input as jq's reports name it: the operand as typed, and `<stdin>` for `-`. */
export function inputName(path) {
if (path.rawPath === '-')
return STDIN_NAME;
return path.rawPath === '' ? path.virtual : path.rawPath;
}
// What the stream ends with once what jq holds outgrows the workspace's
// room (mirage-rs's jq EXHAUSTED). Mirrors Python's EXHAUSTED.
const EXHAUSTED = new MemoryExhausted().message;
/**
* The inputs jq's main loop reads, each opened when the loop first asks for
* its bytes, as jq opens its files one after another, and the clock `now`
* reads. `sources` are the inputs, in order, not yet opened; each goes over
* in bounded pieces, so the loop holds no more of it than it is reading.
* Mirrors Python's JqInputs.
*/
export class JqInputs {
errors = new ByteSpool();
iterators;
sources;
constructor(sources, signal) {
this.sources = sources.map((source) => chunks(source, signal));
this.iterators = sources.map(() => null);
}
/**
* Answer the stream: the clock, or the next bytes of an input, its end, or
* why it could not be opened or read, in strerror's words.
*/
async serve(request) {
if (request.op === 'now')
return { reply: { kind: 'now', now: systemNow() } };
const index = request.input;
const source = typeof index === 'number' ? this.sources[index] : undefined;
if (request.op !== 'read' || typeof index !== 'number' || source === undefined)
throw new Error(`jq: unknown host request ${JSON.stringify(request)}`);
const iterator = (this.iterators[index] ??= source[Symbol.asyncIterator]());
let next;
try {
next = await iterator.next();
}
catch (error) {
if (!isFsError(error))
throw error;
const code = error.code;
return {
reply: {
kind: 'error',
message: fsStrerror(error) ?? '',
read: code !== undefined && READ_FAILURES.has(code),
},
};
}
if (next.done === true)
return { reply: { kind: 'end' } };
return { reply: { kind: 'data' }, data: next.value };
}
/** Close every input, the one the loop stopped in among them. */
async close() {
for (const iterator of this.iterators)
if (iterator !== null)
await closeQuietly(iterator);
}
}
/**
* What mirage-rs's jq takes for one invocation: the program, what the option
* loop decided (readOptions), `env` (JSON text) as `$ENV` and `env`, `zone`
* (localZone) as the `TZ` local time reads, `origin` as the directory
* `get_prog_origin` answers, and the inputs' names as jq reports them.
* Mirrors Python's run_config.
*/
export function runConfig(expr, options, env, zone, origin, inputs) {
const limit = memoryRoom();
return {
...options,
program: expr,
env,
zone,
prog_origin: origin,
...(limit === null ? {} : { memory: limit }),
inputs: [...inputs],
};
}
/**
* jq's main loop in mirage-rs over the inputs, a bounded slice of work at a
* time: what the runs print, as they print it, with jq's exit status and
* stderr settled on `io` once the stream is drained. Throws MemoryExhausted,
* after what was printed before it, once what jq holds outgrew the
* workspace's room. Mirrors Python's run_outputs.
*/
export async function* runOutputs(config, inputs, io, signal) {
const stream = await openStream('jq', config);
try {
yield* serveRequests(stream, inputs, signal);
}
catch (error) {
if (!(error instanceof StreamRefusal) || error.message !== EXHAUSTED)
throw error;
if (error.output.byteLength > 0)
yield error.output;
throw new MemoryExhausted();
}
finally {
await closeRun(stream, inputs, io);
}
}
/**
* Settle a run's exit status and stderr on `io`, then close its inputs and
* its stream, whatever settling threw: a spool that lost a write cannot hand
* its contents on, and still frees what it holds. Mirrors Python's
* _close_run.
*/
async function closeRun(stream, inputs, io) {
try {
io.exitCode = stream.status();
await inputs.errors.append(stream.takeStderr());
io.stderr = await inputs.errors.output();
}
catch (error) {
await inputs.errors.close();
throw error;
}
finally {
await inputs.close();
await closeStream(stream);
}
}
export async function jqGeneric(paths, texts, ctx, stream) {
const readInput = stdinStream(stream, ctx.stdin);
const fl = new FlagView(ctx.flags, specOf('jq'));
// -f, --rawfile and --slurpfile route nothing (the executor's FILE_KEYS and
// DISPATCH_FLAG_KEYS), so a file may sit on another mount than the operands: it
// is read through the dispatcher. jq opens it by name, so /dev/stdin is the
// invocation's own stdin and `-` is a file named `-`.
const readFlagFile = (path) => {
if (isStdin(path, false))
return materialize(readInput(path));
if (ctx.vfs === undefined)
return materialize(stream(path));
return readProgramFile('jq', path, ctx.vfs.dispatch);
};
// jq reads its options before its program, so a refused option is reported
// before an -f file is read.
const plan = await readOptions(...optionTape(fl, texts), readFlagFile);
if (plan instanceof IOResult)
return [null, plan];
if (plan instanceof Uint8Array)
return [plan, new IOResult()];
const { config: options, program } = plan;
const programFile = program instanceof PathSpec ? program : null;
let expression = typeof program === 'string' ? program : '';
if (programFile !== null) {
let data;
try {
data = await readFlagFile(programFile);
}
catch (error) {
if (!isFsError(error))
throw error;
const [line, code] = programFileRefusal('jq', programFile, error);
return [null, new IOResult({ exitCode: code, stderr: ENC.encode(line) })];
}
if (data.includes(0)) {
return [
null,
new IOResult({
exitCode: ERROR_SYSTEM,
stderr: ENC.encode('jq: program file contains NUL bytes\n'),
}),
];
}
expression = DEC.decode(data);
}
const origin = programFile === null ? ctx.cwd.virtual : parent(programFile.virtual);
// `$ENV` and `env` read the session's exported environment in place of
// jq's process environment, and local time its `TZ`.
const env = JSON.stringify(ctx.env ?? {});
const zone = localZone(ctx.env);
const operands = [...paths];
const names = operands.map(inputName);
if (operands.length === 0 && ctx.stdin !== null) {
operands.push(PathSpec.fromStrPath('/dev/stdin'));
names.push(STDIN_NAME);
}
try {
// jq compiles its program before it opens a single input, so a
// refusal is all it prints. mirage-rs words jq's refusals itself,
// reading the program as typed, so its lines and columns are jq's. The
// run compiles it again when it starts, so nothing is held open for a
// stream no one reads.
const checked = await openStream('jq', runConfig(expression, options, env, zone, origin, names));
await closeStream(checked);
}
catch (error) {
if (!(error instanceof StreamRefusal) || !COMPILE_REPORT.test(error.message))
throw error;
return [
null,
new IOResult({ exitCode: ERROR_COMPILE, stderr: ENC.encode(`${error.message}\n`) }),
];
}
const config = runConfig(expression.trim(), options, env, zone, origin, names);
// Each input opens when the loop first reads it, as jq opens its files one
// after another, so -n never opens one its program does not read.
const inputs = new JqInputs(operands.map((path) => readInput(path)), ctx.signal);
const io = new IOResult();
return [runOutputs(config, inputs, io, ctx.signal), io];
}
//# sourceMappingURL=jq.js.map