Files
wehub-resource-sync 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
chore: import upstream snapshot with attribution
2026-07-13 12:43:05 +08:00

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;
}