@struktoai/mirage-core
Version:
A Unified Virtual Filesystem For AI Agents
150 lines • 5.94 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 { END_OF_CONSOLE } from "./constants.js";
import { Channel } from "./types.js";
/**
* In-memory console storage, the default in every topology.
*
* Simpler than its Python counterpart for one reason: JavaScript runs
* this on a single event loop, so a waiter is just a pending promise
* resolver. Python has to park futures per loop and wake them through
* call_soon_threadsafe, because its callers arrive on pool threads with
* their own loops.
*
* The ending chunk is terminal here as it is in Redis: an append after a
* CONTROL chunk is dropped, so a runner that outlived an ending written
* through another handle on this store (a kill from another workspace
* sharing it) cannot append past it.
*/
export class RAMConsoleStore {
chunks;
baseSeq;
nextSeq;
bytes;
ended;
maxBytes;
waiters = [];
isClosed = false;
get closed() {
return this.isClosed;
}
/**
* @param maxBytes retention budget. When set, the oldest chunks are
* dropped once the total exceeds it, and a reader whose cursor was
* dropped is told rather than shorted.
* @param chunks pre-existing chunks, used to rebuild a finished job's
* console from a snapshot.
*/
constructor(maxBytes = null, chunks = null) {
this.chunks = chunks === null ? [] : [...chunks];
const first = this.chunks[0];
const last = this.chunks[this.chunks.length - 1];
this.baseSeq = first === undefined ? 0 : first.seq;
this.nextSeq = last === undefined ? 0 : last.seq + 1;
this.bytes = this.chunks.reduce((sum, c) => sum + c.data.byteLength, 0);
this.ended = this.chunks.some((c) => c.channel === Channel.CONTROL);
this.maxBytes = maxBytes;
}
append(channel, data) {
const last = this.chunks[this.chunks.length - 1];
if (this.ended && last !== undefined)
return Promise.resolve(last);
this.ended = channel === Channel.CONTROL;
const chunk = {
seq: this.nextSeq,
ts: Date.now() / 1000,
channel,
data,
};
this.chunks.push(chunk);
this.nextSeq += 1;
this.bytes += data.byteLength;
this.trim();
this.wake();
return Promise.resolve(chunk);
}
readFrom(seq, limit) {
const truncated = seq < this.baseSeq;
const start = truncated ? 0 : Math.min(seq - this.baseSeq, this.chunks.length);
const window = limit === undefined ? this.chunks.slice(start) : this.chunks.slice(start, start + limit);
return Promise.resolve([window, this.baseSeq + start + window.length, truncated]);
}
wait(seq, signal) {
// A closed store is checked here too, so a reader that re-arms after
// close() released it resolves instead of parking forever.
if (this.isClosed || this.nextSeq > seq || signal?.aborted === true)
return Promise.resolve();
return new Promise((resolve) => {
// The abort listener goes with the waiter, so a reader waiting on
// chunk after chunk under one signal leaves none behind.
const waiter = {
seq,
resolve: () => {
signal?.removeEventListener('abort', abandon);
resolve();
},
};
const abandon = () => {
this.waiters = this.waiters.filter((w) => w !== waiter);
resolve();
};
this.waiters.push(waiter);
signal?.addEventListener('abort', abandon, { once: true });
});
}
waitEnded(signal) {
if (this.isClosed || this.ended || signal?.aborted === true)
return Promise.resolve();
return new Promise((resolve) => {
const waiter = { seq: END_OF_CONSOLE, resolve };
this.waiters.push(waiter);
signal?.addEventListener('abort', () => {
this.waiters = this.waiters.filter((w) => w !== waiter);
resolve();
}, { once: true });
});
}
close() {
this.isClosed = true;
const waiters = this.waiters;
this.waiters = [];
for (const w of waiters)
w.resolve();
return Promise.resolve();
}
trim() {
if (this.maxBytes === null)
return;
while (this.bytes > this.maxBytes) {
const head = this.chunks[0];
// The terminal chunk is what releases waitFinished() and ends
// follow(); dropping it would leave both blocked forever, so it
// outranks the byte budget.
if (head === undefined || head.channel === Channel.CONTROL)
break;
this.chunks.shift();
this.bytes -= head.data.byteLength;
this.baseSeq += 1;
}
}
wake() {
const matured = this.waiters.filter((w) => w.seq < this.nextSeq || this.ended);
if (matured.length === 0)
return;
this.waiters = this.waiters.filter((w) => !matured.includes(w));
for (const w of matured)
w.resolve();
}
}
//# sourceMappingURL=ram.js.map