feat: 线程生命周期对齐 15 项(bindings/refs/快照/心跳绑定/移交落点)
对照 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
This commit is contained in:
1 parent
aee60fcb7d
commit
cf08c4cf37
5 files changed
+1343
-56
No files matched your search
@@ -0,0 +1,298 @@
|
||||
"""线程生命周期补充单测: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()
|
||||
Reference in new issue
Block a user