UNPKG

@struktoai/mirage-core

Version:

A Unified Virtual Filesystem For AI Agents

1,376 lines • 59.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 { ProcessTable } from "../../process/table.js";
import { PathSpec } from "../../types.js";
import { Channel, JobConsole, KILLED_OUTCOME, exitOutcome, killedOutcome, } from "../console/index.js";
import { GENERATION_BITS, HANGUP_EXIT_CODE, KILLED_EXIT_CODE, LIST_CHECK_SECONDS, LIST_MAX_RETRIES, PUBLISH_SECONDS, STOP_WAIT_SECONDS, WATCH_RETRY_SECONDS, } from "./constants.js";
import { RAMJobStore } from "./ram.js";
import { Job, JobStatus } from "./types.js";
import { encodeText } from "../bytes.js";
import { sleep } from "../../utils/abort.js";
import { SystemClock } from "../../utils/clock.js";
import { generationOf } from "../../workspace/record/types.js";
/** The status and exit code a console's ending records. */
function ended(outcome) {
    const [form = '', code = '0'] = outcome.split(':');
    return [form === KILLED_OUTCOME ? JobStatus.KILLED : JobStatus.COMPLETED, Number(code)];
}
/**
 * The status a stopped job answers: the one its program exited with after
 * handling the signal, when the job is that one program, else 128 plus
 * the signal, KILL's 137 without one. Mirrors Python's `_stopped_status`.
 */
function stoppedStatus(job) {
    const code = job.process?.programCode ?? null;
    if (job.simple && code !== null)
        return code;
    const signal = job.process?.signal ?? null;
    return signal === null ? KILLED_EXIT_CODE : 128 + signal;
}
/** The ending text an ended job's status and exit code record. */
function outcomeOf(job) {
    return job.status === JobStatus.KILLED ? killedOutcome(job.exitCode) : exitOutcome(job.exitCode);
}
/** Write the kill marker and the ending chunk. Mirrors Python's `_mark_killed`. */
async function markKilled(console, exitCode) {
    await console.emit(Channel.STDERR, encodeText('Killed'));
    await console.finish(killedOutcome(exitCode));
}
/**
 * End a job's console with how it ended, then take the ending the console
 * holds. Another workspace's `kill`, or the bound on a killed job's stop,
 * may have ended the console first; the store keeps the first ending, and
 * every workspace answers that one. Status is set before the console is
 * finished, so a reader released by the ending chunk always sees settled
 * fields. Mirrors Python's `_record`.
 */
async function record(job, status, exitCode) {
    job.status = status;
    job.exitCode = exitCode;
    if (status === JobStatus.KILLED)
        await markKilled(job.console, exitCode);
    else
        await job.console.finish(exitOutcome(exitCode));
    if (job.console.store.closed)
        return;
    const outcome = await job.console.outcome();
    if (outcome !== null)
        [job.status, job.exitCode] = ended(outcome);
}
/**
 * Stop a running job and settle it as killed by `signal`: its status is
 * 128 plus it. Mirrors Python's `_stop`.
 */
async function stop(job, signal = 9) {
    job.process?.terminate(signal);
    job.status = JobStatus.KILLED;
    job.exitCode = 128 + signal;
    await markKilled(job.console, job.exitCode);
}
/**
 * Run a job to completion and record how it ended.
 *
 * A job's console ends here, in the job's own task, once its runner has:
 * `kill` only stops the runner, so a program that handled the signal
 * leaves its own status (`stoppedStatus`) in the ending every workspace
 * reads. A job something stopped ends as killed however its runner
 * finished.
 */
async function settle(run, job) {
    let result;
    try {
        if (job.abort?.signal.aborted)
            throw new DOMException('aborted', 'AbortError');
        result = await run(job);
    }
    catch (err) {
        const aborted = (err instanceof Error || err instanceof DOMException) && err.name === 'AbortError';
        if (aborted || job.status !== JobStatus.RUNNING) {
            await record(job, JobStatus.KILLED, stoppedStatus(job));
            return aborted ? job.exitCode : 1;
        }
        // Recorded as the job's output and exit status rather than
        // rethrown: nobody awaits this task, so rethrowing would only
        // strand the error in an unhandled rejection.
        const msg = err instanceof Error ? err.message : String(err);
        await job.console.emit(Channel.STDERR, encodeText(msg));
        await record(job, JobStatus.COMPLETED, 1);
        return 1;
    }
    const [ioResult] = result;
    if (job.status !== JobStatus.RUNNING)
        await record(job, JobStatus.KILLED, stoppedStatus(job));
    else
        await record(job, JobStatus.COMPLETED, ioResult.exitCode);
    return ioResult.exitCode;
}
/**
 * Stop a job this table runs if another workspace ended its console. A
 * `kill` elsewhere ends the job's console, the one record of how the job
 * ended, and this stops the runner the way a local kill does, with the
 * signal the ending implies. Mirrors Python's `_adopt_ending`.
 */
async function adoptEnding(job) {
    if (job.status !== JobStatus.RUNNING)
        return;
    const outcome = await job.console.outcome();
    // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- the runner may settle it across the await
    if (outcome === null || job.status !== JobStatus.RUNNING)
        return;
    [job.status, job.exitCode] = ended(outcome);
    job.process?.terminate(job.exitCode > 128 ? job.exitCode - 128 : 9);
}
/**
 * Stop a job this table runs once another workspace ends it. A failed read
 * is retried until the runner settles, which aborts the watch: one that
 * gave up would leave a kill from elsewhere unheeded while the job ran on.
 *
 * @param job the job, run by this table
 * @param signal aborted once its runner settles, ending the watch
 */
async function watch(job, signal) {
    const store = job.console.store;
    while (!signal.aborted && !store.closed) {
        try {
            await store.waitEnded(signal);
            // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- the runner may settle across the await
            if (!signal.aborted)
                await adoptEnding(job);
            return;
        }
        catch (err) {
            console.debug('job watch failed:', err);
        }
        await sleep(WATCH_RETRY_SECONDS * 1000, signal);
    }
}
/** The consoles a session's list holds, listed or disowned. */
function heldConsoles(fields) {
    if (fields === null)
        return new Set();
    const listed = Object.values((fields.jobs ?? {}));
    return new Set([
        ...listed.map((entry) => entry.console),
        ...Object.keys((fields.disowned ?? {})),
    ]);
}
/**
 * The shell's job table: bash's per-shell job list, one list per session.
 *
 * This is job control, not a process table. A job is numbered `%N`
 * within the session that launched it, numbering restarts at 1 once that
 * session's list empties (GNU bash), and `jobs`, `wait`, `fg`, `kill` and
 * `disown` only ever see the calling session's list, exactly as one bash
 * never lists another bash's jobs. Runner PIDs are tracked separately:
 * `$!` and `jobs -l` report the managed PID, while `%N` names a
 * session-local job number. A killed job's process remains stopping until
 * the runner actually finishes, and its console ends then, or once the
 * runner had `STOP_WAIT_SECONDS` to stop and did not.
 *
 * The lists live in the workspace's job store, so every workspace on a
 * shared store sees a session's jobs with the same numbers. A job runs on
 * the table that started it, its owner; another table lists it, reads and
 * waits on its console, and kills it by ending that console, which its
 * owner watches for. `sync` brings a session's list up to date before a
 * builtin reads it. A job whose owner's lease lapsed with no ending was
 * lost with its owner and is ended as hung up, as a bash's job is when its
 * terminal goes.
 *
 * The table is still owned by the workspace rather than by a session,
 * because the workspace owns the tasks: teardown must stop every job it
 * runs in every session (`killAll`), snapshot capture reads every finished
 * one (`allJobs`), and a disowned job keeps running after its shell forgot
 * it. Those are the only cross-session views; every other method takes
 * the session whose list it reads, and the empty session id is the list a
 * caller with no session (a bare table in a test) shares.
 */
export class JobTable {
    processes;
    parent;
    clock;
    store;
    owner = crypto.randomUUID().replaceAll('-', '');
    jobs = new Map();
    // Jobs `disown` removed while still running, and ones another job's
    // number displaced: the shell forgets them, the workspace still owns
    // their tasks so teardown can stop them; each leaves once it finishes.
    disowned = [];
    lease = null;
    checking = null;
    // The bounds on killed jobs' stops (endKilled): each one's ending, and
    // the cut that ends its wait at once.
    stopping = new Map();
    publishing = null;
    // The publisher's pending sleep, which a close ends at once.
    publishTick = null;
    closed = false;
    reaping = new Set();
    // Lapsed owners this table already set about reaping, so a listing that
    // finds one again does not stop its runs again; and whether teardown
    // stopped the reaping, which its runtimes are closing for.
    reaped = new Set();
    reapingStopped = false;
    swept = false;
    // The startup sweep's pending wait for a lease to pass, which a teardown
    // ends at once.
    sweepTick = null;
    // The table whose store and owner the process plane is read and written
    // through: this one, or for a child shell's table the workspace's, since
    // a child shell keeps only its job list private.
    plane = this;
    // The table that holds each running job, by its runner's PID, kept on the
    // plane: a child shell's jobs are in its own table, where a stop another
    // workspace asks for has to find them.
    jobTables = new Map();
    /**
     * Stops the native runs a lapsed owner left, given them and the first
     * signal, and answers the ones this workspace cannot reach; the workspace
     * sets it, since it holds the runtimes that reach them.
     */
    reaper = null;
    /**
     * @param store the job plane holding each session's list and each job's
     *   console; null keeps them in memory, private to this table.
     */
    constructor(store = null, processes = new ProcessTable(), 
    // The table of the shell a `$( )` is part of, whose jobs `jobs` still
    // lists there, as bash's does; its `wait` and `kill` reach none of them.
    parent = null, 
    // The workspace clock a job's `createdAt` is read on.
    clock = new SystemClock()) {
        this.processes = processes;
        this.parent = parent;
        this.clock = clock;
        this.store = store ?? new RAMJobStore();
    }
    /**
     * A table for a child shell (`bash -c`, a script, `( )`, `$( )`): a job
     * list of its own on the same processes, kept in memory, since a child
     * shell's jobs end with it on this workspace and no other one lists
     * them. `parent` is the table a `$( )` still lists in `jobs`. It lists
     * and stops other workspaces' processes through this table, on the
     * workspace's store.
     */
    child(parent = null) {
        const table = new JobTable(null, this.processes, parent, this.clock);
        table.plane = this.plane;
        return table;
    }
    sessionJobs(sessionId) {
        let jobs = this.jobs.get(sessionId);
        if (jobs === undefined) {
            jobs = new Map();
            this.jobs.set(sessionId, jobs);
        }
        return jobs;
    }
    /**
     * Register a job in its session's list and start it.
     *
     * The job's number is taken from the store first, so two workspaces
     * starting a job on one session never share one; a process slot is
     * reserved next, and a refused one gives the number back. The runner is
     * deferred until the job and console are ready; failed setup cancels the
     * reservation without entering the supplied runner. Only fully
     * initialized jobs join the table.
     */
    async submit(init) {
        const sessionId = init.sessionId ?? '';
        const name = crypto.randomUUID().replaceAll('-', '');
        const createdAt = this.clock.now();
        await this.store.renew(this.owner);
        const jobId = await this.allocate(sessionId, {
            command: init.command,
            cwd: init.cwd,
            created_at: createdAt,
            owner: this.owner,
            console: name,
        });
        let job = null;
        let process;
        try {
            process = this.processes.start({
                sessionId,
                parentPid: init.parentPid ?? null,
                command: init.command,
                cwd: PathSpec.fromStrPath(init.cwd),
                run: async () => {
                    if (job === null)
                        return KILLED_EXIT_CODE;
                    if (job.status !== JobStatus.RUNNING)
                        return job.exitCode;
                    const watching = new AbortController();
                    void watch(job, watching.signal).catch((err) => {
                        console.debug('job watch failed:', err);
                    });
                    try {
                        return await settle(init.run, job);
                    }
                    finally {
                        watching.abort();
                        await this.recordEnding(job);
                        this.disowned = this.disowned.filter((j) => j !== job);
                        this.dropLease();
                        this.dropChecking();
                    }
                },
                cancel: () => {
                    init.abort.abort();
                },
                limit: init.limit ?? null,
            });
        }
        catch (error) {
            await this.remove(sessionId, new Set([name]), true);
            throw error;
        }
        try {
            const jobConsole = new JobConsole(this.store.console(name));
            jobConsole.bindExecution(process.info.executionId);
            job = new Job({
                id: jobId,
                command: init.command,
                abort: init.abort,
                cwd: init.cwd,
                sessionId,
                console: jobConsole,
                simple: init.simple ?? false,
                createdAt,
                owner: this.owner,
                consoleName: name,
            });
        }
        catch (error) {
            process.terminate();
            await this.remove(sessionId, new Set([name]), true);
            throw error;
        }
        job.process = process;
        this.sessionJobs(sessionId).set(job.id, job);
        const plane = this.plane;
        const pid = process.info.pid;
        plane.jobTables.set(pid, this);
        void process.join().then(() => plane.jobTables.delete(pid));
        // An abort from elsewhere stops the runner; one the runner's own stop
        // sent is that stop, which a KILL here would cut short.
        const onAbort = () => {
            if (!process.info.cancellationRequested)
                process.terminate();
        };
        init.abort.signal.addEventListener('abort', onAbort, { once: true });
        if (init.abort.signal.aborted)
            process.terminate();
        void process.join().then(() => {
            init.abort.signal.removeEventListener('abort', onAbort);
        });
        this.keepLease();
        this.keepChecking();
        return job;
    }
    /**
     * Insert a finished job restored from a snapshot into its session. Its
     * output moves onto a console in the store, so every workspace on it
     * reads the job the way it reads one that ran there. It keeps its number
     * unless another workspace's job holds it, and then takes the next free
     * one rather than hide that job.
     */
    async load(job) {
        const name = crypto.randomUUID().replaceAll('-', '');
        const store = this.store.console(name);
        const [chunks] = await job.console.readFrom(0);
        for (const chunk of chunks)
            await store.append(chunk.channel, chunk.data);
        const restored = new JobConsole(store, true);
        if (job.executionId !== null)
            restored.bindExecution(job.executionId);
        job.console = restored;
        job.owner = this.owner;
        job.consoleName = name;
        job.id = await this.insert(job.sessionId, job.id, {
            command: job.command,
            cwd: job.cwd,
            created_at: job.createdAt,
            owner: job.owner,
            console: name,
            ending: outcomeOf(job),
        });
        this.sessionJobs(job.sessionId).set(job.id, job);
    }
    /**
     * Bring a session's list up to date with the store.
     *
     * Adds the jobs other workspaces started, drops the ones they reaped or
     * disowned (a job this table runs keeps running, on the side list
     * `disown` uses), and settles a job whose console another workspace
     * ended, or another workspace's job whose owner's lease lapsed with no
     * ending. A job is the one listed under its number only while their
     * consoles match: once the list empties, numbering restarts and a new
     * job can take a reaped one's number.
     *
     * A console attached to a job first seen here is the job's only while
     * the list has not changed since it was read: a store drops the console
     * of a job reaped meanwhile, and attaching then would open an empty one
     * that never ends. So the list is read again after such an attach, and
     * the sync retried should it have moved. This table's own jobs are
     * matched after the last read, with no wait between, so one it started
     * meanwhile is never taken for another workspace's; a running one the
     * list no longer names stays listed, unless the list holds it as
     * disowned or another job took its number, and then it runs on unlisted,
     * as a disowned job does.
     */
    async sync(sessionId = '') {
        let fields = null;
        let remotes = new Map();
        for (let attempt = 0; attempt < LIST_MAX_RETRIES; attempt++) {
            fields = await this.store.load(sessionId);
            const listed = (fields?.jobs ?? {});
            const known = new Set(this.listJobs(sessionId).map((j) => j.consoleName));
            remotes = new Map(Object.entries(listed)
                .filter(([, entry]) => !known.has(entry.console))
                .map(([key, entry]) => [entry.console, this.remote(Number(key), sessionId, entry)]));
            if (remotes.size === 0 ||
                generationOf(await this.store.load(sessionId)) === generationOf(fields))
                break;
        }
        const entries = (fields?.jobs ?? {});
        const held = (fields?.disowned ?? {});
        const mine = new Map(this.listJobs(sessionId).map((j) => [j.consoleName, j]));
        const jobs = new Map();
        for (const [key, entry] of Object.entries(entries)) {
            const job = mine.get(entry.console) ?? remotes.get(entry.console);
            mine.delete(entry.console);
            if (job === undefined)
                continue;
            if (job.process === null && job.status === JobStatus.RUNNING && entry.ending !== undefined)
                [job.status, job.exitCode] = ended(entry.ending);
            jobs.set(Number(key), job);
        }
        for (const job of mine.values()) {
            if (job.process === null || job.status !== JobStatus.RUNNING)
                continue;
            if (job.consoleName in held || jobs.has(job.id))
                this.disowned.push(job);
            else
                jobs.set(job.id, job);
        }
        this.jobs.set(sessionId, new Map([...jobs].sort(([a], [b]) => a - b)));
        for (const job of jobs.values()) {
            if (job.process !== null)
                await adoptEnding(job);
            else if (job.status === JobStatus.RUNNING)
                await this.refresh(job);
        }
    }
    get(jobId, sessionId = '') {
        return this.jobs.get(sessionId)?.get(jobId) ?? null;
    }
    listJobs(sessionId = '') {
        return [...(this.jobs.get(sessionId)?.values() ?? [])];
    }
    /** The jobs `jobs` shows: a `$( )`'s caller's, then its own. */
    listing(sessionId = '') {
        return [...(this.parent?.listing(sessionId) ?? []), ...this.listJobs(sessionId)];
    }
    runningJobs(sessionId = '') {
        return this.listJobs(sessionId).filter((j) => j.status === JobStatus.RUNNING);
    }
    /**
     * Every session's jobs, for the workspace-wide entry points only. Snapshot
     * capture and the server summary read this; a shell builtin never
     * does, since bash lists only its own jobs.
     */
    allJobs() {
        return [...this.jobs.values()].flatMap((jobs) => [...jobs.values()]);
    }
    allRunningJobs() {
        return this.allJobs().filter((j) => j.status === JobStatus.RUNNING);
    }
    /**
     * Stop a job and record it as killed.
     *
     * The job answers as killed at once, but its console ends only once the
     * runner has stopped, so a program that handled the signal leaves its
     * own status in the ending every workspace reads. Nothing here waits for
     * the aborted runner. The signal is only observed where someone checks
     * it, which today is the executor between nodes and the commands that
     * take it, so a job sitting inside one long command would not notice
     * until it finished on its own; joining would hang the shell on exactly
     * the runaway job the caller is trying to stop. A runner that has not
     * stopped `STOP_WAIT_SECONDS` later has its console ended for it, with
     * the status the kill recorded, and one that has not begun has it ended
     * at once: no program ran that could leave its own.
     *
     * Another workspace's job is killed by ending its console; its owner
     * sees the ending and stops the runner. Should the job have ended
     * first, the ending it wrote stands and the job was not killed.
     *
     * @param signal the signal the job is stopped with; its status is 128
     *   plus it, KILL's 137 by default.
     */
    async kill(jobId, sessionId = '', signal = 9) {
        const job = this.get(jobId, sessionId);
        if (job?.status !== JobStatus.RUNNING)
            return false;
        if (job.process !== null) {
            await this.stopRunner(job, job.process, signal);
            return true;
        }
        if (!(await this.store.alive(job.owner)))
            await this.reapOwner(job.owner);
        await markKilled(job.console, 128 + signal);
        await this.refresh(job);
        return job.status === JobStatus.KILLED;
    }
    /**
     * Drop a job from its session's list without stopping it (`disown`):
     * the job keeps running, `jobs` no longer lists it and `wait` no
     * longer knows it. It stays on a side list so `killAll` at teardown
     * reaches its task.
     */
    async disown(jobId, sessionId = '') {
        const jobs = this.jobs.get(sessionId);
        const job = jobs?.get(jobId);
        if (jobs === undefined || job === undefined)
            return false;
        jobs.delete(jobId);
        const running = job.status === JobStatus.RUNNING;
        if (job.process !== null && running)
            this.disowned.push(job);
        await this.remove(sessionId, new Set([job.consoleName]), false, running);
        return true;
    }
    /**
     * Drop a session's job list when the session closes, stopping what is
     * still running, and return what was stopped.
     *
     * What happens to a bash's jobs when that bash exits: they are hung
     * up, and a later shell that reuses the same id starts from an empty
     * list numbered from 1 rather than inheriting jobs it never launched,
     * under a profile it may not share. Session closure revokes process
     * entry points and stops disowned runners too. Closing a session closes
     * it everywhere, so the jobs other workspaces run in it are stopped too,
     * the disowned ones included: their consoles are ended, which their
     * owners watch for. The list goes only while it is the one the stops were
     * taken from: a job another workspace starts meanwhile changes it, and
     * the close stops that job too before it tries again.
     */
    async closeSession(sessionId) {
        const stopped = new Map();
        for (let attempt = 0; attempt < LIST_MAX_RETRIES; attempt++) {
            const fields = await this.store.load(sessionId);
            await this.sync(sessionId);
            for (const job of this.runningJobs(sessionId)) {
                await this.kill(job.id, sessionId);
                stopped.set(job.consoleName, job);
            }
            for (const [name, entry] of Object.entries((fields?.disowned ?? {})))
                if (entry.owner !== this.owner)
                    await markKilled(new JobConsole(this.store.console(name)), KILLED_EXIT_CODE);
            if (await this.store.delete(sessionId, generationOf(fields))) {
                this.jobs.delete(sessionId);
                this.processes.revokeSession(sessionId);
                return [...stopped.values()];
            }
        }
        throw new Error(`job list of session ${sessionId} kept conflicting with another writer`);
    }
    /** Stop every running job this table runs in every session, returning
     * the ones that were running. Disowned jobs are stopped too: the shell
     * forgot them, the workspace did not, and a teardown that left them
     * running would leak tasks. Another workspace's jobs are its own to
     * stop. The reaping of lapsed owners' runs stops here as well, before the
     * runtimes it reaches them through close: none starts, and one under way
     * is waited for, bounded as a reap is. */
    async killAll() {
        this.reapingStopped = true;
        if (this.sweepTick !== null) {
            clearTimeout(this.sweepTick.timer);
            this.sweepTick.wake();
        }
        await Promise.allSettled([...this.reaping]);
        const running = this.allRunningJobs().filter((j) => j.process !== null);
        for (const job of [...running, ...this.disowned])
            if (job.status === JobStatus.RUNNING && job.process !== null)
                await stop(job);
        this.disowned = [];
        return running;
    }
    /**
     * Stop renewing this table's lease, at workspace teardown. Called after
     * killAll(), so every job this table ran has ended and other workspaces
     * need no lease to settle them; a killed job whose runner is still
     * stopping has its console ended now rather than at its bound. The
     * store itself belongs to the workspace's state store.
     */
    async close() {
        if (this.lease !== null)
            clearTimeout(this.lease);
        if (this.checking !== null)
            clearTimeout(this.checking);
        this.lease = null;
        this.checking = null;
        this.closed = true;
        for (const tick of [this.publishTick, this.sweepTick]) {
            if (tick === null)
                continue;
            clearTimeout(tick.timer);
            tick.wake();
        }
        await this.publishing;
        try {
            await this.store.publish(this.owner, {});
        }
        catch (err) {
            console.debug('process record not dropped:', err);
        }
        const stopping = [...this.stopping.values()];
        for (const { cut } of stopping)
            cut();
        await Promise.all(stopping.map(({ ending }) => ending));
    }
    /**
     * Block until a job ends, then return it.
     *
     * Joined on the console's ending chunk, never on the status field:
     * kill() and settle() both flip the status before their final
     * appends, and every await yields a microtask, so a status-based
     * return could let the caller snapshot and reap the job before
     * `Killed` or the ending chunk is persisted. A restored job has no
     * task and its console already holds the ending chunk, so it
     * returns without waiting.
     *
     * A killed job's console ends once its runner has stopped, or
     * `STOP_WAIT_SECONDS` after the kill: what it started stops in that
     * time (a handler finishes, a sandbox is reaped) and the status it
     * leaves is the one answered, as bash's `wait` answers once the child
     * has gone. An ending another workspace wrote is taken at once, before
     * this table's watcher stops the runner. The runner is joined too, for
     * at most `STOP_WAIT_SECONDS`, so what its task holds is let go before
     * this returns.
     *
     * Another workspace's job answers the status its ending records. While
     * none is stored its owner's lease is checked every third of it, and a
     * lapsed one ends the job as hung up.
     *
     * @param signal abandons the wait, so a caller that gave up stops its
     *   reads: a promise cannot be cancelled, where Python cancels its task.
     *   The job keeps running.
     */
    async wait(jobId, sessionId = '', signal) {
        const job = this.get(jobId, sessionId);
        if (job === null) {
            throw new Error(`unknown job: ${jobId.toString()}`);
        }
        if (job.process === null) {
            if (job.status === JobStatus.RUNNING)
                await this.joinRemote(job, signal);
            return job;
        }
        await job.console.waitFinished(signal);
        if (signal?.aborted === true)
            return job;
        await adoptEnding(job);
        let timer;
        let stop = () => undefined;
        const late = new Promise((resolve) => {
            stop = () => {
                resolve(null);
            };
            timer = setTimeout(stop, STOP_WAIT_SECONDS * 1000);
            signal?.addEventListener('abort', stop, { once: true });
        });
        try {
            await Promise.race([job.process.join(), late]);
        }
        finally {
            clearTimeout(timer);
            signal?.removeEventListener('abort', stop);
        }
        return job;
    }
    /**
     * Join every job in a session's list, returning the ones still
     * running.
     *
     * Every job, not only the running ones: a killed job's `Killed`
     * marker can still be in flight (see wait()), and bare `wait`
     * snapshots each console right after this returns. Joining a
     * finished job costs one read.
     */
    async waitAll(sessionId = '', signal) {
        const running = this.runningJobs(sessionId);
        for (const job of this.listJobs(sessionId)) {
            await this.wait(job.id, sessionId, signal);
        }
        return running;
    }
    /**
     * Remove one job from its session's list.
     *
     * What a targeted `wait`/`fg` does once the job has ended, matching
     * GNU bash, where a job waited on by id is deleted from the job list,
     * so a later `jobs` or `wait %N` no longer finds it.
     */
    async reap(jobId, sessionId = '') {
        const job = this.get(jobId, sessionId);
        if (job === null)
            return;
        this.jobs.get(sessionId)?.delete(jobId);
        await this.remove(sessionId, new Set([job.consoleName]));
    }
    /**
     * Return a session's completed/killed jobs and remove them from its
     * list.
     *
     * A reader holding a job's console keeps reading it: the console
     * outlives its table entry and dies with its last reader.
     */
    async popCompleted(sessionId = '') {
        const jobs = this.jobs.get(sessionId);
        if (jobs === undefined)
            return [];
        const completed = [...jobs.values()].filter((j) => j.status !== JobStatus.RUNNING);
        for (const j of completed)
            jobs.delete(j.id);
        if (completed.length > 0)
            await this.remove(sessionId, new Set(completed.map((j) => j.consoleName)));
        return completed;
    }
    /** Another workspace's job, as its list entry describes it. */
    /**
     * Publish the processes this table's workspace runs, now that one
     * started: the publishing goes on while any runs (`publish`).
     */
    processesChanged() {
        if (this.publishing !== null || this.closed)
            return;
        this.publishing = this.publish();
    }
    /**
     * The processes other workspaces on the store run, owner to PID to
     * process, as each last published them. A lapsed owner's are left out:
     * whatever it ran was lost with it, and the native runs it left are
     * stopped meanwhile.
     */
    async remoteProcesses() {
        if (this.plane !== this)
            return this.plane.remoteProcesses();
        const found = {};
        for (const [owner, records] of Object.entries(await this.store.processes())) {
            if (owner === this.owner || Object.keys(records).length === 0)
                continue;
            if (await this.store.alive(owner))
                found[owner] = records;
            else
                this.reapLater(owner);
        }
        return found;
    }
    /**
     * Signal a process another workspace runs, named by a prefix of its
     * owner's id and the owner's PID: its owner stops it once it takes the
     * stop, within `PUBLISH_SECONDS`. Signal 0 only probes. A lapsed owner's
     * process is gone, and the native runs it left are stopped instead. The
     * grants are read once the store has answered, just before anything is
     * stopped, so a profile replaced meanwhile stops nothing under the old
     * one. Answers whether one live process was signalled; throws EPERM for
     * one the session sees but may not stop.
     *
     * @param owner a prefix of the owner's id, as `ps` shows it.
     * @param pid the process, by the owner's PID.
     * @param signal the signal, 0 to probe.
     * @param sessionId the calling session.
     * @param permissions whose processes it may see and stop, null once the
     *   caller's view is revoked.
     */
    async signalRemote(owner, pid, signal, sessionId, permissions) {
        if (this.plane !== this)
            return this.plane.signalRemote(owner, pid, signal, sessionId, permissions);
        const records = await this.store.processes();
        let owners = Object.keys(records).filter((found) => found !== this.owner &&
            found.startsWith(owner) &&
            Object.hasOwn(records[found] ?? {}, String(pid)));
        if (owners.length > 1) {
            const live = await Promise.all(owners.map((found) => this.store.alive(found)));
            owners = owners.filter((_, at) => live[at]);
        }
        const [found] = owners;
        if (owners.length !== 1 || found === undefined)
            return false;
        const record = records[found]?.[String(pid)] ?? {};
        const own = record.session === sessionId;
        const lapsed = !(await this.store.alive(found));
        const granted = permissions();
        if (granted === null || (granted.list === 'session' && !own))
            return false;
        if (lapsed) {
            await this.reapOwner(found);
            return false;
        }
        if (granted.kill === 'session' && !own)
            throw Object.assign(new Error('Operation not permitted'), { code: 'EPERM' });
        if (signal !== 0)
            await this.store.requestStop(found, pid, signal);
        return true;
    }
    remote(jobId, sessionId, entry) {
        return new Job({
            id: jobId,
            command: entry.command,
            cwd: entry.cwd,
            createdAt: entry.created_at,
            console: new JobConsole(this.store.console(entry.console)),
            sessionId,
            owner: entry.owner,
            consoleName: entry.console,
        });
    }
    /**
     * End a killed job's console once its runner has stopped, or
     * `STOP_WAIT_SECONDS` after the kill, whichever comes first. A runner
     * that settles ends the console itself, with the status it left; this
     * ends it with the status the kill recorded when the runner never got
     * that far (aborted before it began) or is still stopping, or at once
     * when cut (close()). Mirrors Python's `_end_killed`.
     */
    /**
     * Stop a job this table runs and record it as killed by `signal`. One
     * that has not begun has its console ended at once: no program ran that
     * could leave its own status. One that has has its console ended once its
     * runner stops, or `STOP_WAIT_SECONDS` later.
     */
    async stopRunner(job, process, signal) {
        if (!process.begun) {
            await stop(job, signal);
            await this.recordEnding(job);
            this.dropLease();
            this.dropChecking();
            return;
        }
        process.terminate(signal);
        job.status = JobStatus.KILLED;
        job.exitCode = 128 + signal;
        this.endKilled(job, process);
    }
    endKilled(job, process) {
        let cut = () => undefined;
        const late = new Promise((resolve) => {
            const timer = setTimeout(resolve, STOP_WAIT_SECONDS * 1000);
            cut = () => {
                clearTimeout(timer);
                resolve();
            };
        });
        const ending = (async () => {
            await Promise.race([process.join(), late]);
            cut();
            await this.endStopping(job);
        })()
            .catch((err) => {
            console.debug('ending a killed job failed:', err);
        })
            .finally(() => this.stopping.delete(job));
        this.stopping.set(job, { ending, cut });
    }
    /**
     * End a killed job's console with the status its kill recorded, unless
     * its runner already ended it.
     */
    async endStopping(job) {
        if (job.console.finished)
            return;
        await markKilled(job.console, job.exitCode);
        await this.recordEnding(job);
        this.dropLease();
        this.dropChecking();
    }
    /**
     * Keep how a job this table ran ended in its list entry, or drop its
     * disowned record, which nothing else would. The entry outlives the
     * console's output, which a shared store expires, so a workspace that
     * attaches after the output went still reads how the job ended instead
     * of taking it as hung up. A failed write is logged: the console still
     * holds the ending.
     */
    async recordEnding(job) {
        const ending = outcomeOf(job);
        try {
            for (let attempt = 0; attempt < LIST_MAX_RETRIES; attempt++) {
                const fields = await this.store.load(job.sessionId);
                if (fields === null)
                    return;
                const jobs = { ...(fields.jobs ?? {}) };
                const held = { ...(fields.disowned ?? {}) };
                if (job.consoleName in held) {
                    // eslint-disable-next-line @typescript-eslint/no-dynamic-delete -- held is keyed by console name
                    delete held[job.consoleName];
                }
                else {
                    const entry = jobs[job.id.toString()];
                    if (entry?.console !== job.consoleName || entry.ending === ending)
                        return;
                    jobs[job.id.toString()] = { ...entry, ending };
                }
                if (await this.write(job.sessionId, fields, jobs, Number(fields.next ?? 1), held))
                    return;
            }
            console.debug('job ending kept conflicting:', job.id);
        }
        catch (err) {
            console.debug('job ending not kept:', err);
        }
    }
    /**
     * Settle another workspace's job from its console's ending, or end it as
     * hung up when its owner's lease lapsed with none.
     */
    async refresh(job) {
        let outcome = await job.console.outcome();
        if (outcome === null) {
            if (await this.store.alive(job.owner))
                return;
            this.reapLater(job.owner);
            await markKilled(job.console, HANGUP_EXIT_CODE);
            outcome = await job.console.outcome();
            if (outcome === null)
                return;
        }
        ;
        [job.status, job.exitCode] = ended(outcome);
    }
    /**
     * Block until another workspace's job ends, or `signal` aborts. One read
     * follows the console across every lease check, so a long wait never
     * stacks readers, and an abandoned one stops reading.
     */
    async joinRemote(job, signal) {
        const lease = this.store.leaseSeconds;
        const finished = job.console.waitFinished(signal);
        finished.catch((err) => {
            console.debug('job wait failed:', err);
        });
        while (job.status === JobStatus.RUNNING && signal?.aborted !== true) {
            if (lease === null) {
                await finished;
            }
            else {
                let timer;
                const check = new Promise((resolve) => {
                    timer = setTimeout(() => {
                        resolve(null);
                    }, (lease * 1000) / 3);
                });
                try {
                    await Promise.race([finished, check]);
                }
                finally {
                    clearTimeout(timer);
                }
            }
            if (job.console.store.closed)
                return;
            await this.refresh(job);
        }
    }
    /**
     * Add a job to a session's list under the next number. Numbering
     * restarts at 1 once the list is empty, as GNU bash restarts it; without
     * that, reaping after a targeted `wait` would leave a later `wait %1`
     * pointing at nothing.
     */
    async allocate(sessionId, entry) {
        for (let attempt = 0; attempt < LIST_MAX_RETRIES; attempt++) {
            const fields = await this.store.load(sessionId);
            const jobs = { ...(fields?.jobs ?? {}) };
            const jobId = Object.keys(jobs).length > 0 ? Number(fields?.next ?? 1) : 1;
            jobs[jobId.toString()] = entry;
            if (await this.write(sessionId, fields, jobs, jobId + 1))
                return jobId;
        }
        throw new Error(`job list of session ${sessionId} kept conflicting with another writer`);
    }
    /**
     * Add a job to a session's list under its own number, or under the next
     * free one should another job hold it, and return the number it took.
     */
    async insert(sessionId, jobId, entry) {
        for (let attempt = 0; attempt < LIST_MAX_RETRIES; attempt++) {
            const fields = await this.store.load(sessionId);
            const jobs = { ...(fields?.jobs ?? {}) };
            const following = Number(fields?.next ?? 1);
            let taken = jobId;
            if (taken.toString() in jobs) {
                taken = following;
                while (taken.toString() in jobs)
                    taken += 1;
            }
            jobs[taken.toString()] = entry;
            if (await this.write(sessionId, fields, jobs, Math.max(following, taken + 1)))
                return taken;
        }
        throw new Error(`job list of session ${sessionId} kept conflicting with another writer`);
    }
    /**
     * Drop jobs from a session's list, named by their consoles, never by
     * number: another workspace may have reaped one and a new job taken its
     * number. With `giveBack` the jobs never started, so the last number
     * handed out is free again, as no job took it. With `disowned` the jobs
     * still run, disowned: their entries move to the list's disowned ones,
     * by console, where the session's close still reaches them.
     */
    async remove(sessionId, names, giveBack = false, disowned = false) {
        for (let attempt = 0; attempt < LIST_MAX_RETRIES; attempt++) {
            const fields = await this.store.load(sessionId);
            if (fields === null)
                return;
            const stored = (fields.jobs ?? {});
            const dropped = new Set(Object.entries(stored)
                .filter(([, entry]) => names.has(entry.console))
                .map(([key]) => Number(key)));
            if (dropped.size === 0)
                return;
            const jobs = Object.fromEntries(Object.entries(stored).filter(([key]) => !dropped.has(Number(key))));
            let following = Number(fields.next ?? 1);
            if (giveBack && dropped.has(following - 1))
                following -= 1;
            const held = { ...(fields.disowned ?? {}) };
            if (disowned)
                for (const [key, entry] of Object.entries(stored))
                    if (dropped.has(Number(key)) && entry.ending === undefined)
                        held[entry.console] = entry;
            if (await this.write(sessionId, fields, jobs, following, held))
                return;
        }
        throw new Error(`job list of session ${sessionId} kept conflicting with another writer`);
    }
    /**
     * Write a session's list over the one read, iff still current.
     * `disowned` is the disowned jobs still running, by console; null keeps
     * the ones read. A list written where none is starts at a random
     * generation rather than at 1, so a list deleted and written again never
     * repeats the generation a reader of the old one holds: a close that read
     * the old one deletes the new one only once it has stopped its jobs.
     */
    write(sessionId, fields, jobs, following, disowned = null) {
        const expected = generationOf(fields);
        const held = disowned ?? (fields?.disowned ?? {});
        const generation = fields !== null ? expected + 1 : 1 + Math.floor(Math.random() * 2 ** GENERATION_BITS);
        const record = { next: following, jobs, generation };
        if (Object.keys(held).length > 0)
            record.disowned = held;
        return this.store.casSet(sessionId, record, expected);
    }
    /**
     * Renew this table's lease while it runs jobs on a store that leases
     * owners, with the consoles of the jobs it still runs, so a quiet one
     * keeps its output while this table lives.
     */
    /** Check the lists of the jobs this table runs every
     * `LIST_CHECK_SECONDS` while it runs any. */
    keepChecking() {
        if (this.checking !== null)
            return;
        const timer = setTimeout(() => {
            void this.checkLists()
                .catch((err) => {
                console.debug('job list check failed:', err);
            })
                .finally(() => {
                if (this.checking !== timer)
                    return;
                this.checking = null;
                this.keepChecking();
            });
        }, LIST_CHECK_SECONDS * 1000);
        this.checking = timer;
    }
    /** Stop checking once every job this table runs has ended its console. */
    dropChecking() {
        if (this.checking === null)
            return;
        if ([...this.allJobs(), ...this.disowned].some((j) => j.process !== null && !j.console.finished))
            return;
        clearTimeout(this.checking);
        this.checking = null;
    }
    /**
     * Stop each job this table runs that its session's list dropped.
     *
     * A close, a reap and a disown of a running job each end its console or
     * keep its entry, so a list that dropped one whose console is still open
     * went with its workspace, deleted elsewhere: an ending written there
     * first would be deleted before this table's watch read it. The job is
     * stopped as a closed session's is. Each session's list is read once a
     * round, however many of its jobs run here; a round that failed for a
     * session, on its list or on a console, is tried again the next round.
     */
    async checkLists() {
        const sessions = new Map();
        for (const job of [...this.allJobs(), ...this.disowned]) {
            if (job.process === null || job.status !== JobStatus.RUNNING)
                continue;
            const jobs = sessions.get(job.sessionId) ?? [];
            jobs.push(job);
            sessions.set(job.sessionId, jobs);
        }
        for (const [sessionId, jobs] of sessions) {
            try {
                const held = heldConsoles(await this.store.load(sessionId));
                for (const job of jobs) {
                    if (held.has(job.consoleName))
                        continue;
                    await adoptEnding(job);
                    if (job.status === JobStatus.RUNNING && job.process !== null)
                        await this.stopRunner(job, job.process, 9);
                }
            }
            catch (err) {
                console.debug('job list check failed:', err);
            }
        }
    }
    /**
     * The live processes this workspace runs, as its record keeps them, each
     * with the native runs it has going.
     */
    records() {
        const records = {};
        for (const handle of this.processes.live()) {
            const info = handle.info;
            records[String(info.pid)] = {
                pid: info.pid,
                parent: info.parentPid,
                group: info.groupId,
                session: info.sessionId,
                command: info.command,
                cwd: info.cwd.virtual,
                started_at: info.startedAt,
                state: info.state,
                execution: info.executionId,
                runs: Object.values(handle.runs),
            };
        }
        return records;
    }
    /**
     * Publish the processes this workspace runs every `PUBLISH_SECONDS`
     * while it runs any, and stop those other workspaces asked it to stop.
     * Once it runs none, its record goes and the publishing stops. The
     * owner's lease is renewed once ahead of the first record it vouches for,
     * then kept by the table's lease renewal (`keepLease`) while any process
     * runs, on its own timer whatever a round costs. The record is written
     * again every third of the lease, so it outlives its store's expiry and
     * comes back should a workspace have taken this one for lapsed. Each
     * record carries `seen_at`, when its processes were read, the last time
     * this workspace vouched for them and their native runs, on the host's
     * clock, which a host reap compares with `ps`. A failed
     * round is logged and tried again the next one, and a stop that fails
     * leaves the others it came with to be applied; the sleep between rounds
     * keeps no host alive on its own.
     */
    async publish() {
        let published = null;
        const lease = this.store.leaseSeconds;
        const rewrite = lease !== null ? Math.max(1, Math.floor(lease / 3 / PUBLISH_SECONDS)) : 0;
        let renewed = lease === null;
        let rounds = 0;
        for (;;) {
            const seen = Date.now() / 1000;
            const records = this.records();
            const text = JSON.stringify(records);
            try {
                if (!renewed) {
                    await this.store.renew(this.owner);
                    renewed = true;
                    this.keepLease();
                }
                if (text !== published || (rewrite > 0 && rounds % rewrite === 0)) {
                    await this.store.publish(this.owner, Object.fromEntries(Object.entries(records).map(([pid, record]) => [pid, { ...record, seen_at: seen }])));
                    published = text;
                }
                for (const [pid, signal] of await this.store.takeStops(this.owner)) {
                    try {
                        await this.stopAsked(pid, signal);
                    }
                    catch (err) {
                        console.debug(`stop of ${String(pid)} not applied:`, err);
                    }
                }
            }
            catch (err) {
                console.debug('process publish failed:', err);
            }
            if (this.closed || (published === '{}' && this.processes.live().length === 0)) {
                this.publishing = null;
                this.dropLease();
                return;
            }
            rounds += 1;
            await new Promise((wake) => {
                const timer = setTimeout(wake, PUBLISH_SECONDS * 1000);
                timer.unref?.();
                this.publishTick = { timer, wake };
            });
            this.publishTick = null;
        }
    }
    /**
     * Stop one of this workspace's processes another workspace asked to
     * stop: a job's runner the way `kill` stops a job, through the table
     * that holds it, a child shell's included; any other runner directly.
     */
    async stopAsked(pid, signal) {
        const table = this.jobTables.get(pid) ?? this;
        for (const job of table.allJobs()) {
            if (job.process !== null && job.pid === pid && job.status === JobStatus.RUNNING) {
                await table.kill(job.id, job.sessionId, signal);
                return;
            }
        }
        for (const handle of this.processes.live()) {
            if (handle.info.pid === pid) {
                handle.terminate(signal);
                return;
            }
        }
    }
    /**
     * Stop what the lapsed instances on the store left running, without
     * waiting: the workspace calls it as it starts, so what a crashed instance
     * ran stops before anything lists it. Once per table, and only on a store
     * that leases owners: on any other, no owner lapses.
     */
    reapLapsed() {
        if (this.swept || this.reapingStopped || this.store.leaseSeconds === null)
            return;
        this.swept = true;
        const sweeping = this.sweep().finally(() => {
            this.reaping.delete(sweeping);
        });
        this.reaping.add(sweeping);
    }
    /**
     * Reap the lapsed owners on the store, and once more a lease later while
     * any other owner still held one: an instance that died just before this
     * one started still holds its lease until then.
     */
    async sweep() {
        try {
            const live = await this.remoteProcesses();
            const lease = this.store.leaseSeconds;
            if (this.reapingStopped || this.closed || Object.keys(live).length === 0 || lease === null)
                return;
            const deadline = performance.now() + lease * 1000;
            let remaining = lease * 1000;
            while (remaining > 0) {
                await new Promise((wake) => {
                    const timer = setTimeout(wake, Math.max(1, remaining));
                    timer.unref?.();
                    this.sweepTick = { timer, wake };
                });
                this.sweepTick = null;
                // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- a teardown may stop it across the await
                if (this.reapingStopped || this.closed)
                    return;
                remaining = deadline - performance.now();
            }
            await this.remoteProcesses();
        }
        catch (err) {
            console.debug('lapsed owners not swept:', err);
        }
    }
    /** Stop the native runs a lapsed owner left, without waiting. */
    reapLater(owner) {
        if (this.reapingStopped || this.reaped.has(owner))
            return;
        this.reaped.add(owner);
        const reaping = this.reapOwner(owner).finally(() => {
            this.reaping.delete(reaping);
        });
        this.reaping.add(reaping);
    }
    /**
     * Stop the native runs a lapsed owner left, those this workspace reaches,
     * and keep its record holding only the rest, for a workspace that reaches
     * them. The owner's lease is checked again before each run is stopped, so
     * one that renewed meanwhile keeps its runs, and its record as it is. Each
     * run goes to the reaper with its record's `seen_at`.
     */
    async reapOwner(owner) {
        try {
            const records = (await this.store.processes())[owner] ?? {};
            const runsOf = (record) => record.runs;
            const left = [];
            for (const record of Object.values(records)) {
                for (const run of runsOf(record)) {
                    if (await this.store.alive(owner))
                        return;
                    const vouched = { ...run, seen_at: (record.seen_at ?? null) };
                    if (this.reaper === null || (await this.reaper([vouched], 15)).length > 0)
                        left.push(run);
                }
            }
            const kept = {};
            for (const [pid, record] of Object.entries(records)) {
                const remaining = runsOf(record).filter((run) => left.includes(run));
                if (remaining.length > 0)
                    kept[pid] = { ...record, runs: remaining };
            }
            if (!(await this.store.alive(owner)))
                await this.store.publish(owner, kept);
        }
        catch (err) {
            console.debug(`lapsed owner ${owner} not reaped:`, err);
        }
    }
    keepLease() {
        const lease = this.store.leaseSeconds;
        if (lease === null || this.lease !== null)
            return;
        const renew = () => {
            this.lease = setTimeout(() => {
                // A failed renewal is logged and retried: should the lease lapse
                // meanwhile, other workspaces end this table's jobs as hung up and
                // the watchers here stop them, so the two never disagree.
                const consoles = [...this.allJobs(), ...this.disowned]
                    .filter((j) => j.process !== null && !j.console.finished)
                    .map((j) => j.consoleName);
                this.store.renew(this.owner, consoles).catch((err) => {
                    console.debug('job lease renewal failed:', err);
                });
                if (this.lease !== null)
                    renew();
            }, (lease * 1000) / 3);
        };
        renew();
    }
    /**
     * Stop renewing once every job this table runs has ended its console and
     * no process it publishes runs: a killed one still stopping keeps the
     * lease, or other workspaces would end it as hung up, and a published
     * process keeps it, or they would take its record for lapsed; the
     * publishing drops it as it stops.
     */
    dropLease() {
        if (this.lease === null)
            return;
        if ((this.publishing !== null && this.processes.live().length > 0) ||
            [...this.allJobs(), ...this.disowned].some((j) => j.process !== null && !j.console.finished))
            return;
        clearTimeout(this.lease);
        this.lease = null;
    }
}
//# sourceMappingURL=table.js.map