@struktoai/mirage-core
Version:
A Unified Virtual Filesystem For AI Agents
112 lines • 5.4 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 { YieldBudget } from "../io/yield_budget.js";
import { makeAbortError } from "../utils/abort.js";
import { currentCaller, lineFrames, runWithCaller, } from "../context/session_context.js";
/**
* Run `fn` as the body of the line `signal` belongs to. Everything the
* body awaits, down to the status write, can then ask `abortedLine`
* whether its caller is still waiting, without the signal being threaded
* through every handler. `shell` is the only caller.
*/
export function runWithLineAbort(signal, sessions, writer, fn, stop) {
return runWithCaller({
...currentCaller(),
lineAbort: { signal, sessions, writer, ...(stop === undefined ? {} : { stop }) },
}, fn);
}
/**
* The identity of the line stamping on `session`, for `recordStatus` to
* record and an aborted line to compare its snapshot against.
*
* Null when no line is running (a background job, a test driving a
* handler directly) and null when more than one line is live on this
* session, which is the case the comparison exists to refuse: with two
* writers in play nothing can attribute the last stamp, so an aborted
* line declines to restore rather than guess.
*/
export function lineStatusWriter(session) {
const frames = lineFrames().filter((f) => f.sessions.includes(session));
return frames.length === 1 ? (frames[0]?.writer ?? null) : null;
}
/**
* The aborted signal of the line stamping on `session`, or undefined
* when that line is still wanted, or when no line is running at all (a
* background job, a test driving a handler directly): nobody is
* waiting there and nothing is an orphan.
*
* Read from every live frame for the session rather than the newest
* frame. On an isolating runtime the live frames are the current task's
* alone, so the answer is exact. On the browser fallback the newest
* frame may belong to another line that happens to overlap, so the status write
* refuses only when every live line on this session has aborted: one
* line's abort never reaches a concurrent line's statement, and the one
* case left open is two aborted-or-not lines overlapping on one session
* without task isolation, where the fallback cannot tell them apart.
*/
export function abortedLine(session) {
const frames = lineFrames().filter((f) => f.sessions.includes(session));
if (frames.length === 0)
return undefined;
const aborted = frames.filter((f) => f.signal?.aborted === true);
return aborted.length === frames.length ? aborted[0]?.signal : undefined;
}
/**
* A dispatch that refuses to start an op once `signal` has fired. A
* cancelled asyncio task unwinds at its next await, so a Python handler
* that loops over operands (`rm link1 link2`, `chmod`, `touch`) never
* reaches the next one. A JS handler resumes after the await that was in
* flight when the caller was released and would begin the next write.
* Refusing at the dispatcher, the one seam every handler's I/O goes through,
* stops it there without threading the signal through each handler. An
* op already in flight settles on its own.
*
* The command's ops also share one yield budget: a walk the index answers
* resolves each op in a microtask, which no timer or abort ever runs between,
* so the budget hands the loop a task-queue turn every few milliseconds and
* the abort or timeout that fired there refuses the next op. Python's
* dispatcher spends the running task's budget, `task_turn`, the same way.
* The ops a command issues at once (the patterns of one grep) take the first
* one's answer, a turn or none, and go on together, as the tasks Python
* gathers start on budgets of their own: a turn each would let one op finish
* all it resolves without I/O before the next starts, and share nothing in
* flight with it.
*/
export function guardDispatch(dispatch, signal) {
const budget = new YieldBudget();
let together = null;
return (op, path, args, kwargs, report, caller) => {
if (signal?.aborted === true)
return Promise.reject(makeAbortError(signal));
if (together === null) {
together = budget.run();
queueMicrotask(() => {
together = null;
});
}
const pending = together;
if (pending === undefined)
return dispatch(op, path, args, kwargs, report, caller);
return pending.then(() => {
if (signal?.aborted === true)
throw makeAbortError(signal);
return dispatch(op, path, args, kwargs, report, caller);
});
};
}
/** A fresh line identity. */
export function newStatusWriter() {
return Symbol('line');
}
//# sourceMappingURL=abort.js.map