调整技能位置

This commit is contained in:
马宝龙
2026-07-28 16:09:45 +08:00
parent 92b718c096
commit 984d0320a7
8 changed files with 127 additions and 15 deletions

View File

@@ -0,0 +1,80 @@
---
name: fullstack-start-verify
description: 快速启动或复用本地项目的前端和后端服务并通过进程状态、HTTP 健康检查、接口检查和可用时的浏览器冒烟测试验证整体功能。用于 Codex 需要运行全栈项目、验证前后端联调、排查启动失败,或确认用户流程是否端到端可用的场景。
---
# 全栈启动与验证
当用户要求启动前后端、验证整体功能、跑通本地联调或确认项目是否可用时使用本技能。默认走快速只读路径,目标是以最少的启动和检查步骤得到可复现结论,而不是只确认某个进程曾经启动过。
## 验证层级
先根据任务范围选择最低必要层级,完成后不要自动升级:
- **启动检查**:复用或启动前后端,检查两个健康地址;仅在需要确认数据库联通时增加 1 个已有的只读 API。
- **联调检查**:在启动检查基础上增加 1 至 2 个主要只读 API确认前端代理、后端路由和基础设施链路。
- **回归检查**:只有用户明确要求、代码发生相关变更或快速检查失败时,才执行构建、测试或浏览器冒烟。
启动检查不执行 `npm ci``mvn test`、前端构建、数据库迁移或浏览器操作。不要用固定 `sleep` 等待服务,统一让内置脚本轮询 HTTP 地址。
## 工作流程
1. 在启动前检查项目。
- 并行阅读适用的 `AGENTS.md``README.md`、根目录环境变量示例,以及前后端的项目清单文件;同一任务中不要重复读取已确认的配置。
- 确认实际使用的包管理器和启动命令。优先使用项目文档和项目自带包装器中的命令,例如 `npm``pnpm``yarn``mvnw``gradlew``uv` 等。
- 找到后端健康检查地址、前端地址、API 基础地址、所需的数据库或缓存服务,以及文档中提供的测试账号。
- 不要为了让检查通过而虚构凭据、覆盖 `.env`、执行迁移或重置数据。
2. 执行启动前检查。
- 确认所需运行时和依赖目录可用。前端已有 `node_modules` 时直接启动,不要每次重复执行 `npm ci`;仅在依赖缺失或用户明确要求重装时安装。
- 检查计划使用的端口。健康地址响应必须能确认属于当前项目时,才使用 `--reuse-running` 复用服务;复用的服务不由脚本结束。无法确认归属时不要复用,也绝不要结束未知进程。
- 仅在项目文档说明了启动方式且用户已将其纳入范围时,启动 MySQL 或 Redis 等基础设施。
- 如果缺少必要的密钥、数据库或账号,应报告阻塞原因,不要降低验证标准。
3. 使用 `scripts/start_and_verify.py` 复用或启动两个应用服务。脚本默认使用 0.25 秒快速轮询,并行等待服务就绪;使用 `--reuse-running` 时会并行探测健康地址,只启动未就绪的服务。
- 从项目根目录运行脚本。
- 传入明确的工作目录、启动命令和地址。对于健康检查之外的重要 API 路由,使用 `--check-url label=url` 添加只读检查;不要重复添加已经作为 `--backend-url` 的健康地址。
- 后端应使用项目专用的健康检查地址,而不是只检查 TCP 端口是否打开;前端应检查开发服务器根路径或已知路由。
- `--check-url` 只添加必要的只读 API。后端健康地址就绪后脚本会立即并行执行这些检查同时继续等待前端避免额外 API 检查串行阻塞启动流程。
- 脚本只管理和清理自己启动的进程。只有用户明确要求服务继续运行时,才使用 `--keep-running`
- 排查失败时使用 `--keep-logs` 保留日志。避免把密钥放在命令行参数中。
Example:
```powershell
python skills/fullstack-start-verify/scripts/start_and_verify.py `
--backend-cmd "mvn -f backend/pom.xml spring-boot:run" `
--backend-dir . `
--backend-url http://127.0.0.1:8080/api/v1/health `
--frontend-cmd "npm run dev -- --host 127.0.0.1" `
--frontend-dir frontend `
--frontend-url http://127.0.0.1:5173/ `
--reuse-running `
--check-url summary=http://127.0.0.1:8080/api/v1/users/summary
```
4. 按递进层级验证应用。
- 启动检查先确认两个健康检查地址;仅在数据库或核心联调需要时增加 1 个主要只读 API。联调检查最多增加 2 个主要只读 API避免重复请求同一资源。
- 只有代码变更、用户明确要求回归,或快速路径暴露问题时,才执行前端构建、后端测试等较慢命令。
- 只有涉及页面或交互变更且有浏览器工具时,才执行浏览器冒烟:加载页面、完成最小有意义的操作,同时验证页面可见结果和网络/API 结果。
- 除非用户明确要求写入流程且测试数据安全,否则不执行新增、修改、删除或数据库迁移。
- 如果没有浏览器工具,应明确说明未验证 UI 交互;仅凭 HTTP 检查不能声称完成了端到端验证。
5. 汇报并清理环境。
- 汇报验证任务 ID、具体命令、地址、执行的检查、通过或失败状态以及第一个可执行的失败原因。
- 分开汇报基础设施、后端、前端和浏览器结果,明确哪些部分已通过。
- 失败时给出日志位置或相关日志尾部,同时隐藏密码、令牌和连接字符串。
- 除非用户要求服务持续运行,否则在结束前确认脚本启动的进程已经停止。
## 失败处理
- 服务在地址就绪前退出,属于启动失败。重试前先检查捕获的日志。
- 超时不算通过。检查依赖是否可用、端口是否冲突、环境变量是否加载、代理配置和服务日志。
- 只允许在定位出可修复的瞬时原因后重试一次;重试时复用已确认健康的服务,不重复安装依赖或重复启动基础设施。连续失败应报告第一个可执行的根因。
- 后端健康但前端 API 调用失败,属于集成失败。检查前端代理或基础地址,以及 CORS 配置。
- 前端页面能加载但浏览器操作失败,属于用户流程失败。记录准确路由、操作、响应状态和控制台错误。
- 验证期间不要修复无关代码、修改生产配置或删除数据,除非用户明确扩大任务范围。
## 内置脚本
`scripts/start_and_verify.py` 是一个不依赖第三方库的进程运行器,负责本流程中的服务复用、启动和 HTTP 检查。脚本会为检查请求添加 `X-Request-Id`,输出验证任务 ID并行探测已运行服务后端就绪后立即执行额外检查同时继续等待前端。使用不常见选项前先阅读 `--help` 输出。脚本默认创建临时日志并在清理后删除;排查问题时传入 `--keep-logs` 保留日志。错误日志摘要会脱敏,但保留日志前仍需确认服务自身没有输出敏感信息。

View File

@@ -0,0 +1,4 @@
interface:
display_name: "全栈启动验证"
short_description: "快速复用或启动前后端并完成分层验证"
default_prompt: "使用 $fullstack-start-verify 优先复用已确认的项目服务,快速启动缺失服务并按最低必要层级完成健康、只读接口和端到端验证。"

View File

@@ -0,0 +1,431 @@
#!/usr/bin/env python3
"""启动两个本地服务,等待 HTTP 就绪后执行检查并清理进程。"""
from __future__ import annotations
import argparse
import concurrent.futures
import os
import re
import shutil
import signal
import subprocess
import sys
import tempfile
import time
import urllib.error
import urllib.request
import uuid
from dataclasses import dataclass
from pathlib import Path
@dataclass
class Service:
name: str
command: str
cwd: Path
url: str
process: subprocess.Popen[bytes] | None = None
started_by_script: bool = False
reused: bool = False
log_path: Path | None = None
log_file: object | None = None
SENSITIVE_VALUE_PATTERN = re.compile(
r"(?i)(password|token|secret|authorization|api[_-]?key)"
r"(\s*[\"']?\s*[:=]\s*[\"']?)([^\"'\s,;}\]]+)"
)
BEARER_PATTERN = re.compile(r"(?i)(Bearer\s+)[^\s,;]+")
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(
description="启动前后端命令,检查 HTTP 地址并清理进程。"
)
parser.add_argument("--backend-cmd", required=True, help="后端启动命令")
parser.add_argument("--backend-dir", default=".", help="后端工作目录")
parser.add_argument("--backend-url", required=True, help="后端健康检查地址")
parser.add_argument("--frontend-cmd", required=True, help="前端启动命令")
parser.add_argument("--frontend-dir", default=".", help="前端工作目录")
parser.add_argument("--frontend-url", required=True, help="前端就绪检查地址")
parser.add_argument(
"--check-url",
action="append",
default=[],
metavar="LABEL=URL",
help="额外的 GET 检查,可重复传入",
)
parser.add_argument(
"--timeout",
type=float,
default=60.0,
help="每个服务的就绪超时时间,单位为秒,默认 60",
)
parser.add_argument(
"--interval",
type=float,
default=0.25,
help="每次就绪检查的间隔秒数,默认 0.25",
)
parser.add_argument(
"--check-timeout",
type=float,
default=5.0,
help="额外接口检查的单请求超时时间,默认 5 秒",
)
parser.add_argument(
"--reuse-running",
action="store_true",
help="健康地址已属于当前项目时复用已运行服务,不启动或清理该服务",
)
parser.add_argument(
"--log-dir",
type=Path,
help="日志目录,运行结束后保留",
)
parser.add_argument(
"--keep-logs",
action="store_true",
help="运行结束后保留临时日志",
)
parser.add_argument(
"--keep-running",
action="store_true",
help="验证结束后保持已启动的服务运行",
)
return parser.parse_args()
def resolve_dir(value: str) -> Path:
path = Path(value).resolve()
if not path.is_dir():
raise RuntimeError(f"工作目录不存在:{path}")
return path
def start_service(service: Service, log_dir: Path) -> None:
service.log_path = log_dir / f"{service.name}.log"
service.log_file = service.log_path.open("w", encoding="utf-8", buffering=1)
creationflags = 0
start_new_session = False
if os.name == "nt":
creationflags = getattr(subprocess, "CREATE_NEW_PROCESS_GROUP", 0)
else:
start_new_session = True
try:
service.process = subprocess.Popen(
service.command,
cwd=service.cwd,
shell=True,
stdout=service.log_file,
stderr=subprocess.STDOUT,
creationflags=creationflags,
start_new_session=start_new_session,
)
service.started_by_script = True
except Exception:
service.log_file.close()
service.log_file = None
raise
print(f"启动 {service.name}:进程={service.process.pid},工作目录={service.cwd}")
def request(url: str, timeout: float, request_id: str) -> tuple[bool, str]:
request_obj = urllib.request.Request(
url,
headers={
"User-Agent": "fullstack-start-verify/1.1",
"X-Request-Id": request_id,
},
)
try:
with urllib.request.urlopen(request_obj, timeout=timeout) as response:
status = response.status
return 200 <= status < 400, str(status)
except urllib.error.HTTPError as error:
return 200 <= error.code < 400, str(error.code)
except (urllib.error.URLError, TimeoutError, OSError) as error:
reason = getattr(error, "reason", error)
return False, type(reason).__name__
def wait_for_service(
service: Service, timeout: float, interval: float, run_id: str
) -> None:
if service.process is None:
raise RuntimeError(f"{service.name} 未启动")
deadline = time.monotonic() + timeout
last_reason = "not checked"
while time.monotonic() < deadline:
exit_code = service.process.poll()
if exit_code is not None:
raise RuntimeError(f"{service.name} 在就绪检查前退出,退出码为 {exit_code}")
request_timeout = min(max(interval * 2, 0.5), 5.0)
ok, reason = request(service.url, request_timeout, f"{run_id}-{service.name}")
last_reason = reason
if ok:
print(f"通过 {service.name}{service.url} [{reason}]")
return
time.sleep(min(max(interval, 0.05), max(deadline - time.monotonic(), 0)))
raise RuntimeError(f"{service.name}{timeout:g} 秒内未就绪 [{last_reason}]")
def parse_extra_checks(values: list[str]) -> list[tuple[str, str]]:
checks: list[tuple[str, str]] = []
for value in values:
if "=" in value:
label, url = value.split("=", 1)
else:
label, url = value, value
label = label.strip() or url.strip()
url = url.strip()
if not url.startswith(("http://", "https://")):
raise RuntimeError(f"检查地址无效:{url}")
checks.append((label, url))
return checks
def wait_for_services(
services: list[Service], timeout: float, interval: float, run_id: str
) -> None:
"""并行等待服务,整体耗时取最慢服务的就绪时间。"""
executor = concurrent.futures.ThreadPoolExecutor(max_workers=len(services))
failed = True
try:
futures = [
executor.submit(wait_for_service, service, timeout, interval, run_id)
for service in services
]
done, _ = concurrent.futures.wait(
futures, return_when=concurrent.futures.FIRST_EXCEPTION
)
for future in done:
future.result()
failed = False
finally:
executor.shutdown(wait=not failed, cancel_futures=failed)
def reuse_running_services(
services: list[Service], timeout: float, run_id: str
) -> None:
"""并行探测已运行服务,避免重复启动本项目服务。"""
if not services:
return
probe_timeout = min(max(timeout, 0.5), 1.0)
def probe(service: Service) -> tuple[Service, bool, str]:
ok, reason = request(
service.url, probe_timeout, f"{run_id}-{service.name}-reuse"
)
return service, ok, reason
with concurrent.futures.ThreadPoolExecutor(max_workers=len(services)) as executor:
results = list(executor.map(probe, services))
for service, ok, reason in results:
if ok:
service.reused = True
print(f"复用 {service.name}{service.url} [{reason}]")
def wait_for_services_and_checks(
services: list[Service],
checks: list[tuple[str, str]],
timeout: float,
interval: float,
check_timeout: float,
run_id: str,
) -> None:
"""后端就绪后立即检查 API同时继续等待前端缩短总耗时。"""
if not services:
run_extra_checks(checks, check_timeout, run_id)
return
if not checks:
wait_for_services(services, timeout, interval, run_id)
return
executor = concurrent.futures.ThreadPoolExecutor(
max_workers=len(services) + max(1, len(checks))
)
failed = True
try:
service_futures = [
(
service,
executor.submit(wait_for_service, service, timeout, interval, run_id),
)
for service in services
]
backend_future = next(
(
future
for service, future in service_futures
if service.name == "backend"
),
None,
)
pending = {future for _, future in service_futures}
check_future: concurrent.futures.Future[None] | None = None
if backend_future is None:
check_future = executor.submit(run_extra_checks, checks, check_timeout, run_id)
pending.add(check_future)
while pending:
done, pending = concurrent.futures.wait(
pending, return_when=concurrent.futures.FIRST_COMPLETED
)
for future in done:
future.result()
if future is backend_future and check_future is None:
check_future = executor.submit(
run_extra_checks, checks, check_timeout, run_id
)
pending.add(check_future)
failed = False
finally:
executor.shutdown(wait=not failed, cancel_futures=failed)
def run_extra_checks(
checks: list[tuple[str, str]], timeout: float, run_id: str
) -> None:
"""并行执行只读接口检查,避免多个接口检查串行等待。"""
if not checks:
return
def execute_check(
item: tuple[int, tuple[str, str]]
) -> tuple[str, str, tuple[bool, str]]:
index, (label, url) = item
return label, url, request(url, timeout, f"{run_id}-check-{index}")
with concurrent.futures.ThreadPoolExecutor(max_workers=len(checks)) as executor:
results = list(executor.map(execute_check, enumerate(checks, start=1)))
for label, url, (ok, reason) in results:
if not ok:
raise RuntimeError(f"检查失败:{label} {url} [{reason}]")
print(f"通过 {label}{url} [{reason}]")
def stop_service(service: Service) -> None:
process = service.process
if not service.started_by_script or process is None or process.poll() is not None:
return
if os.name == "nt":
try:
subprocess.run(
["taskkill", "/PID", str(process.pid), "/T", "/F"],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
check=False,
timeout=2,
)
except subprocess.TimeoutExpired:
pass
try:
process.wait(timeout=1)
except subprocess.TimeoutExpired:
process.kill()
try:
process.wait(timeout=1)
except subprocess.TimeoutExpired:
process.returncode = -1
except OSError:
# taskkill 已经结束进程,但 Windows 可能同时使句柄失效。
process.returncode = -1
else:
try:
os.killpg(process.pid, signal.SIGTERM)
except ProcessLookupError:
return
try:
process.wait(timeout=5)
except subprocess.TimeoutExpired:
os.killpg(process.pid, signal.SIGKILL)
def tail(path: Path | None, lines: int = 20) -> list[str]:
if path is None or not path.exists():
return []
return path.read_text(encoding="utf-8", errors="replace").splitlines()[-lines:]
def redact(line: str) -> str:
"""仅在终端输出日志时脱敏,避免错误摘要泄露凭据。"""
line = SENSITIVE_VALUE_PATTERN.sub(r"\1\2[REDACTED]", line)
return BEARER_PATTERN.sub(r"\1[REDACTED]", line)
def main() -> int:
args = parse_args()
if args.timeout <= 0 or args.interval <= 0 or args.check_timeout <= 0:
raise SystemExit("timeout、interval 和 check-timeout 必须大于 0")
run_id = uuid.uuid4().hex[:12]
print(f"验证任务 ID{run_id}")
checks = parse_extra_checks(args.check_url)
temp_log_dir: Path | None = None
log_dir = args.log_dir
if log_dir is None:
temp_log_dir = Path(tempfile.mkdtemp(prefix="fullstack-start-verify-"))
log_dir = temp_log_dir
log_dir = log_dir.resolve()
log_dir.mkdir(parents=True, exist_ok=True)
services = [
Service("backend", args.backend_cmd, resolve_dir(args.backend_dir), args.backend_url),
Service("frontend", args.frontend_cmd, resolve_dir(args.frontend_dir), args.frontend_url),
]
failure: Exception | None = None
try:
if args.reuse_running:
reuse_running_services(services, args.check_timeout, run_id)
for service in services:
if not service.reused:
start_service(service, log_dir)
pending_services = [service for service in services if not service.reused]
wait_for_services_and_checks(
pending_services,
checks,
args.timeout,
args.interval,
args.check_timeout,
run_id,
)
print(f"全栈验证通过FULLSTACK_VERIFY=PASS任务 ID{run_id}")
return 0
except (OSError, RuntimeError, ValueError) as error:
failure = error
print(f"全栈验证失败:{error}", file=sys.stderr)
for service in services:
for line in tail(service.log_path):
print(f"日志 {service.name}{redact(line)}", file=sys.stderr)
return 1
finally:
if not args.keep_running:
for service in reversed(services):
stop_service(service)
for service in services:
if service.log_file is not None:
service.log_file.close()
if args.keep_running:
print(f"服务仍在运行,日志目录:{log_dir}")
elif temp_log_dir is not None and not args.keep_logs:
shutil.rmtree(temp_log_dir, ignore_errors=True)
elif temp_log_dir is not None:
print(f"日志已保留:{temp_log_dir}")
elif failure is not None:
print(f"日志已保留:{log_dir}")
if __name__ == "__main__":
raise SystemExit(main())