UNPKG

@struktoai/mirage-core

Version:

A Unified Virtual Filesystem For AI Agents

181 lines • 7.85 kB
import { DEFAULT_PROCESS_PERMISSIONS } from "./config.js"; import { ProcessHandle } from "./handle.js"; import { newExecutionId } from "../execution/context.js"; import { systemNow } from "../utils/clock.js"; /** Workspace-owned live runners; exited handles retain their own results. */ export class ProcessTable { bind; ids; changed; now; nextPid = 1; runners = new Map(); ancestors = new Map(); generations = new Map(); stopped = false; /** * @param bind runs each runner as part of its execution, so what it * records names it; the workspace hands over the caller's * `runWithExecution`. Without one a runner runs as it is. */ /** * @param bind runs a process's body under its execution id. * @param ids where a process's execution id is drawn when its caller names * none; omitted means the system source. * @param changed called once a runner is started, so whoever publishes the * live runners knows to. * @param now the epoch seconds a process starts at, which `ps` shows; * omitted means the running workspace's system time. */ constructor(bind = (_id, run) => run(), ids, changed, now = systemNow) { this.bind = bind; this.ids = ids; this.changed = changed; this.now = now; } /** Whether a runner that was started has yet to begin. */ queued() { for (const { handle } of this.runners.values()) if (!handle.begun) return true; return false; } /** * Host-only: the supplied runner must admit commands before effects. * `limit` is the most runners the session may hold, this one included; * stopping runners count until they finish cleanup and leave the live registry. * Past it this throws EAGAIN, what fork(2) answers at `ulimit -u`. * `started` is what the caller sets up from the process's identity before * its runner runs (the session's `$BASHPID`); a caller that passes it has * nothing left to do first, so the runner then starts at once, unless a * runner started before it has yet to begin: runners begin in the order * they were started, so a job's redirect lands before the line that * started it reads the file. */ start(init) { if (this.stopped) throw new Error('process table is stopped'); if (init.limit != null && [...this.runners.values()].filter(({ handle }) => handle.info.sessionId === init.sessionId) .length >= init.limit) throw Object.assign(new Error('Resource temporarily unavailable'), { code: 'EAGAIN' }); const parent = init.parentPid == null ? undefined : this.runners.get(init.parentPid); if (init.parentPid != null && (parent === undefined || parent.handle.info.cancellationRequested)) throw new Error('parent process is no longer accepting children'); const pid = this.nextPid++; const handle = new ProcessHandle({ pid, executionId: init.executionId ?? newExecutionId(this.ids), parentPid: init.parentPid ?? null, groupId: (init.parentPid == null ? undefined : this.runners.get(init.parentPid)?.handle.info.groupId) ?? pid, sessionId: init.sessionId, command: init.command, cwd: init.cwd, startedAt: this.now(), state: 'running', cancellationRequested: false, exitCode: null, failure: null, }, init.run, () => { init.cancel(); }, (id) => { this.runners.delete(id); this.ancestors.delete(id); }, (signal) => { this.terminateChildren(pid, signal); }, this.bind); const eager = init.started !== undefined && !this.queued(); this.runners.set(pid, { generation: this.generations.get(init.sessionId) ?? 0, handle }); this.ancestors.set(pid, init.parentPid == null ? [] : [...(this.ancestors.get(init.parentPid) ?? []), init.parentPid]); init.started?.(handle.info); handle.start(eager); this.changed?.(); return handle; } /** * Request cancellation of every live runner whose parent is `pid` or * an ancestor further up, so grandchildren are reached after an * intermediate runner has exited. * * @param signal what the stop stands for, as the parent's. */ terminateChildren(pid, signal = 9) { for (const child of this.live()) if (this.ancestors.get(child.info.pid)?.includes(pid) === true) child.terminate(signal); } view(sessionId, permissions = () => DEFAULT_PROCESS_PERMISSIONS) { const generation = this.generations.get(sessionId) ?? 0; const valid = () => (this.generations.get(sessionId) ?? 0) === generation; const allowed = (scope, handle) => scope === 'workspace' || handle.info.sessionId === sessionId; const visible = (handle) => { const entry = this.runners.get(handle.info.pid); if (handle.info.sessionId === sessionId && entry !== undefined && entry.generation !== generation) return null; if (!valid() || !allowed(permissions().list, handle)) return null; return handle.info; }; const signalTarget = (pid) => { const entry = this.runners.get(pid); if (entry === undefined || visible(entry.handle) === null) return null; if (!allowed(permissions().kill, entry.handle)) throw Object.assign(new Error('Operation not permitted'), { code: 'EPERM' }); return entry.handle; }; return Object.freeze({ list: () => Object.freeze([...this.runners.values()].flatMap(({ handle }) => { const info = visible(handle); return info === null ? [] : [info]; })), checkSpawn: () => { if (!valid()) throw Object.assign(new Error('process spawn is not permitted'), { code: 'EACCES' }); }, probe: (pid) => signalTarget(pid) !== null, terminate: (pid, signal) => signalTarget(pid)?.terminate(signal) ?? false, permissions: () => (valid() ? permissions() : null), }); } /** * Revoke the session's views and cancel its runners. A closed session's * ID can be reused and a replaced profile grants a new view, so neither * may keep an entry point, or a runner admitted under the old grants. */ revokeSession(sessionId) { this.generations.set(sessionId, (this.generations.get(sessionId) ?? 0) + 1); for (const process of this.live()) if (process.info.sessionId === sessionId) process.terminate(); } /** Host-only inventory, including disowned and stopping runners. */ live() { return [...this.runners.values()].map((entry) => entry.handle); } /** Join managed runners before releasing their workspace resources. */ async drain() { await Promise.all(this.live().map((process) => process.join())); } /** Close admission and request cancellation; stopping is not completion. */ stop() { this.stopped = true; const errors = []; for (const process of this.live()) { try { process.terminate(); } catch (error) { errors.push(error); } } if (errors.length > 0) throw new AggregateError(errors, 'process cancellation failed'); } } //# sourceMappingURL=table.js.map