// SPEC: PJ2026-01060307 控制面模块化 draft-2026-06-25-p0. cleanup module for scripts/src/hwlab-node-impl.ts.

// Moved mechanically from scripts/src/hwlab-node-impl.ts:1886-2201 for #903.

// SPEC: PJ2026-01060505 Workbench Performance draft-2026-06-17-p0.
// SPEC: PJ2026-01060508 Web哨兵 draft-2026-06-25-p0-web-probe-sentinel.
// Responsibility: YAML-first node/lane operations, including Workbench observability control commands.
import { createHash, randomBytes } from "node:crypto";
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { repoRoot, rootPath, type Config } from "../config";
import { runCommand, type CommandResult } from "../command";
import { resolveCliChildJsonCommandResult } from "../cli-child-json-recovery";
import { startJob } from "../jobs";
import { classifySshTcpPoolFailure } from "../ssh";
import { HWLAB_NODE_CONTROL_PLANE_CONFIG_PATH, hwlabNodeControlPlaneInfraHelp, runHwlabNodeControlPlaneInfra } from "../hwlab-node-control-plane";
import { hwlabRuntimeLaneConfigPath, hwlabRuntimeLaneIds, hwlabRuntimeLaneSpec, hwlabRuntimeLaneSpecForNode, hwlabRuntimeNodeIds, hwlabRuntimeSourceSnapshotStageRefPrefix, isHwlabRuntimeLane, type HwlabRuntimeLane, type HwlabRuntimeLaneSpec, type HwlabRuntimeObservabilityRecordingRuleSpec, type HwlabRuntimeObservabilitySpec, type HwlabRuntimeObservabilityWarningAlertSpec, type HwlabRuntimePublicExposureSpec, type HwlabRuntimeWebProbeAlertThresholdsSpec, type HwlabRuntimeWebProbeProjectManagementSpec } from "../hwlab-node-lanes";
import { nodeWebProbeScriptRunnerSource } from "../hwlab-node-web-probe-runner-source";
import { nodeWebObserveAnalyzerSource } from "../hwlab-node-web-observe-analyzer-source";
import { nodeWebObserveRunnerSource } from "../hwlab-node-web-observe-runner-source";
import { nodeWebObserveCollectViewNodeScript, parseNodeWebProbeObserveCollectView, type NodeWebProbeObserveCollectView } from "../hwlab-node-web-observe-collect";
import { withWebObserveCollectRendered, withWebObserveCommandRendered, withWebObserveStatusRendered } from "../hwlab-node-web-observe-render";
import { buildWebObserveWrapperForObserveOptions, webObserveWrapperStateDirFromStatus } from "../hwlab-node-web-observe-wrapper";
import { renderWebObserveWrapperContract } from "../hwlab-node-web-observe-wrapper-render";
import { runWebProbeSentinelCommand, type WebProbeSentinelOptions } from "../hwlab-node-web-sentinel-cicd";
import { hwlabNodeHelp, hwlabNodeObservabilityHelp, hwlabNodeWebProbeHelp } from "../hwlab-node-help";
import { compactWebProbeResult, compactWebProbeScriptResult } from "../hwlab-node-web-probe-summary";
import { nodeObservabilityRecordingRuleExpression, nodeObservabilityRecordingRuleSummaries, nodeObservabilityWarningAlertExpression, nodeObservabilityWarningAlertSummaries } from "../hwlab-node-observability-promql";
import { runDelegatedHwlabNodeCommand, type DelegatedNodeDomain } from "../hwlab-node-transport";
import type { RenderedCliResult } from "../output";

import type { NodeRuntimeCleanupOptions, NodeRuntimeCleanupPipelineRunRow } from "./entry";
import { deleteNodeRuntimeCleanupRuns, nodeRuntimeApply, nodeRuntimeCiObjectCounts, nodeRuntimeCleanupOwnedPodsFromText, nodeRuntimeCleanupOwnedPvcsFromText, nodeRuntimeCleanupOwnedTaskRunsFromText, nodeRuntimeMigration, nodeRuntimeRefresh, nodeRuntimeSync } from "./control-actions";
import { HWLAB_CI_NAMESPACE } from "./entry";
import { nodeRuntimeTriggerCurrentOutput } from "./git-mirror";
import { parseNodeScopedDelegatedOptions } from "./plan";
import { compactRuntimeCommand, compactRuntimeCommandStats, nodeRuntimeUnsupportedAction, runNodeHostScript, transPath } from "./runtime-common";
import { optionValue, positiveIntegerOption, shellQuote, statusText } from "./utils";

export function commandResultFromAsync(spec: HwlabRuntimeLaneSpec, payload: Record<string, unknown>, statusPath: string, timedOut: boolean): CommandResult {
  return {
    command: [transPath(), spec.nodeRoute, "sh", "--", "<remote async script>"],
    cwd: repoRoot,
    exitCode: typeof payload.exitCode === "number" ? payload.exitCode : payload.ok === true ? 0 : 1,
    stdout: typeof payload.stdout === "string" ? payload.stdout : "",
    stderr: typeof payload.stderr === "string" ? payload.stderr : `remote async statusPath=${statusPath}`,
    signal: null,
    timedOut,
  };
}

export function runNodeK3sArgs(spec: HwlabRuntimeLaneSpec, args: string[], timeoutSeconds: number): CommandResult {
  return runCommand([transPath(), spec.nodeKubeRoute, ...args], repoRoot, { timeoutMs: timeoutSeconds * 1000 });
}

export function runNodeK3sScript(spec: HwlabRuntimeLaneSpec, script: string, timeoutSeconds: number, input = ""): CommandResult {
  return runCommand([transPath(), spec.nodeKubeRoute, "sh", "--", script], repoRoot, { input, timeoutMs: timeoutSeconds * 1000 });
}

export function isCommandSuccess(result: CommandResult): boolean {
  return result.exitCode === 0 && !result.timedOut;
}

export function shortSha(value: string): string {
  return value.slice(0, 12).toLowerCase();
}

export function shortValue(value: unknown): string {
  if (typeof value !== "string" || value.length === 0) return "-";
  if (/^[0-9a-f]{40}$/iu.test(value)) return shortSha(value);
  return value.length > 30 ? `${value.slice(0, 27)}~` : value;
}

export function formatElapsedMs(value: unknown): string {
  const ms = Number(value);
  if (!Number.isFinite(ms) || ms < 0) return "-";
  const seconds = Math.round(ms / 1000);
  const minutes = Math.floor(seconds / 60);
  const rest = seconds % 60;
  return minutes > 0 ? `${minutes}m${String(rest).padStart(2, "0")}s` : `${seconds}s`;
}

export function nodeRuntimePipelineRunName(spec: HwlabRuntimeLaneSpec, sourceCommit: string): string {
  return `${spec.pipelineRunPrefix}-${shortSha(sourceCommit)}`;
}

export function nodeRuntimeRerunPipelineRunName(spec: HwlabRuntimeLaneSpec, sourceCommit: string): string {
  return `${nodeRuntimePipelineRunName(spec, sourceCommit)}-r${Date.now().toString(36)}`.slice(0, 63);
}

export function nodeRuntimeGitopsRoot(spec: HwlabRuntimeLaneSpec): string {
  const suffix = `/${spec.runtimeRenderDir}`;
  if (spec.runtimePath.endsWith(suffix)) return spec.runtimePath.slice(0, -suffix.length);
  return spec.gitopsRoot;
}

export function resolveNodeRuntimeLaneHead(spec: HwlabRuntimeLaneSpec): { sourceCommit: string | null; result: CommandResult; stdoutRecovery?: Record<string, unknown> } {
  if (spec.sourceAuthority?.mode === "giteaSnapshot") return resolveNodeRuntimeGiteaLaneHead(spec);
  const mirror = nodeRuntimeSourceMirrorTarget(spec);
  const script = [
    "set +e",
    `namespace=${shellQuote(mirror.namespace)}`,
    `read_deploy=${shellQuote(mirror.serviceReadName)}`,
    `repo_path=${shellQuote(`/cache/${mirror.sourceRepository}.git`)}`,
    `source_branch=${shellQuote(mirror.sourceBranch)}`,
    `source_stage_ref_prefix=${shellQuote(hwlabRuntimeSourceSnapshotStageRefPrefix(spec))}`,
    "read_ref() {",
    "  ref=\"$1\"",
    "  kubectl -n \"$namespace\" exec deploy/\"$read_deploy\" -- sh -lc 'repo_path=$1; ref=$2; git --git-dir=\"$repo_path\" rev-parse --verify \"$ref^{commit}\" 2>/dev/null' sh \"$repo_path\" \"$ref\" 2>/dev/null",
    "}",
    "mirror_stage=$(read_ref \"refs/mirror-stage/heads/$source_branch\")",
    "mirror_stage_rc=$?",
    "source_commit=\"$mirror_stage\"",
    "source_ref=\"refs/mirror-stage/heads/$source_branch\"",
    "source_stage_ref=''",
    "snapshot_commit=''",
    "if printf '%s' \"$source_commit\" | grep -Eq '^[0-9a-fA-F]{40}$'; then",
    "  source_stage_ref=\"${source_stage_ref_prefix%/}/$source_commit\"",
    "  snapshot_commit=$(read_ref \"$source_stage_ref\")",
    "fi",
    "node - \"$mirror_stage_rc\" \"$mirror_stage\" \"$snapshot_commit\" \"$source_commit\" \"$source_ref\" \"$source_stage_ref\" \"$repo_path\" \"$source_branch\" <<'NODE'",
    "const [mirrorStageRc, mirrorStage, snapshotCommit, sourceCommit, sourceRef, sourceStageRef, repoPath, branch] = process.argv.slice(2);",
    "const isSha = (value) => /^[0-9a-f]{40}$/i.test(value || '');",
    "const normalizedSource = isSha(sourceCommit) ? sourceCommit.toLowerCase() : null;",
    "const normalizedSnapshot = isSha(snapshotCommit) ? snapshotCommit.toLowerCase() : null;",
    "const ok = normalizedSource !== null && normalizedSnapshot === normalizedSource;",
    "console.log(JSON.stringify({ ok, mode: 'k8s-git-mirror-snapshot', sourceAuthority: 'git-mirror-snapshot', sourceCommit: ok ? normalizedSnapshot : normalizedSource, sourceRef: normalizedSource ? sourceRef : null, sourceStageRef: sourceStageRef || null, snapshotPresent: ok, degradedReason: ok ? null : normalizedSource === null ? 'source-branch-missing' : 'source-snapshot-missing', mirrorStage: isSha(mirrorStage) ? mirrorStage.toLowerCase() : null, sourceSnapshot: normalizedSnapshot, mirrorStageRc: Number(mirrorStageRc), branch, repoPath, valuesRedacted: true }));",
    "NODE",
  ].join("\n");
  const result = runNodeK3sScript(spec, script, 45);
  const payloadResolution = resolveRuntimeCleanupJson(result, "node runtime source snapshot JSON");
  const payload = payloadResolution.parsed ?? {};
  const payloadCommit = typeof payload.sourceCommit === "string" && /^[0-9a-f]{40}$/iu.test(payload.sourceCommit) ? payload.sourceCommit.toLowerCase() : null;
  const match = payloadCommit ?? /[0-9a-f]{40}/iu.exec(statusText(result))?.[0].toLowerCase() ?? null;
  return { sourceCommit: result.exitCode === 0 ? match : null, result, stdoutRecovery: payloadResolution.diagnostics };
}

function resolveNodeRuntimeGiteaLaneHead(spec: HwlabRuntimeLaneSpec): { sourceCommit: string | null; result: CommandResult; stdoutRecovery?: Record<string, unknown> } {
  const repoKey = nodeRuntimeGiteaRepoKey(spec);
  const result = runCommand([
    "bun",
    "scripts/cli.ts",
    "platform-infra",
    "gitea",
    "mirror",
    "status",
    "--target",
    spec.nodeId,
    "--repo",
    repoKey,
    "--raw",
  ], repoRoot, { timeoutMs: 60_000 });
  const payloadResolution = resolveRuntimeCleanupJson(result, "platform-infra gitea mirror status JSON");
  const payload = runtimeRecord(payloadResolution.parsed?.data ?? payloadResolution.parsed);
  const repositories = Array.isArray(payload.repositories)
    ? payload.repositories.filter((item): item is Record<string, unknown> => typeof item === "object" && item !== null && !Array.isArray(item))
    : [];
  const repo = repositories[0] ?? {};
  const branchCommit = typeof repo.branchCommit === "string" && /^[0-9a-f]{40}$/iu.test(repo.branchCommit) ? repo.branchCommit.toLowerCase() : null;
  const snapshotCommit = typeof repo.snapshotCommit === "string" && /^[0-9a-f]{40}$/iu.test(repo.snapshotCommit) ? repo.snapshotCommit.toLowerCase() : null;
  const snapshotRef = typeof repo.snapshotRef === "string" ? repo.snapshotRef : null;
  const sourceCommit = branchCommit !== null && snapshotCommit === branchCommit ? branchCommit : null;
  return {
    sourceCommit: result.exitCode === 0 ? sourceCommit : null,
    result,
    stdoutRecovery: {
      ...payloadResolution.diagnostics,
      sourceAuthority: "gitea-snapshot",
      repoKey,
      branchCommit,
      snapshotCommit,
      snapshotRef,
      snapshotPresent: sourceCommit !== null,
    },
  };
}

function nodeRuntimeGiteaRepoKey(spec: HwlabRuntimeLaneSpec): string {
  const repoKey = spec.sourceAuthority?.giteaMirrorRepoKey;
  if (typeof repoKey !== "string" || repoKey.length === 0) throw new Error(`giteaMirrorRepoKey is required for node=${spec.nodeId} lane=${spec.lane} gitea source authority`);
  return repoKey;
}

function resolveRuntimeCleanupJson(result: CommandResult, requestedStdoutType: string): { parsed: Record<string, unknown> | null; diagnostics: Record<string, unknown> } {
  const resolved = resolveCliChildJsonCommandResult({
    result,
    requestedStdoutType,
    acceptParsed: (value) => typeof value.ok === "boolean"
      || typeof value.mode === "string"
      || typeof value.sourceCommit === "string"
      || typeof value.degradedReason === "string"
      || typeof value.error === "string"
      || Object.keys(value).length > 1,
  });
  return { parsed: resolved.parsed, diagnostics: resolved.diagnostics };
}

interface NodeRuntimeSourceMirrorTarget {
  readonly namespace: string;
  readonly serviceReadName: string;
  readonly sourceRepository: string;
  readonly sourceBranch: string;
}

function nodeRuntimeSourceMirrorTarget(spec: HwlabRuntimeLaneSpec): NodeRuntimeSourceMirrorTarget {
  const configPath = rootPath(HWLAB_NODE_CONTROL_PLANE_CONFIG_PATH);
  const parsed = runtimeRecord(Bun.YAML.parse(readFileSync(configPath, "utf8")));
  const targets = Array.isArray(parsed.targets) ? parsed.targets.map((item) => runtimeRecord(item)) : [];
  const target = targets.find((item) => item.node === spec.nodeId && item.lane === spec.lane);
  if (target === undefined) throw new Error(`no control-plane target for node=${spec.nodeId} lane=${spec.lane} in ${HWLAB_NODE_CONTROL_PLANE_CONFIG_PATH}`);
  const gitMirror = runtimeRecord(target.gitMirror);
  const source = runtimeRecord(target.source);
  return {
    namespace: runtimeString(gitMirror.namespace, "gitMirror.namespace"),
    serviceReadName: runtimeString(gitMirror.serviceReadName, "gitMirror.serviceReadName"),
    sourceRepository: runtimeString(source.repository, "source.repository"),
    sourceBranch: runtimeString(source.branch, "source.branch"),
  };
}

function runtimeRecord(value: unknown): Record<string, unknown> {
  return typeof value === "object" && value !== null && !Array.isArray(value) ? value as Record<string, unknown> : {};
}

function runtimeString(value: unknown, path: string): string {
  if (typeof value !== "string" || value.length === 0) throw new Error(`${path} must be a non-empty string`);
  return value;
}

export function runtimeLaneCicdRepoEnsureScript(spec: HwlabRuntimeLaneSpec): string {
  const gitRetries = Math.max(1, spec.downloadProfile.git.retries);
  const gitTimeoutSeconds = Math.max(30, spec.downloadProfile.git.timeoutSeconds);
  return [
    `cicd_repo=${shellQuote(spec.cicdRepo)}`,
    `cicd_url=${shellQuote(spec.gitUrl)}`,
    `cicd_branch=${shellQuote(spec.sourceBranch)}`,
    `cicd_repo_lock=${shellQuote(spec.cicdRepoLock)}`,
    `git_retries=${shellQuote(String(gitRetries))}`,
    `git_timeout=${shellQuote(String(gitTimeoutSeconds))}`,
    "run_git() { if command -v timeout >/dev/null 2>&1; then timeout \"$git_timeout\" git -c protocol.version=2 \"$@\"; else git -c protocol.version=2 \"$@\"; fi; }",
    "retry_git() {",
    "  op=$1",
    "  shift",
    "  attempt=1",
    "  while [ \"$attempt\" -le \"$git_retries\" ]; do",
    "    echo \"phase=git-$op attempt=$attempt timeoutSeconds=$git_timeout\" >&2",
    "    run_git \"$@\"",
    "    code=$?",
    "    if [ \"$code\" -eq 0 ]; then return 0; fi",
    "    echo \"phase=git-$op attempt=$attempt exitCode=$code\" >&2",
    "    attempt=$((attempt + 1))",
    "  done",
    "  return 1",
    "}",
    "mkdir -p \"$(dirname \"$cicd_repo\")\"",
    "if [ -d \"$cicd_repo/objects\" ] && [ -f \"$cicd_repo/HEAD\" ]; then",
    "  cicd_promisor=$(git --git-dir=\"$cicd_repo\" config --bool remote.origin.promisor 2>/dev/null || true)",
    "  cicd_promisor_pack=$(find \"$cicd_repo/objects/pack\" -maxdepth 1 -name '*.promisor' -print -quit 2>/dev/null || true)",
    "  if [ \"$cicd_promisor\" = \"true\" ] || [ -n \"$cicd_promisor_pack\" ] || [ -f \"$cicd_repo/shallow\" ]; then",
    "    reason=partial-clone-cache",
    "    if [ -f \"$cicd_repo/shallow\" ]; then reason=shallow-clone-cache; fi",
    "    echo \"phase=git-ci-cache-rebuild reason=$reason\" >&2",
    "    rm -rf \"$cicd_repo\"",
    "  fi",
    "fi",
    "if [ -d \"$cicd_repo/objects\" ] && [ -f \"$cicd_repo/HEAD\" ]; then",
    "  :",
    "elif [ -e \"$cicd_repo\" ]; then",
    "  echo \"CI/CD repo path exists but is not a bare git repo: $cicd_repo\" >&2",
    "  exit 41",
    "else",
    "  retry_git clone-ci-cache clone --bare --single-branch --branch \"$cicd_branch\" \"$cicd_url\" \"$cicd_repo\"",
    "fi",
    "git --git-dir=\"$cicd_repo\" remote set-url origin \"$cicd_url\" 2>/dev/null || git --git-dir=\"$cicd_repo\" remote add origin \"$cicd_url\"",
    "git --git-dir=\"$cicd_repo\" config remote.origin.fetch '+refs/heads/*:refs/remotes/origin/*'",
    "git --git-dir=\"$cicd_repo\" config --unset-all remote.origin.promisor 2>/dev/null || true",
    "git --git-dir=\"$cicd_repo\" config --unset-all remote.origin.partialclonefilter 2>/dev/null || true",
    "if ! retry_git fetch-ci-cache --git-dir=\"$cicd_repo\" fetch origin \"+refs/heads/$cicd_branch:refs/remotes/origin/$cicd_branch\" --prune; then",
    "  rm -rf \"$cicd_repo\"",
    "  retry_git clone-ci-cache-retry clone --bare --single-branch --branch \"$cicd_branch\" \"$cicd_url\" \"$cicd_repo\"",
    "  git --git-dir=\"$cicd_repo\" config remote.origin.fetch '+refs/heads/*:refs/remotes/origin/*'",
    "  git --git-dir=\"$cicd_repo\" config --unset-all remote.origin.promisor 2>/dev/null || true",
    "  git --git-dir=\"$cicd_repo\" config --unset-all remote.origin.partialclonefilter 2>/dev/null || true",
    "  retry_git fetch-ci-cache-retry --git-dir=\"$cicd_repo\" fetch origin \"+refs/heads/$cicd_branch:refs/remotes/origin/$cicd_branch\" --prune",
    "fi",
    "if ! git --git-dir=\"$cicd_repo\" cat-file -e \"refs/remotes/origin/$cicd_branch^{tree}\" 2>/dev/null; then",
    "  echo \"phase=git-ci-cache-rebuild reason=missing-fetched-tree-object\" >&2",
    "  rm -rf \"$cicd_repo\"",
    "  retry_git clone-ci-cache-object-retry clone --bare --single-branch --branch \"$cicd_branch\" \"$cicd_url\" \"$cicd_repo\"",
    "  git --git-dir=\"$cicd_repo\" config remote.origin.fetch '+refs/heads/*:refs/remotes/origin/*'",
    "  git --git-dir=\"$cicd_repo\" config --unset-all remote.origin.promisor 2>/dev/null || true",
    "  git --git-dir=\"$cicd_repo\" config --unset-all remote.origin.partialclonefilter 2>/dev/null || true",
    "  retry_git fetch-ci-cache-object-retry --git-dir=\"$cicd_repo\" fetch origin \"+refs/heads/$cicd_branch:refs/remotes/origin/$cicd_branch\" --prune",
    "  git --git-dir=\"$cicd_repo\" cat-file -e \"refs/remotes/origin/$cicd_branch^{tree}\"",
    "fi",
  ].join("\n");
}

export function nodeRuntimeControlPlaneRun(scoped: ReturnType<typeof parseNodeScopedDelegatedOptions>): Record<string, unknown> | RenderedCliResult {
  if (scoped.action === "refresh") return nodeRuntimeRefresh(scoped);
  if (scoped.action === "sync") return nodeRuntimeSync(scoped);
  if (scoped.action === "apply") return nodeRuntimeApply(scoped);
  if (scoped.action === "trigger-current") return nodeRuntimeTriggerCurrentOutput(scoped);
  if (scoped.action === "runtime-migration") return nodeRuntimeMigration(scoped);
  if (scoped.action === "cleanup-runs") return nodeRuntimeCleanupRuns(scoped);
  if (scoped.action === "cleanup-released-pvs") return nodeRuntimeCleanupReleasedPvs(scoped);
  if (scoped.action === "cleanup-legacy-docker-images") return nodeRuntimeCleanupLegacyDockerImages(scoped);
  if (scoped.action === "cleanup-legacy-docker-registry-volume") return nodeRuntimeCleanupLegacyDockerRegistryVolume(scoped);
  return nodeRuntimeUnsupportedAction(scoped);
}

export function parseNodeRuntimeCleanupOptions(scoped: ReturnType<typeof parseNodeScopedDelegatedOptions>): NodeRuntimeCleanupOptions {
  const sourceCommitRaw = optionValue(scoped.originalArgs, "--source-commit");
  const pipelineRunRaw = optionValue(scoped.originalArgs, "--pipeline-run");
  if (sourceCommitRaw !== undefined && pipelineRunRaw !== undefined) {
    throw new Error("control-plane cleanup-runs accepts only one of --source-commit or --pipeline-run");
  }
  const sourceCommit = sourceCommitRaw?.toLowerCase();
  if (sourceCommit !== undefined && !/^[0-9a-f]{40}$/u.test(sourceCommit)) {
    throw new Error("--source-commit must be a full 40-character git sha for cleanup-runs");
  }
  const pipelineRun = pipelineRunRaw === undefined ? undefined : validateNodeRuntimePipelineRunName(scoped.spec, pipelineRunRaw);
  return {
    minAgeMinutes: positiveIntegerOption(scoped.originalArgs, "--min-age-minutes", 60, 10080),
    limit: positiveIntegerOption(scoped.originalArgs, "--limit", 20, 200),
    sourceCommit,
    pipelineRun,
    targetPipelineRun: pipelineRun ?? (sourceCommit === undefined ? undefined : nodeRuntimePipelineRunName(scoped.spec, sourceCommit)),
    includeActive: scoped.originalArgs.includes("--include-active"),
    dryRun: scoped.dryRun || !scoped.confirm,
  };
}

export function validateNodeRuntimePipelineRunName(spec: HwlabRuntimeLaneSpec, value: string): string {
  const escapedPrefix = spec.pipelineRunPrefix.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&");
  if (!new RegExp(`^${escapedPrefix}-[0-9a-f]{7,40}(?:-[a-z0-9][a-z0-9-]{0,24})?$`, "iu").test(value)) {
    throw new Error(`--pipeline-run must be a ${spec.pipelineRunPrefix}-<sha>[-rerun] PipelineRun name for --lane ${spec.lane}`);
  }
  return value.toLowerCase();
}

export function nodeRuntimeCleanupRuns(scoped: ReturnType<typeof parseNodeScopedDelegatedOptions>): Record<string, unknown> {
  const options = parseNodeRuntimeCleanupOptions(scoped);
  const beforeCounts = nodeRuntimeCiObjectCounts(scoped.spec);
  const candidates = listNodeRuntimeCleanupPipelineRuns(scoped.spec, options);
  const selectedPipelineRuns = candidates
    .filter((item) => item.selected !== false)
    .map((item) => item.name);
  const ownedResources = listNodeRuntimeCleanupOwnedResources(scoped.spec, selectedPipelineRuns);
  const command = `hwlab nodes control-plane cleanup-runs --node ${scoped.node} --lane ${scoped.lane}`;
  if (options.dryRun) {
    return {
      ok: true,
      command,
      mode: "dry-run",
      node: scoped.node,
      lane: scoped.lane,
      namespace: HWLAB_CI_NAMESPACE,
      minAgeMinutes: options.minAgeMinutes,
      limit: options.limit,
      sourceCommit: options.sourceCommit,
      pipelineRun: options.pipelineRun,
      includeActive: options.includeActive,
      candidates,
      candidateCount: candidates.length,
      selectedPipelineRuns,
      selectedPipelineRunCount: selectedPipelineRuns.length,
      ownedResources,
      ciObjectCounts: beforeCounts,
      mutation: false,
      next: {
        confirm: [
          command,
          `--min-age-minutes ${options.minAgeMinutes}`,
          `--limit ${options.limit}`,
          options.pipelineRun === undefined ? "" : `--pipeline-run ${options.pipelineRun}`,
          options.sourceCommit === undefined ? "" : `--source-commit ${options.sourceCommit}`,
          options.includeActive ? "--include-active" : "",
          "--confirm",
          "--wait",
        ].filter(Boolean).join(" "),
      },
    };
  }
  const deletion = deleteNodeRuntimeCleanupRuns(scoped.spec, selectedPipelineRuns, scoped.timeoutSeconds);
  const afterCounts = nodeRuntimeCiObjectCounts(scoped.spec);
  return {
    ok: isCommandSuccess(deletion),
    command,
    mode: "confirmed-cleanup",
    node: scoped.node,
    lane: scoped.lane,
    namespace: HWLAB_CI_NAMESPACE,
    minAgeMinutes: options.minAgeMinutes,
    limit: options.limit,
    sourceCommit: options.sourceCommit,
    pipelineRun: options.pipelineRun,
    includeActive: options.includeActive,
    deletedPipelineRuns: selectedPipelineRuns,
    deletedPipelineRunCount: selectedPipelineRuns.length,
    ownedResourcesBefore: ownedResources,
    ciObjectCountsBefore: beforeCounts,
    ciObjectCountsAfter: afterCounts,
    deletion: compactRuntimeCommand(deletion),
    mutation: isCommandSuccess(deletion),
    degradedReason: isCommandSuccess(deletion) ? undefined : "node-runtime-ci-cleanup-delete-failed",
    next: {
      status: `bun scripts/cli.ts hwlab nodes control-plane status --node ${scoped.node} --lane ${scoped.lane}`,
      rerunStatus: options.targetPipelineRun === undefined
        ? undefined
        : `bun scripts/cli.ts hwlab nodes control-plane status --node ${scoped.node} --lane ${scoped.lane} --pipeline-run ${options.targetPipelineRun}`,
    },
  };
}

export function listNodeRuntimeCleanupPipelineRuns(spec: HwlabRuntimeLaneSpec, options: NodeRuntimeCleanupOptions): NodeRuntimeCleanupPipelineRunRow[] {
  if (options.targetPipelineRun !== undefined) {
    const target = getNodeRuntimeCleanupPipelineRun(spec, options.targetPipelineRun);
    if (target === null) {
      return [{
        name: options.targetPipelineRun,
        createdAt: null,
        ageMinutes: null,
        status: null,
        reason: "target-pipelinerun-not-found",
        selected: false,
      }];
    }
    if (target.status !== "True" && target.status !== "False" && !options.includeActive) {
      return [{ ...target, selected: false, selectedReason: "target-pipelinerun-not-terminal" }];
    }
    if ((target.status === "True" || target.status === "False") && (target.ageMinutes === null || target.ageMinutes < options.minAgeMinutes)) {
      return [{ ...target, selected: false, selectedReason: target.ageMinutes === null ? "missing-creation-timestamp" : "below-min-age" }];
    }
    return [target];
  }
  const result = runNodeK3sArgs(spec, [
    "kubectl",
    "-n",
    HWLAB_CI_NAMESPACE,
    "get",
    "pipelinerun",
    "-o",
    'jsonpath={range .items[*]}{.metadata.name}{"\\t"}{.metadata.creationTimestamp}{"\\t"}{.status.conditions[0].status}{"\\t"}{.status.conditions[0].reason}{"\\n"}{end}',
  ], 60);
  if (!isCommandSuccess(result)) throw new Error(`failed to list ${HWLAB_CI_NAMESPACE} PipelineRuns: ${result.stderr.trim().slice(0, 1000)}`);
  const rows = nodeRuntimeCleanupPipelineRunRowsFromText(result.stdout);
  const prefix = `${spec.pipelineRunPrefix}-`;
  const terminalRuns = rows
    .filter((item) => item.name.startsWith(prefix))
    .filter((item) => item.status === "True" || item.status === "False")
    .sort((left, right) => String(left.createdAt ?? "").localeCompare(String(right.createdAt ?? "")));
  const protectedLatest = terminalRuns
    .slice()
    .sort((left, right) => String(right.createdAt ?? "").localeCompare(String(left.createdAt ?? "")))[0]?.name ?? null;
  return terminalRuns
    .filter((item) => typeof item.ageMinutes === "number" && item.ageMinutes >= options.minAgeMinutes)
    .map((item) => item.name === protectedLatest ? { ...item, selected: false, selectedReason: "protected-latest-pipelinerun" } : item)
    .slice(0, options.limit);
}

export function getNodeRuntimeCleanupPipelineRun(spec: HwlabRuntimeLaneSpec, pipelineRun: string): NodeRuntimeCleanupPipelineRunRow | null {
  const result = runNodeK3sArgs(spec, [
    "kubectl",
    "-n",
    HWLAB_CI_NAMESPACE,
    "get",
    "pipelinerun",
    pipelineRun,
    "-o",
    'jsonpath={.metadata.name}{"\\t"}{.metadata.creationTimestamp}{"\\t"}{.status.conditions[0].status}{"\\t"}{.status.conditions[0].reason}{"\\n"}',
  ], 60);
  if (result.exitCode !== 0) return null;
  return nodeRuntimeCleanupPipelineRunRowsFromText(result.stdout)[0] ?? null;
}

export function nodeRuntimeCleanupPipelineRunRowsFromText(text: string): NodeRuntimeCleanupPipelineRunRow[] {
  const now = Date.now();
  return text.split(/\r?\n/u).map((line) => {
    const [name = "", createdAtRaw = "", statusRaw = "", reasonRaw = ""] = line.trim().split("\t");
    const createdAt = createdAtRaw.length > 0 ? createdAtRaw : null;
    const createdMs = createdAt === null ? NaN : Date.parse(createdAt);
    return {
      name,
      createdAt,
      ageMinutes: Number.isFinite(createdMs) ? Math.floor((now - createdMs) / 60000) : null,
      status: statusRaw || null,
      reason: reasonRaw || null,
    };
  }).filter((item) => item.name.length > 0);
}

export function listNodeRuntimeCleanupOwnedResources(spec: HwlabRuntimeLaneSpec, pipelineRunNames: string[]): Record<string, unknown> {
  const previewLimit = 24;
  if (pipelineRunNames.length === 0) {
    return {
      taskRunPreview: [],
      podPreview: [],
      pvcPreview: [],
      previewLimit,
      truncated: false,
      taskRunCount: 0,
      podCount: 0,
      pvcCount: 0,
    };
  }
  const wanted = new Set(pipelineRunNames);
  const taskRunsResult = runNodeK3sArgs(spec, [
    "kubectl",
    "-n",
    HWLAB_CI_NAMESPACE,
    "get",
    "taskrun",
    "-o",
    'go-template={{range .items}}{{.metadata.name}}{{"\\t"}}{{index .metadata.labels "tekton.dev/pipelineRun"}}{{"\\t"}}{{range .status.conditions}}{{if eq .type "Succeeded"}}{{.status}}{{"\\t"}}{{.reason}}{{end}}{{end}}{{"\\n"}}{{end}}',
  ], 60);
  const podsResult = runNodeK3sArgs(spec, [
    "kubectl",
    "-n",
    HWLAB_CI_NAMESPACE,
    "get",
    "pod",
    "-o",
    'go-template={{range .items}}{{.metadata.name}}{{"\\t"}}{{index .metadata.labels "tekton.dev/pipelineRun"}}{{"\\t"}}{{.status.phase}}{{"\\t"}}{{.spec.nodeName}}{{"\\n"}}{{end}}',
  ], 60);
  const pvcsResult = runNodeK3sArgs(spec, [
    "kubectl",
    "-n",
    HWLAB_CI_NAMESPACE,
    "get",
    "pvc",
    "-o",
    'go-template={{range .items}}{{.metadata.name}}{{"\\t"}}{{.spec.volumeName}}{{"\\t"}}{{.status.phase}}{{"\\t"}}{{range .metadata.ownerReferences}}{{if eq .kind "PipelineRun"}}{{.kind}}{{"\\t"}}{{.name}}{{end}}{{end}}{{"\\t"}}{{.spec.resources.requests.storage}}{{"\\n"}}{{end}}',
  ], 60);
  const taskRuns = isCommandSuccess(taskRunsResult) ? nodeRuntimeCleanupOwnedTaskRunsFromText(taskRunsResult.stdout, wanted) : [];
  const pods = isCommandSuccess(podsResult) ? nodeRuntimeCleanupOwnedPodsFromText(podsResult.stdout, wanted) : [];
  const pvcs = isCommandSuccess(pvcsResult) ? nodeRuntimeCleanupOwnedPvcsFromText(pvcsResult.stdout, wanted) : [];
  return {
    taskRunPreview: taskRuns.slice(0, previewLimit),
    podPreview: pods.slice(0, previewLimit),
    pvcPreview: pvcs.slice(0, previewLimit),
    previewLimit,
    truncated: taskRuns.length > previewLimit || pods.length > previewLimit || pvcs.length > previewLimit,
    taskRunCount: taskRuns.length,
    podCount: pods.length,
    pvcCount: pvcs.length,
    query: {
      taskRuns: compactRuntimeCommandStats(taskRunsResult),
      pods: compactRuntimeCommandStats(podsResult),
      pvcs: compactRuntimeCommandStats(pvcsResult),
    },
  };
}

interface NodeRuntimeReleasedPvCleanupOptions {
  limit: number;
  dryRun: boolean;
}

interface NodeRuntimeCiPvcRow {
  name: string;
  volumeName: string;
  phase: string;
  ownerKind: string;
  ownerName: string;
  storage: string;
}

interface NodeRuntimeCiPodClaimRow {
  name: string;
  phase: string;
  claims: string[];
}

interface NodeRuntimeReleasedPvRow {
  name: string;
  createdAt: string;
  phase: string;
  storageClass: string;
  reclaimPolicy: string;
  claimNamespace: string;
  claimName: string;
  capacity: string;
  hostPath: string | null;
}

interface NodeRuntimeLegacyDockerImageCleanupPolicy {
  enabled: boolean;
  minAgeHours: number;
  keepPerRepository: number;
  repositories: string[];
  source: string;
}

interface NodeRuntimeLegacyDockerImageCleanupOptions {
  minAgeHours: number;
  keepPerRepository: number;
  limit: number;
  dryRun: boolean;
}

interface NodeRuntimeLegacyDockerRegistryVolumeCleanupPolicy {
  enabled: boolean;
  requireK8sRegistryReady: boolean;
  source: string;
}

interface NodeRuntimeLegacyDockerRegistryVolumeTarget {
  dockerContainerName: string;
  dockerImage: string;
  mountDestination: string;
  k8sRegistry: {
    mode: string;
    endpoint: string;
    namespace: string;
    deploymentName: string;
    pvcName: string;
  };
  source: string;
}

function parseNodeRuntimeReleasedPvCleanupOptions(scoped: ReturnType<typeof parseNodeScopedDelegatedOptions>): NodeRuntimeReleasedPvCleanupOptions {
  return {
    limit: positiveIntegerOption(scoped.originalArgs, "--limit", 200, 500),
    dryRun: scoped.dryRun || !scoped.confirm,
  };
}

function parseNodeRuntimeLegacyDockerImageCleanupOptions(scoped: ReturnType<typeof parseNodeScopedDelegatedOptions>, policy: NodeRuntimeLegacyDockerImageCleanupPolicy): NodeRuntimeLegacyDockerImageCleanupOptions {
  return {
    minAgeHours: positiveIntegerOption(scoped.originalArgs, "--min-age-hours", policy.minAgeHours, 8760),
    keepPerRepository: positiveIntegerOption(scoped.originalArgs, "--keep-per-repository", policy.keepPerRepository, 100),
    limit: positiveIntegerOption(scoped.originalArgs, "--limit", 50, 500),
    dryRun: scoped.dryRun || !scoped.confirm,
  };
}

export function nodeRuntimeCleanupReleasedPvs(scoped: ReturnType<typeof parseNodeScopedDelegatedOptions>): Record<string, unknown> {
  const options = parseNodeRuntimeReleasedPvCleanupOptions(scoped);
  const beforeCounts = nodeRuntimeCiObjectCounts(scoped.spec);
  const orphanedPvcs = listNodeRuntimeOrphanedCiPvcs(scoped.spec, options.limit);
  const releasedPvsBefore = listNodeRuntimeReleasedCiPvs(scoped.spec, options.limit);
  const command = `hwlab nodes control-plane cleanup-released-pvs --node ${scoped.node} --lane ${scoped.lane}`;
  if (options.dryRun) {
    return {
      ok: true,
      command,
      mode: "dry-run",
      node: scoped.node,
      lane: scoped.lane,
      namespace: HWLAB_CI_NAMESPACE,
      limit: options.limit,
      orphanedPvcs,
      orphanedPvcCount: orphanedPvcs.length,
      releasedPvs: releasedPvsBefore,
      releasedPvCount: releasedPvsBefore.length,
      mutation: false,
      policy: {
        orphanedPvc: "ownerRef.kind=PipelineRun, owner PipelineRun absent, pvc name matches pvc-*, and no non-terminal pod mounts the claim",
        releasedPv: "phase=Released, storageClass=local-path, reclaimPolicy=Delete, claimRef.namespace=hwlab-ci",
      },
      next: { confirm: `${command} --limit ${options.limit} --confirm` },
    };
  }
  const pvcDeletion = deleteNodeRuntimeCiPvcs(scoped.spec, orphanedPvcs.map((item) => item.name), scoped.timeoutSeconds);
  const releasedPvsAfterPvc = listNodeRuntimeReleasedCiPvs(scoped.spec, options.limit);
  const pvNames = releasedPvsAfterPvc.map((item) => item.name);
  const pvDeletion = deleteNodeRuntimePersistentVolumes(scoped.spec, pvNames, scoped.timeoutSeconds);
  const afterCounts = nodeRuntimeCiObjectCounts(scoped.spec);
  const ok = isCommandSuccess(pvcDeletion) && isCommandSuccess(pvDeletion);
  return {
    ok,
    command,
    mode: "confirmed-cleanup",
    node: scoped.node,
    lane: scoped.lane,
    namespace: HWLAB_CI_NAMESPACE,
    limit: options.limit,
    deletedPvcs: orphanedPvcs.map((item) => item.name),
    deletedPvcCount: orphanedPvcs.length,
    deletedPersistentVolumes: pvNames,
    deletedPersistentVolumeCount: pvNames.length,
    orphanedPvcsBefore: orphanedPvcs.slice(0, 50),
    releasedPvsBefore,
    releasedPvsAfterPvcDelete: releasedPvsAfterPvc.slice(0, 50),
    ciObjectCountsBefore: beforeCounts,
    ciObjectCountsAfter: afterCounts,
    pvcDeletion: compactRuntimeCommand(pvcDeletion),
    pvDeletion: compactRuntimeCommand(pvDeletion),
    mutation: ok,
    degradedReason: ok ? undefined : "node-runtime-ci-pvc-pv-cleanup-failed",
    next: { status: `bun scripts/cli.ts hwlab nodes control-plane status --node ${scoped.node} --lane ${scoped.lane}` },
  };
}

export function nodeRuntimeCleanupLegacyDockerImages(scoped: ReturnType<typeof parseNodeScopedDelegatedOptions>): Record<string, unknown> {
  const policy = nodeRuntimeLegacyDockerImageCleanupPolicy(scoped.node);
  const options = parseNodeRuntimeLegacyDockerImageCleanupOptions(scoped, policy);
  const command = `hwlab nodes control-plane cleanup-legacy-docker-images --node ${scoped.node} --lane ${scoped.lane}`;
  if (!policy.enabled) {
    return {
      ok: false,
      command,
      node: scoped.node,
      lane: scoped.lane,
      mode: "blocked",
      mutation: false,
      config: policy.source,
      degradedReason: "legacy-docker-image-cleanup-disabled",
      message: `${policy.source} is disabled`,
      valuesRedacted: true,
    };
  }
  if (policy.repositories.length === 0) {
    return {
      ok: false,
      command,
      node: scoped.node,
      lane: scoped.lane,
      mode: "blocked",
      mutation: false,
      config: policy.source,
      degradedReason: "legacy-docker-image-cleanup-repositories-empty",
      message: `${policy.source}.repositories must include at least one repository`,
      valuesRedacted: true,
    };
  }
  const remoteOptions = {
    mode: options.dryRun ? "plan" : "run",
    minAgeHours: options.minAgeHours,
    keepPerRepository: options.keepPerRepository,
    limit: options.limit,
    repositories: policy.repositories,
    node: scoped.node,
    lane: scoped.lane,
    valuesRedacted: true,
  };
  const script = [
    "set -eu",
    `node - ${shellQuote(JSON.stringify(remoteOptions))} <<'UNIDESK_LEGACY_DOCKER_IMAGE_GC'`,
    nodeRuntimeLegacyDockerImageCleanupNodeScript(),
    "UNIDESK_LEGACY_DOCKER_IMAGE_GC",
  ].join("\n");
  const result = runNodeHostScript(scoped.spec, script, scoped.timeoutSeconds);
  const payloadResolution = resolveRuntimeCleanupJson(result, "legacy docker image cleanup JSON");
  const payload = payloadResolution.parsed ?? {};
  const ok = isCommandSuccess(result) && payload.ok !== false;
  return {
    ok,
    command,
    node: scoped.node,
    lane: scoped.lane,
    mode: options.dryRun ? "dry-run" : "confirmed-cleanup",
    mutation: !options.dryRun && ok,
    config: policy.source,
    policy: {
      repositories: policy.repositories,
      minAgeHours: options.minAgeHours,
      keepPerRepository: options.keepPerRepository,
      limit: options.limit,
      dockerPruneUsed: false,
      dockerVolumesTouched: false,
      protectedContainerImages: true,
      legacyOnly: true,
      deploymentDependency: false,
      valuesRedacted: true,
    },
    cleanup: options.dryRun || ok ? compactNodeRuntimeLegacyDockerImageCleanupPayload(payload) : payload,
    result: { ...(ok ? compactRuntimeCommandStats(result) : compactRuntimeCommand(result)), stdoutRecovery: payloadResolution.diagnostics },
    degradedReason: ok ? undefined : "legacy-docker-image-cleanup-failed",
    next: options.dryRun
      ? { confirm: `${command} --min-age-hours ${options.minAgeHours} --keep-per-repository ${options.keepPerRepository} --limit ${options.limit} --confirm` }
      : { status: `bun scripts/cli.ts hwlab nodes control-plane status --node ${scoped.node} --lane ${scoped.lane}` },
    valuesRedacted: true,
  };
}

export function nodeRuntimeCleanupLegacyDockerRegistryVolume(scoped: ReturnType<typeof parseNodeScopedDelegatedOptions>): Record<string, unknown> {
  const policy = nodeRuntimeLegacyDockerRegistryVolumeCleanupPolicy();
  const target = nodeRuntimeLegacyDockerRegistryVolumeTarget(scoped.node);
  const dryRun = scoped.dryRun || !scoped.confirm;
  const command = `hwlab nodes control-plane cleanup-legacy-docker-registry-volume --node ${scoped.node} --lane ${scoped.lane}`;
  if (!policy.enabled) {
    return {
      ok: false,
      command,
      node: scoped.node,
      lane: scoped.lane,
      mode: "blocked",
      mutation: false,
      config: policy.source,
      degradedReason: "legacy-docker-registry-volume-cleanup-disabled",
      message: `${policy.source} is disabled`,
      valuesRedacted: true,
    };
  }
  if (target.k8sRegistry.mode !== "k8s-workload") {
    return {
      ok: false,
      command,
      node: scoped.node,
      lane: scoped.lane,
      mode: "blocked",
      mutation: false,
      config: target.source,
      degradedReason: "node-local-registry-is-not-k8s-workload",
      message: "legacy Docker registry volume cleanup is allowed only after node-local registry is declared as a k8s workload",
      target,
      valuesRedacted: true,
    };
  }
  const remoteOptions = {
    mode: dryRun ? "plan" : "run",
    target,
    requireK8sRegistryReady: policy.requireK8sRegistryReady,
    node: scoped.node,
    lane: scoped.lane,
    valuesRedacted: true,
  };
  const script = [
    "set -eu",
    `node - ${shellQuote(JSON.stringify(remoteOptions))} <<'UNIDESK_LEGACY_DOCKER_REGISTRY_VOLUME_GC'`,
    nodeRuntimeLegacyDockerRegistryVolumeCleanupNodeScript(),
    "UNIDESK_LEGACY_DOCKER_REGISTRY_VOLUME_GC",
  ].join("\n");
  const result = runNodeHostScript(scoped.spec, script, scoped.timeoutSeconds);
  const payloadResolution = resolveRuntimeCleanupJson(result, "legacy docker registry volume cleanup JSON");
  const payload = payloadResolution.parsed ?? {};
  const ok = isCommandSuccess(result) && payload.ok !== false;
  return {
    ok,
    command,
    node: scoped.node,
    lane: scoped.lane,
    mode: dryRun ? "dry-run" : "confirmed-cleanup",
    mutation: !dryRun && ok && payload.mutation === true,
    config: {
      policy: policy.source,
      target: target.source,
    },
    target,
    cleanup: compactNodeRuntimeLegacyDockerRegistryVolumeCleanupPayload(payload),
    result: { ...(ok ? compactRuntimeCommandStats(result) : compactRuntimeCommand(result)), stdoutRecovery: payloadResolution.diagnostics },
    degradedReason: ok ? undefined : "legacy-docker-registry-volume-cleanup-failed",
    next: dryRun
      ? { confirm: `${command} --confirm --wait` }
      : { status: `bun scripts/cli.ts hwlab nodes control-plane status --node ${scoped.node} --lane ${scoped.lane}` },
    valuesRedacted: true,
  };
}

function nodeRuntimeLegacyDockerImageCleanupPolicy(node: string): NodeRuntimeLegacyDockerImageCleanupPolicy {
  const source = "config/unidesk-cli.yaml#gc.legacyDockerImages";
  const parsed = objectRecord(Bun.YAML.parse(readFileSync(rootPath("config/unidesk-cli.yaml"), "utf8")));
  const raw = objectRecord(objectRecord(parsed.gc).legacyDockerImages);
  const nodeLower = node.toLowerCase();
  const repositories = Array.isArray(raw.repositories)
    ? [...new Set(raw.repositories
      .filter((item): item is string => typeof item === "string")
      .map((item) => item
        .replace(/\$\{nodeLower\}/gu, nodeLower)
        .replace(/\$\{node\}/gu, node)
        .replace(/\$\{NODE\}/gu, node.toUpperCase()))
      .map((item) => item.trim())
      .filter(Boolean))]
    : [];
  return {
    enabled: raw.enabled === true,
    minAgeHours: nonNegativeInteger(raw.minAgeHours, `${source}.minAgeHours`),
    keepPerRepository: nonNegativeInteger(raw.keepPerRepository, `${source}.keepPerRepository`),
    repositories,
    source,
  };
}

function nodeRuntimeLegacyDockerRegistryVolumeCleanupPolicy(): NodeRuntimeLegacyDockerRegistryVolumeCleanupPolicy {
  const source = "config/unidesk-cli.yaml#gc.legacyDockerRegistryVolumes";
  const parsed = objectRecord(Bun.YAML.parse(readFileSync(rootPath("config/unidesk-cli.yaml"), "utf8")));
  const raw = objectRecord(objectRecord(parsed.gc).legacyDockerRegistryVolumes);
  return {
    enabled: raw.enabled === true,
    requireK8sRegistryReady: raw.requireK8sRegistryReady !== false,
    source,
  };
}

function nodeRuntimeLegacyDockerRegistryVolumeTarget(node: string): NodeRuntimeLegacyDockerRegistryVolumeTarget {
  const source = `${HWLAB_NODE_CONTROL_PLANE_CONFIG_PATH}#nodes.${node}`;
  const parsed = objectRecord(Bun.YAML.parse(readFileSync(rootPath(HWLAB_NODE_CONTROL_PLANE_CONFIG_PATH), "utf8")));
  const nodeConfig = objectRecord(objectRecord(parsed.nodes)[node]);
  const localRegistry = objectRecord(objectRecord(objectRecord(nodeConfig.k3s).install).localRegistry);
  const registry = objectRecord(nodeConfig.registry);
  return {
    dockerContainerName: requiredConfigString(localRegistry.containerName, `${source}.k3s.install.localRegistry.containerName`),
    dockerImage: requiredConfigString(localRegistry.canonicalImage ?? localRegistry.image, `${source}.k3s.install.localRegistry.canonicalImage`),
    mountDestination: "/var/lib/registry",
    k8sRegistry: {
      mode: requiredConfigString(registry.mode, `${source}.registry.mode`),
      endpoint: requiredConfigString(registry.endpoint, `${source}.registry.endpoint`),
      namespace: requiredConfigString(registry.namespace, `${source}.registry.namespace`),
      deploymentName: requiredConfigString(registry.deploymentName, `${source}.registry.deploymentName`),
      pvcName: requiredConfigString(registry.pvcName, `${source}.registry.pvcName`),
    },
    source,
  };
}

function objectRecord(value: unknown): Record<string, unknown> {
  return typeof value === "object" && value !== null && !Array.isArray(value) ? value as Record<string, unknown> : {};
}

function nonNegativeInteger(value: unknown, label: string): number {
  if (!Number.isInteger(value) || value < 0) throw new Error(`${label} must be a non-negative integer`);
  return value;
}

function requiredConfigString(value: unknown, label: string): string {
  if (typeof value !== "string" || value.trim().length === 0) throw new Error(`${label} must be a non-empty string`);
  return value.trim();
}

function compactNodeRuntimeLegacyDockerImageCleanupPayload(payload: Record<string, unknown>): Record<string, unknown> {
  const candidates = Array.isArray(payload.candidates) ? payload.candidates.map(objectRecord) : [];
  const selected = Array.isArray(payload.selected) ? payload.selected.map(objectRecord) : [];
  const protectedImages = Array.isArray(payload.protected) ? payload.protected.map(objectRecord) : [];
  const results = Array.isArray(payload.results) ? payload.results.map(objectRecord) : [];
  return {
    ok: payload.ok ?? null,
    mode: payload.mode ?? null,
    mutation: payload.mutation ?? null,
    diskBefore: payload.diskBefore ?? null,
    diskAfter: payload.diskAfter ?? null,
    actualDiskReclaimBytes: payload.actualDiskReclaimBytes ?? null,
    repositoryCount: payload.repositoryCount ?? null,
    imageCount: payload.imageCount ?? null,
    containerCount: payload.containerCount ?? null,
    candidateCount: payload.candidateCount ?? null,
    selectedCount: payload.selectedCount ?? null,
    deferredCount: payload.deferredCount ?? null,
    protectedCount: payload.protectedCount ?? null,
    estimatedReclaimBytes: payload.estimatedReclaimBytes ?? null,
    estimatedReclaimHuman: payload.estimatedReclaimHuman ?? null,
    candidates: candidates.slice(0, 5).map(compactLegacyDockerImageRow),
    selected: selected.slice(0, 10).map(compactLegacyDockerImageRow),
    protected: protectedImages.slice(0, 5).map((item) => ({
      ...compactLegacyDockerImageRow(item),
      reasons: item.reasons ?? null,
    })),
    results: results.slice(0, 10).map((item) => ({
      shortId: item.shortId ?? null,
      tags: compactLegacyDockerTags(item.refs),
      status: item.status ?? null,
      exitCode: item.exitCode ?? null,
      stderrTail: item.status === "failed" ? item.stderrTail ?? null : undefined,
    })),
    policy: payload.policy ?? null,
    disclosure: {
      candidateRowsShown: Math.min(5, candidates.length),
      selectedRowsShown: Math.min(10, selected.length),
      protectedRowsShown: Math.min(5, protectedImages.length),
      resultRowsShown: Math.min(10, results.length),
    },
    valuesRedacted: true,
  };
}

function compactLegacyDockerImageRow(item: Record<string, unknown>): Record<string, unknown> {
  return {
    shortId: item.shortId ?? null,
    tags: compactLegacyDockerTags(item.repoTags),
    createdAt: item.createdAt ?? null,
    ageHours: item.ageHours ?? null,
    sizeHuman: item.sizeHuman ?? null,
  };
}

function compactLegacyDockerTags(value: unknown): string[] {
  if (!Array.isArray(value)) return [];
  return value
    .filter((item): item is string => typeof item === "string")
    .slice(0, 3)
    .map((item) => {
      const index = item.lastIndexOf(":");
      return index > 0 ? item.slice(index + 1) : item;
    });
}

function compactNodeRuntimeLegacyDockerRegistryVolumeCleanupPayload(payload: Record<string, unknown>): Record<string, unknown> {
  const candidate = objectRecord(payload.candidate);
  return {
    ok: payload.ok ?? null,
    mode: payload.mode ?? null,
    mutation: payload.mutation ?? null,
    diskBefore: payload.diskBefore ?? null,
    diskAfter: payload.diskAfter ?? null,
    actualDiskReclaimBytes: payload.actualDiskReclaimBytes ?? null,
    candidate: Object.keys(candidate).length === 0 ? null : {
      containerName: candidate.containerName ?? null,
      containerStatus: candidate.containerStatus ?? null,
      image: candidate.image ?? null,
      volumeName: candidate.volumeName ?? null,
      mountDestination: candidate.mountDestination ?? null,
      sizeBytes: candidate.sizeBytes ?? null,
      sizeHuman: candidate.sizeHuman ?? null,
    },
    k8sRegistry: payload.k8sRegistry ?? null,
    blockers: Array.isArray(payload.blockers) ? payload.blockers.slice(0, 20) : [],
    actions: payload.actions ?? null,
    policy: payload.policy ?? null,
    valuesRedacted: true,
  };
}

function nodeRuntimeLegacyDockerImageCleanupNodeScript(): string {
  return String.raw`
const childProcess = require("child_process");

const options = JSON.parse(process.argv[2] || "{}");
const mode = options.mode === "run" ? "run" : "plan";
const repositories = Array.isArray(options.repositories) ? options.repositories.filter((item) => typeof item === "string" && item.length > 0) : [];
const repositorySet = new Set(repositories);
const minAgeHours = Number(options.minAgeHours);
const keepPerRepository = Number(options.keepPerRepository);
const limit = Number(options.limit);
const nowMs = Date.now();

function run(args) {
  const result = childProcess.spawnSync(args[0], args.slice(1), { encoding: "utf8", maxBuffer: 64 * 1024 * 1024 });
  return {
    command: args,
    exitCode: typeof result.status === "number" ? result.status : null,
    signal: result.signal || null,
    stdout: result.stdout || "",
    stderr: result.stderr || "",
    ok: result.status === 0,
  };
}

function parseJsonArray(text) {
  try {
    const parsed = JSON.parse(text);
    return Array.isArray(parsed) ? parsed : [];
  } catch {
    return [];
  }
}

function diskSnapshot() {
  const result = run(["df", "-B1", "/"]);
  const line = result.stdout.trim().split(/\r?\n/)[1] || "";
  const cols = line.trim().split(/\s+/);
  const usePercent = Number(String(cols[4] || "0").replace(/%$/, ""));
  return {
    filesystem: cols[0] || null,
    sizeBytes: Number(cols[1] || 0),
    usedBytes: Number(cols[2] || 0),
    availableBytes: Number(cols[3] || 0),
    usePercent: Number.isFinite(usePercent) ? usePercent : null,
    mount: cols[5] || "/",
  };
}

function repoFromTag(tag) {
  const value = String(tag || "");
  const atless = value.split("@")[0];
  const colon = atless.lastIndexOf(":");
  const slash = atless.lastIndexOf("/");
  return colon > slash ? atless.slice(0, colon) : atless;
}

function ageHours(createdAt) {
  const ms = Date.parse(String(createdAt || ""));
  if (!Number.isFinite(ms)) return null;
  return Math.max(0, Math.round(((nowMs - ms) / 3600000) * 10) / 10);
}

function human(bytes) {
  const value = Number(bytes || 0);
  if (!Number.isFinite(value) || value <= 0) return "0B";
  const units = ["B", "KiB", "MiB", "GiB", "TiB"];
  let scaled = value;
  let index = 0;
  while (scaled >= 1024 && index < units.length - 1) {
    scaled /= 1024;
    index += 1;
  }
  return String(Math.round(scaled * 10) / 10) + units[index];
}

function shortId(id) {
  return String(id || "").replace(/^sha256:/, "").slice(0, 12);
}

function dockerImagePresent(id) {
  return run(["docker", "image", "inspect", id]).ok;
}

const errors = [];
const dockerProbe = run(["docker", "version", "--format", "{{json .Server.Version}}"]);
if (!dockerProbe.ok) {
  console.log(JSON.stringify({ ok: false, mode, mutation: false, error: "docker-daemon-unavailable", stderrTail: dockerProbe.stderr.slice(-2000), valuesRedacted: true }));
  process.exit(0);
}

const diskBefore = diskSnapshot();
const imageList = run(["docker", "image", "ls", "-q", "--no-trunc"]);
if (!imageList.ok) errors.push({ command: imageList.command, exitCode: imageList.exitCode, stderrTail: imageList.stderr.slice(-1000) });
const imageIds = [...new Set(imageList.stdout.split(/\r?\n/).map((line) => line.trim()).filter(Boolean))];
const imageInspect = imageIds.length === 0 ? { ok: true, stdout: "[]", stderr: "", exitCode: 0, command: [] } : run(["docker", "image", "inspect", ...imageIds]);
if (!imageInspect.ok) errors.push({ command: ["docker", "image", "inspect", "<image ids>"], exitCode: imageInspect.exitCode, stderrTail: imageInspect.stderr.slice(-1000) });
const images = parseJsonArray(imageInspect.stdout).map((image) => {
  const repoTags = Array.isArray(image.RepoTags) ? image.RepoTags.filter((tag) => typeof tag === "string" && tag !== "<none>:<none>") : [];
  const matchedTags = repoTags.filter((tag) => repositorySet.has(repoFromTag(tag)));
  const matchedRepositories = [...new Set(matchedTags.map(repoFromTag))];
  const createdAt = typeof image.Created === "string" ? image.Created : null;
  return {
    id: String(image.Id || ""),
    shortId: shortId(image.Id),
    repoTags,
    matchedTags,
    matchedRepositories,
    createdAt,
    createdMs: createdAt === null ? 0 : Date.parse(createdAt) || 0,
    ageHours: ageHours(createdAt),
    sizeBytes: Number(image.Size || 0),
    sizeHuman: human(Number(image.Size || 0)),
  };
}).filter((image) => image.id.length > 0);

const containerList = run(["docker", "ps", "-a", "--no-trunc", "--format", "{{.ID}}"]);
if (!containerList.ok) errors.push({ command: containerList.command, exitCode: containerList.exitCode, stderrTail: containerList.stderr.slice(-1000) });
const containerIds = [...new Set(containerList.stdout.split(/\r?\n/).map((line) => line.trim()).filter(Boolean))];
const containerInspect = containerIds.length === 0 ? { ok: true, stdout: "[]", stderr: "", exitCode: 0, command: [] } : run(["docker", "container", "inspect", ...containerIds]);
if (!containerInspect.ok) errors.push({ command: ["docker", "container", "inspect", "<container ids>"], exitCode: containerInspect.exitCode, stderrTail: containerInspect.stderr.slice(-1000) });
const containers = parseJsonArray(containerInspect.stdout).map((container) => ({
  id: String(container.Id || ""),
  name: String(container.Name || "").replace(/^\//, ""),
  imageId: String(container.Image || ""),
  imageRef: String((container.Config && container.Config.Image) || ""),
  state: String((container.State && container.State.Status) || ""),
}));
const protectedByContainer = new Set(containers.map((container) => container.imageId).filter(Boolean));

const latestProtected = new Set();
for (const repository of repositories) {
  images
    .filter((image) => image.matchedRepositories.includes(repository))
    .sort((a, b) => (b.createdMs - a.createdMs) || a.id.localeCompare(b.id))
    .slice(0, keepPerRepository)
    .forEach((image) => latestProtected.add(image.id));
}

const candidates = [];
const protectedImages = [];
for (const image of images.filter((item) => item.matchedRepositories.length > 0)) {
  const reasons = [];
  if (protectedByContainer.has(image.id)) reasons.push("container-referenced");
  if (latestProtected.has(image.id)) reasons.push("latest-retention");
  if (image.ageHours === null) reasons.push("created-at-unknown");
  if (image.ageHours !== null && image.ageHours < minAgeHours) reasons.push("under-min-age");
  if (reasons.length > 0) {
    protectedImages.push({ ...image, reasons });
  } else {
    candidates.push({ ...image, reasons: ["repository-allowlisted", "unreferenced-by-container", "older-than-min-age", "outside-latest-retention"] });
  }
}

candidates.sort((a, b) => (a.createdMs - b.createdMs) || a.id.localeCompare(b.id));
const selected = candidates.slice(0, limit);
const results = [];
if (mode === "run") {
  for (const image of selected) {
    const refs = image.matchedTags.length > 0 ? image.matchedTags : [image.id];
    const remove = run(["docker", "image", "rm", ...refs]);
    results.push({
      id: image.id,
      shortId: image.shortId,
      refs,
      status: remove.ok && !dockerImagePresent(image.id) ? "deleted" : remove.ok ? "untagged-or-shared" : "failed",
      exitCode: remove.exitCode,
      stdoutTail: remove.stdout.slice(-1000),
      stderrTail: remove.stderr.slice(-1000),
    });
  }
}

const diskAfter = mode === "run" ? diskSnapshot() : null;
const actualDiskReclaimBytes = diskAfter === null ? null : Math.max(0, Number(diskBefore.usedBytes || 0) - Number(diskAfter.usedBytes || 0));
const ok = errors.length === 0 && results.every((item) => item.status !== "failed");
console.log(JSON.stringify({
  ok,
  mode,
  mutation: mode === "run",
  node: options.node || null,
  lane: options.lane || null,
  repositories,
  repositoryCount: repositories.length,
  diskBefore,
  diskAfter,
  actualDiskReclaimBytes,
  imageCount: images.length,
  containerCount: containers.length,
  activeContainers: containers.filter((container) => container.state === "running").slice(0, 20),
  candidateCount: candidates.length,
  selectedCount: selected.length,
  deferredCount: Math.max(0, candidates.length - selected.length),
  protectedCount: protectedImages.length,
  estimatedReclaimBytes: selected.reduce((sum, image) => sum + Number(image.sizeBytes || 0), 0),
  estimatedReclaimHuman: human(selected.reduce((sum, image) => sum + Number(image.sizeBytes || 0), 0)),
  candidates,
  selected,
  protected: protectedImages,
  results,
  errors,
  policy: {
    minAgeHours,
    keepPerRepository,
    limit,
    dockerPruneUsed: false,
    dockerVolumesTouched: false,
    matchedRepositoriesOnly: true,
    protectedContainerImages: true,
    valuesRedacted: true,
  },
  valuesRedacted: true,
}));
`;
}

function nodeRuntimeLegacyDockerRegistryVolumeCleanupNodeScript(): string {
  return String.raw`
const childProcess = require("child_process");

const options = JSON.parse(process.argv[2] || "{}");
const mode = options.mode === "run" ? "run" : "plan";
const target = options.target || {};
const requireK8sRegistryReady = options.requireK8sRegistryReady !== false;

function run(args, extraEnv) {
  const result = childProcess.spawnSync(args[0], args.slice(1), {
    encoding: "utf8",
    maxBuffer: 64 * 1024 * 1024,
    env: { ...process.env, ...(extraEnv || {}) },
  });
  return {
    command: args,
    exitCode: typeof result.status === "number" ? result.status : null,
    signal: result.signal || null,
    stdout: result.stdout || "",
    stderr: result.stderr || "",
    ok: result.status === 0,
  };
}

function parseJson(text) {
  try { return JSON.parse(text); } catch { return null; }
}

function diskSnapshot() {
  const result = run(["df", "-B1", "/"]);
  const line = result.stdout.trim().split(/\r?\n/)[1] || "";
  const cols = line.trim().split(/\s+/);
  const usePercent = Number(String(cols[4] || "0").replace(/%$/, ""));
  return {
    filesystem: cols[0] || null,
    sizeBytes: Number(cols[1] || 0),
    usedBytes: Number(cols[2] || 0),
    availableBytes: Number(cols[3] || 0),
    usePercent: Number.isFinite(usePercent) ? usePercent : null,
    mount: cols[5] || "/",
  };
}

function duBytes(path) {
  const bytes = run(["du", "-sb", path]);
  if (bytes.ok) {
    const value = Number((bytes.stdout.trim().split(/\s+/)[0] || "0"));
    if (Number.isFinite(value)) return value;
  }
  const kib = run(["du", "-sk", path]);
  const value = Number((kib.stdout.trim().split(/\s+/)[0] || "0"));
  return Number.isFinite(value) ? value * 1024 : null;
}

function human(bytes) {
  const value = Number(bytes || 0);
  if (!Number.isFinite(value) || value <= 0) return "0B";
  const units = ["B", "KiB", "MiB", "GiB", "TiB"];
  let scaled = value;
  let index = 0;
  while (scaled >= 1024 && index < units.length - 1) {
    scaled /= 1024;
    index += 1;
  }
  return String(Math.round(scaled * 10) / 10) + units[index];
}

function inspectContainer(name) {
  const result = run(["docker", "container", "inspect", name]);
  if (!result.ok) return { ok: false, result, container: null };
  const parsed = parseJson(result.stdout);
  return { ok: Array.isArray(parsed) && parsed.length > 0, result, container: Array.isArray(parsed) ? parsed[0] : null };
}

function listContainers() {
  const list = run(["docker", "ps", "-a", "--no-trunc", "--format", "{{.ID}}"]);
  if (!list.ok) return [];
  const ids = [...new Set(list.stdout.split(/\r?\n/).map((line) => line.trim()).filter(Boolean))];
  if (ids.length === 0) return [];
  const inspect = run(["docker", "container", "inspect", ...ids]);
  const parsed = parseJson(inspect.stdout);
  return Array.isArray(parsed) ? parsed : [];
}

function k8sRegistryStatus(registry) {
  const env = { KUBECONFIG: "/etc/rancher/k3s/k3s.yaml" };
  const namespace = String(registry.namespace || "");
  const deploymentName = String(registry.deploymentName || "");
  const pvcName = String(registry.pvcName || "");
  const deployResult = run(["kubectl", "-n", namespace, "get", "deployment", deploymentName, "-o", "json"], env);
  const pvcResult = run(["kubectl", "-n", namespace, "get", "pvc", pvcName, "-o", "json"], env);
  const endpoint = String(registry.endpoint || "");
  const curlResult = endpoint.length > 0 ? run(["curl", "-fsS", "--max-time", "5", "http://" + endpoint + "/v2/"]) : { ok: false, exitCode: null, stderr: "endpoint missing" };
  const deployment = parseJson(deployResult.stdout) || {};
  const pvc = parseJson(pvcResult.stdout) || {};
  const desired = Math.max(1, Number((deployment.spec && deployment.spec.replicas) || 1));
  const readyReplicas = Number((deployment.status && deployment.status.readyReplicas) || 0);
  const updatedReplicas = Number((deployment.status && deployment.status.updatedReplicas) || 0);
  const pvcPhase = String((pvc.status && pvc.status.phase) || "");
  return {
    ok: deployResult.ok && pvcResult.ok && curlResult.ok && readyReplicas >= desired && updatedReplicas >= desired && pvcPhase === "Bound",
    namespace,
    deploymentName,
    pvcName,
    desiredReplicas: desired,
    readyReplicas,
    updatedReplicas,
    pvcPhase,
    endpoint,
    endpointOk: curlResult.ok,
    deployExitCode: deployResult.exitCode,
    pvcExitCode: pvcResult.exitCode,
    curlExitCode: curlResult.exitCode,
    valuesRedacted: true,
  };
}

const diskBefore = diskSnapshot();
const inspect = inspectContainer(String(target.dockerContainerName || "registry"));
if (!inspect.ok || inspect.container === null) {
  console.log(JSON.stringify({
    ok: true,
    mode,
    mutation: false,
    diskBefore,
    diskAfter: null,
    actualDiskReclaimBytes: null,
    candidate: null,
    k8sRegistry: k8sRegistryStatus(target.k8sRegistry || {}),
    blockers: [],
    actions: { containerRemoved: false, volumeRemoved: false },
    policy: { noCandidateIsOk: true, dockerPruneUsed: false, dockerVolumeRmScoped: false, valuesRedacted: true },
    valuesRedacted: true,
  }));
  process.exit(0);
}

const container = inspect.container;
const state = container.State || {};
const config = container.Config || {};
const mounts = Array.isArray(container.Mounts) ? container.Mounts : [];
const registryMount = mounts.find((mount) => mount && mount.Type === "volume" && mount.Destination === target.mountDestination);
const allContainers = listContainers();
const otherUsers = registryMount === undefined ? [] : allContainers
  .filter((item) => item && item.Id !== container.Id)
  .filter((item) => Array.isArray(item.Mounts) && item.Mounts.some((mount) => mount && mount.Name === registryMount.Name))
  .map((item) => ({ id: String(item.Id || "").slice(0, 12), name: String(item.Name || "").replace(/^\//, ""), state: String((item.State && item.State.Status) || "") }));
const k8sRegistry = k8sRegistryStatus(target.k8sRegistry || {});
const volumeSource = registryMount && typeof registryMount.Source === "string" ? registryMount.Source : null;
const volumeName = registryMount && typeof registryMount.Name === "string" ? registryMount.Name : null;
const sizeBytes = volumeSource === null ? null : duBytes(volumeSource);
const candidate = registryMount === undefined ? null : {
  containerName: String(container.Name || "").replace(/^\//, ""),
  containerStatus: String(state.Status || ""),
  running: state.Running === true,
  image: String(config.Image || ""),
  volumeName,
  volumeSource,
  mountDestination: String(registryMount.Destination || ""),
  sizeBytes,
  sizeHuman: human(sizeBytes),
  otherUsers,
  valuesRedacted: true,
};
const blockers = [];
if (registryMount === undefined) blockers.push("registry-volume-mount-missing");
if (state.Running === true || String(state.Status || "") === "running") blockers.push("docker-registry-container-running");
if (String(config.Image || "") !== String(target.dockerImage || "")) blockers.push("docker-registry-image-mismatch");
if (otherUsers.length > 0) blockers.push("docker-volume-has-other-container-users");
if (volumeName === null || volumeName.length === 0) blockers.push("docker-volume-name-missing");
if (volumeSource === null || !volumeSource.startsWith("/var/lib/docker/volumes/") || !volumeSource.endsWith("/_data")) blockers.push("docker-volume-source-not-under-docker-volumes");
if (requireK8sRegistryReady && k8sRegistry.ok !== true) blockers.push("replacement-k8s-registry-not-ready");

let actions = { containerRemoved: false, volumeRemoved: false, containerRmExitCode: null, volumeRmExitCode: null, stderrTail: "" };
let diskAfter = null;
let mutation = false;
if (mode === "run" && blockers.length === 0 && candidate !== null) {
  const removeContainer = run(["docker", "rm", String(target.dockerContainerName || "registry")]);
  const removeVolume = removeContainer.ok ? run(["docker", "volume", "rm", String(volumeName)]) : { ok: false, exitCode: null, stderr: "container removal failed" };
  actions = {
    containerRemoved: removeContainer.ok,
    volumeRemoved: removeVolume.ok,
    containerRmExitCode: removeContainer.exitCode,
    volumeRmExitCode: removeVolume.exitCode,
    stderrTail: (removeContainer.stderr + "\n" + removeVolume.stderr).trim().slice(-1000),
  };
  mutation = removeContainer.ok && removeVolume.ok;
  if (!removeContainer.ok) blockers.push("docker-container-rm-failed");
  if (removeContainer.ok && !removeVolume.ok) blockers.push("docker-volume-rm-failed");
  diskAfter = diskSnapshot();
}

if (diskAfter === null) diskAfter = mode === "run" ? diskSnapshot() : null;
const actualDiskReclaimBytes = diskAfter === null ? null : Math.max(0, Number(diskBefore.usedBytes || 0) - Number(diskAfter.usedBytes || 0));
console.log(JSON.stringify({
  ok: blockers.length === 0,
  mode,
  mutation,
  diskBefore,
  diskAfter,
  actualDiskReclaimBytes,
  candidate,
  k8sRegistry,
  blockers,
  actions,
  policy: {
    requireK8sRegistryReady,
    dockerPruneUsed: false,
    dockerVolumeRmScoped: mode === "run" && mutation,
    dockerComposeTouched: false,
    databaseTouched: false,
    matchedExitedRegistryContainerOnly: true,
    valuesRedacted: true,
  },
  valuesRedacted: true,
}));
`;
}

function listNodeRuntimePipelineRunNames(spec: HwlabRuntimeLaneSpec): Set<string> {
  const result = runNodeK3sArgs(spec, [
    "kubectl",
    "-n",
    HWLAB_CI_NAMESPACE,
    "get",
    "pipelinerun",
    "-o",
    'jsonpath={range .items[*]}{.metadata.name}{"\\n"}{end}',
  ], 60);
  if (!isCommandSuccess(result)) throw new Error(`failed to list ${HWLAB_CI_NAMESPACE} PipelineRuns: ${result.stderr.trim().slice(0, 1000)}`);
  return new Set(result.stdout.split(/\r?\n/u).map((line) => line.trim()).filter(Boolean));
}

function listNodeRuntimeCiPodClaims(spec: HwlabRuntimeLaneSpec): NodeRuntimeCiPodClaimRow[] {
  const result = runNodeK3sArgs(spec, [
    "kubectl",
    "-n",
    HWLAB_CI_NAMESPACE,
    "get",
    "pod",
    "-o",
    'go-template={{range .items}}{{.metadata.name}}{{"\\t"}}{{.status.phase}}{{"\\t"}}{{range .spec.volumes}}{{if .persistentVolumeClaim}}{{.persistentVolumeClaim.claimName}}{{","}}{{end}}{{end}}{{"\\n"}}{{end}}',
  ], 60);
  if (!isCommandSuccess(result)) throw new Error(`failed to list ${HWLAB_CI_NAMESPACE} Pod PVC mounts: ${result.stderr.trim().slice(0, 1000)}`);
  return result.stdout.split(/\r?\n/u).map((line) => {
    const [name = "", phase = "", claimsRaw = ""] = line.trim().split("\t");
    return { name, phase, claims: claimsRaw.split(",").map((claim) => claim.trim()).filter(Boolean) };
  }).filter((item) => item.name.length > 0);
}

function listNodeRuntimeCiPvcs(spec: HwlabRuntimeLaneSpec): NodeRuntimeCiPvcRow[] {
  const result = runNodeK3sArgs(spec, [
    "kubectl",
    "-n",
    HWLAB_CI_NAMESPACE,
    "get",
    "pvc",
    "-o",
    'go-template={{range .items}}{{.metadata.name}}{{"\\t"}}{{.spec.volumeName}}{{"\\t"}}{{.status.phase}}{{"\\t"}}{{range .metadata.ownerReferences}}{{if eq .kind "PipelineRun"}}{{.kind}}{{"\\t"}}{{.name}}{{end}}{{end}}{{"\\t"}}{{.spec.resources.requests.storage}}{{"\\n"}}{{end}}',
  ], 60);
  if (!isCommandSuccess(result)) throw new Error(`failed to list ${HWLAB_CI_NAMESPACE} PVCs: ${result.stderr.trim().slice(0, 1000)}`);
  return result.stdout.split(/\r?\n/u).map((line) => {
    const [name = "", volumeName = "", phase = "", ownerKind = "", ownerName = "", storage = ""] = line.trim().split("\t");
    return { name, volumeName, phase, ownerKind, ownerName, storage };
  }).filter((item) => item.name.length > 0);
}

export function listNodeRuntimeOrphanedCiPvcs(spec: HwlabRuntimeLaneSpec, limit: number): NodeRuntimeCiPvcRow[] {
  const pipelineRuns = listNodeRuntimePipelineRunNames(spec);
  const activeClaims = new Set(listNodeRuntimeCiPodClaims(spec)
    .filter((pod) => pod.phase !== "Succeeded" && pod.phase !== "Failed")
    .flatMap((pod) => pod.claims));
  return listNodeRuntimeCiPvcs(spec)
    .filter((item) => /^pvc-[a-z0-9]+$/u.test(item.name))
    .filter((item) => item.ownerKind === "PipelineRun" && item.ownerName.length > 0)
    .filter((item) => !pipelineRuns.has(item.ownerName))
    .filter((item) => !activeClaims.has(item.name))
    .sort((a, b) => a.name.localeCompare(b.name))
    .slice(0, limit);
}

export function listNodeRuntimeReleasedCiPvs(spec: HwlabRuntimeLaneSpec, limit: number): NodeRuntimeReleasedPvRow[] {
  const result = runNodeK3sArgs(spec, [
    "kubectl",
    "get",
    "pv",
    "-o",
    'jsonpath={range .items[*]}{.metadata.name}{"\\t"}{.metadata.creationTimestamp}{"\\t"}{.status.phase}{"\\t"}{.spec.storageClassName}{"\\t"}{.spec.persistentVolumeReclaimPolicy}{"\\t"}{.spec.claimRef.namespace}{"\\t"}{.spec.claimRef.name}{"\\t"}{.spec.capacity.storage}{"\\t"}{.spec.local.path}{"\\t"}{.spec.hostPath.path}{"\\n"}{end}',
  ], 60);
  if (!isCommandSuccess(result)) throw new Error(`failed to list released ${HWLAB_CI_NAMESPACE} PVs: ${result.stderr.trim().slice(0, 1000)}`);
  return result.stdout.split(/\r?\n/u).map((line) => {
    const [name = "", createdAt = "", phase = "", storageClass = "", reclaimPolicy = "", claimNamespace = "", claimName = "", capacity = "", localPath = "", hostPath = ""] = line.trim().split("\t");
    return { name, createdAt, phase, storageClass, reclaimPolicy, claimNamespace, claimName, capacity, hostPath: safeNodeRuntimeStoragePath(localPath) ?? safeNodeRuntimeStoragePath(hostPath) };
  })
    .filter((item) => item.name.length > 0)
    .filter((item) => item.phase === "Released")
    .filter((item) => item.storageClass === "local-path" && item.reclaimPolicy === "Delete")
    .filter((item) => item.claimNamespace === HWLAB_CI_NAMESPACE && /^pvc-[a-z0-9]+$/u.test(item.claimName))
    .sort((a, b) => String(a.createdAt).localeCompare(String(b.createdAt)))
    .slice(0, limit);
}

function safeNodeRuntimeStoragePath(value: string): string | null {
  if (!value.startsWith("/var/lib/rancher/k3s/storage/")) return null;
  if (value.includes("\0") || value.split("/").some((part) => part === "..")) return null;
  return value;
}

function deleteNodeRuntimeCiPvcs(spec: HwlabRuntimeLaneSpec, names: string[], timeoutSeconds: number): CommandResult {
  if (names.length === 0) return { command: [], cwd: repoRoot, exitCode: 0, stdout: "no orphaned PVC candidates", stderr: "", signal: null, timedOut: false };
  return runNodeK3sArgs(spec, ["kubectl", "-n", HWLAB_CI_NAMESPACE, "delete", "pvc", ...names, "--ignore-not-found=true", "--wait=true", `--timeout=${timeoutSeconds}s`], timeoutSeconds);
}

function deleteNodeRuntimePersistentVolumes(spec: HwlabRuntimeLaneSpec, names: string[], timeoutSeconds: number): CommandResult {
  if (names.length === 0) return { command: [], cwd: repoRoot, exitCode: 0, stdout: "no released PV candidates", stderr: "", signal: null, timedOut: false };
  return runNodeK3sArgs(spec, ["kubectl", "delete", "pv", ...names, "--ignore-not-found=true", "--wait=true", `--timeout=${timeoutSeconds}s`], timeoutSeconds);
}
