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

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