Merge authoritative main into the task branch
Why: include the current integration baseline before verification. Evidence: fetched origin/main=d9ac28f89159e17781e2a8ef3094965130f29d2f. Impact: integrate upstream changes without rewriting task history. Refs: task-sync
This commit is contained in:
Executable
+68
@@ -0,0 +1,68 @@
|
||||
#!/usr/bin/env bash
|
||||
# 共享 Development-Manager 执行器薄入口:核对 deps/development-manager 与 index 钉住的提交一致且无修改,
|
||||
# 构建(或复用缓存的)dm-server 二进制后执行 `dm-server agent-exec`。结果仅为本地完整性证据(SELF)。
|
||||
set -euo pipefail
|
||||
|
||||
script_dir=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)
|
||||
root=$(cd -- "$script_dir/.." && pwd)
|
||||
sub_path="deps/development-manager"
|
||||
dm="$root/$sub_path"
|
||||
|
||||
die() {
|
||||
echo "agent-exec: $*" >&2
|
||||
exit 2
|
||||
}
|
||||
|
||||
# 在 DM checkout 内执行 git;清除 pre-commit 可能继承的仓库环境变量,避免读取超级仓的索引或对象库。
|
||||
dm_git() {
|
||||
env -u GIT_DIR -u GIT_WORK_TREE -u GIT_INDEX_FILE git -C "$dm" "$@"
|
||||
}
|
||||
|
||||
index_entry=$(git -C "$root" ls-files -s -- "$sub_path") || index_entry=""
|
||||
if [ -z "$index_entry" ]; then
|
||||
die "index 中不存在 $sub_path 的 gitlink,拒绝执行"
|
||||
fi
|
||||
read -r mode pin _ <<<"$index_entry"
|
||||
if [ "$mode" != "160000" ]; then
|
||||
die "$sub_path 在 index 中不是 gitlink(mode=$mode),拒绝执行"
|
||||
fi
|
||||
if ! [[ "$pin" =~ ^[0-9a-f]{40}$ ]]; then
|
||||
die "$sub_path 的 index 钉住值无效:$pin"
|
||||
fi
|
||||
|
||||
remedy="请运行 git submodule update --init deps/development-manager"
|
||||
if [ ! -e "$dm/.git" ]; then
|
||||
die "$sub_path 未检出;$remedy"
|
||||
fi
|
||||
top=$(dm_git rev-parse --show-toplevel 2>/dev/null) || top=""
|
||||
real_dm=$(cd -- "$dm" && pwd -P)
|
||||
if [ -z "$top" ] || [ "$top" != "$real_dm" ]; then
|
||||
die "$sub_path 不是独立的 checkout;$remedy"
|
||||
fi
|
||||
|
||||
head=$(dm_git rev-parse HEAD 2>/dev/null) || head=""
|
||||
if [ "$head" != "$pin" ]; then
|
||||
die "$sub_path 的 HEAD(${head:-未知})与 index 钉住的 $pin 不一致;$remedy"
|
||||
fi
|
||||
|
||||
dirty=$(dm_git status --porcelain --ignore-submodules=all) || die "无法读取 $sub_path 的工作区状态,拒绝执行"
|
||||
if [ -n "$dirty" ]; then
|
||||
die "$sub_path 存在未提交的修改,拒绝执行"
|
||||
fi
|
||||
|
||||
cache_dir="${AGENT_EXEC_CACHE:-${XDG_CACHE_HOME:-$HOME/.cache}/agent-system-dev/agent-exec}"
|
||||
bin="$cache_dir/dm-server-$pin"
|
||||
if [ ! -x "$bin" ]; then
|
||||
command -v go >/dev/null 2>&1 || die "未找到 go,无法构建 dm-server"
|
||||
mkdir -p "$cache_dir"
|
||||
tmp=$(mktemp "$cache_dir/.dm-server-$pin.XXXXXX")
|
||||
trap 'rm -f "$tmp"' EXIT
|
||||
if ! (cd "$dm" && env -u GIT_DIR -u GIT_WORK_TREE -u GIT_INDEX_FILE GOWORK=off go build -trimpath -o "$tmp" ./cmd/dm-server); then
|
||||
die "构建 dm-server 失败"
|
||||
fi
|
||||
chmod 0755 "$tmp"
|
||||
mv -f "$tmp" "$bin"
|
||||
trap - EXIT
|
||||
fi
|
||||
|
||||
exec "$bin" agent-exec "$@"
|
||||
@@ -64,10 +64,11 @@ def check_snapshot(snapshot):
|
||||
def check_receipt(value):
|
||||
if not isinstance(value, dict) or type(value.get("schema")) is not int or value["schema"] != evidence.SCHEMA:
|
||||
raise ValueError("unsupported or malformed receipt")
|
||||
expected = {"schema", "source", "timestamp", "command", "command_sha256", "cwd", "common_dir",
|
||||
"before", "after", "after_error", "exit_code", "output", "output_sha256", "receipt_sha256"}
|
||||
if set(value) not in (expected, expected | {"capture_note"}):
|
||||
if not evidence.receipt_keys_ok(value):
|
||||
raise ValueError("invalid receipt fields")
|
||||
for key in evidence.TARGET_FIELDS:
|
||||
if key in value and not evidence.valid_branch(value[key]):
|
||||
raise ValueError("invalid receipt target: " + key)
|
||||
for key in ("source", "timestamp", "command", "cwd", "common_dir", "output"):
|
||||
if not isinstance(value[key], str):
|
||||
raise ValueError("invalid receipt field: " + key)
|
||||
@@ -96,6 +97,72 @@ def check_receipt(value):
|
||||
return before_stale or after_stale
|
||||
|
||||
|
||||
SUMMARY_FIELDS = {"summary_schema", "source", "timestamp", "command", "exit_code", "cwd",
|
||||
"before_head", "after_head", "branch", "target_branch", "target_head",
|
||||
"before_tracked_diff_sha256", "after_tracked_diff_sha256", "output_tail",
|
||||
"output_sha256", "receipt_sha256", "receipt_path"}
|
||||
|
||||
|
||||
def optional_match(pattern, value):
|
||||
return value is None or (isinstance(value, str) and pattern.fullmatch(value) is not None)
|
||||
|
||||
|
||||
def check_summary_fields(value):
|
||||
if type(value.get("summary_schema")) is not int or value["summary_schema"] != evidence.SUMMARY_SCHEMA:
|
||||
raise ValueError("unsupported summary schema")
|
||||
keys = set(value)
|
||||
targets = keys & set(evidence.TARGET_FIELDS)
|
||||
if targets not in (set(), set(evidence.TARGET_FIELDS)) or keys - targets != SUMMARY_FIELDS:
|
||||
raise ValueError("invalid summary fields")
|
||||
for key in targets:
|
||||
if not evidence.valid_branch(value[key]):
|
||||
raise ValueError("invalid summary target: " + key)
|
||||
for key in ("source", "timestamp", "command", "cwd", "branch", "target_branch", "receipt_path"):
|
||||
if not isinstance(value[key], str):
|
||||
raise ValueError("invalid summary field: " + key)
|
||||
if value["source"] != "SELF" or type(value["exit_code"]) is not int or not 0 <= value["exit_code"] <= 255:
|
||||
raise ValueError("invalid summary source or exit code")
|
||||
try:
|
||||
datetime.fromisoformat(value["timestamp"].replace("Z", "+00:00"))
|
||||
except ValueError:
|
||||
raise ValueError("invalid summary timestamp") from None
|
||||
if not isinstance(value["before_head"], str) or not evidence.GIT_SHA.fullmatch(value["before_head"]):
|
||||
raise ValueError("invalid summary before_head")
|
||||
for key in ("after_head", "target_head"):
|
||||
if not optional_match(evidence.GIT_SHA, value[key]):
|
||||
raise ValueError("invalid summary field: " + key)
|
||||
for key in ("before_tracked_diff_sha256", "output_sha256", "receipt_sha256"):
|
||||
if not isinstance(value[key], str) or not evidence.SHA256.fullmatch(value[key]):
|
||||
raise ValueError("invalid summary digest: " + key)
|
||||
if not optional_match(evidence.SHA256, value["after_tracked_diff_sha256"]) or (
|
||||
(value["after_head"] is None) != (value["after_tracked_diff_sha256"] is None)):
|
||||
raise ValueError("invalid summary digest: after_tracked_diff_sha256")
|
||||
if not isinstance(value["output_tail"], list) or not all(isinstance(line, str) for line in value["output_tail"]):
|
||||
raise ValueError("invalid summary output tail")
|
||||
path = Path(value["receipt_path"])
|
||||
if not Path(value["cwd"]).is_absolute() or not path.is_absolute() or (
|
||||
path.name != value["receipt_sha256"] + ".json"):
|
||||
raise ValueError("invalid summary paths")
|
||||
|
||||
|
||||
def check_summary(label, value):
|
||||
"""Verify a card summary against its full receipt when it is stored on this machine."""
|
||||
check_summary_fields(value)
|
||||
path = Path(value["receipt_path"])
|
||||
if not path.is_file():
|
||||
warn(label, f"unverified: full receipt not available locally ({path})")
|
||||
try:
|
||||
heads = [value["before_head"]] + ([value["after_head"]] if value["after_head"] else [])
|
||||
return any([check_head(head) for head in heads])
|
||||
except (ValueError, OSError):
|
||||
return False
|
||||
full = json.loads(path.read_text(encoding="utf-8"))
|
||||
stale = check_receipt(full)
|
||||
if evidence.build_summary(full, value["receipt_path"], len(value["output_tail"])) != value:
|
||||
raise ValueError("summary does not match full receipt")
|
||||
return stale
|
||||
|
||||
|
||||
def records(content):
|
||||
"""Yield fenced JSON and timestamped legacy rows without interpreting text."""
|
||||
block = None
|
||||
@@ -124,7 +191,12 @@ def main():
|
||||
return
|
||||
for label, kind, record in records(content):
|
||||
try:
|
||||
stale = check_receipt(json.loads(record)) if kind == "json" else check_head(record)
|
||||
if kind == "json":
|
||||
value = json.loads(record)
|
||||
stale = (check_summary(label, value) if isinstance(value, dict) and "summary_schema" in value
|
||||
else check_receipt(value))
|
||||
else:
|
||||
stale = check_head(record)
|
||||
except (ValueError, TypeError, KeyError, OSError, evidence.EvidenceError) as exc:
|
||||
warn(label, "unverified: " + str(exc))
|
||||
continue
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
#!/usr/bin/env bash
|
||||
# 组合指针一致:容器仓在 Dev 钉住的提交上,deps/ 里的每个 gitlink
|
||||
# 必须等于 Dev 顶层对应指针。development-guidelines 只告警。
|
||||
# 必须等于 Dev 顶层对应指针。development-guidelines / development-manager
|
||||
# 是治理规范与工具,版本由各项目固定采纳自行决定,不是运行时依赖,只告警。
|
||||
set -euo pipefail
|
||||
root=$(cd "$(dirname "$0")/.." && pwd)
|
||||
cd "$root"
|
||||
@@ -48,10 +49,10 @@ for container in "${containers[@]}"; do
|
||||
while read -r mode _typ sha name; do
|
||||
[ "$mode" = "160000" ] || continue
|
||||
base=${name#deps/}
|
||||
if [ "$base" = "development-guidelines" ]; then
|
||||
dev=$(pin_of deps/development-guidelines)
|
||||
if [ "$base" = "development-guidelines" ] || [ "$base" = "development-manager" ]; then
|
||||
dev=$(pin_of "deps/$base")
|
||||
if [ "$sha" != "$dev" ]; then
|
||||
echo "warn: $container deps/development-guidelines is ${sha:0:12}, Dev is ${dev:0:12}" >&2
|
||||
echo "warn: $container deps/$base is ${sha:0:12}, Dev is ${dev:0:12}" >&2
|
||||
fi
|
||||
continue
|
||||
fi
|
||||
|
||||
@@ -97,7 +97,7 @@ def materialize(repo):
|
||||
for child, expected in submodules(repo):
|
||||
check_children(repo, "before-update")
|
||||
result = subprocess.run(["git", "-C", str(repo), "submodule", "update", "--init",
|
||||
"--checkout", "--", str(child.relative_to(repo))])
|
||||
"--jobs", "8", "--checkout", "--", str(child.relative_to(repo))])
|
||||
if result.returncode:
|
||||
raise RuntimeError(f"component materialization failed: {child}; preserve and reconcile before retrying")
|
||||
materialize(child)
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
#!/usr/bin/env python3
|
||||
"""CI 等待:以单条阻塞命令代替手写 `sleep N; curl` 轮询。
|
||||
|
||||
用法:ci_wait.py [--sha SHA] [--interval SECONDS] [--timeout SECONDS] [--contexts NAME[,NAME...]]
|
||||
--sha 缺省为 `git rev-parse HEAD`,须解析为完整 40 位 SHA;--interval 缺省 15(最小 5);--timeout 缺省 1200。
|
||||
|
||||
轮询 Gitea 合并状态 GET {api}/repos/{owner}/{repo}/commits/{sha}/status:
|
||||
- 任一状态为 failure/error:逐条打印 `[ci-wait] FAIL <context> <target_url>`,立即退出 1;
|
||||
- 成功须同时满足:至少一条状态、全部 success(skipped/warning 视同 success,不算失败)、每个 --contexts 名称是某个 context 的子串,
|
||||
且连续两次轮询观察到同一全绿集合(防止迟注册的 job 被提前判绿);
|
||||
- 超时:打印最后观察到的状态,退出 2;
|
||||
- 远端/API/token 问题:`[verdict] REMOTE_UNVERIFIED`,退出 6(瞬时错误在超时内重试,
|
||||
截止前从未成功轮询才以 6 放弃)。
|
||||
状态变化时才打印一行,结束时打印 `[verdict] CI_SUCCESS|CI_FAILED|CI_TIMEOUT sha=<sha>`。
|
||||
|
||||
退出码:0 CI_SUCCESS;1 CI_FAILED;2 CI_TIMEOUT 或用法错误;6 REMOTE_UNVERIFIED。
|
||||
仅使用标准库,复用 task_triage 的 Gitea 客户端与 token 读取。token 不打印、不写入任何产物。
|
||||
"""
|
||||
import argparse
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
import urllib.parse
|
||||
|
||||
from task_triage import EXIT_REMOTE, Stop, api_base, http_get_json, read_token, repo_slug
|
||||
|
||||
EXIT_FAILED = 1
|
||||
EXIT_TIMEOUT = 2
|
||||
EXIT_USAGE = 2
|
||||
SHA_RE = re.compile(r"^[0-9a-f]{40}$")
|
||||
FAILING = ("failure", "error")
|
||||
# skipped:分级门禁下 base 非 main 的 PR 跳过全量作业;与 warning 一样是非失败终态。
|
||||
DONE_OK = ("success", "skipped", "warning")
|
||||
FATAL_HTTP = (400, 401, 403, 404, 410)
|
||||
|
||||
|
||||
def git(*args):
|
||||
proc = subprocess.run(["git", *args], capture_output=True, text=True, encoding="utf-8", errors="replace")
|
||||
return proc.stdout.strip() if proc.returncode == 0 else None
|
||||
|
||||
|
||||
def resolve_sha(sha):
|
||||
value = (sha or "HEAD").strip()
|
||||
if not SHA_RE.match(value.lower()):
|
||||
value = git("rev-parse", "--verify", "--quiet", f"{value}^{{commit}}") or ""
|
||||
value = value.lower()
|
||||
if not SHA_RE.match(value):
|
||||
raise Stop(EXIT_USAGE, f"--sha {sha or 'HEAD'} does not resolve to a full 40-hex SHA")
|
||||
return value
|
||||
|
||||
|
||||
def interval_arg(text):
|
||||
value = float(text)
|
||||
if value < 5:
|
||||
raise argparse.ArgumentTypeError("interval must be at least 5 seconds")
|
||||
return value
|
||||
|
||||
|
||||
def timeout_arg(text):
|
||||
value = float(text)
|
||||
if value <= 0:
|
||||
raise argparse.ArgumentTypeError("timeout must be positive")
|
||||
return value
|
||||
|
||||
|
||||
def observe(data):
|
||||
"""[(context, state, target_url)],按 context 排序;忽略非 dict 项。"""
|
||||
items = data.get("statuses") if isinstance(data, dict) else None
|
||||
rows = []
|
||||
for item in items if isinstance(items, list) else []:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
state = str(item.get("status") or item.get("state") or "").strip().lower() or "unknown"
|
||||
rows.append((str(item.get("context") or "(no context)"), state, str(item.get("target_url") or "")))
|
||||
return sorted(rows)
|
||||
|
||||
|
||||
def describe(rows):
|
||||
return " ".join(f"{ctx}={state}" for ctx, state, _ in rows) if rows else "(no statuses yet)"
|
||||
|
||||
|
||||
def green_key(rows, contexts):
|
||||
"""满足全绿(success/skipped/warning)与 --contexts 时返回可比较的集合键,否则 None。"""
|
||||
if not rows or any(state not in DONE_OK for _, state, _ in rows):
|
||||
return None
|
||||
if any(not any(name in ctx for ctx, _, _ in rows) for name in contexts):
|
||||
return None
|
||||
return tuple((ctx, state) for ctx, state, _ in rows)
|
||||
|
||||
|
||||
def wait(sha, interval, timeout, contexts, sleep, clock):
|
||||
token = read_token()
|
||||
if not token:
|
||||
raise Stop(EXIT_REMOTE, "no Gitea token (GITEA_TOKEN or ~/.secrets/gitea ACCESS_TOKEN=)")
|
||||
origin = git("remote", "get-url", "origin")
|
||||
if not origin:
|
||||
raise Stop(EXIT_REMOTE, "cannot read origin remote URL")
|
||||
owner, name = (urllib.parse.quote(part, safe="") for part in repo_slug(origin).split("/"))
|
||||
url = f"{api_base()}/repos/{owner}/{name}/commits/{sha}/status"
|
||||
deadline = clock() + timeout
|
||||
rows, shown, previous_green, polled, last_error = [], None, None, False, None
|
||||
while True:
|
||||
try:
|
||||
data = http_get_json(url, token)
|
||||
except Stop as stop:
|
||||
if stop.status in FATAL_HTTP:
|
||||
raise
|
||||
last_error = stop.message
|
||||
else:
|
||||
polled = True
|
||||
rows = observe(data)
|
||||
line = describe(rows)
|
||||
if line != shown:
|
||||
print(f"[ci-wait] sha={sha[:12]} {line}", flush=True)
|
||||
shown = line
|
||||
failing = [row for row in rows if row[1] in FAILING]
|
||||
if failing:
|
||||
for ctx, _, target in failing:
|
||||
print(f"[ci-wait] FAIL {ctx} {target or '(no target_url)'}")
|
||||
print(f"[verdict] CI_FAILED sha={sha}")
|
||||
return EXIT_FAILED
|
||||
key = green_key(rows, contexts)
|
||||
if key is not None and key == previous_green:
|
||||
print(f"[verdict] CI_SUCCESS sha={sha}")
|
||||
return 0
|
||||
previous_green = key
|
||||
remaining = deadline - clock()
|
||||
if remaining <= 0:
|
||||
break
|
||||
sleep(min(interval, remaining))
|
||||
if not polled:
|
||||
raise Stop(EXIT_REMOTE, last_error or "no successful status poll before the deadline")
|
||||
print(f"[ci-wait] timeout after {timeout:g}s; last observed: {describe(rows)}")
|
||||
missing = [n for n in contexts if not any(n in ctx for ctx, _, _ in rows)]
|
||||
if missing:
|
||||
print(f"[ci-wait] missing contexts: {', '.join(missing)}")
|
||||
print(f"[verdict] CI_TIMEOUT sha={sha}")
|
||||
return EXIT_TIMEOUT
|
||||
|
||||
|
||||
def main(argv=None, sleep=time.sleep, clock=time.monotonic):
|
||||
parser = argparse.ArgumentParser(prog="ci_wait.py", description="阻塞等待指定 SHA 的 Gitea CI 结论")
|
||||
parser.add_argument("--sha")
|
||||
parser.add_argument("--interval", type=interval_arg, default=15.0)
|
||||
parser.add_argument("--timeout", type=timeout_arg, default=1200.0)
|
||||
parser.add_argument("--contexts", default="")
|
||||
args = parser.parse_args(argv)
|
||||
contexts = [name.strip() for name in args.contexts.split(",") if name.strip()]
|
||||
try:
|
||||
sha = resolve_sha(args.sha)
|
||||
return wait(sha, args.interval, args.timeout, contexts, sleep, clock)
|
||||
except Stop as stop:
|
||||
print(f"[ci-wait] ERROR {stop.message}")
|
||||
if stop.code == EXIT_REMOTE:
|
||||
print("[verdict] REMOTE_UNVERIFIED")
|
||||
return stop.code
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Executable
+55
@@ -0,0 +1,55 @@
|
||||
#!/usr/bin/env bash
|
||||
# 共享 dm-server 薄入口:读取 deps/development-manager 的 gitlink 钉住提交,
|
||||
# 按该提交构建并缓存 dm-server,再 exec 其子命令(agent-exec、task、governance)。执行语义与退出码以该二进制为准。
|
||||
set -euo pipefail
|
||||
|
||||
root=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd -P)
|
||||
dm="$root/deps/development-manager"
|
||||
|
||||
die() { echo "dm: $*" >&2; exit 2; }
|
||||
|
||||
# git 钩子会导出仓库本地变量(GIT_DIR、GIT_INDEX_FILE 等),它们会让 `git -C "$dm"` 解析到外层仓库。
|
||||
# 所有针对 DM 检出的 git 与 go 调用都在清除这些变量后执行;读取外层 gitlink 与最终 exec 仍保留继承环境。
|
||||
dm_local_env="GIT_DIR GIT_WORK_TREE GIT_INDEX_FILE GIT_PREFIX GIT_OBJECT_DIRECTORY GIT_ALTERNATE_OBJECT_DIRECTORIES GIT_COMMON_DIR GIT_GRAFT_FILE GIT_NAMESPACE GIT_CONFIG GIT_CONFIG_PARAMETERS GIT_CONFIG_COUNT GIT_SHALLOW_FILE GIT_IMPLICIT_WORK_TREE $(git rev-parse --local-env-vars 2>/dev/null || true)"
|
||||
dm_clean_env() { for v in $dm_local_env; do unset "$v"; done; }
|
||||
dmgit() ( dm_clean_env; git -C "$dm" "$@" )
|
||||
|
||||
# 1. 钉住版本只来自索引中的 gitlink(mode 160000),不读取工作区文件。
|
||||
entry=$(git -C "$root" ls-files -s -- deps/development-manager 2>/dev/null || true)
|
||||
[ -n "$entry" ] || die "索引中缺少 deps/development-manager 的 gitlink,无法确定钉住的 DM 版本"
|
||||
read -r mode pin _stage _path <<<"$entry"
|
||||
[ "$mode" = 160000 ] || die "deps/development-manager 不是 gitlink(mode=$mode)"
|
||||
[[ "$pin" =~ ^[0-9a-f]{40}$ ]] || die "gitlink 提交格式无效:$pin"
|
||||
|
||||
# 2. 检出必须存在且确为 DM 独立仓库(避免 git -C 误解析到外层仓库),HEAD 必须等于钉住提交。
|
||||
[ -e "$dm/.git" ] || die "deps/development-manager 未检出;请按钉住提交 $pin 检出该 gitlink"
|
||||
top=$(dmgit rev-parse --show-toplevel 2>/dev/null || true)
|
||||
[ -n "$top" ] && [ "$top" = "$(cd "$dm" && pwd -P)" ] \
|
||||
|| die "deps/development-manager 不是独立检出;请按钉住提交 $pin 检出该 gitlink"
|
||||
head=$(dmgit rev-parse HEAD 2>/dev/null || true)
|
||||
[ "$head" = "$pin" ] \
|
||||
|| die "deps/development-manager HEAD(${head:-未知})与钉住提交 $pin 不一致;请按钉住提交检出"
|
||||
|
||||
# 3. 检出工作树必须干净(忽略嵌套 submodule 状态)。
|
||||
dirty=$(dmgit status --porcelain --ignore-submodules=all) \
|
||||
|| die "无法读取 deps/development-manager 的工作树状态"
|
||||
[ -z "$dirty" ] || die "deps/development-manager 存在未提交改动,拒绝运行(请先清理该检出)"
|
||||
|
||||
# 4. 二进制按钉住提交缓存;缺失时在缓存目录内临时构建,完成后原子改名,避免留下半成品。
|
||||
cache="${DM_CACHE:-${XDG_CACHE_HOME:-$HOME/.cache}/development-manager/dm-server}"
|
||||
bin="$cache/dm-server-$pin"
|
||||
if [ ! -x "$bin" ]; then
|
||||
command -v go >/dev/null 2>&1 || die "缺少 go,无法构建 dm-server($pin)"
|
||||
mkdir -p "$cache"
|
||||
cache=$(cd "$cache" && pwd -P)
|
||||
bin="$cache/dm-server-$pin"
|
||||
work=$(mktemp -d "$cache/build-$pin.XXXXXX")
|
||||
if ! (dm_clean_env; cd "$dm" && GOWORK=off go build -trimpath -o "$work/dm-server" ./cmd/dm-server); then
|
||||
rm -rf "$work"
|
||||
die "构建 dm-server 失败($pin)"
|
||||
fi
|
||||
mv -f "$work/dm-server" "$bin"
|
||||
rm -rf "$work"
|
||||
fi
|
||||
|
||||
exec "$bin" "$@"
|
||||
@@ -0,0 +1,294 @@
|
||||
#!/usr/bin/env bash
|
||||
# e2e-duties.sh —— 文档职责三视图真实浏览器验收(2026-10-16 S9 验收发现 2/4 复测结论的固化)。
|
||||
#
|
||||
# 动线(与 scripts/e2e.sh 同族;种子改用 2026-10-13 后的凭据契约):
|
||||
# 1. 构建:RDM 二进制(RDM 仓 origin/main archive + pins 快照)+ 基座 CLI(同 pin
|
||||
# backend)+ frontend dist(make frontend)。
|
||||
# 2. 种子:identity ensure → RDM -bootstrap-admin(域台账)→ 基座 user bootstrap-admin
|
||||
# (首位账号)→ user create ×3(login-key 激活;--password 已被
|
||||
# RECOVERY_CONTRACT_REPLACED 拒绝)→ serve(auth on)→ redeem+设密 →
|
||||
# API:宿主项目(自动绑定)/成员/受控文档/职责/perm.set duty.assign/整文送审。
|
||||
# 3. 验收:vite preview(dist+/api 代理)→ node 驱动(playwright chromium):
|
||||
# ①三视图按文档保存;②整文在途轮次下改审核名单 → 确认对话框 → 保存生效
|
||||
# 且轮次 invalidated;③章节在途 → 整文提交被拒 → void-legacy → 可整文提交;
|
||||
# ④未授权成员 preview 400(默认拒绝)。
|
||||
# 4. 收尾:停 serve/preview,工件(截图/results/日志)留 $OUT。
|
||||
#
|
||||
# 用法:bash scripts/e2e-duties.sh [--seed-only]
|
||||
# 环境变量:RDM_REPO(默认 <本仓上级>/Agent-System-RDM)、E2E_API_PORT/E2E_UI_PORT
|
||||
# (默认 18993/4175)、E2E_OUT(默认 .tmp/e2e-duties/<ts>)。账号固定
|
||||
# p0a-alice(基座管理员/批准人)/p0a-bob(编制)/p0a-carol(审核)/p0a-dave(授权者),
|
||||
# 密码见脚本常量——私有数据根里的普通测试夹具。
|
||||
# 依赖:go、node/npm、curl、python3;首次运行 npx playwright install chromium。
|
||||
set -euo pipefail
|
||||
|
||||
DEV_ROOT=$(git -C "$(dirname "$0")/.." rev-parse --show-toplevel)
|
||||
cd "$DEV_ROOT"
|
||||
|
||||
SEED_ONLY=0
|
||||
[ "${1:-}" = "--seed-only" ] && SEED_ONLY=1
|
||||
|
||||
RDM_REPO=${RDM_REPO:-"$DEV_ROOT/../Agent-System-RDM"}
|
||||
API_PORT=${E2E_API_PORT:-18993}
|
||||
UI_PORT=${E2E_UI_PORT:-4175}
|
||||
API_BASE="http://127.0.0.1:$API_PORT"
|
||||
OWNER=P0A_ALICE; MANAGER=P0A_DAVE; AUTHOR=P0A_BOB; REVIEWER=P0A_CAROL
|
||||
OUT=${E2E_OUT:-"$DEV_ROOT/.tmp/e2e-duties/$(date +%Y%m%d-%H%M%S)"}
|
||||
case "$OUT" in /*) ;; *) OUT="$PWD/$OUT" ;; esac
|
||||
mkdir -p "$OUT"
|
||||
|
||||
log() { printf '\033[36m[e2e-duties]\033[0m %s\n' "$*"; }
|
||||
die() { printf '\033[31m[e2e-duties][FAIL]\033[0m %s\n' "$*" >&2; exit 1; }
|
||||
|
||||
command -v go >/dev/null || die "go not found"
|
||||
command -v node >/dev/null || die "node not found"
|
||||
command -v curl >/dev/null || die "curl not found"
|
||||
[ "$(git -C "$RDM_REPO" rev-parse --is-inside-work-tree 2>/dev/null)" = true ] || die "RDM repo not at $RDM_REPO"
|
||||
for p in "$API_PORT" "$UI_PORT"; do
|
||||
timeout 2 bash -c "echo > /dev/tcp/127.0.0.1/$p" 2>/dev/null && die "port $p in use"
|
||||
done
|
||||
|
||||
# ── 1. 构建(与 e2e.sh 同法:archive 固定输入,pins 快照拷贝)─────────────
|
||||
log "build RDM binary (archive origin/main + snapshot pins)"
|
||||
RDM_SHA=$(git -C "$RDM_REPO" rev-parse origin/main) || die "no origin/main"
|
||||
git -C "$RDM_REPO" submodule update --init --recursive --quiet || die "materialize pins failed"
|
||||
PIN_SHA_WANT=$(git -C "$RDM_REPO" ls-tree "$RDM_SHA" pins/agent-system-backend | awk '{print $3}')
|
||||
PIN_SHA_MAIN=$(git -C "$RDM_REPO/pins/agent-system-backend" rev-parse HEAD)
|
||||
[ "$PIN_SHA_MAIN" = "$PIN_SHA_WANT" ] || die "pins/backend ($PIN_SHA_MAIN) != origin/main pin ($PIN_SHA_WANT)"
|
||||
RDM_WT="$OUT/rdm-src"; mkdir -p "$RDM_WT"
|
||||
git -C "$RDM_REPO" archive "$RDM_SHA" | tar -x -C "$RDM_WT"
|
||||
mkdir -p "$RDM_WT/pins"
|
||||
tar -C "$RDM_REPO/pins" --exclude=.git -cf - agent-system-backend | tar -C "$RDM_WT/pins" -xf -
|
||||
(cd "$RDM_WT" && go build -o "$OUT/agent-system-rdm" .) || die "rdm build failed"
|
||||
log "build base CLI (same pinned backend as the RDM binary)"
|
||||
(cd "$RDM_WT/pins/agent-system-backend" && go build -o "$OUT/agent-system" ./cmd/agent-system) || die "cli build failed"
|
||||
log "build frontend dist (make frontend)"
|
||||
make -C "$DEV_ROOT" frontend >/dev/null || die "frontend build failed"
|
||||
|
||||
# ── 2. 种子 ─────────────────────────────────────────────
|
||||
DATA="$OUT/data"; mkdir -p "$DATA"
|
||||
RDM="$OUT/agent-system-rdm -data $DATA"
|
||||
CLI="$OUT/agent-system"
|
||||
|
||||
log "serve at $API_BASE (auth on;首启创建基座存储)"
|
||||
start_serve() {
|
||||
"$OUT/agent-system-rdm" -data "$DATA" -addr "127.0.0.1:$API_PORT" -auth on \
|
||||
>"$OUT/serve.log" 2>&1 &
|
||||
API_PID=$!
|
||||
for i in $(seq 1 40); do
|
||||
[ "$(curl -s -o /dev/null -w '%{http_code}' "$API_BASE/api/me" || echo 000)" != 000 ] && return 0
|
||||
sleep 0.25
|
||||
[ "$i" = 40 ] && { tail -5 "$OUT/serve.log"; die "api not ready"; }
|
||||
done
|
||||
}
|
||||
stop_serve() { [ -n "${API_PID:-}" ] && { kill "$API_PID" 2>/dev/null || true; wait "$API_PID" 2>/dev/null || true; API_PID=""; }; }
|
||||
cleanup() {
|
||||
stop_serve
|
||||
if [ -n "${UI_PID:-}" ] && ps -p "$UI_PID" -o args= 2>/dev/null | grep -Fq -- "vite preview --port $UI_PORT"; then
|
||||
kill "$UI_PID" 2>/dev/null || true
|
||||
fi
|
||||
}
|
||||
trap cleanup EXIT
|
||||
start_serve
|
||||
|
||||
log "identity ensure + RDM ledger bootstrap ($OWNER)"
|
||||
ID_OK=0
|
||||
for i in $(seq 1 15); do
|
||||
if $CLI identity ensure --data "$DATA" --instance-id "e2e-duties" >/dev/null 2>&1; then ID_OK=1; break; fi
|
||||
sleep 1 # 服务端受控迁移(含 identity 表)可能晚于 HTTP 就绪
|
||||
done
|
||||
[ "$ID_OK" = 1 ] || { tail -5 "$OUT/serve.log"; die "identity ensure failed"; }
|
||||
$RDM -bootstrap-admin "$OWNER" >/dev/null || die "rdm ledger bootstrap failed"
|
||||
log "base first account + members (login-key 契约)"
|
||||
$CLI user bootstrap-admin --data "$DATA" --owner "$OWNER" --out-file "$OUT/$OWNER.key" >/dev/null || die "base bootstrap failed"
|
||||
for u in "$MANAGER" "$AUTHOR" "$REVIEWER"; do
|
||||
$CLI user create --data "$DATA" --owner "$u" --role user --out-file "$OUT/$u.key" >/dev/null || die "user create $u failed"
|
||||
done
|
||||
|
||||
activate() { # activate <user> <password>:redeem login-key → 设密 → 登录
|
||||
# 注意:jar 不得与 u 同一条 local 声明——local 的各赋值在同一命令行先展开,
|
||||
# $u 会取到外层残留值(曾致四账号会话写入同一个 jar)。
|
||||
local u=$1 pw=$2 k
|
||||
local jar="$OUT/$u.jar"
|
||||
k=$(cat "$OUT/$u.key")
|
||||
curl -sf -D "$OUT/$u.redeem.hdr" -c "$jar" -X POST "$API_BASE/api/auth/login-key/redeem" -H 'Content-Type: application/json' -d "{\"key\":\"$k\"}" >/dev/null || die "redeem $u"
|
||||
curl -sf -b "$jar" -c "$jar" -X POST "$API_BASE/api/auth/password" -H 'Content-Type: application/json' -d "{\"next\":\"$pw\"}" >/dev/null || die "setpw $u"
|
||||
curl -sf -b "$jar" -c "$jar" -X POST "$API_BASE/api/auth/login" -H 'Content-Type: application/json' -d "{\"owner\":\"$u\",\"password\":\"$pw\"}" >/dev/null || die "login $u"
|
||||
}
|
||||
activate "$OWNER" 'D2Owner#2026'; activate "$MANAGER" 'D2Mgr#2026'
|
||||
activate "$AUTHOR" 'D2Author#2026'; activate "$REVIEWER" 'D2Rev#2026'
|
||||
|
||||
AJ="$OUT/$OWNER.jar"
|
||||
cmd() { # cmd <jar> <id> <params> → payload;rejected 即死
|
||||
curl -sf -b "$1" -X POST "$API_BASE/api/commands/$2" -H 'Content-Type: application/json' -d "{\"params\":$3}" \
|
||||
| python3 -c 'import json,sys; d=json.load(sys.stdin); r=(d or {}).get("receipt",{}); sys.exit(3) if r.get("state")=="rejected" else None; print(json.dumps(d.get("payload", d), ensure_ascii=False))' \
|
||||
|| die "command $2 rejected"
|
||||
}
|
||||
log "seed: host project + members + doc + duties + grant"
|
||||
log "me probe: $(curl -s -b "$AJ" "$API_BASE/api/me" | head -c 160) | jar=$(ls -la "$AJ" 2>/dev/null | awk '{print $5}')"
|
||||
PIDRESP=$(curl -s -w '\n%{http_code}' -b "$AJ" -X POST "$API_BASE/api/projects" -H 'Content-Type: application/json' -d '{"name":"职责验收夹具"}')
|
||||
CODE=${PIDRESP##*$'\n'}; BODY=${PIDRESP%$'\n'*}
|
||||
[ "$CODE" = 200 ] || die "host project create: HTTP $CODE body=${BODY:0:200}"
|
||||
PID=$(printf '%s' "$BODY" | python3 -c 'import json,sys; print(json.load(sys.stdin)["settings"]["rdm.projectId"])')
|
||||
for m in "$MANAGER" "$AUTHOR" "$REVIEWER"; do
|
||||
cmd "$AJ" rdm.project.member.add "{\"projectId\":\"$PID\",\"member\":\"$m\",\"role\":\"member\"}" >/dev/null
|
||||
done
|
||||
DOC=$(cmd "$AJ" rdm.docs.instantiate "{\"projectId\":\"$PID\",\"docType\":\"rd_proposal\",\"templateId\":\"dhf.2750fbaf1f67d10c\",\"version\":\"1.0.0\",\"title\":\"职责验收建议书\"}" \
|
||||
| python3 -c 'import json,sys; d=json.load(sys.stdin); print(d.get("docId") or d.get("id") or "")')
|
||||
[ -n "$DOC" ] || { DOC=$(cmd "$AJ" rdm.duty.catalog "{\"projectId\":\"$PID\"}" | python3 -c 'import json,sys; print(json.load(sys.stdin)["docs"][0]["docId"])'); }
|
||||
cmd "$AJ" rdm.review.assign.doc "{\"projectId\":\"$PID\",\"docId\":\"$DOC\",\"author\":\"$AUTHOR\",\"reviewers\":[\"$MANAGER\"],\"approver\":\"$OWNER\"}" >/dev/null
|
||||
# 未授权负向(授权前):普通成员 preview 必须整面拒绝。
|
||||
NEG=$(curl -s -b "$OUT/$REVIEWER.jar" -X POST "$API_BASE/api/commands/rdm.duty.preview" -H 'Content-Type: application/json' \
|
||||
-d "{\"params\":{\"projectId\":\"$PID\",\"docs\":[{\"docId\":\"$DOC\",\"author\":\"$AUTHOR\",\"reviewers\":[\"$MANAGER\"],\"approver\":\"$OWNER\"}]}}" \
|
||||
| python3 -c 'import json,sys; d=json.load(sys.stdin); r=(d or {}).get("receipt",{}); print(r.get("state",""), r.get("reason",""))')
|
||||
case "$NEG" in rejected*duty.assign*) log "negative check OK (未授权 preview 默认拒绝)";; *) die "未授权 preview 应默认拒绝: $NEG";; esac
|
||||
cmd "$AJ" rdm.perm.set "{\"projectId\":\"$PID\",\"member\":\"$MANAGER\",\"action\":\"duty.assign\",\"allow\":true}" >/dev/null
|
||||
# 授权后正向(P0-A 回归):授权者 preview 必须 200。
|
||||
curl -sf -b "$OUT/$MANAGER.jar" -X POST "$API_BASE/api/commands/rdm.duty.preview" -H 'Content-Type: application/json' \
|
||||
-d "{\"params\":{\"projectId\":\"$PID\",\"docs\":[{\"docId\":\"$DOC\",\"author\":\"$AUTHOR\",\"reviewers\":[\"$MANAGER\"],\"approver\":\"$OWNER\"}]}}" >/dev/null \
|
||||
|| die "授权者 preview 应放行(S9 发现 1 回归)"
|
||||
# 整文在途轮次:编制人 doc.submit(身份只认认证主体 → 用 AUTHOR 会话)。
|
||||
cmd "$OUT/$AUTHOR.jar" rdm.review.doc.submit "{\"projectId\":\"$PID\",\"docId\":\"$DOC\"}" >/dev/null
|
||||
log "seed done: project=$PID doc=$DOC(整文第 1 轮在途)"
|
||||
|
||||
# ── 3. 验收 ─────────────────────────────────────────────
|
||||
if [ "$SEED_ONLY" = 1 ]; then
|
||||
stop_serve
|
||||
log "seed-only: api=$API_BASE ui=manual; data=$DATA; 账号密钥在 $OUT/*.key"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
log "start vite preview at http://127.0.0.1:$UI_PORT"
|
||||
(
|
||||
cd "$DEV_ROOT/modules/frontend"
|
||||
export PLAYWRIGHT_API_TARGET="$API_BASE"
|
||||
exec ./node_modules/.bin/vite preview --port "$UI_PORT" --strictPort
|
||||
) >"$OUT/preview.log" 2>&1 &
|
||||
UI_PID=$!
|
||||
for i in $(seq 1 40); do
|
||||
curl -sf -o /dev/null "http://127.0.0.1:$UI_PORT/" && break
|
||||
sleep 0.25
|
||||
[ "$i" = 40 ] && { tail -5 "$OUT/preview.log"; die "preview not ready"; }
|
||||
done
|
||||
(cd "$DEV_ROOT/modules/frontend" && npx playwright install chromium >/dev/null 2>&1 || true)
|
||||
|
||||
cat > "$OUT/driver.cjs" <<'DRIVER'
|
||||
// 文档职责真实浏览器驱动:三视图保存 + 整文在途确认对话框 + 轮次失效断言。
|
||||
const { chromium } = require('playwright');
|
||||
const fs = require('fs');
|
||||
const BASE = process.env.DRIVER_BASE_URL, API = process.env.DRIVER_API_URL;
|
||||
const OUT = process.env.DRIVER_OUT, HOST = process.env.DRIVER_PROJECT, PID = process.env.DRIVER_PID, DOC = process.env.DRIVER_DOC;
|
||||
const OWN = ['P0A_ALICE', 'D2Owner#2026'], MGR = ['P0A_DAVE', 'D2Mgr#2026'];
|
||||
const shot = (p, n) => p.screenshot({ path: `${OUT}/${n}.png`, fullPage: true }).catch(() => {});
|
||||
const results = [];
|
||||
const record = (id, ok, detail) => { results.push({ id, ok, detail }); console.log(`${ok ? 'PASS' : 'FAIL'} [${id}] ${detail}`); };
|
||||
async function login(ctx, [owner, password]) {
|
||||
const page = await ctx.newPage();
|
||||
await page.goto(BASE + '/');
|
||||
await page.getByPlaceholder('由管理员分配').fill(owner);
|
||||
await page.getByPlaceholder('初始密码或常用密码').fill(password);
|
||||
await page.getByRole('button', { name: '登录', exact: true }).click();
|
||||
await page.waitForTimeout(2500);
|
||||
return page;
|
||||
}
|
||||
async function openDuties(page) {
|
||||
await page.getByText(HOST).first().click(); await page.waitForTimeout(1500);
|
||||
await page.getByRole('button', { name: '项目设置', exact: true }).click(); await page.waitForTimeout(1000);
|
||||
await page.getByRole('tab', { name: '文档职责', exact: true }).click(); await page.waitForTimeout(1200);
|
||||
}
|
||||
async function saveDuties(page, tag) {
|
||||
await page.getByRole('button', { name: '保存职责' }).click();
|
||||
await page.waitForTimeout(1800);
|
||||
let confirms = 0, dialogText = '';
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const btn = page.getByRole('button', { name: /^(确认保存|确认并保存|确认替换|确认|确定|仍要保存|继续保存)$/ }).first();
|
||||
if (await btn.isVisible().catch(() => false)) {
|
||||
await shot(page, `${tag}-dialog-${i}`);
|
||||
const body = await page.locator('body').innerText().catch(() => '');
|
||||
dialogText = body.split('\n').filter(l => /在途|失效|替换|确认/.test(l)).slice(0, 5).join(' | ').slice(0, 260);
|
||||
console.log(`[dialog:${tag}] ${dialogText}`);
|
||||
await btn.click(); confirms++;
|
||||
await page.waitForTimeout(1800);
|
||||
} else break;
|
||||
}
|
||||
await shot(page, `${tag}-final`);
|
||||
return { confirms, dialogText };
|
||||
}
|
||||
(async () => {
|
||||
const browser = await chromium.launch();
|
||||
const ownerCtx = await browser.newContext(), mgrCtx = await browser.newContext();
|
||||
const owner = await login(ownerCtx, OWN);
|
||||
const mgr = await login(mgrCtx, MGR);
|
||||
const apiState = async req => {
|
||||
const r = await req.post(`${API}/api/commands/rdm.review.doc.status`, { data: { params: { projectId: PID, docId: DOC } } });
|
||||
return (await r.json().catch(() => ({}))).payload || {};
|
||||
};
|
||||
|
||||
// 场景②(核心):整文在途轮次(种子 doc.submit 第 1 轮)下,授权管理者 dave
|
||||
// 扩审核名单([dave]→[dave,carol])→ 保存职责 → 预览区出现「在途审签将失效…
|
||||
// confirmInvalidation=true」警告与确认勾选框 → 勾选后再次保存 → 保存生效且
|
||||
// 整文轮次被失效(S9 发现 2 的 UI 确认链路固化)。
|
||||
await openDuties(mgr);
|
||||
await mgr.getByRole('tab', { name: '按文档', exact: true }).click(); await mgr.waitForTimeout(1200);
|
||||
await mgr.getByLabel('选择文档').selectOption(DOC); await mgr.waitForTimeout(1200);
|
||||
await mgr.getByLabel(`P0A_CAROL 在 ${process.env.DRIVER_DOC_TITLE} 的职责`).selectOption('reviewer');
|
||||
await shot(mgr, 's2-draft');
|
||||
await saveDuties(mgr, 's2-first');
|
||||
await shot(mgr, 's2-warning');
|
||||
const cb = mgr.locator('input[type="checkbox"]').last();
|
||||
const cbVisible = await cb.isVisible().catch(() => false);
|
||||
if (cbVisible) { await cb.check(); await mgr.waitForTimeout(400); }
|
||||
const r2 = await saveDuties(mgr, 's2-confirm');
|
||||
const after = await apiState(mgr.request);
|
||||
const resp2 = await mgr.request.post(`${API}/api/commands/rdm.duty.matrix`, { data: { params: { projectId: PID, view: 'document' } } });
|
||||
const reviewers = (((await resp2.json()).payload || {}).cells || []).filter(c => c.docId === DOC && c.duty === 'reviewer').map(c => c.owner);
|
||||
record('S2-确认勾选框', cbVisible, `确认勾选框可见=${cbVisible}(预览区警告与文案已截图留痕)`);
|
||||
record('S2-确认后保存生效且轮次失效', !!after.submission && after.submission.state === 'invalidated' && reviewers.length === 2,
|
||||
`after.submission.state=${after.submission ? after.submission.state : 'null'} reviewers=${JSON.stringify(reviewers)} confirms=${r2.confirms}`);
|
||||
|
||||
// 场景①(幂等回归):同一目标经 API 再保存 → applied=0 unchanged=1,版本不变。
|
||||
const resp1 = await mgr.request.post(`${API}/api/commands/rdm.review.assign.doc.batch`, { data: { params: { projectId: PID, docs: [{ docId: DOC, author: process.env.DRIVER_AUTHOR, reviewers: [process.env.DRIVER_MANAGER, process.env.DRIVER_REVIEWER], approver: process.env.DRIVER_APPROVER }] } } });
|
||||
const b1 = (await resp1.json().catch(() => ({}))).payload || {};
|
||||
record('S1-幂等计数', b1.applied === 0 && b1.unchanged === 1, `applied=${b1.applied} unchanged=${b1.unchanged}`);
|
||||
await mgrCtx.close();
|
||||
await ownerCtx.close();
|
||||
|
||||
await browser.close();
|
||||
fs.writeFileSync(`${OUT}/results.json`, JSON.stringify(results, null, 2));
|
||||
const bad = results.filter(r => !r.ok);
|
||||
console.log(`\n==== driver: ${results.length - bad.length}/${results.length} PASS ====`);
|
||||
if (bad.length) { bad.forEach(b => console.log('FAIL', b.id, b.detail)); process.exit(1); }
|
||||
})().catch(e => { console.error('FATAL', e.message); process.exit(1); });
|
||||
DRIVER
|
||||
|
||||
log "run driver"
|
||||
cd "$DEV_ROOT/modules/frontend"
|
||||
DRIVER_BASE_URL="http://127.0.0.1:$UI_PORT" DRIVER_API_URL="$API_BASE" DRIVER_OUT="$OUT" \
|
||||
DRIVER_PROJECT="职责验收夹具" DRIVER_PID="$PID" DRIVER_DOC="$DOC" DRIVER_DOC_TITLE="职责验收建议书" \
|
||||
DRIVER_AUTHOR="$AUTHOR" DRIVER_MANAGER="$MANAGER" DRIVER_REVIEWER="$REVIEWER" DRIVER_APPROVER="$OWNER" \
|
||||
NODE_PATH="$DEV_ROOT/modules/frontend/node_modules" \
|
||||
node "$OUT/driver.cjs" 2>&1 | tee "$OUT/driver.log"
|
||||
[ "${PIPESTATUS[0]}" = 0 ] || die "driver failed (log: $OUT/driver.log)"
|
||||
|
||||
# ── 4. API 断言:整文在途处置与章节迁移语义 ────────────────────
|
||||
AJ="$OUT/$OWNER.jar"; BJ="$OUT/$AUTHOR.jar"
|
||||
subState=$(curl -s -b "$AJ" -X POST "$API_BASE/api/commands/rdm.review.doc.status" -H 'Content-Type: application/json' \
|
||||
-d "{\"params\":{\"projectId\":\"$PID\",\"docId\":\"$DOC\"}}" \
|
||||
| python3 -c 'import json,sys; d=json.load(sys.stdin); s=(d.get("payload") or {}).get("submission"); print("none" if s is None else "active")')
|
||||
[ "$subState" = "none" ] || log "WARN: 职责保存后整文 submission=$subState(预期在途处置已发生或本就无在途)"
|
||||
|
||||
log "章节在途迁移语义(S9 发现 4 官方路径): submit-section → doc.submit 拒 → void-legacy → doc.submit 过"
|
||||
SEC=$(curl -s -b "$AJ" -X POST "$API_BASE/api/commands/rdm.duty.get" -H 'Content-Type: application/json' \
|
||||
-d "{\"params\":{\"projectId\":\"$PID\",\"docId\":\"$DOC\"}}" | python3 -c 'import json,sys; print(json.load(sys.stdin)["payload"]["sections"][0]["sectionId"])')
|
||||
curl -sf -b "$BJ" -X POST "$API_BASE/api/commands/rdm.review.submit" -H 'Content-Type: application/json' -d "{\"params\":{\"projectId\":\"$PID\",\"sectionId\":\"$SEC\"}}" >/dev/null \
|
||||
|| log "章节提交被拒(可接受:文档生命周期状态依赖前序步骤)"
|
||||
REJECT_MSG=$(curl -s -b "$BJ" -X POST "$API_BASE/api/commands/rdm.review.doc.submit" -H 'Content-Type: application/json' \
|
||||
-d "{\"params\":{\"projectId\":\"$PID\",\"docId\":\"$DOC\"}}" | python3 -c 'import json,sys; print(json.load(sys.stdin).get("receipt",{}).get("reason",""))' || true)
|
||||
log "整文提交对章节在途的拒绝信息: ${REJECT_MSG:0:80}"
|
||||
curl -sf -b "$BJ" -X POST "$API_BASE/api/commands/rdm.review.doc.void-legacy" -H 'Content-Type: application/json' \
|
||||
-d "{\"params\":{\"projectId\":\"$PID\",\"docId\":\"$DOC\",\"confirm\":true}}" >/dev/null || die "void-legacy 应成功"
|
||||
curl -sf -b "$BJ" -X POST "$API_BASE/api/commands/rdm.review.doc.submit" -H 'Content-Type: application/json' \
|
||||
-d "{\"params\":{\"projectId\":\"$PID\",\"docId\":\"$DOC\"}}" >/dev/null || die "void-legacy 后整文提交应通过"
|
||||
log "PASS 章节在途迁移语义(void-legacy → 整文提交)"
|
||||
|
||||
log "PASS 全部场景;工件在 $OUT"
|
||||
Executable → Regular
+62
-21
@@ -5,8 +5,9 @@
|
||||
# 1. 构建:RDM 二进制(RDM 仓 origin/main 临时 worktree,pins 物化)
|
||||
# + 基座 CLI(本 worktree modules/backend,建号用)
|
||||
# + frontend dist(本 worktree modules/frontend)
|
||||
# 2. 种子:RDM -bootstrap-admin(域管理员)→ 基座 user create(登录账号)
|
||||
# → serve(auth on)→ API rdm.project.create×2 + rdm.ingest(主项目)
|
||||
# 2. 种子:RDM -bootstrap-admin(域管理员)→ serve 首启建基座存储 → identity ensure →
|
||||
# 基座 user bootstrap-admin(login-key 激活契约;--password 已被拒绝)→
|
||||
# redeem+设密 → API rdm.project.create×2 + rdm.ingest(主项目)
|
||||
# 3. 验收:vite preview(dist+/api 代理)→ npx playwright test
|
||||
# 4. 收尾:停 serve/preview,保存认证/失败工件到 $E2E_OUT
|
||||
#
|
||||
@@ -87,7 +88,7 @@ trap cleanup EXIT
|
||||
(cd "$RDM_WT" && go build -o "$OUT/agent-system-rdm" .) || die "rdm build failed"
|
||||
log "rdm binary: $OUT/agent-system-rdm (RDM@${RDM_SHA:0:12}, pins@${PIN_SHA_WANT:0:12})"
|
||||
|
||||
log "build base CLI (user create) from the same pinned backend as the RDM binary"
|
||||
log "build base CLI (account bootstrap/activation) from the same pinned backend as the RDM binary"
|
||||
# 版本一致性:建号 CLI 必须与被测 RDM 二进制共用同一基座 pin——用 Dev 顶层
|
||||
# backend(main 最新)建号会把库迁到更高 schema,RDM pins(较旧)打不开。
|
||||
(cd "$RDM_WT/pins/agent-system-backend" && go build -o "$OUT/agent-system" ./cmd/agent-system) \
|
||||
@@ -104,11 +105,10 @@ RDM="$OUT/agent-system-rdm -data $DATA"
|
||||
log "bootstrap: RDM domain admin = $OWNER"
|
||||
$RDM -bootstrap-admin "$OWNER" >/dev/null
|
||||
|
||||
log "bootstrap: base account $OWNER (admin)"
|
||||
"$OUT/agent-system" user create --data "$DATA" --owner "$OWNER" --name "E2E 审计员" \
|
||||
--password "$PASSWORD" --admin >/dev/null
|
||||
|
||||
log "serve RDM at $API_BASE (auth on)"
|
||||
# ── 凭据契约(2026-10-13 起):--password 建号被 RECOVERY_CONTRACT_REPLACED 拒绝,
|
||||
# 改走 login-key 激活:serve 首启创建基座存储 → identity ensure → 基座首位账号
|
||||
# bootstrap-admin(激活 Key 交付)→ API redeem → 设密(PASSWORD2 为登录密码)。
|
||||
log "serve RDM at $API_BASE (auth on;首启创建基座存储)"
|
||||
start_serve() {
|
||||
"$OUT/agent-system-rdm" -data "$DATA" -addr "127.0.0.1:$API_PORT" -auth on \
|
||||
>"$OUT/serve.log" 2>&1 &
|
||||
@@ -136,16 +136,6 @@ login() {
|
||||
done
|
||||
die "login failed"
|
||||
}
|
||||
# CLI 建号留下 must_change_password。会话在改密前不能调用业务命令。
|
||||
# 强制设密会作废当前会话,所以设密后用新密码再登录。
|
||||
finish_password_gate() {
|
||||
curl -sf -b "$JAR" -c "$JAR" -X POST "$API_BASE/api/auth/password" \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d "{\"next\":\"$PASSWORD2\"}" >/dev/null || die "password setup failed"
|
||||
PASSWORD=$PASSWORD2
|
||||
login
|
||||
}
|
||||
|
||||
cmd() { # cmd <id> <params-json> → payload(失败即死,种子步骤不容忍部分成功)
|
||||
local id=$1 params=${2:-{\}}
|
||||
curl -sf -b "$JAR" -X POST "$API_BASE/api/commands/$id" \
|
||||
@@ -154,11 +144,27 @@ cmd() { # cmd <id> <params-json> → payload(失败即死,种子步骤不容
|
||||
|| die "command $id rejected"
|
||||
}
|
||||
|
||||
# 阶段①:建项目与需求台账(API;需 serve)。
|
||||
# 阶段①:激活账号并建项目与需求台账(API;需 serve)。
|
||||
start_serve
|
||||
JAR="$OUT/cookies.txt"
|
||||
log "seed: identity ensure + base first account (login-key 契约)"
|
||||
ID_OK=0
|
||||
for i in $(seq 1 15); do
|
||||
if "$OUT/agent-system" identity ensure --data "$DATA" --instance-id "e2e-audit" >/dev/null 2>&1; then ID_OK=1; break; fi
|
||||
sleep 1 # 服务端受控迁移(含 identity 表)可能晚于 HTTP 就绪
|
||||
done
|
||||
[ "$ID_OK" = 1 ] || { tail -5 "$OUT/serve.log"; die "identity ensure failed"; }
|
||||
"$OUT/agent-system" user bootstrap-admin --data "$DATA" --owner "$OWNER" \
|
||||
--name "E2E 审计员" --out-file "$OUT/$OWNER.bootstrap.key" >/dev/null \
|
||||
|| die "base bootstrap-admin failed"
|
||||
K=$(cat "$OUT/$OWNER.bootstrap.key")
|
||||
curl -sf -c "$JAR" -X POST "$API_BASE/api/auth/login-key/redeem" \
|
||||
-H 'Content-Type: application/json' -d "{\"key\":\"$K\"}" >/dev/null || die "login-key redeem failed"
|
||||
# 激活会话仅能设密:设为 PASSWORD2(后续登录与规格消费一致)。
|
||||
curl -sf -b "$JAR" -c "$JAR" -X POST "$API_BASE/api/auth/password" \
|
||||
-H 'Content-Type: application/json' -d "{\"next\":\"$PASSWORD2\"}" >/dev/null || die "password setup failed"
|
||||
PASSWORD=$PASSWORD2
|
||||
login
|
||||
finish_password_gate
|
||||
log "seed: create projects"
|
||||
PID_MAIN=$(cmd rdm.project.create "{\"name\":\"$PROJ_MAIN\"}" | python3 -c 'import json,sys; print(json.load(sys.stdin)["projectId"])')
|
||||
PID_ISO=$(cmd rdm.project.create "{\"name\":\"$PROJ_ISO\"}" | python3 -c 'import json,sys; print(json.load(sys.stdin)["projectId"])')
|
||||
@@ -171,6 +177,10 @@ cmd rdm.perm.cap '{"action":"audit.read","allow":true}' >/dev/null
|
||||
cmd rdm.perm.set "{\"projectId\":\"$PID_MAIN\",\"member\":\"$OWNER\",\"action\":\"audit.read\",\"allow\":true}" >/dev/null
|
||||
cmd rdm.perm.set "{\"projectId\":\"$PID_ISO\",\"member\":\"$OWNER\",\"action\":\"audit.read\",\"allow\":true}" >/dev/null
|
||||
|
||||
log "seed: grant explicit doc.read for the cockpit fixture owner"
|
||||
cmd rdm.perm.set "{\"projectId\":\"$PID_MAIN\",\"member\":\"$OWNER\",\"action\":\"doc.read\",\"allow\":true}" >/dev/null
|
||||
cmd rdm.perm.set "{\"projectId\":\"$PID_ISO\",\"member\":\"$OWNER\",\"action\":\"doc.read\",\"allow\":true}" >/dev/null
|
||||
|
||||
log "seed: import requirement ledger into $PID_MAIN"
|
||||
cmd rdm.reqs.import "{\"projectId\":\"$PID_MAIN\",\"items\":$(
|
||||
python3 - <<'PY'
|
||||
@@ -193,6 +203,11 @@ log "seed: load doc-tree structure into $PID_MAIN"
|
||||
"$OUT/agent-system-rdm" -data "$DATA" \
|
||||
-structure "$RDM_WT/docs/structure/rdm-self.yaml" -project "$PID_MAIN" \
|
||||
>"$OUT/structure.log" 2>&1 || { tail -5 "$OUT/structure.log"; die "structure load failed"; }
|
||||
|
||||
log "seed: load cockpit doc-tree into $PID_MAIN"
|
||||
"$OUT/agent-system-rdm" -data "$DATA" \
|
||||
-structure "$DEV_ROOT/docs/structure/cockpit-e2e.yaml" -project "$PID_MAIN" \
|
||||
>"$OUT/structure-cockpit.log" 2>&1 || { tail -5 "$OUT/structure-cockpit.log"; die "cockpit structure load failed"; }
|
||||
grep -o '装载(.*)' "$OUT/structure.log" | head -1 || true
|
||||
|
||||
# 阶段③:重启服务,切片入库挂载到已装载骨架(API)。
|
||||
@@ -213,6 +228,31 @@ log "seed: ingest corpus into $PID_MAIN"
|
||||
cmd rdm.ingest "{\"projectId\":\"$PID_MAIN\",\"source\":\"01-用户需求与系统需求.md\",\"stage\":\"BD\",\"text\":$(python3 -c 'import json,sys; print(json.dumps(sys.stdin.read()))' <<<"$SEED_MD")}" >/dev/null
|
||||
log "seed done"
|
||||
|
||||
# ── 驾驶舱 I4 种子:章节探针提案 + 非成员账号(正文经结构 YAML 装载) ──
|
||||
cmd rdm.perm.set "{\"projectId\":\"$PID_MAIN\",\"member\":\"$OWNER\",\"action\":\"doc.write\",\"allow\":true}" >/dev/null
|
||||
log "seed: cockpit pending proposal probe (non-danger; scenario 4)"
|
||||
COCKPIT_OV=$(cmd rdm.cockpit.overview "{\"projectId\":\"$PID_MAIN\"}")
|
||||
DOC_ID=$(printf '%s' "$COCKPIT_OV" | python3 -c 'import json,sys; d=json.load(sys.stdin); print([i["ref"]["id"] for i in d["documents"] if i["title"]=="验证主体报告"][0])')
|
||||
SEC_JSON=$(cmd rdm.cockpit.tree "{\"projectId\":\"$PID_MAIN\",\"parent\":\"$DOC_ID\"}")
|
||||
SEC_DESIGN=$(printf '%s' "$SEC_JSON" | python3 -c 'import json,sys; d=json.load(sys.stdin); print([i["ref"]["id"] for i in d["items"] if i["title"]=="设计说明"][0])')
|
||||
# 场景 4 驱动用:留一个未采纳提案(浏览器内经 API 采纳后观察投影一致)。
|
||||
cmd rdm.draft.propose "{\"projectId\":\"$PID_MAIN\",\"sectionId\":\"$SEC_DESIGN\",\"content\":\"一致性探针内容。\"}" >/dev/null
|
||||
# 场景 2 驱动:验证主体报告设文档级三权(职责判据与正文版本作者无关;
|
||||
# 结构装载作者为 structure,owner 经文档职责获得正文可读)。
|
||||
log "seed: duty accounts + doc-level duties (scenario 2)"
|
||||
DOC_MAIN=$(printf '%s' "$COCKPIT_OV" | python3 -c 'import json,sys; d=json.load(sys.stdin); print([i["ref"]["id"] for i in d["documents"] if i["title"]=="验证主体报告"][0])')
|
||||
"$OUT/agent-system" user create --data "$DATA" --owner e2e-rev --out-file "$OUT/e2e-rev.bootstrap.key" >/dev/null || die "rev create failed"
|
||||
"$OUT/agent-system" user create --data "$DATA" --owner e2e-app --out-file "$OUT/e2e-app.bootstrap.key" >/dev/null || die "app create failed"
|
||||
cmd rdm.project.member.add "{\"projectId\":\"$PID_MAIN\",\"member\":\"e2e-rev\",\"role\":\"member\"}" >/dev/null
|
||||
cmd rdm.project.member.add "{\"projectId\":\"$PID_MAIN\",\"member\":\"e2e-app\",\"role\":\"member\"}" >/dev/null
|
||||
cmd rdm.review.assign.doc "{\"projectId\":\"$PID_MAIN\",\"docId\":\"$DOC_MAIN\",\"author\":\"$OWNER\",\"reviewers\":[\"e2e-rev\"],\"approver\":\"e2e-app\"}" >/dev/null
|
||||
log "seed: second account (non-member negative case)"
|
||||
"$OUT/agent-system" user create --data "$DATA" --owner e2e-reader --out-file "$OUT/e2e-reader.bootstrap.key" >/dev/null || die "reader create failed"
|
||||
K2=$(cat "$OUT/e2e-reader.bootstrap.key")
|
||||
JAR2="$OUT/reader.jar"
|
||||
curl -sf -c "$JAR2" -X POST "$API_BASE/api/auth/login-key/redeem" -H 'Content-Type: application/json' -d "{\"key\":\"$K2\",\"owner\":\"e2e-reader\"}" >/dev/null || die "reader redeem failed"
|
||||
curl -sf -b "$JAR2" -c "$JAR2" -X POST "$API_BASE/api/auth/password" -H 'Content-Type: application/json' -d "{\"next\":\"E2eReader#2026\"}" >/dev/null || die "reader password setup failed"
|
||||
|
||||
# ── 3. 验收 ─────────────────────────────────────────────
|
||||
if [ "$SEED_ONLY" = 1 ]; then
|
||||
log "seed-only mode: api=$API_BASE ui=next;cookie jar=$JAR;数据=$DATA"
|
||||
@@ -245,7 +285,8 @@ export PLAYWRIGHT_BASE_URL="http://127.0.0.1:$UI_PORT" E2E_OWNER="$OWNER" \
|
||||
E2E_PROJECT_MAIN="$PROJ_MAIN" E2E_PROJECT_ISOLATED="$PROJ_ISO"
|
||||
set +e
|
||||
# 本脚本的种子只覆盖审计工作区。u7 现场验收用例依赖另一套入口,不在这里跑。
|
||||
npx playwright test e2e/rdm-audit.playwright.ts --reporter=list >"$OUT/playwright.log" 2>&1
|
||||
# admin-create 场景复用本种子的基座管理员账号(bootstrap-admin)。
|
||||
npx playwright test e2e/rdm-audit.playwright.ts e2e/rdm-cockpit.playwright.ts e2e/admin-user-create.playwright.ts --reporter=list >"$OUT/playwright.log" 2>&1
|
||||
PW_EXIT=$?
|
||||
set -e
|
||||
cat "$OUT/playwright.log"
|
||||
|
||||
+178
-22
@@ -14,12 +14,22 @@ from pathlib import Path
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
|
||||
|
||||
SCHEMA = 1
|
||||
SUMMARY_SCHEMA = 1
|
||||
DEFAULT_TAIL = 15
|
||||
SECRET_NAME = re.compile(r"token|password|passwd|secret|api[_-]?key|credential|private[_-]?key", re.I)
|
||||
SHA256 = re.compile(r"[0-9a-f]{64}\Z")
|
||||
GIT_SHA = re.compile(r"(?:[0-9a-f]{40}|[0-9a-f]{64})\Z")
|
||||
BRANCH = re.compile(r"[A-Za-z0-9][A-Za-z0-9._/-]{0,199}\Z")
|
||||
# 可选的分支目标字段:两者须同时出现或同时缺省(旧收据没有)。
|
||||
TARGET_FIELDS = ("root_target", "immediate_target")
|
||||
PRE_MERGE_CLOSED = "已完成(合入前闭合)"
|
||||
TASK_STATE = re.compile(r"^\[task-state\] registered=(.+) initial_base=([0-9a-f]+)$", re.M)
|
||||
BASE_FIELDS = {"schema", "source", "timestamp", "command", "command_sha256", "cwd", "common_dir",
|
||||
"before", "after", "after_error", "exit_code", "output", "output_sha256", "receipt_sha256"}
|
||||
|
||||
|
||||
class EvidenceError(Exception):
|
||||
@@ -111,9 +121,41 @@ def snapshot():
|
||||
}
|
||||
|
||||
|
||||
def valid_branch(name):
|
||||
return (isinstance(name, str) and BRANCH.match(name) is not None and ".." not in name
|
||||
and "//" not in name and not name.endswith(("/", ".", ".lock")))
|
||||
|
||||
|
||||
def receipt_keys_ok(keys):
|
||||
keys = set(keys)
|
||||
targets = keys & set(TARGET_FIELDS)
|
||||
if targets not in (set(), set(TARGET_FIELDS)):
|
||||
return False
|
||||
rest = keys - targets
|
||||
return rest in (BASE_FIELDS, BASE_FIELDS | {"capture_note"})
|
||||
|
||||
|
||||
def task_targets():
|
||||
"""Root and immediate targets named by the current branch card; no metadata means main/main."""
|
||||
default = os.environ.get("TASK_TARGET_BRANCH", "main")
|
||||
try:
|
||||
card = find_card()
|
||||
except EvidenceError:
|
||||
return default, default
|
||||
match = re.search(r"^- 分支类型:[^\n]*?;root_target:([^;\n]+);immediate_target:([^;\n]+)",
|
||||
card.read_text(encoding="utf-8"), re.M)
|
||||
if not match:
|
||||
return default, default
|
||||
root, immediate = match.group(1).strip(), match.group(2).strip()
|
||||
if not (valid_branch(root) and valid_branch(immediate)):
|
||||
raise EvidenceError("Task card target metadata is invalid")
|
||||
return root, immediate
|
||||
|
||||
|
||||
def create_receipt(command, output, exit_code, before, after, *, timestamp=None,
|
||||
cwd=None, git_common_dir=None, after_error=None, capture_note=None):
|
||||
cwd=None, git_common_dir=None, after_error=None, capture_note=None, targets=None):
|
||||
"""Package an actual captured run; callers must not reconstruct missing output."""
|
||||
root_target, immediate_target = targets or task_targets()
|
||||
receipt = sanitize_tree({
|
||||
"schema": SCHEMA, "source": "SELF",
|
||||
"timestamp": timestamp or datetime.datetime.now(datetime.timezone.utc).isoformat(),
|
||||
@@ -122,6 +164,7 @@ def create_receipt(command, output, exit_code, before, after, *, timestamp=None,
|
||||
"common_dir": git_common_dir or common_dir(),
|
||||
"before": before, "after": after, "after_error": after_error,
|
||||
"exit_code": exit_code, "output": output,
|
||||
"root_target": root_target, "immediate_target": immediate_target,
|
||||
})
|
||||
if capture_note is not None:
|
||||
receipt["capture_note"] = sanitize(capture_note)
|
||||
@@ -159,10 +202,11 @@ def read_receipt(filename):
|
||||
value = json.loads(Path(filename).read_text())
|
||||
if not isinstance(value, dict) or type(value.get("schema")) is not int or value["schema"] != SCHEMA:
|
||||
raise EvidenceError("Unsupported receipt schema")
|
||||
expected = {"schema", "source", "timestamp", "command", "command_sha256", "cwd", "common_dir",
|
||||
"before", "after", "after_error", "exit_code", "output", "output_sha256", "receipt_sha256"}
|
||||
if set(value) not in (expected, expected | {"capture_note"}):
|
||||
if not receipt_keys_ok(value):
|
||||
raise EvidenceError("Invalid receipt fields")
|
||||
for key in TARGET_FIELDS:
|
||||
if key in value and not valid_branch(value[key]):
|
||||
raise EvidenceError("Invalid receipt target: " + key)
|
||||
for key in ("source", "timestamp", "command", "command_sha256", "cwd", "common_dir",
|
||||
"output", "output_sha256", "receipt_sha256"):
|
||||
if not isinstance(value.get(key), str):
|
||||
@@ -197,7 +241,7 @@ def read_receipt(filename):
|
||||
return value
|
||||
|
||||
|
||||
def task_context(current=None):
|
||||
def task_context(current=None, require_target=True):
|
||||
"""Verify an existing task checkout; this is not an approval identity."""
|
||||
current = snapshot() if current is None else current
|
||||
root = Path(git_text("rev-parse", "--show-toplevel")).resolve()
|
||||
@@ -216,7 +260,7 @@ def task_context(current=None):
|
||||
if (root / ".task").is_symlink():
|
||||
raise EvidenceError("Task registration must belong to the current checkout")
|
||||
card = find_card()
|
||||
if card.is_symlink() or card.resolve().parent != root / ".task":
|
||||
if card.is_symlink() or card.resolve().parent not in (root / ".task", root / ".task" / "archive"):
|
||||
raise EvidenceError("Task registration must belong to the current checkout")
|
||||
content = card.read_text()
|
||||
locations = re.findall(r"^- 工作区:(.+?)\s*[((]worktree[))];分支:([^\s]+)\s*$", content, re.M)
|
||||
@@ -226,10 +270,11 @@ def task_context(current=None):
|
||||
if len(bases) != 1 or bases[0][0] != current["target_branch"] or not GIT_SHA.fullmatch(bases[0][1]):
|
||||
raise EvidenceError("Task card has no valid target and initial baseline")
|
||||
r0 = bases[0][1]
|
||||
if not current["target_head"]:
|
||||
if not current["target_head"] and require_target:
|
||||
raise EvidenceError("Task target is unverified; fetch it first")
|
||||
for tip in (current["head"], current["target_head"]):
|
||||
git("merge-base", "--is-ancestor", r0, tip)
|
||||
if tip:
|
||||
git("merge-base", "--is-ancestor", r0, tip)
|
||||
check = subprocess.run([sys.executable, str(Path(__file__).with_name("check-worktree.py")), "before-sync"],
|
||||
capture_output=True, text=True)
|
||||
if check.returncode:
|
||||
@@ -252,12 +297,13 @@ def validate_precheck(receipt, purpose="start"):
|
||||
raise EvidenceError("Precheck object capture is incomplete")
|
||||
if receipt["before"]["head"] != current["head"] or after["head"] != current["head"]:
|
||||
raise EvidenceError("Precheck source HEAD has changed")
|
||||
if after["target_branch"] != current["target_branch"] or not after["target_head"]:
|
||||
if after["target_branch"] != current["target_branch"] or (
|
||||
not after["target_head"] and purpose != "resume-local"):
|
||||
raise EvidenceError("Precheck authoritative target has changed")
|
||||
if purpose != "resume-local" and after["target_head"] != current["target_head"]:
|
||||
raise EvidenceError("Precheck authoritative target has changed")
|
||||
if purpose.startswith("resume-"):
|
||||
context = task_context(current)
|
||||
context = task_context(current, require_target=purpose != "resume-local")
|
||||
try:
|
||||
Path(receipt["cwd"]).resolve().relative_to(context["root"])
|
||||
except ValueError:
|
||||
@@ -265,12 +311,38 @@ def validate_precheck(receipt, purpose="start"):
|
||||
if any(obj["branch"] != current["branch"] or obj["target_branch"] != current["target_branch"]
|
||||
for obj in (receipt["before"], after)):
|
||||
raise EvidenceError("Precheck belongs to a different task branch")
|
||||
git("merge-base", "--is-ancestor", context["r0"], after["target_head"])
|
||||
if after["target_head"]:
|
||||
git("merge-base", "--is-ancestor", context["r0"], after["target_head"])
|
||||
if purpose == "resume-local":
|
||||
try:
|
||||
git("merge-base", "--is-ancestor", after["target_head"], current["target_head"])
|
||||
except EvidenceError:
|
||||
raise EvidenceError("Precheck target history has rewound or diverged") from None
|
||||
if after["target_head"] and current["target_head"]:
|
||||
try:
|
||||
git("merge-base", "--is-ancestor", after["target_head"], current["target_head"])
|
||||
except EvidenceError:
|
||||
raise EvidenceError("Precheck target history has rewound or diverged") from None
|
||||
validate_local_scope(receipt, current, context)
|
||||
|
||||
|
||||
def require_active_task(root):
|
||||
"""Closed, archived or abandoned cards cannot authorize local continuation."""
|
||||
card = find_card()
|
||||
status = re.search(r";状态:\s*([^;\s][^;\n]*)", card.read_text(encoding="utf-8"))
|
||||
if card.resolve().parent != root / ".task" or not status or status.group(1).startswith("已"):
|
||||
raise EvidenceError("Local continuation requires an active registered task")
|
||||
|
||||
|
||||
def validate_local_scope(receipt, current, context):
|
||||
"""LOCAL_READY continues one active registered task; it remains SELF local-work evidence only."""
|
||||
require_active_task(context["root"])
|
||||
states = TASK_STATE.findall(receipt["output"])
|
||||
if len(states) != 1 or Path(states[0][0]) != context["root"] or states[0][1] != context["r0"]:
|
||||
raise EvidenceError("Precheck belongs to a different task identity or initial baseline")
|
||||
for obj in (receipt["before"], receipt["after"]):
|
||||
if obj["status"] or obj["untracked"] or obj["tracked_diff_sha256"] != digest(b""):
|
||||
raise EvidenceError("Precheck workspace was not clean")
|
||||
if obj["submodules"] != current["submodules"]:
|
||||
raise EvidenceError("Precheck component state has changed")
|
||||
if tuple(receipt.get(key) for key in TARGET_FIELDS) != task_targets():
|
||||
raise EvidenceError("Precheck authorization scope differs from the registered task")
|
||||
|
||||
|
||||
def find_card():
|
||||
@@ -279,14 +351,92 @@ def find_card():
|
||||
card = root / ".task" / (branch.replace("/", "-") + ".md")
|
||||
if card.is_file():
|
||||
return card
|
||||
owner = re.compile(r"^- 工作区:.*分支:" + re.escape(branch) + r"(?:\s|$)", re.M)
|
||||
for candidate in (root / ".task").glob("*.md"):
|
||||
if re.search(r"^- 工作区:.*分支:" + re.escape(branch) + r"(?:\s|$)", candidate.read_text(), re.M):
|
||||
if owner.search(candidate.read_text()):
|
||||
return candidate
|
||||
# A card fold-closed before merge (task.sh close --self) lives in the archive until its PR lands.
|
||||
for candidate in sorted((root / ".task" / "archive").glob("*.md")):
|
||||
text = candidate.read_text()
|
||||
status = re.search(r";状态:([^;\n]*)", text)
|
||||
if owner.search(text) and status and status.group(1).startswith(PRE_MERGE_CLOSED):
|
||||
return candidate
|
||||
raise EvidenceError("No task card exists for the current branch")
|
||||
|
||||
|
||||
def evidence_dir():
|
||||
configured = os.environ.get("AGENT_EVIDENCE_DIR")
|
||||
if configured:
|
||||
base = Path(configured)
|
||||
else:
|
||||
base = Path(os.environ.get("XDG_CACHE_HOME") or Path.home() / ".cache") / "agent-system-dev" / "evidence"
|
||||
base.mkdir(mode=0o700, parents=True, exist_ok=True)
|
||||
return base.resolve()
|
||||
|
||||
|
||||
def store_receipt(receipt):
|
||||
"""Write the full receipt once under its digest; identical existing content is accepted."""
|
||||
directory = evidence_dir()
|
||||
path = directory / (receipt["receipt_sha256"] + ".json")
|
||||
encoded = json.dumps(receipt, ensure_ascii=False, indent=2) + "\n"
|
||||
if path.exists() or path.is_symlink():
|
||||
try:
|
||||
same = not path.is_symlink() and json.loads(path.read_text(encoding="utf-8")) == receipt
|
||||
except (OSError, ValueError):
|
||||
same = False
|
||||
if not same:
|
||||
raise EvidenceError("Full receipt path holds different content: " + str(path))
|
||||
return path
|
||||
fd, temporary = tempfile.mkstemp(prefix=".receipt-", dir=directory)
|
||||
try:
|
||||
with os.fdopen(fd, "w", encoding="utf-8") as stream:
|
||||
os.fchmod(stream.fileno(), 0o600)
|
||||
stream.write(encoded)
|
||||
stream.flush()
|
||||
os.fsync(stream.fileno())
|
||||
try:
|
||||
os.link(temporary, path)
|
||||
except FileExistsError:
|
||||
return store_receipt(receipt)
|
||||
finally:
|
||||
os.unlink(temporary)
|
||||
return path
|
||||
|
||||
|
||||
def tail_lines():
|
||||
value = os.environ.get("TAIL", "")
|
||||
return int(value) if value.isdigit() else DEFAULT_TAIL
|
||||
|
||||
|
||||
def build_summary(receipt, receipt_path, tail=None):
|
||||
"""Card-sized view of a full receipt; every field is derived from the sanitized receipt."""
|
||||
tail = tail_lines() if tail is None else tail
|
||||
before, after = receipt["before"], receipt["after"]
|
||||
summary = {
|
||||
"summary_schema": SUMMARY_SCHEMA, "source": "SELF",
|
||||
"timestamp": receipt["timestamp"], "command": receipt["command"],
|
||||
"exit_code": receipt["exit_code"], "cwd": receipt["cwd"],
|
||||
"before_head": before["head"], "after_head": after["head"] if after else None,
|
||||
"branch": before["branch"], "target_branch": before["target_branch"],
|
||||
"target_head": before["target_head"],
|
||||
}
|
||||
for key in TARGET_FIELDS:
|
||||
if key in receipt:
|
||||
summary[key] = receipt[key]
|
||||
summary.update({
|
||||
"before_tracked_diff_sha256": before["tracked_diff_sha256"],
|
||||
"after_tracked_diff_sha256": after["tracked_diff_sha256"] if after else None,
|
||||
"output_tail": receipt["output"].splitlines()[-tail:] if tail > 0 else [],
|
||||
"output_sha256": receipt["output_sha256"],
|
||||
"receipt_sha256": receipt["receipt_sha256"],
|
||||
"receipt_path": str(receipt_path),
|
||||
})
|
||||
return summary
|
||||
|
||||
|
||||
def append_card(card, receipt):
|
||||
encoded = json.dumps(receipt, ensure_ascii=False, indent=2)
|
||||
path = store_receipt(receipt)
|
||||
encoded = json.dumps(build_summary(receipt, path), ensure_ascii=False, indent=2)
|
||||
block = "\n## 证据(Evidence)\n\n" + (
|
||||
"<!-- Local receipt: SELF. Digests detect corruption; they do not authenticate a reviewer or CI. -->\n"
|
||||
+ "```json\n" + encoded + "\n```\n")
|
||||
@@ -322,8 +472,10 @@ def main():
|
||||
try:
|
||||
if os.environ.get("SRC", "SELF") not in ("", "SELF"):
|
||||
raise EvidenceError("Local evidence is SELF; GATE/REPLAY require external execution identity")
|
||||
if sys.argv[1:] == ["--local-context"]:
|
||||
context = task_context()
|
||||
if sys.argv[1:] in (["--local-context"], ["--local-context", "--active"]):
|
||||
context = task_context(require_target=False)
|
||||
if sys.argv[2:]:
|
||||
require_active_task(context["root"])
|
||||
print("[task-state] registered=" + str(context["root"]) + " initial_base=" + context["r0"])
|
||||
return 0
|
||||
if sys.argv[1:]:
|
||||
@@ -336,8 +488,10 @@ def main():
|
||||
if imported or validated:
|
||||
receipt = read_receipt(imported or validated)
|
||||
if validated:
|
||||
validate_precheck(receipt, os.environ.get("VALIDATE_RECEIPT_PURPOSE", "start"))
|
||||
report(receipt, "precheck validated")
|
||||
purpose = os.environ.get("VALIDATE_RECEIPT_PURPOSE", "start")
|
||||
validate_precheck(receipt, purpose)
|
||||
report(receipt, "local continuation validated (local work only; not delivery evidence)"
|
||||
if purpose == "resume-local" else "precheck validated")
|
||||
else:
|
||||
card = find_card()
|
||||
append_card(card, receipt)
|
||||
@@ -350,6 +504,7 @@ def main():
|
||||
raise EvidenceError("Capture requires an absolute, unused receipt path")
|
||||
card = find_card() if path is None else None
|
||||
before = snapshot()
|
||||
targets = task_targets()
|
||||
source_cwd, source_common = str(Path.cwd().resolve()), common_dir()
|
||||
timestamp = datetime.datetime.now(datetime.timezone.utc).isoformat()
|
||||
child_env = os.environ.copy()
|
||||
@@ -366,7 +521,8 @@ def main():
|
||||
after_error = str(error)
|
||||
receipt = create_receipt(command, result.stdout.decode("utf-8", "replace"), child_exit,
|
||||
before, after, timestamp=timestamp, cwd=source_cwd,
|
||||
git_common_dir=source_common, after_error=after_error)
|
||||
git_common_dir=source_common, after_error=after_error,
|
||||
targets=targets)
|
||||
if path is not None:
|
||||
write_receipt(path, receipt)
|
||||
report(receipt, "receipt=" + str(path))
|
||||
|
||||
@@ -3,7 +3,86 @@
|
||||
# 规则依据:AGENTS.md「证据与验证门禁」(外部事实零信任);verification.md「声明与证据来源」。
|
||||
# 用法:make facts-verify [ARGS=--update] (--update:把 PASS 项 last_verified 刷为当天并写回)
|
||||
# 表内验证命令约定不含竖线;含 "\|" 转义时按字面还原后执行。
|
||||
# 登记表引用的探针子命令(单独运行时在 stderr 说明判定,不输出凭据):
|
||||
# api-token URL URL 须逐字等于登记端点 https://git-home.u1680832.nyat.app:30319/api/v1/user,
|
||||
# 校验通过后才进程内读 ~/.secrets/gitea HOME 段 ACCESS_TOKEN 并 GET(不跟随重定向);
|
||||
# 0=接受 3=缺失 4=格式不符 5=请求失败/被拒/重定向 6=非登记端点
|
||||
# tls-handshake-fails HOST PORT 仅当 TCP 已连通且 TLS 层握手报错时 0;
|
||||
# 3=DNS 失败 4=超时 5=连接失败 6=握手成功(端点仍可用)
|
||||
set -uo pipefail
|
||||
SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
|
||||
|
||||
case "${1:-}" in
|
||||
api-token)
|
||||
[ $# -eq 2 ] || { echo "usage: facts-verify.sh api-token URL" >&2; exit 2; }
|
||||
exec python3 - "$SCRIPT_DIR" "$2" <<'PY'
|
||||
import sys, urllib.request
|
||||
sys.path.insert(0, sys.argv[1])
|
||||
import task_triage
|
||||
|
||||
ENDPOINT = "https://git-home.u1680832.nyat.app:30319/api/v1/user"
|
||||
if sys.argv[2] != ENDPOINT:
|
||||
print("api-token: URL 不是 policy/facts.md F2 登记端点,拒绝发送凭据", file=sys.stderr)
|
||||
sys.exit(6)
|
||||
|
||||
|
||||
class NoRedirect(urllib.request.HTTPRedirectHandler):
|
||||
def redirect_request(self, *args, **kwargs):
|
||||
return None
|
||||
|
||||
|
||||
urllib.request.install_opener(urllib.request.build_opener(NoRedirect))
|
||||
url = ENDPOINT
|
||||
token = task_triage.secrets_file_token()
|
||||
if not token:
|
||||
print("api-token: ~/.secrets/gitea HOME 段 ACCESS_TOKEN 缺失", file=sys.stderr)
|
||||
sys.exit(3)
|
||||
if not task_triage.TOKEN_RE.fullmatch(token):
|
||||
print("api-token: ~/.secrets/gitea HOME 段 ACCESS_TOKEN 格式不符(应为单个无空白值)", file=sys.stderr)
|
||||
sys.exit(4)
|
||||
try:
|
||||
task_triage.http_get_json(url, token)
|
||||
except task_triage.Stop as stop:
|
||||
print("api-token: " + stop.message, file=sys.stderr)
|
||||
sys.exit(5)
|
||||
print("api-token: accepted by " + url, file=sys.stderr)
|
||||
PY
|
||||
;;
|
||||
tls-handshake-fails)
|
||||
[ $# -eq 3 ] || { echo "usage: facts-verify.sh tls-handshake-fails HOST PORT" >&2; exit 2; }
|
||||
exec python3 - "$2" "$3" <<'PY'
|
||||
import socket, ssl, sys
|
||||
|
||||
host, port = sys.argv[1], int(sys.argv[2])
|
||||
|
||||
|
||||
def verdict(code, text):
|
||||
print(f"tls-handshake-fails {host}:{port}: {text}", file=sys.stderr)
|
||||
sys.exit(code)
|
||||
|
||||
|
||||
try:
|
||||
sock = socket.create_connection((host, port), timeout=8)
|
||||
except socket.gaierror:
|
||||
verdict(3, "DNS resolution failed (not a TLS observation)")
|
||||
except TimeoutError:
|
||||
verdict(4, "TCP connect timed out (not a TLS observation)")
|
||||
except OSError as exc:
|
||||
verdict(5, f"TCP connect failed ({type(exc).__name__}; not a TLS observation)")
|
||||
try:
|
||||
with sock, ssl.create_default_context().wrap_socket(sock, server_hostname=host) as tls:
|
||||
version = tls.version()
|
||||
except ssl.SSLError as exc:
|
||||
verdict(0, f"TLS handshake failed ({type(exc).__name__}: {getattr(exc, 'reason', None) or 'unknown'})")
|
||||
except TimeoutError:
|
||||
verdict(4, "TLS handshake timed out (not a TLS-layer error)")
|
||||
except OSError as exc:
|
||||
verdict(5, f"connection failed during handshake ({type(exc).__name__}; not a TLS-layer error)")
|
||||
verdict(6, f"TLS handshake succeeded ({version}); endpoint still answers")
|
||||
PY
|
||||
;;
|
||||
esac
|
||||
|
||||
F="${FACTS:-policy/facts.md}"
|
||||
[ -f "$F" ] || { echo "ERROR: 事实登记表 $F 不存在" >&2; exit 1; }
|
||||
python3 - "$F" "${1:-}" <<'PY'
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
#!/usr/bin/env bash
|
||||
# 本地钩子(post-merge):pull/merge 落地后提示 owner 按授权闭合任务卡;ff-only 也会触发。
|
||||
# precheck [cards] 报告欠账;此钩子只提示,不执行闭合或其他写入。
|
||||
# 本地钩子(post-merge):pull/merge 落地后提示任务卡闭合方式;ff-only 也会触发。
|
||||
# 新任务在合入前用 make task-close SELF=1 闭合本卡;precheck [cards] 报告的遗留卡才需主检出闭合。
|
||||
# 此钩子只提示,不执行闭合或其他写入。
|
||||
# 触发条件收紧到"主检出 + 集成分支 main"——任务 worktree 里 task-sync 的 merge 不触发,
|
||||
# 防止把卡片闭合提示混进任务分支现场。
|
||||
common=$(git rev-parse --git-common-dir) && common=$(cd "$common" 2>/dev/null && pwd) || exit 0
|
||||
@@ -8,5 +9,5 @@ mainrepo=$(dirname "$common")
|
||||
[ "$mainrepo" = "$PWD" ] || exit 0
|
||||
[ "$(git rev-parse --abbrev-ref HEAD)" = "main" ] || exit 0
|
||||
[ -f "$mainrepo/scripts/task.sh" ] || exit 0
|
||||
echo "[next] owner 按已有授权在主检出运行 make task-close 闭合已合入任务卡;未授权时先取得授权。"
|
||||
echo "[next] 新任务在合入前于任务 worktree 运行 make task-close SELF=1 闭合本卡;主检出的 make task-close 仅用于 precheck [cards] 报告的遗留卡,owner 按已有授权运行,未授权时先取得授权。"
|
||||
exit 0
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
#!/usr/bin/env bash
|
||||
# 本地硬闸(pre-commit):集成分支直提拒绝 + 分支命名。权威准入在 PR(baseline.md:26)。
|
||||
# 本地检查(pre-commit):集成分支直提拒绝 + 分支命名 + 本地完整性核对。权威准入在 PR(baseline.md:26)。
|
||||
branch=$(git rev-parse --abbrev-ref HEAD)
|
||||
case "$branch" in
|
||||
main|master)
|
||||
@@ -10,4 +10,7 @@ echo "$branch" | grep -qE '^(feature|fix|hotfix|release)/[A-Za-z0-9._-]+$' || {
|
||||
echo "pre-commit: 拒绝——分支名须为 feature|fix|hotfix|release/<slug>,当前:${branch}" >&2
|
||||
exit 1
|
||||
}
|
||||
exit 0
|
||||
root=$(git rev-parse --show-toplevel)
|
||||
# admit 只是本地完整性/篡改证据(SELF):同一 OS 用户可读取本地 HMAC 密钥并绕过本钩子,
|
||||
# 它不是 GATE,也不是可信身份。缺失或无效的本地记录仍拒绝;交付信任来自精确 HEAD 的 CI 或独立 REPLAY。
|
||||
"$root/scripts/agent-exec" admit --repo "$root"
|
||||
|
||||
+846
-54
File diff suppressed because it is too large
Load Diff
Executable
+4
@@ -0,0 +1,4 @@
|
||||
#!/usr/bin/env bash
|
||||
# 通用任务入口:转调 scripts/dm 的 task 子命令(triage / start / pr-check)。
|
||||
set -euo pipefail
|
||||
exec "$(dirname "$0")/dm" task "$@"
|
||||
+623
-88
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,304 @@
|
||||
#!/usr/bin/env python3
|
||||
"""PR base/head 门禁(两级分支模型:child → initiative → main)。
|
||||
|
||||
子命令:
|
||||
local [--pr N] [--final]
|
||||
核验当前检出的任务分支及其 PR(事实来源是 Gitea 与 origin):PR 处于开放状态、base 为卡片期望目标、
|
||||
PR head 与本地 HEAD 一致、origin 分支头与本地 HEAD 一致、期望目标分支存在;child 的父 initiative
|
||||
须仍有开放 PR 指向其 root_target。--final(仅 initiative):accepting_requirements=false,且
|
||||
`## 子任务登记` 中「必需=是」的子任务为「已合入」(PR 已合并入本 initiative)或「已延期」。
|
||||
ci
|
||||
仅 pull_request 事件:按 GITHUB_HEAD_REF 在检出的 .task/ 顶层取卡片(顶层无卡时取 .task/archive/
|
||||
中状态为「已完成(合入前闭合)」的卡),核对 GITHUB_BASE_REF。
|
||||
不联网、不需要 token;非 pull_request 事件跳过(退出 0)。
|
||||
|
||||
期望目标:child → immediate_target;其余(initiative / standalone / 无卡)→ root_target(无卡即 main)。
|
||||
|
||||
退出码:0 通过;1 门禁拒绝(PR_CHECK_FAILED);2 用法;6 远端无法核验(REMOTE_UNVERIFIED,fail closed)。
|
||||
仅使用标准库,复用 task_triage 的 Gitea 客户端与 token 读取。token 不打印、不写入任何产物。
|
||||
"""
|
||||
import argparse
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
import urllib.parse
|
||||
from pathlib import Path
|
||||
|
||||
from task_triage import (EXIT_REMOTE, Stop, api_base, as_dict, card_branch, card_status, http_get_json,
|
||||
open_pulls, read_token, repo_slug, target_branch)
|
||||
|
||||
EXIT_FAILED = 1
|
||||
EXIT_USAGE = 2
|
||||
PRE_MERGE_CLOSED = "已完成(合入前闭合)"
|
||||
CHILD_COLUMNS = ("子任务分支", "必需", "状态", "PR")
|
||||
DONE_STATES = ("已合入", "已延期")
|
||||
|
||||
|
||||
def sh(*args):
|
||||
return subprocess.run(["git", *args], capture_output=True, text=True,
|
||||
encoding="utf-8", errors="replace")
|
||||
|
||||
|
||||
def standalone(target):
|
||||
return {"kind": "standalone", "initiative_id": "", "root_target": target,
|
||||
"immediate_target": target, "accepting": "false"}
|
||||
|
||||
|
||||
def card_meta(text, target):
|
||||
"""两级分支元数据行;无该行即旧卡,按 standalone 处理(与 task.sh 的 card_branch_kind 一致)。"""
|
||||
match = re.search(r"^- 分支类型:([^\n]*)$", text, re.M)
|
||||
if not match:
|
||||
return standalone(target)
|
||||
line = match.group(1)
|
||||
|
||||
def field(name, default):
|
||||
found = re.search(";" + name + r":([^;]*)", line)
|
||||
return found.group(1).strip() if found else default
|
||||
|
||||
return {"kind": line.split(";", 1)[0].strip(), "initiative_id": field("initiative_id", ""),
|
||||
"root_target": field("root_target", target),
|
||||
"immediate_target": field("immediate_target", target),
|
||||
"accepting": field("accepting_requirements", "false")}
|
||||
|
||||
|
||||
def expected_base(meta):
|
||||
return meta["immediate_target"] if meta["kind"] == "child" else meta["root_target"]
|
||||
|
||||
|
||||
def matching_card(directory, branch, archived):
|
||||
"""directory 下声明该分支的唯一卡片;archived=True 时只认状态以「已完成(合入前闭合)」开头的卡。"""
|
||||
found = []
|
||||
for path in sorted(Path(directory).glob("*.md")):
|
||||
text = path.read_text(encoding="utf-8", errors="replace")
|
||||
if card_branch(text) != branch:
|
||||
continue
|
||||
if archived and not card_status(text).startswith(PRE_MERGE_CLOSED):
|
||||
continue
|
||||
found.append((path, text))
|
||||
if len(found) > 1:
|
||||
raise Stop(EXIT_FAILED, f"ambiguous task cards for {branch}: {', '.join(str(p) for p, _ in found)}")
|
||||
return found[0] if found else None
|
||||
|
||||
|
||||
def find_card(branch):
|
||||
"""先看检出树的 .task/ 顶层;顶层无卡时再看 .task/archive/ 中合入前闭合(close --self)的卡,
|
||||
其余归档卡不参与。返回 (路径, 文本),无卡返回 (None, None)。"""
|
||||
return (matching_card(".task", branch, False)
|
||||
or matching_card(".task/archive", branch, True)
|
||||
or (None, None))
|
||||
|
||||
|
||||
def branch_meta(branch, target):
|
||||
_, text = find_card(branch)
|
||||
return (standalone(target) if text is None else card_meta(text, target)), text
|
||||
|
||||
|
||||
def current_branch():
|
||||
proc = sh("symbolic-ref", "--quiet", "--short", "HEAD")
|
||||
if proc.returncode != 0 or not proc.stdout.strip():
|
||||
raise Stop(EXIT_USAGE, "detached HEAD: check out the task branch first")
|
||||
return proc.stdout.strip()
|
||||
|
||||
|
||||
def local_head():
|
||||
proc = sh("rev-parse", "--verify", "-q", "HEAD^{commit}")
|
||||
if proc.returncode != 0:
|
||||
raise Stop(EXIT_USAGE, "cannot resolve local HEAD")
|
||||
return proc.stdout.strip()
|
||||
|
||||
|
||||
def remote_head(branch):
|
||||
proc = sh("ls-remote", "origin", f"refs/heads/{branch}")
|
||||
if proc.returncode != 0:
|
||||
raise Stop(EXIT_REMOTE, "git ls-remote origin failed")
|
||||
for line in proc.stdout.splitlines():
|
||||
sha, _, ref = line.partition("\t")
|
||||
if ref == f"refs/heads/{branch}":
|
||||
return sha
|
||||
return None
|
||||
|
||||
|
||||
def remote_context():
|
||||
origin = sh("remote", "get-url", "origin")
|
||||
if origin.returncode != 0:
|
||||
raise Stop(EXIT_REMOTE, "origin remote is not configured")
|
||||
slug = repo_slug(origin.stdout.strip())
|
||||
token = read_token()
|
||||
if not token:
|
||||
raise Stop(EXIT_REMOTE, "no Gitea token: set GITEA_TOKEN or ~/.secrets/gitea ACCESS_TOKEN")
|
||||
return api_base(), slug, token
|
||||
|
||||
|
||||
def pull_request(api, slug, token, number):
|
||||
owner, name = (urllib.parse.quote(part, safe="") for part in slug.split("/"))
|
||||
pr = http_get_json(f"{api}/repos/{owner}/{name}/pulls/{number}", token)
|
||||
if not isinstance(pr, dict):
|
||||
raise Stop(EXIT_REMOTE, f"Gitea PR #{number} response is malformed")
|
||||
return pr
|
||||
|
||||
|
||||
def base_ref(pr):
|
||||
return as_dict(pr.get("base")).get("ref")
|
||||
|
||||
|
||||
def head_ref(pr):
|
||||
return as_dict(pr.get("head")).get("ref")
|
||||
|
||||
|
||||
def head_sha(pr):
|
||||
return as_dict(pr.get("head")).get("sha")
|
||||
|
||||
|
||||
def parent_pr_open(api, slug, token, parent, root):
|
||||
return any(isinstance(pr, dict) and pr.get("state") == "open"
|
||||
and head_ref(pr) == parent and base_ref(pr) == root
|
||||
for pr in open_pulls(api, slug, token))
|
||||
|
||||
|
||||
def pr_number(cell):
|
||||
for pattern in (r"pulls/(\d+)", r"#(\d+)", r"^(\d+)$"):
|
||||
found = re.search(pattern, cell.strip())
|
||||
if found:
|
||||
return int(found.group(1))
|
||||
return None
|
||||
|
||||
|
||||
def child_registry(text):
|
||||
"""解析 `## 子任务登记` 表(列:子任务分支 | 必需 | 状态 | PR);缺失即拒绝(fail closed)。"""
|
||||
lines = text.splitlines()
|
||||
starts = [i for i, line in enumerate(lines) if line.strip() == "## 子任务登记"]
|
||||
if not starts:
|
||||
raise Stop(EXIT_FAILED, "initiative card has no ## 子任务登记 section")
|
||||
rows, columns = [], None
|
||||
for line in lines[starts[0] + 1:]:
|
||||
if line.startswith("## "):
|
||||
break
|
||||
if not line.strip().startswith("|"):
|
||||
continue
|
||||
cells = [cell.strip() for cell in line.strip().strip("|").split("|")]
|
||||
if columns is None:
|
||||
if not all(name in cells for name in CHILD_COLUMNS):
|
||||
raise Stop(EXIT_FAILED, "子任务登记 table must have columns 子任务分支 | 必需 | 状态 | PR")
|
||||
columns = {name: cells.index(name) for name in CHILD_COLUMNS}
|
||||
continue
|
||||
if all(re.fullmatch(r":?-{3,}:?", cell) for cell in cells):
|
||||
continue
|
||||
if len(cells) <= max(columns.values()):
|
||||
raise Stop(EXIT_FAILED, f"malformed 子任务登记 row: {line.strip()}")
|
||||
rows.append({name: cells[index] for name, index in columns.items()})
|
||||
if columns is None:
|
||||
raise Stop(EXIT_FAILED, "子任务登记 section has no table")
|
||||
return rows
|
||||
|
||||
|
||||
def final_problems(api, slug, token, branch, meta, text):
|
||||
problems = []
|
||||
if meta["accepting"] != "false":
|
||||
problems.append(f"initiative {branch} still accepts requirements "
|
||||
f"(accepting_requirements={meta['accepting']}); set false before --final")
|
||||
for row in child_registry(text):
|
||||
name, required, status, pr_cell = row["子任务分支"], row["必需"], row["状态"], row["PR"]
|
||||
if required == "否":
|
||||
continue
|
||||
if required != "是":
|
||||
problems.append(f"child {name} has invalid 必需 value {required!r}")
|
||||
elif status == "已合入":
|
||||
number = pr_number(pr_cell)
|
||||
if number is None:
|
||||
problems.append(f"child {name} is 已合入 but PR cell {pr_cell!r} names no PR")
|
||||
continue
|
||||
pr = pull_request(api, slug, token, number)
|
||||
if pr.get("merged") is not True or base_ref(pr) != branch:
|
||||
problems.append(f"child {name} PR #{number} is not merged into {branch}")
|
||||
elif status not in DONE_STATES:
|
||||
problems.append(f"required child {name} is {status!r}; must be 已合入 or 已延期")
|
||||
return problems
|
||||
|
||||
|
||||
def verdict(problems, kind, base, head):
|
||||
if problems:
|
||||
for problem in problems:
|
||||
print(f"[pr-check] FAIL {problem}")
|
||||
print("[verdict] PR_CHECK_FAILED")
|
||||
return EXIT_FAILED
|
||||
print(f"[verdict] PR_CHECK_OK base={base} head={head} kind={kind}")
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_local(args):
|
||||
if args.pr is not None and args.pr < 1:
|
||||
raise Stop(EXIT_USAGE, "--pr must be a positive PR number")
|
||||
branch = current_branch()
|
||||
head = local_head()
|
||||
meta, text = branch_meta(branch, target_branch())
|
||||
kind, expected = meta["kind"], expected_base(meta)
|
||||
api, slug, token = remote_context()
|
||||
if args.pr is not None:
|
||||
pr = pull_request(api, slug, token, args.pr)
|
||||
else:
|
||||
pulls = [pr for pr in open_pulls(api, slug, token) if isinstance(pr, dict) and head_ref(pr) == branch]
|
||||
if not pulls:
|
||||
raise Stop(EXIT_FAILED, f"no open PR has head {branch}")
|
||||
if len(pulls) > 1:
|
||||
raise Stop(EXIT_FAILED, f"{len(pulls)} open PRs have head {branch}; pass --pr N")
|
||||
pr = pulls[0]
|
||||
number = pr.get("number")
|
||||
problems = []
|
||||
if pr.get("merged") is True:
|
||||
problems.append(f"PR #{number} is already merged")
|
||||
elif pr.get("state") != "open":
|
||||
problems.append(f"PR #{number} is not open (state={pr.get('state')})")
|
||||
if base_ref(pr) != expected:
|
||||
problems.append(f"PR #{number} base is {base_ref(pr)!r}; expected {expected!r} for {kind} branch {branch}")
|
||||
if head_sha(pr) != head:
|
||||
problems.append(f"PR #{number} head {head_sha(pr) or '(none)'} != local HEAD {head}")
|
||||
remote = remote_head(branch)
|
||||
if remote is None:
|
||||
problems.append(f"origin has no branch {branch}")
|
||||
elif remote != head:
|
||||
problems.append(f"origin/{branch} is {remote}, local HEAD is {head}")
|
||||
if remote_head(expected) is None:
|
||||
problems.append(f"base branch {expected} is absent on origin")
|
||||
if kind == "child" and not parent_pr_open(api, slug, token, expected, meta["root_target"]):
|
||||
problems.append(f"parent initiative {expected} has no open PR into {meta['root_target']}")
|
||||
if args.final and kind == "initiative":
|
||||
problems.extend(final_problems(api, slug, token, branch, meta, text))
|
||||
return verdict(problems, kind, expected, head)
|
||||
|
||||
|
||||
def cmd_ci(_args):
|
||||
event = os.environ.get("GITHUB_EVENT_NAME", "")
|
||||
if event != "pull_request":
|
||||
print(f"[pr-check] skip: event {event or '(none)'} is not pull_request")
|
||||
return 0
|
||||
base = os.environ.get("GITHUB_BASE_REF", "").strip()
|
||||
head = os.environ.get("GITHUB_HEAD_REF", "").strip()
|
||||
if not base or not head:
|
||||
raise Stop(EXIT_FAILED, "pull_request event lacks GITHUB_BASE_REF or GITHUB_HEAD_REF")
|
||||
meta, _ = branch_meta(head, target_branch())
|
||||
expected = expected_base(meta)
|
||||
if base != expected:
|
||||
raise Stop(EXIT_FAILED, f"PR base {base!r} differs from expected {expected!r} "
|
||||
f"for {meta['kind']} branch {head}")
|
||||
return verdict([], meta["kind"], base, head)
|
||||
|
||||
|
||||
def main(argv=None):
|
||||
parser = argparse.ArgumentParser(prog="task_pr_check.py", description="PR base/head 门禁(两级分支模型)")
|
||||
sub = parser.add_subparsers(dest="cmd", required=True)
|
||||
local = sub.add_parser("local")
|
||||
local.add_argument("--pr", type=int)
|
||||
local.add_argument("--final", action="store_true")
|
||||
sub.add_parser("ci")
|
||||
args = parser.parse_args(argv)
|
||||
try:
|
||||
return cmd_local(args) if args.cmd == "local" else cmd_ci(args)
|
||||
except Stop as stop:
|
||||
print(f"[pr-check] FAIL {stop.message}")
|
||||
print("[verdict] REMOTE_UNVERIFIED" if stop.code == EXIT_REMOTE else "[verdict] PR_CHECK_FAILED")
|
||||
return stop.code
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,535 @@
|
||||
#!/usr/bin/env python3
|
||||
"""远端需求分流收据(task.sh triage / triage-decide / start 共用的唯一分流依据)。
|
||||
|
||||
事实来源是 origin:`git ls-remote` 取分支头,Gitea open PR 取 base/head 关系,候选分支提交中的
|
||||
任务卡取 initiative 元数据。本地 `.task/` 不参与发现。
|
||||
|
||||
子命令:
|
||||
discover --receipt PATH --requirement TEXT
|
||||
查询远端并写收据;无候选 -> NEW;有候选 -> DECISION_REQUIRED(退出 5,不自动选择 JOIN)。
|
||||
decide --receipt PATH --verdict JOIN|NEW [--initiative BRANCH] --reason TEXT
|
||||
复查远端;候选集合或任一候选的 HEAD/卡片/PR 字段变化则拒绝(退出 7,收据不改)。
|
||||
verify --receipt PATH --kind standalone|initiative|child [--initiative BRANCH] [--max-age-seconds N]
|
||||
复查远端并核对收据;供 task.sh start 在创建任何 worktree/分支之前调用(拒绝退出 8)。
|
||||
|
||||
退出码:0 成功;1 本地错误;2 用法;5 DECISION_REQUIRED;6 REMOTE_UNVERIFIED;7 决策被拒;8 核验被拒。
|
||||
仅使用标准库。token 只读自环境变量 GITEA_TOKEN 或 ~/.secrets/gitea 的 ACCESS_TOKEN=,不打印、不写入收据。
|
||||
"""
|
||||
import argparse
|
||||
import datetime as dt
|
||||
import hashlib
|
||||
import http.client
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
|
||||
SCHEMA = "agent-system/task-triage/v1"
|
||||
DEFAULT_API = "https://git-home.u1680832.nyat.app:30319/api/v1"
|
||||
DEFAULT_TARGET = "main"
|
||||
DEFAULT_MAX_AGE = 14400
|
||||
PAGE_LIMIT = 50
|
||||
MAX_PAGES = 100
|
||||
SECRETS_FILE = "~/.secrets/gitea"
|
||||
SECTION_RE = re.compile(r"^([A-Za-z][A-Za-z0-9_-]*):$")
|
||||
TOKEN_RE = re.compile(r"[A-Za-z0-9._~+/=-]+")
|
||||
CARD_RE = re.compile(r"^\.task/[^/]+\.md$")
|
||||
CANDIDATE_KEYS = ("branch", "head_sha", "card_path", "card_sha256", "pr_number", "pr_state", "pr_base", "pr_head_sha")
|
||||
RECEIPT_KEYS = ("repo", "root_target", "root_target_sha", "requirement", "discovered_at",
|
||||
"candidates", "discovery_verdict", "final_verdict", "decision")
|
||||
|
||||
EXIT_LOCAL = 1
|
||||
EXIT_USAGE = 2
|
||||
EXIT_DECISION_REQUIRED = 5
|
||||
EXIT_REMOTE = 6
|
||||
EXIT_DECIDE = 7
|
||||
EXIT_VERIFY = 8
|
||||
|
||||
|
||||
class Stop(Exception):
|
||||
"""受控失败:退出码 + 不含 token 的说明。"""
|
||||
|
||||
def __init__(self, code, message, status=None):
|
||||
super().__init__(message)
|
||||
self.code = code
|
||||
self.message = message
|
||||
self.status = status
|
||||
|
||||
|
||||
def utc_now():
|
||||
return dt.datetime.now(dt.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
||||
|
||||
|
||||
def parse_time(text):
|
||||
value = dt.datetime.fromisoformat(text)
|
||||
if value.tzinfo is None:
|
||||
value = value.replace(tzinfo=dt.timezone.utc)
|
||||
return value
|
||||
|
||||
|
||||
def sha256_bytes(data):
|
||||
return hashlib.sha256(data).hexdigest()
|
||||
|
||||
|
||||
def as_dict(value):
|
||||
return value if isinstance(value, dict) else {}
|
||||
|
||||
|
||||
def target_branch():
|
||||
return os.environ.get("TASK_TARGET_BRANCH", "").strip() or DEFAULT_TARGET
|
||||
|
||||
|
||||
def git(*args):
|
||||
return subprocess.run(["git", *args], capture_output=True)
|
||||
|
||||
|
||||
def redact_url(url):
|
||||
"""去掉 URL 中的 userinfo(https://user:pw@host 与 user@host:path 两种形式)。"""
|
||||
if "://" in url:
|
||||
parts = urllib.parse.urlsplit(url)
|
||||
host = parts.netloc.rsplit("@", 1)[-1]
|
||||
return urllib.parse.urlunsplit((parts.scheme, host, parts.path, parts.query, parts.fragment))
|
||||
scp = re.match(r"^[^/@]+@([^/]+:.*)$", url)
|
||||
return scp.group(1) if scp else url
|
||||
|
||||
|
||||
def repo_slug(origin_url):
|
||||
slug = os.environ.get("GITEA_REPO", "").strip()
|
||||
if not slug:
|
||||
if "://" in origin_url:
|
||||
path = urllib.parse.urlsplit(origin_url).path
|
||||
else:
|
||||
path = origin_url.split(":", 1)[1] if re.match(r"^[^/]+:", origin_url) else origin_url
|
||||
segments = [s for s in path.split("/") if s]
|
||||
if len(segments) < 2:
|
||||
raise Stop(EXIT_REMOTE, "cannot derive owner/repo from origin; set GITEA_REPO")
|
||||
name = segments[-1][:-4] if segments[-1].endswith(".git") else segments[-1]
|
||||
slug = f"{segments[-2]}/{name}"
|
||||
parts = slug.split("/")
|
||||
if len(parts) != 2 or not all(parts) or any(p in (".", "..") for p in parts):
|
||||
raise Stop(EXIT_REMOTE, "invalid owner/repo slug")
|
||||
return slug
|
||||
|
||||
|
||||
def api_base():
|
||||
return (os.environ.get("GITEA_API_URL", "").strip() or DEFAULT_API).rstrip("/")
|
||||
|
||||
|
||||
def secrets_file_token():
|
||||
"""HOME/IMC 分段时只取 HOME 段的 ACCESS_TOKEN(同 policy/facts.md F2);无分段时取第一条。"""
|
||||
try:
|
||||
with open(os.path.expanduser(SECRETS_FILE), encoding="utf-8") as fh:
|
||||
lines = fh.read().splitlines()
|
||||
except (OSError, UnicodeDecodeError):
|
||||
return None
|
||||
first = home = None
|
||||
sectioned = False
|
||||
section = None
|
||||
for raw in lines:
|
||||
line = raw.strip()
|
||||
header = SECTION_RE.match(line)
|
||||
if header:
|
||||
sectioned = True
|
||||
section = header.group(1)
|
||||
continue
|
||||
if line.startswith("- "):
|
||||
line = line[2:].strip()
|
||||
if not line.startswith("ACCESS_TOKEN="):
|
||||
continue
|
||||
value = line.split("=", 1)[1].strip()
|
||||
if first is None:
|
||||
first = value
|
||||
if section == "HOME" and home is None:
|
||||
home = value
|
||||
return (home if sectioned else first) or None
|
||||
|
||||
|
||||
def read_token():
|
||||
token = os.environ.get("GITEA_TOKEN", "").strip() or secrets_file_token()
|
||||
if not token:
|
||||
return None
|
||||
if not TOKEN_RE.fullmatch(token):
|
||||
raise Stop(EXIT_REMOTE, "Gitea token is malformed (expected one value without whitespace); "
|
||||
"check GITEA_TOKEN or ~/.secrets/gitea")
|
||||
return token
|
||||
|
||||
|
||||
def remote_heads():
|
||||
proc = git("ls-remote", "--heads", "origin")
|
||||
if proc.returncode != 0:
|
||||
raise Stop(EXIT_REMOTE, "git ls-remote --heads origin failed")
|
||||
heads = {}
|
||||
for line in proc.stdout.decode("utf-8", "replace").splitlines():
|
||||
sha, _, ref = line.partition("\t")
|
||||
if ref.startswith("refs/heads/"):
|
||||
heads[ref[len("refs/heads/"):]] = sha
|
||||
return heads
|
||||
|
||||
|
||||
def http_get_json(url, token):
|
||||
try:
|
||||
request = urllib.request.Request(
|
||||
url, headers={"Accept": "application/json", "Authorization": f"token {token}"})
|
||||
with urllib.request.urlopen(request, timeout=20) as response:
|
||||
body = response.read()
|
||||
except urllib.error.HTTPError as exc:
|
||||
raise Stop(EXIT_REMOTE, f"Gitea API returned HTTP {exc.code}", status=exc.code) from None
|
||||
except (urllib.error.URLError, http.client.HTTPException, OSError) as exc:
|
||||
raise Stop(EXIT_REMOTE, f"Gitea API unreachable ({type(exc).__name__})") from None
|
||||
except Stop:
|
||||
raise
|
||||
except Exception as exc:
|
||||
# 异常文本可能含 Authorization 头(如 http.client 的 Invalid header value),只报类型名。
|
||||
raise Stop(EXIT_REMOTE, f"Gitea API request failed ({type(exc).__name__})") from None
|
||||
try:
|
||||
return json.loads(body.decode("utf-8"))
|
||||
except ValueError:
|
||||
raise Stop(EXIT_REMOTE, "Gitea API returned invalid JSON")
|
||||
|
||||
|
||||
def open_pulls(api, slug, token):
|
||||
owner, name = (urllib.parse.quote(part, safe="") for part in slug.split("/"))
|
||||
pulls = []
|
||||
for page in range(1, MAX_PAGES + 1):
|
||||
url = f"{api}/repos/{owner}/{name}/pulls?state=open&limit={PAGE_LIMIT}&page={page}"
|
||||
batch = http_get_json(url, token)
|
||||
if not isinstance(batch, list):
|
||||
raise Stop(EXIT_REMOTE, "Gitea pulls response is not a list")
|
||||
if not batch:
|
||||
return pulls
|
||||
pulls.extend(batch)
|
||||
raise Stop(EXIT_REMOTE, f"open pull requests exceed {MAX_PAGES} pages")
|
||||
|
||||
|
||||
def fetch_head(branch):
|
||||
proc = git("fetch", "-q", "origin", f"refs/heads/{branch}")
|
||||
if proc.returncode != 0:
|
||||
raise Stop(EXIT_REMOTE, f"cannot fetch origin/{branch}")
|
||||
rev = git("rev-parse", "--verify", "-q", "FETCH_HEAD^{commit}")
|
||||
if rev.returncode != 0:
|
||||
raise Stop(EXIT_REMOTE, f"fetched origin/{branch} has no commit")
|
||||
return rev.stdout.decode("utf-8", "replace").strip()
|
||||
|
||||
|
||||
def task_card_paths(commit):
|
||||
proc = git("ls-tree", "-r", "-z", "--name-only", commit, "--", ".task")
|
||||
if proc.returncode != 0:
|
||||
raise Stop(EXIT_REMOTE, "cannot list task cards at candidate head")
|
||||
names = proc.stdout.decode("utf-8", "replace").split("\0")
|
||||
return sorted(name for name in names if CARD_RE.match(name))
|
||||
|
||||
|
||||
def blob_bytes(commit, path):
|
||||
proc = git("cat-file", "blob", f"{commit}:{path}")
|
||||
if proc.returncode != 0:
|
||||
raise Stop(EXIT_REMOTE, f"cannot read {path} at candidate head")
|
||||
return proc.stdout
|
||||
|
||||
|
||||
def card_branch(text):
|
||||
for line in text.splitlines():
|
||||
match = re.match(r"^- 工作区:.*分支:([A-Za-z0-9._/-]+)", line)
|
||||
if match:
|
||||
return match.group(1)
|
||||
return None
|
||||
|
||||
|
||||
def card_status(text):
|
||||
match = re.search(r";状态:([^;\n]*)", text)
|
||||
return match.group(1).strip() if match else ""
|
||||
|
||||
|
||||
def branch_meta(text, target):
|
||||
"""分支元数据行 '- 分支类型:…;field:value…';旧卡缺省为 standalone、不接收需求。"""
|
||||
match = re.search(r"^- 分支类型:([^\n]*)$", text, re.M)
|
||||
if not match:
|
||||
return {"kind": "standalone", "initiative_id": "", "root_target": target, "accepting": "false"}
|
||||
line = match.group(1)
|
||||
|
||||
def field(name, default):
|
||||
found = re.search(";" + name + r":([^;]*)", line)
|
||||
return found.group(1).strip() if found else default
|
||||
|
||||
return {
|
||||
"kind": line.split(";", 1)[0].strip(),
|
||||
"initiative_id": field("initiative_id", ""),
|
||||
"root_target": field("root_target", target),
|
||||
"accepting": field("accepting_requirements", "false"),
|
||||
}
|
||||
|
||||
|
||||
def goal_excerpt(text):
|
||||
lines, inside = [], False
|
||||
for line in text.splitlines():
|
||||
if line.startswith("## "):
|
||||
if inside:
|
||||
break
|
||||
inside = line.strip() == "## 目标与非目标"
|
||||
continue
|
||||
if inside and line.strip():
|
||||
lines.append(line.strip())
|
||||
if len(lines) == 5:
|
||||
break
|
||||
return " / ".join(lines)[:300]
|
||||
|
||||
|
||||
def inspect_branch(target, branch, head_sha, pr):
|
||||
fetched = fetch_head(branch)
|
||||
if fetched != head_sha:
|
||||
raise Stop(EXIT_REMOTE, f"{branch} moved during discovery; retry")
|
||||
matches = []
|
||||
for path in task_card_paths(head_sha):
|
||||
raw = blob_bytes(head_sha, path)
|
||||
text = raw.decode("utf-8", "replace")
|
||||
if card_branch(text) != branch:
|
||||
continue
|
||||
meta = branch_meta(text, target)
|
||||
if meta["kind"] != "initiative" or meta["accepting"] != "true" or meta["root_target"] != target:
|
||||
continue
|
||||
if card_status(text).startswith("已完成"):
|
||||
continue
|
||||
matches.append((path, raw, text, meta))
|
||||
if not matches:
|
||||
return None
|
||||
if len(matches) > 1:
|
||||
raise Stop(EXIT_REMOTE, f"ambiguous initiative cards on {branch}")
|
||||
path, raw, text, meta = matches[0]
|
||||
head = as_dict(pr.get("head"))
|
||||
return {
|
||||
"branch": branch,
|
||||
"head_sha": head_sha,
|
||||
"pr_number": pr["number"],
|
||||
"pr_state": pr.get("state"),
|
||||
"pr_base": as_dict(pr.get("base")).get("ref"),
|
||||
"pr_head_sha": head.get("sha") or "",
|
||||
"card_path": path,
|
||||
"card_sha256": sha256_bytes(raw),
|
||||
"initiative_id": meta["initiative_id"],
|
||||
"goal_excerpt": goal_excerpt(text),
|
||||
}
|
||||
|
||||
|
||||
def candidates_from(target, api, slug, token, heads):
|
||||
by_branch = {}
|
||||
for pr in open_pulls(api, slug, token):
|
||||
if not isinstance(pr, dict) or pr.get("state") != "open":
|
||||
continue
|
||||
base = as_dict(pr.get("base")).get("ref")
|
||||
head = as_dict(pr.get("head")).get("ref")
|
||||
if base != target or not isinstance(head, str) or head == target or head not in heads:
|
||||
continue
|
||||
if not isinstance(pr.get("number"), int):
|
||||
raise Stop(EXIT_REMOTE, "malformed pull request number")
|
||||
by_branch.setdefault(head, []).append(pr)
|
||||
found = []
|
||||
for branch in sorted(by_branch):
|
||||
if len(by_branch[branch]) != 1:
|
||||
raise Stop(EXIT_REMOTE, f"ambiguous: several open PRs target {target} from {branch}")
|
||||
candidate = inspect_branch(target, branch, heads[branch], by_branch[branch][0])
|
||||
if candidate:
|
||||
found.append(candidate)
|
||||
return found
|
||||
|
||||
|
||||
def snapshot(target):
|
||||
origin = git("remote", "get-url", "origin")
|
||||
if origin.returncode != 0:
|
||||
raise Stop(EXIT_REMOTE, "origin remote is not configured")
|
||||
origin_url = origin.stdout.decode("utf-8", "replace").strip()
|
||||
heads = remote_heads()
|
||||
if target not in heads:
|
||||
raise Stop(EXIT_REMOTE, f"origin has no {target} branch")
|
||||
slug = repo_slug(origin_url)
|
||||
api = api_base()
|
||||
token = read_token()
|
||||
if not token:
|
||||
raise Stop(EXIT_REMOTE, "no Gitea token: set GITEA_TOKEN or ~/.secrets/gitea ACCESS_TOKEN")
|
||||
return {
|
||||
"repo": {"origin_url": redact_url(origin_url), "api_base": redact_url(api), "repo_slug": slug},
|
||||
"root_target": target,
|
||||
"root_target_sha": heads[target],
|
||||
"candidates": candidates_from(target, api, slug, token, heads),
|
||||
}
|
||||
|
||||
|
||||
def fingerprint(candidates):
|
||||
return sorted(tuple(c[key] for key in CANDIDATE_KEYS) for c in candidates)
|
||||
|
||||
|
||||
def check_shape(receipt):
|
||||
missing = [key for key in RECEIPT_KEYS if key not in receipt]
|
||||
if missing:
|
||||
raise ValueError(f"missing {missing[0]}")
|
||||
if not isinstance(receipt["candidates"], list):
|
||||
raise ValueError("candidates")
|
||||
for cand in receipt["candidates"]:
|
||||
if not isinstance(cand, dict) or any(key not in cand for key in CANDIDATE_KEYS):
|
||||
raise ValueError("candidate")
|
||||
if not isinstance(as_dict(receipt["requirement"]).get("sha256"), str):
|
||||
raise ValueError("requirement")
|
||||
parse_time(receipt["discovered_at"])
|
||||
|
||||
|
||||
def load_receipt(path, code):
|
||||
try:
|
||||
with open(path, "rb") as fh:
|
||||
raw = fh.read()
|
||||
except OSError:
|
||||
raise Stop(code, f"receipt missing or unreadable: {path}")
|
||||
try:
|
||||
receipt = json.loads(raw.decode("utf-8"))
|
||||
except ValueError:
|
||||
raise Stop(code, "receipt is not valid JSON")
|
||||
if not isinstance(receipt, dict) or receipt.get("schema") != SCHEMA:
|
||||
raise Stop(code, f"receipt schema is not {SCHEMA}")
|
||||
try:
|
||||
check_shape(receipt)
|
||||
except (ValueError, TypeError):
|
||||
raise Stop(code, "receipt is malformed")
|
||||
return receipt, raw
|
||||
|
||||
|
||||
def write_receipt(path, receipt):
|
||||
data = json.dumps(receipt, ensure_ascii=False, indent=2) + "\n"
|
||||
tmp = f"{path}.tmp-{os.getpid()}"
|
||||
try:
|
||||
with open(tmp, "w", encoding="utf-8") as fh:
|
||||
fh.write(data)
|
||||
os.replace(tmp, path)
|
||||
except OSError as exc:
|
||||
raise Stop(EXIT_LOCAL, f"cannot write receipt {path}: {exc.strerror}")
|
||||
|
||||
|
||||
def describe(cand):
|
||||
return (f"[candidate] branch={cand['branch']} head_sha={cand['head_sha']} pr=#{cand['pr_number']} "
|
||||
f"base={cand['pr_base']} card={cand['card_path']} card_sha256={cand['card_sha256']} "
|
||||
f"initiative_id={cand['initiative_id'] or '无'}")
|
||||
|
||||
|
||||
def cmd_discover(args):
|
||||
text = args.requirement.strip()
|
||||
if not text:
|
||||
raise Stop(EXIT_USAGE, "requirement text is required")
|
||||
snap = snapshot(target_branch())
|
||||
now = utc_now()
|
||||
receipt = {
|
||||
"schema": SCHEMA,
|
||||
**snap,
|
||||
"requirement": {"sha256": sha256_bytes(text.encode("utf-8")),
|
||||
"summary": " ".join(text.split())[:200]},
|
||||
"discovered_at": now,
|
||||
}
|
||||
path = os.path.realpath(args.receipt)
|
||||
if not snap["candidates"]:
|
||||
receipt.update(discovery_verdict="NEW", final_verdict="NEW", decision={
|
||||
"verdict": "NEW", "selected_initiative": None,
|
||||
"rationale": "no accepting initiative", "decided_at": now})
|
||||
write_receipt(args.receipt, receipt)
|
||||
print(f"[verdict] NEW — 远端没有接纳中的 initiative;收据:{path}")
|
||||
return 0
|
||||
receipt.update(discovery_verdict="DECISION_REQUIRED", final_verdict="DECISION_REQUIRED", decision=None)
|
||||
write_receipt(args.receipt, receipt)
|
||||
print(f"[triage] 远端接纳中的 initiative 候选: {len(snap['candidates'])}(以 origin 为准,本地 .task 不参与)")
|
||||
for cand in snap["candidates"]:
|
||||
print(describe(cand))
|
||||
if cand["goal_excerpt"]:
|
||||
print(f"[goal] branch={cand['branch']} {cand['goal_excerpt']}")
|
||||
print(f"[verdict] DECISION_REQUIRED — 须人工决策:JOIN 其一,或 NEW 并说明不适配理由;收据:{path}")
|
||||
for cand in snap["candidates"]:
|
||||
print(f"[decide] make task-triage-decide TRIAGE_RECEIPT={path} VERDICT=JOIN "
|
||||
f"INITIATIVE={cand['branch']} REASON='<加入理由>'")
|
||||
print(f"[decide] make task-triage-decide TRIAGE_RECEIPT={path} VERDICT=NEW REASON='<不适配理由>'")
|
||||
return EXIT_DECISION_REQUIRED
|
||||
|
||||
|
||||
def cmd_decide(args):
|
||||
if args.verdict not in ("JOIN", "NEW"):
|
||||
raise Stop(EXIT_DECIDE, "verdict must be JOIN or NEW")
|
||||
receipt, _ = load_receipt(args.receipt, EXIT_DECIDE)
|
||||
if receipt["discovery_verdict"] != "DECISION_REQUIRED":
|
||||
raise Stop(EXIT_DECIDE, f"no decision needed: discovery verdict is {receipt['discovery_verdict']}")
|
||||
reason = args.reason.strip()
|
||||
if not reason:
|
||||
raise Stop(EXIT_DECIDE, "--reason is required")
|
||||
initiative = args.initiative.strip()
|
||||
if args.verdict == "JOIN" and not initiative:
|
||||
raise Stop(EXIT_DECIDE, "JOIN requires --initiative")
|
||||
if args.verdict == "NEW" and initiative:
|
||||
raise Stop(EXIT_DECIDE, "NEW must not select an initiative")
|
||||
current = snapshot(receipt["root_target"])
|
||||
if fingerprint(current["candidates"]) != fingerprint(receipt["candidates"]):
|
||||
raise Stop(EXIT_DECIDE, "candidate set changed since discovery; re-run make task-triage")
|
||||
if args.verdict == "JOIN" and initiative not in {c["branch"] for c in receipt["candidates"]}:
|
||||
raise Stop(EXIT_DECIDE, f"{initiative} is not a discovered candidate")
|
||||
receipt["decision"] = {"verdict": args.verdict, "selected_initiative": initiative or None,
|
||||
"rationale": reason, "decided_at": utc_now()}
|
||||
receipt["final_verdict"] = args.verdict
|
||||
write_receipt(args.receipt, receipt)
|
||||
print(f"[decided] verdict={args.verdict} selected_initiative={initiative or '无'} "
|
||||
f"receipt={os.path.realpath(args.receipt)}")
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_verify(args):
|
||||
receipt, raw = load_receipt(args.receipt, EXIT_VERIFY)
|
||||
final = receipt["final_verdict"]
|
||||
decision = as_dict(receipt["decision"])
|
||||
if final not in ("JOIN", "NEW") or decision.get("verdict") != final:
|
||||
raise Stop(EXIT_VERIFY, "receipt has no final JOIN/NEW decision; run make task-triage-decide")
|
||||
age = (dt.datetime.now(dt.timezone.utc) - parse_time(receipt["discovered_at"])).total_seconds()
|
||||
if age > args.max_age_seconds:
|
||||
raise Stop(EXIT_VERIFY, f"receipt is stale ({int(age)}s old, max {args.max_age_seconds}s); re-run make task-triage")
|
||||
if age < -300:
|
||||
raise Stop(EXIT_VERIFY, "receipt discovered_at is in the future")
|
||||
if receipt["root_target"] != target_branch():
|
||||
raise Stop(EXIT_VERIFY, "receipt root target does not match TASK_TARGET_BRANCH")
|
||||
selected = decision.get("selected_initiative")
|
||||
if args.kind == "child":
|
||||
if final != "JOIN" or not args.initiative or args.initiative != selected:
|
||||
raise Stop(EXIT_VERIFY, f"child requires a JOIN receipt selecting {args.initiative or '(none)'}")
|
||||
elif final != "NEW":
|
||||
raise Stop(EXIT_VERIFY, f"{args.kind} requires a NEW receipt, got {final}")
|
||||
current = snapshot(receipt["root_target"])
|
||||
if fingerprint(current["candidates"]) != fingerprint(receipt["candidates"]):
|
||||
raise Stop(EXIT_VERIFY, "remote initiative candidates changed since triage; re-run make task-triage")
|
||||
head = next((c["head_sha"] for c in receipt["candidates"] if c["branch"] == selected), None)
|
||||
print(f"[triage-verified] verdict={final} selected_initiative={selected or '无'} "
|
||||
f"requirement_sha256={receipt['requirement']['sha256']} receipt_sha256={sha256_bytes(raw)}")
|
||||
print(f"[triage-head] selected_head_sha={head or '无'}")
|
||||
return 0
|
||||
|
||||
|
||||
def main(argv=None):
|
||||
parser = argparse.ArgumentParser(prog="task_triage.py", description="远端需求分流收据")
|
||||
sub = parser.add_subparsers(dest="cmd", required=True)
|
||||
p = sub.add_parser("discover")
|
||||
p.add_argument("--receipt", required=True)
|
||||
p.add_argument("--requirement", required=True)
|
||||
p = sub.add_parser("decide")
|
||||
p.add_argument("--receipt", required=True)
|
||||
p.add_argument("--verdict", required=True)
|
||||
p.add_argument("--initiative", default="")
|
||||
p.add_argument("--reason", default="")
|
||||
p = sub.add_parser("verify")
|
||||
p.add_argument("--receipt", required=True)
|
||||
p.add_argument("--kind", required=True, choices=("standalone", "initiative", "child"))
|
||||
p.add_argument("--initiative", default="")
|
||||
p.add_argument("--max-age-seconds", type=int, default=DEFAULT_MAX_AGE)
|
||||
args = parser.parse_args(argv)
|
||||
handlers = {"discover": cmd_discover, "decide": cmd_decide, "verify": cmd_verify}
|
||||
try:
|
||||
return handlers[args.cmd](args)
|
||||
except Stop as stop:
|
||||
if stop.code == EXIT_REMOTE:
|
||||
print(f"[verdict] REMOTE_UNVERIFIED — {stop.message}")
|
||||
else:
|
||||
print(f"[triage] refused ({stop.code}): {stop.message}", file=sys.stderr)
|
||||
return stop.code
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,141 @@
|
||||
#!/usr/bin/env python3
|
||||
"""按分支改动选择最小直接检查:host 实施中的 make 目标子集(完整套件仍以 CI 为准)。
|
||||
|
||||
用法:test_affected.py [--base REF] [--dry-run]
|
||||
base:--base,否则环境变量 BASE,否则当前分支卡片的 immediate_target(task_pr_check 助手),
|
||||
再否则 origin/main;裸分支名在 origin/<name> 存在时解析为 origin/<name>。
|
||||
|
||||
改动路径 = `git diff <merge-base>...HEAD` ∪ 未提交的已跟踪改动(相对 HEAD)。路径到目标:
|
||||
scripts/** tests/** .gitea/** policy/** Makefile AGENTS.md AGENT-EXECUTION.md PROJECT.md → test-governance
|
||||
modules/{core,backend,frontend,ui} → test-{core,backend,frontend,ui};deps/agent-command → test-agentcmd;
|
||||
deps/agent-{intent,native,squad} → test-libs;gitlink 本身或 .gitmodules → test-pins;
|
||||
modules/** 或 scripts/lint-boundaries.sh → test-boundaries;其余(.task/**、docs/**、其他 *.md)不选。
|
||||
选中任一目标时先跑 test-worktree;单次 `make <targets>` 执行,退出码随 make。
|
||||
|
||||
退出码:make 的退出码;无目标或 --dry-run 为 0;用法/ref 错误为 2。仅使用标准库。
|
||||
"""
|
||||
import argparse
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
DEFAULT_BASE = "origin/main"
|
||||
EXIT_USAGE = 2
|
||||
GITLINK_MODE = "160000"
|
||||
ORDER = ("test-governance", "test-boundaries", "test-pins", "test-libs", "test-core",
|
||||
"test-backend", "test-frontend", "test-ui", "test-agentcmd")
|
||||
GOVERNANCE_DIRS = ("scripts/", "tests/", ".gitea/", "policy/")
|
||||
GOVERNANCE_FILES = ("Makefile", "AGENTS.md", "AGENT-EXECUTION.md", "PROJECT.md")
|
||||
COMPONENTS = {
|
||||
"modules/core": "test-core",
|
||||
"modules/backend": "test-backend",
|
||||
"modules/frontend": "test-frontend",
|
||||
"modules/ui": "test-ui",
|
||||
"deps/agent-command": "test-agentcmd",
|
||||
"deps/agent-intent": "test-libs",
|
||||
"deps/agent-native": "test-libs",
|
||||
"deps/agent-squad": "test-libs",
|
||||
}
|
||||
|
||||
|
||||
class Stop(Exception):
|
||||
pass
|
||||
|
||||
|
||||
def git(*args):
|
||||
proc = subprocess.run(["git", *args], capture_output=True, text=True, encoding="utf-8", errors="replace")
|
||||
if proc.returncode != 0:
|
||||
raise Stop(f"git {' '.join(args)} failed: {proc.stderr.strip() or proc.returncode}")
|
||||
return proc.stdout
|
||||
|
||||
|
||||
def ref_exists(ref):
|
||||
return subprocess.run(["git", "rev-parse", "--verify", "--quiet", f"{ref}^{{commit}}"],
|
||||
capture_output=True).returncode == 0
|
||||
|
||||
|
||||
def card_target():
|
||||
"""当前分支卡片的 immediate_target;无分支/无助手/无卡时返回 None。"""
|
||||
try:
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
import task_pr_check
|
||||
meta, text = task_pr_check.branch_meta(task_pr_check.current_branch(), "main")
|
||||
except Exception:
|
||||
return None
|
||||
return meta.get("immediate_target") if text is not None else None
|
||||
|
||||
|
||||
def resolve_base(requested):
|
||||
name = (requested or os.environ.get("BASE", "")).strip() or card_target() or DEFAULT_BASE
|
||||
if not name.startswith("origin/") and ref_exists(f"refs/remotes/origin/{name}"):
|
||||
name = f"origin/{name}"
|
||||
if not ref_exists(name):
|
||||
raise Stop(f"base {name} does not resolve to a commit")
|
||||
return name
|
||||
|
||||
|
||||
def raw_changes(*diff_args):
|
||||
"""{path: is_gitlink},取自 `git diff --raw -z --no-renames`。"""
|
||||
out = git("diff", "--raw", "-z", "--no-renames", *diff_args)
|
||||
fields = out.split("\0")
|
||||
changes = {}
|
||||
for meta, path in zip(fields[0::2], fields[1::2]):
|
||||
modes = meta.lstrip(":").split()[:2]
|
||||
changes[path] = changes.get(path, False) or GITLINK_MODE in modes
|
||||
return changes
|
||||
|
||||
|
||||
def changed_paths(base):
|
||||
merge_base = git("merge-base", base, "HEAD").strip()
|
||||
changes = raw_changes(merge_base, "HEAD")
|
||||
for path, gitlink in raw_changes("HEAD").items():
|
||||
changes[path] = changes.get(path, False) or gitlink
|
||||
return changes
|
||||
|
||||
|
||||
def targets_for(path, gitlink):
|
||||
found = set()
|
||||
if path.startswith(GOVERNANCE_DIRS) or path in GOVERNANCE_FILES:
|
||||
found.add("test-governance")
|
||||
for prefix, target in COMPONENTS.items():
|
||||
if path == prefix or path.startswith(prefix + "/"):
|
||||
found.add(target)
|
||||
if gitlink or path == ".gitmodules":
|
||||
found.add("test-pins")
|
||||
if path.startswith("modules/") or path == "scripts/lint-boundaries.sh":
|
||||
found.add("test-boundaries")
|
||||
return found
|
||||
|
||||
|
||||
def select(changes):
|
||||
found = set()
|
||||
for path, gitlink in changes.items():
|
||||
found |= targets_for(path, gitlink)
|
||||
return ["test-worktree", *(t for t in ORDER if t in found)] if found else []
|
||||
|
||||
|
||||
def main(argv=None):
|
||||
parser = argparse.ArgumentParser(prog="test_affected.py", description="按分支改动运行最小直接检查")
|
||||
parser.add_argument("--base")
|
||||
parser.add_argument("--dry-run", action="store_true")
|
||||
args = parser.parse_args(argv)
|
||||
try:
|
||||
os.chdir(git("rev-parse", "--show-toplevel").strip())
|
||||
base = resolve_base(args.base)
|
||||
changes = changed_paths(base)
|
||||
except Stop as stop:
|
||||
print(f"[affected] ERROR {stop}")
|
||||
return EXIT_USAGE
|
||||
targets = select(changes)
|
||||
print(f"[affected] base={base} changed={len(changes)} targets={','.join(targets) or 'none'}", flush=True)
|
||||
if not targets:
|
||||
print("[affected] nothing to test")
|
||||
return 0
|
||||
if args.dry_run:
|
||||
print(f"[affected] dry-run: make {' '.join(targets)}")
|
||||
return 0
|
||||
return subprocess.run(["make", *targets]).returncode
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Reference in New Issue
Block a user