@struktoai/mirage-core
Version:
A Unified Virtual Filesystem For AI Agents
181 lines • 7.85 kB
JavaScript
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