Files
codex-app-server/cloud/tests/test_thread_wait_ops.py
host 755029f220 feat: 线程等待与状态读取 8 项(wait/read/message/handoff/worktree/pr-checks/automation/sleep)
对照 Codex app:wait_threads 首变+afterCursor+8上限、
read_thread 详情摘要、send_message_to_thread 复活失败任务、
handoff 登记与上报轮询、worktree 派生状态、PR checks JSON+
check-runs 日志与注释、automation GET/PATCH、clock.sleep
(API+host)。诚实边界见 docs/thread-wait-ops.md:无
worktree 阶段流、无 automation 变化静默、无独立测试结果
记录、handoff 服务端不执行迁移、wait 仅数据库级变化检测。

三套件:cloud 940 OK(4 跳过)、host 91 OK、deploy 29 OK
2026-10-28 04:33:20 +08:00

669 lines
29 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""线程等待与移交操作单测:cloud/wait_ops 纯逻辑 + server 端点。
不跑真实模型、不联网、不调真实 gh:thread-read 用 stub
host.py(与 test_thread_ops.py 同款契约),pr checks 用
注入的 fake gh runner,worktree 状态用临时目录。覆盖鉴权、
权限、校验、状态机(移交转移、终态不可动)、超时语义与
诚实降级(异机 worktree / Docker 任务的 thread-read 仍回
200,只 thread 为空)。
"""
import http.client
import json
import os
import sqlite3
import sys
import tempfile
import threading
import unittest
from pathlib import Path
from types import SimpleNamespace
try:
from cloud.auth import AuthStore
from cloud.server import create_server
from cloud.wait_ops import (build_pr_diagnostics, create_handoff,
decode_wait_cursor,
detect_significant_change,
encode_wait_cursor, interruptible_sleep,
latest_handoff, parse_pr_checks,
poll_for_first_change, poll_task_statuses,
report_handoff, worktree_status)
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.wait_ops import (build_pr_diagnostics, create_handoff,
decode_wait_cursor,
detect_significant_change,
encode_wait_cursor, interruptible_sleep,
latest_handoff, parse_pr_checks,
poll_for_first_change, poll_task_statuses,
report_handoff, worktree_status)
STUB = r'''import json
import os
import sys
def main():
argv = sys.argv[1:]
cmd = None
rest = []
i = 0
while i < len(argv):
a = argv[i]
if a == "--codex-home":
i += 2
continue
if a.startswith("--codex-home="):
i += 1
continue
if cmd is None:
cmd = a
i += 1
continue
rest.append(a)
i += 1
if cmd == "thread-list":
print(json.dumps({"data": [
{"id": "t-old", "updatedAt": 100, "createdAt": 90},
{"id": "t-new", "updatedAt": 200, "createdAt": 190},
]}))
elif cmd == "thread-read":
print(json.dumps({"thread": {"id": "t-new", "updatedAt": 200}}))
else:
print(json.dumps({"error": "unknown subcommand"}))
sys.exit(1)
main()
'''
class WaitOpsPureTestCase(unittest.TestCase):
def test_parse_pr_checks_tabular_and_human(self):
checks, all_pass, failed, pending = parse_pr_checks(
"build\tpass\ntest\tfail\nlint\tpending\n")
self.assertEqual([c["state"] for c in checks],
["pass", "fail", "pending"])
self.assertFalse(all_pass)
self.assertEqual(failed, ["test"])
self.assertEqual(pending, ["lint"])
checks, all_pass, _, _ = parse_pr_checks(
"\u2713 build pass\n\u2713 test pass\n")
self.assertTrue(all_pass)
checks, all_pass, _, _ = parse_pr_checks("no checks reported")
self.assertEqual(checks, [])
self.assertFalse(all_pass)
def test_worktree_status_matrix(self):
self.assertEqual(
worktree_status("running", "/tmp/x", False, False)["status"],
"creating")
self.assertEqual(
worktree_status("completed", "/tmp/x", False, False)["status"],
"missing")
self.assertEqual(
worktree_status("running", "/tmp/x", True, True)["status"],
"ready")
self.assertEqual(
worktree_status("running", "/tmp/x", True, False)["status"],
"present_no_git")
remote = worktree_status("running", "/tmp/x", False, False,
remote_host="host-b")
self.assertEqual(remote["status"], "remote")
self.assertIsNone(remote["exists"])
def test_handoff_transitions(self):
conn = sqlite3.connect(":memory:")
conn.row_factory = sqlite3.Row
h = create_handoff(conn, "t1", "p1", "host-b", "u1",
source_host="host-a")
self.assertEqual(h["status"], "pending")
with self.assertRaises(ValueError):
create_handoff(conn, "t1", "p1", "host-c", "u1")
ok, h = report_handoff(conn, h["id"], "running", progress=40)
self.assertTrue(ok)
self.assertEqual((h["status"], h["progress"]), ("running", 40))
ok, h = report_handoff(conn, h["id"], "completed")
self.assertTrue(ok)
ok, err = report_handoff(conn, h["id"], "running")
self.assertFalse(ok)
self.assertIn("cannot move", err)
ok, err = report_handoff(conn, h["id"], "bogus")
self.assertFalse(ok)
self.assertEqual(latest_handoff(conn, "t1")["status"], "completed")
conn.close()
def test_wait_cursor_and_first_change(self):
base = {"a": {"status": "running", "pending_approvals": 0,
"pending_input": 0, "pending_followups": 0},
"b": {"status": "running", "pending_approvals": 0,
"pending_input": 0, "pending_followups": 0}}
cursor = encode_wait_cursor(base)
self.assertEqual(decode_wait_cursor(cursor), base)
with self.assertRaises(ValueError):
decode_wait_cursor("not-a-cursor!!")
cur = dict(base)
cur["b"] = {"status": "running", "pending_approvals": 0,
"pending_input": 1, "pending_followups": 0}
found = detect_significant_change(base, cur, ["a", "b"])
self.assertEqual(found[0], "b")
self.assertEqual(found[1], "needs_input")
cur2 = dict(base)
cur2["a"] = {"status": "failed", "pending_approvals": 0,
"pending_input": 0, "pending_followups": 0}
found = detect_significant_change(base, cur2, ["a", "b"])
self.assertEqual(found[1], "terminal")
# 基线里没有的任务不误报;无游标时已终态立即算变化。
found = detect_significant_change(
base, {"c": {"status": "completed", "pending_approvals": 0,
"pending_input": 0, "pending_followups": 0}},
["c"])
self.assertIsNone(found)
found = detect_significant_change(
None, {"c": {"status": "completed", "pending_approvals": 0,
"pending_input": 0, "pending_followups": 0}},
["c"])
self.assertEqual(found[1], "terminal")
changed, summaries, _w, timed_out = poll_for_first_change(
lambda: {"a": {"status": "completed",
"pending_approvals": 0, "pending_input": 0,
"pending_followups": 0}},
["a"], baseline=None, timeout_seconds=5)
self.assertFalse(timed_out)
self.assertEqual(changed["kind"], "terminal")
def test_build_pr_diagnostics_json_and_check_runs(self):
def run(cmd):
if "--json" in cmd:
return (0, json.dumps([
{"name": "build", "bucket": "pass",
"workflow": "CI",
"link": "https://example.invalid/ci/1",
"description": "ok"},
{"name": "test", "bucket": "fail",
"workflow": "CI",
"link": "https://example.invalid/ci/2",
"description": "1 failed"}]), "")
if "view" in cmd:
return (0, "abc123\n", "")
if any("commits" in c for c in cmd):
return (0, json.dumps({"check_runs": [
{"id": 99, "name": "test",
"conclusion": "failure"}]}), "")
if any("check-runs" in c for c in cmd):
return (0, json.dumps({
"id": 99, "conclusion": "failure",
"output": {
"title": "Test failed",
"summary": "1 test failed",
"text": "FAILED test_x: assert failed",
"annotations": [{
"path": "src/a.py", "start_line": 12,
"annotation_level": "failure",
"message": "assert failed"}]}}), "")
return (1, "", "unknown cmd")
diag = build_pr_diagnostics(run, 42, owner_repo="o/r",
head_sha="abc123")
self.assertFalse(diag["all_pass"])
self.assertEqual(diag["failed"], ["test"])
self.assertEqual(diag["checks"][1]["workflow"], "CI")
self.assertTrue(diag["check_runs"]["available"])
run_detail = diag["check_runs"]["runs"][0]
self.assertIn("FAILED test_x", run_detail["log_text"])
self.assertEqual(run_detail["annotations"][0]["path"], "src/a.py")
self.assertEqual(run_detail["annotations"][0]["start_line"], 12)
# 制表符回落:--json 失败时仍能解析,且无仓库时不查 API。
diag2 = build_pr_diagnostics(
lambda cmd: (1, "", "boom") if "--json" in cmd
else (0, "build\tpass\ntest\tfail\n", ""),
7)
self.assertEqual(diag2["failed"], ["test"])
self.assertFalse(diag2["check_runs"]["available"])
def test_poll_and_sleep(self):
calls = {"n": 0}
def fetch():
calls["n"] += 1
return {"a": "running"} if calls["n"] < 3 else {"a": "completed"}
ok, statuses, waited, timed_out = poll_task_statuses(
fetch, timeout_seconds=5)
self.assertTrue(ok)
self.assertFalse(timed_out)
self.assertEqual(statuses, {"a": "completed"})
ok, _, _, timed_out = poll_task_statuses(
lambda: {"a": "running"}, timeout_seconds=0)
self.assertFalse(ok)
self.assertTrue(timed_out)
ok, _, _, _ = poll_task_statuses(lambda: {}, timeout_seconds=5)
self.assertTrue(ok)
slept, interrupted = interruptible_sleep(0)
self.assertEqual((slept, interrupted), (0.0, False))
class ThreadWaitServerTestCase(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.stub_path = os.path.join(self.tmp.name, "stub_host.py")
with open(self.stub_path, "w", encoding="utf-8") as f:
f.write(STUB)
self._old_host_py = os.environ.get("CODEX_HOST_PY")
os.environ["CODEX_HOST_PY"] = self.stub_path
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")
status, self.project2 = self._req(
"POST", "/projects", {"name": "other"}, self.owner_key)
self.assertEqual(status, 201, self.project2)
def tearDown(self):
self.server.shutdown()
self.server.server_close()
self.thread.join(timeout=5)
if self._old_host_py is None:
os.environ.pop("CODEX_HOST_PY", None)
else:
os.environ["CODEX_HOST_PY"] = self._old_host_py
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, project_id=None, **extra):
body = {"project_id": project_id or 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 test_thread_read_summary_and_docker_downgrade(self):
# 默认执行器是 docker(线程守卫会挡),读摘要的 thread
# 部分必须用显式 local 任务测;docker 任务测降级分支。
task = self._create_task(executor="local")
self._set_task(task["id"], status="running")
status, body = self._req(
"GET", "/tasks/%s/thread/read" % task["id"],
None, self.reader_key)
self.assertEqual(status, 200, body)
self.assertEqual(body["status"], "running")
self.assertEqual(body["state"], "running")
self.assertEqual(body["thread_id"], "t-new")
self.assertEqual(body["thread"]["id"], "t-new")
self.assertEqual(body["progress"]["trace_entries"], 0)
self.assertIsNone(body["result"]["text"])
self.assertEqual(body["changed_files"], [])
self.assertEqual(body["commands"], [])
self.assertEqual(body["recent_activity"], [])
self.assertFalse(body["attention"]["blocked"])
self.assertFalse(body["attention"]["needs_instruction"])
self.assertIsNone(body["test_results"])
docker = self._create_task()
status, body = self._req(
"GET", "/tasks/%s/thread/read" % docker["id"],
None, self.owner_key)
self.assertEqual(status, 200, body)
self.assertIsNone(body["thread"])
self.assertIn("docker", body["note"])
status, _ = self._req(
"GET", "/tasks/%s/thread/read" % task["id"],
None, self.outsider_key)
self.assertEqual(status, 403)
status, _ = self._req(
"GET", "/tasks/nope/thread/read", None, self.owner_key)
self.assertEqual(status, 404)
def test_thread_message_followup_semantics(self):
task = self._create_task()
self._set_task(task["id"], status="running")
status, body = self._req(
"POST", "/tasks/%s/thread/message" % task["id"],
{"body": "下一步改 README"}, self.owner_key)
self.assertEqual(status, 201, body)
self.assertEqual(body["delivery"], "queued")
status, body = self._req(
"POST", "/tasks/%s/thread/message" % task["id"],
{"body": "立刻改", "mode": "direct"}, self.owner_key)
self.assertEqual(status, 201, body)
self.assertEqual(body["delivery"], "direct")
status, body = self._req(
"POST", "/tasks/%s/thread/message" % task["id"],
{"body": "坏模式", "mode": "bogus"}, self.owner_key)
self.assertEqual(status, 400, body)
# 失败任务发修复指令:复活(不是 409),状态回 queued。
done = self._create_task()
self._set_task(done["id"], status="failed",
error_text="compile error")
status, body = self._req(
"POST", "/tasks/%s/thread/message" % done["id"],
{"body": "继续修复编译错误并重跑测试"}, self.owner_key)
self.assertEqual(status, 201, body)
self.assertEqual(body["delivery"], "revived")
status, task_after = self._req(
"GET", "/tasks/%s" % done["id"], None, self.owner_key)
self.assertEqual(task_after["status"], "queued")
self.assertIsNone(task_after["error_text"])
# 已合并任务不能复活(worktree 已删),应 409。
merged = self._create_task()
self._set_task(merged["id"], status="merged")
status, body = self._req(
"POST", "/tasks/%s/thread/message" % merged["id"],
{"body": "再改一下"}, self.owner_key)
self.assertEqual(status, 409, body)
def test_handoff_lifecycle_and_cross_project(self):
task = self._create_task()
status, body = self._req(
"GET", "/tasks/%s/handoff" % task["id"], None, self.owner_key)
self.assertEqual(status, 200, body)
self.assertIsNone(body["handoff"])
status, h = self._req(
"POST", "/tasks/%s/handoff" % task["id"],
{"target_host": "host-b"}, self.owner_key)
self.assertEqual(status, 201, h)
self.assertEqual(h["status"], "pending")
status, body = self._req(
"POST", "/tasks/%s/handoff" % task["id"],
{"target_host": "host-c"}, self.owner_key)
self.assertEqual(status, 409, body)
status, body = self._req(
"POST", "/tasks/%s/handoff/%s/report" % (task["id"], h["id"]),
{"status": "running", "progress": 40}, self.owner_key)
self.assertEqual(status, 200, body)
self.assertEqual((body["status"], body["progress"]),
("running", 40))
status, body = self._req(
"GET", "/tasks/%s/handoff" % task["id"], None, self.owner_key)
self.assertEqual(body["handoff"]["status"], "running")
other = self._create_task(self.project2["id"])
status, body = self._req(
"POST", "/tasks/%s/handoff/%s/report" % (other["id"], h["id"]),
{"status": "completed"}, self.owner_key)
self.assertEqual(status, 404, body)
status, body = self._req(
"POST", "/tasks/%s/handoff/%s/report" % (task["id"], h["id"]),
{"status": "completed"}, self.owner_key)
self.assertEqual(status, 200, body)
status, body = self._req(
"POST", "/tasks/%s/handoff/%s/report" % (task["id"], h["id"]),
{"status": "running"}, self.owner_key)
self.assertEqual(status, 409, body)
def test_worktree_status_derived(self):
task = self._create_task()
status, body = self._req(
"GET", "/tasks/%s/worktree" % task["id"], None, self.owner_key)
self.assertEqual(status, 200, body)
self.assertEqual(body["status"], "creating")
import re
safe = re.sub(r"[^A-Za-z0-9_.-]", "-", task["id"])
path = os.path.join(self.server.host_worktrees_dir, safe)
os.makedirs(os.path.join(path, ".git"), exist_ok=True)
status, body = self._req(
"GET", "/tasks/%s/worktree" % task["id"], None, self.owner_key)
self.assertEqual(body["status"], "ready")
self.assertTrue(body["exists"])
self._set_task(task["id"], worktree_host="other-host")
status, body = self._req(
"GET", "/tasks/%s/worktree" % task["id"], None, self.owner_key)
self.assertEqual(body["status"], "remote")
self.assertIsNone(body["exists"])
def test_pr_checks_and_no_pr(self):
task = self._create_task()
status, body = self._req(
"GET", "/tasks/%s/pr/checks" % task["id"], None, self.owner_key)
self.assertEqual(status, 409, body)
self._set_task(task["id"], pr_number=42,
pr_url="https://example.invalid/pr/42")
# 制表符回落 + 无仓库时不查 check-runs API。
self.server.pr_gh_runner = lambda cmd, cwd: SimpleNamespace(
returncode=0, stdout="build\tpass\ntest\tfail\n",
stderr="")
status, body = self._req(
"GET", "/tasks/%s/pr/checks" % task["id"], None, self.owner_key)
self.assertEqual(status, 200, body)
self.assertFalse(body["all_pass"])
self.assertEqual(body["failed"], ["test"])
self.assertIn("test", body["diagnostics"])
self.assertFalse(body["check_runs"]["available"])
# 有仓库时:JSON checks + check-runs 日志与行号注释。
status, repo_proj = self._req(
"POST", "/projects",
{"name": "repo-proj",
"repo_url": "https://github.com/openqj/demo.git"},
self.owner_key)
self.assertEqual(status, 201, repo_proj)
repo_task = self._create_task(repo_proj["id"])
self._set_task(repo_task["id"], pr_number=43,
pr_url="https://example.invalid/pr/43")
def _gh(cmd, cwd):
if "--json" in cmd:
return SimpleNamespace(returncode=0, stdout=json.dumps([
{"name": "build", "bucket": "pass",
"workflow": "CI",
"link": "https://example.invalid/ci/1",
"description": "ok"},
{"name": "test", "bucket": "fail",
"workflow": "CI",
"link": "https://example.invalid/ci/2",
"description": "1 failed"}]), stderr="")
if "view" in cmd:
return SimpleNamespace(returncode=0, stdout="abc123\n",
stderr="")
if any("commits" in c for c in cmd):
return SimpleNamespace(returncode=0, stdout=json.dumps(
{"check_runs": [{"id": 99, "name": "test",
"conclusion": "failure"}]}),
stderr="")
if any("check-runs" in c for c in cmd):
return SimpleNamespace(returncode=0, stdout=json.dumps({
"id": 99, "conclusion": "failure",
"output": {
"title": "Test failed",
"summary": "1 test failed",
"text": "FAILED test_x: assert failed",
"annotations": [{
"path": "src/a.py", "start_line": 12,
"annotation_level": "failure",
"message": "assert failed"}]}}),
stderr="")
return SimpleNamespace(returncode=1, stdout="", stderr="boom")
self.server.pr_gh_runner = _gh
status, body = self._req(
"GET", "/tasks/%s/pr/checks" % repo_task["id"],
None, self.owner_key)
self.assertEqual(status, 200, body)
self.assertEqual(body["checks"][1]["workflow"], "CI")
self.assertIn("example.invalid/ci/2", body["checks"][1]["link"])
self.assertTrue(body["check_runs"]["available"])
run_detail = body["check_runs"]["runs"][0]
self.assertIn("FAILED test_x", run_detail["log_text"])
self.assertEqual(run_detail["annotations"][0]["path"], "src/a.py")
self.assertEqual(run_detail["annotations"][0]["start_line"], 12)
self._set_task(task["id"], worktree_host="other-host")
status, body = self._req(
"GET", "/tasks/%s/pr/checks" % task["id"], None, self.owner_key)
self.assertEqual(status, 409, body)
def test_sleep_and_wait(self):
task = self._create_task()
status, body = self._req(
"POST", "/tasks/%s/sleep" % task["id"],
{"seconds": 0}, self.owner_key)
self.assertEqual(status, 200, body)
self.assertEqual(body["slept_seconds"], 0)
status, body = self._req(
"POST", "/tasks/%s/sleep" % task["id"],
{"seconds": 61}, self.owner_key)
self.assertEqual(status, 400, body)
parent = self._create_task()
child = self._create_task()
self._set_task(child["id"], status="completed",
parent_task_id=parent["id"])
# 无游标:已终态的子任务立即作为首变返回,不等满超时。
status, body = self._req(
"POST", "/tasks/%s/wait" % parent["id"],
{"timeout_seconds": 30}, self.owner_key)
self.assertEqual(status, 200, body)
self.assertTrue(body["all_terminal"])
self.assertFalse(body["timed_out"])
self.assertEqual(body["children"][0]["task_id"], child["id"])
self.assertEqual(body["changed_task"]["task_id"], child["id"])
self.assertEqual(body["changed_task"]["kind"], "terminal")
cursor = body["cursor"]
# 同一游标再等:已终态不再重复报,超时回进度 + 新游标。
status, body = self._req(
"POST", "/tasks/%s/wait" % parent["id"],
{"timeout_seconds": 0, "after_cursor": cursor},
self.owner_key)
self.assertEqual(status, 200, body)
self.assertTrue(body["timed_out"])
self.assertIsNone(body["changed_task"])
# 非法游标 400;超 8 个 400;运行中 + 超时 0 回超时。
status, body = self._req(
"POST", "/tasks/%s/wait" % parent["id"],
{"timeout_seconds": 0, "after_cursor": "bogus!!"},
self.owner_key)
self.assertEqual(status, 400, body)
running = self._create_task()
status, body = self._req(
"POST", "/tasks/%s/wait" % parent["id"],
{"task_ids": [running["id"]], "timeout_seconds": 0},
self.owner_key)
self.assertEqual(status, 200, body)
self.assertTrue(body["timed_out"])
self.assertFalse(body["all_terminal"])
status, body = self._req(
"POST", "/tasks/%s/wait" % parent["id"],
{"task_ids": ["x"] * 9, "timeout_seconds": 0},
self.owner_key)
self.assertEqual(status, 400, body)
status, body = self._req(
"POST", "/tasks/%s/wait" % parent["id"],
{"timeout_seconds": 121}, self.owner_key)
self.assertEqual(status, 400, body)
def test_wait_cursor_approval_trigger(self):
# 基线游标(运行中、无待审批)-> 新建 elicitation 审批
# -> 再等同一游标,立即报 needs_input(不是等满超时)。
parent = self._create_task()
target = self._create_task()
status, body = self._req(
"POST", "/tasks/%s/wait" % parent["id"],
{"task_ids": [target["id"]], "timeout_seconds": 0},
self.owner_key)
self.assertEqual(status, 200, body)
self.assertTrue(body["timed_out"])
cursor = body["cursor"]
status, appr = self._req(
"POST", "/tasks/%s/approvals" % target["id"],
{"kind": "elicitation", "summary": "请确认是否继续"},
self.owner_key)
self.assertEqual(status, 201, appr)
status, body = self._req(
"POST", "/tasks/%s/wait" % parent["id"],
{"task_ids": [target["id"]], "timeout_seconds": 30,
"after_cursor": cursor},
self.owner_key)
self.assertEqual(status, 200, body)
self.assertFalse(body["timed_out"])
self.assertEqual(body["changed_task"]["kind"], "needs_input")
self.assertEqual(body["changed_task"]["task_id"], target["id"])
def test_automation_update_and_get(self):
status, auto = self._req(
"POST", "/projects/%s/automations" % self.project["id"],
{"name": "daily", "prompt": "做每日检查"}, self.owner_key)
self.assertEqual(status, 201, auto)
status, body = self._req(
"PATCH", "/projects/%s/automations/%s"
% (self.project["id"], auto["id"]),
{"interval_seconds": 7200}, self.owner_key)
self.assertEqual(status, 200, body)
self.assertEqual(body["interval_seconds"], 7200)
status, body = self._req(
"GET", "/projects/%s/automations/%s"
% (self.project["id"], auto["id"]),
None, self.owner_key)
self.assertEqual(status, 200, body)
self.assertEqual(body["runs"], [])
status, body = self._req(
"PATCH", "/projects/%s/automations/%s"
% (self.project["id"], auto["id"]),
{}, self.owner_key)
self.assertEqual(status, 400, body)
status, body = self._req(
"PATCH", "/projects/%s/automations/%s"
% (self.project2["id"], auto["id"]),
{"interval_seconds": 3600}, self.owner_key)
self.assertEqual(status, 404, body)
status, body = self._req(
"POST", "/projects/%s/automations/%s/cancel"
% (self.project["id"], auto["id"]),
{}, self.owner_key)
self.assertEqual(status, 200, body)
status, body = self._req(
"PATCH", "/projects/%s/automations/%s"
% (self.project["id"], auto["id"]),
{"interval_seconds": 3600}, self.owner_key)
self.assertEqual(status, 409, body)
if __name__ == "__main__":
unittest.main()