UNPKG

@struktoai/mirage-core

Version:

A Unified Virtual Filesystem For AI Agents

172 lines • 6.5 kB
// ========= 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