@struktoai/mirage-core
Version:
A Unified Virtual Filesystem For AI Agents
485 lines • 19.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 { z } from 'zod';
import { ApprovalSnapshot, approvalQuery, recordWire } from "../rs/approvals.js";
import { concat } from "../utils/bytes.js";
import { asyncContextIsolatesTasks } from "../utils/async_context.js";
import { currentCaller, explaining, liveCallers, runWithCaller, } from "../context/session_context.js";
import { sha256Hex } from "../utils/hash.js";
import { PolicyDenied } from "./errors.js";
import { DecisionOwner, Outcome } from "./types.js";
import { DryRun, Scope } from "./types.js";
const ABANDONED = { kind: 'abandoned' };
/**
* Tell the abandonment of a question from a host's answer.
*
* Structural, not by identity: a `Decision` carries no `kind`, so the
* field alone separates the two without depending on this module being
* the only place the sentinel is built.
*
* @param said what the wait produced.
* @returns true when the run went away mid-question.
*/
function isAbandoned(said) {
return said !== null && 'kind' in said;
}
/**
* A host's answer, or the abandonment of the question when the run
* waiting on it is killed first.
*
* The wait is taken as a thunk so a run already over never starts one:
* nothing should be put to a host on behalf of a line that no longer
* exists. An abandoned wait is not cancelled, because a promise cannot
* be; its value is dropped, and the rejection handler stays attached so
* a channel that falls over afterwards does not surface as an unhandled
* rejection.
*
* @param start begins the wait; called at most once.
* @param signal the run's abort signal; absent leaves the wait alone.
* @returns the answer, or ABANDONED once the run is gone.
*/
async function answered(start, signal) {
if (signal === undefined)
return start();
if (signal.aborted)
return ABANDONED;
const pending = start();
return new Promise((resolve, reject) => {
const onAbort = () => {
resolve(ABANDONED);
};
signal.addEventListener('abort', onAbort, { once: true });
pending.then(resolve, reject).finally(() => {
signal.removeEventListener('abort', onAbort);
});
});
}
/**
* The id a record is named by: a digest of what was asked, so a retry
* of the same line quotes the same id and a host answers it once.
*
* The id names the record; it does not decide what a retry matches.
* That comparison is made against the recorded fields themselves
* (`covers`), so a line the digest cannot tell apart from another still
* cannot borrow its answer.
*/
export async function decisionId(sessionId, cwd, argv) {
const enc = new TextEncoder();
const parts = [sessionId, cwd, ...argv].map((part) => enc.encode(part + '\0'));
const bytes = concat(parts);
return (await sha256Hex(bytes)).slice(0, 12);
}
/**
* The rule an Ask is keyed on: the document's, or for a coded Ask one
* synthesized over what was asked about (the program, the path, the
* line, the variable), so a session answer reads "stop asking me about
* this".
*/
export function askRule(ctx, ask) {
if (ask.rule != null)
return ask.rule;
if ('verb' in ctx)
return { reason: ask.reason, commands: [ctx.key] };
if ('address' in ctx)
return { reason: ask.reason, commands: [ctx.host === '' ? ctx.address : ctx.host] };
if ('call' in ctx)
return { reason: ask.reason, paths: [ctx.path.virtual] };
if ('commands' in ctx)
return { reason: ask.reason, commands: [ctx.line] };
const program = (ctx.program ?? [ctx.command]).join(' ');
return { reason: ask.reason, commands: [program] };
}
function sameList(a = [], b = []) {
return a.length === b.length && a.every((v, i) => v === b[i]);
}
export function sameRule(a, b) {
return (a.reason === b.reason &&
a.mount === b.mount &&
sameList(a.commands, b.commands) &&
sameList(a.paths, b.paths));
}
export function covers(record, rule, argv, cwd) {
return approvalQuery({
kind: 'covers',
record: recordWire(record, 0, sameRule(record.rule, rule) ? 0 : null, argv, cwd),
rule: 0,
}, z.boolean());
}
export function lineage(handed) {
const out = [];
for (let h = handed; h !== null; h = h.parent)
out.push(h);
return out;
}
export function sameOccurrence(a, b) {
let x = a;
let y = b;
while (x !== null && y !== null) {
if (x.source !== y.source || x.start !== y.start || x.end !== y.end)
return false;
x = x.parent;
y = y.parent;
}
return x === null && y === null;
}
export function isPending(refusal) {
return refusal?.kind === 'pending';
}
export function opQuestion(ctx, ask) {
const rules = ask.rules ?? [askRule(ctx, ask)];
const rule = ask.rule ?? rules[0];
const keyed = { ...ask, ...(rule !== undefined ? { rule } : {}), rules };
let words;
let paths = [];
let cwd = '';
if ('call' in ctx) {
words = [ctx.path.virtual];
paths = [ctx.path];
}
else if ('verb' in ctx) {
words = ctx.value === null ? [ctx.key] : [ctx.key, ctx.value];
}
else {
words = [ctx.line];
cwd = ctx.session?.cwd() ?? '';
}
const asked = {
command: '',
paths,
argv: words,
cwd,
sessionId: ctx.sessionId ?? '',
};
return [asked, keyed];
}
export function encloses(scope, occurrence) {
const facts = new ApprovalSnapshot();
const outer = facts.occurrenceKey(scope);
const inner = facts.occurrenceKey(occurrence);
const state = facts.wire;
return approvalQuery({
kind: 'encloses',
occurrences: state.occurrences ?? [],
sources: state.sources ?? [],
scope: outer,
occurrence: inner,
}, z.boolean());
}
export class Decisions extends DecisionOwner {
sessions;
onAsk;
memory = new Map();
live = new Map();
constructor(sessions = null, onAsk = null) {
super();
this.sessions = sessions;
this.onAsk = onAsk;
}
/** Every record, oldest first: questions waiting and questions settled. */
list(sessionId = '') {
if (sessionId)
return [...this.records(sessionId)];
return this.keys().flatMap((key) => [...this.records(key)]);
}
pending(sessionId = '') {
return this.list(sessionId).filter((record) => record.outcome === null);
}
async answer(decisionId, outcome, scope = Scope.ONCE, note = '') {
this.writeAnswer(decisionId, outcome, scope, note);
await this.flush();
}
writeAnswer(decisionId, outcome, scope, note) {
if (outcome === Outcome.ASK)
throw new Error('ASK is the question, not an answer');
for (const sessionId of this.keys()) {
const records = [...this.records(sessionId)];
const candidates = records.filter((record) => record.id === decisionId);
if (candidates.length === 0)
continue;
const selected = new ApprovalSnapshot(candidates).answer(decisionId, outcome);
if (selected === null)
continue;
const index = records.indexOf(selected);
const settled = { ...selected, outcome, scope, note };
records[index] = settled;
this.set(sessionId, records);
return settled;
}
throw new Error(`no decision waiting with id ${decisionId}`);
}
async resolve(ctx, ask, signal, claimant = null) {
const rules = ask.rules ?? [askRule(ctx, ask)];
const argv = [ctx.command, ...ctx.argv];
const sessionId = ctx.sessionId ?? '';
const initial = this.plan(sessionId, rules, argv, ctx.cwd, claimant);
if (initial.denied !== null) {
const refused = rules[initial.denied];
if (refused === undefined)
throw new Error('approval returned an unknown rule');
await this.spend(sessionId, initial.once);
return { kind: 'deny', reason: refused.reason };
}
for (const [position, index] of initial.missing.entries()) {
const rule = rules[index];
if (rule === undefined)
throw new Error('approval returned an unknown rule');
const waiting = position === 0 ? (initial.waiting[index] ?? null) : this.waiting(ctx, rule, argv);
const action = await this.raise(ctx, rule, argv, signal, claimant, waiting);
if (action !== null)
return action;
}
const settled = initial.missing.length === 0 ? initial : this.plan(sessionId, rules, argv, ctx.cwd, claimant);
if (claimant === null)
await this.spend(sessionId, settled.once);
else if (settled.claim !== null)
this.applyClaim(sessionId, claimant, settled.claim);
return null;
}
claim(sessionId, claimant, once) {
if (once.length === 0)
return;
const facts = new ApprovalSnapshot([], this.live.get(sessionId), [claimant.line]);
this.applyClaim(sessionId, claimant, facts.claim(claimant.line, once));
}
async resolveOp(ctx, ask, caller) {
const trace = caller === undefined ? explaining() : caller.explaining;
if (trace === DryRun.DECIDING)
return this.heldOp(ctx, ask);
const [asked, keyed] = opQuestion(ctx, ask);
const sessionId = ctx.sessionId ?? '';
const call = caller !== undefined
? callOf(caller, this, sessionId)
: asyncContextIsolatesTasks || !('verb' in ctx)
? getOpCall(this, sessionId)
: null;
const claimant = call === null
? null
: {
line: call,
occurrence: { parent: null, source: asked.argv[0] ?? '', start: 0, end: 0 },
};
const said = await this.resolve(asked, keyed, undefined, claimant);
return said?.kind === 'abandoned' ? { kind: 'deny', reason: ask.reason } : said;
}
heldOp(ctx, ask) {
const [asked, keyed] = opQuestion(ctx, ask);
return this.held(asked, keyed);
}
async withinCall(sessionId, run) {
const outer = asyncContextIsolatesTasks ? getOpCall(this, sessionId) : null;
if (outer !== null)
return run(outer);
const handed = { claimed: [], parent: null, origin: null };
try {
return await runWithOpCall(this, handed, sessionId, () => run(handed));
}
catch (err) {
const held = err instanceof PolicyDenied && isPending(err.refusal);
if (held && asyncContextIsolatesTasks)
this.release(sessionId, handed);
throw err;
}
finally {
await this.revoke(sessionId, handed);
}
}
async revoke(sessionId, handed) {
if (handed.claimed.length === 0 && !this.live.get(sessionId)?.has(handed))
return;
const facts = new ApprovalSnapshot([], this.live.get(sessionId), [handed]);
await this.spend(sessionId, facts.revoke(handed));
this.release(sessionId, handed);
}
split(sessionId, handed, scope) {
const job = { claimed: [], parent: handed.parent, origin: handed.origin };
const facts = new ApprovalSnapshot([], [], [handed]);
if (facts.claimObjects.length === 0)
return job;
job.claimed.push(...facts.split(handed, scope));
if (job.claimed.length > 0) {
const live = this.live.get(sessionId) ?? new Set();
live.add(job);
this.live.set(sessionId, live);
}
return job;
}
handUp(sessionId, handed) {
if (handed.parent === null)
throw new Error("a typed line's claims are spent, not handed up");
if (handed.claimed.length === 0 &&
handed.parent.claimed.length === 0 &&
!this.live.get(sessionId)?.has(handed))
return;
const facts = new ApprovalSnapshot([], this.live.get(sessionId), [handed]);
const result = facts.handUp(handed);
result.parent.claimed.push(...result.claims);
handed.claimed.length = 0;
this.live.set(sessionId, new Set(result.live));
}
release(sessionId, handed) {
handed.claimed.length = 0;
this.live.get(sessionId)?.delete(handed);
}
async held(ctx, ask, claimant = null) {
const rules = ask.rules ?? [askRule(ctx, ask)];
const argv = [ctx.command, ...ctx.argv];
const sessionId = ctx.sessionId ?? '';
const result = this.plan(sessionId, rules, argv, ctx.cwd, claimant);
if (result.denied !== null) {
const refused = rules[result.denied];
if (refused === undefined)
throw new Error('approval returned an unknown rule');
return { kind: 'deny', reason: refused.reason };
}
const index = result.missing[0];
if (index === undefined)
return null;
const unanswered = rules[index];
if (unanswered === undefined)
throw new Error('approval returned an unknown rule');
return {
kind: 'pending',
id: await decisionId(sessionId, ctx.cwd, argv),
reason: unanswered.reason,
};
}
async raise(ctx, rule, argv, signal, claimant = null, record = null) {
if (record === null) {
record = {
id: await decisionId(ctx.sessionId ?? '', ctx.cwd, argv),
sessionId: ctx.sessionId ?? '',
command: ctx.command,
argv: [...ctx.argv],
cwd: ctx.cwd,
paths: ctx.paths.map((p) => p.virtual),
reason: rule.reason,
rule,
outcome: null,
scope: Scope.ONCE,
note: '',
};
this.add(ctx.sessionId ?? '', record);
await this.flush();
}
const onAsk = this.onAsk;
if (onAsk === null)
return { kind: 'pending', id: record.id, reason: rule.reason };
const said = await answered(() => onAsk(record, signal), signal);
if (isAbandoned(said))
return said;
if (said?.outcome == null)
return { kind: 'pending', id: record.id, reason: rule.reason };
const settled = this.writeAnswer(record.id, said.outcome, said.scope, said.note);
if (claimant !== null && settled.outcome === Outcome.ALLOW && settled.scope === Scope.ONCE)
this.claim(ctx.sessionId ?? '', claimant, [settled]);
await this.flush();
return said.outcome === Outcome.DENY ? { kind: 'deny', reason: rule.reason } : null;
}
waiting(ctx, rule, argv) {
const records = this.records(ctx.sessionId ?? '').filter((record) => sameRule(record.rule, rule));
return new ApprovalSnapshot(records).waiting(argv, ctx.cwd);
}
async spend(sessionId, spent) {
if (spent.length === 0)
return;
this.set(sessionId, this.records(sessionId).filter((record) => !spent.some((consumed) => consumed === record)));
await this.flush();
}
keys() {
return this.sessions !== null ? [...this.sessions.decisionSessions()] : [...this.memory.keys()];
}
plan(sessionId, rules, argv, cwd, claimant) {
const records = [];
const recordRules = [];
const knownRules = new Map();
const asked = rules.map((rule) => {
const key = rules.findIndex((other) => sameRule(rule, other));
knownRules.set(rule, key);
return key;
});
for (const record of this.records(sessionId)) {
let rule = knownRules.get(record.rule);
if (rule === undefined) {
rule = rules.findIndex((rule) => sameRule(record.rule, rule));
knownRules.set(record.rule, rule);
}
if (rule < 0)
continue;
records.push(record);
recordRules.push(rule);
}
return new ApprovalSnapshot(records, this.live.get(sessionId)).plan(recordRules, asked, argv, cwd, claimant);
}
applyClaim(sessionId, claimant, result) {
if (result.records.length === 0)
return;
claimant.line.claimed.push(...result.records.map((decision) => ({ occurrence: claimant.occurrence, decision })));
this.live.set(sessionId, new Set(result.live));
}
records(sessionId) {
return this.sessions !== null
? this.sessions.decisionsOf(sessionId)
: (this.memory.get(sessionId) ?? []);
}
set(sessionId, records) {
if (this.sessions !== null)
this.sessions.setDecisions(sessionId, records);
else
this.memory.set(sessionId, records);
}
add(sessionId, record) {
this.set(sessionId, [...this.records(sessionId), record]);
}
async flush() {
if (this.sessions !== null)
await this.sessions.flush();
}
}
/**
* Run one call, the unit an op-level answer covers: a file tool's, or a
* line's or a job's, whose session writes are its ops. A grant one of its
* ops is answered by is claimed on `handed` for the call's other ops on
* that subject. `owner` is the ledger
* the call runs under, the only one that claims on `handed` and spends
* it. Mirrors Python's `set_op_call`.
*/
export function runWithOpCall(owner, handed, sessionId, fn) {
return runWithCaller({ ...currentCaller(), call: [owner, handed, sessionId] }, fn);
}
/**
* The call running in this context under `owner`'s ledger for
* `sessionId`, null for a bare op, for a call another ledger
* runs (a host callback reaching a second workspace mid-call) or for
* another session's call. Without task isolation every live call is in
* reach, so the newest of this ledger's and session's is taken.
*/
export function getOpCall(owner, sessionId) {
const call = liveCallers()
.flatMap((caller) => (caller.call === null ? [] : [caller.call]))
.reverse()
.find(([ledger, , session]) => ledger === owner && session === sessionId);
return call?.[1] ?? null;
}
/**
* The call `caller` runs in under `owner`'s ledger for `sessionId`, null
* for a bare op, for a call another ledger runs or for another session's
* call. Mirrors Python's `call_of`.
*/
export function callOf(caller, owner, sessionId) {
const call = caller.call;
if (call?.[0] !== owner || call[2] !== sessionId)
return null;
return call[1];
}
//# sourceMappingURL=decisions.js.map