diff --git a/.claude/skills/capture-tui-diagram/SKILL.md b/.agents/skills/capture-tui-diagram/SKILL.md similarity index 86% rename from .claude/skills/capture-tui-diagram/SKILL.md rename to .agents/skills/capture-tui-diagram/SKILL.md index 4be75d2..e2cf7fa 100644 --- a/.claude/skills/capture-tui-diagram/SKILL.md +++ b/.agents/skills/capture-tui-diagram/SKILL.md @@ -1,6 +1,6 @@ --- name: capture-tui-diagram -description: Capture live actop TUI frames via tmux and embed them as faithful ASCII diagrams in the docs (DESIGN-system.md §5, README). Covers picking a width/preset for each view (grid / stack / process+filter), the poll-cycle wait that avoids stale frames, cleaning a raw capture with clean_capture.py, and splicing it into a doc via a Python replace instead of hand-transcribing braille. Use when a doc's terminal mockup is stale after a TUI/layout change. +description: Capture live actop TUI frames via tmux and embed them as faithful ASCII diagrams in the docs (SPEC-system.md §5, README). Covers picking a width/preset for each view (grid / stack / process+filter), the poll-cycle wait that avoids stale frames, cleaning a raw capture with clean_capture.py, and splicing it into a doc via a Python replace instead of hand-transcribing braille. Use when a doc's terminal mockup is stale after a TUI/layout change. --- # capture-tui-diagram @@ -16,7 +16,7 @@ focuses on capture → clean → splice. ## When to use -- After a TUI or layout change makes a doc diagram stale (e.g. DESIGN-system.md +- After a TUI or layout change makes a doc diagram stale (e.g. SPEC-system.md §5, README hero block). - When you need side-by-side "here's preset A vs preset B" captures. @@ -71,14 +71,14 @@ sleep 6 # let the next poll apply it, THEN capture lines, trailing pad whitespace, and scrollbar thumb artifacts: ```bash -python .claude/skills/capture-tui-diagram/clean_capture.py tmp/raw.txt > tmp/grid.txt +python .agents/skills/capture-tui-diagram/clean_capture.py tmp/raw.txt > tmp/grid.txt ``` For a **single-column** capture (the `stack` preset) add `--compress` to collapse runs of blank chart rows so a ~47-row frame fits a doc: ```bash -python .claude/skills/capture-tui-diagram/clean_capture.py tmp/stack_raw.txt --compress > tmp/stack.txt +python .agents/skills/capture-tui-diagram/clean_capture.py tmp/stack_raw.txt --compress > tmp/stack.txt ``` **Never `--compress` a side-by-side frame** (grid, or dashboard+table): the two @@ -93,7 +93,7 @@ Replace the region between two stable markers with a small Python edit: ```bash python - <<'PY' import pathlib -DOC = pathlib.Path("docs/DESIGN-system.md") +DOC = pathlib.Path("docs/SPEC-system.md") grid = pathlib.Path("tmp/grid.txt").read_text().rstrip("\n") stack = pathlib.Path("tmp/stack.txt").read_text().rstrip("\n") proc = pathlib.Path("tmp/proc.txt").read_text().rstrip("\n") @@ -108,8 +108,8 @@ PY Then sanity-check the fences are balanced and headings survived: ```bash -grep -c '^```' docs/DESIGN-system.md # must be even -grep -n '^## \|^### ' docs/DESIGN-system.md # headings intact +grep -c '^```' docs/SPEC-system.md # must be even +grep -n '^## \|^### ' docs/SPEC-system.md # headings intact ``` ## Gotchas diff --git a/.claude/skills/capture-tui-diagram/clean_capture.py b/.agents/skills/capture-tui-diagram/clean_capture.py similarity index 100% rename from .claude/skills/capture-tui-diagram/clean_capture.py rename to .agents/skills/capture-tui-diagram/clean_capture.py diff --git a/.agents/skills/publish-cover/SKILL.md b/.agents/skills/publish-cover/SKILL.md new file mode 100644 index 0000000..688c715 --- /dev/null +++ b/.agents/skills/publish-cover/SKILL.md @@ -0,0 +1,151 @@ +--- +name: publish-cover +description: >- + "Publish, redeploy, or update the project landing page / cover to Cloudflare + Pages. Trigger words: publish, redeploy, cover, landing page. Boundary: do + NOT use for CI docs, internal-only artifacts, or README badges." +--- + +# publish-cover + +Route + decision layer for publishing the project landing page (cover) to +Cloudflare Pages. **This file decides when and whether to act;** the +deterministic build work lives in the bundled +`scripts/build-cover-dist.sh`. The deploy step requires explicit human +confirmation — no auto-deploy. + +## Decision gates (must pass before invoking build) + +### Gate 1 — Source exists + +```bash +ls "${COVER_SRC:-cover}/index.html" >/dev/null 2>&1 +``` + +If `COVER_SRC` is unset, defaults to `cover/` at the repo root. The script +refuses to run if no `index.html` is found. If the cover source does not exist +yet, scaffold it first — this skill does not author content. + +### Gate 2 — Curated subset + +The build script copies `COVER_SRC` into `dist-cover/`, then injects dynamic +assets (version badge, build metadata). Before proceeding, list what will be +published: + +```bash +find "${COVER_SRC:-cover}" -type f | sort +``` + +Ask: *"Publish this subset? Any files that should be excluded?"* Files +not intended for public deployment (drafts, notes, PSDs, unused assets) must be +removed or `.gitignore`d before the build. + +### Gate 3 — Third-party material + +If the cover references external fonts, images, or libraries (CDN or bundled): + +- Confirm each has an explicit license and attribution path. +- If bundled, confirm the license file is included in the dist. + +Ask: *"All third-party assets accounted for and licensed?"* + +### Gate 4 — Untracked / dirty working tree + +```bash +git diff --quiet && git diff --cached --quiet +``` + +The build script stamps the current commit hash into the dist. Publishing +uncommitted changes is a footgun. If the tree is dirty, flag it and ask +whether to proceed (the commit stamp will read `dirty`). + +## Flow + +``` +Gate 1–4 → Build → Audit → [MANUAL CONFIRM] → Deploy → Verify +``` + +### Step 1 — Build + +```bash +bash .agents/skills/publish-cover/scripts/build-cover-dist.sh +``` + +What the script does deterministically: + +| Phase | Action | +|---|---| +| Copy | `COVER_SRC` → `COVER_DIST` (wiped first) | +| Rewrite | Asset paths for deployment (relative → deployment-safe) | +| Inject | Version badge from `pyproject.toml` (or `package.json` fallback) | +| Stamp | `` in every `.html` | +| Validate | Every `.gif` in dist: max file size, nonzero frame count | +| Scan | Secret scan across all dist files (rejects on match) | +| Report | Manifest + per-file sizes to stdout | + +**Env overrides:** + +| Env | Default | Purpose | +|---|---|---| +| `COVER_SRC` | `cover/` | Source directory | +| `COVER_DIST` | `dist-cover/` | Build output | +| `MAX_GIF_SIZE_MB` | `5` | Reject gifs larger than this | +| `SKIP_SECRET_SCAN` | `0` | `1` = bypass (escape hatch) | + +### Step 2 — Audit + +After build, inspect `dist-cover/`: + +```bash +open dist-cover/index.html # visual check +du -sh dist-cover/ # size at a glance +diff -rq dist-cover/ dist-cover.prev/ 2>/dev/null # vs last published (if saved) +``` + +This is a human eye pass — does the page render correctly? Are version +numbers right? Do gif references resolve? The script cannot judge visual +fidelity. + +### Step 3 — Deploy (MANUAL, interactive) + +**This step requires explicit human confirmation.** Cloudflare Pages deploys +use `wrangler pages deploy` with interactive browser-based OAuth. The agent +must pause here; the human runs this: + +```bash +npx wrangler pages deploy dist-cover/ --project-name --branch main +``` + +Confirm before running: *"Deploy dist-cover/ to Cloudflare Pages? This +overwrites the live site."* + +Save the previous dist for diffing on the next redeploy: + +```bash +rm -rf dist-cover.prev && cp -r dist-cover/ dist-cover.prev +``` + +### Step 4 — Verify + +```bash +curl -sI https://.pages.dev | head -5 # check 200 +curl -s https://.pages.dev | grep -o 'v[0-9]\+\.[0-9]\+\.[0-9]\+' # version matches +``` + +Acceptance: HTTP 200, version badge matches source of truth, gifs animate, +all links resolve, no broken images. + +## Prerequisites (one-time) + +```bash +npm install -g wrangler +``` + +## Gotchas + +- **Cloudflare login drift**: `wrangler` sessions expire. `npx wrangler login` first. +- **Secret scan fires**: remove the secret from `COVER_SRC` and rebuild. Dotfiles + and `.gitignore`d paths inside `cover/` are skipped by the build copy. +- **Stale dist**: always rebuild before deploy. The script wipes `COVER_DIST` on each run. +- **Commit stamp drift**: if you build, commit, then deploy without rebuilding, + the stamp is one commit behind — visually confusing but not breaking. diff --git a/.agents/skills/publish-cover/scripts/build-cover-dist.sh b/.agents/skills/publish-cover/scripts/build-cover-dist.sh new file mode 100755 index 0000000..aa55131 --- /dev/null +++ b/.agents/skills/publish-cover/scripts/build-cover-dist.sh @@ -0,0 +1,198 @@ +#!/usr/bin/env bash +# build-cover-dist.sh — deterministic build for the landing page dist. +# All determinism lives here: path rewrite, version injection, gif validation, +# secret scan. The SKILL.md owns the decision gates; this script owns the build. +set -euo pipefail + +COVER_SRC="${COVER_SRC:-cover}" +COVER_DIST="${COVER_DIST:-dist-cover}" +MAX_GIF_SIZE_MB="${MAX_GIF_SIZE_MB:-5}" +SKIP_SECRET_SCAN="${SKIP_SECRET_SCAN:-0}" + +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +CYAN='\033[0;36m' +NC='\033[0m' + +say() { printf "${GREEN}[build-cover]${NC} %s\n" "$*"; } +warn() { printf "${YELLOW}[build-cover] WARN:${NC} %s\n" "$*" >&2; } +die() { printf "${RED}[build-cover] FATAL:${NC} %s\n" "$*" >&2; exit 1; } + +# ── preflight ─────────────────────────────────────────────────────────────── + +command -v python3 >/dev/null 2>&1 || die "python3 required (for version badge + secret scan)" +[ -f "${COVER_SRC}/index.html" ] || die "${COVER_SRC}/index.html not found (set COVER_SRC if not cover/)" + +REPO_ROOT="$(git rev-parse --show-toplevel 2>/dev/null || echo "$(pwd)")" +COMMIT_HASH="$(git -C "$REPO_ROOT" rev-parse --short HEAD 2>/dev/null || echo 'unknown')" +BUILD_TIME="$(date -u +%Y-%m-%dT%H:%M:%SZ)" + +# ── version ───────────────────────────────────────────────────────────────── + +VERSION="" +if [ -f "$REPO_ROOT/pyproject.toml" ]; then + VERSION="$(python3 -c " +import tomllib, pathlib +try: + data = tomllib.loads(pathlib.Path('$REPO_ROOT/pyproject.toml').read_text()) + print(data.get('project', {}).get('version', '')) +except Exception as e: + print('', end='') +" 2>/dev/null || echo '')" +fi + +if [ -z "$VERSION" ] && [ -f "$REPO_ROOT/package.json" ]; then + VERSION="$(python3 -c " +import json, pathlib +try: + data = json.loads(pathlib.Path('$REPO_ROOT/package.json').read_text()) + print(data.get('version', '')) +except Exception: + print('', end='') +" 2>/dev/null || echo '')" +fi + +if [ -z "$VERSION" ]; then + warn "no version found in pyproject.toml or package.json" +else + say "version: ${VERSION}" +fi + +# ── copy ───────────────────────────────────────────────────────────────────── + +say "wiping ${COVER_DIST}" +rm -rf "$COVER_DIST" +mkdir -p "$COVER_DIST" + +say "copying ${COVER_SRC} → ${COVER_DIST}" +# rsync skips dotfiles and gitignored paths, preserving structure +if command -v rsync >/dev/null 2>&1; then + rsync -a --exclude='.*' --exclude='*.psd' --exclude='*.sketch' \ + --exclude='*.xcf' --exclude='*.ai' "$COVER_SRC/" "$COVER_DIST/" +else + # fallback: cp, still skip dotfiles + (cd "$COVER_SRC" && find . -type f ! -path './.*' ! -name '.*' -print0) \ + | while IFS= read -r -d '' f; do + mkdir -p "$(dirname "$COVER_DIST/$f")" + cp "$COVER_SRC/$f" "$COVER_DIST/$f" + done +fi + +FILE_COUNT=$(find "$COVER_DIST" -type f | wc -l | tr -d ' ') +say "copied ${FILE_COUNT} files" + +# ── version badge injection ──────────────────────────────────────────────── + +if [ -n "$VERSION" ]; then + say "injecting version badge: v${VERSION}" + find "$COVER_DIST" -type f -name '*.html' -print0 | while IFS= read -r -d '' f; do + # replace {{VERSION}} placeholders + if grep -q '{{VERSION}}' "$f" 2>/dev/null; then + if [[ "$OSTYPE" == "darwin"* ]]; then + sed -i '' "s/{{VERSION}}/v${VERSION}/g" "$f" + else + sed -i "s/{{VERSION}}/v${VERSION}/g" "$f" + fi + fi + done +else + warn "skipping version injection (no version detected)" +fi + +# ── build metadata stamp ──────────────────────────────────────────────────── + +say "stamping build metadata (commit=${COMMIT_HASH})" +find "$COVER_DIST" -type f -name '*.html' -print0 | while IFS= read -r -d '' f; do + STAMP="" + if [[ "$OSTYPE" == "darwin"* ]]; then + sed -i '' "1s/^/${STAMP}\n/" "$f" + else + sed -i "1s/^/${STAMP}\n/" "$f" + fi +done + +# ── gif validation ────────────────────────────────────────────────────────── + +GIFS=$(find "$COVER_DIST" -type f -name '*.gif' 2>/dev/null || true) +if [ -n "$GIFS" ]; then + say "validating gif files (max ${MAX_GIF_SIZE_MB} MB)" + while IFS= read -r gif; do + SIZE_BYTES=$(stat -f%z "$gif" 2>/dev/null || stat -c%s "$gif" 2>/dev/null || echo 0) + SIZE_MB=$(( SIZE_BYTES / 1048576 )) + if [ "$SIZE_MB" -gt "$MAX_GIF_SIZE_MB" ]; then + die "gif too large: ${gif} (${SIZE_MB} MB > ${MAX_GIF_SIZE_MB} MB limit)" + fi + # nonzero frame count + if command -v gifsicle >/dev/null 2>&1; then + FRAMES=$(gifsicle --info "$gif" 2>/dev/null | grep -c 'image #' || echo 0) + if [ "$FRAMES" -eq 0 ]; then + die "gif has zero frames: ${gif}" + fi + say " ${gif##*/}: ${SIZE_MB} MB, ${FRAMES} frames" + else + say " ${gif##*/}: ${SIZE_MB} MB (install gifsicle for frame count)" + fi + done <<< "$GIFS" +else + say "no gif files to validate" +fi + +# ── secret scan ───────────────────────────────────────────────────────────── + +if [ "$SKIP_SECRET_SCAN" = "1" ]; then + warn "secret scan skipped (SKIP_SECRET_SCAN=1)" +else + say "running secret scan" + HITS=$(python3 -c " +import re, pathlib, sys + +root = pathlib.Path('$COVER_DIST') +# patterns that look like live secrets — broad enough to catch common leaks +# without excessive false positives on hex colors / base64 data uris +PATTERNS = [ + (r'sk-[a-zA-Z0-9]{32,}', 'Stripe secret key'), + (r'(?i)api[_-]?key[\"\': ]*[a-zA-Z0-9_\-]{20,}', 'API key in plaintext'), + (r'(?i)secret[\"\': ]*[a-zA-Z0-9_\-+/=]{20,}', 'secret in plaintext'), + (r'(?i)token[\"\': ]*[a-zA-Z0-9_\-+/=]{20,}', 'token in plaintext'), + (r'(?i)password[\"\': ]*[a-zA-Z0-9_\-!@#\$%^&*()]{8,}', 'password in plaintext'), + (r'-----BEGIN (RSA |EC |OPENSSH |DSA )?PRIVATE KEY-----', 'private key block'), + (r'ghp_[a-zA-Z0-9]{36}', 'GitHub personal access token'), + (r'(?i)cloudflare[._-]?(api[._-]?)?(token|key)[\"\': ]*[a-zA-Z0-9_\-]{20,}', 'Cloudflare credential'), + (r'AKIA[0-9A-Z]{16}', 'AWS access key ID'), +] + +total_hits = 0 +for f in root.rglob('*'): + if not f.is_file(): + continue + try: + text = f.read_text(errors='replace') + except Exception: + continue + for pattern, label in PATTERNS: + for m in re.finditer(pattern, text): + total_hits += 1 + ctx = text[max(0,m.start()-20):m.end()+20].replace('\n',' ') + print(f' HIT: {f.relative_to(root)} [{label}] …{ctx}…', file=sys.stderr) +if total_hits: + sys.exit(1) +else: + print(' clean') +" 2>&1) || die "secret scan found ${HITS} potential leak(s) — remove secrets from source and rebuild (or set SKIP_SECRET_SCAN=1)" + echo "$HITS" +fi + +# ── manifest + summary ────────────────────────────────────────────────────── + +say "build complete" +printf "${CYAN}%8s %s${NC}\n" "SIZE" "FILE" +find "$COVER_DIST" -type f | sort | while IFS= read -r f; do + SIZE=$(stat -f%z "$f" 2>/dev/null || stat -c%s "$f" 2>/dev/null || echo 0) + REL="${f#$COVER_DIST/}" + printf "%8s %s\n" "$(numfmt --to=iec "$SIZE" 2>/dev/null || echo "${SIZE}B")" "$REL" +done + +TOTAL=$(du -sh "$COVER_DIST" 2>/dev/null | cut -f1 || echo '?') +say "total dist size: ${TOTAL}" +say "ready for deploy → npx wrangler pages deploy ${COVER_DIST}/ --project-name --branch main" diff --git a/.claude/skills/run-actop/SKILL.md b/.agents/skills/run-actop/SKILL.md similarity index 100% rename from .claude/skills/run-actop/SKILL.md rename to .agents/skills/run-actop/SKILL.md diff --git a/.github/dependabot.yml b/.github/dependabot.yml index ee60575..105f705 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -1,7 +1,7 @@ # Dependabot keeps Python dependencies and pinned GitHub Actions current. # Free on public repos; update runs execute on GitHub's Dependabot infrastructure # and do NOT consume this repo's Actions minutes. PRs it opens go through the normal -# PR-only flow and are validated by main-ci. See docs/DESIGN-sdlc-cicd.md. +# PR-only flow and are validated by main-ci. version: 2 updates: # Python runtime + dev dependencies (declared in pyproject.toml). diff --git a/.gitignore b/.gitignore index f634aa4..8fd8696 100644 --- a/.gitignore +++ b/.gitignore @@ -139,5 +139,6 @@ ref_mactop/ # Temporary scratch and workspace files tmp/ -# Private launch/growth notes — kept local, never in origin -docs/RUNBOOK-launch-and-growth.md +# Landing page build output (generated by .agents/skills/publish-cover) +dist-cover/ +dist-cover.prev/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 4761787..ec930af 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,28 @@ This project follows a Keep a Changelog-style format and uses version tags for r ## [Unreleased] +### Changed +- **% readouts and the fan spinner tint by utilization color.** The headline % + in each metric row (and the fan-glyph) now wears the same severity color as + the sparkline that traces it — a 90% reading reads red, an idle 5% stays cool. + NO_COLOR / dumb terminals degrade to untinted, matching the charts. +- **README badge row** — PyPI version + supported Python versions, License, + and CI badges at the top. +- **Docs reorg**: skills consolidated under `.agents/skills/` + (`capture-tui-diagram`, `run-actop`, `record-tui-gif`, plus new + `publish-cover` for the landing-page cover); `docs/DESIGN-system.md` renamed + to `docs/SPEC-system.md`; the CI/CD + release runbook folded from the deleted + `docs/DESIGN-sdlc-cicd.md` into `CLAUDE.md` → Release Process; README / + CONTRIBUTING / SECURITY release references updated; SECURITY gains a + `HOMEBREW_TAP_TOKEN` handling section; `.gitignore` covers the + `dist-cover/` landing-page build output. + +### Added +- Tests covering `domain_throttling` on an unknown DVFS ceiling (never + throttling) and the die-temp fallback when the thermal state is nominal. + +## [1.6.6] - 2026-08-09 + ### Changed - **README hero GIF re-recorded** (`images/actop-demo.gif`) — refreshed after the GPU% process-table column landed; recorded under a live llama.cpp workload diff --git a/CLAUDE.md b/CLAUDE.md index ccf6a0e..5eb32c5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -30,8 +30,8 @@ This file is the single source of truth for repository guidelines, used by Claud | `actop/sampler.py` | `IOReportSampler`: subscription lifecycle, delta computation, `SampleResult` conversion, DVFS table discovery via native ctypes | | `actop/ioreport.py` | ctypes bindings to `libIOReport.dylib` and CoreFoundation (`IOReportSubscription`, `cfstr`, `cf_release`) | | `actop/smc.py` | SMC temperature reader: IOKit ctypes bindings to `AppleSMC`, key discovery, CPU/GPU die temperature reads | -| `actop/gpu_registry.py` | GPU accelerator registry reads via IOKit ctypes bindings: `get_gpu_time_by_pid()` sums `accumulatedGPUTime` off each `AGXDeviceUserClient`; `get_gpu_perf_stats()` reads device-level Device/Renderer/Tiler utilization % off the accelerator's own `PerformanceStatistics` dict (driver point reads — a fallback for `gpu_util_pct` plus the compute-vs-geometry breakdown IOReport cannot express; see `docs/DESIGN-system.md` §3.8) | -| `actop/utils.py` | L1 acquisition + platform discovery: native `ctypes` RAM/swap metrics and per-process CPU/GPU time collection (via `native_sys.py`), `sysctl`/`system_profiler` SoC info. Byte quantities cross layer seams as **raw bytes** (exact, prefix-free, Prometheus base-unit convention); GiB/MiB formatting is presentation and lives in `tui/`, and bandwidth stays decimal `GB/s` because that is Apple's unit for the bus — see `docs/DESIGN-system.md` §2.1. The `*_GB` keys and `rss_mb` are deprecated rounded views removed in 2.0.0. Holds no derived domain math | +| `actop/gpu_registry.py` | GPU accelerator registry reads via IOKit ctypes bindings: `get_gpu_time_by_pid()` sums `accumulatedGPUTime` off each `AGXDeviceUserClient`; `get_gpu_perf_stats()` reads device-level Device/Renderer/Tiler utilization % off the accelerator's own `PerformanceStatistics` dict (driver point reads — a fallback for `gpu_util_pct` plus the compute-vs-geometry breakdown IOReport cannot express; see `docs/SPEC-system.md` §3.8) | +| `actop/utils.py` | L1 acquisition + platform discovery: native `ctypes` RAM/swap metrics and per-process CPU/GPU time collection (via `native_sys.py`), `sysctl`/`system_profiler` SoC info. Byte quantities cross layer seams as **raw bytes** (exact, prefix-free, Prometheus base-unit convention); GiB/MiB formatting is presentation and lives in `tui/`, and bandwidth stays decimal `GB/s` because that is Apple's unit for the bus — see `docs/SPEC-system.md` §2.1. The `*_GB` keys and `rss_mb` are deprecated rounded views removed in 2.0.0. Holds no derived domain math | | `actop/analytics.py` | L2 domain analytics over acquired data points (imports only `models`/`power_scaling`, never `tui/*`): per-process power attribution (`attribute_power`), throttle detection (`domain_throttling`), bandwidth/package-power normalization, and the stateful `AlertEngine` (sustain-counted alerts + session-energy integral → `AlertFrame`) | | `actop/soc_profiles.py` | 16 built-in M1–M4 SoC profiles with power/bandwidth reference values; tier fallbacks for unknown chips | | `actop/power_scaling.py` | Power chart scaling: `auto` mode (rolling peak x1.25) vs `profile` mode (SoC reference wattage) | @@ -47,15 +47,82 @@ This file is the single source of truth for repository guidelines, used by Claud ## Release Process -`main` is **PR-only** (branch protection + `.githooks/pre-commit` redaction check and `.githooks/pre-push` guard; run `git config core.hooksPath .githooks` once). Bump the version + CHANGELOG via a PR, merge, then tag with `scripts/tag_release.sh [version]`. The Homebrew formula lives in the separate tap repo `binlecode/homebrew-actop` (not this repo); CI syncs it on tag and publishes to PyPI via OIDC. See `docs/DESIGN-sdlc-cicd.md` for the full runbook. +`main` is **PR-only** (branch protection + `.githooks/pre-commit` redaction check and `.githooks/pre-push` guard; run `git config core.hooksPath .githooks` once). The Homebrew formula lives in the separate tap repo `binlecode/homebrew-actop` (not this repo); CI syncs it on tag and publishes to PyPI via OIDC. CI/CD mechanics are owned by the workflow files themselves (`.github/workflows/main-ci.yml`, `release-formula.yml`, `publish-pypi.yml`) — each carries its one-time setup inline. + +### Release steps + +```bash +# 1. Bump version and changelog via a PR (never commit the bump to main) +git switch -c release-vX.Y.Z +# edit pyproject.toml ([project].version) and CHANGELOG.md (move Unreleased -> new version + date) +git commit -am "Release vX.Y.Z" && git push -u origin release-vX.Y.Z +gh pr create --base main --fill + +# 2. Run checks (locally and/or on the PR) +.venv/bin/python -m ruff check --fix . +.venv/bin/python -m ruff format . +.venv/bin/python -m actop.actop --help +.venv/bin/pytest -q + +# 3. Merge and sync +gh pr merge --merge --delete-branch +git checkout main && git pull --ff-only + +# 4. Tag the release (after the bump PR merges) +scripts/tag_release.sh "X.Y.Z" + +# 5. Monitor CI — wait for release-formula and publish-pypi to pass +gh run list -R binlecode/actop --limit 10 + +# 6. Verify +pipx install actop && actop --version # PyPI +brew update && brew upgrade binlecode/actop/actop # Homebrew (tap repo synced) +``` + +### Failure playbooks + +- **`tag_release.sh` fails — local `main` behind/diverged:** the bump PR may not be merged/pulled yet. + ```bash + git fetch origin && git checkout main && git pull --ff-only origin main + scripts/tag_release.sh "X.Y.Z" + ``` +- **`release-formula` fails with tag/version mismatch:** tag `vX.Y.Z` doesn't match `pyproject.toml` in the tag commit. Fix in a new PR, merge, then create a **new** tag (never reuse the old tag). +- **`release-formula` fails at tap checkout/push:** missing or under-scoped `HOMEBREW_TAP_TOKEN`. Confirm the secret exists on `binlecode/actop` and the PAT has **Contents: Read/write** on `binlecode/homebrew-actop`. Re-run the failed job. +- **`publish-pypi` fails with OIDC / trusted-publisher error:** verify the trusted publisher on pypi.org matches repo `binlecode/actop`, workflow `publish-pypi.yml`, environment `pypi`; confirm the run is from a `v*` tag (the `pypi` environment's deploy policy is tag-only). `skip-existing` makes re-runs safe. **Break-glass:** publish that version manually with the token-driven Flow B (`twine upload --skip-existing`), then restore OIDC for subsequent releases. +- **Emergency rerun without resource refresh:** `workflow_dispatch` on `release-formula` with `refresh_resources=false` as a temporary workaround; follow up with a normal run (`refresh_resources=true`). + +### PyPI publishing — two flows + +OIDC Trusted Publishing is the default (no stored secret; the `pypi` environment on `binlecode/actop` is tag-only, and the workflow file documents the one-time publisher registration). The token-driven flow is the fallback / bootstrap and the only place a long-lived PyPI credential appears: + +```bash +python -m build # produces dist/*.tar.gz and dist/*.whl +python -m twine upload --skip-existing dist/* +# username: __token__ password: +``` + +Prefer `TWINE_USERNAME=__token__` + `TWINE_PASSWORD` env vars (or `~/.pypirc`) so the token never hits shell history. **Hygiene:** once OIDC is confirmed on a release, delete the project-scoped token from pypi.org and `~/env-secrets/api_keys/` so no long-lived PyPI credential remains; regenerate only to bootstrap or break glass. + +### Homebrew Core submission (optional) + +To make `actop` natively trusted without users running `brew tap`, meet Homebrew Core's popularity bar (75+ stars, notable usage), run `brew audit --new-formula actop`, and open a PR to [homebrew-core](https://github.com/Homebrew/homebrew-core). + +### Quick reference + +```bash +gh run list -R binlecode/actop --limit 12 +gh run view -R binlecode/actop --log-failed +git ls-remote --tags origin "v*" +gh secret list -R binlecode/actop # confirm HOMEBREW_TAP_TOKEN +gh api /repos/binlecode/actop/environments/pypi/deployment-branch-policies +``` ## SDLC & Architectural Documentation The `docs/` directory contains essential system reviews, research, and operations guides: -- `docs/DESIGN-system.md`: Detailed system design reference — native bindings, sampling layer, SoC profile fallback, TUI rendering, testing contract. Kept in sync with the code on every PR that touches architecture. +- `docs/SPEC-system.md`: Detailed system design reference — native bindings, sampling layer, SoC profile fallback, TUI rendering, testing contract. Kept in sync with the code on every PR that touches architecture. - `docs/REVIEW-architecture-comparison.md`: Performance and architectural comparison between `actop` (Python) and `mactop` (Go). - `docs/REVIEW-tui-frameworks.md`: Analysis of modern Python TUI frameworks and selection of Textual. -- `docs/DESIGN-sdlc-cicd.md`: CI/CD bottling and tap release operational runbook. - `docs/TODO-architecture-roadmap.md`: Open hardware/metric-coverage gaps and their priority. **Conformance auditing:** the `/audit-conformance` skill (`.claude/skills/audit-conformance/`) periodically judgment-scans the whole tree against 12 coding rules (layering, dead code, DRY, naming, swallowed errors) — the whole-codebase counterpart to diff-scoped `/code-review`. It writes an actionable `docs/TODO-conformance-YYYY-MM-DD.md` and never proposes structural/guard tests (they would violate the functional-tests-only mandate). @@ -149,7 +216,7 @@ a mount point, not a fake); faking the data or the logic under test is not. ## Commit & Pull Request Guidelines - **Branch from `main`; PR strictly into `main`.** Every branch forks from `main` and targets `main`. **Never fork a feature branch off another feature branch** (no stacked PRs): if you need work that is still on an unmerged branch, wait for it to merge and re-branch from `main`. This holds especially for CI/CD and release changes — they land via a single PR into `main`, never a chained branch. -- **Bump the version in every PR.** Each PR updates `pyproject.toml` version + moves `CHANGELOG.md` `[Unreleased]` into a new dated section, in the same PR: **patch bump by default, minor only for a milestone PR** (major reserved for breaking API/CLI changes). Tagging (`scripts/tag_release.sh`) is a separate step after merge and is what publishes — see `docs/DESIGN-sdlc-cicd.md`. +- **Bump the version in every PR.** Each PR updates `pyproject.toml` version + moves `CHANGELOG.md` `[Unreleased]` into a new dated section, in the same PR: **patch bump by default, minor only for a milestone PR** (major reserved for breaking API/CLI changes). Tagging (`scripts/tag_release.sh`) is a separate step after merge and is what publishes — see the Release Process section above. - Use concise, imperative commit subjects (as seen in history), e.g. `Add support for M1 Ultra` or `actop/utils.py: add bandwidth of M2`. - Keep commits scoped to one logical change. - Before every commit and before every push, always run: diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 008db9e..67271b0 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -53,9 +53,8 @@ full accept/reject rules. ## Where to look - `CLAUDE.md` — full contributor guidelines and coding conventions. -- `docs/DESIGN-system.md` — architecture: native bindings, sampling, SoC +- `docs/SPEC-system.md` — architecture: native bindings, sampling, SoC profiles, TUI rendering, testing contract. -- `docs/DESIGN-sdlc-cicd.md` — CI/CD and release runbook (maintainers). By contributing, you agree that your contributions are licensed under the project's [MIT License](LICENSE). diff --git a/README.md b/README.md index 62ae106..f944f41 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,10 @@ # actop +[![PyPI version](https://img.shields.io/pypi/v/actop.svg)](https://pypi.org/project/actop/) +[![Python versions](https://img.shields.io/pypi/pyversions/actop.svg)](https://pypi.org/project/actop/) +[![License: MIT](https://img.shields.io/github/license/binlecode/actop.svg)](LICENSE) +[![CI](https://github.com/binlecode/actop/actions/workflows/main-ci.yml/badge.svg)](https://github.com/binlecode/actop/actions/workflows/main-ci.yml) + **Watch your Apple Silicon Mac the way it actually works — and profile your own workloads from Python.** `actop` is a sudoless, in-process performance monitor for M1–M4 Macs: a real-time @@ -377,7 +382,7 @@ and the functional-tests-only policy (`CLAUDE.md` is the full source of truth). ## Release -See `GUIDE-release-operations.md` for the full runbook. +See `CLAUDE.md` → Release Process for the full runbook (versioning, tag flow, failure playbooks). ```bash # 1. Bump version and changelog diff --git a/SECURITY.md b/SECURITY.md index 17637fe..f9f0638 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -35,5 +35,34 @@ release before reporting. ## Handling Verified reports are fixed through the normal **PR-only** flow and shipped as a patch -release (see [`docs/DESIGN-sdlc-cicd.md`](docs/DESIGN-sdlc-cicd.md)). We credit -reporters unless you ask to remain anonymous. +release (see `CLAUDE.md` → Release Process). We credit reporters unless you ask to +remain anonymous. + +## Release-path secrets (`HOMEBREW_TAP_TOKEN`) + +`HOMEBREW_TAP_TOKEN` is the **only** long-lived secret in the release path (PyPI uses +tokenless OIDC). Treat it accordingly: + +- **Least privilege — prefer a fine-grained PAT.** Scope it to **only** + `binlecode/homebrew-actop` with **Contents: Read/write** and nothing else. A classic + PAT (`repo`, `workflow` scopes) *works*, but its blast radius is every repo the account + owns — if the CI secret leaks, so does write access to all of them. The fine-grained + token caps the damage at the tap repo. +- **Set an expiry and rotate.** Give the token a bounded lifetime (e.g. 90–180 days) and + rotate on schedule or on any suspicion of exposure. Rotation is a one-liner — no + workflow change needed: + ```bash + # paste the new token at the prompt; never pass it as a CLI arg + gh secret set HOMEBREW_TAP_TOKEN --repo binlecode/actop + ``` +- **Never put the token in a command argument.** Pipe it via **stdin** (as above) so it + stays out of `ps`/argv and shell history. Do **not** use `--body ""`, and do not + `echo` it. `gh secret set` encrypts the value client-side (libsodium sealed box) with + the repo's Actions public key *before* upload over TLS, so GitHub stores only ciphertext + and never sees the plaintext. +- **Logs are masked, not a safety net.** GitHub auto-redacts the registered secret value + in workflow logs, but obfuscation tricks can defeat masking — the real protections are + least-privilege scope + `main` being PR-only (fork-PR runs don't receive secrets). +- **Storage.** The plaintext copy lives only in `~/env-secrets/` (never in the repo); + GitHub keeps the encrypted-at-rest copy. When you rotate, revoke the old token on + GitHub and update `~/env-secrets/`. diff --git a/actop/models.py b/actop/models.py index 0e9818c..29df4c5 100644 --- a/actop/models.py +++ b/actop/models.py @@ -108,7 +108,7 @@ class SystemSnapshot: # than a render-time derivation. ane_util_pct: float = 0.0 # GPU utilization from IOAccelerator PerformanceStatistics (driver point - # reads, no interval integration — see docs/DESIGN-system.md §3.8). + # reads, no interval integration — see docs/SPEC-system.md §3.8). # gpu_util_pct above remains the interval-integrated primary metric. # Renderer vs Tiler separates shader/compute work from geometry work: an # MLX/CoreML compute frame shows Renderer high with Tiler near zero. diff --git a/actop/tui/widgets.py b/actop/tui/widgets.py index 514d084..0602348 100644 --- a/actop/tui/widgets.py +++ b/actop/tui/widgets.py @@ -29,7 +29,7 @@ # gradient byte-for-byte; `viridis` is a colorblind-safe perceptual ramp; `mono` # is grayscale intensity for a monochrome / high-contrast preference. Dict # insertion order is the order a future runtime cycle keybind would advance -# through (deliberately deferred — see docs/DESIGN-system.md §5.2). +# through (deliberately deferred — see docs/SPEC-system.md §5.2). _PALETTES = { "thermal": [_COOL_RGB, _HOT_RGB], "viridis": [ @@ -569,6 +569,10 @@ def __init__(self, config, **kwargs) -> None: # Gradient palette, fixed for the session (--palette). Passed eagerly to # every chart at compose time, exactly like _chart_glyph. self._palette = getattr(cfg, "palette", _DEFAULT_PALETTE) + # Color tier for the %-readout and fan-glyph tinting. None => resolve + # lazily from the running app's console once mounted (like BrailleChart), + # falling back to environment detection before then. + self._color_mode = None requested = getattr(cfg, "layout", "grid") if requested not in self._VALID_PRESETS: @@ -635,6 +639,24 @@ def __init__(self, config, **kwargs) -> None: def on_mount(self) -> None: self.set_interval(_FAN_SPINNER_TICK_SECONDS, self._tick_fan_spinners) + if self._color_mode is None: + self._color_mode = resolve_color_mode(getattr(self.app, "console", None)) + + def _active_color_mode(self) -> str: + if self._color_mode is not None: + return self._color_mode + return resolve_color_mode() + + def _util_color(self, pct: float) -> str: + """Chart color for a percent, for the readout text that names that value. + + The headline % in each metric row (and the fan spinner glyph) wears the + same color as the sparkline that traces it — the chart paints the + hottest sample's color, so a 90% reading shows the same red the chart + does for that column, and an idle 5% stays at the cool end. NO_COLOR / + dumb terminals degrade to an empty style (no tint), like the charts. + """ + return _pct_to_color(pct, self._active_color_mode(), self._palette) def _tick_fan_spinners(self) -> None: if not self._last_fans: @@ -654,21 +676,46 @@ def _tick_fan_spinners(self) -> None: if advanced: self._render_fan_label() + def _fan_util_percent(self) -> float | None: + """Average fan speed as a percent of max across fans that report a max. + + Fans without a max-RPM key contribute nothing (a lone unknown-max fan + must not floor the colour to 0). None when no fan exposes a max — the + glyph then renders untinted rather than inventing a severity. + """ + ratios = [ + fan.current / fan.max for fan in self._last_fans if fan.max and fan.max > 0 + ] + if not ratios: + return None + return sum(ratios) / len(ratios) * 100.0 + def _render_fan_label(self) -> None: frames = _FAN_SPINNER_FRAMES + util = self._fan_util_percent() + glyph_style = self._util_color(util) if util is not None else "" if self._last_fans: - rpm_text = " · ".join( - "{} {}".format( - frames[self._fan_spin_idx[i] % len(frames)], - f"{fan.current:.0f}/{fan.max:.0f}" + rpm_text = Text() + for i, fan in enumerate(self._last_fans): + if i: + rpm_text.append(" · ") + # The spinner glyph wears the chart's color for the fan's + # utilization (high RPM = red, idle = cool), matching the % and + # sparkline treatment; the RPM figures stay plain. + rpm_text.append( + frames[self._fan_spin_idx[i] % len(frames)], style=glyph_style + ) + rpm_text.append( + f" {fan.current:.0f}/{fan.max:.0f}" if fan.max - else f"{fan.current:.0f}", + else f" {fan.current:.0f}" ) - for i, fan in enumerate(self._last_fans) - ) else: - rpm_text = "0" - self.query_one("#fan-label", Static).update(f"Fan {rpm_text} RPM") + rpm_text = Text("0") + line = Text("Fan ") + line.append_text(rpm_text) + line.append(" RPM") + self.query_one("#fan-label", Static).update(line) def compose(self) -> ComposeResult: cfg = self._config @@ -1012,13 +1059,14 @@ def update_metrics(self, message: MetricsUpdated) -> None: # rather than IOReport residency: in that case the GPU DVFS table could # not be classified, so there is no trustworthy frequency to print and # the percent's provenance differs from every other gauge here. + gpu_label = Text("GPU ") + gpu_label.append(f"{gpu}%", style=self._util_color(gpu)) if s.gpu_util_source == "ioaccelerator": - gpu_reading = f"GPU {gpu}% (drv)" + gpu_label.append(" (drv)") else: - gpu_reading = f"GPU {gpu}% @{s.gpu_freq_mhz}MHz" - self.query_one("#gpu-label", Static).update( - f"{gpu_reading}{gpu_temp}{self._pct_stats_suffix(self._gpu_hist)}" - ) + gpu_label.append(f" @{s.gpu_freq_mhz}MHz") + gpu_label.append(f"{gpu_temp}{self._pct_stats_suffix(self._gpu_hist)}") + self.query_one("#gpu-label", Static).update(gpu_label) if cfg.show_residency: self.query_one("#gpu-residency-row", Static).update( _format_residency_row("GPU", s.gpu_residency_pct) @@ -1035,9 +1083,12 @@ def update_metrics(self, message: MetricsUpdated) -> None: f"Rend {clamp_percent(s.gpu_renderer_pct)}%" f" · Tiler {clamp_percent(s.gpu_tiler_pct)}%" ) - self.query_one("#ane-label", Static).update( - f"ANE {ane_pct}% ({s.ane_watts:.1f}W){self._pct_stats_suffix(self._ane_hist)}" + ane_label = Text("ANE ") + ane_label.append(f"{ane_pct}%", style=self._util_color(ane_pct)) + ane_label.append( + f" ({s.ane_watts:.1f}W){self._pct_stats_suffix(self._ane_hist)}" ) + self.query_one("#ane-label", Static).update(ane_label) # The snapshot carries raw bytes; formatting into GiB is presentation, so # it happens here. GiB (not GB) because these are 2^30 divisions — the @@ -1166,8 +1217,15 @@ def _update_cluster_summary_row( """Render one full-width cluster summary line.""" widget = self.query_one(widget_id, Static) avail = max(widget.size.width, 1) - line = f"{label} {util_pct:3d}% @{freq_mhz}MHz{cpu_temp}{stats_suffix}" - widget.update(line[:avail].ljust(avail)) + line = Text(f"{label} ") + # The % wears the chart's color for that value (see _util_color) so the + # readout and its tracer agree at a glance; the MHz/temp/avg/max context + # stays muted plain text. + line.append(f"{util_pct:3d}%", style=self._util_color(util_pct)) + line.append(f" @{freq_mhz}MHz{cpu_temp}{stats_suffix}") + line = line[:avail] + line.pad_right(max(0, avail - line.cell_len)) + widget.update(line) # Inline power spark bounds: keep the spark legible (>= 8 chars) but never # let a wide terminal turn a one-line row into a full chart (cap 24), the diff --git a/docs/DESIGN-sdlc-cicd.md b/docs/DESIGN-sdlc-cicd.md deleted file mode 100644 index a6ac933..0000000 --- a/docs/DESIGN-sdlc-cicd.md +++ /dev/null @@ -1,352 +0,0 @@ -# SDLC, CI/CD & Release Design - -This document serves three purposes: - -1. **SDLC governance** — the rules every change follows: branching, versioning, review gates, guardrails (hooks + GitHub-native scanning), dependencies, and the functional-tests mandate. -2. **Design / tutorial** — explains the Homebrew packaging model, CI/CD component ownership, the CI validation matrix, and the end-to-end release flow so that new contributors (human or AI) can understand how releases work without reading CI workflow files. -3. **Runbook** — provides step-by-step release instructions, operational rules, and failure recovery playbooks for day-to-day release execution. - -> **Architecture note (since v1.0.0):** `main` of `binlecode/actop` is **strictly PR-only** (branch protection + `enforce_admins` + `.githooks/pre-commit` redaction check + `.githooks/pre-push` guard). CI never pushes to `main`. The Homebrew formula lives in a **separate tap repo**, `binlecode/homebrew-actop`, and the release workflow syncs it there using a **token-driven** push (`HOMEBREW_TAP_TOKEN`). PyPI publishing supports **two flows**: the current **tokenless OIDC Trusted Publishing** flow and a legacy **token-driven** (`twine` + API token) flow retained as fallback — both are detailed below. - -## SDLC Rules & Guardrails - -The rules below bind every change. They are enforced by a mix of local hooks, GitHub -branch protection, GitHub-native scanning, and CI — with `CLAUDE.md` as the canonical -short-form rules file for AI/human contributors and this section as the rationale. - -### Branching & PR model - -- **Branch from `main`; PR strictly into `main`.** Every branch forks from `main` and - targets `main`. **Never fork a feature branch off another feature branch** (no stacked - PRs): if you need work that's still on an unmerged branch, wait for it to land and - re-branch from `main`. This holds especially for CI/CD and release changes. -- **One logical change per branch**, with concise imperative commit subjects. -- **`main` is PR-only**, enforced three ways: server-side branch protection - (`enforce_admins=true`), a local `.githooks/pre-push` guard, and the fact that CI never - pushes to `main`. - -### Versioning (patch-per-PR) - -- **Every PR bumps the version** in `pyproject.toml` **and** moves `CHANGELOG.md` - `[Unreleased]` into a new dated section — **in the same PR**. There is no separate - "release-only" PR; each merged PR is a releasable increment. -- **Patch bump by default** (`X.Y.Z` → `X.Y.(Z+1)`). **Minor bump only for milestone - PRs** (a headline feature / flagship differentiator). Majors are reserved for breaking - public-API/CLI changes. -- **Tagging is a separate, explicit step after merge** (`scripts/tag_release.sh`), which - triggers the PyPI + Homebrew publish. Bumping the version in a PR does **not** publish; - merging without tagging just stages the next release. - -### Guardrails - -| Guardrail | Mechanism | Scope | -| --- | --- | --- | -| No direct push to `main` | branch protection + `.githooks/pre-push` | push time | -| CI must pass before merge | branch protection **required status checks** — the four `validate (3.11–3.14)` contexts, strict (branch up-to-date); admins included (`enforce_admins`). A red or missing check blocks merge | merge time | -| No committed secrets | `.githooks/pre-commit` redaction (length-anchored patterns) **and** GitHub **secret scanning + push protection** (free on public repos; the backstop for clones that never ran `core.hooksPath`) | commit + push time | -| Hooks activation | `git config core.hooksPath .githooks` — **run once per clone** (fresh clones have no hooks until set) | first clone | -| Lint / format / tests | `main-ci.yml` on every PR: `ruff check` + `ruff format --check` + `pytest -m "not local"` across Python 3.11–3.14 (+ 3.15 canary) | PR | -| Functional tests only | `CLAUDE.md` "Testing Guidelines" (no structural/mock-the-data/coverage-only tests); reviewed at PR and swept whole-tree by `/audit-conformance` | PR + periodic | -| Supply chain: pinned Actions | every workflow `uses:` pinned to a **full commit SHA** (with a `# vX.Y.Z` comment) — no mutable tag/branch refs; Dependabot's `github-actions` ecosystem keeps the pins current via the comment | build time | -| Dependency freshness / CVEs | `.github/dependabot.yml` (weekly `pip` + `github-actions`, grouped minor/patch) **plus** GitHub **Dependabot alerts + security updates** (auto-PRs for vulnerable deps) | scheduled + on-advisory | -| PR completeness | `.github/PULL_REQUEST_TEMPLATE.md` (validation commands, Apple-Silicon run, functional-tests attestation) | PR | -| Vulnerability disclosure | `SECURITY.md` + GitHub **private vulnerability reporting** (enabled; the "Report a vulnerability" button under the Security tab) | ongoing | - -> **GitHub security posture (maintainer, public-repo, free — all enabled as of -> 2026-07-02):** Secret scanning + push protection, Dependabot **alerts** and -> **security updates**, Private vulnerability reporting, and branch-protection -> **required status checks** on `main`. These are platform features and consume **no** -> Actions minutes. Two deeper secret-scanning toggles (validity checks, non-provider -> patterns) are UI-only (not settable via API) and remain optional. Verify current -> state with `gh api repos/binlecode/actop --jq .security_and_analysis` and -> `gh api repos/binlecode/actop/branches/main/protection`. - -### Conformance auditing - -`/code-review` catches what a *diff* introduces; the **`/audit-conformance`** skill -(`.claude/skills/audit-conformance/`) is the whole-tree counterpart — it periodically -scans the codebase against 12 coding rules (layering, dead code, DRY, naming, swallowed -errors) and writes an actionable `docs/TODO-conformance-YYYY-MM-DD.md`. By doctrine it -**never** proposes structural/guard tests (they would violate the functional-tests -mandate); fixes flow through the normal branch → PR → `/code-review` → merge cycle. - -## Homebrew Packaging Model - -`actop` uses Homebrew's Python virtualenv formula pattern: - -- The formula (`Formula/actop.rb`) lives in the tap repo **`binlecode/homebrew-actop`**, not in this code repo. -- It includes `Language::Python::Virtualenv` and depends on `python@3.13`. -- `virtualenv_install_with_resources` creates a `libexec` venv and pip-installs the declared `resource` blocks (`textual` and its dependencies: `rich`, `markdown-it-py`, `mdurl`, `pygments`, `linkify-it-py`, `uc-micro-py`, `mdit-py-plugins`, `platformdirs`, `typing-extensions`). -- Users install with `brew tap binlecode/actop && brew install actop` (Homebrew maps the tap name `binlecode/actop` → `binlecode/homebrew-actop`). - -## Component Ownership - -| Component | Responsibility | -| --- | --- | -| **Maintainer** | Bumps `pyproject.toml` version + `CHANGELOG.md` **via a PR**, runs local checks, merges, then triggers the release tag | -| **`scripts/tag_release.sh`** | Verifies clean tree, validates version vs `pyproject.toml`, confirms local `main` matches `origin/main`, creates and pushes `vX.Y.Z` tag. Does **not** modify any formula | -| **`main-ci.yml`** | Runs on `main` push **and on `pull_request` to `main`**. `validate` job runs a Python **matrix (3.11, 3.12, 3.13, 3.14)**: installs `-e .` + `ruff`/`pytest`, runs `ruff check` + `ruff format --check`, runs `pytest -m "not local"` (CI-safe tests only), then the `--help` CLI smoke test. A non-blocking `canary-next-python` job (`continue-on-error`) repeats the checks on pre-release **3.15** for early warning. (Required for PR validation.) | -| **`release-formula.yml`** | Runs on `v*` tag push (or manual `workflow_dispatch`). Runs on `macos-latest` under a `formula-sync-tap` concurrency group. Validates tag/version match **from the tag commit**, computes the source tarball SHA256, updates `url` + `sha256` (and regenerates `resource` blocks via `brew update-python-resources` unless `refresh_resources=false`) in **`binlecode/homebrew-actop`**, and pushes there using the `HOMEBREW_TAP_TOKEN` secret (**token-driven**). Never touches `main` of this repo | -| **`publish-pypi.yml`** | Runs on `v*` tag push (or manual). Builds on Python 3.12 in the `pypi` environment with `id-token: write`. Validates tag/version, builds sdist+wheel, publishes to PyPI via **OIDC Trusted Publishing** (`skip-existing`). The `pypa/gh-action-pypi-publish` action is **pinned to a commit SHA** (was the mutable `@release/v1` branch) — critical since this job holds PyPI publish rights. See both PyPI flows below | - -> **Action pinning (since v1.4.5):** every `uses:` across `main-ci.yml`, -> `publish-pypi.yml`, and `release-formula.yml` is pinned to a full commit SHA with a -> `# vX.Y.Z` comment (`actions/checkout`, `actions/setup-python`, -> `pypa/gh-action-pypi-publish`). This removes the mutable-ref supply-chain risk — a -> compromised upstream tag/branch can no longer inject code into CI or the -> OIDC-privileged publish job. Bumps arrive as Dependabot `github-actions` PRs; keep the -> version comment in sync with the SHA when merging them. - -### One-time setup (prerequisites) - -- **Tap repo:** `binlecode/homebrew-actop` exists with `Formula/actop.rb`. -- **`HOMEBREW_TAP_TOKEN`** secret on `binlecode/actop`: a fine-grained PAT scoped to `binlecode/homebrew-actop` with **Contents: Read/write**. Set via `gh secret set HOMEBREW_TAP_TOKEN --repo binlecode/actop`. -- **PyPI Trusted Publishing (OIDC):** see the dedicated section below. The GitHub `pypi` environment is already created (tag-only deploys); the one remaining manual step is adding the trusted publisher on pypi.org. - -### Secret hygiene (`HOMEBREW_TAP_TOKEN`) - -`HOMEBREW_TAP_TOKEN` is the **only** long-lived secret in the release path (PyPI uses tokenless OIDC). Treat it accordingly. - -- **Least privilege — prefer a fine-grained PAT.** Scope it to **only** `binlecode/homebrew-actop` with **Contents: Read/write** and nothing else. A classic PAT (`repo`, `workflow` scopes) *works*, but its blast radius is every repo the account owns — if the CI secret leaks, so does write access to all of them. The fine-grained token caps the damage at the tap repo. -- **Set an expiry and rotate.** Give the token a bounded lifetime (e.g. 90–180 days) and rotate on schedule or on any suspicion of exposure. Rotation is a one-liner — no workflow change needed: - ```bash - # paste the new token at the prompt; never pass it as a CLI arg - gh secret set HOMEBREW_TAP_TOKEN --repo binlecode/actop - ``` -- **Never put the token in a command argument.** Pipe it via **stdin** (as above) so it stays out of `ps`/argv and shell history. Do **not** use `--body ""`, and do not `echo` it. `gh secret set` encrypts the value client-side (libsodium sealed box) with the repo's Actions public key *before* upload over TLS, so GitHub stores only ciphertext and never sees the plaintext. -- **Logs are masked, not a safety net.** GitHub auto-redacts the registered secret value in workflow logs, but obfuscation tricks can defeat masking — the real protections are least-privilege scope + `main` being PR-only (fork-PR runs don't receive secrets). -- **Storage.** The plaintext copy lives only in `~/env-secrets/` (never in the repo); GitHub keeps the encrypted-at-rest copy. When you rotate, revoke the old token on GitHub and update `~/env-secrets/`. - -> **Current state (v1.0.0):** the secret was bootstrapped from a **classic** PAT (`repo` + `workflow`). Replacing it with a tap-scoped **fine-grained** token is the recommended hardening follow-up. - -## CI Validation (`main-ci.yml`) - -Every push to `main` and every PR targeting `main` runs `main-ci.yml`. It has two jobs: - -- **`validate`** (required) — a Python matrix over **3.11, 3.12, 3.13, 3.14** (`fail-fast: false`, so one version's failure does not cancel the others). Each leg: - 1. installs the package editable (`pip install -e .`) plus `ruff` and `pytest`; - 2. `ruff check .` and `ruff format --check .` (lint + format gate); - 3. `pytest -m "not local"` — runs the **CI-safe** subset only. Host-dependent tests (SMC/IOReport/Apple-Silicon-specific) are marked `@pytest.mark.local` and skipped in CI because the GitHub runners are not Apple Silicon; - 4. `python -m actop.actop --help` smoke test. -- **`canary-next-python`** (non-blocking, `continue-on-error: true`) — repeats install + `pytest -m "not local"` + `--help` on the **pre-release 3.15** toolchain. Since `requires-python` is uncapped (`>=3.11`), this is an early-warning signal for breakage on the next CPython before it ships; a red canary never blocks a merge. - -> The Homebrew formula pins `python@3.13`; the matrix is the set actively verified, not a compatibility cap. - -## End-to-End Flow - -```text -PR: bump pyproject.toml version + CHANGELOG.md - -> main-ci (pull_request) # matrix lint/format/test/help must pass - -> merge PR to main (squash/merge) - -> main-ci (push to main) # same checks on the merge commit - -> git pull --ff-only # local main now has the bump - -> scripts/tag_release.sh # pushes tag vX.Y.Z only (main is already in sync) - -> release-formula (tag push) -> formula sync commit in binlecode/homebrew-actop [token-driven: HOMEBREW_TAP_TOKEN] - -> publish-pypi (tag push) -> sdist+wheel published to PyPI [OIDC by default; token-driven fallback] -``` - -## PyPI Publishing — two flows - -Publishing to PyPI can authenticate two ways. `actop` uses **OIDC Trusted Publishing** -as the default (no stored secret); the **token-driven** flow is documented as the -fallback and as the mechanism used to bootstrap the project name. Both produce the -identical artifacts (`sdist` + `wheel` from `python -m build`) and both should keep -`skip-existing: true` so re-running a tag whose version already exists is a safe no-op. - -| | **OIDC Trusted Publishing (default)** | **Token-driven (fallback / bootstrap)** | -| --- | --- | --- | -| Credential | Short-lived OIDC identity minted per run | Long-lived PyPI API token stored as a secret | -| Stored secret | **None** | `PYPI_API_TOKEN` on `binlecode/actop` | -| Trust anchor | repo + workflow + environment must match the PyPI publisher | possession of the token | -| Setup | one-time publisher registration on pypi.org (browser) | generate a project-scoped token on pypi.org, store via `gh secret set` | -| Blast radius if compromised | none to steal — nothing at rest | token = publish rights until revoked | -| When used | every `v*` tag release | emergency fallback, or first-ever upload to claim the name | - -### Flow A — OIDC Trusted Publishing (current default) - -GitHub Actions mints a short-lived OIDC identity that PyPI verifies against the exact -repo + workflow + environment. `publish-pypi.yml` (with `id-token: write`, -`environment: pypi`) uses `pypa/gh-action-pypi-publish@release/v1` to do this on every -`v*` tag — **no API token is stored anywhere**. - -**GitHub side — already configured (no action needed):** -- Environment **`pypi`** exists on `binlecode/actop`, and the job declares - `permissions: id-token: write` (required to mint the OIDC token). -- Deployment policy: **custom, tag-only** — only refs matching **`v*` (type: tag)** - can deploy to the `pypi` environment, so no branch can ever trigger a publish. - Recreate/inspect with: - ```bash - gh api /repos/binlecode/actop/environments/pypi - gh api /repos/binlecode/actop/environments/pypi/deployment-branch-policies - ``` -- *(Optional hardening)* add yourself as a required reviewer on the `pypi` - environment for a manual approve-gate before each publish. - -**PyPI side — one-time manual step (must be done in the browser as the project owner):** -PyPI has no token-authenticated API for managing trusted publishers, so add it via the UI: -1. Go to → **Add a new publisher** (GitHub). -2. Enter these values **exactly** (they must match the workflow): - | Field | Value | - | --- | --- | - | Owner | `binlecode` | - | Repository name | `actop` | - | Workflow name | `publish-pypi.yml` | - | Environment name | `pypi` | - | PyPI Project Name | `actop` | - (If the project didn't yet exist you'd add it as a *pending* publisher; `actop` - exists as of 1.0.0, so it's a normal publisher.) - -### Flow B — Token-driven publish (fallback / bootstrap) - -Used to **bootstrap** the project (`1.0.0` was uploaded this way to claim the name), -and retained as a break-glass fallback if OIDC is unavailable (e.g. PyPI publisher -config drift, or publishing from a machine/CI without OIDC). - -**Manual, from a trusted machine** (token lives only in `~/env-secrets/`, never in argv): -```bash -python -m build # produces dist/*.tar.gz and dist/*.whl -python -m twine upload --skip-existing dist/* -# username: __token__ password: -``` -Prefer a `~/.pypirc` or `TWINE_USERNAME=__token__` + `TWINE_PASSWORD` env var over -typing the token so it stays out of shell history. - -**In CI (only if OIDC must be bypassed):** store a **project-scoped** token as a secret -and pass it to the same publish action — never commit it, never echo it: -```bash -# paste the token at the prompt; stdin keeps it out of argv/history -gh secret set PYPI_API_TOKEN --repo binlecode/actop -``` -```yaml -# variant of the Publish step in publish-pypi.yml (drop id-token/environment) -- name: Publish to PyPI (token) - uses: pypa/gh-action-pypi-publish@release/v1 - with: - password: ${{ secrets.PYPI_API_TOKEN }} - skip-existing: true -``` - -**Migration / hygiene note:** OIDC supersedes the bootstrap token. Once trusted -publishing is confirmed on a release, **delete the account/project-scoped token** from -pypi.org and from `~/env-secrets/api_keys/` so no long-lived PyPI credential remains. -Only regenerate one if you must invoke Flow B again. - -## Release Steps - -### 1. Bump version and changelog via a PR - -`main` is PR-only — do not commit the bump directly. - -```bash -git switch -c release-vX.Y.Z -# edit pyproject.toml ([project].version) and CHANGELOG.md (move Unreleased -> new version + date) -git commit -am "Release vX.Y.Z" -git push -u origin release-vX.Y.Z -gh pr create --base main --fill -``` - -### 2. Run checks (locally and/or on the PR) - -```bash -.venv/bin/python -m ruff check --fix . -.venv/bin/python -m ruff format . -.venv/bin/python -m actop.actop --help -.venv/bin/pytest -q -``` - -### 3. Merge and sync - -```bash -gh pr merge --merge --delete-branch -git checkout main && git pull --ff-only -``` - -### 4. Tag the release - -```bash -scripts/tag_release.sh "X.Y.Z" -``` - -### 5. Monitor CI - -```bash -gh run list -R binlecode/actop --limit 10 -``` - -Wait for `release-formula` and `publish-pypi` to complete successfully. - -### 6. Verify - -```bash -# PyPI -pipx install actop && actop --version # or: pip install actop - -# Homebrew (formula now updated in the tap repo) -brew update && brew upgrade binlecode/actop/actop -brew info binlecode/actop/actop -``` - -## Rules - -**Do:** -- **Branch from `main` and PR strictly into `main`** — one logical change per branch. **Never fork a feature branch off another feature branch** (no stacked PRs); if you need unmerged work, wait for it to land and re-branch from `main`. CI/CD and release changes in particular go in via a single PR to `main`. -- **Bump the version + CHANGELOG in the same PR** (never push `main` directly): patch by default, minor for a milestone PR. See *SDLC Rules → Versioning*. -- Use `scripts/tag_release.sh` for all release tags **after** the bump PR merges. -- Let `release-formula.yml` own formula sync commits (in the tap repo). -- Verify `release-formula` and `publish-pypi` after each release. -- Keep `refresh_resources=true` (default) for normal releases. - -**Do not:** -- Push directly to `main` (blocked by branch protection + pre-push hook). -- Manually edit `Formula/actop.rb` in the tap repo during releases. -- Push a tag before the version/changelog PR is merged. -- Force-push `main` (ever). -- Disable resource refresh except for emergency reruns. - -## Homebrew Core Submission Guide - -To make `actop` natively trusted without users running `brew tap`, it can be submitted to Homebrew Core: - -1. **Popularity Requirements**: meet Homebrew Core's bar (usually 75+ stars, 30+ watchers, notable usage). -2. **Formula Audit**: `brew audit --new-formula actop`. -3. **Open a PR** to [homebrew-core](https://github.com/Homebrew/homebrew-core). Once merged, `actop` becomes an official formula, implicitly trusted globally. - -## Failure Playbooks - -### `tag_release.sh` fails because local `main` is behind/diverged - -The version-bump PR may not be merged/pulled yet. - -```bash -git fetch origin && git checkout main && git pull --ff-only origin main -scripts/tag_release.sh "X.Y.Z" -``` - -### `release-formula` fails with tag/version mismatch - -Tag `vX.Y.Z` does not match `pyproject.toml` in the tag commit. Fix the version in a new PR, merge, then create a new tag (do not reuse the old tag). - -### `release-formula` fails at the tap checkout / push - -Usually a missing or under-scoped `HOMEBREW_TAP_TOKEN`. Confirm the secret exists on `binlecode/actop` and the PAT has **Contents: Read/write** on `binlecode/homebrew-actop`. Re-run the failed job. - -### `publish-pypi` fails with OIDC / trusted-publisher error - -The trusted publisher isn't configured (or names don't match). Verify the publisher on pypi.org matches repo `binlecode/actop`, workflow `publish-pypi.yml`, environment `pypi` (see **PyPI Publishing — Flow A**). Also confirm the tag matches the `pypi` environment's `v*` tag deployment policy — a run from a non-`v*` ref won't be allowed into the environment. `skip-existing` means re-runs after a successful upload are safe. **Break-glass:** if OIDC cannot be fixed in time, publish that version manually with the token-driven **Flow B** (`twine upload --skip-existing`), then restore OIDC for subsequent releases. - -### Emergency rerun without resource refresh - -Use `workflow_dispatch` on `release-formula` with `refresh_resources=false` as a temporary workaround. Follow up with a normal run (`refresh_resources=true`) to restore full synchronization. - -## Quick Reference - -```bash -gh run list -R binlecode/actop --limit 12 # recent CI runs -gh run view -R binlecode/actop # inspect run -gh run view -R binlecode/actop --log-failed -git ls-remote --tags origin "v*" # remote tags -gh secret list -R binlecode/actop # confirm HOMEBREW_TAP_TOKEN -gh api /repos/binlecode/actop/environments/pypi/deployment-branch-policies # OIDC tag policy -``` - -**Source of truth:** version in `pyproject.toml`, notes in `CHANGELOG.md`, formula in the tap repo `binlecode/homebrew-actop` (`Formula/actop.rb`), tag helper in `scripts/tag_release.sh`, CI in `.github/workflows/`. diff --git a/docs/REVIEW-architecture-comparison.md b/docs/REVIEW-architecture-comparison.md index 356f298..31a9b57 100644 --- a/docs/REVIEW-architecture-comparison.md +++ b/docs/REVIEW-architecture-comparison.md @@ -92,7 +92,7 @@ The tally above mixes two different kinds of gap: features only one peer has (a **Finding:** convergence pressure on actop is now effectively closed. Both capabilities that were genuinely converged (implemented independently by both actively-developed peers) and still missing/partial in actop have been addressed: 1. **Color** — ✅ **addressed in v1.4.1, on actop's own terms.** The converged peer feature is a *decorative* runtime cycle (mactop party mode, macmon 6 themes). An ROI review found that chasing it verbatim added little to actop's thesis, and reframed the real gap as *accessibility*: actop shipped a startup `--palette {thermal,viridis,mono}` flag (thermal = the unchanged gradient; viridis colorblind-safe; mono grayscale) — the only tool in the field with colorblind-safe/mono palettes. The *runtime cycle keybind* itself was deliberately deferred as optional (set-once is the right model for an accessibility preference; the keybind is a purely additive follow-on). -2. **Richer fan telemetry** — ✅ **closed in v1.2.3.** `smc.py`'s discovery now also reads `F{n}Mx` (max RPM) alongside `F{n}Ac`, and `read_fan_info()` returns `FanReading(current, max)` per fan (`docs/DESIGN-system.md` §4.3), matching macmon's read-only current+max. `F{n}Mn` (min) is deliberately skipped — peers use it only to clamp fan-set *writes* actop doesn't perform. +2. **Richer fan telemetry** — ✅ **closed in v1.2.3.** `smc.py`'s discovery now also reads `F{n}Mx` (max RPM) alongside `F{n}Ac`, and `read_fan_info()` returns `FanReading(current, max)` per fan (`docs/SPEC-system.md` §4.3), matching macmon's read-only current+max. `F{n}Mn` (min) is deliberately skipped — peers use it only to clamp fan-set *writes* actop doesn't perform. Everything else mactop has that actop doesn't (network/disk, Thunderbolt/RDMA, battery, i18n, menu-bar/overlay, process-kill, config persistence) is **single-peer breadth**, not a converged expectation — mactop is simply pursuing a broader "do everything" scope that neither macmon nor actop shares. This reframes `docs/TODO-architecture-roadmap.md`'s net/disk-I/O item: it's a deliberate bet on catching up to the breadth leader for its own sake (the roadmap doc says as much — "deliberately widens scope"), not a response to peer convergence. With both converged gaps now addressed (fan telemetry v1.2.3; color via the v1.4.1 accessibility `--palette` MVP, with the decorative runtime-cycle keybind deferred as optional), there is no remaining converged gap — net/disk I/O is the next roadmap item purely as a breadth bet. diff --git a/docs/REVIEW-tui-frameworks.md b/docs/REVIEW-tui-frameworks.md index ed64b48..c4af5e5 100644 --- a/docs/REVIEW-tui-frameworks.md +++ b/docs/REVIEW-tui-frameworks.md @@ -124,5 +124,5 @@ concurrent, interactive terminal dashboard. The shipped UI favors a single-view, keyboard-first layout over the originally-proposed tabbed design, and uses a custom braille chart widget in place of Textual's stock `Sparkline` — both choices made during implementation to better fit a live hardware monitor. See -`docs/DESIGN-system.md` for the TUI rendering contract and `docs/REVIEW-architecture-comparison.md` +`docs/SPEC-system.md` for the TUI rendering contract and `docs/REVIEW-architecture-comparison.md` for the current `actop`-vs-`mactop` comparison. diff --git a/docs/DESIGN-system.md b/docs/SPEC-system.md similarity index 99% rename from docs/DESIGN-system.md rename to docs/SPEC-system.md index dec8511..e571e7c 100644 --- a/docs/DESIGN-system.md +++ b/docs/SPEC-system.md @@ -53,7 +53,7 @@ This document provides a highly detailed system design and implementation refere - **Distribution model.** - **PyPI** (`pip install actop` / `pipx install actop`) published via **OIDC Trusted Publishing** — no stored token in CI. - **Homebrew** via a **dedicated tap repo `binlecode/homebrew-actop`** (`brew tap binlecode/actop && brew install actop`). The formula does **not** live in this repo; CI syncs it to the tap on each `v*` tag. The keg is self-contained on Homebrew's `python@3.13` (isolated `libexec` venv; the macOS system Python is never used). - - **`main` is strictly PR-only** (branch protection + `enforce_admins` + a local `.githooks/pre-push` guard); CI never pushes to `main`. Release mechanics and secret handling are documented in [`DESIGN-sdlc-cicd.md`](DESIGN-sdlc-cicd.md). + - **`main` is strictly PR-only** (branch protection + `enforce_admins` + a local `.githooks/pre-push` guard); CI never pushes to `main`. Release mechanics and secret handling are documented in `CLAUDE.md` → Release Process. - **Rejected alternative: a stand-alone binary** (Nuitka/PyInstaller, bundling Textual, published from the release pipeline). PyPI (`uv tool install` / `pip install`) already gives a Python-free path and Homebrew already gives a package-manager-free path, so a bundled binary's only unique value is a locked-down environment with no package-manager access at all — too narrow an audience to justify the recurring codesigning/notarization + per-arch CI tax on a single-maintainer project. Revisit only if that niche produces a concrete request; if revived, prefer Nuitka and budget for codesigning/notarization from day one. --- @@ -503,8 +503,8 @@ The process table's `PWR` column answers "which process is drawing the watts" su ### 5.8 Documentation capture (TUI → docs) The TUI frames in this doc and the README are generated from **real** actop output, not hand-drawn — hand mockups drift from the code (wrong version, stale layout, invented labels) and can't reproduce the Braille sparklines. Two repo-local Claude Code skills own this, both keyed off the same public run path (`run-actop`: launch in `tmux`, wait for the ready marker, drive keybindings): -- **`capture-tui-diagram`** (`.claude/skills/capture-tui-diagram/`): captures a live frame via `tmux capture-pane`, cleans it (`clean_capture.py` strips the splash spinner, scrollbar thumbs, trailing pad), and splices it into a doc verbatim (never hand-transcribed). Produces the **still ASCII frames** in §5 here and the README. Single-column (`stack`) frames may be `--compress`ed; side-by-side (`grid`, dashboard+table) frames go in verbatim so box borders don't tear. -- **`record-tui-gif`** (`.claude/skills/record-tui-gif/`, since v1.4.6): records the **animated README hero** (`images/actop-demo.gif`) with [`vhs`](https://github.com/charmbracelet/vhs) — a scripted, reproducible terminal→GIF. `record.sh` drives a live GPU workload (`gpu_workload.py` against an Ollama-compatible endpoint) so the gauges actually move, records the `.tape`, and **always stops the workload** (EXIT/INT/TERM trap). The tape warms actop up off-camera so the first visible frame is already live, and does layout switches off-camera (a `grid` frame at the taller `stack` height would leave a gap) while showing the glyph/process toggles on-camera. Re-run after any TUI/layout change. +- **`capture-tui-diagram`** (`.agents/skills/capture-tui-diagram/`): captures a live frame via `tmux capture-pane`, cleans it (`clean_capture.py` strips the splash spinner, scrollbar thumbs, trailing pad), and splices it into a doc verbatim (never hand-transcribed). Produces the **still ASCII frames** in §5 here and the README. Single-column (`stack`) frames may be `--compress`ed; side-by-side (`grid`, dashboard+table) frames go in verbatim so box borders don't tear. +- **`record-tui-gif`** (`.agents/skills/record-tui-gif/`, since v1.4.6): records the **animated README hero** (`images/actop-demo.gif`) with [`vhs`](https://github.com/charmbracelet/vhs) — a scripted, reproducible terminal→GIF. `record.sh` drives a live GPU workload (`gpu_workload.py` against a llama.cpp or Ollama-compatible router — OpenAI protocol for llama.cpp at `:9040`, native `/api/generate` for the ollama fallback at `:11433`) so the gauges actually move, records the `.tape`, and **always stops the workload** (EXIT/INT/TERM trap). The tape warms actop up off-camera so the first visible frame is already live, and does layout switches off-camera (a `grid` frame at the taller `stack` height would leave a gap) while showing the glyph/process toggles on-camera. Re-run after any TUI/layout change. Both are dev-time tooling only — no runtime dependency, and `vhs` is a maintainer prerequisite (`brew install vhs`), not shipped with actop. diff --git a/docs/TODO-architecture-roadmap.md b/docs/TODO-architecture-roadmap.md index 326eaa4..a9db75f 100644 --- a/docs/TODO-architecture-roadmap.md +++ b/docs/TODO-architecture-roadmap.md @@ -2,13 +2,13 @@ Roadmap for hardening `actop`'s core. We stay scoped to one thesis — **a fast, unprivileged, resource-efficient Apple Silicon telemetry monitor** — and reject feature creep into ML/APM frameworks. -Completed work is **not** tracked here — its as-built design is folded into `docs/DESIGN-system.md` (with dated entries in `CHANGELOG.md`). This file holds only what remains open. +Completed work is **not** tracked here — its as-built design is folded into `docs/SPEC-system.md` (with dated entries in `CHANGELOG.md`). This file holds only what remains open. --- ## Must-Have — Hardware & Metric Coverage -* [ ] **Net / disk I/O via native ctypes** → **`docs/TODO-net-disk-io-2026-07-02.md`.** Moderate effort; deliberately widens scope. **Feasibility spike complete (2026-07-02)** — verified on-device (M4 Max, unprivileged) and cross-checked against `mactop`'s shipped implementation; the full impl-ready design (exact syscalls, struct layouts, IOKit matching strings, property keys, aggregation, and where each plugs into the sampler/models/TUI/API/export layers) lives in that dedicated plan. Summary of the verified approach: **network** via `getifaddrs()`/`freeifaddrs()` walking `AF_LINK` entries and casting `ifa_data` → `struct if_data` (one new ctypes binding in `native_sys.py`; the originally-guessed `net.link.generic.system.stats` MIB does not exist on-device); **disk** via IOKit `IOServiceMatching("AppleAPFSVolume")` summing each volume's `Statistics` dict, with `IOServiceMatching("IOBlockStorageDriver")` as the non-APFS fallback (read the way `gpu_registry.py` walks `IOAccelerator` — no new IOKit binding classes); both **aggregated** as `(current_total - previous_total) / elapsed_seconds`, the same delta-over-interval shape as `sampler._compute_bandwidth_gbps` (§3.5). Update `DESIGN-system.md` §3.6 to drop the non-goal framing once it ships. +* [ ] **Net / disk I/O via native ctypes** → **`docs/TODO-net-disk-io-2026-07-02.md`.** Moderate effort; deliberately widens scope. **Feasibility spike complete (2026-07-02)** — verified on-device (M4 Max, unprivileged) and cross-checked against `mactop`'s shipped implementation; the full impl-ready design (exact syscalls, struct layouts, IOKit matching strings, property keys, aggregation, and where each plugs into the sampler/models/TUI/API/export layers) lives in that dedicated plan. Summary of the verified approach: **network** via `getifaddrs()`/`freeifaddrs()` walking `AF_LINK` entries and casting `ifa_data` → `struct if_data` (one new ctypes binding in `native_sys.py`; the originally-guessed `net.link.generic.system.stats` MIB does not exist on-device); **disk** via IOKit `IOServiceMatching("AppleAPFSVolume")` summing each volume's `Statistics` dict, with `IOServiceMatching("IOBlockStorageDriver")` as the non-APFS fallback (read the way `gpu_registry.py` walks `IOAccelerator` — no new IOKit binding classes); both **aggregated** as `(current_total - previous_total) / elapsed_seconds`, the same delta-over-interval shape as `sampler._compute_bandwidth_gbps` (§3.5). Update `SPEC-system.md` §3.6 to drop the non-goal framing once it ships. * **Why a must-have despite being single-peer breadth:** per `docs/REVIEW-architecture-comparison.md` §4 this is *single-peer breadth* (mactop-only; macmon deliberately omits it), **not** a converged peer expectation. We keep it as a conscious bet on narrowing the gap to the breadth leader, tracked openly rather than silently declined. Post-launch — launch gates on none of it. * [ ] **Export parity for processes / alerts / session energy** → **`docs/TODO-layering-cleanup-2026-07-02.md` §10.2.** The layering cleanup (LC-1→LC-3, shipped v1.2.4–v1.3.0) made `SystemSnapshot` the sole frame contract, but three data points the TUI shows still never reach `--json` / `--serve`: per-process rows (the export loops build `Monitor` without `include_processes=True`), the throttle/alert flags, and cumulative session energy (both produced by `analytics.AlertEngine`, which only the dashboard widget drives). **Why it matters:** someone profiling a local inference run through the export backends gets watts and utilization but cannot answer *which process drew them*, *did the chip throttle*, or *what did the run cost in total energy* — the questions that profiling session exists to ask. That plan's §10.2 records both candidate designs (opt-in export flags vs. moving `AlertEngine` behind `Monitor`), the recommendation, and the per-PID Prometheus cardinality constraint that keeps process data NDJSON-only either way. diff --git a/docs/TODO-layering-cleanup-2026-07-02.md b/docs/TODO-layering-cleanup-2026-07-02.md index 29146b7..1e0c199 100644 --- a/docs/TODO-layering-cleanup-2026-07-02.md +++ b/docs/TODO-layering-cleanup-2026-07-02.md @@ -259,7 +259,7 @@ an analytics engine — strictly less churn than either order reversed. ## 6. Docs to sync (with the PR that changes each fact) -- `docs/DESIGN-system.md`: data-flow section — snapshot as sole frame +- `docs/SPEC-system.md`: data-flow section — snapshot as sole frame contract, `analytics.py` layer, opt-in process collection. - `CLAUDE.md` + `README.md`: module tables (add `analytics.py`), data-flow paragraph, Python-API snippet if it gains `include_processes`. diff --git a/docs/TODO-net-disk-io-2026-07-02.md b/docs/TODO-net-disk-io-2026-07-02.md index 9372a77..f382d9b 100644 --- a/docs/TODO-net-disk-io-2026-07-02.md +++ b/docs/TODO-net-disk-io-2026-07-02.md @@ -16,7 +16,7 @@ originally-guessed network MIB (`net.link.generic.system.stats`) and disk orderi **Constraint (non-negotiable):** built via **native `ctypes`**, matching `native_sys.py`'s existing pattern — **not** by reintroducing `psutil`. This is the condition under which the -roadmap overrides `DESIGN-system.md` §3.6's non-goal framing. +roadmap overrides `SPEC-system.md` §3.6's non-goal framing. This is a **planning doc, not shipped code.** Implement as a normal PR (patch bump, `[Unreleased]` → dated section, `ruff check --fix .` + `ruff format .`, functional tests @@ -124,7 +124,7 @@ read/write; keep packet/op counts if cheap. `Monitor().get_snapshot()` carries the rates; `--json` and `--serve` both surface them. - **T5 — TUI rows.** Net + Disk rows/sparklines with the hide-row pattern for unavailable hosts. `done_when`: rows update live under load and hide cleanly when unavailable. -- **T6 — Docs + version.** Replace `DESIGN-system.md` §3.6's non-goal bullet with the +- **T6 — Docs + version.** Replace `SPEC-system.md` §3.6's non-goal bullet with the as-built design; check off the roadmap item; update `README.md` metric list and the comparison doc (§3 table "Network / Disk I/O" row → ✅). Patch bump + CHANGELOG. diff --git a/docs/TODO-reading-plane-audit-2026-07-29.md b/docs/TODO-reading-plane-audit-2026-07-29.md index 8595260..b05c445 100644 --- a/docs/TODO-reading-plane-audit-2026-07-29.md +++ b/docs/TODO-reading-plane-audit-2026-07-29.md @@ -259,7 +259,7 @@ convert_to_GB = convert_to_GiB # deprecated misnomer; removed in 2.0.0 Check width: `GiB` is one char wider per occurrence (two occurrences + two swap = +4 chars). The `stack`/`grid` presets auto-degrade below ~96 cols (`widgets.py` `set_layout_preset`) — re-capture at 80, 96 and 200 cols and confirm no - truncation. Use the `capture-tui-diagram` skill to refresh `docs/DESIGN-system.md` §5 and + truncation. Use the `capture-tui-diagram` skill to refresh `docs/SPEC-system.md` §5 and the README if the frame changes. 7. **Docs** — update `CLAUDE.md` module table row for `utils.py` and `README.md` where memory @@ -549,7 +549,7 @@ sampling monitor. Adopt it as **fallback + breakdown** only (§8). ## 8. Adopt `IOAccelerator` PerformanceStatistics — fallback + Renderer/Tiler breakdown -**Status: SHIPPED in v1.6.0.** As-built design is documented in `docs/DESIGN-system.md` §3.8; +**Status: SHIPPED in v1.6.0.** As-built design is documented in `docs/SPEC-system.md` §3.8; the plan below is retained as the originating spec. Two deviations from it, both deliberate: * **No service caching.** §8.3 called for caching the matched `io_object_t` the way `SMCReader` @@ -675,7 +675,7 @@ the `io_object_t` and only re-enumerate on read failure. user knows the number's provenance changed. - **`CLAUDE.md`** — update the `gpu_registry.py` module-table row (currently "Per-process GPU time via IOKit") to cover device-level perf stats, and extend the data-flow paragraph. -- **`docs/DESIGN-system.md`** — document the dual GPU source and the fallback rule. +- **`docs/SPEC-system.md`** — document the dual GPU source and the fallback rule. ### 8.5 Verification diff --git a/pyproject.toml b/pyproject.toml index fda77e0..eac6c58 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "actop" -version = "1.6.6" +version = "1.6.7" description = "Apple Silicon (M1–M4) power, GPU, ANE & memory-bandwidth monitor — sudoless TUI + Python API for profiling local LLM / MLX / CoreML inference" readme = "README.md" license = { text = "MIT" } diff --git a/tests/test_analytics.py b/tests/test_analytics.py index b123b33..318c73c 100644 --- a/tests/test_analytics.py +++ b/tests/test_analytics.py @@ -8,7 +8,7 @@ is the public entrypoint and `AlertFrame` fields are the public output. """ -from actop.analytics import AlertEngine, AlertFrame +from actop.analytics import AlertEngine, AlertFrame, domain_throttling from actop.models import SystemSnapshot _IDLE_RESIDENCY = {"idle": 100, "low": 0, "mid": 0, "high": 0} @@ -78,6 +78,33 @@ def _engine(**overrides) -> AlertEngine: return AlertEngine(**kwargs) +def test_domain_throttling_ceiling_unknown_is_never_throttling(): + # When the DVFS ceiling is unknown (max_freq <= 0) the slow-freq ratio is + # uncomputable, so the domain must never be reported as throttling — even + # busy + slow + hot. A regression here would fire a false THROTTLING token + # on a machine whose pmgr voltage-states table failed to classify. + assert ( + domain_throttling(95.0, 1200, 0, 100.0, "Serious", freq_percent=90.0) is False + ) + + +def test_domain_throttling_uses_die_temp_fallback_when_thermal_nominal(): + # The thermal-state string is the primary "hot" test, but a machine whose + # SMC temps read 0 (or that reports Nominal during a spike) must still + # throttle via the die-temp gate. Dropping that fallback would hide + # throttling on such machines. + assert ( + domain_throttling(95.0, 1200, 3200, 95.0, "Nominal", freq_percent=90.0) is True + ) + # A cool die under a Nominal/Unknown state stays quiet. + assert ( + domain_throttling(95.0, 1200, 3200, 50.0, "Nominal", freq_percent=90.0) is False + ) + assert ( + domain_throttling(95.0, 1200, 3200, 50.0, "Unknown", freq_percent=90.0) is False + ) + + def test_throttle_alert_fires_only_after_sustain_threshold(): # A busy P-cluster held well below its DVFS ceiling while hot must NOT alert # until the condition has held for sustain_samples consecutive frames: diff --git a/tests/test_api.py b/tests/test_api.py index bd79229..9f0af16 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -8,7 +8,7 @@ import pytest -from actop import Monitor, Profiler, SystemSnapshot +from actop import AsyncMonitor, Monitor, Profiler, SystemSnapshot from actop.native_sys import get_sysctl_int pytestmark = pytest.mark.local @@ -145,6 +145,24 @@ def test_monitor_get_snapshot_returns_valid_snapshot(): assert math.isfinite(snapshot.timestamp) +def test_async_monitor_get_snapshot_async_returns_snapshot(): + # AsyncMonitor is the documented async surface of the public API; a broken + # thread-pool wrapper (e.g. never awaiting the executor) would surface as a + # missing/broken snapshot here, not in any sync test. + import asyncio + + async def _run(): + with AsyncMonitor(interval_s=1) as mon: + snap = await mon.get_snapshot_async() + return snap + + snapshot = asyncio.run(_run()) + assert isinstance(snapshot, SystemSnapshot) + assert snapshot.cpu_watts >= 0 + assert snapshot.package_watts >= 0 + assert snapshot.timestamp > 0 + + def test_profiler_collects_samples_and_summarizes(): with Profiler(interval_s=1) as p: time.sleep(3) diff --git a/tests/test_chart_rendering.py b/tests/test_chart_rendering.py index 15506c1..02192b8 100644 --- a/tests/test_chart_rendering.py +++ b/tests/test_chart_rendering.py @@ -37,17 +37,19 @@ def test_dumb_and_unknown_terminals_get_no_color(): def test_console_color_system_is_preferred_when_present(): - class _Console: - color_system = "256" + from rich.console import Console # A detected console color system wins over env heuristics... - assert resolve_color_mode(_Console(), env={"COLORTERM": "truecolor"}) == "256" + assert ( + resolve_color_mode(Console(color_system="256"), env={"COLORTERM": "truecolor"}) + == "256" + ) # ...but NO_COLOR still overrides even a truecolor console. - class _TrueConsole: - color_system = "truecolor" - - assert resolve_color_mode(_TrueConsole(), env={"NO_COLOR": "1"}) == "none" + assert ( + resolve_color_mode(Console(color_system="truecolor"), env={"NO_COLOR": "1"}) + == "none" + ) # --- the resolved tier actually shapes rendered output ----------------------- diff --git a/tests/test_cli_contract.py b/tests/test_cli_contract.py index d12f73c..b2a1548 100644 --- a/tests/test_cli_contract.py +++ b/tests/test_cli_contract.py @@ -25,6 +25,8 @@ def test_cli_help_runs_and_exposes_show_cores_as_flag(): assert "--chart-glyph {dots,block}" in result.stdout assert "--layout {grid,stack}" in result.stdout assert "--palette {thermal,viridis,mono}" in result.stdout + assert "--json" in result.stdout + assert "--serve PORT" in result.stdout # --help surfaces every option's default (ArgumentDefaultsHelpFormatter), so # the supported value set + default is documented for all args. assert "(default:" in result.stdout @@ -57,22 +59,6 @@ def test_cli_rejects_legacy_show_cores_value_form(): assert "unrecognized arguments: true" in result.stderr -def test_cli_json_with_show_processes_forwards_both(): - args = build_parser().parse_args(["--json", "--show-processes"]) - assert args.json is True - assert args.show_processes is True - - -def test_cli_chart_glyph_accepts_block(): - args = build_parser().parse_args(["--chart-glyph", "block"]) - assert args.chart_glyph == "block" - - -def test_cli_layout_accepts_stack(): - args = build_parser().parse_args(["--layout", "stack"]) - assert args.layout == "stack" - - def test_cli_rejects_unknown_layout(): result = subprocess.run( [sys.executable, "-m", "actop.actop", "--layout", "foo"], @@ -85,11 +71,6 @@ def test_cli_rejects_unknown_layout(): assert "invalid choice: 'foo'" in result.stderr -def test_cli_palette_accepts_viridis(): - args = build_parser().parse_args(["--palette", "viridis"]) - assert args.palette == "viridis" - - def test_cli_rejects_unknown_palette(): result = subprocess.run( [sys.executable, "-m", "actop.actop", "--palette", "rainbow"], @@ -102,24 +83,6 @@ def test_cli_rejects_unknown_palette(): assert "invalid choice: 'rainbow'" in result.stderr -def test_cli_help_exposes_export_flags(): - result = subprocess.run( - [sys.executable, "-m", "actop.actop", "--help"], - capture_output=True, - text=True, - check=False, - ) - - assert result.returncode == 0 - assert "--json" in result.stdout - assert "--serve PORT" in result.stdout - - -def test_cli_serve_accepts_valid_port(): - args = build_parser().parse_args(["--serve", "9095"]) - assert args.serve == 9095 - - def test_cli_rejects_serve_port_out_of_range(): result = subprocess.run( [sys.executable, "-m", "actop.actop", "--serve", "70000"], diff --git a/tests/test_dashboard_metrics.py b/tests/test_dashboard_metrics.py index 6cc3d7b..2ccb82a 100644 --- a/tests/test_dashboard_metrics.py +++ b/tests/test_dashboard_metrics.py @@ -178,6 +178,12 @@ async def _drive(snapshots, config=None): "gpu_rt_row_display": dash.query_one("#gpu-rt-row", Static).display, "status": app.last_status, } + # Raw renderables (not str) for the styled-span assertions in the + # coloring tests — the str form strips the severity tint the span tests + # need to see. + state["pcpu_row"] = dash.query_one("#pcpu-summary-row", Static).render() + state["ecpu_row"] = dash.query_one("#ecpu-summary-row", Static).render() + state["fan_label_content"] = dash.query_one("#fan-label", Static).render() residency_ids = ( "pcpu-residency-row", "ecpu-residency-row", @@ -296,6 +302,91 @@ def test_gpu_label_marks_driver_provenance_and_drops_meaningless_freq(): assert "(drv)" not in residency["gpu_label"] +_FAN_SPINNER_CHARS = "⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏" + + +def _styled_segment(content, predicate): + """Style string of the first span matching `predicate`, or None. + + The rendered `Content` carries a `spans`/`plain` pair (Textual's wrapper + over Rich's styled Text); the plain string the rest of the suite asserts on + is the same text with the severity tint stripped. + """ + for span in content.spans: + if predicate(content.plain[span.start : span.end]): + return str(span.style) + return None + + +def _fan_glyph_style(content): + return _styled_segment( + content, lambda s: re.fullmatch(rf"[{_FAN_SPINNER_CHARS}]", s) is not None + ) + + +def test_util_percent_wears_chart_color_high_vs_low(): + # The % readout must wear the chart's severity color for that value (the + # number and its tracer agree), so a hot reading reads red-hot and an idle + # one stays cool. A regression back to a plain string leaves every span + # unstyled and this test fails — the substring assertions elsewhere would + # keep passing. + state = asyncio.run(_drive([_snapshot(0.0, False, pcpu_util_pct=90.0)])) + pcpu_style = _styled_segment(state["pcpu_row"], lambda s: s.strip() == "90%") + ecpu_style = _styled_segment(state["ecpu_row"], lambda s: s.strip() == "10%") + assert pcpu_style # colored, not the empty plain-text style + assert ecpu_style + assert pcpu_style != ecpu_style # high/low read different colors + + +def test_fan_spinner_glyph_wears_utilization_color(): + # The fan spinner glyph tints by fan load the same way the % readouts do: + # a fan near its max spins in the hot color, a nearly-idle fan in the cool + # one. max-less fans stay untinted (there is no utilization to colour by). + high = asyncio.run( + _drive( + [ + _snapshot( + 0.0, + False, + fans=[FanReading(5400.0, 6000.0)], + fan_available=True, + ) + ] + ) + ) + low = asyncio.run( + _drive( + [ + _snapshot( + 0.0, + False, + fans=[FanReading(1200.0, 6000.0)], + fan_available=True, + ) + ] + ) + ) + no_max = asyncio.run( + _drive( + [ + _snapshot( + 0.0, + False, + fans=[FanReading(5400.0, None)], + fan_available=True, + ) + ] + ) + ) + high_style = _fan_glyph_style(high["fan_label_content"]) + low_style = _fan_glyph_style(low["fan_label_content"]) + no_max_style = _fan_glyph_style(no_max["fan_label_content"]) + assert high_style + assert low_style + assert high_style != low_style + assert no_max_style is None # no max key -> no invented severity + + def test_fan_row_shows_current_and_max_when_available(): # Two fans, each with a known max: render "current/max" per fan, joined by # " · " so the inter-fan separator never collides with the current/max "/".