@struktoai/mirage-core
Version:
A Unified Virtual Filesystem For AI Agents
470 lines • 18.8 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 { chunks } from "../../../io/cooperative.js";
import { ByteSpool, IO_BUFFER_SIZE } from "../../../io/spool.js";
import { drain } from "../../../io/stream.js";
import { stdinStream } from "../utils/stream.js";
import { recordSeparator } from "../utils/lines.js";
import { IOResult } from "../../../io/types.js";
import { YieldBudget } from "../../../io/yield_budget.js";
import { StreamRefusal } from "../../../rs/errors.js";
import { chainOutput, closeStream, openStream, runStream, } from "../../../rs/stream.js";
import { LineFacts, PathSpec } from "../../../types.js";
import { concat } from "../../../utils/bytes.js";
import { fsStrerror, isFsError } from "../../../errors/fs.js";
import { posixPhrase } from "../../../errors/posix.js";
import { stripSlash } from "../../../utils/slash.js";
import { shellQuote } from "../../../utils/quote.js";
import { refused, scan } from "../../../rs/scan.js";
import { OPERAND } from "../../spec/constants.js";
import { FlagView } from "../../spec/flag_view.js";
import {} from "../../spec/types.js";
import { specOf } from "../../spec/builtins.js";
const ENC = new TextEncoder();
// The options sort's grammar in mirage-rs reads.
const GRAMMAR = [
'reverse',
'numeric_sort',
'general_numeric_sort',
'human_numeric_sort',
'version_sort',
'month_sort',
'ignore_case',
'dictionary_order',
'ignore_nonprinting',
'ignore_leading_blanks',
'unique',
'stable',
'zero_terminated',
'field_separator',
'key',
'output',
'c',
'C',
'check',
];
// The steps GNU sort names when a file fails it. GNU checks that every input
// is readable before it reads any (`cannot read`), opens the output and the
// one input -c reads (`open failed`), stats every input it is about to sort
// (`stat failed`), and only then reads (`read failed`).
export const CANNOT_READ = 'cannot read';
export const OPEN_FAILED = 'open failed';
export const STAT_FAILED = 'stat failed';
export const READ_FAILED = 'read failed';
// The step at which GNU sort first meets an input's failure, declared in
// the order GNU takes the steps, so the value is also the precedence: sort
// runs every input through one step before it moves any of them to the
// next, and a missing second input is reported ahead of an unreadable
// first one. Mirrors InputStage in sort.py.
export var InputStage;
(function (InputStage) {
InputStage[InputStage["ACCESS"] = 0] = "ACCESS";
InputStage[InputStage["STAT"] = 1] = "STAT";
InputStage[InputStage["READ"] = 2] = "READ";
})(InputStage || (InputStage = {}));
// Which step an input failure belongs to, told by its strerror.
// euidaccess(R_OK) passes a directory, so GNU meets one only when the read
// of it fails, and a mount's record cap refuses the same way. A closed
// standard input fails the fstat plain sort takes of every input before
// reading, but `-m` and `-c` never stat and fail on the read instead.
// Classified by the strerror a renderer wrote, so the stream path, which
// only holds a rendered line, sorts a failure into the same step as a
// command holding the error. Mirrors input_stage in sort.py.
export function inputStage(strerror, sorting) {
if (strerror === posixPhrase('EBADF'))
return sorting ? InputStage.STAT : InputStage.READ;
for (const code of ['EISDIR', 'EFBIG']) {
if (strerror === posixPhrase(code))
return InputStage.READ;
}
return InputStage.ACCESS;
}
// GNU sort's line for a failed step: the step, the name, the errno.
export function stepFailure(verb, label, strerror) {
return ENC.encode(`sort: ${verb}: ${shellQuote(label)}: ${strerror}\n`);
}
function stageVerb(stage, checking) {
if (stage === InputStage.READ)
return READ_FAILED;
if (stage === InputStage.STAT)
return STAT_FAILED;
return checking ? OPEN_FAILED : CANNOT_READ;
}
function earliest(firstFailure, stage, line) {
if (firstFailure === null || stage < firstFailure[0])
return [stage, line];
return firstFailure;
}
// sort's options in the order typed, then its operands. An output is the word
// typed, which GNU compares (STREQ), so `-o ./out -o out` names two outputs
// though they are one file, and a bare `--check` is `-c`, apart from
// `--check=`'s empty word. The tape holds each `-o` resolved, so its word
// comes from the path the line typed in the same place; throws when the tape
// holds other `-o` words than the paths. Mirrors _typed_options in sort.py.
function typedOptions(fl, paths) {
const tape = fl.occurrences(...GRAMMAR);
const outputs = fl.asPaths('output');
if (tape.filter(([name]) => name === 'output').length !== outputs.length) {
throw new Error(`sort's tape holds other -o words than its ${String(outputs.length)} paths`);
}
const words = outputs.map((path) => path.rawPath).reverse();
const typed = [];
for (const [name, value] of tape) {
const word = name === 'output' ? words.pop() : undefined;
if (word !== undefined) {
typed.push([name, word]);
}
else if (value === true) {
typed.push([name === 'check' ? 'c' : name, '']);
}
else if (typeof value === 'string') {
typed.push([name, value]);
}
}
for (const path of paths)
typed.push([OPERAND, path.rawPath]);
return typed;
}
/**
* The configuration a sort scan read, which every sort engine takes; throws
* when the scan read none. Mirrors engine_config in sort.py.
*/
export function engineConfig(values) {
const config = values.config;
if (typeof config === 'object' &&
config !== null &&
!Array.isArray(config) &&
typeof config.zero_terminated === 'boolean') {
return config;
}
throw new Error(`sort's scan read no config: ${JSON.stringify(values)}`);
}
/**
* Read sort's options and operands in mirage-rs, before any input: what the
* options decide, or what GNU sort refuses first, exit 1 for a refused
* `--check` word and 2 for the rest. Throws when the scan read no
* configuration or check mode. Mirrors parse_flags in sort.py.
*/
export function parseFlags(bag, paths) {
const fl = new FlagView(bag, specOf('sort'));
let values;
try {
values = scan('sort', typedOptions(fl, paths));
}
catch (error) {
if (!(error instanceof StreamRefusal))
throw error;
return refused(error);
}
const config = engineConfig(values);
const check = values.check;
if (check !== null && check !== 'c' && check !== 'C') {
throw new Error(`sort's scan read no check mode: ${JSON.stringify(check)}`);
}
const outputs = fl.asPaths('output');
return {
config,
check: check !== null,
checkQuiet: check === 'C',
merge: fl.asBool('merge'),
output: outputs[outputs.length - 1] ?? null,
zeroTerminated: config.zero_terminated === true,
};
}
async function checkStream(source, path, parsed, signal) {
const config = { ...parsed.config, mode: 'check' };
try {
await drain(runStream('sort', config, source, signal));
}
catch (error) {
// The refusal's message is the record's number, its output the record,
// which GNU writes with its own terminator.
if (error instanceof StreamRefusal) {
return new IOResult({
exitCode: 1,
stderr: parsed.checkQuiet
? null
: concat([
ENC.encode(`sort: ${label(path)}:${error.message}: disorder: `),
error.output,
Uint8Array.of(recordSeparator(parsed.zeroTerminated)),
]),
});
}
if (!isFsError(error))
throw error;
const strerror = fsStrerror(error) ?? String(error);
const stage = inputStage(strerror, false);
return new IOResult({
exitCode: 2,
stderr: stepFailure(stageVerb(stage, true), label(path), strerror),
});
}
return new IOResult();
}
function label(path) {
return path === null ? '-' : path.rawPath;
}
// A run is sorted once its records reach SORT_RUN_BYTES, each counting its
// length plus 64, and spooled; MERGE_FAN_IN runs merge into one at a time,
// so a sort of any size holds one run and one chunk per merged input.
export const SORT_RUN_BYTES = 8 * 1024 * 1024;
const MERGE_FAN_IN = 16;
// A k-way merge of sorted spools, each read as the merge asks; the runs
// are closed once merged. Mirrors _merge_spools in sort.py.
async function mergeSpools(runs, config, signal) {
const output = new ByteSpool();
const engine = await openStream('sort', { ...config, mode: 'merge', inputs: runs.length });
try {
await output.fill(chainOutput(engine, runs.map((run) => run.stream(0, undefined, IO_BUFFER_SIZE)), signal));
return output;
}
catch (error) {
await output.close();
throw error;
}
finally {
await closeStream(engine);
for (const run of runs)
await run.close();
}
}
async function addRun(levels, initial, config, signal) {
let run = initial;
for (let level = 0;; level++) {
const group = levels[level] ?? (levels[level] = []);
group.push(run);
if (group.length < MERGE_FAN_IN)
return;
levels[level] = [];
run = await mergeSpools(group, config, signal);
}
}
async function mergeOperands(paths, read, stdin, config, signal) {
const stream = stdinStream(read, stdin);
const state = { firstFailure: null };
const levels = [[]];
async function* source(path) {
try {
yield* chunks(path === null ? (stdin ?? new Uint8Array()) : stream(path), signal);
}
catch (err) {
if (!isFsError(err))
throw err;
const strerror = fsStrerror(err) ?? String(err);
const stage = inputStage(strerror, false);
state.firstFailure = earliest(state.firstFailure, stage, stepFailure(stageVerb(stage, false), label(path), strerror));
}
}
try {
const operands = paths.length > 0 ? paths : [null];
for (let start = 0; start < operands.length; start += MERGE_FAN_IN) {
const group = operands.slice(start, start + MERGE_FAN_IN);
const engine = await openStream('sort', { ...config, mode: 'merge', inputs: group.length });
const output = new ByteSpool();
try {
await output.fill(chainOutput(engine, group.map((path) => source(path)), signal));
await addRun(levels, output, config, signal);
}
catch (error) {
await output.close();
throw error;
}
finally {
await closeStream(engine);
}
}
if (state.firstFailure !== null)
return state.firstFailure[1];
const runs = [...levels].reverse().flat();
while (runs.length > 1) {
const group = runs.slice(0, MERGE_FAN_IN);
const merged = await mergeSpools(group, config, signal);
levels.push([merged]);
runs.splice(0, group.length, merged);
}
const output = runs.pop() ?? new ByteSpool();
for (const level of levels)
for (const run of level)
if (run !== output)
await run.close();
levels.length = 0;
return output;
}
finally {
for (const level of levels)
for (const run of level)
await run.close();
}
}
async function externalRecords(paths, stream, stdin, parsed, signal) {
const separator = recordSeparator(parsed.zeroTerminated);
const config = parsed.config;
if (parsed.merge)
return mergeOperands(paths, stream, stdin, config, signal);
const levels = [[]];
let firstFailure = null;
const budget = new YieldBudget(signal);
const sorter = await openStream('sort', { ...config, mode: 'sort', run_bytes: SORT_RUN_BYTES });
// While the stream is busy each drain is one bounded step of a run and
// what it writes belongs to that run, so the loop yields between steps; a
// feed of no bytes then starts a run already buffered.
async function addSorted(stream) {
while (stream.busy()) {
const run = new ByteSpool();
try {
while (stream.busy()) {
const piece = stream.drain();
if (piece.byteLength > 0)
await run.append(piece);
await budget.run();
}
await addRun(levels, run, config, signal);
}
catch (error) {
await run.close();
throw error;
}
stream.feed(new Uint8Array());
}
}
try {
const inputs = paths.length > 0 ? paths : [null];
for (const path of inputs) {
const source = path === null ? (stdin ?? new Uint8Array()) : stream(path);
let last = separator;
try {
for await (const chunk of chunks(source, signal)) {
if (chunk.byteLength === 0)
continue;
last = chunk[chunk.byteLength - 1] ?? separator;
sorter.feed(chunk);
await addSorted(sorter);
}
// Each input's last record ends at its end, as GNU reads it.
if (last !== separator) {
sorter.feed(Uint8Array.of(separator));
await addSorted(sorter);
}
}
catch (error) {
if (!isFsError(error))
throw error;
const strerror = fsStrerror(error) ?? String(error);
const stage = inputStage(strerror, true);
firstFailure = earliest(firstFailure, stage, stepFailure(stageVerb(stage, false), label(path), strerror));
if (stage === InputStage.ACCESS)
break;
}
}
if (firstFailure !== null)
return firstFailure[1];
sorter.finish();
await addSorted(sorter);
let runs = [...levels].reverse().flat();
while (runs.length > 1) {
const next = [];
for (let start = 0; start < runs.length; start += MERGE_FAN_IN) {
const mergedRun = await mergeSpools(runs.slice(start, start + MERGE_FAN_IN), config, signal);
levels.push([mergedRun]);
next.push(mergedRun);
}
runs = next;
}
const output = runs[0] ?? new ByteSpool();
for (const level of levels)
for (const run of level)
if (run !== output)
await run.close();
levels.length = 0;
return output;
}
finally {
await closeStream(sorter);
for (const level of levels)
for (const run of level)
await run.close();
}
}
// GNU sort over the given inputs. The refusals come in GNU's order: what
// mirage-rs refuses in the options and the operands, then the inputs, and
// last the output. Deliberate divergence: GNU opens `-o` before
// it reads any input and this writes it once every input has been read, so
// when an input fails only at its stat or its read (a directory), GNU
// reports an unopenable output first and leaves a new one behind empty, and
// this reports the input and writes nothing. An output is named by the
// word typed, as GNU echoes it. Mirrors sort in sort.py.
export async function sortGeneric(paths, ctx, stream, write) {
stream = stdinStream(stream, ctx.stdin);
const parsed = parseFlags(ctx.flags, paths);
if (parsed instanceof IOResult)
return [new Uint8Array(), parsed];
if (parsed.check) {
const path = paths[0] ?? null;
return [
new Uint8Array(),
await checkStream(path === null ? (ctx.stdin ?? new Uint8Array()) : stream(path), path, parsed, ctx.signal),
];
}
const ordered = await externalRecords(paths, stream, ctx.stdin, parsed, ctx.signal);
if (ordered instanceof Uint8Array)
return [new Uint8Array(), new IOResult({ exitCode: 2, stderr: ordered })];
if (parsed.output !== null) {
if (write === undefined) {
await ordered.close();
return [
new Uint8Array(),
new IOResult({
exitCode: 2,
stderr: ENC.encode('sort: output is not writable on this backend\n'),
}),
];
}
const outputPath = new PathSpec({
virtual: parsed.output.virtual,
directory: parsed.output.directory,
vfsPath: stripSlash(parsed.output.virtual),
rawPath: parsed.output.rawPath,
dotted: parsed.output.dotted,
walkError: parsed.output.walkError,
});
try {
await write(outputPath, ordered.stream(0, undefined, IO_BUFFER_SIZE));
}
catch (error) {
if (!isFsError(error))
throw error;
const strerror = fsStrerror(error) ?? String(error);
return [
new Uint8Array(),
new IOResult({
exitCode: 2,
stderr: stepFailure(OPEN_FAILED, parsed.output.rawPath, strerror),
}),
];
}
finally {
await ordered.close();
}
return [new Uint8Array(), new IOResult()];
}
return [await ordered.output(), new IOResult()];
}
/**
* A sort line writes only the file `-o` names. Mirrors Python's `facts`.
*/
export function facts(fl, _operands) {
return new LineFacts({ writes: fl.raw('output') !== undefined });
}
//# sourceMappingURL=sort.js.map