UNPKG

@struktoai/mirage-core

Version:

A Unified Virtual Filesystem For AI Agents

446 lines • 21.8 kB
import type { ProcessPermissions } from '../../process/config.ts'; import { ProcessTable } from '../../process/table.ts'; import { type JsonValue } from '../../types.ts'; import type { JobStore } from './store.ts'; import { Job, type JobRunner } from './types.ts'; import { type Clock } from '../../utils/clock.ts'; import { type RecordFields } from '../../workspace/record/types.ts'; /** * 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 declare class JobTable { readonly processes: ProcessTable; readonly parent: JobTable | null; readonly clock: Clock; store: JobStore; readonly owner: string; private jobs; private disowned; private lease; private checking; private stopping; private publishing; private publishTick; private closed; private reaping; private reaped; private reapingStopped; private swept; private sweepTick; private plane; private jobTables; /** * 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: ((runs: Record<string, JsonValue>[], signum: number) => Promise<Record<string, JsonValue>[]>) | 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?: JobStore | null, processes?: ProcessTable, parent?: JobTable | null, clock?: Clock); /** * 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?: JobTable | null): JobTable; private sessionJobs; /** * 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. */ submit(init: { command: string; run: JobRunner; abort: AbortController; cwd: string; sessionId?: string; parentPid?: number | null; limit?: number | null; simple?: boolean; }): Promise<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. */ load(job: Job): Promise<void>; /** * 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. */ sync(sessionId?: string): Promise<void>; get(jobId: number, sessionId?: string): Job | null; listJobs(sessionId?: string): Job[]; /** The jobs `jobs` shows: a `$( )`'s caller's, then its own. */ listing(sessionId?: string): Job[]; runningJobs(sessionId?: string): Job[]; /** * 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(): Job[]; allRunningJobs(): Job[]; /** * 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. */ kill(jobId: number, sessionId?: string, signal?: number): Promise<boolean>; /** * 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. */ disown(jobId: number, sessionId?: string): Promise<boolean>; /** * 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. */ closeSession(sessionId: string): Promise<Job[]>; /** 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. */ killAll(): Promise<Job[]>; /** * 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. */ close(): Promise<void>; /** * 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. */ wait(jobId: number, sessionId?: string, signal?: AbortSignal): Promise<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. */ waitAll(sessionId?: string, signal?: AbortSignal): Promise<Job[]>; /** * 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. */ reap(jobId: number, sessionId?: string): Promise<void>; /** * 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. */ popCompleted(sessionId?: string): Promise<Job[]>; /** 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(): void; /** * 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. */ remoteProcesses(): Promise<Record<string, Record<string, RecordFields>>>; /** * 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. */ signalRemote(owner: string, pid: number, signal: number, sessionId: string, permissions: () => ProcessPermissions | null): Promise<boolean>; private remote; /** * 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. */ private stopRunner; private endKilled; /** * End a killed job's console with the status its kill recorded, unless * its runner already ended it. */ private endStopping; /** * 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. */ private recordEnding; /** * Settle another workspace's job from its console's ending, or end it as * hung up when its owner's lease lapsed with none. */ private refresh; /** * 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. */ private joinRemote; /** * 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. */ private allocate; /** * 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. */ private insert; /** * 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. */ private remove; /** * 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. */ private write; /** * 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. */ private keepChecking; /** Stop checking once every job this table runs has ended its console. */ private dropChecking; /** * 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. */ private checkLists; /** * The live processes this workspace runs, as its record keeps them, each * with the native runs it has going. */ private 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. */ private publish; /** * 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. */ private stopAsked; /** * 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(): void; /** * 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. */ private sweep; /** Stop the native runs a lapsed owner left, without waiting. */ private reapLater; /** * 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`. */ private reapOwner; private keepLease; /** * 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. */ private dropLease; } //# sourceMappingURL=table.d.ts.map