diff --git a/README.md b/README.md index a2908601663..1a229c7996c 100644 --- a/README.md +++ b/README.md @@ -86,7 +86,8 @@ In about 30 seconds, you have a typed `source → task → sink` graph that prin first messages and records `logs/hello-copper.copper`. Start with `copperconfig.ron`, `src/main.rs`, and `src/tasks.rs`; the generated `justfile` also provides helpers for logs, CopperLists, topology (`just dag`), the exact -generated process schedule (`just plan`), and replay. +generated process schedule (`just plan`), post-execution timing from the default +Copper log (`just plan-log`), and replay. ## How Copper Fits Together diff --git a/examples/cu_caterpillar/README.md b/examples/cu_caterpillar/README.md index 331d1e5216f..7cd62e4e08c 100644 --- a/examples/cu_caterpillar/README.md +++ b/examples/cu_caterpillar/README.md @@ -12,3 +12,4 @@ See the crate cu29 for more information about the Copper project. - `just resim` — rerun the logged mission from `logs/caterpillar.copper`. - `just resim-debug` — start the replay-backed remote debug server for `logs/caterpillar.copper`. - `just dag-logstats` — generate logstats and open an annotated DAG SVG for the current `copperconfig.ron`. +- `just plan-log` — auto-detect the default log and logreader, then append packed, proportional observed task timing to `plan.svg`. diff --git a/support/cargo_cunew/src/lib.rs b/support/cargo_cunew/src/lib.rs index 3f004a7b91c..551430cd360 100644 --- a/support/cargo_cunew/src/lib.rs +++ b/support/cargo_cunew/src/lib.rs @@ -608,6 +608,8 @@ mod tests { assert!(justfile.contains("dag:")); assert!(justfile.contains("[positional-arguments]")); assert!(justfile.contains("plan *options:")); + assert!(justfile.contains("plan-log:")); + assert!(justfile.contains("log-stats")); assert!(justfile.contains("--bin cu29-plan")); } @@ -648,6 +650,8 @@ mod tests { assert!(app_manifest.contains("edition = \"2024\"")); assert!(justfile.contains("cu29-rendercfg")); assert!(justfile.contains("cu29-plan")); + assert!(justfile.contains("plan-log:")); + assert!(justfile.contains("log-stats")); assert!(!project.join(".git").exists()); } diff --git a/support/cargo_cunew/templates/README.md b/support/cargo_cunew/templates/README.md index 5156d54a0cb..dae369b7f55 100644 --- a/support/cargo_cunew/templates/README.md +++ b/support/cargo_cunew/templates/README.md @@ -90,7 +90,8 @@ The generated project includes helper commands in its `justfile`: * `just resim`: Replay the recorded log once into a fresh replay log. * `just resim-debug`: Start the replay-backed remote debug server manually. * `just dag`: Render the application's execution Directed Acyclic Graph (DAG). Local-checkout templates run the tool from the Copper repo; crates.io and git templates use `cu29-rendercfg` from `PATH` or install the matching `cu29-runtime` on demand. `just rcfg` remains as a compatibility alias. -* `just plan`: Render the exact generated per-CopperList process order as `plan.svg`, with serial and `parallel-rt` projections. Pass `mission=` or `features=` when needed. Templates use the local `cu29-plan` binary or install the matching `cu29-runtime` on demand. +* `just plan`: Render the exact generated per-CopperList process order as `plan.svg`, with serial and `parallel-rt` projections. Pass `log=logs/.copper` to append packed typical/slowest timelines whose back-to-back segments remain proportional to recorded task duration, or `mission=` / `features=` when needed. Templates use the local `cu29-plan` binary or install the matching `cu29-runtime` on demand. +* `just plan-log`: Render the same SVG with observed timing from the project's default Copper log. Set `APP_NAME=test-workspace-demo APP_DIR=test_workspace-demo` to target the demo app (for example, `APP_NAME=test-workspace-demo APP_DIR=test_workspace-demo just log`). diff --git a/support/cargo_cunew/templates/cu_full/README.md b/support/cargo_cunew/templates/cu_full/README.md index 407198067a9..4ec17bc6300 100644 --- a/support/cargo_cunew/templates/cu_full/README.md +++ b/support/cargo_cunew/templates/cu_full/README.md @@ -9,7 +9,7 @@ This template bootstraps a Copper workspace with an app crate and a shared-compo - `components/bridges/cu_example_shared_bridge/`: shared bridge example crate. - `components/`: shared components by category (placeholders to extend). - `doc/`: design notes and project docs. -- `justfile`: automation helpers like `just dag` for topology, `just plan` for generated process order, `just log`, `just cl`, `just resim`, and `just resim-debug` (`just rcfg` remains as a compatibility alias). +- `justfile`: automation helpers like `just dag` for topology, `just plan` for generated or log-observed process timing, `just log`, `just cl`, `just resim`, and `just resim-debug` (`just rcfg` remains as a compatibility alias). ## Quick start diff --git a/support/cargo_cunew/templates/cu_full/justfile b/support/cargo_cunew/templates/cu_full/justfile index be111dddaaa..583c035f087 100644 --- a/support/cargo_cunew/templates/cu_full/justfile +++ b/support/cargo_cunew/templates/cu_full/justfile @@ -40,18 +40,30 @@ plan *options: args=("$config" --output "$output" --open) mission_value="" features_value="" + log_value="" for option in "$@"; do case "$option" in mission=*) mission_value="${option#mission=}" ;; features=*) features_value="${option#features=}" ;; + log=*) log_value="${option#log=}" ;; *) - echo "Unknown plan option '$option'; expected mission= or features=." >&2 + echo "Unknown plan option '$option'; expected mission=, features=, or log=." >&2 exit 2 ;; esac done [[ -z "$mission_value" ]] || args+=(--mission "$mission_value") [[ -z "$features_value" ]] || args+=(--features "$features_value") + if [[ -n "$log_value" ]]; then + APP_NAME="${APP_NAME:-${APP_DIR}}" + [[ "$log_value" != *_0.copper ]] || log_value="${log_value%_0.copper}.copper" + stats="apps/${APP_DIR}/target/cu29_plan_logstats.json" + mkdir -p "$(dirname "$stats")" + logreader_args=(-p "$APP_NAME" --features=logreader --bin "${APP_NAME}-logreader" -- "$log_value" log-stats --config "$config" --output "$stats" --features logreader) + [[ -z "$mission_value" ]] || logreader_args+=(--mission "$mission_value") + cargo run "${logreader_args[@]}" + args+=(--logstats "$stats") + fi {% if copper_source == "local" %} cargo run --manifest-path "{{copper_root_path}}/Cargo.toml" -p cu29-runtime --bin cu29-plan -- "${args[@]}" {% else %} @@ -70,6 +82,13 @@ plan *options: "$planner" "${args[@]}" {% endif %} +# Render observed timing from the selected app's default Copper log. +plan-log: + #!/usr/bin/env bash + APP_DIR="${APP_DIR:-cu_example_app}" + APP_NAME="${APP_NAME:-${APP_DIR}}" + just plan "log=apps/${APP_DIR}/logs/${APP_NAME}.copper" features=logreader + # Extract the structured log via the log reader. log: #!/usr/bin/env bash diff --git a/support/cargo_cunew/templates/cu_project/README.md b/support/cargo_cunew/templates/cu_project/README.md index d79f6020832..cc5c29afe4c 100644 --- a/support/cargo_cunew/templates/cu_project/README.md +++ b/support/cargo_cunew/templates/cu_project/README.md @@ -9,7 +9,7 @@ This template bootstraps a single-crate Copper project for quick experiments. - `src/resim.rs`: replay binary with one-shot replay and remote-debug server modes. - `src/tasks.rs`: sample tasks. - `copperconfig.ron`: runtime configuration. -- `justfile`: automation helpers like `just log`, `just cl`, `just resim`, `just resim-debug`, `just dag` for topology, and `just plan` for generated process order (`just rcfg` remains as a compatibility alias). +- `justfile`: automation helpers like `just log`, `just cl`, `just resim`, `just resim-debug`, `just dag` for topology, and `just plan` for generated or log-observed process timing (`just rcfg` remains as a compatibility alias). ## Quick start diff --git a/support/cargo_cunew/templates/cu_project/justfile b/support/cargo_cunew/templates/cu_project/justfile index 6a0d53ddccd..ad204da8ae5 100644 --- a/support/cargo_cunew/templates/cu_project/justfile +++ b/support/cargo_cunew/templates/cu_project/justfile @@ -71,18 +71,28 @@ plan *options: args=(copperconfig.ron --output plan.svg --open) mission_value="" features_value="" + log_value="" for option in "$@"; do case "$option" in mission=*) mission_value="${option#mission=}" ;; features=*) features_value="${option#features=}" ;; + log=*) log_value="${option#log=}" ;; *) - echo "Unknown plan option '$option'; expected mission= or features=." >&2 + echo "Unknown plan option '$option'; expected mission=, features=, or log=." >&2 exit 2 ;; esac done [[ -z "$mission_value" ]] || args+=(--mission "$mission_value") [[ -z "$features_value" ]] || args+=(--features "$features_value") + if [[ -n "$log_value" ]]; then + mkdir -p target + [[ "$log_value" != *_0.copper ]] || log_value="${log_value%_0.copper}.copper" + logreader_args=(--features=logreader --bin {{project-name|kebab_case}}-logreader -- "$log_value" log-stats --config copperconfig.ron --output target/cu29_plan_logstats.json --features logreader) + [[ -z "$mission_value" ]] || logreader_args+=(--mission "$mission_value") + cargo run "${logreader_args[@]}" + args+=(--logstats target/cu29_plan_logstats.json) + fi {% if copper_source == "local" %} cargo run --manifest-path "{{copper_root_path}}/Cargo.toml" -p cu29-runtime --bin cu29-plan -- "${args[@]}" {% else %} @@ -100,3 +110,7 @@ plan *options: [[ -x "$planner" ]] || { echo "Failed to find cu29-plan after installation." >&2; exit 1; } "$planner" "${args[@]}" {% endif %} + +# Render observed timing from the project's default Copper log. +plan-log: + just plan log=logs/{{project-name|kebab_case}}.copper features=logreader diff --git a/support/ci/plan_defaults.py b/support/ci/plan_defaults.py new file mode 100644 index 00000000000..06b70600ef2 --- /dev/null +++ b/support/ci/plan_defaults.py @@ -0,0 +1,97 @@ +#!/usr/bin/env python3 + +"""Discover the local Copper log and typed logreader used by `just plan-log`.""" + +from __future__ import annotations + +import json +import re +import subprocess +import sys +from pathlib import Path +from typing import Any + + +def fail(message: str) -> None: + raise SystemExit(f"plan-log: {message}") + + +def copper_log_bases(directory: Path) -> list[Path]: + logs = directory / "logs" + bases: set[Path] = set() + for path in logs.glob("*.copper"): + name = re.sub(r"_\d+\.copper$", ".copper", path.name) + bases.add(logs / name) + return sorted(bases) + + +def choose_log(candidates: list[Path]) -> Path: + if not candidates: + fail("no logs/*.copper slabs found; run the example first") + preferred = [ + path + for path in candidates + if "resim" not in path.stem and "replay" not in path.stem + ] + if preferred: + candidates = preferred + non_compute = [path for path in candidates if "compute" not in path.stem] + if non_compute: + candidates = non_compute + if len(candidates) != 1: + names = ", ".join(path.name for path in candidates) + fail(f"multiple plausible logs ({names}); use `just plan log=... bin=...`") + return candidates[0] + + +def choose_logreader(targets: list[dict[str, Any]], log: Path) -> dict[str, Any]: + candidates = [ + target + for target in targets + if "bin" in target.get("kind", []) and "logreader" in target.get("name", "") + ] + if not candidates: + fail("this example has no typed logreader binary") + wants_compute = "compute" in log.stem + matched = [ + target + for target in candidates + if ("compute" in target["name"]) == wants_compute + ] + if matched: + candidates = matched + if len(candidates) != 1: + names = ", ".join(target["name"] for target in candidates) + fail(f"multiple plausible logreaders ({names}); use `just plan log=... bin=...`") + return candidates[0] + + +def main() -> None: + if len(sys.argv) != 2: + fail("expected the example directory") + directory = Path(sys.argv[1]).resolve() + metadata = json.loads( + subprocess.check_output( + ["cargo", "metadata", "--format-version", "1", "--no-deps"], + cwd=directory, + text=True, + encoding="utf-8", + ) + ) + packages = [ + package + for package in metadata.get("packages", []) + if Path(package["manifest_path"]).resolve().parent == directory + ] + if len(packages) != 1: + fail(f"could not identify one Cargo package in {directory}") + log = choose_log(copper_log_bases(directory)) + target = choose_logreader(packages[0].get("targets", []), log) + required_features = target.get("required-features", []) + print(target["name"]) + print(",".join(required_features)) + print(log) + + +if __name__ == "__main__": + main() diff --git a/support/just/plan.just b/support/just/plan.just index b3c1980e3a3..8d5481180e8 100644 --- a/support/just/plan.just +++ b/support/just/plan.just @@ -11,13 +11,19 @@ plan *options: mission_value="" features_value="" config_value="" + log_value="" + bin_value="" + log_features_value="" for option in "$@"; do case "$option" in mission=*) mission_value="${option#mission=}" ;; features=*) features_value="${option#features=}" ;; config=*) config_value="${option#config=}" ;; + log=*) log_value="${option#log=}" ;; + bin=*) bin_value="${option#bin=}" ;; + logfeatures=*) log_features_value="${option#logfeatures=}" ;; *) - echo "Unknown plan option '$option'; expected mission=, features=, or config=." >&2 + echo "Unknown plan option '$option'; expected mission=, features=, config=, log=, bin=, or logfeatures=." >&2 exit 2 ;; esac @@ -64,5 +70,51 @@ plan *options: args+=(--features "$features_value") fi + if [[ -n "$log_value" ]]; then + [[ -n "$bin_value" ]] || { echo "bin= is required with log=." >&2; exit 2; } + if [[ "$log_value" = /* ]]; then + log_path="$log_value" + else + log_path="${invocation_dir}/${log_value}" + fi + [[ "$log_path" != *_0.copper ]] || log_path="${log_path%_0.copper}.copper" + log_slab="${log_path%.copper}_0.copper" + [[ -f "$log_path" || -f "$log_slab" ]] || { echo "Copper log not found: $log_path" >&2; exit 1; } + stats_path="${output_dir}/target/cu29_plan_logstats.json" + mkdir -p "$(dirname "$stats_path")" + logreader_args=(run --bin "$bin_value") + [[ -z "$log_features_value" ]] || logreader_args+=(--features "$log_features_value") + logreader_args+=(-- "$log_path" log-stats --config "$cfg_path" --output "$stats_path") + [[ -z "$mission_value" ]] || logreader_args+=(--mission "$mission_value") + config_features_value="$features_value" + [[ -n "$config_features_value" ]] || config_features_value="$log_features_value" + [[ -z "$config_features_value" ]] || logreader_args+=(--features "$config_features_value") + (cd "$invocation_dir" && cargo "${logreader_args[@]}") + args+=(--logstats "$stats_path") + fi + cd "{{PLAN_WORKSPACE_ROOT}}" cargo run -p cu29-runtime --bin cu29-plan -- "${args[@]}" + +# Render observed timing from this example's default local Copper log. +[positional-arguments] +plan-log *options: + #!/usr/bin/env bash + set -euo pipefail + + invocation_dir="{{invocation_directory()}}" + defaults="$(python3 "{{PLAN_WORKSPACE_ROOT}}/support/ci/plan_defaults.py" "$invocation_dir")" + bin_default="$(printf '%s\n' "$defaults" | sed -n '1p')" + features_default="$(printf '%s\n' "$defaults" | sed -n '2p')" + log_default="$(printf '%s\n' "$defaults" | sed -n '3p')" + if [[ -z "$bin_default" || -z "$log_default" ]]; then + echo "Could not discover plan-log defaults for ${invocation_dir}." >&2 + exit 1 + fi + + args=("log=${log_default}" "bin=${bin_default}") + if [[ -n "$features_default" ]]; then + args+=("features=${features_default}" "logfeatures=${features_default}") + fi + args+=("$@") + (cd "$invocation_dir" && just plan "${args[@]}")