"""线程生命周期补充单测:cloud/lifecycle 纯逻辑 + server 端点。 覆盖:bindings 聚合、外部引用增删查、心跳绑定与唤醒、 worktree 快照归档/恢复、移交完成更新 worktree_host、 PR checks 补 head_sha。不跑真实模型、不联网、不调真实 git(快照测试用空 .git 目录,git 命令失败时仍能建快照, 只 branch/head 为空——这是诚实降级分支本身)。""" import http.client import json import os import shutil import sqlite3 import sys import tempfile import threading import unittest from pathlib import Path try: from cloud.auth import AuthStore from cloud.server import create_server from cloud.lifecycle import (add_ref, bind_automation, create_snapshot, get_binding, latest_snapshot, list_refs, list_snapshots) except ImportError: sys.path.insert(0, str(Path(__file__).resolve().parents[2])) from cloud.auth import AuthStore from cloud.server import create_server from cloud.lifecycle import (add_ref, bind_automation, create_snapshot, get_binding, latest_snapshot, list_refs, list_snapshots) class LifecyclePureTestCase(unittest.TestCase): def setUp(self): self.conn = sqlite3.connect(":memory:") self.conn.row_factory = sqlite3.Row def tearDown(self): self.conn.close() def test_snapshot_truncates_and_lists(self): snap = create_snapshot( self.conn, "t1", "p1", "/tmp/wt", "main", "abc", "M a.py", "diff " * 10000, ["untracked.txt"]) self.assertLessEqual(len(snap["diff_text"]), 20000) self.assertEqual(snap["untracked"], ["untracked.txt"]) self.assertIsNone(snap["restored_at"]) self.assertEqual(latest_snapshot(self.conn, "t1")["id"], snap["id"]) self.assertEqual(len(list_snapshots(self.conn, "t1")), 1) def test_ref_validation(self): ref = add_ref(self.conn, "t1", "p1", "doc", "https://example.invalid/spec.md", "规格", "u1") self.assertEqual(ref["kind"], "doc") with self.assertRaises(ValueError): add_ref(self.conn, "t1", "p1", "bogus", "x", None, "u1") with self.assertRaises(ValueError): add_ref(self.conn, "t1", "p1", "doc", " ", None, "u1") self.assertEqual(len(list_refs(self.conn, "t1")), 1) def test_binding_mode_and_overwrite(self): b = bind_automation(self.conn, "a1", "p1", "t1", "heartbeat") self.assertEqual(b["mode"], "heartbeat") b = bind_automation(self.conn, "a1", "p1", "t2", "cron") self.assertEqual((b["task_id"], b["mode"]), ("t2", "cron")) self.assertEqual(get_binding(self.conn, "a1")["task_id"], "t2") with self.assertRaises(ValueError): bind_automation(self.conn, "a2", "p1", "t1", "weekly") class LifecycleServerTestCase(unittest.TestCase): def setUp(self): self.tmp = tempfile.TemporaryDirectory() self.db_path = os.path.join(self.tmp.name, "control.db") self.auth = AuthStore(self.db_path) self.owner_id, self.owner_key = self.auth.create_user("owner") self.reader_id, self.reader_key = self.auth.create_user("reader") self.outsider_id, self.outsider_key = self.auth.create_user( "outsider") self.server = create_server(self.db_path, "127.0.0.1", 0) self.server.tenants_root = os.path.join(self.tmp.name, "tenants") self.server.host_worktrees_dir = os.path.join( self.tmp.name, "worktrees") self.port = self.server.server_address[1] self.thread = threading.Thread( target=self.server.serve_forever, daemon=True) self.thread.start() status, self.project = self._req( "POST", "/projects", {"name": "demo"}, self.owner_key) self.assertEqual(status, 201, self.project) self.auth.set_member(self.project["id"], self.owner_id, self.reader_id, "viewer") def tearDown(self): self.server.shutdown() self.server.server_close() self.thread.join(timeout=5) try: self.auth.conn.close() except Exception: pass self.tmp.cleanup() def _req(self, method, path, body=None, key=None): conn = http.client.HTTPConnection( "127.0.0.1", self.port, timeout=10) hdrs = {} payload = None if body is not None: payload = json.dumps(body).encode("utf-8") hdrs["Content-Type"] = "application/json" if key is not None: hdrs["X-API-Key"] = key try: conn.request(method, path, body=payload, headers=hdrs) resp = conn.getresponse() data = resp.read() finally: conn.close() try: parsed = json.loads(data.decode("utf-8")) if data else None except ValueError: parsed = None return resp.status, parsed def _create_task(self, **extra): body = {"project_id": self.project["id"], "prompt": "做事"} body.update(extra) status, task = self._req("POST", "/tasks", body, self.owner_key) self.assertEqual(status, 201, task) return task def _set_task(self, task_id, **fields): conn = sqlite3.connect(self.db_path) try: for key, value in fields.items(): conn.execute( "UPDATE tasks SET %s = ? WHERE id = ?" % key, (value, task_id)) conn.commit() finally: conn.close() def _worktree_dir(self, task_id): # _thread_worktree_for_task 的算法:host_worktrees_dir + # 安全化后的任务 id(本测试任务 id 只含 hex,无需转义)。 return os.path.join(self.server.host_worktrees_dir, task_id) def test_bindings_aggregates(self): task = self._create_task() self._set_task(task["id"], pr_number=7, pr_url="https://example.invalid/pr/7") status, body = self._req( "GET", "/tasks/%s/bindings" % task["id"], None, self.reader_key) self.assertEqual(status, 200, body) self.assertEqual(body["project"]["name"], "demo") self.assertEqual(body["pull_request"]["number"], 7) self.assertEqual(body["worktree"]["status"], "creating") self.assertTrue(body["host"]["local"]) self.assertEqual(body["lineage"]["parent_task_id"], None) self.assertEqual(body["attachments"]["files"], []) status, _ = self._req( "GET", "/tasks/%s/bindings" % task["id"], None, self.outsider_key) self.assertEqual(status, 403) def test_refs_crud(self): task = self._create_task() status, ref = self._req( "POST", "/tasks/%s/refs" % task["id"], {"kind": "doc", "ref": "https://example.invalid/spec.md", "note": "规格"}, self.owner_key) self.assertEqual(status, 201, ref) status, body = self._req( "POST", "/tasks/%s/refs" % task["id"], {"kind": "bogus", "ref": "x"}, self.owner_key) self.assertEqual(status, 400, body) status, body = self._req( "GET", "/tasks/%s/refs" % task["id"], None, self.reader_key) self.assertEqual(len(body["refs"]), 1) status, body = self._req( "DELETE", "/tasks/%s/refs/%s" % (task["id"], ref["id"]), None, self.owner_key) self.assertEqual(status, 200, body) status, body = self._req( "DELETE", "/tasks/%s/refs/%s" % (task["id"], ref["id"]), None, self.owner_key) self.assertEqual(status, 404, body) def test_worktree_snapshot_and_restore(self): task = self._create_task() wt = self._worktree_dir(task["id"]) os.makedirs(os.path.join(wt, ".git"), exist_ok=True) status, body = self._req( "POST", "/tasks/%s/worktree/archive" % task["id"], {}, self.owner_key) self.assertEqual(status, 201, body) self.assertEqual(body["untracked_count"], 0) status, body = self._req( "GET", "/tasks/%s/worktree/snapshots" % task["id"], None, self.reader_key) self.assertEqual(len(body["snapshots"]), 1) status, body = self._req( "POST", "/tasks/%s/worktree/restore" % task["id"], {}, self.owner_key) self.assertEqual(status, 200, body) self.assertIsNotNone(body["snapshot"]["restored_at"]) # 目录删了之后再恢复:409 并附快照,不编假恢复。 shutil.rmtree(wt) status, body = self._req( "POST", "/tasks/%s/worktree/archive" % task["id"], {}, self.owner_key) self.assertEqual(status, 409, body) # 先再拍一张是不可能的(目录已删),直接用旧快照恢复: status, body = self._req( "POST", "/tasks/%s/worktree/restore" % task["id"], {}, self.owner_key) self.assertEqual(status, 409, body) self.assertIn("snapshot", body) def test_handoff_completed_moves_host(self): task = self._create_task() status, h = self._req( "POST", "/tasks/%s/handoff" % task["id"], {"target_host": "host-b"}, self.owner_key) self.assertEqual(status, 201, h) for st in ("running", "completed"): status, body = self._req( "POST", "/tasks/%s/handoff/%s/report" % (task["id"], h["id"]), {"status": st}, self.owner_key) self.assertEqual(status, 200, body) status, task_after = self._req( "GET", "/tasks/%s" % task["id"], None, self.owner_key) self.assertEqual(task_after["worktree_host"], "host-b") def test_automation_heartbeat_binding(self): status, auto = self._req( "POST", "/projects/%s/automations" % self.project["id"], {"name": "watch", "prompt": "检查部署健康", "interval_seconds": 3600}, self.owner_key) self.assertEqual(status, 201, auto) aid = auto["id"] task = self._create_task() self._set_task(task["id"], status="running") # 未绑定时心跳 409。 status, body = self._req( "POST", "/projects/%s/automations/%s/heartbeat" % (self.project["id"], aid), {"message": "查一下"}, self.owner_key) self.assertEqual(status, 409, body) status, body = self._req( "POST", "/projects/%s/automations/%s/bind" % (self.project["id"], aid), {"task_id": task["id"], "mode": "heartbeat"}, self.owner_key) self.assertEqual(status, 200, body) self.assertEqual(body["binding"]["task_id"], task["id"]) status, body = self._req( "GET", "/projects/%s/automations/%s" % (self.project["id"], aid), None, self.reader_key) self.assertEqual(body["heartbeat_binding"]["mode"], "heartbeat") status, body = self._req( "POST", "/projects/%s/automations/%s/heartbeat" % (self.project["id"], aid), {"message": "检查部署健康并汇报"}, self.owner_key) self.assertEqual(status, 201, body) self.assertEqual(body["delivery"], "queued") # cron 绑定不唤醒线程。 status, body = self._req( "POST", "/projects/%s/automations/%s/bind" % (self.project["id"], aid), {"task_id": task["id"], "mode": "cron"}, self.owner_key) self.assertEqual(status, 200, body) status, body = self._req( "POST", "/projects/%s/automations/%s/heartbeat" % (self.project["id"], aid), {}, self.owner_key) self.assertEqual(status, 409, body) status, body = self._req( "DELETE", "/projects/%s/automations/%s/bind" % (self.project["id"], aid), None, self.owner_key) self.assertEqual(status, 200, body) status, body = self._req( "GET", "/projects/%s/automations/%s/bind" % (self.project["id"], aid), None, self.reader_key) self.assertIsNone(body["binding"]) if __name__ == "__main__": unittest.main()