From a6f9c07afa78210137e2da8caafd8c616091ae45 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 11 Apr 2026 12:49:38 +0000 Subject: [PATCH 1/3] feat: add AI agent entry docs, skill index, and health json output Agent-Logs-Url: https://github.com/uuhan/workhorse/sessions/e474f58f-0368-469b-bff6-62f30efc0e8a Co-authored-by: uuhan <3286111+uuhan@users.noreply.github.com> --- .github/workflows/ci.yml | 11 ++++ AGENTS.md | 2 + AI_AGENT.md | 60 ++++++++++++++++++ README.en.md | 8 +++ README.md | 8 +++ cargo-work/src/command/health.rs | 59 +++++++++++++----- cargo-work/src/command/ping.rs | 2 +- cargo-work/src/main.rs | 2 +- cargo-work/src/options.rs | 8 ++- docs/agent-playbooks.md | 80 ++++++++++++++++++++++++ horsed/src/ssh/mod.rs | 16 +++-- justfile | 4 ++ scripts/agent-regression-cases.json | 86 ++++++++++++++++++++++++++ scripts/check-agent-docs.py | 85 +++++++++++++++++++++++++ scripts/check-agent-regression.py | 61 ++++++++++++++++++ skills/index.json | 96 +++++++++++++++++++++++++++++ 16 files changed, 564 insertions(+), 24 deletions(-) create mode 100644 AI_AGENT.md create mode 100644 docs/agent-playbooks.md create mode 100644 scripts/agent-regression-cases.json create mode 100755 scripts/check-agent-docs.py create mode 100755 scripts/check-agent-regression.py create mode 100644 skills/index.json diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a5c767f..a497041 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -10,6 +10,17 @@ env: CARGO_TERM_COLOR: always jobs: + agent-validation: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Build cargo-work for CLI regression checks + run: cargo build --bin cargo-work + - name: Agent docs consistency checks + run: python3 scripts/check-agent-docs.py + - name: Agent regression checks + run: python3 scripts/check-agent-regression.py + build-linux: runs-on: ubuntu-latest diff --git a/AGENTS.md b/AGENTS.md index 3f7a7ba..8684301 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -50,6 +50,8 @@ Notes: - Add regression tests for protocol, IPC, and path-handling fixes (common failure areas). ## Project Skills +- `./AI_AGENT.md`: single entry for AI agents, including routing rules, standard commands, success criteria, and safety boundaries. +- `./skills/index.json`: machine-readable skill index for deterministic task-to-skill routing and risk classification. - `./skills/workhorse/SKILL.md`: top-level dispatcher for this repo; use first when the task broadly mentions Workhorse. - `./skills/workhorse-cargo-work/SKILL.md`: entry skill for client-side `cargo work` usage. - `./skills/workhorse-remote-build/SKILL.md`: remote Cargo and `just` build/test/lint/run workflows. diff --git a/AI_AGENT.md b/AI_AGENT.md new file mode 100644 index 0000000..91ac224 --- /dev/null +++ b/AI_AGENT.md @@ -0,0 +1,60 @@ +# AI Agent Entry (Claude Code / Codex) + +This file is the single entry point for AI agents operating on this repository. + +## Primary Routing + +1. Read `/home/runner/work/workhorse/workhorse/skills/index.json`. +2. Classify task into one domain: + - `cargo-work` client workflow + - `horsed` server workflow + - cross-boundary workflow +3. Dispatch to the matching skill in `skills/`. +4. Use standard playbooks in `/home/runner/work/workhorse/workhorse/docs/agent-playbooks.md`. + +## Task Classification -> Skill + +- General Workhorse triage -> `skills/workhorse/SKILL.md` +- `cargo work` remote build/test/check/clippy/run/just -> `skills/workhorse-remote-build/SKILL.md` +- `cargo work ssh` / raw remote commands / forwarding / proxy -> `skills/workhorse-remote-access/SKILL.md` +- `get/scp/push/pull/ping/health/logs/job` -> `skills/workhorse-artifact-sync/SKILL.md` +- `horsed` bootstrap / first user / setup mode -> `skills/workhorse-horsed-setup/SKILL.md` +- `horsed` ops / service manager / troubleshooting -> `skills/workhorse-horsed-ops/SKILL.md` +- `horsed` code, migration, protocol, tests -> `skills/workhorse-horsed-dev/SKILL.md` + +## Standard Commands + +- Build binaries: `cargo build --bin cargo-work --bin horsed` +- Workspace tests: `cargo test --verbose` +- Health (human): `cargo work health` +- Health (machine): `cargo work health --json` +- Logs: `cargo work logs` / `cargo work logs -f` +- Jobs: `cargo work job list` / `cargo work job attach -f` + +## Success Criteria + +- Build/test tasks: command exits with code `0`. +- Remote build tasks: expected artifact exists on remote and can be fetched with `cargo work get`. +- Health check (JSON mode): JSON parse succeeds and includes `status`, `protocol`, `ulimit_nofile` fields. +- Ops tasks: service state and logs match expected behavior. + +## Safety Boundaries + +- Low risk: read-only inspection (`ping`, `health`, `logs`, `job list`). +- Medium risk: remote command execution, file sync, interactive shell, forwarding/proxy. +- High risk: `horsed --dangerous`, service restart/replace, user/key admin mutation. + +## Confirmation Policy + +Require explicit confirmation from the user before high-risk actions: + +- enabling `--dangerous` +- restarting/stopping production `horsed` +- overwriting binaries/state in remote deploy paths +- destructive user/key/admin operations + +## Related Assets + +- Agent skill index: `/home/runner/work/workhorse/workhorse/skills/index.json` +- Agent playbooks: `/home/runner/work/workhorse/workhorse/docs/agent-playbooks.md` +- Human-oriented guidance: `/home/runner/work/workhorse/workhorse/README.md`, `/home/runner/work/workhorse/workhorse/README.en.md`, `/home/runner/work/workhorse/workhorse/AGENTS.md` diff --git a/README.en.md b/README.en.md index 5c9d7c3..b310606 100644 --- a/README.en.md +++ b/README.en.md @@ -28,6 +28,12 @@ Recommended quick start: If you add or rename any skill, update the Project Skills section in `AGENTS.md` in the same change so docs and directory structure stay aligned. +### AI Agent Entry (Claude Code / Codex) + +- Unified entry: `AI_AGENT.md` +- Machine-readable skill index: `skills/index.json` +- Standard task playbooks: `docs/agent-playbooks.md` + ### Supported Platforms - Linux @@ -269,6 +275,8 @@ cargo work health RUST_LOG=info cargo work health # For trace-stage diagnostics: RUST_LOG=info WH_DEBUG=1 cargo work health +# Machine-readable output (recommended for AI agents): +cargo work health --json ``` Admins can manage users and public keys with the `admin` subcommand: diff --git a/README.md b/README.md index ecdde57..ee4939c 100644 --- a/README.md +++ b/README.md @@ -32,6 +32,12 @@ n. 驮马,做粗工者,重负荷机器 如果你在扩展工作流,请在新增或重命名 skill 后同步更新 `AGENTS.md` 的 Project Skills 列表,保持入口文档和实际目录一致。 +### AI Agent 入口(Claude Code / Codex) + +- 统一入口:`AI_AGENT.md` +- 机器可读技能索引:`skills/index.json` +- 标准任务配方:`docs/agent-playbooks.md` + ### 支持的平台 - Linux @@ -265,6 +271,8 @@ cargo work health RUST_LOG=info cargo work health # 排障时可附加 trace 流: RUST_LOG=info WH_DEBUG=1 cargo work health +# 机器可读输出(推荐给 AI Agent): +cargo work health --json ``` 管理员可以使用 `admin` 子命令管理用户和公钥: diff --git a/cargo-work/src/command/health.rs b/cargo-work/src/command/health.rs index 8b74b45..c7fa8f3 100644 --- a/cargo-work/src/command/health.rs +++ b/cargo-work/src/command/health.rs @@ -3,6 +3,7 @@ use crate::options::HealthOptions; use color_eyre::eyre::WrapErr; use color_eyre::eyre::{anyhow, ContextCompat, Result}; use git2::Repository; +use serde_json::json; use stable::data::v2::{self, Body}; use std::path::Path; use tokio::io::AsyncWriteExt; @@ -14,7 +15,7 @@ pub async fn run(sk: &Path, mut options: HealthOptions) -> Result<()> { super::log_stage(&trace_id, action, "resolve.start"); let repo = Repository::discover(".")?; - if let Some(remote) = options.remote { + if let Some(remote) = options.host { options.horse.remote.replace(remote); } @@ -38,7 +39,9 @@ pub async fn run(sk: &Path, mut options: HealthOptions) -> Result<()> { match call_health_once(sk, &options.horse, host, &trace_id, Body::HealthCheckV2).await { Ok(body) => body, Err(err) => { - tracing::warn!("health v2 失败, 回退到 v1: {}", err); + if !options.json { + tracing::warn!("health v2 失败, 回退到 v1: {}", err); + } call_health_once(sk, &options.horse, host, &trace_id, Body::HealthCheck).await? } }; @@ -52,25 +55,49 @@ pub async fn run(sk: &Path, mut options: HealthOptions) -> Result<()> { family, default_shell, } => { - tracing::info!("Health OK."); - tracing::info!("Server version: {} ({})", version, commit); - tracing::info!("Server OS: {} / {} ({})", os, arch, family); - tracing::info!( - "Server default shell: {}", - default_shell.unwrap_or_else(|| "unknown".to_string()) - ); - if let Some(lim) = ulimit { - tracing::info!("Server ulimit -n: {}", lim); + if options.json { + let out = json!({ + "status": "ok", + "protocol": "v2", + "version": version, + "commit": commit, + "os": os, + "arch": arch, + "family": family, + "default_shell": default_shell.unwrap_or_else(|| "unknown".to_string()), + "ulimit_nofile": ulimit, + }); + println!("{}", serde_json::to_string_pretty(&out)?); } else { - tracing::info!("Server ulimit -n: unknown"); + tracing::info!("Health OK."); + tracing::info!("Server version: {} ({})", version, commit); + tracing::info!("Server OS: {} / {} ({})", os, arch, family); + tracing::info!( + "Server default shell: {}", + default_shell.unwrap_or_else(|| "unknown".to_string()) + ); + if let Some(lim) = ulimit { + tracing::info!("Server ulimit -n: {}", lim); + } else { + tracing::info!("Server ulimit -n: unknown"); + } } } Body::HealthStatus { ulimit } => { - tracing::info!("Health OK (legacy)."); - if let Some(lim) = ulimit { - tracing::info!("Server ulimit -n: {}", lim); + if options.json { + let out = json!({ + "status": "ok", + "protocol": "v1", + "ulimit_nofile": ulimit, + }); + println!("{}", serde_json::to_string_pretty(&out)?); } else { - tracing::info!("Server ulimit -n: unknown"); + tracing::info!("Health OK (legacy)."); + if let Some(lim) = ulimit { + tracing::info!("Server ulimit -n: {}", lim); + } else { + tracing::info!("Server ulimit -n: unknown"); + } } } _ => { diff --git a/cargo-work/src/command/ping.rs b/cargo-work/src/command/ping.rs index 321f652..67e9dee 100644 --- a/cargo-work/src/command/ping.rs +++ b/cargo-work/src/command/ping.rs @@ -16,7 +16,7 @@ pub async fn run(sk: &Path, mut options: PingOptions) -> Result<()> { let repo = Repository::discover(".")?; let head = repo.head()?; - if let Some(remote) = options.remote { + if let Some(remote) = options.host { // arg comes first options.horse.remote.replace(remote); } diff --git a/cargo-work/src/main.rs b/cargo-work/src/main.rs index d1fe46c..f32961f 100644 --- a/cargo-work/src/main.rs +++ b/cargo-work/src/main.rs @@ -219,7 +219,7 @@ async fn main() -> Result<()> { let options = PingOptions { horse: horse.clone(), count: Some(3), - remote: None, + host: None, }; if let Err(err) = ping::run(&key, options).await { tracing::error!("执行失败: {}", err); diff --git a/cargo-work/src/options.rs b/cargo-work/src/options.rs index d22f2e9..3965941 100644 --- a/cargo-work/src/options.rs +++ b/cargo-work/src/options.rs @@ -230,7 +230,8 @@ pub struct PingOptions { pub horse: HorseOptions, #[clap(short, long, help = "指定次数")] pub count: Option, - pub remote: Option, + #[clap(value_name = "REMOTE")] + pub host: Option, } #[derive(Clone, Debug, Args)] @@ -261,7 +262,10 @@ pub struct JustOptions { pub struct HealthOptions { #[clap(flatten)] pub horse: HorseOptions, - pub remote: Option, + #[clap(value_name = "REMOTE")] + pub host: Option, + #[clap(long, help = "以 JSON 格式输出健康信息")] + pub json: bool, } #[derive(Clone, Debug, Args)] diff --git a/docs/agent-playbooks.md b/docs/agent-playbooks.md new file mode 100644 index 0000000..913d910 --- /dev/null +++ b/docs/agent-playbooks.md @@ -0,0 +1,80 @@ +# Agent Playbooks + +## 1) Remote Build Playbook + +### Preconditions +- Target server is resolvable via `--repo`, `--repo-name`, or git remote `horsed`. +- SSH key is available (`--ssh-key` or default key path). + +### Steps +1. `cargo work ping --count 1` +2. `cargo work build --release` (or `cargo work test` for test tasks) +3. If needed, attach output: `cargo work job list` then `cargo work job attach -f` + +### Fallback +- If repo/host resolve fails: provide `--repo ssh://git@HOST:2222/ns/repo.git`. +- If command hangs: retry with `RUST_LOG=info WH_DEBUG=1` to collect staged traces. + +### Acceptance Signals +- Exit code is `0`. +- Output contains successful completion from Cargo. + +## 2) Horsed Deploy Playbook + +### Preconditions +- Remote branch is up to date. +- Explicit confirmation if service restart is required. + +### Steps (Linux/macOS) +1. `just install-work` +2. `HORSED_SHELL=/bin/bash cargo work just install-horsed` +3. `HORSED_SHELL=/bin/bash cargo work -- systemctl --user restart horsed` +4. `cargo work health --json` + +### Steps (Windows) +1. `just install-work` +2. `HORSED_SHELL=powershell.exe cargo work just deploy-horsed` +3. `cargo work health --json` + +### Fallback +- If `nu` is missing on server, use `/bin/bash`, `/bin/sh`, or `powershell.exe`. +- If post-restart health fails, inspect: `cargo work logs -f`. + +### Acceptance Signals +- `health --json` returns parseable JSON with `status: "ok"`. + +## 3) Artifact Retrieval Playbook + +### Preconditions +- Remote build already completed. + +### Steps +1. Retrieve a file: `cargo work get target/release/ -f` +2. Retrieve a directory: `cargo work get target -f` +3. Alternative stream copy: `cargo work scp ` + +### Fallback +- If local file exists and retrieval fails, use `-f` or `--outfile`. + +### Acceptance Signals +- Retrieved path exists locally. +- Artifact checksum/size matches expected output (when available). + +## 4) Health/Logs Troubleshooting Playbook + +### Preconditions +- Server is reachable. + +### Steps +1. `cargo work ping --count 3` +2. `cargo work health --json` +3. `cargo work logs` (or `cargo work logs -f`) +4. `cargo work job list` + +### Fallback +- If health output seems empty in normal mode: `RUST_LOG=info cargo work health` +- For deeper traces: `RUST_LOG=info WH_DEBUG=1 cargo work health` + +### Acceptance Signals +- JSON includes stable fields: `status`, `protocol`, `ulimit_nofile`. +- Logs show expected service state transitions. diff --git a/horsed/src/ssh/mod.rs b/horsed/src/ssh/mod.rs index dc82292..d991366 100644 --- a/horsed/src/ssh/mod.rs +++ b/horsed/src/ssh/mod.rs @@ -883,10 +883,18 @@ impl AppServer { // 渐进退避: 前几次快速重试, 之后逐渐放慢 let wait = match idle_count { 1..=3 => tokio::task::yield_now().await, - 4..=20 => tokio::time::sleep( - std::time::Duration::from_micros(100)).await, - _ => tokio::time::sleep( - std::time::Duration::from_millis(1)).await, + 4..=20 => { + tokio::time::sleep( + std::time::Duration::from_micros(100), + ) + .await + } + _ => { + tokio::time::sleep( + std::time::Duration::from_millis(1), + ) + .await + } }; } Ok(buf) => { diff --git a/justfile b/justfile index 3d24550..8cbe161 100644 --- a/justfile +++ b/justfile @@ -38,3 +38,7 @@ changes: get-release: cargo work get ./target/release/cargo-work -f cargo work get ./target/release/horsed -f + +agent-check: + @python3 scripts/check-agent-docs.py + @python3 scripts/check-agent-regression.py diff --git a/scripts/agent-regression-cases.json b/scripts/agent-regression-cases.json new file mode 100644 index 0000000..e4b662e --- /dev/null +++ b/scripts/agent-regression-cases.json @@ -0,0 +1,86 @@ +[ + { + "id": "case-01", + "query": "帮我在远端跑 cargo test", + "expected_skill": "workhorse-remote-build", + "expected_playbook": "Remote Build Playbook", + "requires_confirmation": false + }, + { + "id": "case-02", + "query": "开个 ssh shell 并转发 3000", + "expected_skill": "workhorse-remote-access", + "expected_playbook": "Health/Logs Troubleshooting Playbook", + "requires_confirmation": false + }, + { + "id": "case-03", + "query": "把远端构建好的二进制拉回来", + "expected_skill": "workhorse-artifact-sync", + "expected_playbook": "Artifact Retrieval Playbook", + "requires_confirmation": false + }, + { + "id": "case-04", + "query": "我想看看服务端健康情况", + "expected_skill": "workhorse-artifact-sync", + "expected_playbook": "Health/Logs Troubleshooting Playbook", + "requires_confirmation": false + }, + { + "id": "case-05", + "query": "首次部署 horsed 怎么做", + "expected_skill": "workhorse-horsed-setup", + "expected_playbook": "Horsed Deploy Playbook", + "requires_confirmation": true + }, + { + "id": "case-06", + "query": "给我重启远端 horsed 服务", + "expected_skill": "workhorse-horsed-ops", + "expected_playbook": "Horsed Deploy Playbook", + "requires_confirmation": true + }, + { + "id": "case-07", + "query": "我要改 horsed 的 setup 逻辑", + "expected_skill": "workhorse-horsed-dev", + "expected_playbook": "Remote Build Playbook", + "requires_confirmation": false + }, + { + "id": "case-08", + "query": "帮我分类这条 Workhorse 请求", + "expected_skill": "workhorse", + "expected_playbook": "Health/Logs Troubleshooting Playbook", + "requires_confirmation": false + }, + { + "id": "case-09", + "query": "运行 cargo work ping 三次", + "expected_skill": "workhorse-artifact-sync", + "expected_playbook": "Health/Logs Troubleshooting Playbook", + "requires_confirmation": false + }, + { + "id": "case-10", + "query": "使用 dangerous 模式录入公钥", + "expected_skill": "workhorse-horsed-setup", + "expected_playbook": "Horsed Deploy Playbook", + "requires_confirmation": true + }, + { + "id": "case-11", + "query": "我要看远端运行中的 job 并 attach", + "expected_skill": "workhorse-artifact-sync", + "expected_playbook": "Health/Logs Troubleshooting Playbook", + "requires_confirmation": false + }, + { + "id": "case-12", + "query": "我需要一个统一 AI agent 入口", + "expected_skill": "workhorse", + "expected_playbook": "Remote Build Playbook", + "requires_confirmation": false + } +] diff --git a/scripts/check-agent-docs.py b/scripts/check-agent-docs.py new file mode 100755 index 0000000..0287c12 --- /dev/null +++ b/scripts/check-agent-docs.py @@ -0,0 +1,85 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import json +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] + + +def fail(msg: str) -> None: + raise SystemExit(f"[agent-doc-check] {msg}") + + +required_files = [ + ROOT / "AI_AGENT.md", + ROOT / "docs" / "agent-playbooks.md", + ROOT / "skills" / "index.json", + ROOT / "README.md", + ROOT / "README.en.md", +] +for path in required_files: + if not path.exists(): + fail(f"missing required file: {path}") + +index = json.loads((ROOT / "skills" / "index.json").read_text(encoding="utf-8")) +if "skills" not in index or not isinstance(index["skills"], list): + fail("skills/index.json must contain a 'skills' array") + +required_fields = {"id", "entry", "keywords", "preconditions", "recommended_commands", "risk", "requires_confirmation"} +skill_ids = set() +entry_paths = set() +for item in index["skills"]: + missing = required_fields - set(item.keys()) + if missing: + fail(f"skill entry missing fields: {sorted(missing)}") + sid = item["id"] + if sid in skill_ids: + fail(f"duplicate skill id: {sid}") + skill_ids.add(sid) + + entry = ROOT / item["entry"] + if not entry.exists(): + fail(f"skill entry path does not exist: {entry}") + entry_paths.add(entry.resolve()) + + risk = item["risk"] + if risk.get("level") not in {"low", "medium", "high"}: + fail(f"invalid risk level for skill {sid}: {risk.get('level')}") + + if not isinstance(item["keywords"], list) or not item["keywords"]: + fail(f"keywords must be a non-empty list for skill {sid}") + +all_skill_docs = sorted((ROOT / "skills").glob("*/SKILL.md")) +all_skill_docs.append(ROOT / "skills" / "workhorse" / "SKILL.md") +all_skill_docs = sorted({p.resolve() for p in all_skill_docs}) + +# Keep only actual files (glob above may include duplicates from set ops) +all_skill_docs = [p for p in all_skill_docs if p.exists()] +for doc in all_skill_docs: + if doc not in entry_paths: + fail(f"skill doc not indexed in skills/index.json: {doc}") + +ai_agent_text = (ROOT / "AI_AGENT.md").read_text(encoding="utf-8") +for token in ["Task Classification", "Standard Commands", "Success Criteria", "Safety Boundaries", "skills/index.json"]: + if token not in ai_agent_text: + fail(f"AI_AGENT.md missing section/token: {token}") + +playbooks_text = (ROOT / "docs" / "agent-playbooks.md").read_text(encoding="utf-8") +for token in [ + "Remote Build Playbook", + "Horsed Deploy Playbook", + "Artifact Retrieval Playbook", + "Health/Logs Troubleshooting Playbook", +]: + if token not in playbooks_text: + fail(f"agent-playbooks.md missing playbook: {token}") + +for readme in [ROOT / "README.md", ROOT / "README.en.md"]: + text = readme.read_text(encoding="utf-8") + if "AI_AGENT.md" not in text: + fail(f"{readme.name} must mention AI_AGENT.md") + if "health --json" not in text: + fail(f"{readme.name} must mention health --json") + +print("[agent-doc-check] OK") diff --git a/scripts/check-agent-regression.py b/scripts/check-agent-regression.py new file mode 100755 index 0000000..5fbc707 --- /dev/null +++ b/scripts/check-agent-regression.py @@ -0,0 +1,61 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import json +import subprocess +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] + + +def fail(msg: str) -> None: + raise SystemExit(f"[agent-regression] {msg}") + + +index = json.loads((ROOT / "skills" / "index.json").read_text(encoding="utf-8")) +skills = {s["id"]: s for s in index["skills"]} + +cases = json.loads((ROOT / "scripts" / "agent-regression-cases.json").read_text(encoding="utf-8")) +if not (10 <= len(cases) <= 20): + fail(f"expected 10-20 regression cases, got {len(cases)}") + +for case in cases: + for field in ["id", "query", "expected_skill", "expected_playbook", "requires_confirmation"]: + if field not in case: + fail(f"case missing field {field}: {case}") + + sid = case["expected_skill"] + if sid not in skills: + fail(f"case references unknown skill {sid}: {case['id']}") + + skill_requires_confirmation = bool(skills[sid]["requires_confirmation"]) + if case["requires_confirmation"] and not skill_requires_confirmation: + fail(f"case {case['id']} expects confirmation but skill {sid} does not require it") + +playbooks_text = (ROOT / "docs" / "agent-playbooks.md").read_text(encoding="utf-8") +for case in cases: + if case["expected_playbook"] not in playbooks_text: + fail(f"case {case['id']} references missing playbook: {case['expected_playbook']}") + +binary = ROOT / "target" / "debug" / "cargo-work" +if not binary.exists(): + fail("target/debug/cargo-work does not exist, run cargo build --bin cargo-work first") + +health_help = subprocess.run( + [str(binary), "work", "health", "--help"], + cwd=str(ROOT), + check=True, + capture_output=True, + text=True, +).stdout +if "--json" not in health_help: + fail("health --help does not expose --json") + +for cmd in ( + [str(binary), "work", "job", "--help"], + [str(binary), "work", "logs", "--help"], + [str(binary), "work", "ping", "--help"], +): + subprocess.run(cmd, cwd=str(ROOT), check=True, capture_output=True, text=True) + +print("[agent-regression] OK") diff --git a/skills/index.json b/skills/index.json new file mode 100644 index 0000000..1c43131 --- /dev/null +++ b/skills/index.json @@ -0,0 +1,96 @@ +{ + "version": 1, + "description": "Machine-readable skill index for AI agents", + "confirmation_policy": { + "require_confirmation_for_risk_levels": ["high"], + "high_risk_examples": [ + "horsed --dangerous", + "production horsed restart", + "binary overwrite on remote server", + "admin user/key destructive operations" + ] + }, + "skills": [ + { + "id": "workhorse", + "entry": "skills/workhorse/SKILL.md", + "keywords": ["workhorse", "route", "dispatch", "triage"], + "preconditions": ["request mentions Workhorse but domain is not clear"], + "recommended_commands": [], + "risk": { "level": "low", "notes": ["routing only"] }, + "requires_confirmation": false + }, + { + "id": "workhorse-cargo-work", + "entry": "skills/workhorse-cargo-work/SKILL.md", + "keywords": ["cargo work", "remote build", "remote command", "artifact sync"], + "preconditions": ["task belongs to client side"], + "recommended_commands": ["cargo work --help"], + "risk": { "level": "low", "notes": ["dispatcher"] }, + "requires_confirmation": false + }, + { + "id": "workhorse-remote-build", + "entry": "skills/workhorse-remote-build/SKILL.md", + "keywords": ["build", "test", "check", "clippy", "run", "just"], + "preconditions": ["target host is resolvable", "ssh key is available"], + "recommended_commands": ["cargo work build --release", "cargo work test", "cargo work clippy -- -D warnings"], + "risk": { "level": "medium", "notes": ["executes remote build/test workflows"] }, + "requires_confirmation": false + }, + { + "id": "workhorse-remote-access", + "entry": "skills/workhorse-remote-access/SKILL.md", + "keywords": ["ssh", "port forwarding", "proxy", "remote shell", "cargo work --"], + "preconditions": ["target host is resolvable", "interactive/adhoc execution is required"], + "recommended_commands": ["cargo work ssh", "cargo work ssh -L 3000:127.0.0.1:3000", "cargo work -- ls -al"], + "risk": { "level": "medium", "notes": ["interactive remote command execution and forwarding"] }, + "requires_confirmation": false + }, + { + "id": "workhorse-artifact-sync", + "entry": "skills/workhorse-artifact-sync/SKILL.md", + "keywords": ["get", "scp", "push", "pull", "health", "logs", "job"], + "preconditions": ["target host is resolvable"], + "recommended_commands": ["cargo work get target/release/horsed -f", "cargo work health --json", "cargo work job list"], + "risk": { "level": "medium", "notes": ["can modify git state with push/pull"] }, + "requires_confirmation": false + }, + { + "id": "workhorse-horsed", + "entry": "skills/workhorse-horsed/SKILL.md", + "keywords": ["horsed", "setup", "server", "autostart", "ops", "dev"], + "preconditions": ["task belongs to server side"], + "recommended_commands": ["horsed --help"], + "risk": { "level": "low", "notes": ["dispatcher"] }, + "requires_confirmation": false + }, + { + "id": "workhorse-horsed-setup", + "entry": "skills/workhorse-horsed-setup/SKILL.md", + "keywords": ["bootstrap", "first user", "2223", "dangerous"], + "preconditions": ["new or reset horsed server"], + "recommended_commands": ["horsed -f --show-log", "ssh -p 2223 USER@HOST", "horsed -f --show-log --dangerous"], + "risk": { "level": "high", "notes": ["dangerous mode can admit arbitrary keys"] }, + "requires_confirmation": true + }, + { + "id": "workhorse-horsed-ops", + "entry": "skills/workhorse-horsed-ops/SKILL.md", + "keywords": ["systemd", "launchd", "restart", "logs", "health", "troubleshooting"], + "preconditions": ["running horsed service"], + "recommended_commands": ["cargo work logs -f", "cargo work health --json", "cargo work -- systemctl --user restart horsed"], + "risk": { "level": "high", "notes": ["service restart and runtime operations may impact availability"] }, + "requires_confirmation": true + }, + { + "id": "workhorse-horsed-dev", + "entry": "skills/workhorse-horsed-dev/SKILL.md", + "keywords": ["migration", "protocol", "ssh", "db", "server code"], + "preconditions": ["user asks for server implementation changes"], + "recommended_commands": ["cargo build -p horsed", "cargo test -p horsed --verbose"], + "risk": { "level": "medium", "notes": ["code-change scope"] }, + "requires_confirmation": false + } + ] +} From 9efd71de1b16acf3fa188c41f2ab42af48450b63 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 11 Apr 2026 12:49:56 +0000 Subject: [PATCH 2/3] chore: drop unrelated horsed formatting change Agent-Logs-Url: https://github.com/uuhan/workhorse/sessions/e474f58f-0368-469b-bff6-62f30efc0e8a Co-authored-by: uuhan <3286111+uuhan@users.noreply.github.com> --- horsed/src/ssh/mod.rs | 16 ++++------------ 1 file changed, 4 insertions(+), 12 deletions(-) diff --git a/horsed/src/ssh/mod.rs b/horsed/src/ssh/mod.rs index d991366..dc82292 100644 --- a/horsed/src/ssh/mod.rs +++ b/horsed/src/ssh/mod.rs @@ -883,18 +883,10 @@ impl AppServer { // 渐进退避: 前几次快速重试, 之后逐渐放慢 let wait = match idle_count { 1..=3 => tokio::task::yield_now().await, - 4..=20 => { - tokio::time::sleep( - std::time::Duration::from_micros(100), - ) - .await - } - _ => { - tokio::time::sleep( - std::time::Duration::from_millis(1), - ) - .await - } + 4..=20 => tokio::time::sleep( + std::time::Duration::from_micros(100)).await, + _ => tokio::time::sleep( + std::time::Duration::from_millis(1)).await, }; } Ok(buf) => { From 762fc47ea8b53d10f62ba7db3a78e962ad1c328e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 11 Apr 2026 12:53:54 +0000 Subject: [PATCH 3/3] docs: replace absolute paths in AI agent entry Agent-Logs-Url: https://github.com/uuhan/workhorse/sessions/e474f58f-0368-469b-bff6-62f30efc0e8a Co-authored-by: uuhan <3286111+uuhan@users.noreply.github.com> --- AI_AGENT.md | 10 +++++----- horsed/src/ssh/mod.rs | 16 ++++++++++++---- 2 files changed, 17 insertions(+), 9 deletions(-) diff --git a/AI_AGENT.md b/AI_AGENT.md index 91ac224..b93d31b 100644 --- a/AI_AGENT.md +++ b/AI_AGENT.md @@ -4,13 +4,13 @@ This file is the single entry point for AI agents operating on this repository. ## Primary Routing -1. Read `/home/runner/work/workhorse/workhorse/skills/index.json`. +1. Read `skills/index.json`. 2. Classify task into one domain: - `cargo-work` client workflow - `horsed` server workflow - cross-boundary workflow 3. Dispatch to the matching skill in `skills/`. -4. Use standard playbooks in `/home/runner/work/workhorse/workhorse/docs/agent-playbooks.md`. +4. Use standard playbooks in `docs/agent-playbooks.md`. ## Task Classification -> Skill @@ -55,6 +55,6 @@ Require explicit confirmation from the user before high-risk actions: ## Related Assets -- Agent skill index: `/home/runner/work/workhorse/workhorse/skills/index.json` -- Agent playbooks: `/home/runner/work/workhorse/workhorse/docs/agent-playbooks.md` -- Human-oriented guidance: `/home/runner/work/workhorse/workhorse/README.md`, `/home/runner/work/workhorse/workhorse/README.en.md`, `/home/runner/work/workhorse/workhorse/AGENTS.md` +- Agent skill index: `skills/index.json` +- Agent playbooks: `docs/agent-playbooks.md` +- Human-oriented guidance: `README.md`, `README.en.md`, `AGENTS.md` diff --git a/horsed/src/ssh/mod.rs b/horsed/src/ssh/mod.rs index dc82292..d991366 100644 --- a/horsed/src/ssh/mod.rs +++ b/horsed/src/ssh/mod.rs @@ -883,10 +883,18 @@ impl AppServer { // 渐进退避: 前几次快速重试, 之后逐渐放慢 let wait = match idle_count { 1..=3 => tokio::task::yield_now().await, - 4..=20 => tokio::time::sleep( - std::time::Duration::from_micros(100)).await, - _ => tokio::time::sleep( - std::time::Duration::from_millis(1)).await, + 4..=20 => { + tokio::time::sleep( + std::time::Duration::from_micros(100), + ) + .await + } + _ => { + tokio::time::sleep( + std::time::Duration::from_millis(1), + ) + .await + } }; } Ok(buf) => {