From 532e3bccbe059e81795ee0d1d573e89b7d3ad608 Mon Sep 17 00:00:00 2001 From: Ramesh Padmanabhaiah Date: Sun, 19 Jul 2026 08:23:07 -0700 Subject: [PATCH 1/2] Make run bundle directories human-readable --- cli/bash/commands/basectl/basectl.sh | 80 ++++++++++++++++++- .../basectl/tests/runtime-dispatch.bats | 20 +++++ cli/python/base_logs/engine.py | 33 +++++--- cli/python/base_logs/tests/test_engine.py | 19 +++++ docs/base-cli.md | 4 +- docs/cache-ownership-and-layout.md | 10 ++- docs/observability.md | 4 +- lib/python/base_cli/_runtime.py | 4 +- lib/python/base_cli/history.py | 2 +- lib/python/base_cli/paths.py | 8 ++ .../tests/test_app_runtime_boundary.py | 8 +- 11 files changed, 162 insertions(+), 30 deletions(-) diff --git a/cli/bash/commands/basectl/basectl.sh b/cli/bash/commands/basectl/basectl.sh index bf1fa899..b5f361ec 100644 --- a/cli/bash/commands/basectl/basectl.sh +++ b/cli/bash/commands/basectl/basectl.sh @@ -526,12 +526,83 @@ basectl_cache_root() { fi } +basectl_runtime_slug() { + local value="${1,,}" slug="" char + local index + + for ((index = 0; index < ${#value}; index++)); do + char="${value:index:1}" + case "$char" in + [a-z0-9._-]) slug+="$char" ;; + *) + [[ -n "$slug" && "${slug: -1}" != "-" ]] && slug+="-" + ;; + esac + done + while [[ "$slug" == -* ]]; do slug="${slug#-}"; done + while [[ "$slug" == *- ]]; do slug="${slug%-}"; done + [[ -n "$slug" ]] || slug="run" + printf '%s\n' "${slug:0:40}" +} + +basectl_run_bundle_project() { + local command="$1" + local argument option_value=0 + shift + + case "$command" in + setup|check|doctor|test|build|demo|run|activate|export-context|devcontainer|devenv-report|onboard|update) + ;; + trust) + [[ "${1:-}" == status || "${1:-}" == allow || "${1:-}" == revoke ]] && shift + ;; + *) + return 0 + ;; + esac + + for argument in "$@"; do + if ((option_value)); then + option_value=0 + continue + fi + case "$argument" in + --manifest|--format|--profile|--environment|--config|--log-file|--project|--workspace|--path|--target|--version|--command|--status|--older-than|--keep-last|--since|--until|--last|--lines) + option_value=1 + ;; + --*=*|--*|-*) + ;; + *) + basectl_runtime_slug "$argument" + return 0 + ;; + esac + done +} + +basectl_run_bundle_label() { + local command_slug project_slug + command_slug="$(basectl_runtime_slug "${1:-run}")" + shift || true + project_slug="$(basectl_run_bundle_project "$command_slug" "$@")" + if [[ -n "$project_slug" ]]; then + printf '%s__%s\n' "$command_slug" "$project_slug" + else + printf '%s\n' "$command_slug" + fi +} + basectl_initialize_run_bundle() { - local cache_root run_id run_root + local command="${1:-run}" cache_root run_id run_root label + shift || true if [[ -n "${BASE_CLI_RUN_ROOT:-}" ]]; then export BASE_CLI_RUNTIME_OWNER=base - export BASE_CLI_RUN_ID="${BASE_CLI_RUN_ID:-$(basename -- "$BASE_CLI_RUN_ROOT")}" + if [[ -z "${BASE_CLI_RUN_ID:-}" ]]; then + BASE_CLI_RUN_ID="$(basename -- "$BASE_CLI_RUN_ROOT")" + BASE_CLI_RUN_ID="${BASE_CLI_RUN_ID%%__*}" + export BASE_CLI_RUN_ID + fi export BASE_CLI_PRIMARY_LOG="${BASE_CLI_PRIMARY_LOG:-$BASE_CLI_RUN_ROOT/logs/primary.log}" export BASE_CLI_HISTORY_PARENT_RUN_ID="${BASE_CLI_HISTORY_PARENT_RUN_ID:-$BASE_CLI_RUN_ID}" return 0 @@ -539,7 +610,8 @@ basectl_initialize_run_bundle() { cache_root="$(basectl_cache_root)" run_id="$(date -u +%Y%m%dT%H%M%S 2>/dev/null || true)_${BASHPID:-$$}_${RANDOM}" - run_root="$cache_root/base/runs/$run_id" + label="$(basectl_run_bundle_label "$command" "$@")" + run_root="$cache_root/base/runs/${run_id}__${label}" mkdir -p "$run_root/logs" "$run_root/tmp" || { basectl_error "Unable to create Base run bundle '$run_root'. Check BASE_CACHE_DIR permissions." return 1 @@ -691,7 +763,7 @@ basectl_main() { basectl_get_base_home || return 1 if ((run_bundle_enabled)); then - basectl_initialize_run_bundle || return 1 + basectl_initialize_run_bundle "$command" "$@" || return 1 fi export BASE_CLI_HISTORY_SCOPE=internal BASE_CLI_HISTORY_STARTED_AT="$(date -u +%Y-%m-%dT%H:%M:%SZ 2>/dev/null || true)" diff --git a/cli/bash/commands/basectl/tests/runtime-dispatch.bats b/cli/bash/commands/basectl/tests/runtime-dispatch.bats index 43d047dd..489ac38e 100644 --- a/cli/bash/commands/basectl/tests/runtime-dispatch.bats +++ b/cli/bash/commands/basectl/tests/runtime-dispatch.bats @@ -26,6 +26,26 @@ load ./basectl_helpers.bash } +@test "basectl labels run bundle directories without changing the run ID" { + run env \ + BASE_HOME="$BASE_REPO_ROOT" \ + BASE_TEST_TMPDIR="$TEST_TMPDIR" \ + bash -c ' + source "$BASE_HOME/cli/bash/commands/basectl/basectl.sh" + printf "label=%s\n" "$(basectl_run_bundle_label setup base-demo)" + BASE_CACHE_DIR="$BASE_TEST_TMPDIR/cache" basectl_initialize_run_bundle setup base-demo || exit $? + printf "run_id=%s\n" "$BASE_CLI_RUN_ID" + printf "run_root=%s\n" "$BASE_CLI_RUN_ROOT" + ' + + [ "$status" -eq 0 ] + [[ "$output" == *"label=setup__base-demo"* ]] + [[ "$output" == *"run_root="*"__setup__base-demo" ]] + run_id="$(printf '%s\n' "$output" | sed -n 's/^run_id=//p')" + [[ "$run_id" != *"__"* ]] +} + + @test "basectl prints help when no command is given in a non-interactive shell" { run_basectl diff --git a/cli/python/base_logs/engine.py b/cli/python/base_logs/engine.py index da20a79e..f5fd7091 100644 --- a/cli/python/base_logs/engine.py +++ b/cli/python/base_logs/engine.py @@ -28,7 +28,7 @@ from base_cli.redaction import redact_text_value -RUN_ID_RE = re.compile(r"^(?P\d{8}T\d{6})_[A-Za-z0-9]+$") +RUN_ID_RE = re.compile(r"^(?P\d{8}T\d{6})_[A-Za-z0-9]+(?:__.*)?$") LOG_SECRET_ASSIGNMENT_RE = re.compile( r"(?P\b[A-Za-z_][A-Za-z0-9_.-]*)=(?P[^\s,;]+)", re.IGNORECASE, @@ -449,7 +449,8 @@ def discover_log_entries(cache_root: Path) -> list[LogEntry]: for path in sorted(logs_root.glob("primary.log"), key=str): if not path.is_file(): continue - history_status = history_status_for_log(history_statuses, run_id=run_root.name, path=path) + run_id = canonical_run_id(run_root) + history_status = history_status_for_log(history_statuses, run_id=run_id, path=path) raw_command = raw_command_for_log(path) entries.append( LogEntry( @@ -457,7 +458,7 @@ def discover_log_entries(cache_root: Path) -> list[LogEntry]: if history_status is not None and history_status.command is not None else infer_display_command(raw_command, path), raw_command=raw_command, - run_id=run_root.name, + run_id=run_id, path=path, timestamp=entry_timestamp(path), status=history_status.status if history_status is not None else infer_status(path), @@ -484,16 +485,24 @@ def infer_raw_command_from_log_path(path: Path) -> str: def raw_command_for_log(path: Path) -> str: """Resolve the command identity without encoding it in the log filename.""" - metadata_path = path.parent.parent / "run.json" + metadata = run_metadata(path.parent.parent) + value = optional_string(metadata.get("raw_command")) or optional_string(metadata.get("cli")) + if value: + return value + return infer_raw_command_from_log_path(path) + + +def run_metadata(run_root: Path) -> dict[str, Any]: try: - metadata = json.loads(metadata_path.read_text(encoding="utf-8")) + payload = json.loads((run_root / "run.json").read_text(encoding="utf-8")) except (OSError, TypeError, ValueError): - metadata = {} - if isinstance(metadata, dict): - value = optional_string(metadata.get("raw_command")) or optional_string(metadata.get("cli")) - if value: - return value - return infer_raw_command_from_log_path(path) + return {} + return payload if isinstance(payload, dict) else {} + + +def canonical_run_id(run_root: Path) -> str: + value = optional_string(run_metadata(run_root).get("run_id")) + return value or run_root.name.split("__", 1)[0] def read_history_log_statuses(cache_root: Path) -> HistoryLogStatusIndex: @@ -572,7 +581,7 @@ def first_lines(path: Path, limit: int) -> list[str]: def entry_timestamp(path: Path) -> datetime: - run_id = path.parent.parent.name if path.name == "primary.log" else path.stem + run_id = canonical_run_id(path.parent.parent) if path.name == "primary.log" else path.stem.split("__", 1)[0] match = RUN_ID_RE.match(run_id) if match is not None: try: diff --git a/cli/python/base_logs/tests/test_engine.py b/cli/python/base_logs/tests/test_engine.py index 76e27021..c2a12eb0 100644 --- a/cli/python/base_logs/tests/test_engine.py +++ b/cli/python/base_logs/tests/test_engine.py @@ -104,6 +104,25 @@ def test_recent_logs_sorts_by_run_id_timestamp(self) -> None: self.assertEqual([entry.path for entry in entries], [new_path, old_path]) self.assertEqual([entry.command for entry in entries], ["clean", "projects"]) + def test_readable_bundle_name_does_not_change_canonical_run_id(self) -> None: + with tempfile.TemporaryDirectory() as tmpdir: + cache_root = Path(tmpdir) + run_id = "20260601T010000_aaaaaaaa" + run_root = cache_root / "base" / "runs" / f"{run_id}__setup__demo" + log_path = run_root / "logs" / "primary.log" + log_path.parent.mkdir(parents=True) + log_path.write_text("INFO setup\n", encoding="utf-8") + (run_root / "run.json").write_text( + json.dumps({"run_id": run_id, "cli": "base_setup"}) + "\n", + encoding="utf-8", + ) + + entries = engine.recent_logs(cache_root) + + self.assertEqual(len(entries), 1) + self.assertEqual(entries[0].run_id, run_id) + self.assertEqual(entries[0].timestamp.strftime("%Y%m%dT%H%M%S"), "20260601T010000") + def test_base_setup_command_is_inferred_from_action(self) -> None: with tempfile.TemporaryDirectory() as tmpdir: cache_root = Path(tmpdir) diff --git a/docs/base-cli.md b/docs/base-cli.md index 88715211..937485cf 100644 --- a/docs/base-cli.md +++ b/docs/base-cli.md @@ -169,10 +169,10 @@ root. / base/ history/runs.jsonl - runs//{run.json,logs/,tmp/} + runs/____/{run.json,logs/,tmp/} cache/components// projects/// - runs//{run.json,logs/,tmp/} + runs/____/{run.json,logs/,tmp/} cache/components// / ``` diff --git a/docs/cache-ownership-and-layout.md b/docs/cache-ownership-and-layout.md index f2f96132..b86db3e5 100644 --- a/docs/cache-ownership-and-layout.md +++ b/docs/cache-ownership-and-layout.md @@ -55,7 +55,7 @@ With the cache root set to `~/Library/Caches/base`, the clean layout is: │ │ ├── base_setup/ │ │ └── base_github_projects/ │ └── runs/ -│ └── / + │ └── ____/ │ ├── run.json │ ├── logs/ │ │ └── primary.log @@ -68,7 +68,7 @@ With the cache root set to `~/Library/Caches/base`, the clean layout is: │ ├── cache/ │ │ └── components/ │ └── runs/ - │ └── / + │ └── ____/ │ ├── run.json │ ├── logs/ │ │ └── primary.log @@ -105,7 +105,11 @@ by individual Base components. This state is not tied to one diagnostic run. ### `base/runs/` -One directory per Base control-plane invocation. `run.json` is written at the +One directory per Base control-plane invocation. The directory name starts +with the canonical run ID and adds sanitized command/project labels for manual +inspection, for example `20260719T052536_37996_8494__setup__base`. The labels +are filesystem display context only; `run.json`, history, and CLI output retain +the canonical run ID without the suffix. `run.json` is written at the start with `status: running` and finalized with timestamps, exit status, project metadata, command arguments, and parent/child references when known. Run metadata and the primary log are private (`0600`). `primary.log` is the diff --git a/docs/observability.md b/docs/observability.md index 42cccbbe..a4bb1a5c 100644 --- a/docs/observability.md +++ b/docs/observability.md @@ -98,8 +98,8 @@ A history record should include: "exit_code": 0, "status": "ok", "owner": "base", - "bundle_path": "~/Library/Caches/base/base/runs/20260610T101500_ab12cd", - "log_path": "~/Library/Caches/base/base/runs/20260610T101500_ab12cd/logs/primary.log", + "bundle_path": "~/Library/Caches/base/base/runs/20260610T101500_ab12cd__setup__base", + "log_path": "~/Library/Caches/base/base/runs/20260610T101500_ab12cd__setup__base/logs/primary.log", "base_version": "0.4.0", "os": "macos", "shell": "bash", diff --git a/lib/python/base_cli/_runtime.py b/lib/python/base_cli/_runtime.py index 7b0c6570..a8cf838d 100644 --- a/lib/python/base_cli/_runtime.py +++ b/lib/python/base_cli/_runtime.py @@ -4,7 +4,7 @@ from dataclasses import dataclass from pathlib import Path -from .paths import runtime_owner_root +from .paths import runtime_owner_root, runtime_run_directory_name @dataclass(frozen=True) @@ -29,7 +29,7 @@ def runtime_layout( inherited_run_root: Path | None = None, ) -> RuntimeLayout: owner_root = runtime_owner_root(cache_root, owner, project_name, project_root) - run_root = inherited_run_root or owner_root / "runs" / run_id + run_root = inherited_run_root or owner_root / "runs" / runtime_run_directory_name(run_id, cli_name, project_name) state_dir = owner_root # Every public invocation owns one run bundle and one diagnostic log. # Child processes inherit that bundle instead of creating component logs. diff --git a/lib/python/base_cli/history.py b/lib/python/base_cli/history.py index 7b22ec35..1e9e1847 100644 --- a/lib/python/base_cli/history.py +++ b/lib/python/base_cli/history.py @@ -43,7 +43,7 @@ def write_finished_record( try: record = build_finished_record(context, argv, sensitive_options, started_at, exit_code) write_history_record(record) - if context.run_root is not None and context.run_root.name == context.run_id: + if context.run_root is not None: update_run_metadata(context.run_root, record) except Exception as exc: # pylint: disable=broad-exception-caught context.log.debug("Unable to write command history record: %s", exc) diff --git a/lib/python/base_cli/paths.py b/lib/python/base_cli/paths.py index 1fbd1c47..c36ca7dd 100644 --- a/lib/python/base_cli/paths.py +++ b/lib/python/base_cli/paths.py @@ -85,6 +85,14 @@ def runtime_slug(value: str, fallback: str = "unnamed") -> str: return normalized or fallback +def runtime_run_directory_name(run_id: str, cli_name: str, project_name: str | None = None) -> str: + """Return a readable bundle directory name without changing the canonical run ID.""" + labels = [runtime_slug(cli_name, fallback="run")] + if project_name: + labels.append(runtime_slug(project_name)) + return f"{run_id}__{'__'.join(labels)}" + + def checkout_id(project_root: Path | None) -> str | None: if project_root is None: return None diff --git a/lib/python/base_cli/tests/test_app_runtime_boundary.py b/lib/python/base_cli/tests/test_app_runtime_boundary.py index 34253df5..a78d6bc8 100644 --- a/lib/python/base_cli/tests/test_app_runtime_boundary.py +++ b/lib/python/base_cli/tests/test_app_runtime_boundary.py @@ -14,11 +14,11 @@ def test_runtime_layout_names_base_cli_directories() -> None: layout = runtime.runtime_layout(Path("/tmp/base-cache"), "demo", "run-123") assert layout.owner_root == Path("/tmp/base-cache/base") - assert layout.run_root == Path("/tmp/base-cache/base/runs/run-123") + assert layout.run_root == Path("/tmp/base-cache/base/runs/run-123__demo") assert layout.state_dir == Path("/tmp/base-cache/base") - assert layout.log_dir == Path("/tmp/base-cache/base/runs/run-123/logs") + assert layout.log_dir == Path("/tmp/base-cache/base/runs/run-123__demo/logs") assert layout.cache_dir == Path("/tmp/base-cache/base/cache/components/demo") - assert layout.temp_dir == Path("/tmp/base-cache/base/runs/run-123/tmp/demo/run-123") + assert layout.temp_dir == Path("/tmp/base-cache/base/runs/run-123__demo/tmp/demo/run-123") def test_runtime_helpers_stay_out_of_public_api() -> None: @@ -39,7 +39,7 @@ def test_runtime_layout_is_checkout_scoped_for_project_owner() -> None: assert layout.owner_root.parent.parent == Path("/tmp/base-cache/projects") assert layout.owner_root.parent.name == "banyanlabs" - assert layout.run_root == layout.owner_root / "runs" / "run-123" + assert layout.run_root == layout.owner_root / "runs" / "run-123__native-cli__banyanlabs" assert layout.log_dir == layout.run_root / "logs" From a97b23bc1eae77f0768cb602f13a5a4e607a692c Mon Sep 17 00:00:00 2001 From: Ramesh Padmanabhaiah Date: Sun, 19 Jul 2026 14:07:12 -0700 Subject: [PATCH 2/2] Make temporary run retention explicit (#1687) ## Summary - remove the complete run tmp tree by default, including empty component parents - expose basectl --keep-temp for explicit retention - preserve the full temporary tree when requested and document the contract ## Validation - full Python suite: 1019 passed, 1 skipped - full source-checkout Bats suite: 802 passed - touched-file Pylint - isolated CLI smoke tests for default cleanup, explicit retention, and readable bundle paths ## Issue Closes #1685 ## Dependency This PR is based on #1686 and should merge after it. --- cli/bash/commands/basectl/basectl.sh | 25 +++++++-- cli/bash/commands/basectl/tests/help.bats | 1 + .../basectl/tests/runtime-dispatch.bats | 54 +++++++++++++++++++ docs/base-cli.md | 9 ++-- docs/cache-ownership-and-layout.md | 8 +-- docs/command-reference.md | 5 +- lib/python/base_cli/README.md | 5 +- lib/python/base_cli/context.py | 5 ++ lib/python/base_cli/tests/test_base_cli.py | 3 ++ 9 files changed, 101 insertions(+), 14 deletions(-) diff --git a/cli/bash/commands/basectl/basectl.sh b/cli/bash/commands/basectl/basectl.sh index b5f361ec..51b97b1f 100644 --- a/cli/bash/commands/basectl/basectl.sh +++ b/cli/bash/commands/basectl/basectl.sh @@ -121,6 +121,7 @@ Wrapper options: --debug-wrapper Enable DEBUG logging before the Base runtime is loaded. --verbose-wrapper Enable verbose runtime argument handling before dispatch. --utc-wrapper Print wrapper/runtime log timestamps in UTC. + --keep-temp Preserve temporary run files after the command completes. --color Preserve color-aware wrapper argument handling. Notes: @@ -131,12 +132,14 @@ Notes: Base rejects `--option=value` syntax before command delegation. Arguments after `--` belong to the delegated project command. - Use `-v` for command-level debug logs. Python package standard options such - as `--debug`, `--quiet`, `--log-file`, `--config`, `--environment`, and - `--keep-temp` are not public `basectl` options. + as `--debug`, `--quiet`, `--log-file`, `--config`, and `--environment` are + not public `basectl` options. - Invoking `basectl` with no command starts a Base runtime shell for the nearest project manifest above the current directory, preserving that directory. If no manifest is found, it falls back to project `base`. In non-interactive shells it prints this help text. + - Use `--keep-temp` before the command name to preserve the complete run + temporary tree; temporary files are removed by default. - Use `--debug-wrapper` when debugging startup before command dispatch or Base runtime initialization. EOF @@ -643,6 +646,9 @@ basectl_finalize_run_bundle() { "${BASE_CLI_HISTORY_STARTED_AT:-}" \ "$(date -u +%Y-%m-%dT%H:%M:%SZ 2>/dev/null || true)" >"$tmp_file" && mv -f "$tmp_file" "$run_root/run.json" chmod 600 "$run_root/run.json" "${BASE_CLI_PRIMARY_LOG:-$run_root/logs/primary.log}" || return 1 + if [[ "${BASE_CLI_KEEP_TEMP:-}" != true ]]; then + rm -rf -- "$run_root/tmp" + fi } basectl_history_record() { @@ -682,7 +688,12 @@ basectl_history_record() { basectl_main() { - local base_debug=0 command="" command_status run_bundle_enabled=1 + local base_debug=0 keep_temp=0 command="" command_status run_bundle_enabled=1 + + while [[ "${1:-}" == --keep-temp ]]; do + keep_temp=1 + shift + done local history_args=() history_scope="${BASE_CLI_HISTORY_SCOPE:-primary}" local opt @@ -752,6 +763,11 @@ basectl_main() { done shift $((OPTIND - 1)) + while [[ "${1:-}" == --keep-temp ]]; do + keep_temp=1 + shift + done + command="${1:-}" [[ -n "$command" ]] && shift history_args=("$@") @@ -762,6 +778,9 @@ basectl_main() { basectl_history_recordable_command "$command" || run_bundle_enabled=0 basectl_get_base_home || return 1 + if ((keep_temp)); then + export BASE_CLI_KEEP_TEMP=true + fi if ((run_bundle_enabled)); then basectl_initialize_run_bundle "$command" "$@" || return 1 fi diff --git a/cli/bash/commands/basectl/tests/help.bats b/cli/bash/commands/basectl/tests/help.bats index 62aacfb6..6bfd4e70 100644 --- a/cli/bash/commands/basectl/tests/help.bats +++ b/cli/bash/commands/basectl/tests/help.bats @@ -41,6 +41,7 @@ load ./basectl_helpers.bash [[ "$output" == *"--debug-wrapper"* ]] [[ "$output" == *"--verbose-wrapper"* ]] [[ "$output" == *"--utc-wrapper"* ]] + [[ "$output" == *"--keep-temp"* ]] [[ "$output" == *"--color"* ]] } diff --git a/cli/bash/commands/basectl/tests/runtime-dispatch.bats b/cli/bash/commands/basectl/tests/runtime-dispatch.bats index 489ac38e..9c526962 100644 --- a/cli/bash/commands/basectl/tests/runtime-dispatch.bats +++ b/cli/bash/commands/basectl/tests/runtime-dispatch.bats @@ -46,6 +46,60 @@ load ./basectl_helpers.bash } +@test "basectl removes the complete temp tree by default" { + run env \ + BASE_HOME="$BASE_REPO_ROOT" \ + BASE_TEST_TMPDIR="$TEST_TMPDIR" \ + bash -c ' + source "$BASE_HOME/cli/bash/commands/basectl/basectl.sh" + BASE_CACHE_DIR="$BASE_TEST_TMPDIR/cache" basectl_initialize_run_bundle setup base-demo || exit $? + mkdir -p "$BASE_CLI_RUN_ROOT/tmp/base_setup" + printf "temporary\n" >"$BASE_CLI_RUN_ROOT/tmp/base_setup/file.txt" + run_root="$BASE_CLI_RUN_ROOT" + basectl_finalize_run_bundle 0 || exit $? + printf "run_root=%s\n" "$run_root" + [[ ! -e "$run_root/tmp" ]] + ' + + [ "$status" -eq 0 ] +} + + +@test "basectl preserves the complete temp tree when explicitly requested" { + run env \ + BASE_HOME="$BASE_REPO_ROOT" \ + BASE_TEST_TMPDIR="$TEST_TMPDIR" \ + BASE_CLI_KEEP_TEMP=true \ + bash -c ' + source "$BASE_HOME/cli/bash/commands/basectl/basectl.sh" + BASE_CACHE_DIR="$BASE_TEST_TMPDIR/cache" basectl_initialize_run_bundle setup base-demo || exit $? + mkdir -p "$BASE_CLI_RUN_ROOT/tmp/base_setup" + printf "temporary\n" >"$BASE_CLI_RUN_ROOT/tmp/base_setup/file.txt" + run_root="$BASE_CLI_RUN_ROOT" + basectl_finalize_run_bundle 0 || exit $? + [[ -f "$run_root/tmp/base_setup/file.txt" ]] + ' + + [ "$status" -eq 0 ] +} + + +@test "basectl exposes keep-temp as a wrapper option" { + run env \ + BASE_HOME="$BASE_REPO_ROOT" \ + bash -c ' + source "$BASE_HOME/cli/bash/commands/basectl/basectl.sh" + log_debug() { :; } + basectl_get_base_home() { return 0; } + basectl_do_version() { printf "keep=%s\n" "${BASE_CLI_KEEP_TEMP:-}"; } + basectl_main --keep-temp version + ' + + [ "$status" -eq 0 ] + [ "$output" = "keep=true" ] +} + + @test "basectl prints help when no command is given in a non-interactive shell" { run_basectl diff --git a/docs/base-cli.md b/docs/base-cli.md index 937485cf..c7a884d3 100644 --- a/docs/base-cli.md +++ b/docs/base-cli.md @@ -191,7 +191,7 @@ Directory lifecycle: | Directory | Created | Removed | |---|---|---| -| `run.json`, `logs/`, `tmp/` | before command execution | run-bundle cleanup | +| `run.json`, `logs/`, `tmp/` | before command execution | run-bundle cleanup; `tmp/` is removed unless `--keep-temp` is requested | | `cache/components/` | when a component needs it | explicit CLI cleanup | Commands running with `ctx.dry_run` skip default `logs/`, `cache/`, and @@ -352,8 +352,9 @@ arguments. These are direct Python package options. Public `basectl` launchers expose `-v` for command-level debug logs and command-specific flags from `basectl --help`; they do not expose `--debug`, `--quiet`, -`--log-file`, `--config`, `--environment`, or `--keep-temp` as public -`basectl` options. +`--log-file`, `--config`, or `--environment` as public `basectl` options. +The wrapper-level `basectl --keep-temp ` option explicitly preserves +the complete temporary tree for that run. ## Interrupt And Cleanup @@ -362,7 +363,7 @@ register them on import. Cleanup should: 1. call user cleanup hooks 2. flush and close log handlers -3. remove `ctx.temp_dir` unless `keep_temp` is true +3. remove `ctx.temp_dir` and empty temporary parents unless `keep_temp` is true CLI authors can register hooks: diff --git a/docs/cache-ownership-and-layout.md b/docs/cache-ownership-and-layout.md index b86db3e5..6726201e 100644 --- a/docs/cache-ownership-and-layout.md +++ b/docs/cache-ownership-and-layout.md @@ -140,9 +140,11 @@ project run through the history index and `run.json` metadata. ### `tmp/` -Temporary files scoped to one run and component. Successful runs remove these -directories automatically unless retention is requested. Failed or interrupted -runs retain them for diagnosis until cleanup removes the completed bundle. +Temporary files scoped to one run and component. Base removes the complete +`tmp/` tree, including empty component parents, after every run by default. +Pass the explicit `--keep-temp` wrapper option to preserve the complete tree +for diagnosis. Run metadata, history, and `primary.log` are retained +independently of this choice. ## Timestamps and permissions diff --git a/docs/command-reference.md b/docs/command-reference.md index df75ea36..54312ced 100644 --- a/docs/command-reference.md +++ b/docs/command-reference.md @@ -11,8 +11,9 @@ syntax. `basectl` exposes `-v` as the public command-level debug switch. Direct `base_cli` package standard options such as `--debug`, `--quiet`, `--log-file`, -`--config`, `--environment`, and `--keep-temp` are private to Python package -execution and are rejected by `basectl`. +`--config` and `--environment` are private to Python package execution and are +rejected by `basectl`. Use `basectl --keep-temp ` when temporary run +files must be preserved for diagnosis; they are removed by default. ## Stability Tiers diff --git a/lib/python/base_cli/README.md b/lib/python/base_cli/README.md index f242edc9..fc347ec2 100644 --- a/lib/python/base_cli/README.md +++ b/lib/python/base_cli/README.md @@ -75,8 +75,9 @@ equals-form values such as `--name=Ada` before Click parses arguments. These are direct package options. Public `basectl` launchers expose `-v` for command-level debug logs and command-specific flags from `basectl --help`; they do not expose `--debug`, `--quiet`, -`--log-file`, `--config`, `--environment`, or `--keep-temp` as public -`basectl` options. +`--log-file`, `--config`, or `--environment` as public `basectl` options. +The wrapper-level `basectl --keep-temp ` option preserves the +complete temporary tree for that run. ## Command Registration diff --git a/lib/python/base_cli/context.py b/lib/python/base_cli/context.py index 314d8f45..0b564588 100644 --- a/lib/python/base_cli/context.py +++ b/lib/python/base_cli/context.py @@ -67,6 +67,11 @@ def cleanup(self) -> None: if not self.keep_temp and self.temp_dir.exists(): try: shutil.rmtree(self.temp_dir) + for parent in (self.temp_dir.parent, self.temp_dir.parent.parent): + try: + parent.rmdir() + except OSError: + break except OSError as exc: self.log.warning("Temp directory cleanup failed for '%s': %s", self.temp_dir, exc) for handler in list(self.log.handlers): diff --git a/lib/python/base_cli/tests/test_base_cli.py b/lib/python/base_cli/tests/test_base_cli.py index dede6c83..65986dab 100644 --- a/lib/python/base_cli/tests/test_base_cli.py +++ b/lib/python/base_cli/tests/test_base_cli.py @@ -598,6 +598,8 @@ def main(ctx: base_cli.Context, name: str) -> None: self.assertEqual(result.exit_code, 0, result.output) self.assertEqual(seen["name"], "Ada") self.assertFalse(seen["temp_dir"].exists()) + self.assertFalse(seen["temp_dir"].parent.exists()) + self.assertFalse(seen["temp_dir"].parent.parent.exists()) self.assertTrue(seen["cache_dir"].is_dir()) log_dir = home / ".cache" / "base" / "base" / "runs" / next( path.name for path in (home / ".cache" / "base" / "base" / "runs").iterdir() @@ -945,6 +947,7 @@ def main(ctx: base_cli.Context, token: str) -> None: self.assertEqual(seen["token"], "super-secret") self.assertTrue(seen["debug"]) self.assertTrue(seen["temp_dir"].exists()) + self.assertTrue(seen["temp_dir"].parent.exists()) self.assertEqual(seen["log_file"], log_file) self.assertTrue( seen["temp_dir"].is_relative_to(home / ".cache" / "base" / "base" / "runs")