@struktoai/mirage-core
Version:
A Unified Virtual Filesystem For AI Agents
172 lines • 6.5 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 { concat } from "../../utils/bytes.js";
import { Channel } from "./types.js";
import { END_OF_CONSOLE } from "./constants.js";
import { RAMConsoleStore } from "./ram.js";
import { decodeText, encodeText } from "../bytes.js";
/**
* Everything one job has printed, readable from any position.
*
* A job writes here as it runs, and any number of readers consume at
* their own pace. A reader's whole state is one number, so readers cost
* nothing, may join late, and may disappear without the console
* noticing.
*/
export class JobConsole {
backing;
finishedFlag;
execution = null;
get executionId() {
return this.execution;
}
/** Associate this job's output with its one managed execution. */
bindExecution(executionId) {
if (this.execution !== null && this.execution !== executionId)
throw new Error('console already belongs to another execution');
this.execution = executionId;
}
/**
* @param store where chunks live. Defaults to memory, which is the
* right home whenever the job and its readers share a process.
* @param finished whether the job has already ended, set when
* rebuilding a finished console from a snapshot.
*/
constructor(store = null, finished = false) {
this.backing = store ?? new RAMConsoleStore();
this.finishedFlag = finished;
}
/**
* Where this console's chunks live. A job's console is the one its job
* store names, which every workspace on that store reads and ends.
*/
get store() {
return this.backing;
}
/** Whether this process has seen the job end. */
get finished() {
return this.finishedFlag;
}
/**
* Append output produced by the job.
*
* Ignored once the job has ended, so a runner still unwinding after a
* kill cannot append past the ending chunk and strand readers that
* already stopped following. Ignored too once the store is closed: the
* console was discarded, and a Redis store written after close would
* open a client that nothing quits.
*/
async emit(channel, data) {
if (this.finishedFlag || this.backing.closed)
return;
await this.backing.append(channel, data);
}
/** Append output written to a stream a level owns; this console keeps it on its channel. */
async emitTo(stream, data) {
await this.emit(stream.channel, data);
}
/**
* Record how the job ended and release every waiting reader.
*
* Idempotent, so a job killed while it was already exiting does not
* get two endings.
*/
async finish(outcome) {
if (this.finishedFlag)
return;
this.finishedFlag = true;
if (this.backing.closed)
return;
await this.backing.append(Channel.CONTROL, encodeText(outcome));
}
/** Read chunks at or after a cursor. */
readFrom(seq, limit) {
return this.backing.readFrom(seq, limit);
}
/** Yield chunks as they arrive, ending when the job does. */
async *follow(seq = 0) {
let cursor = seq;
for (;;) {
const [chunks, next] = await this.backing.readFrom(cursor);
cursor = next;
for (const chunk of chunks) {
yield chunk;
if (chunk.channel === Channel.CONTROL)
return;
}
await this.backing.wait(cursor);
// A discarded console will never produce an ending chunk, and
// releasing once would only send this loop back to waiting.
if (this.backing.closed)
return;
}
}
/**
* Resolve once the job has ended.
*
* Resolves on a closed store too, so a console discarded while someone
* was joining on it does not strand them, and once `signal` aborts, so
* an abandoned join stops reading.
*
* @param signal ends the wait early
*/
async waitFinished(signal) {
let cursor = 0;
while (signal?.aborted !== true) {
const [chunks, next] = await this.backing.readFrom(cursor);
cursor = next;
if (chunks.some((c) => c.channel === Channel.CONTROL))
return;
await this.backing.wait(cursor, signal);
if (this.backing.closed)
return;
}
}
/**
* How the job ended, or null while no ending is stored.
*
* Reads only the last chunk, wherever the store keeps it: a read past
* the end answers the console's length, and the ending is always the
* last chunk. This is how a workspace learns how another one's job
* ended.
*/
async outcome() {
const [, end] = await this.backing.readFrom(END_OF_CONSOLE);
if (end === 0)
return null;
const [chunks] = await this.backing.readFrom(end - 1, 1);
const last = chunks[chunks.length - 1];
if (last?.channel !== Channel.CONTROL)
return null;
return decodeText(last.data);
}
/**
* Join everything the job has printed so far.
*
* With no channel, joins stdout and stderr in the order they were
* produced and omits the CONTROL chunk, which is status not output.
*/
async snapshot(channel = null) {
const [chunks] = await this.backing.readFrom(0);
if (channel === null) {
return concat(chunks.filter((c) => c.channel !== Channel.CONTROL).map((c) => c.data));
}
return concat(chunks.filter((c) => c.channel === channel).map((c) => c.data));
}
/** Release the underlying store. */
close() {
return this.backing.close();
}
}
//# sourceMappingURL=job_console.js.map