426e9eeabd
Voice Workbench / headless workbench (mocked backends) (push) Has been cancelled
Voice Workbench / real acoustic lane (nightly, provisioned only) (push) Has been cancelled
ci / test (push) Has been cancelled
ci / lint-and-format (push) Has been cancelled
ci / build (push) Has been cancelled
ci / dev-startup (push) Has been cancelled
gitleaks / gitleaks (push) Has been cancelled
Markdown Links / Relative Markdown Links (push) Has been cancelled
Quality (Extended) / Homepage Build (PR smoke) (push) Has been cancelled
Quality (Extended) / Comment-only diff guard (push) Has been cancelled
Quality (Extended) / Format + Type Safety Ratchet (push) Has been cancelled
Quality (Extended) / Develop Gate (secret scan + UI determinism) (push) Has been cancelled
Quality (Extended) / Develop Gate (lint) (push) Has been cancelled
Chat shell gestures / Chat shell gesture + parity e2e (push) Has been cancelled
Cloud Gateway Discord / Test (push) Has been cancelled
Benchmark Bridge Tests / benchmark (bunx @biomejs/biome check packages/lifeops-bench/src, benchmark-lint) (push) Has been cancelled
Benchmark Bridge Tests / benchmark (bunx vitest run --config packages/lifeops-bench/vitest.config.ts --root packages/lifeops-bench --passWithNoTests, benchmark-tests) (push) Has been cancelled
Build Agent Image / build-and-push (push) Has been cancelled
Dev Smoke / bun run dev onboarding chat (push) Has been cancelled
Dev Smoke / Vite HMR dependency-level smoke (push) Has been cancelled
Electrobun Submodule Guard / electrobun gitlink is fetchable (push) Has been cancelled
Publish @elizaos/example-code / check_npm (push) Has been cancelled
Publish @elizaos/example-code / publish_npm (push) Has been cancelled
Publish @elizaos/plugin-elizacloud / verify_version (push) Has been cancelled
Publish @elizaos/plugin-elizacloud / publish_npm (push) Has been cancelled
Sandbox Live Smoke / Sandbox live smoke (push) Has been cancelled
Snap Build & Test / Build Snap (amd64) (push) Has been cancelled
Snap Build & Test / Build Snap (arm64) (push) Has been cancelled
Test Packaging / elizaos CLI global-install smoke (node + bun) (push) Has been cancelled
Cloud Gateway Webhook / Test (push) Has been cancelled
Cloud Tests / lint-and-types (push) Has been cancelled
Cloud Tests / unit-tests (push) Has been cancelled
Cloud Tests / integration-tests (push) Has been cancelled
Cloud Tests / e2e-tests (push) Has been cancelled
CodeQL Advanced / Analyze (javascript-typescript) (push) Has been cancelled
Deploy Apps Worker (Product 2) / Determine environment (push) Has been cancelled
Deploy Apps Worker (Product 2) / Deploy apps worker to apps-control host (${{ needs.determine-env.outputs.environment }}) (push) Has been cancelled
Deploy Eliza Provisioning Worker / Determine environment (push) Has been cancelled
Deploy Eliza Provisioning Worker / Deploy worker to Hetzner host (${{ needs.determine-env.outputs.environment }} @ ${{ needs.determine-env.outputs.deployment_sha }}) (push) Has been cancelled
Dev Smoke / Classify changed paths (push) Has been cancelled
supply-chain / sbom (push) Has been cancelled
supply-chain / vulnerability-scan (push) Has been cancelled
Build, Push & Deploy to Phala Cloud / build-and-push (push) Has been cancelled
Test Packaging / Validate Packaging Configs (push) Has been cancelled
Test Packaging / Build & Test PyPI Package (push) Has been cancelled
Test Packaging / PyPI on Python ${{ matrix.python }} (push) Has been cancelled
Test Packaging / Pack & Test JS Tarballs (push) Has been cancelled
UI Fixture E2E / ui-fixture-e2e (push) Has been cancelled
UI Fixture E2E / fixture-e2e (push) Has been cancelled
UI Story Gate / story-gate (push) Has been cancelled
vault-ci / test (macos-latest) (push) Has been cancelled
vault-ci / test (ubuntu-latest) (push) Has been cancelled
vault-ci / test (windows-latest) (push) Has been cancelled
vault-ci / app-core wiring tests (push) Has been cancelled
verify-patches / verify patches/CHECKSUMS.sha256 (push) Has been cancelled
Voice Benchmark Smoke / voice-emotion fixture smoke (push) Has been cancelled
Voice Benchmark Smoke / voiceagentbench fixture smoke (push) Has been cancelled
Voice Benchmark Smoke / voicebench-quality unit smoke (push) Has been cancelled
Voice Benchmark Smoke / voicebench TypeScript unit (no audio) (push) Has been cancelled
Voice Benchmark Smoke / voice bench smoke summary (push) Has been cancelled
Windows CI / windows ([bun run --cwd packages/app-core test bun run --cwd packages/elizaos test bun run --cwd packages/cloud/shared test], app-and-cli) (push) Has been cancelled
Windows CI / windows ([bun run --cwd packages/scenario-runner test bun run --cwd packages/vault test bun run --cwd packages/security test bun run --cwd plugins/plugin-coding-tools test], framework-packages) (push) Has been cancelled
Windows CI / windows ([bun run --cwd plugins/plugin-elizacloud test bun run --cwd plugins/plugin-discord test bun run --cwd plugins/plugin-anthropic test bun run --cwd plugins/plugin-openai test bun run --cwd plugins/plugin-app-control test bun run --cwd plugins/pl… (push) Has been cancelled
Windows CI / windows ([node packages/scripts/run-turbo.mjs run build --filter=@elizaos/core --filter=@elizaos/shared --filter=@elizaos/agent --concurrency=4 node packages/scripts/run-bash-linux-only.mjs scripts/verify-riscv64-buildpaths.sh node packages/scripts/run… (push) Has been cancelled
Windows CI / windows ([node packages/scripts/run-turbo.mjs run typecheck --filter=@elizaos/core --filter=@elizaos/shared --filter=@elizaos/cloud-shared --concurrency=4 bun run --cwd packages/core test bun run --cwd packages/shared test], core-runtime, 75) (push) Has been cancelled
372 lines
8.7 KiB
TypeScript
372 lines
8.7 KiB
TypeScript
/**
|
|
* Module-level registry of shell process sessions — both running and finished —
|
|
* backing ShellService's session tracking. State lives in module-scope Maps, so
|
|
* tests reset it via resetProcessRegistryForTests(). Enforces per-session output
|
|
* caps and a TTL sweep that evicts finished-session records.
|
|
*/
|
|
|
|
import type { FinishedSession, ProcessSession, ProcessStatus } from "../types";
|
|
|
|
const DEFAULT_JOB_TTL_MS = 30 * 60 * 1000; // 30 minutes
|
|
const MIN_JOB_TTL_MS = 60 * 1000; // 1 minute
|
|
const MAX_JOB_TTL_MS = 3 * 60 * 60 * 1000; // 3 hours
|
|
const DEFAULT_PENDING_OUTPUT_CHARS = 30_000;
|
|
|
|
function clampTtl(value: number | undefined): number {
|
|
if (!value || Number.isNaN(value)) {
|
|
return DEFAULT_JOB_TTL_MS;
|
|
}
|
|
return Math.min(Math.max(value, MIN_JOB_TTL_MS), MAX_JOB_TTL_MS);
|
|
}
|
|
|
|
let jobTtlMs = clampTtl(Number.parseInt(process.env.SHELL_JOB_TTL_MS ?? "", 10));
|
|
|
|
const runningSessions = new Map<string, ProcessSession>();
|
|
const finishedSessions = new Map<string, FinishedSession>();
|
|
|
|
let sweeper: NodeJS.Timeout | null = null;
|
|
|
|
function isSessionIdTaken(id: string): boolean {
|
|
return runningSessions.has(id) || finishedSessions.has(id);
|
|
}
|
|
|
|
// Session slug generation
|
|
const SLUG_ADJECTIVES = [
|
|
"amber",
|
|
"briny",
|
|
"brisk",
|
|
"calm",
|
|
"clear",
|
|
"cool",
|
|
"crisp",
|
|
"dawn",
|
|
"delta",
|
|
"ember",
|
|
"faint",
|
|
"fast",
|
|
"fresh",
|
|
"gentle",
|
|
"glow",
|
|
"good",
|
|
"grand",
|
|
"keen",
|
|
"kind",
|
|
"lucky",
|
|
"marine",
|
|
"mellow",
|
|
"mild",
|
|
"neat",
|
|
"nimble",
|
|
"nova",
|
|
"oceanic",
|
|
"plaid",
|
|
"quick",
|
|
"quiet",
|
|
"rapid",
|
|
"salty",
|
|
"sharp",
|
|
"swift",
|
|
"tender",
|
|
"tidal",
|
|
"tidy",
|
|
"tide",
|
|
"vivid",
|
|
"warm",
|
|
"wild",
|
|
"young",
|
|
];
|
|
|
|
const SLUG_NOUNS = [
|
|
"atlas",
|
|
"basil",
|
|
"bison",
|
|
"bloom",
|
|
"breeze",
|
|
"canyon",
|
|
"cedar",
|
|
"claw",
|
|
"cloud",
|
|
"comet",
|
|
"coral",
|
|
"cove",
|
|
"crest",
|
|
"daisy",
|
|
"dune",
|
|
"ember",
|
|
"falcon",
|
|
"fjord",
|
|
"forest",
|
|
"glade",
|
|
"gulf",
|
|
"harbor",
|
|
"haven",
|
|
"kelp",
|
|
"lagoon",
|
|
"meadow",
|
|
"mist",
|
|
"nexus",
|
|
"ocean",
|
|
"orbit",
|
|
"otter",
|
|
"pine",
|
|
"prairie",
|
|
"reef",
|
|
"ridge",
|
|
"river",
|
|
"rook",
|
|
"sable",
|
|
"sage",
|
|
"shell",
|
|
"shoal",
|
|
"shore",
|
|
"slug",
|
|
"summit",
|
|
"trail",
|
|
"valley",
|
|
"wharf",
|
|
"willow",
|
|
"zephyr",
|
|
];
|
|
|
|
function randomChoice(values: string[], fallback: string): string {
|
|
return values[Math.floor(Math.random() * values.length)] ?? fallback;
|
|
}
|
|
|
|
function createSlugBase(words = 2): string {
|
|
const parts = [randomChoice(SLUG_ADJECTIVES, "steady"), randomChoice(SLUG_NOUNS, "harbor")];
|
|
if (words > 2) {
|
|
parts.push(randomChoice(SLUG_NOUNS, "reef"));
|
|
}
|
|
return parts.join("-");
|
|
}
|
|
|
|
export function createSessionSlug(isTaken?: (id: string) => boolean): string {
|
|
const isIdTaken = isTaken ?? isSessionIdTaken;
|
|
for (let attempt = 0; attempt < 12; attempt += 1) {
|
|
const base = createSlugBase(2);
|
|
if (!isIdTaken(base)) {
|
|
return base;
|
|
}
|
|
for (let i = 2; i <= 12; i += 1) {
|
|
const candidate = `${base}-${i}`;
|
|
if (!isIdTaken(candidate)) {
|
|
return candidate;
|
|
}
|
|
}
|
|
}
|
|
for (let attempt = 0; attempt < 12; attempt += 1) {
|
|
const base = createSlugBase(3);
|
|
if (!isIdTaken(base)) {
|
|
return base;
|
|
}
|
|
for (let i = 2; i <= 12; i += 1) {
|
|
const candidate = `${base}-${i}`;
|
|
if (!isIdTaken(candidate)) {
|
|
return candidate;
|
|
}
|
|
}
|
|
}
|
|
const fallback = `${createSlugBase(3)}-${Math.random().toString(36).slice(2, 5)}`;
|
|
return isIdTaken(fallback) ? `${fallback}-${Date.now().toString(36)}` : fallback;
|
|
}
|
|
|
|
export function addSession(session: ProcessSession): void {
|
|
runningSessions.set(session.id, session);
|
|
startSweeper();
|
|
}
|
|
|
|
export function getSession(id: string): ProcessSession | undefined {
|
|
return runningSessions.get(id);
|
|
}
|
|
|
|
export function getFinishedSession(id: string): FinishedSession | undefined {
|
|
return finishedSessions.get(id);
|
|
}
|
|
|
|
export function deleteSession(id: string): void {
|
|
runningSessions.delete(id);
|
|
finishedSessions.delete(id);
|
|
}
|
|
|
|
function sumPendingChars(buffer: string[]): number {
|
|
let total = 0;
|
|
for (const chunk of buffer) {
|
|
total += chunk.length;
|
|
}
|
|
return total;
|
|
}
|
|
|
|
function capPendingBuffer(buffer: string[], pendingChars: number, cap: number): number {
|
|
if (pendingChars <= cap) {
|
|
return pendingChars;
|
|
}
|
|
const last = buffer.at(-1);
|
|
if (last && last.length >= cap) {
|
|
buffer.length = 0;
|
|
buffer.push(last.slice(last.length - cap));
|
|
return cap;
|
|
}
|
|
while (buffer.length && pendingChars - buffer[0].length >= cap) {
|
|
pendingChars -= buffer[0].length;
|
|
buffer.shift();
|
|
}
|
|
if (buffer.length && pendingChars > cap) {
|
|
const overflow = pendingChars - cap;
|
|
buffer[0] = buffer[0].slice(overflow);
|
|
pendingChars = cap;
|
|
}
|
|
return pendingChars;
|
|
}
|
|
|
|
export function tail(text: string, max = 2000): string {
|
|
if (text.length <= max) {
|
|
return text;
|
|
}
|
|
return text.slice(text.length - max);
|
|
}
|
|
|
|
export function trimWithCap(text: string, max: number): string {
|
|
if (text.length <= max) {
|
|
return text;
|
|
}
|
|
return text.slice(text.length - max);
|
|
}
|
|
|
|
export function appendOutput(
|
|
session: ProcessSession,
|
|
stream: "stdout" | "stderr",
|
|
chunk: string
|
|
): void {
|
|
session.pendingStdout ??= [];
|
|
session.pendingStderr ??= [];
|
|
session.pendingStdoutChars ??= sumPendingChars(session.pendingStdout);
|
|
session.pendingStderrChars ??= sumPendingChars(session.pendingStderr);
|
|
const buffer = stream === "stdout" ? session.pendingStdout : session.pendingStderr;
|
|
const bufferChars = stream === "stdout" ? session.pendingStdoutChars : session.pendingStderrChars;
|
|
const pendingCap = Math.min(
|
|
session.pendingMaxOutputChars ?? DEFAULT_PENDING_OUTPUT_CHARS,
|
|
session.maxOutputChars
|
|
);
|
|
buffer.push(chunk);
|
|
let pendingChars = bufferChars + chunk.length;
|
|
if (pendingChars > pendingCap) {
|
|
session.truncated = true;
|
|
pendingChars = capPendingBuffer(buffer, pendingChars, pendingCap);
|
|
}
|
|
if (stream === "stdout") {
|
|
session.pendingStdoutChars = pendingChars;
|
|
} else {
|
|
session.pendingStderrChars = pendingChars;
|
|
}
|
|
session.totalOutputChars += chunk.length;
|
|
const aggregated = trimWithCap(session.aggregated + chunk, session.maxOutputChars);
|
|
session.truncated =
|
|
session.truncated || aggregated.length < session.aggregated.length + chunk.length;
|
|
session.aggregated = aggregated;
|
|
session.tail = tail(session.aggregated, 2000);
|
|
}
|
|
|
|
export function drainSession(session: ProcessSession): {
|
|
stdout: string;
|
|
stderr: string;
|
|
} {
|
|
const stdout = session.pendingStdout.join("");
|
|
const stderr = session.pendingStderr.join("");
|
|
session.pendingStdout = [];
|
|
session.pendingStderr = [];
|
|
session.pendingStdoutChars = 0;
|
|
session.pendingStderrChars = 0;
|
|
return { stdout, stderr };
|
|
}
|
|
|
|
function moveToFinished(session: ProcessSession, status: ProcessStatus): void {
|
|
runningSessions.delete(session.id);
|
|
if (!session.backgrounded) {
|
|
return;
|
|
}
|
|
finishedSessions.set(session.id, {
|
|
id: session.id,
|
|
command: session.command,
|
|
scopeKey: session.scopeKey,
|
|
startedAt: session.startedAt,
|
|
endedAt: Date.now(),
|
|
cwd: session.cwd,
|
|
status,
|
|
exitCode: session.exitCode,
|
|
exitSignal: session.exitSignal,
|
|
aggregated: session.aggregated,
|
|
tail: session.tail,
|
|
truncated: session.truncated,
|
|
totalOutputChars: session.totalOutputChars,
|
|
});
|
|
}
|
|
|
|
export function markExited(
|
|
session: ProcessSession,
|
|
exitCode: number | null,
|
|
exitSignal: NodeJS.Signals | number | null,
|
|
status: ProcessStatus
|
|
): void {
|
|
session.exited = true;
|
|
session.exitCode = exitCode;
|
|
session.exitSignal = exitSignal;
|
|
session.tail = tail(session.aggregated, 2000);
|
|
moveToFinished(session, status);
|
|
}
|
|
|
|
export function markBackgrounded(session: ProcessSession): void {
|
|
session.backgrounded = true;
|
|
}
|
|
|
|
export function listRunningSessions(): ProcessSession[] {
|
|
return Array.from(runningSessions.values()).filter((s) => s.backgrounded);
|
|
}
|
|
|
|
export function listFinishedSessions(): FinishedSession[] {
|
|
return Array.from(finishedSessions.values());
|
|
}
|
|
|
|
export function clearFinished(): void {
|
|
finishedSessions.clear();
|
|
}
|
|
|
|
export function resetProcessRegistryForTests(): void {
|
|
runningSessions.clear();
|
|
finishedSessions.clear();
|
|
stopSweeper();
|
|
}
|
|
|
|
export function setJobTtlMs(value?: number): void {
|
|
if (value === undefined || Number.isNaN(value)) {
|
|
return;
|
|
}
|
|
jobTtlMs = clampTtl(value);
|
|
stopSweeper();
|
|
startSweeper();
|
|
}
|
|
|
|
function pruneFinishedSessions(): void {
|
|
const cutoff = Date.now() - jobTtlMs;
|
|
for (const [id, session] of finishedSessions.entries()) {
|
|
if (session.endedAt < cutoff) {
|
|
finishedSessions.delete(id);
|
|
}
|
|
}
|
|
}
|
|
|
|
function startSweeper(): void {
|
|
if (sweeper) {
|
|
return;
|
|
}
|
|
sweeper = setInterval(pruneFinishedSessions, Math.max(30_000, jobTtlMs / 6));
|
|
sweeper.unref();
|
|
}
|
|
|
|
function stopSweeper(): void {
|
|
if (!sweeper) {
|
|
return;
|
|
}
|
|
clearInterval(sweeper);
|
|
sweeper = null;
|
|
}
|