diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json new file mode 100644 index 0000000..bb49491 --- /dev/null +++ b/.devcontainer/devcontainer.json @@ -0,0 +1,21 @@ +{ + "name": "StorageOps", + "image": "mcr.microsoft.com/devcontainers/python:1-3.12-bookworm", + "features": { + "ghcr.io/devcontainers/features/node:1": { + "version": "22" + }, + "ghcr.io/devcontainers/features/common-utils:2": {} + }, + "postCreateCommand": "bash scripts/dev_setup.sh --persist-path", + "customizations": { + "vscode": { + "settings": { + "python.defaultInterpreterPath": "${workspaceFolder}/.venv/bin/python" + } + } + }, + "remoteEnv": { + "PATH": "${containerEnv:PATH}:${workspaceFolder}/.venv/bin:${containerEnv:HOME}/.local/bin" + } +} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ef09ca3..39b0baa 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -132,22 +132,10 @@ jobs: python -m build --wheel pip install dist/*.whl storageops install - - name: Run a diagnosis and evaluate it + - name: Run live diagnosis smoke (3 golden cases) if: steps.gate.outputs.present == 'true' env: - MODEL_KEY: ${{ secrets.STORAGEOPS_MODEL_KEY }} - PROVIDER: ${{ vars.STORAGEOPS_PROVIDER }} - MODEL: ${{ vars.STORAGEOPS_MODEL }} - run: | - set -e - CASE=skills/storageops-eval-golden-cases/cases/throttling-hot-prefix - INPUT=$(ls "$CASE"/input/* | head -1) - storageops --provider "${PROVIDER:-deepseek}" --model "${MODEL:-deepseek-v4-pro}" \ - --api-key "$MODEL_KEY" --print @"$INPUT" \ - 'Diagnose the root cause and safe next steps' > /tmp/diag.md - echo "----- diagnosis output (head) -----"; head -40 /tmp/diag.md - test -s /tmp/diag.md - # eval_runner exits non-zero on HARD_FAIL (wrong category, low confidence, - # missing required evidence, or forbidden/unsafe output). - python skills/storageops-eval-golden-cases/scripts/eval_runner.py \ - --case "$CASE" --output /tmp/diag.md + STORAGEOPS_MODEL_KEY: ${{ secrets.STORAGEOPS_MODEL_KEY }} + STORAGEOPS_PROVIDER: ${{ vars.STORAGEOPS_PROVIDER }} + STORAGEOPS_MODEL: ${{ vars.STORAGEOPS_MODEL }} + run: bash scripts/live_smoke.sh diff --git a/AGENTS.md b/AGENTS.md index c693cce..1852cf4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -35,6 +35,7 @@ make test # alias for ci-local make test-fast # pytest + extension tests + fast validate make ci-full # ci-local + package-check (pre-release) make dev # one-shot dev setup (venv, Node, storageops install) +make live-smoke # live model smoke + 3 golden-case diagnoses (needs API key) ``` `make validate` does not exercise the TypeScript extension — the routing, @@ -79,9 +80,10 @@ commands live in the `## Validation Commands` section above, `Makefile`, and re-run `storageops install` with the nvm Node on `PATH` (needs network for the npm Pi install). - **Offline vs. live.** All gates and `make ci-local` / `storageops eval --baselines` - run fully offline and need no key. A **live diagnosis** - (`storageops --print '...'`) and `storageops smoke` require a model provider key - (`ANTHROPIC_API_KEY` / `DEEPSEEK_API_KEY` / `OPENAI_API_KEY`, etc.), which is not - configured in this environment. -- **PR gate.** Run `make ci-local` before opening a PR; it mirrors the CI `validate` - job. Use `make dev` for first-time setup (`scripts/dev_setup.sh`). + run fully offline and need no key. **Live diagnosis** needs install + Node/Pi + API key; + check `storageops doctor --json` field `live_diagnosis_available`, then run + `make live-smoke` (3 golden cases + model smoke). +- **Devcontainer.** Open in VS Code / Codespaces with `.devcontainer/devcontainer.json` + (Python 3.12 + Node 22; postCreate runs `dev_setup.sh --persist-path`). +- **PR gate.** Run `make ci-local` before opening a PR. Use `make dev` for first-time + setup (`scripts/dev_setup.sh`; optional `DEV_SETUP_FLAGS="--persist-path --verify"`). diff --git a/CHANGELOG.md b/CHANGELOG.md index 71f6f72..8d8899f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,20 @@ # Changelog +## 2026-06-29 — v0.7.2: Live diagnosis smoke & devcontainer + +- **`make live-smoke` / `scripts/live_smoke.sh`** — model round-trip plus live + diagnosis + eval for three golden cases (`throttling-hot-prefix`, + `access-denied-cross-account`, `signature-clock-skew`). Requires a configured + API key; reads `STORAGEOPS_MODEL_KEY` or provider env vars when set. +- **`storageops doctor`** — adds `Live diagnosis` row and `live_diagnosis_available` + in `--json` output when install, Node, Pi, and API key are ready. +- **CI `diagnosis-smoke`** — calls `scripts/live_smoke.sh` (3 cases) when + `STORAGEOPS_MODEL_KEY` is configured. +- **`.devcontainer/devcontainer.json`** — Python 3.12 + Node 22; postCreate runs + `dev_setup.sh --persist-path`. +- **`dev_setup.sh`** — new `--persist-path` (idempotent `~/.bashrc` PATH hints) and + `--verify` (runs `make ci-local` after setup). + ## 2026-06-29 — v0.7.1: Dev ergonomics & CI alignment - **`make ci-local`** mirrors the offline PR gate (provider/contract/coverage checks, diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index ab95343..2edfb49 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -14,6 +14,14 @@ make dev ensures Node.js >= 22.19 (prefers nvm when the default `node` is too old), runs `storageops install --force`, and prints `storageops doctor`. +Optional flags via `DEV_SETUP_FLAGS`: + +```bash +make dev DEV_SETUP_FLAGS="--persist-path --verify" +``` + +VS Code / Codespaces: use `.devcontainer/devcontainer.json` (runs `dev_setup.sh --persist-path`). + Manual setup (equivalent): ```bash @@ -111,14 +119,17 @@ python3 skills/storageops-eval-golden-cases/scripts/eval_all.py \ .venv/bin/python -m pytest ``` -Optional live diagnosis smoke (needs a model provider API key; CI uses -`STORAGEOPS_MODEL_KEY` — see `.github/workflows/ci.yml` `diagnosis-smoke` job): +Optional live diagnosis smoke (needs a model provider API key): ```bash storageops configure --provider deepseek --model deepseek-v4-pro --api-key -storageops smoke --provider deepseek --model deepseek-v4-pro +make live-smoke ``` +CI runs the same script when the repo secret **`STORAGEOPS_MODEL_KEY`** is set +(optional vars: **`STORAGEOPS_PROVIDER`**, **`STORAGEOPS_MODEL`**) — see +`.github/workflows/ci.yml` `diagnosis-smoke` job and `docs/release.md`. + ## Pull Request Checklist - The change is scoped and documented. diff --git a/Makefile b/Makefile index d8f13bd..ceec756 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: help validate validate-full ci-local ci-full extension-tests test-fast test install dev package-check +.PHONY: help validate validate-full ci-local ci-full extension-tests test-fast test install dev live-smoke package-check help: @echo "StorageOps — Pi Coding Agent extension + skill pack" @@ -12,11 +12,12 @@ help: @echo " make test-fast pytest + extension tests + fast validate only" @echo " make extension-tests Run the TypeScript extension behavioral tests" @echo " make install Install thin CLI shim (pip install -e .)" - @echo " make dev One-shot dev setup (venv, Node, storageops install)" + @echo " make dev One-shot dev setup (scripts/dev_setup.sh; DEV_SETUP_FLAGS=...)" + @echo " make live-smoke Model smoke + 3 golden-case live diagnoses (needs API key)" @echo " make package-check Build wheel and run package_check.py (needs network once)" @echo "" - @echo " Note: install-smoke and diagnosis-smoke run in CI only" - @echo " (wheel install / model key) — see docs/release.md." + @echo " Note: install-smoke and diagnosis-smoke run in CI when configured" + @echo " (wheel install / STORAGEOPS_MODEL_KEY) — see docs/release.md." validate: @echo "=== Validating skills ===" @@ -67,7 +68,10 @@ install: pip install -e . dev: - @bash scripts/dev_setup.sh + @bash scripts/dev_setup.sh $(DEV_SETUP_FLAGS) + +live-smoke: + @bash scripts/live_smoke.sh package-check: python3 -m pip install --upgrade pip build diff --git a/README.md b/README.md index 2d06e83..61a6db6 100644 --- a/README.md +++ b/README.md @@ -190,6 +190,7 @@ make test # alias for ci-local make test-fast # faster loop while editing make ci-full # pre-release: ci-local + package-check make dev # one-shot dev setup (venv, Node, storageops install) +make live-smoke # live model smoke + 3 golden-case diagnoses (needs API key) ``` `make validate` only *greps* the TypeScript extension; the routing, provider, and @@ -198,6 +199,9 @@ trace behavior is covered by the extension tests, so run `make ci-local` `make ci-full`, `install-smoke`, and `diagnosis-smoke` need a wheel build or network/model key — see [Release](docs/release.md). +Reproducible dev environment: open `.devcontainer/devcontainer.json` in VS Code +or Codespaces (`make dev` with `--persist-path` on postCreate). + ## Documentation Map - [Getting Started](docs/getting-started.md) diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index d6198f8..d5923f5 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -1,6 +1,6 @@ # Architecture -StorageOps v0.7.1 is a Pi Coding Agent extension and skill pack. +StorageOps v0.7.2 is a Pi Coding Agent extension and skill pack. ## Components diff --git a/docs/cli-reference.md b/docs/cli-reference.md index fe27cdd..d9e9e3d 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -41,7 +41,7 @@ from the older local package. Deployment provenance is written to ## `storageops --version` ```text -StorageOps v0.7.1 (pi: 0.78.0) +StorageOps v0.7.2 (pi: 0.78.0) httpmon : /root/.storageops/bin/httpmon api key : api-key file independent install : yes (~/.storageops/agent) diff --git a/docs/release.md b/docs/release.md index 98181d7..b7b309d 100644 --- a/docs/release.md +++ b/docs/release.md @@ -75,3 +75,24 @@ source repository. Use the workflow's manual `workflow_dispatch` preflight before the first real release, or whenever release plumbing changes. Manual runs build and validate the package but never publish to PyPI. + +## Live diagnosis smoke (optional CI) + +The `diagnosis-smoke` job in `.github/workflows/ci.yml` runs `scripts/live_smoke.sh` +when a model key is configured. Without the secret it skips cleanly (job still passes). + +| GitHub setting | Purpose | +| --- | --- | +| Secret `STORAGEOPS_MODEL_KEY` | Model provider API key (required to enable live smoke) | +| Var `STORAGEOPS_PROVIDER` | Optional; default `deepseek` | +| Var `STORAGEOPS_MODEL` | Optional; default `deepseek-v4-pro` | + +Locally (after `storageops configure --api-key` or exporting a provider env var): + +```bash +make live-smoke +``` + +The script runs `storageops smoke` plus live `--print` diagnosis + eval for three +golden cases: `throttling-hot-prefix`, `access-denied-cross-account`, +`signature-clock-skew`. diff --git a/pyproject.toml b/pyproject.toml index de1c869..2f82da7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "storageops" -version = "0.7.1" +version = "0.7.2" description = "StorageOps — S3-compatible object storage diagnostic toolkit. A Pi Coding Agent extension + skill pack." requires-python = ">=3.11" readme = "README.md" diff --git a/scripts/dev_setup.sh b/scripts/dev_setup.sh index 366f255..fc275eb 100755 --- a/scripts/dev_setup.sh +++ b/scripts/dev_setup.sh @@ -6,6 +6,11 @@ cd "$(dirname "$0")/.." USE_VENV=1 SKIP_INSTALL=0 +PERSIST_PATH=0 +VERIFY=0 +NVM_BIN="" +PYTHON_BIN_PREFIX="" + while [[ $# -gt 0 ]]; do case "$1" in --user) @@ -16,10 +21,20 @@ while [[ $# -gt 0 ]]; do SKIP_INSTALL=1 shift ;; + --persist-path) + PERSIST_PATH=1 + shift + ;; + --verify) + VERIFY=1 + shift + ;; -h|--help) - echo "Usage: bash scripts/dev_setup.sh [--user] [--skip-storageops-install]" - echo " --user pip install -e '.[dev]' to user site (~/.local/bin)" + echo "Usage: bash scripts/dev_setup.sh [OPTIONS]" + echo " --user pip install -e '.[dev]' to user site (~/.local/bin)" echo " --skip-storageops-install skip storageops install --force (offline only)" + echo " --persist-path append PATH hints to ~/.bashrc (idempotent)" + echo " --verify run make ci-local after setup" exit 0 ;; *) @@ -31,6 +46,16 @@ done min_node_major=22 min_node_minor=19 +STORAGEOPS_PATH_MARKER="# storageops-dev-setup" + +persist_path_line() { + local line="$1" + touch "$HOME/.bashrc" + if ! grep -Fq "$line" "$HOME/.bashrc" 2>/dev/null; then + echo "$line $STORAGEOPS_PATH_MARKER" >> "$HOME/.bashrc" + echo "[ok] persisted: $line" + fi +} node_triple() { node --version 2>/dev/null | sed -n 's/^v\?\([0-9]*\)\.\([0-9]*\)\.\([0-9]*\).*/\1 \2 \3/p' @@ -63,6 +88,7 @@ prepend_nvm_node() { fi done if [[ -n "$best" ]]; then + NVM_BIN="$best" export PATH="$best:$PATH" return 0 fi @@ -81,11 +107,13 @@ if [[ "$USE_VENV" == 1 ]]; then source .venv/bin/activate python -m pip install -U pip python -m pip install -e '.[dev]' - export PATH="$(pwd)/.venv/bin:$PATH" + PYTHON_BIN_PREFIX="$(pwd)/.venv/bin" + export PATH="${PYTHON_BIN_PREFIX}:$PATH" else python3 -m pip install -U pip python3 -m pip install -e '.[dev]' - export PATH="$HOME/.local/bin:$PATH" + PYTHON_BIN_PREFIX="$HOME/.local/bin" + export PATH="${PYTHON_BIN_PREFIX}:$PATH" fi echo "=== Node.js (need >= ${min_node_major}.${min_node_minor}) ===" @@ -101,6 +129,16 @@ else echo "[ok] Node $(node --version)" fi +if [[ "$PERSIST_PATH" == 1 ]]; then + echo "=== Persist PATH hints ===" + if [[ -n "$PYTHON_BIN_PREFIX" ]]; then + persist_path_line "export PATH=\"${PYTHON_BIN_PREFIX}:\$PATH\"" + fi + if [[ -n "$NVM_BIN" ]]; then + persist_path_line "export PATH=\"${NVM_BIN}:\$PATH\"" + fi +fi + if [[ "$SKIP_INSTALL" != 1 ]]; then echo "=== storageops install --force ===" storageops install --force @@ -111,6 +149,13 @@ fi echo "=== storageops doctor ===" storageops doctor || true +if [[ "$VERIFY" == 1 ]]; then + echo "=== make ci-local (--verify) ===" + make ci-local +fi + echo echo "Dev setup complete. Before opening a PR, run:" echo " make ci-local" +echo "With a model key configured, also run:" +echo " make live-smoke" diff --git a/scripts/live_smoke.sh b/scripts/live_smoke.sh new file mode 100755 index 0000000..c248e4c --- /dev/null +++ b/scripts/live_smoke.sh @@ -0,0 +1,63 @@ +#!/usr/bin/env bash +# Live diagnosis smoke: model round-trip + golden-case eval for core flows. +# Requires storageops install, Node/Pi readiness, and a model provider API key. +set -euo pipefail +cd "$(dirname "$0")/.." + +PROVIDER="${STORAGEOPS_PROVIDER:-deepseek}" +MODEL="${STORAGEOPS_MODEL:-deepseek-v4-pro}" +PROMPT="${STORAGEOPS_LIVE_PROMPT:-Diagnose the root cause and safe next steps}" + +# Optional explicit key; otherwise storageops reads api-key file / env via doctor. +MODEL_KEY="${STORAGEOPS_MODEL_KEY:-}" + +live_available() { + storageops doctor --json 2>/dev/null | python3 -c \ + "import json,sys; print(json.load(sys.stdin).get('live_diagnosis_available', False))" +} + +if [[ "$(live_available)" != "True" ]]; then + echo "[error] Live diagnosis is not available." >&2 + echo " Run: storageops doctor" >&2 + echo " Configure: storageops configure --provider ${PROVIDER} --model ${MODEL} --api-key" >&2 + echo " Or set STORAGEOPS_MODEL_KEY / DEEPSEEK_API_KEY / ANTHROPIC_API_KEY / OPENAI_API_KEY" >&2 + exit 1 +fi + +smoke_args=(smoke --provider "$PROVIDER" --model "$MODEL") +diag_args=(--provider "$PROVIDER" --model "$MODEL" --print) +if [[ -n "$MODEL_KEY" ]]; then + smoke_args+=(--api-key "$MODEL_KEY") + diag_args+=(--api-key "$MODEL_KEY") +fi + +echo "=== storageops smoke ===" +storageops "${smoke_args[@]}" + +CASES=( + throttling-hot-prefix + access-denied-cross-account + signature-clock-skew +) + +for case_name in "${CASES[@]}"; do + case_dir="skills/storageops-eval-golden-cases/cases/${case_name}" + if [[ ! -f "${case_dir}/expected.json" ]]; then + echo "[error] missing golden case: ${case_name}" >&2 + exit 1 + fi + input_file="$(ls "${case_dir}"/input/* 2>/dev/null | head -1 || true)" + if [[ -z "$input_file" ]]; then + echo "[error] no input file for case: ${case_name}" >&2 + exit 1 + fi + out="/tmp/storageops-live-smoke-${case_name}.md" + echo "=== live diagnosis: ${case_name} ===" + storageops "${diag_args[@]}" @"${input_file}" "${PROMPT}" > "${out}" + test -s "${out}" + head -20 "${out}" || true + python3 skills/storageops-eval-golden-cases/scripts/eval_runner.py \ + --case "${case_dir}" --output "${out}" +done + +echo "live-smoke OK (${#CASES[@]} cases)" diff --git a/skill-registry.yaml b/skill-registry.yaml index 8819a71..755d375 100644 --- a/skill-registry.yaml +++ b/skill-registry.yaml @@ -1,4 +1,4 @@ -# StorageOps Skill Registry v0.7.1 +# StorageOps Skill Registry v0.7.2 # Machine-readable metadata for all StorageOps skills. # Skills are auto-discovered by Pi from the skills/ directory. diff --git a/storageops_cli/__init__.py b/storageops_cli/__init__.py index b0a3c7b..8433b65 100644 --- a/storageops_cli/__init__.py +++ b/storageops_cli/__init__.py @@ -1052,6 +1052,16 @@ def _doctor_readiness(s: dict) -> tuple[bool, str]: return True, "storageops --print 'hello'" +def _live_diagnosis_available(s: dict) -> bool: + """True when a live model diagnosis can run (install + Node/Pi + API key).""" + return bool( + (s["independent"] or s["merged"]) + and s["pi_ok"] + and s["node_ok"] + and s["key_source"] + ) + + def _doctor_report(s: dict, ready: bool, next_action: str) -> dict: """Machine-readable, redacted readiness report (never the raw key value).""" nv = s["node_triple"] @@ -1074,6 +1084,7 @@ def _doctor_report(s: dict, ready: bool, next_action: str) -> dict: "api_key_conflict": s["conflict"], "default_model": s["default_model"], "ready": ready, + "live_diagnosis_available": _live_diagnosis_available(s), "next_action": next_action, } @@ -1119,6 +1130,12 @@ def cmd_doctor(as_json: bool = False) -> int: _doctor_row("API key", "ok" if key_source else "warn", key_source or "not configured") if s["conflict"]: _doctor_row("Key conflict", "warn", s["conflict"]) + live_ok = _live_diagnosis_available(s) + _doctor_row( + "Live diagnosis", + "ok" if live_ok else "warn", + "available (make live-smoke)" if live_ok else "needs install + Node/Pi + API key", + ) _doctor_row("Default model", "ok", s["default_model"]) if marker: _doctor_row("Deployed", "ok", f"v{marker.get('package_version', 'unknown')} from {marker.get('package_path', 'unknown')}") diff --git a/tests/test_cli_productization.py b/tests/test_cli_productization.py index a77d718..926d590 100644 --- a/tests/test_cli_productization.py +++ b/tests/test_cli_productization.py @@ -139,6 +139,7 @@ def test_doctor_json_is_redacted_and_actionable(tmp_path, monkeypatch, capsys): assert report["next_action"] == "storageops --print 'hello'" assert report["install_mode"] == "independent" assert report["api_key_source"] == "environment variable: DEEPSEEK_API_KEY" + assert report["live_diagnosis_available"] is True # The raw key value must never appear in the machine-readable report. assert "sk-supersecretvalue-should-not-leak" not in out @@ -150,6 +151,16 @@ def test_doctor_exit_code_reflects_not_ready(tmp_path, monkeypatch, capsys): out = capsys.readouterr().out assert rc == 1 assert "Next: storageops configure" in out + assert "Live diagnosis" in out + + +def test_doctor_json_live_diagnosis_false_without_key(tmp_path, monkeypatch, capsys): + _ready_doctor_env(tmp_path, monkeypatch, key_source=None) + rc = storageops_cli.cmd_doctor(as_json=True) + report = json.loads(capsys.readouterr().out) + assert rc == 1 + assert report["live_diagnosis_available"] is False + assert report["ready"] is False def test_suggest_node_path_picks_newest_nvm(tmp_path, monkeypatch): diff --git a/tests/test_live_smoke_script.py b/tests/test_live_smoke_script.py new file mode 100644 index 0000000..063f5be --- /dev/null +++ b/tests/test_live_smoke_script.py @@ -0,0 +1,37 @@ +import os +import subprocess +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[1] +LIVE_SMOKE = ROOT / "scripts" / "live_smoke.sh" + +_STRIP_ENV = ( + "DEEPSEEK_API_KEY", + "ANTHROPIC_API_KEY", + "OPENAI_API_KEY", + "GEMINI_API_KEY", + "STORAGEOPS_MODEL_KEY", +) + + +def test_live_smoke_script_exists(): + assert LIVE_SMOKE.is_file() + + +def test_live_smoke_exits_when_live_diagnosis_unavailable(tmp_path): + """Uses an empty HOME so doctor reports live_diagnosis_available=false.""" + env = {k: v for k, v in os.environ.items() if k not in _STRIP_ENV} + env["HOME"] = str(tmp_path) + env["PATH"] = os.environ.get("PATH", "") + result = subprocess.run( + ["bash", str(LIVE_SMOKE)], + cwd=ROOT, + env=env, + capture_output=True, + text=True, + timeout=30, + ) + combined = result.stdout + result.stderr + assert result.returncode != 0 + assert "Live diagnosis is not available" in combined