对照 Codex app 第二批:GET /tasks/{id}/bindings(项目/
环境/主机/worktree/PR/附件/血缘总表)、/refs 外部引用
增删查、/worktree/archive 快照(branch/head/status/diff/
untracked 路径)与 restore(目录在才解归档、目录删了
409 附快照)、自动化 bind + heartbeat(走 thread/message
同款投递路,cron 绑定不唤醒)、移交 completed 后任务
worktree_host 跟随切换、pr/checks 响应补 head_sha、
thread/read 的 tool 条目补 server/status。
诚实边界见 docs/thread-lifecycle-align.md:已有 worktree
附加、自动化变化静默、建议器、服务端定时触发器未实现;
Memory 对位是待合的 PR #102,本批不重复实现。
三套件:cloud 948 OK(4 跳过)、host 91 OK、deploy 29 OK
299 lines
12 KiB
Python
299 lines
12 KiB
Python
"""线程生命周期补充单测: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()
|