From 7cf03d231055643425499c091c20827db718b211 Mon Sep 17 00:00:00 2001 From: Ramesh Padmanabhaiah Date: Fri, 17 Jul 2026 19:32:49 -0700 Subject: [PATCH 1/2] Make command history user-facing while retaining internal trace --- README.md | 10 +- cli/bash/commands/basectl/basectl.sh | 114 +++++++++++++----- .../commands/basectl/subcommands/activate.sh | 1 + .../commands/basectl/subcommands/build.sh | 2 + cli/bash/commands/basectl/subcommands/demo.sh | 1 + .../basectl/subcommands/export_context.sh | 1 + .../commands/basectl/subcommands/history.sh | 5 + .../subcommands/project_command_helpers.sh | 6 + cli/bash/commands/basectl/subcommands/run.sh | 1 + .../basectl/subcommands/setup_common.sh | 3 + cli/bash/commands/basectl/subcommands/test.sh | 1 + .../commands/basectl/subcommands/trust.sh | 9 +- .../commands/basectl/tests/completions.bats | 2 +- cli/bash/commands/basectl/tests/history.bats | 5 +- cli/python/base_history/engine.py | 10 ++ cli/python/base_history/record.py | 86 +++++++++++++ cli/python/base_history/tests/test_engine.py | 22 +++- docs/base-cli.md | 2 + docs/command-reference.md | 2 +- docs/observability.md | 18 ++- lib/python/base_cli/README.md | 2 + lib/python/base_cli/app.py | 2 + lib/python/base_cli/context.py | 2 + lib/python/base_cli/history.py | 43 +++++++ lib/python/base_cli/tests/test_history.py | 54 +++++++++ lib/shell/completions/basectl_completion.sh | 2 +- lib/shell/completions/basectl_completion.zsh | 1 + lib/shell/completions/tests/completions.bats | 1 + 28 files changed, 362 insertions(+), 46 deletions(-) create mode 100644 cli/python/base_history/record.py diff --git a/README.md b/README.md index 00198f2d..21f8d438 100644 --- a/README.md +++ b/README.md @@ -1113,13 +1113,15 @@ basectl history --command check --status error basectl history --format json basectl history --report basectl history --report --format json +basectl history --include-internal ``` `basectl history` reads the local history index at -`/history/runs.jsonl`. History records are best-effort -metadata for Python-backed Base commands with persistent logs; they point to raw -logs instead of replacing them, and malformed history lines are ignored while -listing recent runs. `--report` prints a privacy-conscious local activity +`/history/runs.jsonl`. The default view shows one primary row +per public `basectl` command; delegated Python and resolver steps remain linked +as internal records and can be shown with `--include-internal`. History records +point to raw logs instead of replacing them, and malformed history lines are +ignored while listing recent runs. `--report` prints a privacy-conscious local activity summary with recent commands, failure counts, common failing command families, and log file locations. Reports do not include raw log contents, compact home paths to `~`, and redact secret-looking arguments and URL credentials. The diff --git a/cli/bash/commands/basectl/basectl.sh b/cli/bash/commands/basectl/basectl.sh index 624a4fd5..ded68144 100644 --- a/cli/bash/commands/basectl/basectl.sh +++ b/cli/bash/commands/basectl/basectl.sh @@ -495,9 +495,50 @@ basectl_default_activate_project() { printf '%s\n' base } +basectl_history_recordable_command() { + case "$1" in + ""|help|history|logs|version) + return 1 + ;; + *) + return 0 + ;; + esac +} + +basectl_history_record() { + local command="$1" + local exit_code="$2" + local wrapper="$BASE_HOME/bin/base-wrapper" + local args=() + shift 2 + + basectl_history_recordable_command "$command" || return 0 + [[ -x "$wrapper" ]] || return 0 + + args+=(--command "$command") + args+=(--run-id "${BASE_CLI_HISTORY_PARENT_RUN_ID:-}") + args+=(--exit-code "$exit_code") + if [[ -n "${BASE_CLI_HISTORY_STARTED_AT:-}" ]]; then + args+=(--started-at "$BASE_CLI_HISTORY_STARTED_AT") + fi + if [[ -n "${BASE_CLI_HISTORY_PROJECT:-}" ]]; then + args+=(--project "$BASE_CLI_HISTORY_PROJECT") + fi + if [[ -n "${BASE_CLI_HISTORY_PROJECT_ROOT:-}" ]]; then + args+=(--project-root "$BASE_CLI_HISTORY_PROJECT_ROOT") + fi + if [[ -n "${BASE_CLI_HISTORY_MANIFEST:-}" ]]; then + args+=(--manifest "$BASE_CLI_HISTORY_MANIFEST") + fi + + "$wrapper" --project base base_history.record "${args[@]}" -- basectl "$command" "$@" >/dev/null 2>&1 || true +} + basectl_main() { - local base_debug=0 command="" + local base_debug=0 command="" command_status + local history_args=() local opt if [[ "${1:-}" == "help" ]]; then @@ -568,49 +609,58 @@ basectl_main() { command="${1:-}" [[ -n "$command" ]] && shift + history_args=("$@") basectl_reject_equals_option_values "$@" || return $? basectl_reject_private_standard_options "$@" || return $? basectl_get_base_home || return 1 + if [[ -z "${BASE_CLI_HISTORY_PARENT_RUN_ID:-}" ]]; then + export BASE_CLI_HISTORY_PARENT_RUN_ID="basectl-${BASHPID:-$$}-${RANDOM}" + fi + export BASE_CLI_HISTORY_SCOPE=internal + BASE_CLI_HISTORY_STARTED_AT="$(date -u +%Y-%m-%dT%H:%M:%SZ 2>/dev/null || true)" + export BASE_CLI_HISTORY_STARTED_AT ((base_debug)) && basectl_enable_debug_logging log_debug "Running basectl command '${command:-}' with args: $*" case "$command" in - activate) basectl_do_activate "$@" ;; - check) basectl_do_check "$@" ;; - test) basectl_do_test "$@" ;; - export-context) basectl_do_export_context "$@" ;; - devcontainer) basectl_do_devcontainer "$@" ;; - devenv-report) basectl_do_devenv_report "$@" ;; - build) basectl_do_build "$@" ;; - demo) basectl_do_demo "$@" ;; - run) basectl_do_run "$@" ;; - repo) basectl_do_repo "$@" ;; - ci) basectl_do_ci "$@" ;; - release) basectl_do_release "$@" ;; - prompt) basectl_do_prompt "$@" ;; - docs) basectl_do_docs "$@" ;; - clean) basectl_do_clean "$@" ;; - logs) basectl_do_logs "$@" ;; - history) basectl_do_history "$@" ;; - config) basectl_do_config "$@" ;; - trust) basectl_do_trust "$@" ;; - doctor) basectl_do_doctor "$@" ;; - gh) basectl_do_gh "$@" ;; - onboard) basectl_do_onboard "$@" ;; - setup) basectl_do_setup "$@" ;; - help) basectl_show_help ;; - projects) basectl_do_projects "$@" ;; - workspace) basectl_do_workspace "$@" ;; - update) basectl_do_update "$@" ;; - update-profile) basectl_do_update_profile "$@" ;; - version) basectl_do_version "$@" ;; + activate) basectl_do_activate "$@"; command_status=$? ;; + check) basectl_do_check "$@"; command_status=$? ;; + test) basectl_do_test "$@"; command_status=$? ;; + export-context) basectl_do_export_context "$@"; command_status=$? ;; + devcontainer) basectl_do_devcontainer "$@"; command_status=$? ;; + devenv-report) basectl_do_devenv_report "$@"; command_status=$? ;; + build) basectl_do_build "$@"; command_status=$? ;; + demo) basectl_do_demo "$@"; command_status=$? ;; + run) basectl_do_run "$@"; command_status=$? ;; + repo) basectl_do_repo "$@"; command_status=$? ;; + ci) basectl_do_ci "$@"; command_status=$? ;; + release) basectl_do_release "$@"; command_status=$? ;; + prompt) basectl_do_prompt "$@"; command_status=$? ;; + docs) basectl_do_docs "$@"; command_status=$? ;; + clean) basectl_do_clean "$@"; command_status=$? ;; + logs) basectl_do_logs "$@"; command_status=$? ;; + history) basectl_do_history "$@"; command_status=$? ;; + config) basectl_do_config "$@"; command_status=$? ;; + trust) basectl_do_trust "$@"; command_status=$? ;; + doctor) basectl_do_doctor "$@"; command_status=$? ;; + gh) basectl_do_gh "$@"; command_status=$? ;; + onboard) basectl_do_onboard "$@"; command_status=$? ;; + setup) basectl_do_setup "$@"; command_status=$? ;; + help) basectl_show_help; command_status=$? ;; + projects) basectl_do_projects "$@"; command_status=$? ;; + workspace) basectl_do_workspace "$@"; command_status=$? ;; + update) basectl_do_update "$@"; command_status=$? ;; + update-profile) basectl_do_update_profile "$@"; command_status=$? ;; + version) basectl_do_version "$@"; command_status=$? ;; "") if basectl_should_start_shell; then BASE_ACTIVATE_PRESERVE_CWD=1 basectl_do_activate "$(basectl_default_activate_project)" + command_status=$? else basectl_show_help + command_status=$? fi ;; *) @@ -619,8 +669,12 @@ basectl_main() { else basectl_usage_error "Unrecognized command: $command" fi + command_status=$? ;; esac + + basectl_history_record "$command" "$command_status" "${history_args[@]}" + return "$command_status" } main() { diff --git a/cli/bash/commands/basectl/subcommands/activate.sh b/cli/bash/commands/basectl/subcommands/activate.sh index 7f39d76e..2ca92321 100644 --- a/cli/bash/commands/basectl/subcommands/activate.sh +++ b/cli/bash/commands/basectl/subcommands/activate.sh @@ -117,6 +117,7 @@ base_activate_subcommand_main() { route_venv_dir="${BASE_COMMAND_PROTOCOL_FIELDS[project_venv_dir]}" uses_uv_manager="${BASE_COMMAND_PROTOCOL_FIELDS[uses_uv_manager]}" trust_required="${BASE_COMMAND_PROTOCOL_FIELDS[manifest_command_trust_required]}" + base_project_set_history_context "$resolved_name" "$project_root" "$manifest_path" [[ -n "$resolved_name" && -n "$project_root" && -n "$manifest_path" ]] || { fatal_error "Unable to resolve project '$project'." diff --git a/cli/bash/commands/basectl/subcommands/build.sh b/cli/bash/commands/basectl/subcommands/build.sh index 16ecf7cc..b391f3f6 100644 --- a/cli/bash/commands/basectl/subcommands/build.sh +++ b/cli/bash/commands/basectl/subcommands/build.sh @@ -74,6 +74,8 @@ base_build_run_target_record() { local command_runner="${BASE_COMMAND_PROTOCOL_FIELDS[runner]}" local command_to_run display_command + base_project_set_history_context "$resolved_name" "$project_root" "$manifest_path" + command_to_run="$(base_command_with_runner "$command_runner" "$build_command" "${extra_args[@]}")" || return $? display_command="$(base_display_command_with_runner "$command_runner" "$build_command" "${extra_args[@]}")" || return $? diff --git a/cli/bash/commands/basectl/subcommands/demo.sh b/cli/bash/commands/basectl/subcommands/demo.sh index 5b26be72..5b397c01 100644 --- a/cli/bash/commands/basectl/subcommands/demo.sh +++ b/cli/bash/commands/basectl/subcommands/demo.sh @@ -120,6 +120,7 @@ base_demo_subcommand_main() { route_venv_dir="${BASE_COMMAND_PROTOCOL_FIELDS[project_venv_dir]}" uses_uv_manager="${BASE_COMMAND_PROTOCOL_FIELDS[uses_uv_manager]}" trust_required="${BASE_COMMAND_PROTOCOL_FIELDS[manifest_command_trust_required]}" + base_project_set_history_context "$resolved_name" "$project_root" "$manifest_path" demo_script="${BASE_COMMAND_PROTOCOL_FIELDS[demo_script]}" command_runner="${BASE_COMMAND_PROTOCOL_FIELDS[runner]}" diff --git a/cli/bash/commands/basectl/subcommands/export_context.sh b/cli/bash/commands/basectl/subcommands/export_context.sh index 987b68b2..f5254e1b 100644 --- a/cli/bash/commands/basectl/subcommands/export_context.sh +++ b/cli/bash/commands/basectl/subcommands/export_context.sh @@ -126,6 +126,7 @@ base_export_context_subcommand_main() { resolved_name="${BASE_COMMAND_PROTOCOL_FIELDS[project_name]}" project_root="${BASE_COMMAND_PROTOCOL_FIELDS[project_root]}" manifest_path="${BASE_COMMAND_PROTOCOL_FIELDS[manifest_path]}" + base_project_set_history_context "$resolved_name" "$project_root" "$manifest_path" [[ -n "$resolved_name" && -n "$project_root" && -n "$manifest_path" ]] || { fatal_error "Unable to resolve project for export-context." diff --git a/cli/bash/commands/basectl/subcommands/history.sh b/cli/bash/commands/basectl/subcommands/history.sh index faa785b5..639b53fe 100644 --- a/cli/bash/commands/basectl/subcommands/history.sh +++ b/cli/bash/commands/basectl/subcommands/history.sh @@ -17,6 +17,7 @@ Options: --format Output format. Defaults to text, or Markdown with --report. --report Print a privacy-conscious Markdown or JSON activity report. + --include-internal Include delegated internal steps in the output. -v Enable DEBUG logging for this subcommand. -h, --help Show this help text. @@ -42,6 +43,10 @@ base_history_subcommand_main() { args+=("$1") shift ;; + --include-internal) + args+=("$1") + shift + ;; --project|--command|--status|--limit|--format) [[ -n "${2:-}" ]] || { base_history_subcommand_usage >&2 diff --git a/cli/bash/commands/basectl/subcommands/project_command_helpers.sh b/cli/bash/commands/basectl/subcommands/project_command_helpers.sh index ce471bcd..c17d95e1 100644 --- a/cli/bash/commands/basectl/subcommands/project_command_helpers.sh +++ b/cli/bash/commands/basectl/subcommands/project_command_helpers.sh @@ -27,6 +27,12 @@ base_project_venv_dir() { printf '%s\n' "$HOME/.base.d/$project/.venv" } +base_project_set_history_context() { + export BASE_CLI_HISTORY_PROJECT="$1" + export BASE_CLI_HISTORY_PROJECT_ROOT="$2" + export BASE_CLI_HISTORY_MANIFEST="$3" +} + base_project_venv_uses_project_local_default() { local project="$1" local project_root="${2:-}" diff --git a/cli/bash/commands/basectl/subcommands/run.sh b/cli/bash/commands/basectl/subcommands/run.sh index 3b8d66cb..ada49e09 100644 --- a/cli/bash/commands/basectl/subcommands/run.sh +++ b/cli/bash/commands/basectl/subcommands/run.sh @@ -218,6 +218,7 @@ base_run_subcommand_main() { route_venv_dir="${BASE_COMMAND_PROTOCOL_FIELDS[project_venv_dir]}" uses_uv_manager="${BASE_COMMAND_PROTOCOL_FIELDS[uses_uv_manager]}" trust_required="${BASE_COMMAND_PROTOCOL_FIELDS[manifest_command_trust_required]}" + base_project_set_history_context "$resolved_name" "$project_root" "$manifest_path" run_command="${BASE_COMMAND_PROTOCOL_FIELDS[command]}" command_runner="${BASE_COMMAND_PROTOCOL_FIELDS[runner]}" diff --git a/cli/bash/commands/basectl/subcommands/setup_common.sh b/cli/bash/commands/basectl/subcommands/setup_common.sh index 7d3c276d..89d617d4 100644 --- a/cli/bash/commands/basectl/subcommands/setup_common.sh +++ b/cli/bash/commands/basectl/subcommands/setup_common.sh @@ -1041,6 +1041,9 @@ setup_run_project_artifact_layer() { project="${BASE_COMMAND_PROTOCOL_FIELDS[project_name]}" resolved_root="${BASE_COMMAND_PROTOCOL_FIELDS[project_root]}" manifest_path="${BASE_COMMAND_PROTOCOL_FIELDS[manifest_path]}" + export BASE_CLI_HISTORY_PROJECT="$project" + export BASE_CLI_HISTORY_PROJECT_ROOT="$resolved_root" + export BASE_CLI_HISTORY_MANIFEST="$manifest_path" project_venv_dir="${BASE_COMMAND_PROTOCOL_FIELDS[project_venv_dir]}" project_uses_uv_manager="${BASE_COMMAND_PROTOCOL_FIELDS[uses_uv_manager]}" project_requires_python="${BASE_COMMAND_PROTOCOL_FIELDS[requires_project_python]}" diff --git a/cli/bash/commands/basectl/subcommands/test.sh b/cli/bash/commands/basectl/subcommands/test.sh index 64d7d6f0..c0a6efb0 100644 --- a/cli/bash/commands/basectl/subcommands/test.sh +++ b/cli/bash/commands/basectl/subcommands/test.sh @@ -120,6 +120,7 @@ base_test_subcommand_main() { route_venv_dir="${BASE_COMMAND_PROTOCOL_FIELDS[project_venv_dir]}" uses_uv_manager="${BASE_COMMAND_PROTOCOL_FIELDS[uses_uv_manager]}" trust_required="${BASE_COMMAND_PROTOCOL_FIELDS[manifest_command_trust_required]}" + base_project_set_history_context "$resolved_name" "$project_root" "$manifest_path" test_command="${BASE_COMMAND_PROTOCOL_FIELDS[command]}" command_runner="${BASE_COMMAND_PROTOCOL_FIELDS[runner]}" diff --git a/cli/bash/commands/basectl/subcommands/trust.sh b/cli/bash/commands/basectl/subcommands/trust.sh index 92b3554d..98e976c6 100644 --- a/cli/bash/commands/basectl/subcommands/trust.sh +++ b/cli/bash/commands/basectl/subcommands/trust.sh @@ -87,7 +87,7 @@ base_trust_usage_error() { base_trust_subcommand_main() { local trust_command="${1:-}" local wrapper="$BASE_HOME/bin/base-wrapper" - local args=() + local args=() project_name="" case "$trust_command" in ""|-h|--help|help) @@ -121,6 +121,13 @@ base_trust_subcommand_main() { esac done + if [[ "$trust_command" != status || ${#args[@]} -gt 1 ]]; then + project_name="${args[1]:-}" + fi + if [[ -n "$project_name" && "$project_name" != -* ]]; then + export BASE_CLI_HISTORY_PROJECT="$project_name" + fi + [[ -x "$wrapper" ]] || fatal_error "Base Python wrapper '$wrapper' is missing or is not executable." BASE_TRUST_ACTIVE_PROJECT="${BASE_PROJECT:-}" \ BASE_TRUST_ACTIVE_PROJECT_MANIFEST="${BASE_PROJECT_MANIFEST:-}" \ diff --git a/cli/bash/commands/basectl/tests/completions.bats b/cli/bash/commands/basectl/tests/completions.bats index 5bae0bf6..20e30ee9 100644 --- a/cli/bash/commands/basectl/tests/completions.bats +++ b/cli/bash/commands/basectl/tests/completions.bats @@ -331,7 +331,7 @@ EOF [[ "$output" == *"logs_options=--command --limit --path --tail --open --lines"* ]] [[ "$output" == *"logs_last_options=--command --lines --format"* ]] [[ "$output" == *"logs_commands=last"* ]] - [[ "$output" == *"history_options=--project --command --status --limit --format --report"* ]] + [[ "$output" == *"history_options=--project --command --status --limit --format --report --include-internal"* ]] [[ "$output" == *"trust_commands=status allow revoke"* ]] [[ "$output" == *"trust_projects=base demo"* ]] [[ "$output" == *"trust_status_options=--workspace --format"* ]] diff --git a/cli/bash/commands/basectl/tests/history.bats b/cli/bash/commands/basectl/tests/history.bats index af555155..177d8ddc 100644 --- a/cli/bash/commands/basectl/tests/history.bats +++ b/cli/bash/commands/basectl/tests/history.bats @@ -19,11 +19,11 @@ exit 1 EOF chmod +x "$python_bin" - run_basectl history --project demo --command check --status error --limit 3 --format json + run_basectl history --project demo --command check --status error --limit 3 --format json --include-internal [ "$status" -eq 0 ] [[ "$output" == *"BASE_PROJECT=base"* ]] - [[ "$output" == *"ARGS=--project demo --command check --status error --limit 3 --format json"* ]] + [[ "$output" == *"ARGS=--project demo --command check --status error --limit 3 --format json --include-internal"* ]] } @test "basectl history forwards report mode to the Python history layer" { @@ -76,6 +76,7 @@ EOF [[ "$output" == *"basectl history [options]"* ]] [[ "$output" == *"--project "* ]] [[ "$output" == *"--report"* ]] + [[ "$output" == *"--include-internal"* ]] [[ "$output" == *"--format "* ]] } diff --git a/cli/python/base_history/engine.py b/cli/python/base_history/engine.py index 2d41558b..7002e814 100644 --- a/cli/python/base_history/engine.py +++ b/cli/python/base_history/engine.py @@ -10,6 +10,8 @@ import base_cli from base_cli.history import HISTORY_PATH +from base_cli.history import HISTORY_SCOPE_INTERNAL +from base_cli.history import HISTORY_SCOPE_PRIMARY from base_cli.history import optional_int from base_cli.history import optional_string from base_cli.history import parse_finished_history_record_line @@ -33,6 +35,7 @@ class HistoryRecord: ended_at: str sort_time: datetime log_path: str | None + scope: str @property def log_exists(self) -> bool: @@ -54,6 +57,7 @@ class HistoryOptions: limit: int output_format: str report: bool + include_internal: bool @dataclass(frozen=True) @@ -84,6 +88,7 @@ def main(argv: list[str] | None = None) -> int: @base_cli.option("--limit", default="10", help="Maximum history records to list.") @base_cli.option("--format", "output_format", default="text", help="Output format: text, markdown, or json.") @base_cli.option("--report", is_flag=True, help="Print a privacy-conscious local activity report.") +@base_cli.option("--include-internal", is_flag=True, help="Include delegated internal steps in the output.") # pylint: disable=too-many-arguments,too-many-positional-arguments def run( ctx: base_cli.Context, @@ -93,6 +98,7 @@ def run( limit: str, output_format: str, report: bool, + include_internal: bool, ) -> int: try: options = HistoryOptions( @@ -102,6 +108,7 @@ def run( limit=parse_positive_int("--limit", limit), output_format=normalize_report_format(output_format) if report else normalize_format(output_format), report=report, + include_internal=include_internal, ) except ValueError as exc: ctx.log.error(str(exc)) @@ -202,6 +209,7 @@ def parse_history_line(line: str) -> HistoryRecord | None: ended_at=ended_at, sort_time=parse_timestamp(ended_at), log_path=optional_string(payload.get("log_path")), + scope=optional_string(payload.get("scope")) or HISTORY_SCOPE_PRIMARY, ) @@ -216,6 +224,8 @@ def parse_timestamp(value: str) -> datetime: def filter_history(records: list[HistoryRecord], options: HistoryOptions) -> list[HistoryRecord]: filtered = records + if not options.include_internal: + filtered = [record for record in filtered if record.scope != HISTORY_SCOPE_INTERNAL] if options.project: filtered = [record for record in filtered if record.project == options.project] if options.command: diff --git a/cli/python/base_history/record.py b/cli/python/base_history/record.py new file mode 100644 index 00000000..257b15c5 --- /dev/null +++ b/cli/python/base_history/record.py @@ -0,0 +1,86 @@ +"""Append a primary history record for a Bash-dispatched command.""" + +from __future__ import annotations + +import argparse +from datetime import datetime, timezone + +import base_cli +from base_cli.history import HISTORY_PATH +from base_cli.history import optional_int +from base_cli.history import optional_string +from base_cli.history import parse_finished_history_record_line +from base_cli.history import utc_now +from base_cli.paths import base_cache_root +from base_cli.history import write_primary_record + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--command", required=True) + parser.add_argument("--run-id", required=True) + parser.add_argument("--exit-code", required=True, type=int) + parser.add_argument("--started-at") + parser.add_argument("--project") + parser.add_argument("--project-root") + parser.add_argument("--manifest") + parser.add_argument("argv", nargs=argparse.REMAINDER) + options = parser.parse_args(argv) + + command_argv = list(options.argv) + if command_argv and command_argv[0] == "--": + command_argv = command_argv[1:] + started_at = parse_timestamp(options.started_at) if options.started_at else utc_now() + write_primary_record( + command=options.command, + argv=command_argv, + started_at=started_at, + exit_code=options.exit_code, + run_id=options.run_id, + project=options.project, + project_root=options.project_root, + manifest=options.manifest, + log_path=child_log_path(options.run_id, options.exit_code), + ) + return base_cli.ExitCode.SUCCESS + + +def parse_timestamp(value: str) -> datetime: + try: + return datetime.fromisoformat(value.replace("Z", "+00:00")).astimezone(timezone.utc) + except ValueError as exc: + raise SystemExit(f"Invalid --started-at timestamp: {value}") from exc + + +def child_log_path(run_id: str, exit_code: int) -> str | None: + history_path = base_cache_root() / HISTORY_PATH + if not history_path.is_file(): + return None + + candidates: list[tuple[int, str, str]] = [] + try: + with history_path.open("r", encoding="utf-8", errors="replace") as handle: + for line in handle: + payload = parse_finished_history_record_line(line) + if payload is None or payload.get("parent_run_id") != run_id: + continue + log_path = optional_string(payload.get("log_path")) + if log_path is None: + continue + child_exit_code = optional_int(payload.get("exit_code")) + failed = payload.get("status") == "error" or (child_exit_code is not None and child_exit_code != 0) + candidates.append((int(failed), optional_string(payload.get("ended_at")) or "", log_path)) + except OSError: + return None + + if not candidates: + return None + failed_candidates = [candidate for candidate in candidates if candidate[0]] + selected = max(failed_candidates or candidates, key=lambda candidate: candidate[:2]) + if exit_code == 0 and selected[0]: + selected = max(candidates, key=lambda candidate: candidate[1]) + return selected[2] + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/cli/python/base_history/tests/test_engine.py b/cli/python/base_history/tests/test_engine.py index 0ebcac16..ee74678f 100644 --- a/cli/python/base_history/tests/test_engine.py +++ b/cli/python/base_history/tests/test_engine.py @@ -31,8 +31,9 @@ def history_record( ended_at: str = "2026-06-10T10:15:00Z", log_path: str = "~/logs/run.log", argv: list[str] | None = None, + scope: str | None = None, ) -> dict: - return { + payload = { "schema_version": 1, "run_id": run_id, "event": "finished", @@ -45,6 +46,9 @@ def history_record( "status": status, "log_path": log_path, } + if scope is not None: + payload["scope"] = scope + return payload def invoke(args: list[str], cache_root: Path) -> tuple[int, str, str]: @@ -150,6 +154,22 @@ def test_json_output_filters_history_records(self) -> None: self.assertEqual(payload[0]["status"], "error") self.assertFalse(payload[0]["log_exists"]) + def test_history_hides_internal_records_unless_requested(self) -> None: + with tempfile.TemporaryDirectory() as tmpdir: + cache_root = Path(tmpdir) + write_history_line(cache_root, history_record("primary", "test")) + write_history_line(cache_root, history_record("child", "projects", scope="internal")) + + status, stdout, stderr = invoke([], cache_root) + trace_status, trace_stdout, trace_stderr = invoke(["--include-internal"], cache_root) + + self.assertEqual((status, stderr), (0, "")) + self.assertIn("test", stdout) + self.assertNotIn("projects", stdout) + self.assertEqual((trace_status, trace_stderr), (0, "")) + self.assertIn("test", trace_stdout) + self.assertIn("projects", trace_stdout) + def test_empty_history_set_is_not_an_error_for_table_output(self) -> None: with tempfile.TemporaryDirectory() as tmpdir: status, stdout, stderr = invoke([], Path(tmpdir)) diff --git a/docs/base-cli.md b/docs/base-cli.md index a063ebde..dac9ae7e 100644 --- a/docs/base-cli.md +++ b/docs/base-cli.md @@ -128,6 +128,8 @@ ctx.base_home # Path | None ctx.project_name # selected project name, or None ctx.project_root # Path | None ctx.manifest_path # Path | None +ctx.history_scope # primary or internal +ctx.history_parent_run_id # parent basectl invocation ID, or None ctx.workspace_root # configured workspace root, or None ctx.state_dir # Base cache root / cli / ctx.log_dir # state_dir/logs diff --git a/docs/command-reference.md b/docs/command-reference.md index 8beda393..7c159a4e 100644 --- a/docs/command-reference.md +++ b/docs/command-reference.md @@ -109,7 +109,7 @@ manifest trust. | `basectl logs` | List recent Base CLI runtime logs. | `--command `, `--limit ` | | `basectl logs last` | Print the latest failed command metadata plus a bounded redacted log tail. | `--command `, `--lines `, `--format ` | | `basectl logs --path` | Print the newest matching log path only. | `--command ` | -| `basectl history` | List recent structured Base command history records. | `--project `, `--command `, `--status `, `--format ` | +| `basectl history` | List recent user-facing Base command history records. | `--project `, `--command `, `--status `, `--format `, `--include-internal` | | `basectl history --report` | Print a local Markdown or JSON activity report from history and log metadata. | `--limit `, `--format ` | | `basectl logs --open` | Open the newest matching log in `PAGER` or `EDITOR`. | `--command ` | | `basectl logs --tail` | Tail and follow the newest matching log. | `--command `, `--lines ` | diff --git a/docs/observability.md b/docs/observability.md index 587f93a6..b730f42a 100644 --- a/docs/observability.md +++ b/docs/observability.md @@ -66,14 +66,15 @@ inspect with ordinary tools, and easy to recover when one line is malformed. History writes are best-effort: -- write a final completion record when a Python-backed command with a - persistent log exits +- write a primary completion record for a public `basectl` command +- retain internal Python/helper completion records linked to that primary + invocation - never fail the user command because the history file cannot be written - ignore malformed history lines while warning in debug output -The first implementation emits one `finished` record per recorded run. Shell-only -commands and no-durable-write modes such as `ctx.dry_run` or -`App(log_to_file=False)` do not create history records in this slice. +The default `basectl history` view shows primary public-command records. Internal +records remain in the local index and can be inspected with +`basectl history --include-internal`. ## Record Shape @@ -84,6 +85,7 @@ A history record should include: "schema_version": 1, "run_id": "20260610T101500_ab12cd", "event": "finished", + "scope": "primary", "command": "setup", "raw_command": "base_setup", "argv": ["basectl", "setup", "base"], @@ -104,6 +106,10 @@ A history record should include: } ``` +Primary records use `scope: "primary"` and represent the command the user +invoked. Delegated records use `scope: "internal"` and carry that invocation's +`run_id` in `parent_run_id`. + Fields should be omitted when unknown instead of guessed. The first implementation should not store: @@ -172,6 +178,8 @@ Expected options: - `--format json` prints structured records for scripts. - `--report` prints a Markdown activity report by default. - `--report --format json` prints the same report as deterministic JSON. +- `--include-internal` includes delegated resolver, routing, bootstrap, and + trust-gate records linked to each primary command. `basectl logs` should remain the command for opening or tailing raw log files. `basectl history` should point to logs, not replace them. diff --git a/lib/python/base_cli/README.md b/lib/python/base_cli/README.md index a2d4450a..80e49270 100644 --- a/lib/python/base_cli/README.md +++ b/lib/python/base_cli/README.md @@ -231,6 +231,8 @@ Important fields include: - `ctx.project_root`: directory containing the nearest `base_manifest.yaml`. - `ctx.workspace_root`: configured workspace root from `~/.base.d/config.yaml`. - `ctx.manifest_path`: nearest discovered Base manifest. +- `ctx.history_scope`: whether this record is a primary or internal event. +- `ctx.history_parent_run_id`: parent `basectl` invocation ID, when delegated. - `ctx.state_dir`: per-CLI runtime directory under the Base cache root. - `ctx.log_dir`: persistent log directory. - `ctx.cache_dir`: persistent cache directory. diff --git a/lib/python/base_cli/app.py b/lib/python/base_cli/app.py index bace0068..04c57db1 100644 --- a/lib/python/base_cli/app.py +++ b/lib/python/base_cli/app.py @@ -216,6 +216,8 @@ def _create_context(self, standard: dict[str, Any], sensitive_options: set[str], log=logger, user_config=user_config, dry_run=dry_run, + history_scope=os.environ.get("BASE_CLI_HISTORY_SCOPE", "primary"), + history_parent_run_id=os.environ.get("BASE_CLI_HISTORY_PARENT_RUN_ID") or None, ) diff --git a/lib/python/base_cli/context.py b/lib/python/base_cli/context.py index 605da39f..f88bff61 100644 --- a/lib/python/base_cli/context.py +++ b/lib/python/base_cli/context.py @@ -39,6 +39,8 @@ class Context: project_root: Path | None = None manifest_path: Path | None = None project_name: str | None = None + history_scope: str = "primary" + history_parent_run_id: str | None = None user_config: UserConfig = field(default_factory=_default_user_config) cleanup_hooks: list[Callable[[], None]] = field(default_factory=list) workspace_root: Path | None = None diff --git a/lib/python/base_cli/history.py b/lib/python/base_cli/history.py index 5b562bc4..7a0260d8 100644 --- a/lib/python/base_cli/history.py +++ b/lib/python/base_cli/history.py @@ -20,6 +20,8 @@ SCHEMA_VERSION = 1 HISTORY_PATH = Path("history") / "runs.jsonl" +HISTORY_SCOPE_PRIMARY = "primary" +HISTORY_SCOPE_INTERNAL = "internal" def utc_now() -> datetime: @@ -72,11 +74,52 @@ def build_finished_record( "workspace_root": compact_optional_path(context.workspace_root), "base_version": base_version(context.base_home), "shell": os.environ.get("SHELL"), + "scope": context.history_scope, + "parent_run_id": context.history_parent_run_id, } record.update({key: value for key, value in optional_fields.items() if value}) return record +# pylint: disable=too-many-arguments,too-many-positional-arguments +def write_primary_record( + command: str, + argv: list[str], + started_at: datetime, + exit_code: int, + run_id: str, + project: str | None = None, + project_root: str | None = None, + manifest: str | None = None, + log_path: str | None = None, +) -> None: + """Write the user-facing record for a Bash-dispatched command.""" + ended_at = utc_now() + record: dict[str, Any] = { + "schema_version": SCHEMA_VERSION, + "run_id": run_id, + "event": "finished", + "command": command, + "raw_command": "basectl", + "argv": redact_history_argv(argv, sensitive_options=set()), + "started_at": format_timestamp(started_at), + "ended_at": format_timestamp(ended_at), + "duration_ms": duration_ms(started_at, ended_at), + "exit_code": exit_code, + "status": "ok" if exit_code == 0 else "error", + "os": normalized_os(), + "scope": HISTORY_SCOPE_PRIMARY, + } + optional_fields = { + "project": project, + "project_root": compact_optional_path(Path(project_root)) if project_root else None, + "manifest": compact_optional_path(Path(manifest)) if manifest else None, + "log_path": compact_optional_path(Path(log_path)) if log_path else None, + } + record.update({key: value for key, value in optional_fields.items() if value}) + write_history_record(record) + + def write_history_record(record: dict[str, Any]) -> None: path = base_cache_root() / HISTORY_PATH path.parent.mkdir(parents=True, exist_ok=True) diff --git a/lib/python/base_cli/tests/test_history.py b/lib/python/base_cli/tests/test_history.py index ea924144..2e609d8b 100644 --- a/lib/python/base_cli/tests/test_history.py +++ b/lib/python/base_cli/tests/test_history.py @@ -109,6 +109,33 @@ def test_write_history_record_appends_without_fcntl(self) -> None: self.assertEqual(records, [record]) self.assertEqual(history_mode, 0o600) + def test_write_primary_record_preserves_user_command_and_project_metadata(self) -> None: + with tempfile.TemporaryDirectory() as tmpdir: + cache_root = Path(tmpdir) / "cache" + project_root = Path(tmpdir) / "work" / "demo" + manifest = project_root / "base_manifest.yaml" + with mock.patch.dict(os.environ, {"BASE_CACHE_DIR": str(cache_root)}): + history_helpers.write_primary_record( + command="test", + argv=["basectl", "test", "demo"], + started_at=history_helpers.utc_now(), + exit_code=1, + run_id="parent-1", + project="demo", + project_root=str(project_root), + manifest=str(manifest), + ) + record = read_history_records(cache_root)[0] + + self.assertEqual(record["command"], "test") + self.assertEqual(record["raw_command"], "basectl") + self.assertEqual(record["scope"], "primary") + self.assertEqual(record["run_id"], "parent-1") + self.assertEqual(record["project"], "demo") + self.assertEqual(record["project_root"], str(project_root.resolve())) + self.assertEqual(record["manifest"], str(manifest.resolve())) + self.assertEqual(record["status"], "error") + @unittest.skipUnless(importlib.util.find_spec("click"), "Click is not installed") def test_app_records_successful_command_history_with_redacted_metadata(self) -> None: app = base_cli.App(name="history-demo", version="0.1.0") @@ -176,6 +203,33 @@ def main(ctx: base_cli.Context, endpoint: str, token: str) -> None: self.assertIn("https://[REDACTED]@example.invalid/path", record["argv"]) self.assertNotIn("super-secret", json.dumps(record)) + @unittest.skipUnless(importlib.util.find_spec("click"), "Click is not installed") + def test_app_records_internal_scope_and_parent_run_id(self) -> None: + app = base_cli.App(name="history-internal") + + @app.command() + def main(ctx: base_cli.Context) -> None: + self.assertEqual(ctx.history_scope, "internal") + self.assertEqual(ctx.history_parent_run_id, "parent-1") + + with tempfile.TemporaryDirectory() as tmpdir: + home = Path(tmpdir) / "home" + with mock.patch.dict( + os.environ, + { + "HOME": str(home), + "BASE_CACHE_DIR": str(home / ".cache" / "base"), + "BASE_CLI_HISTORY_SCOPE": "internal", + "BASE_CLI_HISTORY_PARENT_RUN_ID": "parent-1", + }, + ): + status = base_cli.run_app(app, []) + records = read_history_records(home / ".cache" / "base") + + self.assertEqual(status, 0) + self.assertEqual(records[0]["scope"], "internal") + self.assertEqual(records[0]["parent_run_id"], "parent-1") + @unittest.skipUnless(importlib.util.find_spec("click"), "Click is not installed") def test_run_app_uses_explicit_argv_for_history_and_log_metadata(self) -> None: app = base_cli.App(name="history-explicit", version="0.1.0") diff --git a/lib/shell/completions/basectl_completion.sh b/lib/shell/completions/basectl_completion.sh index 5ca2c701..bc364808 100644 --- a/lib/shell/completions/basectl_completion.sh +++ b/lib/shell/completions/basectl_completion.sh @@ -766,7 +766,7 @@ _base_basectl_completion() { fi ;; history) - _base_basectl_completion_compgen "--project --command --status --limit --format --report -v -h --help" "$cur" + _base_basectl_completion_compgen "--project --command --status --limit --format --report --include-internal -v -h --help" "$cur" ;; config) if ((COMP_CWORD == 2)); then diff --git a/lib/shell/completions/basectl_completion.zsh b/lib/shell/completions/basectl_completion.zsh index 90cfc666..222781d4 100644 --- a/lib/shell/completions/basectl_completion.zsh +++ b/lib/shell/completions/basectl_completion.zsh @@ -915,6 +915,7 @@ _base_basectl_completion() { '--limit[Number of records]:count:' \ '--format[Output format]:format:(text json)' \ '--report[Print a privacy-conscious Markdown or JSON activity report]' \ + '--include-internal[Include delegated internal steps in the output]' \ '-v[Enable DEBUG logging]' '(-h --help)'{-h,--help}'[Show help text]' ;; config) diff --git a/lib/shell/completions/tests/completions.bats b/lib/shell/completions/tests/completions.bats index acd71385..30670ecb 100644 --- a/lib/shell/completions/tests/completions.bats +++ b/lib/shell/completions/tests/completions.bats @@ -599,6 +599,7 @@ run_zsh_positional_completion() { )" [[ "$block" == *"--report"* ]] + [[ "$block" == *"--include-internal"* ]] } @test "Zsh release completion scopes inspection and publish options" { From 1af59671f286c665d63758cf33fc041458c5a2a2 Mon Sep 17 00:00:00 2001 From: Ramesh Padmanabhaiah Date: Fri, 17 Jul 2026 19:37:47 -0700 Subject: [PATCH 2/2] Update project history test fixture for trace recorder --- cli/bash/commands/basectl/tests/projects.bats | 3 +++ 1 file changed, 3 insertions(+) diff --git a/cli/bash/commands/basectl/tests/projects.bats b/cli/bash/commands/basectl/tests/projects.bats index d12a67d7..fbbc6ef0 100644 --- a/cli/bash/commands/basectl/tests/projects.bats +++ b/cli/bash/commands/basectl/tests/projects.bats @@ -43,6 +43,9 @@ EOF mkdir -p "$(dirname "$python_bin")" "$workspace/base" cat > "$python_bin" <<'EOF' #!/usr/bin/env bash +if [[ "${1:-}" == "-m" && "${2:-}" == "base_history.record" ]]; then + exit 0 +fi printf '%s\n' "$*" > "${BASE_TEST_PROJECTS_LIST_STATE:?}" if [[ "${1:-}" == "-m" && "${2:-}" == "base_projects" && "${3:-}" == "list" ]]; then printf '[{"name":"base","path":"%s"}]\n' "${BASE_TEST_WORKSPACE:?}/base"