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
285 lines
8.2 KiB
TypeScript
285 lines
8.2 KiB
TypeScript
#!/usr/bin/env bun
|
|
|
|
/**
|
|
* Local cleanup for GitHub Actions self-hosted runner workspaces on robot hosts.
|
|
*
|
|
* Runner installations keep completed-job checkouts under each runner's `_work`
|
|
* directory and do not reclaim them automatically. On small-root agent robots
|
|
* this can consume tens of GB while Docker cleanup reports nothing to reclaim.
|
|
* This script is intentionally host-local: run it from cron/systemd on the
|
|
* runner host, not from the control plane. It refuses to delete while a
|
|
* `Runner.Worker` process is active unless the operator explicitly overrides
|
|
* that guard.
|
|
*/
|
|
|
|
import { spawnSync } from "node:child_process";
|
|
import type { Stats } from "node:fs";
|
|
import { existsSync, lstatSync, readdirSync, rmSync, statSync } from "node:fs";
|
|
import * as path from "node:path";
|
|
import { fileURLToPath } from "node:url";
|
|
|
|
export interface RunnerWorkspacePruneArgs {
|
|
root: string;
|
|
minAgeHours: number;
|
|
dryRun: boolean;
|
|
allowActive: boolean;
|
|
}
|
|
|
|
export interface WorkspaceEntry {
|
|
path: string;
|
|
ageMs: number;
|
|
bytes: number;
|
|
}
|
|
|
|
export interface WorkspacePlan {
|
|
workDirs: string[];
|
|
entries: WorkspaceEntry[];
|
|
skippedFresh: number;
|
|
totalBytes: number;
|
|
}
|
|
|
|
const DEFAULT_ROOT = "/opt/actions-runners";
|
|
const DEFAULT_MIN_AGE_HOURS = 6;
|
|
const MIN_AGE_HOURS_FLOOR = 1;
|
|
|
|
export function parseRunnerWorkspacePruneArgs(
|
|
argv: string[],
|
|
env: NodeJS.ProcessEnv,
|
|
): RunnerWorkspacePruneArgs {
|
|
const flags = new Map<string, string>();
|
|
let dryRun = false;
|
|
let allowActive = false;
|
|
for (let i = 0; i < argv.length; i++) {
|
|
const arg = argv[i];
|
|
if (arg === undefined) continue;
|
|
if (arg === "--dry-run") {
|
|
dryRun = true;
|
|
continue;
|
|
}
|
|
if (arg === "--allow-active") {
|
|
allowActive = true;
|
|
continue;
|
|
}
|
|
if (arg.startsWith("--")) {
|
|
const key = arg.slice(2);
|
|
const value = argv[i + 1];
|
|
if (value === undefined || value.startsWith("--")) {
|
|
throw new Error(`Flag --${key} requires a value`);
|
|
}
|
|
flags.set(key, value);
|
|
i++;
|
|
}
|
|
}
|
|
|
|
const rawRoot =
|
|
flags.get("root") ?? env.RUNNER_WORKSPACE_ROOT ?? DEFAULT_ROOT;
|
|
// Runner hosts are Linux; keep POSIX-absolute roots verbatim so parsing
|
|
// behaves identically when this suite runs on win32 CI, where path.resolve
|
|
// would drive-qualify "/opt/..." into "D:\opt\...".
|
|
const root = rawRoot.startsWith("/")
|
|
? path.posix.normalize(rawRoot)
|
|
: path.resolve(rawRoot);
|
|
const minAgeRaw =
|
|
flags.get("min-age-hours") ??
|
|
env.RUNNER_WORKSPACE_MIN_AGE_HOURS ??
|
|
String(DEFAULT_MIN_AGE_HOURS);
|
|
const minAgeHours = Number.parseInt(minAgeRaw, 10);
|
|
if (!Number.isInteger(minAgeHours) || minAgeHours < MIN_AGE_HOURS_FLOOR) {
|
|
throw new Error(`Invalid min-age-hours: ${minAgeRaw}`);
|
|
}
|
|
|
|
return { root, minAgeHours, dryRun, allowActive };
|
|
}
|
|
|
|
function realPathInsideRoot(candidate: string, root: string): boolean {
|
|
const relative = path.relative(root, candidate);
|
|
return (
|
|
relative.length > 0 &&
|
|
!relative.startsWith("..") &&
|
|
!path.isAbsolute(relative)
|
|
);
|
|
}
|
|
|
|
export function findRunnerWorkDirs(root: string): string[] {
|
|
if (!existsSync(root)) return [];
|
|
const stat = statSync(root);
|
|
if (!stat.isDirectory()) return [];
|
|
const dirs = new Set<string>();
|
|
|
|
const maybeAdd = (candidate: string) => {
|
|
const base = path.basename(candidate);
|
|
if (base !== "_work") return;
|
|
const realCandidate = path.resolve(candidate);
|
|
if (realCandidate !== root && !realPathInsideRoot(realCandidate, root))
|
|
return;
|
|
try {
|
|
if (lstatSync(realCandidate).isDirectory()) dirs.add(realCandidate);
|
|
} catch {
|
|
// error-policy:J6 best-effort host cleanup; racing deletes are harmless.
|
|
}
|
|
};
|
|
|
|
maybeAdd(root);
|
|
for (const entry of readdirSync(root, { withFileTypes: true })) {
|
|
if (!entry.isDirectory()) continue;
|
|
const child = path.join(root, entry.name);
|
|
maybeAdd(child);
|
|
try {
|
|
for (const nested of readdirSync(child, { withFileTypes: true })) {
|
|
if (nested.isDirectory()) maybeAdd(path.join(child, nested.name));
|
|
}
|
|
} catch {
|
|
// error-policy:J6 best-effort host cleanup; an unreadable runner dir is skipped.
|
|
}
|
|
}
|
|
|
|
return [...dirs].sort();
|
|
}
|
|
|
|
function pathSizeBytes(target: string): number {
|
|
let total = 0;
|
|
const stack = [target];
|
|
while (stack.length > 0) {
|
|
const current = stack.pop();
|
|
if (!current) continue;
|
|
let stat: Stats;
|
|
try {
|
|
stat = lstatSync(current);
|
|
} catch {
|
|
// error-policy:J6 best-effort size reporting; cleanup still attempts the path.
|
|
continue;
|
|
}
|
|
total += stat.size;
|
|
if (!stat.isDirectory()) continue;
|
|
try {
|
|
for (const child of readdirSync(current))
|
|
stack.push(path.join(current, child));
|
|
} catch {
|
|
// error-policy:J6 best-effort size reporting; unreadable children are ignored.
|
|
}
|
|
}
|
|
return total;
|
|
}
|
|
|
|
export function buildRunnerWorkspacePrunePlan(input: {
|
|
root: string;
|
|
now: number;
|
|
minAgeHours: number;
|
|
}): WorkspacePlan {
|
|
const minAgeMs = input.minAgeHours * 60 * 60_000;
|
|
const workDirs = findRunnerWorkDirs(input.root);
|
|
const entries: WorkspaceEntry[] = [];
|
|
let skippedFresh = 0;
|
|
|
|
for (const workDir of workDirs) {
|
|
let children: ReturnType<typeof readdirSync>;
|
|
try {
|
|
children = readdirSync(workDir, { withFileTypes: true });
|
|
} catch {
|
|
// error-policy:J6 best-effort host cleanup; a racing runner dir can be retried on the next pass.
|
|
continue;
|
|
}
|
|
|
|
for (const child of children) {
|
|
const childPath = path.join(workDir, child.name);
|
|
if (!realPathInsideRoot(childPath, input.root)) continue;
|
|
let stat: Stats;
|
|
try {
|
|
stat = lstatSync(childPath);
|
|
} catch {
|
|
// error-policy:J6 best-effort host cleanup; racing child deletes are harmless.
|
|
continue;
|
|
}
|
|
const ageMs = input.now - stat.mtimeMs;
|
|
if (ageMs < minAgeMs) {
|
|
skippedFresh += 1;
|
|
continue;
|
|
}
|
|
entries.push({
|
|
path: childPath,
|
|
ageMs,
|
|
bytes: pathSizeBytes(childPath),
|
|
});
|
|
}
|
|
}
|
|
|
|
return {
|
|
workDirs,
|
|
entries,
|
|
skippedFresh,
|
|
totalBytes: entries.reduce((sum, entry) => sum + entry.bytes, 0),
|
|
};
|
|
}
|
|
|
|
export function isRunnerWorkerActive(): boolean {
|
|
const result = spawnSync("pgrep", ["-f", "Runner\\.Worker"], {
|
|
stdio: "ignore",
|
|
});
|
|
return result.status === 0;
|
|
}
|
|
|
|
function formatBytes(bytes: number): string {
|
|
if (!Number.isFinite(bytes) || bytes <= 0) return "0 B";
|
|
const units = ["B", "KiB", "MiB", "GiB"];
|
|
let value = bytes;
|
|
let unit = units[0];
|
|
for (let i = 1; i < units.length && value >= 1024; i++) {
|
|
value /= 1024;
|
|
unit = units[i];
|
|
}
|
|
return `${value >= 10 || unit === "B" ? value.toFixed(0) : value.toFixed(1)} ${unit}`;
|
|
}
|
|
|
|
async function main(): Promise<void> {
|
|
const args = parseRunnerWorkspacePruneArgs(
|
|
process.argv.slice(2),
|
|
process.env,
|
|
);
|
|
if (!args.allowActive && isRunnerWorkerActive()) {
|
|
throw new Error(
|
|
"Runner.Worker is active; refusing to prune. Re-run after jobs finish.",
|
|
);
|
|
}
|
|
|
|
const plan = buildRunnerWorkspacePrunePlan({
|
|
root: args.root,
|
|
now: Date.now(),
|
|
minAgeHours: args.minAgeHours,
|
|
});
|
|
|
|
console.log(`[prune-runner-workspaces] root: ${args.root}`);
|
|
console.log(`[prune-runner-workspaces] work dirs: ${plan.workDirs.length}`);
|
|
console.log(
|
|
`[prune-runner-workspaces] stale entries: ${plan.entries.length}`,
|
|
);
|
|
console.log(
|
|
`[prune-runner-workspaces] skipped fresh entries: ${plan.skippedFresh}`,
|
|
);
|
|
console.log(
|
|
`[prune-runner-workspaces] reclaimable: ${formatBytes(plan.totalBytes)}`,
|
|
);
|
|
|
|
for (const entry of plan.entries) {
|
|
console.log(
|
|
`[prune-runner-workspaces] ${args.dryRun ? "would remove" : "removing"} ${entry.path} (${formatBytes(entry.bytes)})`,
|
|
);
|
|
if (!args.dryRun) rmSync(entry.path, { recursive: true, force: true });
|
|
}
|
|
}
|
|
|
|
function isMainModule(): boolean {
|
|
const entry = process.argv[1];
|
|
return entry ? path.resolve(entry) === fileURLToPath(import.meta.url) : false;
|
|
}
|
|
|
|
if (isMainModule()) {
|
|
main().catch((error) => {
|
|
// error-policy:J1 CLI boundary translates failures into a non-zero exit.
|
|
console.error(
|
|
"[prune-runner-workspaces] failed:",
|
|
error instanceof Error ? error.message : String(error),
|
|
);
|
|
process.exit(1);
|
|
});
|
|
}
|