Compare commits
14
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8160c2b2f6 | ||
|
|
706c0b0096 | ||
|
|
2c08a9d0cf | ||
|
|
3d9782fc34 | ||
|
|
4a9c5138af | ||
|
|
2de29c59bb | ||
|
|
66d637fc03 | ||
|
|
d19b307fff | ||
|
|
59565ebc25 | ||
|
|
fd7abd84ba | ||
|
|
12d09b1dea | ||
|
|
6905629268 | ||
|
|
44de4b9e7d | ||
|
|
dbc482e0c1 |
@@ -7,7 +7,7 @@ Pipeline: Detect -> Diagnose -> Fix Plan -> Patch Generate -> Owner Approval ->
|
|||||||
Complete autonomous cycle with notification integration.
|
Complete autonomous cycle with notification integration.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import json, os, hashlib, time, sys, logging, subprocess, requests
|
import json, os, hashlib, time, sys, logging, subprocess, requests, sqlite3
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
WORKSPACE = Path(os.environ.get("AIOS_WORKSPACE", "/data/aios-gitops/workspace"))
|
WORKSPACE = Path(os.environ.get("AIOS_WORKSPACE", "/data/aios-gitops/workspace"))
|
||||||
@@ -18,6 +18,9 @@ REJECTED_DIR = WORKSPACE / "agents" / "patches" / "rejected/"
|
|||||||
AUDIT_DIR = WORKSPACE / "audit" / "controller-runs/"
|
AUDIT_DIR = WORKSPACE / "audit" / "controller-runs/"
|
||||||
STATE_FILE = WORKSPACE / "agents" / "state" / "controller-state.json"
|
STATE_FILE = WORKSPACE / "agents" / "state" / "controller-state.json"
|
||||||
HEALTH_FILE = WORKSPACE / "agents" / "state" / "health.json"
|
HEALTH_FILE = WORKSPACE / "agents" / "state" / "health.json"
|
||||||
|
BARE_REPO_PATH = Path("/data/aios-gitops/runtime/gitea-bin/data/repositories/aios/aios-v23.git")
|
||||||
|
GITEA_DB_PATH = Path("/data/aios-gitops/runtime/gitea-bin/data/gitea.db")
|
||||||
|
REPO_ID = 1
|
||||||
NOTIF_DIR = WORKSPACE / "agents" / "notifications/"
|
NOTIF_DIR = WORKSPACE / "agents" / "notifications/"
|
||||||
ACTIVE_DIR = WORKSPACE / "agents" / "commands" / "active/"
|
ACTIVE_DIR = WORKSPACE / "agents" / "commands" / "active/"
|
||||||
RUNNING_DIR = WORKSPACE / "agents" / "commands" / "running/"
|
RUNNING_DIR = WORKSPACE / "agents" / "commands" / "running/"
|
||||||
@@ -76,22 +79,49 @@ def h_api():
|
|||||||
except Exception as e: return {"ok": False, "err": str(e)}
|
except Exception as e: return {"ok": False, "err": str(e)}
|
||||||
|
|
||||||
def h_branch_sync():
|
def h_branch_sync():
|
||||||
bare = Path("/data/aios-gitops/runtime/gitea-bin/data/repositories/aios/aios-v23.git")
|
"""Compare bare repo refs with Gitea DB live branches using git ls-remote (no Gitea API)."""
|
||||||
|
bare = BARE_REPO_PATH
|
||||||
|
db_path = GITEA_DB_PATH
|
||||||
|
if not bare.exists():
|
||||||
|
return {"ok": False, "err": f"bare repo path not found: {bare}"}
|
||||||
|
if not db_path.exists():
|
||||||
|
return {"ok": False, "err": f"gitea db path not found: {db_path}"}
|
||||||
try:
|
try:
|
||||||
r = subprocess.run(["git","--git-dir",str(bare),"branch","-a"], capture_output=True, text=True, timeout=10)
|
# Use git ls-remote which reads directly from the bare repo with no API dependency
|
||||||
if r.returncode != 0: return {"ok": False, "err": r.stderr[:200]}
|
r = subprocess.run(
|
||||||
bb = set(l.strip().replace("remotes/origin/","").replace("* ","") for l in r.stdout.strip().split("\n") if l.strip())
|
["git", "--git-dir", str(bare), "ls-remote", "--refs", str(bare), "refs/heads/*"],
|
||||||
gb = []; pg = 1
|
capture_output=True, text=True, timeout=10
|
||||||
while True:
|
)
|
||||||
r2 = requests.get(f"{GITEA_URL}/api/v1/repos/aios/aios-v23/branches", params={"page":pg,"limit":50},
|
if r.returncode != 0:
|
||||||
headers={"Authorization":f"token {GITEA_TOKEN}"}, timeout=10)
|
return {"ok": False, "err": f"git ls-remote failed: {r.stderr[:200]}"}
|
||||||
if r2.status_code != 200: break
|
bare_names = set()
|
||||||
d = r2.json()
|
for line in r.stdout.strip().split("\n"):
|
||||||
if not d: break
|
if not line.strip():
|
||||||
gb.extend(b["name"] for b in d); pg += 1
|
continue
|
||||||
gs = set(gb); miss = bb - gs
|
parts = line.strip().split("\t")
|
||||||
return {"ok": len(miss)==0, "bare": len(bb), "gitea": len(gs), "missing": list(miss)[:10]}
|
if len(parts) == 2:
|
||||||
except Exception as e: return {"ok": False, "err": str(e)}
|
ref = parts[1]
|
||||||
|
if ref.startswith("refs/heads/"):
|
||||||
|
bare_names.add(ref[len("refs/heads/"):])
|
||||||
|
|
||||||
|
# Read live branches directly from SQLite (no Gitea API pagination)
|
||||||
|
import urllib.parse
|
||||||
|
uri = f"file:{urllib.parse.quote(str(db_path.resolve()))}?mode=ro"
|
||||||
|
conn = sqlite3.connect(uri, uri=True, timeout=5)
|
||||||
|
try:
|
||||||
|
cursor = conn.execute(
|
||||||
|
"SELECT name FROM branch WHERE repo_id = ? AND is_deleted = 0 ORDER BY name",
|
||||||
|
(REPO_ID,),
|
||||||
|
)
|
||||||
|
db_names = {row[0] for row in cursor.fetchall()}
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
miss = bare_names - db_names
|
||||||
|
return {"ok": len(miss) == 0, "bare": len(bare_names), "gitea": len(db_names), "missing": list(miss)[:10]}
|
||||||
|
except Exception as e:
|
||||||
|
return {"ok": False, "err": str(e)}
|
||||||
|
|
||||||
|
|
||||||
def h_stuck():
|
def h_stuck():
|
||||||
s = []
|
s = []
|
||||||
@@ -123,11 +153,62 @@ def diagnose(results):
|
|||||||
"stuck": s[:3], "fix": "move_to_failed"})
|
"stuck": s[:3], "fix": "move_to_failed"})
|
||||||
return plans
|
return plans
|
||||||
|
|
||||||
|
# === Safe Mode Gate ===
|
||||||
|
|
||||||
|
SAFE_MODE_FILE = Path("/data/aios-gitops/runtime/state/autonomous-safe-mode.json")
|
||||||
|
SENTINEL_REPORT_FILE = Path("/data/aios-gitops/runtime/state/gitea-branch-consistency-report.json")
|
||||||
|
|
||||||
|
|
||||||
|
def check_safe_mode():
|
||||||
|
"""Return True if safe mode is active (no mutations allowed)."""
|
||||||
|
if not SAFE_MODE_FILE.exists():
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
marker = json.loads(SAFE_MODE_FILE.read_text())
|
||||||
|
if marker.get("status") == "active":
|
||||||
|
log.warning(f"Safe mode active since {marker.get('entered_at','?')}: {marker.get('reason','?')}")
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
log.warning(f"Failed to read safe mode marker: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def check_sentinel_drift():
|
||||||
|
"""Read the consistency sentinel report and log any drift. Returns True if recent report shows drift."""
|
||||||
|
if not SENTINEL_REPORT_FILE.exists():
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
report = json.loads(SENTINEL_REPORT_FILE.read_text())
|
||||||
|
drift_counts = report.get("drift_counts", {})
|
||||||
|
total_drift = sum(drift_counts.values())
|
||||||
|
if total_drift > 0:
|
||||||
|
log.warning(f"Consistency sentinel reports {total_drift} drifts (report: {SENTINEL_REPORT_FILE})")
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
except Exception as e:
|
||||||
|
log.warning(f"Failed to read sentinel report: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
# === Apply Patches ===
|
# === Apply Patches ===
|
||||||
|
|
||||||
def apply_approved_patches():
|
def apply_approved_patches():
|
||||||
applied = []
|
applied = []
|
||||||
if not APPROVED_DIR.exists(): return applied
|
if not APPROVED_DIR.exists():
|
||||||
|
return applied
|
||||||
|
|
||||||
|
# Gate: if safe mode is active, refuse all mutations
|
||||||
|
if check_safe_mode():
|
||||||
|
log.error("Safe mode is ACTIVE — refusing to apply patches until cleared")
|
||||||
|
notify("Safe Mode Active — Patches Blocked",
|
||||||
|
"Gitea branch consistency safe mode is active. Refusing to apply approved patches.",
|
||||||
|
"critical")
|
||||||
|
return applied
|
||||||
|
|
||||||
|
# Gate: if sentinel report shows recent drift, log warning
|
||||||
|
if check_sentinel_drift():
|
||||||
|
log.warning("Sentinel drift detected — proceeding with current approved patches")
|
||||||
|
|
||||||
for f in sorted(APPROVED_DIR.iterdir()):
|
for f in sorted(APPROVED_DIR.iterdir()):
|
||||||
if not f.name.endswith(".json"): continue
|
if not f.name.endswith(".json"): continue
|
||||||
try:
|
try:
|
||||||
@@ -183,7 +264,7 @@ def run_cycle():
|
|||||||
# Diagnose and create patches
|
# Diagnose and create patches
|
||||||
plans = diagnose(results)
|
plans = diagnose(results)
|
||||||
for pl in plans:
|
for pl in plans:
|
||||||
pending = create_patch(pl)
|
pending = create_patch(pl['check'], pl.get('diagnosis', 'No diagnosis'))
|
||||||
if pending:
|
if pending:
|
||||||
notify(f"⚠️ Issue Detected: {pl['check']}", pl['diagnosis'], "warning")
|
notify(f"⚠️ Issue Detected: {pl['check']}", pl['diagnosis'], "warning")
|
||||||
|
|
||||||
@@ -203,16 +284,17 @@ def run_cycle():
|
|||||||
return results, plans, applied
|
return results, plans, applied
|
||||||
|
|
||||||
|
|
||||||
def create_patch(pl):
|
def create_patch(title, content):
|
||||||
patch = {"patch_id": f"patch-{int(time.time())}", "created": time.strftime("%Y-%m-%dT%H:%M:%S%z"),
|
"""Create a patch file in PENDING_DIR. Returns dict with ok/id/title/content."""
|
||||||
"check": pl["check"], "severity": pl["severity"], "diagnosis": pl["diagnosis"],
|
pid = f"patch-{int(time.time())}-{hashlib.md5(title.encode()).hexdigest()[:8]}"
|
||||||
"branches": pl.get("branches",[]), "stuck": pl.get("stuck",[]), "fix": pl.get("fix","")}
|
patch = {"title": title, "content": content, "id": pid, "created": time.strftime("%Y-%m-%dT%H:%M:%S%z")}
|
||||||
PENDING_DIR.mkdir(parents=True, exist_ok=True)
|
PENDING_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
f = PENDING_DIR / f"{patch['patch_id']}.json"
|
f = PENDING_DIR / f"{pid}.json"
|
||||||
if f.exists(): return False
|
if f.exists():
|
||||||
|
return {"ok": False, "err": "already exists", "id": pid, "title": title, "content": content}
|
||||||
f.write_text(json.dumps(patch, indent=2, ensure_ascii=False))
|
f.write_text(json.dumps(patch, indent=2, ensure_ascii=False))
|
||||||
log.info(f"Patch created: {patch['patch_id']}")
|
log.info(f"Patch created: {pid}")
|
||||||
return True
|
return {"ok": True, "id": pid, "title": title, "content": content}
|
||||||
|
|
||||||
|
|
||||||
def run_forever():
|
def run_forever():
|
||||||
|
|||||||
@@ -0,0 +1,73 @@
|
|||||||
|
# 微信文本-图片缓存隔离事故报告
|
||||||
|
|
||||||
|
## 结论
|
||||||
|
|
||||||
|
本次事故已经按最新备份链路回滚并完成第二轮评审修复。`3d9782fc346d1e7defde090ab8637e62ab28ab8a` 进一步修复了英文诊断文本和未确认引用文本误入图片路径的问题,`706c0b0` 则是后续最小修复提交。当前文档只记录证据、部署和验收边界,不把“真实 owner inbound 已完成”写成结论。
|
||||||
|
|
||||||
|
## 现象
|
||||||
|
|
||||||
|
- 纯文本消息被误答成旧图片回复,属于 stale-image reply on pure text。
|
||||||
|
- 现场证据显示 `types=1` 且 `hasMedia=false`,但后续又进入 `message_type=image`、`image_count=0`、`implicit_image_message`,最终落到 `cached_image_expired`。
|
||||||
|
- 这说明“诊断文本”被误判成图片意图后,又继承了 sender 级旧图缓存。
|
||||||
|
|
||||||
|
## 根因
|
||||||
|
|
||||||
|
- `fixes/patch_openclaw_weixin_hermes.py:768-839` 把文本意图判断和缓存回退放在同一条链上。
|
||||||
|
- `looksLikeHermesImageFollowup()` 会把含图片关键字的诊断文本推进 image intent。
|
||||||
|
- `resolveHermesFollowupImageContext()` 随后会读取 sender 级缓存;一旦缓存命中失败或过期,就会落到 `cached_image_expired`,从而让纯文本沾上旧图上下文。
|
||||||
|
- `handleHermesImplicitImage()` 继续把消息送入 `handleHermes(..., "implicit_image_message")`,所以误判会沿着同一条图片处理通道传播。
|
||||||
|
|
||||||
|
## 护栏不变量
|
||||||
|
|
||||||
|
- 图片处理上下文必须来自当前消息图片、已确认的引用图片,或精确匹配的缓存图片 ID;缺少这三类证据时,纯文本不能进入 `handleHermesImplicitImage()`。
|
||||||
|
- 未确认的引用文本不能继承 sender 级旧图缓存,必须返回 `referenced_image_not_confirmed`。
|
||||||
|
- 只有精确缓存图片 ID 命中且缓存仍有效时,才允许走 `resolveHermesCachedImageEntry()` 的 `cached_image_matched` 路径。
|
||||||
|
- `/hermes` 这类 slash 路径保持原有分流,slash/channel 面不变。
|
||||||
|
- 这次修复的守卫目标,是把中英文诊断文本、未确认引用文本与图片缓存回退拆开,避免再把普通文本提升成图片意图。
|
||||||
|
|
||||||
|
## TDD 与幂等性
|
||||||
|
|
||||||
|
- `66d637fc0364a617c7b9fd8cd9baa68fb0368675`(`fix(weixin): isolate text from stale image cache`)引入了隔离修复和 `tests/test_weixin_image_followup_intent.py`。
|
||||||
|
- `2de29c59bb946cc3db3e14e8a06270391ede8401`(`fix(weixin): add process-only runtime patch mode`)继续收紧 process-only 运行模式,保持回放幂等。
|
||||||
|
- `3d9782fc346d1e7defde090ab8637e62ab28ab8a`(`fix(weixin): require confirmed image reference context`)补齐英文诊断文本和未确认引用文本用例,并把图片引用收紧到已确认上下文。
|
||||||
|
- 用户实测的 TDD 路径为 RED `8 PASS / 1 FAIL`,随后 GREEN `9 PASS`。
|
||||||
|
- 最小修复提交为 `706c0b0`,对应 quoted pure text 的最后一层收敛修复。
|
||||||
|
- 第二轮回归结果为 `8 PASS`;node check 同样通过,重复应用补丁保持幂等。
|
||||||
|
- 生产 npm 分发侧本身缺少 `tsconfig/tests`,baseline 与 patched 共享这一限制,所以这里不把分发目录的缺口当成回归点;验收以 node check 和 8 tests 为准。
|
||||||
|
|
||||||
|
## 用户实测补充
|
||||||
|
|
||||||
|
- 普通文本两项为 PASS。
|
||||||
|
- quoted pure text 为 FAIL,日志证据包含 `ref_msg.message_item.type=0/NONE`、quoted id、`implicit_image_message`、`referenced_image_unresolved`。
|
||||||
|
- 这组证据说明 quoted pure text 没有被当成真正的图片 follow-up,且没有越过引用确认边界。
|
||||||
|
|
||||||
|
## 回滚与备份
|
||||||
|
|
||||||
|
- 回滚按最新备份执行,基线指向 `/data/aios-gitops/runtime/latest-backup-dir.txt` 所示的最新备份目录。
|
||||||
|
- 备份路径链路按供给证据记录为 `T064506Z`、`T074212Z`、`T081658Z`。
|
||||||
|
- 第一轮恢复后的部署过程哈希记录为 `e9625d12` 和 `c00c58f8`。
|
||||||
|
- v2 部署备份为 `/var/backups/aios/weixin-text-cache-isolation-20260711T094908Z`。
|
||||||
|
- v2 部署文件哈希为 TS `024c6498284d1871b048f6157d5d3dbd4d34e54b53a05afd744b00458ceeb79f`、JS `d73b15d6752056192a1d1939b04107334419785b41175b39f1cbc1132ac65c47`;slash/channel 口径保持不变。
|
||||||
|
- 首次部署因为 `-- No entries --` 门禁误判而回滚,随后纠正部署在线,PID 为 `2496603`。
|
||||||
|
- 在线修复后的 hashes 为 `86dfe...` / `a9b854...`,`channel 4` accounts healthy,cache unchanged。
|
||||||
|
|
||||||
|
## 运行态验证
|
||||||
|
|
||||||
|
- 第一轮 gateway PID 由 `4025932` 变为 `1723576`;v2 部署后由 `1723576` 变为 `1876713`。
|
||||||
|
- v2 现场/供给证据为 `active`、监听 `18789`、`error_count=0`、`channel gate pass`、`cache preserved`。
|
||||||
|
- 最终 read-only gate 通过,说明部署后运行态未出现新的插件错误,channel 状态和缓存均保持预期。
|
||||||
|
- quoted pure text 的第二次 owner live acceptance 仍为 PENDING。
|
||||||
|
|
||||||
|
## 未完成的真实验收
|
||||||
|
|
||||||
|
- owner 纯文本
|
||||||
|
- diagnostic text
|
||||||
|
- genuine image-followup message
|
||||||
|
- quoted pure text 的第二次 owner live acceptance
|
||||||
|
|
||||||
|
以上四类消息仍需要由 owner 真实 inbound 到达,才能把这次“文本隔离”结论升格为完整的线上接受;本报告明确保留该项为 pending,不把它们写成已经完成。
|
||||||
|
|
||||||
|
## 安全说明
|
||||||
|
|
||||||
|
- 本次仅更新本报告,没有修改代码、runtime、services 或 secrets。
|
||||||
|
- 报告里没有任何凭据、密钥或原始敏感 ID。
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
# V1R4C4 SR-01: Gitea Branch Sync Fix
|
||||||
|
|
||||||
|
Status: started
|
||||||
|
|
||||||
|
Priority: P0
|
||||||
|
|
||||||
|
Risk: medium
|
||||||
|
|
||||||
|
Baseline dependency: V1R4C3
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
Start V1R4C4 with SR-01 only.
|
||||||
|
|
||||||
|
SR-04 Portal AIOS Integration Merge remains deferred and is not part of this execution scope.
|
||||||
|
|
||||||
|
## Background
|
||||||
|
|
||||||
|
V1R4C3 completed the immediate branch_sync mitigation by changing branch synchronization to use `git ls-remote` instead of depending on the Gitea branch API pagination path.
|
||||||
|
|
||||||
|
That mitigation avoids the user-visible failure path, but it does not fully certify or eliminate the root reliability issue:
|
||||||
|
|
||||||
|
```text
|
||||||
|
bare repository refs
|
||||||
|
!=
|
||||||
|
Gitea branch metadata / DB index
|
||||||
|
!=
|
||||||
|
Gitea branch API visible state
|
||||||
|
```
|
||||||
|
|
||||||
|
SR-01 exists to validate and fix the root problem category: Gitea branch metadata/index drift from the bare repository state.
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
SR-01 includes:
|
||||||
|
|
||||||
|
1. Confirm current branch_sync no longer depends on Gitea API pagination.
|
||||||
|
2. Validate that Gitea DB branch metadata is consistent with bare repository refs.
|
||||||
|
3. Add or activate consistency verification around branch metadata drift.
|
||||||
|
4. Ensure autonomous operations do not continue mutating state when drift is detected.
|
||||||
|
5. Produce a repeatable acceptance test for future incidents.
|
||||||
|
|
||||||
|
SR-01 excludes:
|
||||||
|
|
||||||
|
- Portal AIOS Integration Merge.
|
||||||
|
- Search Gateway real provider binding.
|
||||||
|
- Broad P1 capability merges.
|
||||||
|
- Bulk patch merge or branch deletion.
|
||||||
|
|
||||||
|
## Required Checks
|
||||||
|
|
||||||
|
### Branch sync path
|
||||||
|
|
||||||
|
Verify branch_sync source of truth:
|
||||||
|
|
||||||
|
```text
|
||||||
|
git ls-remote / bare repository refs
|
||||||
|
```
|
||||||
|
|
||||||
|
The Gitea branch API must not be the mandatory source of truth for synchronization.
|
||||||
|
|
||||||
|
### Gitea consistency path
|
||||||
|
|
||||||
|
Verify equality between:
|
||||||
|
|
||||||
|
```text
|
||||||
|
bare repository refs count
|
||||||
|
Gitea branch live metadata count
|
||||||
|
Gitea branch API paginated visible count
|
||||||
|
```
|
||||||
|
|
||||||
|
Any mismatch must be treated as reliability drift.
|
||||||
|
|
||||||
|
### Safe-mode behavior
|
||||||
|
|
||||||
|
When drift is detected:
|
||||||
|
|
||||||
|
1. Stop autonomous mutations.
|
||||||
|
2. Preserve read-only diagnostics.
|
||||||
|
3. Emit safe-mode marker.
|
||||||
|
4. Require controlled recovery before mutation resumes.
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
|
||||||
|
SR-01 is complete when all of the following are true:
|
||||||
|
|
||||||
|
1. branch_sync continues to work without Gitea branch API pagination dependency.
|
||||||
|
2. Gitea branch metadata drift can be detected deterministically.
|
||||||
|
3. Drift causes autonomous safe mode instead of continued mutation.
|
||||||
|
4. Recovery procedure includes minimal repair, not default restart.
|
||||||
|
5. Runtime acceptance passes:
|
||||||
|
|
||||||
|
```text
|
||||||
|
getRuntimeStatus
|
||||||
|
->
|
||||||
|
getBaseline
|
||||||
|
->
|
||||||
|
createBranch
|
||||||
|
->
|
||||||
|
createPatch
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected results:
|
||||||
|
|
||||||
|
- runtime status is active;
|
||||||
|
- baseline reads V1R4C3 or later;
|
||||||
|
- createBranch returns commit/rev/sync status;
|
||||||
|
- createPatch returns commit/rev/sync status.
|
||||||
|
|
||||||
|
## Execution Order
|
||||||
|
|
||||||
|
1. Open SR-01 patch branch.
|
||||||
|
2. Review V1R4C3 branch_sync behavior.
|
||||||
|
3. Integrate Gitea branch consistency sentinel and safe-mode gate.
|
||||||
|
4. Run runtime acceptance.
|
||||||
|
5. If clean, prepare V1R4C4 reliability baseline proposal.
|
||||||
|
|
||||||
|
## Follow-on Gates
|
||||||
|
|
||||||
|
SR-02 Watcher State Cleanup may start after SR-01 acceptance is green.
|
||||||
|
|
||||||
|
SR-03 Search Gateway Real Provider Binding may start after SR-01 confirms GitOps/Gitea reliability is stable enough for non-P0 work.
|
||||||
|
|
||||||
|
SR-04 Portal AIOS Integration Merge remains deferred pending explicit approval.
|
||||||
|
|
||||||
|
## Safety Rules
|
||||||
|
|
||||||
|
- Do not bulk merge old patches.
|
||||||
|
- Do not delete remote branches during SR-01 unless the branch consistency sentinel is already deployed and healthy.
|
||||||
|
- Do not restart Gitea unless process-level failure is proven.
|
||||||
|
- Do not modify Gitea DB without backup and owner/admin approval.
|
||||||
Executable
+2218
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,11 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=Run Gitea branch consistency sentinel periodically
|
||||||
|
|
||||||
|
[Timer]
|
||||||
|
OnBootSec=2min
|
||||||
|
OnUnitActiveSec=1min
|
||||||
|
RandomizedDelaySec=10s
|
||||||
|
Persistent=true
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=timers.target
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Safe-mode gate for autonomous mutations.
|
||||||
|
|
||||||
|
Controllers should run `check` before writes. Exit code 75 means the
|
||||||
|
controller must stop mutation and wait for controlled recovery.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import datetime
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
import sys
|
||||||
|
|
||||||
|
DEFAULT = "/data/aios-gitops/runtime/state/autonomous-safe-mode.json"
|
||||||
|
|
||||||
|
|
||||||
|
def now():
|
||||||
|
return datetime.datetime.now(datetime.timezone.utc).isoformat().replace("+00:00", "Z")
|
||||||
|
|
||||||
|
|
||||||
|
def load(path):
|
||||||
|
if not path.exists():
|
||||||
|
return None
|
||||||
|
with path.open("r", encoding="utf-8") as f:
|
||||||
|
return json.load(f)
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
parser = argparse.ArgumentParser()
|
||||||
|
parser.add_argument("--safe-mode-file", default=os.environ.get("AUTONOMOUS_SAFE_MODE_FILE", DEFAULT))
|
||||||
|
parser.add_argument("command", choices=["check", "enter", "clear"])
|
||||||
|
parser.add_argument("--reason", default="")
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
path = Path(args.safe_mode_file)
|
||||||
|
|
||||||
|
if args.command == "check":
|
||||||
|
marker = load(path)
|
||||||
|
if marker:
|
||||||
|
print(json.dumps(marker, ensure_ascii=False, indent=2))
|
||||||
|
return 75
|
||||||
|
return 0
|
||||||
|
|
||||||
|
if args.command == "enter":
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
path.write_text(json.dumps({
|
||||||
|
"status": "active",
|
||||||
|
"entered_at": now(),
|
||||||
|
"reason": args.reason or "manual",
|
||||||
|
}, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
if args.command == "clear":
|
||||||
|
if path.exists():
|
||||||
|
path.unlink()
|
||||||
|
return 0
|
||||||
|
|
||||||
|
return 2
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -0,0 +1,414 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Gitea branch metadata consistency sentinel.
|
||||||
|
|
||||||
|
Compares three views of a repository's branch state:
|
||||||
|
1. Bare Git refs under refs/heads
|
||||||
|
2. Gitea SQLite branch metadata rows with is_deleted=0
|
||||||
|
3. Gitea branch API pagination results
|
||||||
|
|
||||||
|
On drift or check failure, optionally writes an autonomous safe-mode marker so
|
||||||
|
controllers can stop mutating state before the incident expands.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import datetime as _dt
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
import shlex
|
||||||
|
import sqlite3
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
from typing import Any
|
||||||
|
import urllib.error
|
||||||
|
import urllib.parse
|
||||||
|
import urllib.request
|
||||||
|
|
||||||
|
|
||||||
|
DEFAULT_REPO_DIR = "/data/aios-gitops/runtime/gitea-bin/data/repositories/aios/aios.git"
|
||||||
|
DEFAULT_DB_PATH = "/data/aios-gitops/runtime/gitea-bin/data/gitea.db"
|
||||||
|
DEFAULT_API_URL = "http://127.0.0.1:3000/api/v1/repos/aios/aios/branches"
|
||||||
|
DEFAULT_STATE_DIR = "/data/aios-gitops/runtime/state"
|
||||||
|
DEFAULT_SAFE_MODE_FILE = f"{DEFAULT_STATE_DIR}/autonomous-safe-mode.json"
|
||||||
|
DEFAULT_REPORT_FILE = f"{DEFAULT_STATE_DIR}/gitea-branch-consistency-report.json"
|
||||||
|
DEFAULT_DOCTOR_CMD = (
|
||||||
|
"/data/aios-gitops/runtime/bin/gitea "
|
||||||
|
"-C /data/aios-gitops/runtime/gitea-bin/custom "
|
||||||
|
"doctor check --run check-db-consistency"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def utc_now() -> str:
|
||||||
|
return _dt.datetime.now(_dt.timezone.utc).isoformat().replace("+00:00", "Z")
|
||||||
|
|
||||||
|
|
||||||
|
def env_bool(name: str, default: bool = False) -> bool:
|
||||||
|
value = os.environ.get(name)
|
||||||
|
if value is None or value == "":
|
||||||
|
return default
|
||||||
|
return value.strip().lower() in {"1", "true", "yes", "y", "on"}
|
||||||
|
|
||||||
|
|
||||||
|
def atomic_write_json(path: Path, payload: dict[str, Any], mode: int = 0o644) -> None:
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
fd, tmp_name = tempfile.mkstemp(prefix=f".{path.name}.", suffix=".tmp", dir=str(path.parent))
|
||||||
|
try:
|
||||||
|
with os.fdopen(fd, "w", encoding="utf-8") as handle:
|
||||||
|
json.dump(payload, handle, ensure_ascii=False, indent=2, sort_keys=True)
|
||||||
|
handle.write("\n")
|
||||||
|
os.chmod(tmp_name, mode)
|
||||||
|
os.replace(tmp_name, path)
|
||||||
|
finally:
|
||||||
|
try:
|
||||||
|
os.unlink(tmp_name)
|
||||||
|
except FileNotFoundError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def run_command(command: list[str], timeout_seconds: int) -> dict[str, Any]:
|
||||||
|
try:
|
||||||
|
proc = subprocess.run(
|
||||||
|
command,
|
||||||
|
check=False,
|
||||||
|
text=True,
|
||||||
|
stdout=subprocess.PIPE,
|
||||||
|
stderr=subprocess.PIPE,
|
||||||
|
timeout=timeout_seconds,
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"ok": proc.returncode == 0,
|
||||||
|
"code": proc.returncode,
|
||||||
|
"stdout": proc.stdout.strip(),
|
||||||
|
"stderr": proc.stderr.strip(),
|
||||||
|
"cmd": command,
|
||||||
|
}
|
||||||
|
except FileNotFoundError as exc:
|
||||||
|
return {"ok": False, "code": 127, "stdout": "", "stderr": str(exc), "cmd": command}
|
||||||
|
except subprocess.TimeoutExpired as exc:
|
||||||
|
return {
|
||||||
|
"ok": False,
|
||||||
|
"code": 124,
|
||||||
|
"stdout": (exc.stdout or "").strip() if isinstance(exc.stdout, str) else "",
|
||||||
|
"stderr": (exc.stderr or "").strip() if isinstance(exc.stderr, str) else "",
|
||||||
|
"cmd": command,
|
||||||
|
"timeout_seconds": timeout_seconds,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def git_branch_names(repo_dir: str, timeout_seconds: int) -> tuple[set[str], dict[str, Any]]:
|
||||||
|
command = ["git", "--git-dir", repo_dir, "for-each-ref", "--format=%(refname:short)", "refs/heads"]
|
||||||
|
result = run_command(command, timeout_seconds)
|
||||||
|
if not result["ok"]:
|
||||||
|
raise RuntimeError(f"git branch ref scan failed: {result['stderr'] or result['stdout']}")
|
||||||
|
names = {line.strip() for line in result["stdout"].splitlines() if line.strip()}
|
||||||
|
result["count"] = len(names)
|
||||||
|
return names, result
|
||||||
|
|
||||||
|
|
||||||
|
def sqlite_branch_names(db_path: str, repo_id: int) -> tuple[set[str], dict[str, Any]]:
|
||||||
|
path = Path(db_path)
|
||||||
|
if not path.exists():
|
||||||
|
raise RuntimeError(f"Gitea database does not exist: {db_path}")
|
||||||
|
|
||||||
|
uri = f"file:{urllib.parse.quote(str(path.resolve()))}?mode=ro"
|
||||||
|
try:
|
||||||
|
conn = sqlite3.connect(uri, uri=True, timeout=5)
|
||||||
|
try:
|
||||||
|
cursor = conn.execute(
|
||||||
|
"SELECT name FROM branch WHERE repo_id = ? AND is_deleted = 0 ORDER BY name",
|
||||||
|
(repo_id,),
|
||||||
|
)
|
||||||
|
names = {row[0] for row in cursor.fetchall()}
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
except sqlite3.Error as exc:
|
||||||
|
raise RuntimeError(f"SQLite branch metadata query failed: {exc}") from exc
|
||||||
|
|
||||||
|
return names, {"ok": True, "repo_id": repo_id, "count": len(names), "db_path": db_path}
|
||||||
|
|
||||||
|
|
||||||
|
def add_query_params(url: str, params: dict[str, Any]) -> str:
|
||||||
|
split = urllib.parse.urlsplit(url)
|
||||||
|
query = dict(urllib.parse.parse_qsl(split.query, keep_blank_values=True))
|
||||||
|
query.update({key: str(value) for key, value in params.items()})
|
||||||
|
return urllib.parse.urlunsplit(
|
||||||
|
(split.scheme, split.netloc, split.path, urllib.parse.urlencode(query), split.fragment)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def read_api_token() -> str | None:
|
||||||
|
token = os.environ.get("GITEA_API_TOKEN")
|
||||||
|
if token:
|
||||||
|
return token.strip()
|
||||||
|
|
||||||
|
token_file = os.environ.get("GITEA_API_TOKEN_FILE")
|
||||||
|
if token_file:
|
||||||
|
token_path = Path(token_file)
|
||||||
|
if token_path.exists():
|
||||||
|
return token_path.read_text(encoding="utf-8").strip()
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def api_branch_names(
|
||||||
|
api_url: str,
|
||||||
|
limit: int,
|
||||||
|
timeout_seconds: int,
|
||||||
|
max_pages: int,
|
||||||
|
) -> tuple[set[str], dict[str, Any]]:
|
||||||
|
token = read_api_token()
|
||||||
|
names: list[str] = []
|
||||||
|
pages: list[dict[str, Any]] = []
|
||||||
|
|
||||||
|
for page in range(1, max_pages + 1):
|
||||||
|
url = add_query_params(api_url, {"page": page, "limit": limit})
|
||||||
|
headers = {"Accept": "application/json"}
|
||||||
|
if token:
|
||||||
|
headers["Authorization"] = f"token {token}"
|
||||||
|
|
||||||
|
request = urllib.request.Request(url, headers=headers, method="GET")
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(request, timeout=timeout_seconds) as response:
|
||||||
|
body = response.read()
|
||||||
|
status = getattr(response, "status", response.getcode())
|
||||||
|
except urllib.error.HTTPError as exc:
|
||||||
|
detail = exc.read().decode("utf-8", errors="replace")
|
||||||
|
raise RuntimeError(f"Gitea branch API HTTP {exc.code}: {detail[:500]}") from exc
|
||||||
|
except urllib.error.URLError as exc:
|
||||||
|
raise RuntimeError(f"Gitea branch API request failed: {exc}") from exc
|
||||||
|
except TimeoutError as exc:
|
||||||
|
raise RuntimeError(f"Gitea branch API request timed out: {exc}") from exc
|
||||||
|
|
||||||
|
try:
|
||||||
|
payload = json.loads(body.decode("utf-8"))
|
||||||
|
except json.JSONDecodeError as exc:
|
||||||
|
raise RuntimeError(f"Gitea branch API returned invalid JSON on page {page}") from exc
|
||||||
|
|
||||||
|
if not isinstance(payload, list):
|
||||||
|
raise RuntimeError(f"Gitea branch API returned non-list payload on page {page}: {type(payload).__name__}")
|
||||||
|
|
||||||
|
page_names = []
|
||||||
|
for item in payload:
|
||||||
|
if not isinstance(item, dict) or "name" not in item:
|
||||||
|
raise RuntimeError(f"Gitea branch API returned malformed branch item on page {page}: {item!r}")
|
||||||
|
page_names.append(str(item["name"]))
|
||||||
|
|
||||||
|
names.extend(page_names)
|
||||||
|
pages.append({"page": page, "status": status, "count": len(page_names)})
|
||||||
|
|
||||||
|
if len(page_names) < limit:
|
||||||
|
break
|
||||||
|
else:
|
||||||
|
raise RuntimeError(f"Gitea branch API exceeded max_pages={max_pages}; refusing to trust partial result")
|
||||||
|
|
||||||
|
return set(names), {
|
||||||
|
"ok": True,
|
||||||
|
"api_url": api_url,
|
||||||
|
"limit": limit,
|
||||||
|
"pages": pages,
|
||||||
|
"raw_count": len(names),
|
||||||
|
"unique_count": len(set(names)),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def doctor_check(command_text: str, timeout_seconds: int, required: bool) -> dict[str, Any]:
|
||||||
|
command_text = command_text.strip()
|
||||||
|
if not command_text:
|
||||||
|
return {"ok": True, "skipped": True, "reason": "empty GITEA_DOCTOR_CMD"}
|
||||||
|
|
||||||
|
command = shlex.split(command_text)
|
||||||
|
if not command:
|
||||||
|
return {"ok": True, "skipped": True, "reason": "empty parsed GITEA_DOCTOR_CMD"}
|
||||||
|
|
||||||
|
if not Path(command[0]).exists() and "/" in command[0]:
|
||||||
|
return {
|
||||||
|
"ok": not required,
|
||||||
|
"skipped": True,
|
||||||
|
"required": required,
|
||||||
|
"reason": f"doctor binary not found: {command[0]}",
|
||||||
|
"cmd": command,
|
||||||
|
}
|
||||||
|
|
||||||
|
result = run_command(command, timeout_seconds)
|
||||||
|
result["required"] = required
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def compare_sets(left_name: str, left: set[str], right_name: str, right: set[str]) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
f"{left_name}_missing_from_{right_name}": sorted(left - right),
|
||||||
|
f"{right_name}_missing_from_{left_name}": sorted(right - left),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def build_report(args: argparse.Namespace) -> dict[str, Any]:
|
||||||
|
checks: dict[str, Any] = {}
|
||||||
|
errors: list[str] = []
|
||||||
|
|
||||||
|
git_names: set[str] = set()
|
||||||
|
db_names: set[str] = set()
|
||||||
|
api_names: set[str] = set()
|
||||||
|
|
||||||
|
try:
|
||||||
|
git_names, checks["git_refs"] = git_branch_names(args.repo_dir, args.timeout)
|
||||||
|
except Exception as exc: # noqa: BLE001 - must preserve diagnostic text
|
||||||
|
errors.append(str(exc))
|
||||||
|
checks["git_refs"] = {"ok": False, "error": str(exc), "repo_dir": args.repo_dir}
|
||||||
|
|
||||||
|
try:
|
||||||
|
db_names, checks["sqlite_branch_metadata"] = sqlite_branch_names(args.db_path, args.repo_id)
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
errors.append(str(exc))
|
||||||
|
checks["sqlite_branch_metadata"] = {"ok": False, "error": str(exc), "db_path": args.db_path}
|
||||||
|
|
||||||
|
if not args.skip_api:
|
||||||
|
try:
|
||||||
|
api_names, checks["gitea_branch_api"] = api_branch_names(
|
||||||
|
args.api_url,
|
||||||
|
args.api_limit,
|
||||||
|
args.timeout,
|
||||||
|
args.max_pages,
|
||||||
|
)
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
errors.append(str(exc))
|
||||||
|
checks["gitea_branch_api"] = {"ok": False, "error": str(exc), "api_url": args.api_url}
|
||||||
|
else:
|
||||||
|
checks["gitea_branch_api"] = {"ok": True, "skipped": True}
|
||||||
|
|
||||||
|
if not args.skip_doctor:
|
||||||
|
checks["gitea_doctor"] = doctor_check(args.doctor_cmd, args.doctor_timeout, args.doctor_required)
|
||||||
|
if not checks["gitea_doctor"].get("ok", False):
|
||||||
|
errors.append("gitea doctor consistency check failed")
|
||||||
|
else:
|
||||||
|
checks["gitea_doctor"] = {"ok": True, "skipped": True}
|
||||||
|
|
||||||
|
drift: dict[str, Any] = {}
|
||||||
|
if checks.get("git_refs", {}).get("ok") and checks.get("sqlite_branch_metadata", {}).get("ok"):
|
||||||
|
drift.update(compare_sets("git", git_names, "db", db_names))
|
||||||
|
if checks.get("git_refs", {}).get("ok") and checks.get("gitea_branch_api", {}).get("ok") and not args.skip_api:
|
||||||
|
drift.update(compare_sets("git", git_names, "api", api_names))
|
||||||
|
if checks.get("sqlite_branch_metadata", {}).get("ok") and checks.get("gitea_branch_api", {}).get("ok") and not args.skip_api:
|
||||||
|
drift.update(compare_sets("db", db_names, "api", api_names))
|
||||||
|
|
||||||
|
drift_counts = {key: len(value) for key, value in drift.items()}
|
||||||
|
drift_found = any(drift_counts.values())
|
||||||
|
|
||||||
|
counts = {
|
||||||
|
"git_refs": len(git_names) if checks.get("git_refs", {}).get("ok") else None,
|
||||||
|
"db_live_branches": len(db_names) if checks.get("sqlite_branch_metadata", {}).get("ok") else None,
|
||||||
|
"api_visible_branches": len(api_names)
|
||||||
|
if checks.get("gitea_branch_api", {}).get("ok") and not args.skip_api
|
||||||
|
else None,
|
||||||
|
}
|
||||||
|
|
||||||
|
unhealthy_checks = [
|
||||||
|
name for name, result in checks.items()
|
||||||
|
if not result.get("ok", False)
|
||||||
|
]
|
||||||
|
|
||||||
|
status = "unhealthy" if errors or drift_found or unhealthy_checks else "healthy"
|
||||||
|
|
||||||
|
return {
|
||||||
|
"service": "gitea-branch-consistency-sentinel",
|
||||||
|
"status": status,
|
||||||
|
"generated_at": utc_now(),
|
||||||
|
"repo": {
|
||||||
|
"repo_dir": args.repo_dir,
|
||||||
|
"db_path": args.db_path,
|
||||||
|
"repo_id": args.repo_id,
|
||||||
|
"api_url": args.api_url,
|
||||||
|
},
|
||||||
|
"counts": counts,
|
||||||
|
"checks": checks,
|
||||||
|
"drift": drift,
|
||||||
|
"drift_counts": drift_counts,
|
||||||
|
"errors": errors,
|
||||||
|
"unhealthy_checks": unhealthy_checks,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def write_safe_mode_if_needed(report: dict[str, Any], args: argparse.Namespace) -> None:
|
||||||
|
safe_file = Path(args.safe_mode_file)
|
||||||
|
report_file = Path(args.report_file)
|
||||||
|
|
||||||
|
if report["status"] != "healthy":
|
||||||
|
if args.emit_safe_mode:
|
||||||
|
marker = {
|
||||||
|
"service": "autonomous-safe-mode",
|
||||||
|
"status": "active",
|
||||||
|
"entered_at": utc_now(),
|
||||||
|
"reason": "gitea_branch_consistency_unhealthy",
|
||||||
|
"source": "gitea-branch-consistency-sentinel",
|
||||||
|
"report_file": str(report_file),
|
||||||
|
"counts": report.get("counts", {}),
|
||||||
|
"drift_counts": report.get("drift_counts", {}),
|
||||||
|
"unhealthy_checks": report.get("unhealthy_checks", []),
|
||||||
|
"operator_action": (
|
||||||
|
"Stop autonomous mutations, inspect Gitea refs/branch metadata/API drift, "
|
||||||
|
"repair minimal inconsistent state, then run runtime acceptance tests."
|
||||||
|
),
|
||||||
|
}
|
||||||
|
atomic_write_json(safe_file, marker)
|
||||||
|
return
|
||||||
|
|
||||||
|
if args.clear_safe_mode_if_healthy and safe_file.exists():
|
||||||
|
history_dir = safe_file.parent / "safe-mode-history"
|
||||||
|
history_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
archive = history_dir / f"{safe_file.stem}.cleared-{utc_now().replace(':', '').replace('-', '')}.json"
|
||||||
|
safe_file.replace(archive)
|
||||||
|
|
||||||
|
|
||||||
|
def parse_args(argv: list[str]) -> argparse.Namespace:
|
||||||
|
parser = argparse.ArgumentParser(description="Detect Gitea branch metadata drift and emit safe mode markers.")
|
||||||
|
parser.add_argument("--repo-dir", default=os.environ.get("GITEA_REPO_DIR", DEFAULT_REPO_DIR))
|
||||||
|
parser.add_argument("--db-path", default=os.environ.get("GITEA_DB_PATH", DEFAULT_DB_PATH))
|
||||||
|
parser.add_argument("--repo-id", type=int, default=int(os.environ.get("GITEA_REPO_ID", "1")))
|
||||||
|
parser.add_argument("--api-url", default=os.environ.get("GITEA_API_URL", DEFAULT_API_URL))
|
||||||
|
parser.add_argument("--api-limit", type=int, default=int(os.environ.get("GITEA_BRANCH_API_LIMIT", "50")))
|
||||||
|
parser.add_argument("--max-pages", type=int, default=int(os.environ.get("GITEA_BRANCH_API_MAX_PAGES", "200")))
|
||||||
|
parser.add_argument("--timeout", type=int, default=int(os.environ.get("GITEA_SENTINEL_TIMEOUT_SECONDS", "10")))
|
||||||
|
parser.add_argument("--doctor-timeout", type=int, default=int(os.environ.get("GITEA_DOCTOR_TIMEOUT_SECONDS", "45")))
|
||||||
|
parser.add_argument("--doctor-cmd", default=os.environ.get("GITEA_DOCTOR_CMD", DEFAULT_DOCTOR_CMD))
|
||||||
|
parser.add_argument("--doctor-required", action="store_true", default=env_bool("GITEA_DOCTOR_REQUIRED", False))
|
||||||
|
parser.add_argument("--skip-api", action="store_true", default=env_bool("GITEA_SENTINEL_SKIP_API", False))
|
||||||
|
parser.add_argument("--skip-doctor", action="store_true", default=env_bool("GITEA_SENTINEL_SKIP_DOCTOR", False))
|
||||||
|
parser.add_argument("--report-file", default=os.environ.get("GITEA_SENTINEL_REPORT_FILE", DEFAULT_REPORT_FILE))
|
||||||
|
parser.add_argument("--safe-mode-file", default=os.environ.get("AUTONOMOUS_SAFE_MODE_FILE", DEFAULT_SAFE_MODE_FILE))
|
||||||
|
parser.add_argument("--emit-safe-mode", action="store_true", help="Write safe-mode marker when unhealthy.")
|
||||||
|
parser.add_argument(
|
||||||
|
"--clear-safe-mode-if-healthy",
|
||||||
|
action="store_true",
|
||||||
|
help="Archive the existing safe-mode marker when all checks are healthy. Use only during controlled recovery.",
|
||||||
|
)
|
||||||
|
parser.add_argument("--json", action="store_true", help="Print full JSON report.")
|
||||||
|
return parser.parse_args(argv)
|
||||||
|
|
||||||
|
|
||||||
|
def main(argv: list[str]) -> int:
|
||||||
|
args = parse_args(argv)
|
||||||
|
report = build_report(args)
|
||||||
|
|
||||||
|
report_path = Path(args.report_file)
|
||||||
|
atomic_write_json(report_path, report)
|
||||||
|
write_safe_mode_if_needed(report, args)
|
||||||
|
|
||||||
|
if args.json or report["status"] != "healthy":
|
||||||
|
json.dump(report, sys.stdout, ensure_ascii=False, indent=2, sort_keys=True)
|
||||||
|
sys.stdout.write("\n")
|
||||||
|
else:
|
||||||
|
print(
|
||||||
|
"healthy: "
|
||||||
|
f"git={report['counts']['git_refs']} "
|
||||||
|
f"db={report['counts']['db_live_branches']} "
|
||||||
|
f"api={report['counts']['api_visible_branches']}"
|
||||||
|
)
|
||||||
|
|
||||||
|
return 0 if report["status"] == "healthy" else 2
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main(sys.argv[1:]))
|
||||||
@@ -0,0 +1,347 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Tests for autonomous_controller.py"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import time
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from agents.scripts.autonomous_controller import (
|
||||||
|
h_branch_sync,
|
||||||
|
h_stuck,
|
||||||
|
diagnose,
|
||||||
|
create_patch,
|
||||||
|
apply_approved_patches,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def tmp_workspace(tmp_path, monkeypatch):
|
||||||
|
"""Set up a temporary workspace and patch module-level paths."""
|
||||||
|
base = tmp_path / "workspace"
|
||||||
|
pending = base / "agents/patches/pending/"
|
||||||
|
approved = base / "agents/patches/approved/"
|
||||||
|
applied = base / "agents/patches/applied/"
|
||||||
|
rejected = base / "agents/patches/rejected/"
|
||||||
|
running = base / "agents/commands/running/"
|
||||||
|
failed = base / "agents/commands/failed/"
|
||||||
|
audit = base / "audit/controller-runs/"
|
||||||
|
state_file = base / "agents/state/controller-state.json"
|
||||||
|
health_file = base / "agents/state/health.json"
|
||||||
|
notif_dir = base / "agents/notifications/"
|
||||||
|
active = base / "agents/commands/active/"
|
||||||
|
completed = base / "agents/commands/completed/"
|
||||||
|
|
||||||
|
for d in [pending, approved, applied, rejected, running, failed, audit, notif_dir, active, completed]:
|
||||||
|
d.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
import agents.scripts.autonomous_controller as ctrl
|
||||||
|
monkeypatch.setattr(ctrl, "WORKSPACE", base)
|
||||||
|
monkeypatch.setattr(ctrl, "PENDING_DIR", pending)
|
||||||
|
monkeypatch.setattr(ctrl, "APPROVED_DIR", approved)
|
||||||
|
monkeypatch.setattr(ctrl, "APPLIED_DIR", applied)
|
||||||
|
monkeypatch.setattr(ctrl, "REJECTED_DIR", rejected)
|
||||||
|
monkeypatch.setattr(ctrl, "RUNNING_DIR", running)
|
||||||
|
monkeypatch.setattr(ctrl, "FAILED_DIR", failed)
|
||||||
|
monkeypatch.setattr(ctrl, "AUDIT_DIR", audit)
|
||||||
|
monkeypatch.setattr(ctrl, "STATE_FILE", state_file)
|
||||||
|
monkeypatch.setattr(ctrl, "HEALTH_FILE", health_file)
|
||||||
|
monkeypatch.setattr(ctrl, "NOTIF_DIR", notif_dir)
|
||||||
|
monkeypatch.setattr(ctrl, "ACTIVE_DIR", active)
|
||||||
|
monkeypatch.setattr(ctrl, "COMPLETED_DIR", completed)
|
||||||
|
|
||||||
|
yield base
|
||||||
|
|
||||||
|
|
||||||
|
class TestHBranchSync:
|
||||||
|
"""Tests for h_branch_sync() health check."""
|
||||||
|
|
||||||
|
def test_all_branches_synced(self, tmp_workspace, monkeypatch):
|
||||||
|
"""Bare repo branches match Gitea DB branches exactly."""
|
||||||
|
import agents.scripts.autonomous_controller as ctrl
|
||||||
|
import sqlite3
|
||||||
|
|
||||||
|
bare_path = tmp_workspace / "bare_repo.git"
|
||||||
|
db_path = tmp_workspace / "gitea.db"
|
||||||
|
bare_path.mkdir(parents=True, exist_ok=True)
|
||||||
|
db_path.touch()
|
||||||
|
|
||||||
|
monkeypatch.setattr(ctrl, "BARE_REPO_PATH", bare_path)
|
||||||
|
monkeypatch.setattr(ctrl, "GITEA_DB_PATH", db_path)
|
||||||
|
monkeypatch.setattr(ctrl, "REPO_ID", 1)
|
||||||
|
|
||||||
|
def mock_run(args, **kwargs):
|
||||||
|
class Result:
|
||||||
|
returncode = 0
|
||||||
|
stdout = "abc12345\trefs/heads/main\n67890abc\trefs/heads/feature-x\n"
|
||||||
|
stderr = ""
|
||||||
|
return Result()
|
||||||
|
|
||||||
|
monkeypatch.setattr(ctrl.subprocess, "run", mock_run)
|
||||||
|
|
||||||
|
def mock_connect(*a, **kw):
|
||||||
|
class FakeCursor:
|
||||||
|
def execute(self, sql, params):
|
||||||
|
return self
|
||||||
|
def fetchall(self):
|
||||||
|
return [("main",), ("feature-x",)]
|
||||||
|
class FakeConn:
|
||||||
|
def __init__(self):
|
||||||
|
self.closed = False
|
||||||
|
def execute(self, sql, params):
|
||||||
|
return FakeCursor()
|
||||||
|
def close(self):
|
||||||
|
self.closed = True
|
||||||
|
return FakeConn()
|
||||||
|
monkeypatch.setattr(sqlite3, "connect", mock_connect)
|
||||||
|
|
||||||
|
result = h_branch_sync()
|
||||||
|
assert result["ok"] is True
|
||||||
|
assert result["bare"] == 2
|
||||||
|
assert result["gitea"] == 2
|
||||||
|
assert result["missing"] == []
|
||||||
|
|
||||||
|
def test_branches_missing_from_gitea(self, tmp_workspace, monkeypatch):
|
||||||
|
"""Some branches exist in bare repo but not in Gitea DB."""
|
||||||
|
import agents.scripts.autonomous_controller as ctrl
|
||||||
|
import sqlite3
|
||||||
|
|
||||||
|
bare_path = tmp_workspace / "bare_repo.git"
|
||||||
|
db_path = tmp_workspace / "gitea.db"
|
||||||
|
bare_path.mkdir(parents=True, exist_ok=True)
|
||||||
|
db_path.touch()
|
||||||
|
|
||||||
|
monkeypatch.setattr(ctrl, "BARE_REPO_PATH", bare_path)
|
||||||
|
monkeypatch.setattr(ctrl, "GITEA_DB_PATH", db_path)
|
||||||
|
monkeypatch.setattr(ctrl, "REPO_ID", 1)
|
||||||
|
|
||||||
|
def mock_run(args, **kwargs):
|
||||||
|
class Result:
|
||||||
|
returncode = 0
|
||||||
|
stdout = "abc12345\trefs/heads/main\n67890abc\trefs/heads/feature-a\n11111bb\trefs/heads/feature-b\n"
|
||||||
|
stderr = ""
|
||||||
|
return Result()
|
||||||
|
|
||||||
|
monkeypatch.setattr(ctrl.subprocess, "run", mock_run)
|
||||||
|
|
||||||
|
def mock_connect(*a, **kw):
|
||||||
|
class FakeCursor:
|
||||||
|
def execute(self, sql, params):
|
||||||
|
return self
|
||||||
|
def fetchall(self):
|
||||||
|
return [("main",), ("feature-a",)]
|
||||||
|
class FakeConn:
|
||||||
|
def __init__(self):
|
||||||
|
self.closed = False
|
||||||
|
def execute(self, sql, params):
|
||||||
|
return FakeCursor()
|
||||||
|
def close(self):
|
||||||
|
self.closed = True
|
||||||
|
return FakeConn()
|
||||||
|
monkeypatch.setattr(sqlite3, "connect", mock_connect)
|
||||||
|
|
||||||
|
result = h_branch_sync()
|
||||||
|
assert result["ok"] is False
|
||||||
|
assert result["bare"] == 3
|
||||||
|
assert result["gitea"] == 2
|
||||||
|
assert "feature-b" in result["missing"]
|
||||||
|
|
||||||
|
def test_git_failure(self, tmp_workspace, monkeypatch):
|
||||||
|
"""Git command fails."""
|
||||||
|
import agents.scripts.autonomous_controller as ctrl
|
||||||
|
|
||||||
|
bare_path = tmp_workspace / "bare_repo.git"
|
||||||
|
db_path = tmp_workspace / "gitea.db"
|
||||||
|
bare_path.mkdir(parents=True, exist_ok=True)
|
||||||
|
db_path.touch()
|
||||||
|
|
||||||
|
monkeypatch.setattr(ctrl, "BARE_REPO_PATH", bare_path)
|
||||||
|
monkeypatch.setattr(ctrl, "GITEA_DB_PATH", db_path)
|
||||||
|
|
||||||
|
def mock_run(args, **kwargs):
|
||||||
|
class Result:
|
||||||
|
returncode = 1
|
||||||
|
stderr = "fatal: not a git repository"
|
||||||
|
return Result()
|
||||||
|
|
||||||
|
monkeypatch.setattr(ctrl.subprocess, "run", mock_run)
|
||||||
|
|
||||||
|
result = h_branch_sync()
|
||||||
|
assert result["ok"] is False
|
||||||
|
assert "err" in result
|
||||||
|
|
||||||
|
def test_db_failure(self, tmp_workspace, monkeypatch):
|
||||||
|
"""SQLite query fails."""
|
||||||
|
import agents.scripts.autonomous_controller as ctrl
|
||||||
|
import sqlite3
|
||||||
|
|
||||||
|
bare_path = tmp_workspace / "bare_repo.git"
|
||||||
|
db_path = tmp_workspace / "gitea.db"
|
||||||
|
bare_path.mkdir(parents=True, exist_ok=True)
|
||||||
|
db_path.touch()
|
||||||
|
|
||||||
|
monkeypatch.setattr(ctrl, "BARE_REPO_PATH", bare_path)
|
||||||
|
monkeypatch.setattr(ctrl, "GITEA_DB_PATH", db_path)
|
||||||
|
|
||||||
|
def mock_run(args, **kwargs):
|
||||||
|
class Result:
|
||||||
|
returncode = 0
|
||||||
|
stdout = "abc12345\trefs/heads/main\n"
|
||||||
|
stderr = ""
|
||||||
|
return Result()
|
||||||
|
|
||||||
|
monkeypatch.setattr(ctrl.subprocess, "run", mock_run)
|
||||||
|
|
||||||
|
def mock_connect(uri, **kwargs):
|
||||||
|
raise Exception("Fake DB error")
|
||||||
|
|
||||||
|
monkeypatch.setattr(sqlite3, "connect", mock_connect)
|
||||||
|
|
||||||
|
result = h_branch_sync()
|
||||||
|
assert result["ok"] is False
|
||||||
|
assert "err" in result
|
||||||
|
|
||||||
|
|
||||||
|
class TestHStuck:
|
||||||
|
def test_no_stuck_commands(self, tmp_workspace):
|
||||||
|
"""When there are no commands in the running dir, h_stuck returns ok=True."""
|
||||||
|
result = h_stuck()
|
||||||
|
assert result["ok"] is True
|
||||||
|
assert len(result["stuck"]) == 0
|
||||||
|
|
||||||
|
def test_stuck_commands_found(self, tmp_workspace):
|
||||||
|
"""Stuck commands older than 30 minutes should be detected."""
|
||||||
|
stuck_time = time.time() - 31 * 60
|
||||||
|
import agents.scripts.autonomous_controller as ctrl
|
||||||
|
running_dir = ctrl.RUNNING_DIR
|
||||||
|
|
||||||
|
for name in ["stuck_cmd.md", "another_cmd.md"]:
|
||||||
|
f = running_dir / name
|
||||||
|
f.write_text(json.dumps({"ts": stuck_time, "name": name}))
|
||||||
|
import os
|
||||||
|
os.utime(f, (stuck_time, stuck_time))
|
||||||
|
|
||||||
|
result = h_stuck()
|
||||||
|
assert result["ok"] is False
|
||||||
|
assert len(result["stuck"]) == 2
|
||||||
|
|
||||||
|
def test_recent_commands_not_stuck(self, tmp_workspace):
|
||||||
|
"""Commands less than 30 minutes old should not be considered stuck."""
|
||||||
|
recent_time = time.time() - 15 * 60
|
||||||
|
import agents.scripts.autonomous_controller as ctrl
|
||||||
|
running_dir = ctrl.RUNNING_DIR
|
||||||
|
f = running_dir / "recent_cmd.md"
|
||||||
|
f.write_text(json.dumps({"ts": recent_time, "name": "recent_cmd.md"}))
|
||||||
|
import os
|
||||||
|
os.utime(f, (recent_time, recent_time))
|
||||||
|
result = h_stuck()
|
||||||
|
assert result["ok"] is True
|
||||||
|
assert len(result["stuck"]) == 0
|
||||||
|
|
||||||
|
|
||||||
|
class TestDiagnose:
|
||||||
|
def test_branch_sync_failure(self, tmp_workspace):
|
||||||
|
result = diagnose({
|
||||||
|
"branch_sync": {"ok": False, "missing": ["br1", "br2"]}
|
||||||
|
})
|
||||||
|
assert len(result) == 1
|
||||||
|
assert result[0]["check"] == "branch_sync"
|
||||||
|
assert result[0]["severity"] == "high"
|
||||||
|
assert "branches missing" in result[0]["diagnosis"]
|
||||||
|
assert result[0]["fix"] == "sync_branches"
|
||||||
|
|
||||||
|
def test_stuck_failure(self, tmp_workspace):
|
||||||
|
result = diagnose({
|
||||||
|
"stuck": {"ok": False, "stuck": [{"n": "cmd1.md", "age": 35.0}]}
|
||||||
|
})
|
||||||
|
assert len(result) == 1
|
||||||
|
assert result[0]["check"] == "stuck"
|
||||||
|
assert result[0]["severity"] == "medium"
|
||||||
|
assert "commands stuck" in result[0]["diagnosis"]
|
||||||
|
assert result[0]["fix"] == "move_to_failed"
|
||||||
|
|
||||||
|
def test_both_failures(self, tmp_workspace):
|
||||||
|
result = diagnose({
|
||||||
|
"branch_sync": {"ok": False, "missing": ["br1"]},
|
||||||
|
"stuck": {"ok": False, "stuck": [{"n": "cmd1.md", "age": 35.0}]}
|
||||||
|
})
|
||||||
|
assert len(result) == 2
|
||||||
|
checks = [r["check"] for r in result]
|
||||||
|
assert "branch_sync" in checks
|
||||||
|
assert "stuck" in checks
|
||||||
|
|
||||||
|
def test_all_healthy(self, tmp_workspace):
|
||||||
|
result = diagnose({
|
||||||
|
"branch_sync": {"ok": True},
|
||||||
|
"stuck": {"ok": True}
|
||||||
|
})
|
||||||
|
assert len(result) == 0
|
||||||
|
|
||||||
|
|
||||||
|
class TestCreatePatch:
|
||||||
|
def test_creates_patch_file(self, tmp_workspace):
|
||||||
|
import agents.scripts.autonomous_controller as ctrl
|
||||||
|
pid = ctrl.PENDING_DIR
|
||||||
|
result = create_patch("my-title", "my-content")
|
||||||
|
assert result["ok"] is True
|
||||||
|
files = list(pid.iterdir())
|
||||||
|
assert len(files) == 1
|
||||||
|
content = json.loads(files[0].read_text())
|
||||||
|
assert content["title"] == "my-title"
|
||||||
|
assert content["content"] == "my-content"
|
||||||
|
|
||||||
|
def test_returns_metadata(self, tmp_workspace):
|
||||||
|
result = create_patch("title", "body")
|
||||||
|
assert "id" in result
|
||||||
|
assert "title" in result
|
||||||
|
assert "content" in result
|
||||||
|
assert result["title"] == "title"
|
||||||
|
assert result["content"] == "body"
|
||||||
|
|
||||||
|
|
||||||
|
class TestApplyApprovedPatches:
|
||||||
|
def test_apply_one_patch(self, tmp_workspace):
|
||||||
|
import agents.scripts.autonomous_controller as ctrl
|
||||||
|
approved = ctrl.APPROVED_DIR
|
||||||
|
f = approved / "patch01.json"
|
||||||
|
f.write_text(json.dumps({"check": "branch_sync", "fix": "sync_branches", "branches": []}))
|
||||||
|
|
||||||
|
result = apply_approved_patches()
|
||||||
|
assert len(result) == 1
|
||||||
|
assert result[0]["check"] == "branch_sync"
|
||||||
|
|
||||||
|
def test_move_to_applied(self, tmp_workspace):
|
||||||
|
import agents.scripts.autonomous_controller as ctrl
|
||||||
|
approved = ctrl.APPROVED_DIR
|
||||||
|
f = approved / "patch02.json"
|
||||||
|
f.write_text(json.dumps({"check": "stuck", "fix": "move_to_failed", "stuck": []}))
|
||||||
|
applied_dir = ctrl.APPLIED_DIR
|
||||||
|
|
||||||
|
result = apply_approved_patches()
|
||||||
|
assert len(result) == 1
|
||||||
|
# Verify file was moved to APPLIED_DIR
|
||||||
|
assert not list(approved.iterdir()) or all(p.name.endswith(".md") for p in approved.iterdir())
|
||||||
|
applied_files = list(applied_dir.iterdir())
|
||||||
|
assert len(applied_files) == 1
|
||||||
|
|
||||||
|
def test_apply_failure_handling(self, tmp_workspace):
|
||||||
|
import agents.scripts.autonomous_controller as ctrl
|
||||||
|
approved = ctrl.APPROVED_DIR
|
||||||
|
f = approved / "patch_fail.json"
|
||||||
|
# Write invalid JSON to cause failure
|
||||||
|
f.write_text("not valid json")
|
||||||
|
|
||||||
|
rejected = ctrl.REJECTED_DIR
|
||||||
|
result = apply_approved_patches()
|
||||||
|
assert len(result) == 0
|
||||||
|
# Should be moved to rejected dir
|
||||||
|
rejected_files = list(rejected.iterdir())
|
||||||
|
assert len(rejected_files) == 1
|
||||||
|
|
||||||
|
def test_empty_approved_dir(self, tmp_workspace):
|
||||||
|
import agents.scripts.autonomous_controller as ctrl
|
||||||
|
# Approved dir is empty from fixture
|
||||||
|
result = apply_approved_patches()
|
||||||
|
assert len(result) == 0
|
||||||
|
|
||||||
@@ -0,0 +1,178 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Tests for evolution_engine.py (Closed-loop self-evolution engine).
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sys
|
||||||
|
import importlib.util
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
_m = Path(__file__).resolve().parent.parent / 'runtime' / 'agent-os' / 'evolution_engine.py'
|
||||||
|
_spec = importlib.util.spec_from_file_location('runtime.agent_os.evolution_engine', str(_m))
|
||||||
|
_mod = importlib.util.module_from_spec(_spec)
|
||||||
|
sys.modules['runtime.agent_os.evolution_engine'] = _mod
|
||||||
|
_spec.loader.exec_module(_mod)
|
||||||
|
|
||||||
|
EvolutionEngine = _mod.EvolutionEngine
|
||||||
|
EvolutionStage = _mod.EvolutionStage
|
||||||
|
EvolutionRun = _mod.EvolutionRun
|
||||||
|
|
||||||
|
|
||||||
|
class TestEvolutionEngine:
|
||||||
|
|
||||||
|
def test_engine_has_all_stages(self):
|
||||||
|
engine = EvolutionEngine()
|
||||||
|
assert len(engine.ORDER) == 8
|
||||||
|
assert engine.ORDER == [
|
||||||
|
EvolutionStage.DETECT,
|
||||||
|
EvolutionStage.PLAN,
|
||||||
|
EvolutionStage.SCHEDULE,
|
||||||
|
EvolutionStage.EXECUTE,
|
||||||
|
EvolutionStage.VALIDATE,
|
||||||
|
EvolutionStage.VOTE,
|
||||||
|
EvolutionStage.PATCH,
|
||||||
|
EvolutionStage.LEARN,
|
||||||
|
]
|
||||||
|
|
||||||
|
def test_run_cycle_basic(self):
|
||||||
|
engine = EvolutionEngine()
|
||||||
|
result = engine.run_cycle()
|
||||||
|
assert "run_id" in result
|
||||||
|
assert result["status"] == "completed"
|
||||||
|
assert len(result["timeline"]) == 8
|
||||||
|
for entry in result["timeline"]:
|
||||||
|
assert "stage" in entry
|
||||||
|
assert "result" in entry
|
||||||
|
assert "ts" in entry
|
||||||
|
|
||||||
|
def test_run_cycle_with_context(self):
|
||||||
|
engine = EvolutionEngine()
|
||||||
|
ctx = {"signals": ["err_001"], "plan": ["fix_branch_sync"]}
|
||||||
|
result = engine.run_cycle(ctx)
|
||||||
|
detect_timeline = result["timeline"][0]
|
||||||
|
assert detect_timeline["stage"] == "detect"
|
||||||
|
assert detect_timeline["result"]["signals"] == ["err_001"]
|
||||||
|
|
||||||
|
def test_custom_handler_detect(self):
|
||||||
|
engine = EvolutionEngine()
|
||||||
|
def custom_detect(run):
|
||||||
|
return {"detected": True, "custom": True}
|
||||||
|
engine.register_handler(EvolutionStage.DETECT, custom_detect)
|
||||||
|
result = engine.run_cycle()
|
||||||
|
detect_result = result["timeline"][0]["result"]
|
||||||
|
assert detect_result["custom"] is True
|
||||||
|
|
||||||
|
def test_custom_handler_plan(self):
|
||||||
|
engine = EvolutionEngine()
|
||||||
|
def custom_plan(run):
|
||||||
|
return {"plan": ["step1", "step2"]}
|
||||||
|
engine.register_handler(EvolutionStage.PLAN, custom_plan)
|
||||||
|
result = engine.run_cycle()
|
||||||
|
plan_result = result["timeline"][1]["result"]
|
||||||
|
assert plan_result["plan"] == ["step1", "step2"]
|
||||||
|
|
||||||
|
def test_custom_handler_schedule(self):
|
||||||
|
engine = EvolutionEngine()
|
||||||
|
def custom_schedule(run):
|
||||||
|
return {"scheduled": False, "reason": "no capacity"}
|
||||||
|
engine.register_handler(EvolutionStage.SCHEDULE, custom_schedule)
|
||||||
|
result = engine.run_cycle()
|
||||||
|
sched_result = result["timeline"][2]["result"]
|
||||||
|
assert sched_result["scheduled"] is False
|
||||||
|
|
||||||
|
def test_custom_handler_execute(self):
|
||||||
|
engine = EvolutionEngine()
|
||||||
|
def custom_execute(run):
|
||||||
|
return {"executed": False, "error": "timeout"}
|
||||||
|
engine.register_handler(EvolutionStage.EXECUTE, custom_execute)
|
||||||
|
result = engine.run_cycle()
|
||||||
|
exec_result = result["timeline"][3]["result"]
|
||||||
|
assert exec_result["executed"] is False
|
||||||
|
|
||||||
|
def test_custom_handler_validate(self):
|
||||||
|
engine = EvolutionEngine()
|
||||||
|
def custom_validate(run):
|
||||||
|
return {"valid": False, "issues": ["test_failed"]}
|
||||||
|
engine.register_handler(EvolutionStage.VALIDATE, custom_validate)
|
||||||
|
result = engine.run_cycle()
|
||||||
|
val_result = result["timeline"][4]["result"]
|
||||||
|
assert val_result["valid"] is False
|
||||||
|
|
||||||
|
def test_custom_handler_vote(self):
|
||||||
|
engine = EvolutionEngine()
|
||||||
|
def custom_vote(run):
|
||||||
|
return {"decision": "rejected", "reason": "not ready"}
|
||||||
|
engine.register_handler(EvolutionStage.VOTE, custom_vote)
|
||||||
|
result = engine.run_cycle()
|
||||||
|
vote_result = result["timeline"][5]["result"]
|
||||||
|
assert vote_result["decision"] == "rejected"
|
||||||
|
|
||||||
|
def test_custom_handler_patch(self):
|
||||||
|
engine = EvolutionEngine()
|
||||||
|
def custom_patch(run):
|
||||||
|
return {"patch_ready": False, "build_error": "merge conflict"}
|
||||||
|
engine.register_handler(EvolutionStage.PATCH, custom_patch)
|
||||||
|
result = engine.run_cycle()
|
||||||
|
patch_result = result["timeline"][6]["result"]
|
||||||
|
assert patch_result["patch_ready"] is False
|
||||||
|
|
||||||
|
def test_custom_handler_learn(self):
|
||||||
|
engine = EvolutionEngine()
|
||||||
|
def custom_learn(run):
|
||||||
|
return {"learned": False, "summary": "nothing to learn"}
|
||||||
|
engine.register_handler(EvolutionStage.LEARN, custom_learn)
|
||||||
|
result = engine.run_cycle()
|
||||||
|
learn_result = result["timeline"][7]["result"]
|
||||||
|
assert learn_result["learned"] is False
|
||||||
|
|
||||||
|
def test_register_handler_by_string(self):
|
||||||
|
engine = EvolutionEngine()
|
||||||
|
def handler(run):
|
||||||
|
return {"from_string": True}
|
||||||
|
engine.register_handler("detect", handler)
|
||||||
|
result = engine.run_cycle()
|
||||||
|
assert result["timeline"][0]["result"]["from_string"] is True
|
||||||
|
|
||||||
|
def test_run_evolves_stages_in_order(self):
|
||||||
|
engine = EvolutionEngine()
|
||||||
|
def track_stage(run, stage_name):
|
||||||
|
return {"stage": stage_name}
|
||||||
|
engine.register_handler(EvolutionStage.DETECT, lambda r: track_stage(r, "detect"))
|
||||||
|
engine.register_handler(EvolutionStage.PLAN, lambda r: track_stage(r, "plan"))
|
||||||
|
engine.register_handler(EvolutionStage.SCHEDULE, lambda r: track_stage(r, "schedule"))
|
||||||
|
engine.register_handler(EvolutionStage.EXECUTE, lambda r: track_stage(r, "execute"))
|
||||||
|
engine.register_handler(EvolutionStage.VALIDATE, lambda r: track_stage(r, "validate"))
|
||||||
|
engine.register_handler(EvolutionStage.VOTE, lambda r: track_stage(r, "vote"))
|
||||||
|
engine.register_handler(EvolutionStage.PATCH, lambda r: track_stage(r, "patch"))
|
||||||
|
engine.register_handler(EvolutionStage.LEARN, lambda r: track_stage(r, "learn"))
|
||||||
|
result = engine.run_cycle()
|
||||||
|
stages = [entry["result"]["stage"] for entry in result["timeline"]]
|
||||||
|
assert stages == ["detect", "plan", "schedule", "execute", "validate", "vote", "patch", "learn"]
|
||||||
|
|
||||||
|
def test_evolution_run_dataclass(self):
|
||||||
|
run = EvolutionRun()
|
||||||
|
assert run.status == "created"
|
||||||
|
assert run.run_id is not None
|
||||||
|
assert run.context == {}
|
||||||
|
assert run.timeline == []
|
||||||
|
run.record(EvolutionStage.DETECT, {"found": True})
|
||||||
|
assert run.stage == EvolutionStage.DETECT
|
||||||
|
assert len(run.timeline) == 1
|
||||||
|
|
||||||
|
def test_run_cycle_creates_new_run_id(self):
|
||||||
|
engine = EvolutionEngine()
|
||||||
|
result1 = engine.run_cycle()
|
||||||
|
result2 = engine.run_cycle()
|
||||||
|
assert result1["run_id"] != result2["run_id"]
|
||||||
|
|
||||||
|
def test_no_handler_uses_default_values(self):
|
||||||
|
engine = EvolutionEngine()
|
||||||
|
result = engine.run_cycle()
|
||||||
|
assert result["timeline"][0]["result"] == {"detected": True, "signals": []}
|
||||||
|
assert result["timeline"][1]["result"] == {"plan": []}
|
||||||
|
assert result["timeline"][2]["result"] == {"scheduled": True}
|
||||||
|
assert result["timeline"][3]["result"] == {"executed": True}
|
||||||
|
assert result["timeline"][4]["result"] == {"valid": True}
|
||||||
|
assert result["timeline"][5]["result"] == {"decision": "accepted"}
|
||||||
|
assert result["timeline"][6]["result"] == {"patch_ready": True}
|
||||||
|
assert result["timeline"][7]["result"] == {"learned": True}
|
||||||
@@ -0,0 +1,270 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import importlib.util
|
||||||
|
import json
|
||||||
|
import subprocess
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
|
||||||
|
def load_js_process_helpers() -> str:
|
||||||
|
patcher_path = Path(__file__).resolve().parents[1] / 'fixes' / 'patch_openclaw_weixin_hermes.py'
|
||||||
|
assert patcher_path.exists(), f'missing patcher: {patcher_path}'
|
||||||
|
spec = importlib.util.spec_from_file_location('patch_openclaw_weixin_hermes', patcher_path)
|
||||||
|
assert spec is not None and spec.loader is not None
|
||||||
|
module = importlib.util.module_from_spec(spec)
|
||||||
|
spec.loader.exec_module(module)
|
||||||
|
return module.JS_PROCESS_HELPERS
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def load_patcher_module() -> object:
|
||||||
|
patcher_path = Path(__file__).resolve().parents[1] / 'fixes' / 'patch_openclaw_weixin_hermes.py'
|
||||||
|
assert patcher_path.exists(), f'missing patcher: {patcher_path}'
|
||||||
|
spec = importlib.util.spec_from_file_location('patch_openclaw_weixin_hermes', patcher_path)
|
||||||
|
assert spec is not None and spec.loader is not None
|
||||||
|
module = importlib.util.module_from_spec(spec)
|
||||||
|
spec.loader.exec_module(module)
|
||||||
|
return module
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def run_js_process_helpers(tmp_path: Path, body: str) -> dict:
|
||||||
|
js_process_helpers = load_js_process_helpers()
|
||||||
|
node_prelude = '\n'.join(
|
||||||
|
[
|
||||||
|
'const hermesTestFs = require("node:fs");',
|
||||||
|
'globalThis.existsSync = hermesTestFs.existsSync;',
|
||||||
|
'globalThis.readFileSync = hermesTestFs.readFileSync;',
|
||||||
|
'globalThis.writeFileSync = hermesTestFs.writeFileSync;',
|
||||||
|
'globalThis.mkdirSync = hermesTestFs.mkdirSync;',
|
||||||
|
'globalThis.appendFileSync = hermesTestFs.appendFileSync;',
|
||||||
|
'globalThis.path = require("node:path");',
|
||||||
|
'globalThis.logger = { info() {}, warn() {}, error() {} };',
|
||||||
|
]
|
||||||
|
)
|
||||||
|
node_script = tmp_path / 'hermes_image_followup_check.js'
|
||||||
|
node_script.write_text(node_prelude + '\n\n' + js_process_helpers + '\n\n' + body, encoding='utf-8')
|
||||||
|
completed = subprocess.run(['node', str(node_script)], check=True, capture_output=True, text=True)
|
||||||
|
return json.loads(completed.stdout.strip())
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize('is_ts', [True, False], ids=['ts', 'js'])
|
||||||
|
def test_replace_or_insert_process_helpers_is_byte_idempotent(is_ts: bool) -> None:
|
||||||
|
patcher = load_patcher_module()
|
||||||
|
marker = 'export async function processOneMessage'
|
||||||
|
source = marker + '(message) {\n return message;\n}\n'
|
||||||
|
|
||||||
|
once = patcher.replace_or_insert_process_helpers(source, is_ts=is_ts)
|
||||||
|
twice = patcher.replace_or_insert_process_helpers(once, is_ts=is_ts)
|
||||||
|
helpers = patcher.TS_PROCESS_HELPERS if is_ts else patcher.JS_PROCESS_HELPERS
|
||||||
|
|
||||||
|
assert helpers + '\n\n' + marker in once
|
||||||
|
assert twice == once
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
('is_ts', 'hook_name'),
|
||||||
|
[(True, 'TS_PROCESS_HOOK'), (False, 'JS_PROCESS_HOOK')],
|
||||||
|
ids=['ts', 'js'],
|
||||||
|
)
|
||||||
|
def test_remove_and_reinsert_process_hook_is_byte_idempotent(is_ts: bool, hook_name: str) -> None:
|
||||||
|
patcher = load_patcher_module()
|
||||||
|
hook = getattr(patcher, hook_name)
|
||||||
|
indent = ' ' if is_ts else ' '
|
||||||
|
needle = indent + 'ctx.CommandAuthorized = commandAuthorized;\n'
|
||||||
|
next_nonblank = indent + 'continueProcessing();\n'
|
||||||
|
|
||||||
|
def cycle(text: str) -> str:
|
||||||
|
without_hook = patcher.remove_existing_process_hermes_hook(text)
|
||||||
|
return without_hook.replace(needle, needle + hook + '\n', 1)
|
||||||
|
|
||||||
|
once = cycle(needle + next_nonblank)
|
||||||
|
twice = cycle(once)
|
||||||
|
|
||||||
|
assert hook + '\n' + next_nonblank in once
|
||||||
|
assert twice == once
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def test_looks_like_hermes_image_followup_distinguishes_diagnostics_from_real_image_questions(tmp_path: Path) -> None:
|
||||||
|
cases = {
|
||||||
|
'diag_cache_expired': ('\u56fe\u7247\u7f13\u5b58\u5df2\u5931\u6548\uff0c\u8bf7\u91cd\u65b0\u53d1\u9001\u56fe\u7247\u3002', False),
|
||||||
|
'diag_cache_error_en': ('image cache error', False),
|
||||||
|
'diag_cache_expired_en': ('the image cache expired, please resend', False),
|
||||||
|
'diag_copying_text_shows_cache_error': ('\u4e3a\u4ec0\u4e48\u6211\u590d\u5236\u53d1\u7ed9\u4f60\u7684\u6587\u5b57\uff0c\u4f60\u90fd\u663e\u793a\u56fe\u7247\u7f13\u5b58\u5df2\u5931\u6548\uff0c\u8bf7\u91cd\u65b0\u53d1\u9001\u56fe\u7247\u3002', False),
|
||||||
|
'diag_not_image': ('\u8fd9\u4e0d\u662f\u56fe\u7247\uff0c\u662f\u6587\u5b57\u6d88\u606f', False),
|
||||||
|
'diag_text_not_image_en': ('this is a text message, not an image', False),
|
||||||
|
'diag_no_image_sent': ('\u6211\u6ca1\u6709\u53d1\u56fe\u7247', False),
|
||||||
|
'diag_system_logs': ('\u8bf7\u68c0\u67e5\u7cfb\u7edf\u65e5\u5fd7\uff0c\u4ece\u673a\u5236\u4e0a\u4fee\u590d\u9519\u8bef', False),
|
||||||
|
'image_cn_1': ('\u8bf7\u770b\u4e00\u4e0b\u8fd9\u5f20\u56fe\u7247', True),
|
||||||
|
'image_cn_2': ('\u8fd9\u56fe\u662f\u4ec0\u4e48', True),
|
||||||
|
'image_cn_3': ('\u8bc6\u522b\u56fe\u7247\u5185\u5bb9', True),
|
||||||
|
'image_en': ('look at this image', True),
|
||||||
|
}
|
||||||
|
results = run_js_process_helpers(
|
||||||
|
tmp_path,
|
||||||
|
'const cases = '
|
||||||
|
+ json.dumps({key: text for key, (text, _) in cases.items()}, ensure_ascii=False)
|
||||||
|
+ ';\nconst results = {};\n'
|
||||||
|
+ 'for (const [key, text] of Object.entries(cases)) {\n'
|
||||||
|
+ ' results[key] = looksLikeHermesImageFollowup(text);\n'
|
||||||
|
+ '}\n'
|
||||||
|
+ 'console.log(JSON.stringify(results));\n',
|
||||||
|
)
|
||||||
|
for key, (_, expected) in cases.items():
|
||||||
|
assert results[key] is expected, f'{key}: expected {expected}, got {results[key]!r}'
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def test_resolve_hermes_referenced_image_followup_requires_exact_cache(tmp_path: Path) -> None:
|
||||||
|
cache_path = tmp_path / 'hermes-last-image-cache.json'
|
||||||
|
results = run_js_process_helpers(
|
||||||
|
tmp_path,
|
||||||
|
'\n'.join(
|
||||||
|
[
|
||||||
|
'const fs = require("fs");',
|
||||||
|
f'const cachePath = {json.dumps(str(cache_path))};',
|
||||||
|
'const accountId = "account-a";',
|
||||||
|
'const senderId = "sender-b";',
|
||||||
|
'const exactQuotedId = "quoted-123";',
|
||||||
|
'const cache = {};',
|
||||||
|
'cache[hermesLastImageCacheKey(accountId, senderId)] = {',
|
||||||
|
' mediaUrl: "data:image/png;base64,iVBORw0KGgo=",',
|
||||||
|
' timestampMs: Date.now(),',
|
||||||
|
' messageId: "latest-999",',
|
||||||
|
' mediaType: "image/png",',
|
||||||
|
'};',
|
||||||
|
'cache[hermesExactImageCacheKey(accountId, senderId, exactQuotedId)] = {',
|
||||||
|
' mediaUrl: "data:image/png;base64,iVBORw0KGgo=",',
|
||||||
|
' timestampMs: Date.now(),',
|
||||||
|
' messageId: exactQuotedId,',
|
||||||
|
' mediaType: "image/png",',
|
||||||
|
'};',
|
||||||
|
'fs.writeFileSync(cachePath, JSON.stringify(cache, null, 2), "utf-8");',
|
||||||
|
'process.env.AIOS_HERMES_WEIXIN_LAST_IMAGE_CACHE = cachePath;',
|
||||||
|
'const generic = resolveHermesReferencedImageFollowup(accountId, senderId, "plain quoted text", "quoted-999");',
|
||||||
|
'const exact = resolveHermesReferencedImageFollowup(accountId, senderId, "plain quoted text", exactQuotedId);',
|
||||||
|
'const forced = resolveHermesFollowupImageContext(accountId, senderId, "plain quoted text", exactQuotedId, false, true);',
|
||||||
|
'console.log(JSON.stringify({ generic, exact, forced }));',
|
||||||
|
]
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert results['generic']['imageIntent'] is False
|
||||||
|
assert results['generic']['matched'] is False
|
||||||
|
assert results['generic']['reason'] == 'referenced_image_not_confirmed'
|
||||||
|
assert results['exact']['imageIntent'] is True
|
||||||
|
assert results['exact']['matched'] is True
|
||||||
|
assert results['exact']['reason'] == 'cached_image_matched'
|
||||||
|
assert results['forced']['imageIntent'] is True
|
||||||
|
assert results['forced']['matched'] is True
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def test_referenced_text_without_media_evidence_stays_out_of_image_cache(tmp_path: Path) -> None:
|
||||||
|
cache_path = tmp_path / 'hermes-last-image-cache.json'
|
||||||
|
results = run_js_process_helpers(
|
||||||
|
tmp_path,
|
||||||
|
'\n'.join(
|
||||||
|
[
|
||||||
|
'const fs = require("fs");',
|
||||||
|
f'const cachePath = {json.dumps(str(cache_path))};',
|
||||||
|
'const accountId = "account-a";',
|
||||||
|
'const senderId = "sender-b";',
|
||||||
|
'const cache = {};',
|
||||||
|
'cache[hermesLastImageCacheKey(accountId, senderId)] = {',
|
||||||
|
' mediaUrl: "data:image/png;base64,iVBORw0KGgo=",',
|
||||||
|
' timestampMs: Date.now(),',
|
||||||
|
' messageId: "latest-999",',
|
||||||
|
' mediaType: "image/png",',
|
||||||
|
'};',
|
||||||
|
'fs.writeFileSync(cachePath, JSON.stringify(cache, null, 2), "utf-8");',
|
||||||
|
'process.env.AIOS_HERMES_WEIXIN_LAST_IMAGE_CACHE = cachePath;',
|
||||||
|
'const full = { item_list: [{ ref_msg: { message_item: { type: 0, id: "quoted-999" } } }] };',
|
||||||
|
'const hermesContent = "\u8fd9\u662f\u4ec0\u4e48";',
|
||||||
|
'const hermesReferenceContextPresent = hasHermesReferenceContext(full.item_list);',
|
||||||
|
'const hermesQuotedMessageId = findHermesReferencedMessageId(full.item_list);',
|
||||||
|
'const refMediaItem = undefined;',
|
||||||
|
'const hermesReferencedMediaIsImage = Boolean(refMediaItem && hermesCurrentMessageIsImage({}, refMediaItem));',
|
||||||
|
'const hermesReferencedImageFollowupIntent = hermesReferenceContextPresent && !refMediaItem;',
|
||||||
|
'const hermesCurrentImage = false;',
|
||||||
|
'const hermesFollowup = hermesCurrentImage',
|
||||||
|
' ? { imageIntent: false, matched: false, reason: "current_image_message", cacheStrategy: "current", quotedMessageId: hermesQuotedMessageId }',
|
||||||
|
' : (hermesReferencedMediaIsImage',
|
||||||
|
' ? resolveHermesFollowupImageContext(accountId, senderId, hermesContent, hermesQuotedMessageId, false, true)',
|
||||||
|
' : (hermesReferencedImageFollowupIntent',
|
||||||
|
' ? resolveHermesReferencedImageFollowup(accountId, senderId, hermesContent, hermesQuotedMessageId)',
|
||||||
|
' : resolveHermesFollowupImageContext(accountId, senderId, hermesContent)));',
|
||||||
|
'console.log(JSON.stringify({ hermesReferenceContextPresent, hermesQuotedMessageId, hermesReferencedMediaIsImage, hermesReferencedImageFollowupIntent, hermesFollowup }));',
|
||||||
|
]
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert results['hermesReferenceContextPresent'] is True
|
||||||
|
assert results['hermesQuotedMessageId'] == 'quoted-999'
|
||||||
|
assert results['hermesReferencedMediaIsImage'] is False
|
||||||
|
assert results['hermesReferencedImageFollowupIntent'] is True
|
||||||
|
assert results['hermesFollowup']['imageIntent'] is False
|
||||||
|
assert results['hermesFollowup']['matched'] is False
|
||||||
|
assert results['hermesFollowup']['reason'] == 'referenced_image_not_confirmed'
|
||||||
|
|
||||||
|
|
||||||
|
def test_patch_plugin_process_only_skips_slash_and_channel(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
|
||||||
|
patcher = load_patcher_module()
|
||||||
|
calls: list[tuple[str, Path]] = []
|
||||||
|
|
||||||
|
def record(name: str, result):
|
||||||
|
def _inner(root: Path):
|
||||||
|
calls.append((name, root))
|
||||||
|
return result
|
||||||
|
|
||||||
|
return _inner
|
||||||
|
|
||||||
|
monkeypatch.setattr(patcher, 'patch_ts', record('patch_ts', False))
|
||||||
|
monkeypatch.setattr(patcher, 'patch_js', record('patch_js', False))
|
||||||
|
monkeypatch.setattr(patcher, 'patch_process_messages', record('patch_process_messages', {'src/messaging/process-message.ts': True, 'dist/src/messaging/process-message.js': False}))
|
||||||
|
monkeypatch.setattr(patcher, 'patch_channel_files', record('patch_channel_files', {'src/channel.ts': False, 'dist/src/channel.js': False}))
|
||||||
|
monkeypatch.setattr(patcher, 'verify_plugin_hook', record('verify_plugin_hook', {
|
||||||
|
'hook_status': {},
|
||||||
|
'hook_installed': False,
|
||||||
|
'process_status': {'src/messaging/process-message.ts': True, 'dist/src/messaging/process-message.js': True},
|
||||||
|
'process_hook_installed': True,
|
||||||
|
'channel_status': {},
|
||||||
|
'channel_hook_installed': False,
|
||||||
|
'dispatch_reference_found': False,
|
||||||
|
'dispatch_reference_files': [],
|
||||||
|
}))
|
||||||
|
|
||||||
|
result = patcher.patch_plugin(tmp_path, process_only=True)
|
||||||
|
|
||||||
|
assert calls == [
|
||||||
|
('patch_process_messages', tmp_path),
|
||||||
|
('verify_plugin_hook', tmp_path),
|
||||||
|
]
|
||||||
|
assert result['mode'] == 'process-only'
|
||||||
|
assert result['plugin_root'] == str(tmp_path)
|
||||||
|
assert result['slash_changes'] == {}
|
||||||
|
assert result['channel_changes'] == {}
|
||||||
|
assert result['process_changes'] == {'src/messaging/process-message.ts': True, 'dist/src/messaging/process-message.js': False}
|
||||||
|
assert result['behavior_changes'] == {'src/messaging/process-message.ts': True, 'dist/src/messaging/process-message.js': False}
|
||||||
|
assert result['verification']['process_hook_installed'] is True
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_args_supports_default_root_and_process_only_flag() -> None:
|
||||||
|
patcher = load_patcher_module()
|
||||||
|
|
||||||
|
defaults = patcher.parse_args([])
|
||||||
|
assert defaults.plugin_root == patcher.DEFAULT_PLUGIN_ROOT
|
||||||
|
assert defaults.process_only is False
|
||||||
|
|
||||||
|
process_only = patcher.parse_args(['--process-only'])
|
||||||
|
assert process_only.plugin_root == patcher.DEFAULT_PLUGIN_ROOT
|
||||||
|
assert process_only.process_only is True
|
||||||
|
|
||||||
|
custom_root = Path('/tmp/custom-plugin-root')
|
||||||
|
custom = patcher.parse_args([str(custom_root), '--process-only'])
|
||||||
|
assert custom.plugin_root == custom_root
|
||||||
|
assert custom.process_only is True
|
||||||
Reference in New Issue
Block a user