diff --git a/cloud/server.py b/cloud/server.py index 410e946..156b3aa 100644 --- a/cloud/server.py +++ b/cloud/server.py @@ -2088,6 +2088,51 @@ def _validate_hard_gate_body(body): return None +def _validate_task_goal_options(body, prompt, prompt_goal_target, plan): + # 从 _create_task 平移的 goal 校验:纯函数,不碰 self/DB; + # 返回 (goal_mode, goal, verify_command, goal_max_rounds, error), + # error 非空时调用方按原样发 400。 + # goal 模式分新旧两种语义(2026-10-27 起 console 只发新语义): + # 新语义 goal_mode=1:prompt 本身就是目标,不需要专用 goal + # 文本、不需要验收命令、不设轮次上限(goal_max_rounds 存 0, + # worker 走 host.py goal 循环的模型自决分支);传了 + # verify_command 是语义冲突,400。旧语义(verify_command + # 非空)保留:goal 文本缺省用 prompt,轮次缺省 3,非法值 + # 400 且不建任务;老客户端与已建的旧 goal 任务继续可用。 + # /goal 命令优先于 body 里的 goal_mode=0(prompt 里的 + # 命令比选项更具体),plan/验收命令冲突仍在下面 400。 + goal_mode = 1 if body.get("goal_mode") in (True, 1) \ + or prompt_goal_target else 0 + if goal_mode: + if plan: + return (0, None, None, 0, "plan 模式与 goal 模式冲突(只读 vs 改文件)") + if body.get("verify_command"): + return (0, None, None, 0, "goal 模式不需要验收命令(达标由模型宣告)") + return (1, prompt, None, 0, None) + else: + goal = body.get("goal") + if goal is not None and not isinstance(goal, str): + return (0, None, None, 0, "goal must be a string") + verify_command = body.get("verify_command") + if verify_command is not None and not isinstance( + verify_command, str): + return (0, None, None, 0, "verify_command must be a string") + # 计划模式与 goal 模式也冲突:goal 循环就是要改文件 + # 直到验测通过,只读沙箱下它永远做不成。 + if plan and verify_command: + return (0, None, None, 0, "plan 模式与 goal 模式冲突(只读 vs 改文件)") + try: + goal_max_rounds = int(body.get("goal_max_rounds", 3)) + except (TypeError, ValueError): + return (0, None, None, 0, "goal_max_rounds must be an integer") + if goal_max_rounds < 1: + return (0, None, None, 0, "goal_max_rounds must be >= 1") + # P1:旧验收形态轮次设上限 100(与 worker/sandbox 两道门 + # 一致;新 goal_mode=1 不走本分支,仍由配额兜底)。 + if goal_max_rounds > 100: + return (0, None, None, 0, "goal_max_rounds 超过上限 100 轮") + return (0, goal, verify_command, goal_max_rounds, None) + class CloudControlPlaneHandler(BaseHTTPRequestHandler): """控制面请求处理:除 /health 与 /webhooks/* 外一律要求 X-API-Key。""" @@ -2199,6 +2244,160 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler): except Exception: pass + def _dispatch_task_route(self, user_id, method, path, query): + # 从 _dispatch 平移的 /tasks/* 子路由:语句原样平移, + # 仅缩进减一格;未命中任一子路由时仍返回 404。 + rest = path[len("/tasks/"):] + if "/" not in rest and method == "GET": + self._get_task(user_id, rest) + elif rest.endswith("/cost") and method == "GET": + self._task_cost(user_id, rest[: -len("/cost")]) + elif rest.endswith("/trace/stream") and method == "GET": + self._task_trace_stream( + user_id, rest[: -len("/trace/stream")]) + elif rest.endswith("/trace") and method == "GET": + self._task_trace(user_id, rest[: -len("/trace")]) + elif rest.endswith("/result-full") and method == "GET": + self._task_result_full(user_id, + rest[: -len("/result-full")]) + elif rest.endswith("/agents") and method == "GET": + self._task_agents(user_id, rest[: -len("/agents")]) + elif rest.endswith("/cancel") and method == "POST": + self._cancel_task(user_id, rest[: -len("/cancel")]) + elif rest.endswith("/pause") and method == "POST": + self._pause_task(user_id, rest[: -len("/pause")]) + elif rest.endswith("/merge") and method == "POST": + self._merge_task(user_id, rest[: -len("/merge")]) + elif rest.endswith("/pr") and method == "POST": + self._task_pr(user_id, rest[: -len("/pr")]) + elif rest.endswith("/retry") and method == "POST": + self._retry_task(user_id, rest[: -len("/retry")]) + elif rest.endswith("/goal-resume") and method == "POST": + self._goal_resume_task(user_id, rest[: -len("/goal-resume")]) + elif rest.endswith("/auto-memory-flags") and method == "POST": + self._task_auto_memory_flags( + user_id, rest[: -len("/auto-memory-flags")]) + elif rest.endswith("/heartbeat") and method == "POST": + self._heartbeat_task(user_id, rest[: -len("/heartbeat")]) + elif rest.endswith("/followups") and method == "POST": + self._create_followup(user_id, rest[: -len("/followups")]) + elif rest.endswith("/followups") and method == "GET": + self._list_followups(user_id, rest[: -len("/followups")]) + elif rest.endswith("/timeline") and method == "GET": + self._task_timeline(user_id, rest[: -len("/timeline")]) + elif rest.endswith("/files") and method == "GET": + self._task_files(user_id, rest[: -len("/files")], query) + elif rest.endswith("/tree") and method == "GET": + self._task_tree(user_id, rest[: -len("/tree")], query) + elif rest.endswith("/browser/state") and method == "GET": + self._browser_state(user_id, + rest[: -len("/browser/state")]) + elif rest.endswith("/browser/input") and method == "POST": + self._browser_input(user_id, + rest[: -len("/browser/input")], + self._read_body()) + elif rest.endswith("/browser/control") and method == "POST": + self._browser_control(user_id, + rest[: -len("/browser/control")], + self._read_body()) + elif rest.endswith("/browser/stream") and method == "GET": + self._browser_stream(user_id, + rest[: -len("/browser/stream")]) + elif rest.endswith("/chat-history") and method == "GET": + self._task_chat_history( + user_id, rest[: -len("/chat-history")]) + elif rest.endswith("/messages") and method == "POST": + self._create_peer_message(user_id, rest[: -len("/messages")]) + elif rest.endswith("/messages") and method == "GET": + self._list_peer_messages(user_id, rest[: -len("/messages")]) + elif rest.endswith("/reviews") and method == "POST": + self._create_review(user_id, rest[: -len("/reviews")]) + elif rest.endswith("/reviews") and method == "GET": + self._list_reviews(user_id, rest[: -len("/reviews")]) + elif "/reviews/" in rest and rest.endswith("/findings") \ + and method == "POST": + _task_part, _rev_part = rest.split("/reviews/", 1) + self._update_review_findings( + user_id, _task_part, _rev_part.split("/")[0]) + elif rest.endswith("/threads") and method == "GET": + self._task_threads(user_id, rest[: -len("/threads")]) + elif rest.endswith("/thread") and method == "GET": + self._task_thread(user_id, rest[: -len("/thread")], query) + elif rest.endswith("/thread/rename") and method == "POST": + self._task_thread_rename( + user_id, rest[: -len("/thread/rename")]) + elif rest.endswith("/thread/archive") and method == "POST": + self._task_thread_archive( + user_id, rest[: -len("/thread/archive")]) + elif rest.endswith("/thread/unarchive") and method == "POST": + self._task_thread_unarchive( + user_id, rest[: -len("/thread/unarchive")]) + elif rest.endswith("/thread/compact") and method == "POST": + self._task_thread_compact( + user_id, rest[: -len("/thread/compact")]) + elif rest.endswith("/thread/read") and method == "GET": + self._task_thread_read(user_id, + rest[: -len("/thread/read")]) + elif rest.endswith("/thread/message") and method == "POST": + self._task_thread_message( + user_id, rest[: -len("/thread/message")]) + elif rest.endswith("/handoff") and method == "GET": + self._task_handoff_get(user_id, + rest[: -len("/handoff")]) + elif rest.endswith("/handoff") and method == "POST": + self._task_handoff_create(user_id, + rest[: -len("/handoff")]) + elif "/handoff/" in rest and rest.endswith("/report") \ + and method == "POST": + _task_part, _hid_part = rest.split("/handoff/", 1) + self._task_handoff_report( + user_id, _task_part, _hid_part[: -len("/report")]) + elif rest.endswith("/worktree") and method == "GET": + self._task_worktree_status(user_id, + rest[: -len("/worktree")]) + elif rest.endswith("/bindings") and method == "GET": + self._task_bindings(user_id, + rest[: -len("/bindings")]) + elif rest.endswith("/refs") and method == "GET": + self._task_refs_list(user_id, rest[: -len("/refs")]) + elif rest.endswith("/refs") and method == "POST": + self._task_refs_create(user_id, rest[: -len("/refs")]) + elif "/refs/" in rest and method == "DELETE": + _task_part, _ref_part = rest.split("/refs/", 1) + self._task_refs_delete(user_id, _task_part, _ref_part) + elif rest.endswith("/worktree/archive") and method == "POST": + self._task_worktree_archive( + user_id, rest[: -len("/worktree/archive")]) + elif rest.endswith("/worktree/restore") and method == "POST": + self._task_worktree_restore( + user_id, rest[: -len("/worktree/restore")]) + elif rest.endswith("/worktree/snapshots") and method == "GET": + self._task_worktree_snapshots( + user_id, rest[: -len("/worktree/snapshots")]) + elif rest.endswith("/pr/checks") and method == "GET": + self._task_pr_checks(user_id, + rest[: -len("/pr/checks")]) + elif rest.endswith("/sleep") and method == "POST": + self._task_sleep(user_id, rest[: -len("/sleep")]) + elif rest.endswith("/wait") and method == "POST": + self._task_wait(user_id, rest[: -len("/wait")]) + elif rest.endswith("/fork") and method == "POST": + self._fork_task(user_id, rest[: -len("/fork")]) + elif rest.endswith("/approvals") and method == "GET": + self._list_approvals(user_id, rest[: -len("/approvals")]) + elif rest.endswith("/approvals") and method == "POST": + self._create_approval(user_id, rest[: -len("/approvals")]) + elif rest.endswith("/approve") and method == "POST": + self._decide_approval( + user_id, rest[: -len("/approve")], "approved") + elif rest.endswith("/deny") and method == "POST": + self._decide_approval( + user_id, rest[: -len("/deny")], "denied") + elif rest.endswith("/continue") and method == "POST": + self._continue_task(user_id, rest[: -len("/continue")]) + else: + self._send_json(404, {"error": "not found"}) + def _dispatch(self, method): parsed = urlparse(self.path) path = parsed.path @@ -2444,156 +2643,7 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler): elif path == "/admin/push/dispatch" and method == "POST": self._push_dispatch_endpoint(user_id) elif path.startswith("/tasks/"): - rest = path[len("/tasks/"):] - if "/" not in rest and method == "GET": - self._get_task(user_id, rest) - elif rest.endswith("/cost") and method == "GET": - self._task_cost(user_id, rest[: -len("/cost")]) - elif rest.endswith("/trace/stream") and method == "GET": - self._task_trace_stream( - user_id, rest[: -len("/trace/stream")]) - elif rest.endswith("/trace") and method == "GET": - self._task_trace(user_id, rest[: -len("/trace")]) - elif rest.endswith("/result-full") and method == "GET": - self._task_result_full(user_id, - rest[: -len("/result-full")]) - elif rest.endswith("/agents") and method == "GET": - self._task_agents(user_id, rest[: -len("/agents")]) - elif rest.endswith("/cancel") and method == "POST": - self._cancel_task(user_id, rest[: -len("/cancel")]) - elif rest.endswith("/pause") and method == "POST": - self._pause_task(user_id, rest[: -len("/pause")]) - elif rest.endswith("/merge") and method == "POST": - self._merge_task(user_id, rest[: -len("/merge")]) - elif rest.endswith("/pr") and method == "POST": - self._task_pr(user_id, rest[: -len("/pr")]) - elif rest.endswith("/retry") and method == "POST": - self._retry_task(user_id, rest[: -len("/retry")]) - elif rest.endswith("/goal-resume") and method == "POST": - self._goal_resume_task(user_id, rest[: -len("/goal-resume")]) - elif rest.endswith("/auto-memory-flags") and method == "POST": - self._task_auto_memory_flags( - user_id, rest[: -len("/auto-memory-flags")]) - elif rest.endswith("/heartbeat") and method == "POST": - self._heartbeat_task(user_id, rest[: -len("/heartbeat")]) - elif rest.endswith("/followups") and method == "POST": - self._create_followup(user_id, rest[: -len("/followups")]) - elif rest.endswith("/followups") and method == "GET": - self._list_followups(user_id, rest[: -len("/followups")]) - elif rest.endswith("/timeline") and method == "GET": - self._task_timeline(user_id, rest[: -len("/timeline")]) - elif rest.endswith("/files") and method == "GET": - self._task_files(user_id, rest[: -len("/files")], query) - elif rest.endswith("/tree") and method == "GET": - self._task_tree(user_id, rest[: -len("/tree")], query) - elif rest.endswith("/browser/state") and method == "GET": - self._browser_state(user_id, - rest[: -len("/browser/state")]) - elif rest.endswith("/browser/input") and method == "POST": - self._browser_input(user_id, - rest[: -len("/browser/input")], - self._read_body()) - elif rest.endswith("/browser/control") and method == "POST": - self._browser_control(user_id, - rest[: -len("/browser/control")], - self._read_body()) - elif rest.endswith("/browser/stream") and method == "GET": - self._browser_stream(user_id, - rest[: -len("/browser/stream")]) - elif rest.endswith("/chat-history") and method == "GET": - self._task_chat_history( - user_id, rest[: -len("/chat-history")]) - elif rest.endswith("/messages") and method == "POST": - self._create_peer_message(user_id, rest[: -len("/messages")]) - elif rest.endswith("/messages") and method == "GET": - self._list_peer_messages(user_id, rest[: -len("/messages")]) - elif rest.endswith("/reviews") and method == "POST": - self._create_review(user_id, rest[: -len("/reviews")]) - elif rest.endswith("/reviews") and method == "GET": - self._list_reviews(user_id, rest[: -len("/reviews")]) - elif "/reviews/" in rest and rest.endswith("/findings") \ - and method == "POST": - _task_part, _rev_part = rest.split("/reviews/", 1) - self._update_review_findings( - user_id, _task_part, _rev_part.split("/")[0]) - elif rest.endswith("/threads") and method == "GET": - self._task_threads(user_id, rest[: -len("/threads")]) - elif rest.endswith("/thread") and method == "GET": - self._task_thread(user_id, rest[: -len("/thread")], query) - elif rest.endswith("/thread/rename") and method == "POST": - self._task_thread_rename( - user_id, rest[: -len("/thread/rename")]) - elif rest.endswith("/thread/archive") and method == "POST": - self._task_thread_archive( - user_id, rest[: -len("/thread/archive")]) - elif rest.endswith("/thread/unarchive") and method == "POST": - self._task_thread_unarchive( - user_id, rest[: -len("/thread/unarchive")]) - elif rest.endswith("/thread/compact") and method == "POST": - self._task_thread_compact( - user_id, rest[: -len("/thread/compact")]) - elif rest.endswith("/thread/read") and method == "GET": - self._task_thread_read(user_id, - rest[: -len("/thread/read")]) - elif rest.endswith("/thread/message") and method == "POST": - self._task_thread_message( - user_id, rest[: -len("/thread/message")]) - elif rest.endswith("/handoff") and method == "GET": - self._task_handoff_get(user_id, - rest[: -len("/handoff")]) - elif rest.endswith("/handoff") and method == "POST": - self._task_handoff_create(user_id, - rest[: -len("/handoff")]) - elif "/handoff/" in rest and rest.endswith("/report") \ - and method == "POST": - _task_part, _hid_part = rest.split("/handoff/", 1) - self._task_handoff_report( - user_id, _task_part, _hid_part[: -len("/report")]) - elif rest.endswith("/worktree") and method == "GET": - self._task_worktree_status(user_id, - rest[: -len("/worktree")]) - elif rest.endswith("/bindings") and method == "GET": - self._task_bindings(user_id, - rest[: -len("/bindings")]) - elif rest.endswith("/refs") and method == "GET": - self._task_refs_list(user_id, rest[: -len("/refs")]) - elif rest.endswith("/refs") and method == "POST": - self._task_refs_create(user_id, rest[: -len("/refs")]) - elif "/refs/" in rest and method == "DELETE": - _task_part, _ref_part = rest.split("/refs/", 1) - self._task_refs_delete(user_id, _task_part, _ref_part) - elif rest.endswith("/worktree/archive") and method == "POST": - self._task_worktree_archive( - user_id, rest[: -len("/worktree/archive")]) - elif rest.endswith("/worktree/restore") and method == "POST": - self._task_worktree_restore( - user_id, rest[: -len("/worktree/restore")]) - elif rest.endswith("/worktree/snapshots") and method == "GET": - self._task_worktree_snapshots( - user_id, rest[: -len("/worktree/snapshots")]) - elif rest.endswith("/pr/checks") and method == "GET": - self._task_pr_checks(user_id, - rest[: -len("/pr/checks")]) - elif rest.endswith("/sleep") and method == "POST": - self._task_sleep(user_id, rest[: -len("/sleep")]) - elif rest.endswith("/wait") and method == "POST": - self._task_wait(user_id, rest[: -len("/wait")]) - elif rest.endswith("/fork") and method == "POST": - self._fork_task(user_id, rest[: -len("/fork")]) - elif rest.endswith("/approvals") and method == "GET": - self._list_approvals(user_id, rest[: -len("/approvals")]) - elif rest.endswith("/approvals") and method == "POST": - self._create_approval(user_id, rest[: -len("/approvals")]) - elif rest.endswith("/approve") and method == "POST": - self._decide_approval( - user_id, rest[: -len("/approve")], "approved") - elif rest.endswith("/deny") and method == "POST": - self._decide_approval( - user_id, rest[: -len("/deny")], "denied") - elif rest.endswith("/continue") and method == "POST": - self._continue_task(user_id, rest[: -len("/continue")]) - else: - self._send_json(404, {"error": "not found"}) + self._dispatch_task_route(user_id, method, path, query) else: self._send_json(404, {"error": "not found"}) @@ -3239,6 +3289,38 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler): return 403, "forbidden" return None + def _project_push_subscription_delete(self, user_id, method, project_id, parts): + # 从 _dispatch_project 平移的 push 订阅删除分支: + # 语句原样平移,仅缩进减一格;每条路径都已发响应。 + if method != "DELETE": + self._send_json(404, {"error": "not found"}) + return + err = self._check_project(user_id, project_id, "create_task") + if err: + self._send_json(err[0], {"error": err[1]}) + return + subs = _push_list(self.server.db_path, project_id) + mine = [s for s in subs if s["id"] == parts[3]] + if not mine: + self._send_json(404, {"error": "subscription not found"}) + return + # 只能删自己的订阅,项目 owner 可删任意(owner 角色检查)。 + with _auth_store(self.server.db_path) as auth: + is_owner = auth.check_permission( + user_id, project_id, "manage_project") + if mine[0]["user_id"] != user_id and not is_owner: + self._send_json(403, {"error": "forbidden"}) + return + deleted = _push_delete(self.server.db_path, + subscription_id=parts[3]) + if deleted: + with _auth_store(self.server.db_path) as store: + store.audit( + user_id, project_id, "push_subscription_deleted", + "subscription_id=%s" % parts[3]) + self._send_json(200, {"deleted": bool(deleted)}) + return + def _dispatch_project(self, user_id, method, path, query): rest = path[len("/projects/"):] parts = rest.split("/") @@ -3391,33 +3473,8 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler): return # /projects/{id}/push/subscriptions/{sid} DELETE if len(parts) == 4 and parts[1] == "push" and parts[2] == "subscriptions": - if method != "DELETE": - self._send_json(404, {"error": "not found"}) - return - err = self._check_project(user_id, project_id, "create_task") - if err: - self._send_json(err[0], {"error": err[1]}) - return - subs = _push_list(self.server.db_path, project_id) - mine = [s for s in subs if s["id"] == parts[3]] - if not mine: - self._send_json(404, {"error": "subscription not found"}) - return - # 只能删自己的订阅,项目 owner 可删任意(owner 角色检查)。 - with _auth_store(self.server.db_path) as auth: - is_owner = auth.check_permission( - user_id, project_id, "manage_project") - if mine[0]["user_id"] != user_id and not is_owner: - self._send_json(403, {"error": "forbidden"}) - return - deleted = _push_delete(self.server.db_path, - subscription_id=parts[3]) - if deleted: - with _auth_store(self.server.db_path) as store: - store.audit( - user_id, project_id, "push_subscription_deleted", - "subscription_id=%s" % parts[3]) - self._send_json(200, {"deleted": bool(deleted)}) + self._project_push_subscription_delete( + user_id, method, project_id, parts) return # /projects/{id}/automations if len(parts) == 2 and parts[1] == "automations": @@ -4819,62 +4876,12 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler): self._send_json(400, { "error": "plan 模式与 approval=full 冲突(只读 vs 完全访问)"}) return - # goal 模式分新旧两种语义(2026-10-27 起 console 只发新语义): - # 新语义 goal_mode=1:prompt 本身就是目标,不需要专用 goal - # 文本、不需要验收命令、不设轮次上限(goal_max_rounds 存 0, - # worker 走 host.py goal 循环的模型自决分支);传了 - # verify_command 是语义冲突,400。旧语义(verify_command - # 非空)保留:goal 文本缺省用 prompt,轮次缺省 3,非法值 - # 400 且不建任务;老客户端与已建的旧 goal 任务继续可用。 - # /goal 命令优先于 body 里的 goal_mode=0(prompt 里的 - # 命令比选项更具体),plan/验收命令冲突仍在下面 400。 - goal_mode = 1 if body.get("goal_mode") in (True, 1) \ - or prompt_goal_target else 0 - if goal_mode: - if plan: - self._send_json(400, { - "error": "plan 模式与 goal 模式冲突(只读 vs 改文件)"}) - return - if body.get("verify_command"): - self._send_json(400, { - "error": "goal 模式不需要验收命令(达标由模型宣告)"}) - return - goal = prompt - verify_command = None - goal_max_rounds = 0 - else: - goal = body.get("goal") - if goal is not None and not isinstance(goal, str): - self._send_json(400, {"error": "goal must be a string"}) - return - verify_command = body.get("verify_command") - if verify_command is not None and not isinstance( - verify_command, str): - self._send_json(400, { - "error": "verify_command must be a string"}) - return - # 计划模式与 goal 模式也冲突:goal 循环就是要改文件 - # 直到验测通过,只读沙箱下它永远做不成。 - if plan and verify_command: - self._send_json(400, { - "error": "plan 模式与 goal 模式冲突(只读 vs 改文件)"}) - return - try: - goal_max_rounds = int(body.get("goal_max_rounds", 3)) - except (TypeError, ValueError): - self._send_json(400, { - "error": "goal_max_rounds must be an integer"}) - return - if goal_max_rounds < 1: - self._send_json(400, { - "error": "goal_max_rounds must be >= 1"}) - return - # P1:旧验收形态轮次设上限 100(与 worker/sandbox 两道门 - # 一致;新 goal_mode=1 不走本分支,仍由配额兜底)。 - if goal_max_rounds > 100: - self._send_json(400, { - "error": "goal_max_rounds 超过上限 100 轮"}) - return + (goal_mode, goal, verify_command, goal_max_rounds, + _goal_err) = _validate_task_goal_options( + body, prompt, prompt_goal_target, plan) + if _goal_err: + self._send_json(400, {"error": _goal_err}) + return # 协调员:coordinator_parts 非空时任务本身不跑模型回合, # worker 认领后按分工建并行子任务、等齐、合流;与 goal/ # plan/验收命令语义冲突(那些都是“本任务跑模型”的意思), diff --git a/cloud/tests/test_longfunc_split.py b/cloud/tests/test_longfunc_split.py new file mode 100644 index 0000000..253f37d --- /dev/null +++ b/cloud/tests/test_longfunc_split.py @@ -0,0 +1,91 @@ +"""长函数小步拆分单测:只测从 _create_task 抽出的纯函数。 + +不跑真实模型回合、不联网、不碰 DB:_validate_task_goal_options +只读 body/prompt/plan,错误串须与抽取前 _create_task 内联分支 +发出的 400 文案逐字一致(改动文案即本文件失败)。 +本文件不含任何真实密钥、token、证书或私钥。 +""" + +import sys +import unittest +from pathlib import Path + +try: + from cloud.server import ( + CloudControlPlaneHandler, + _validate_task_goal_options, + ) +except ImportError: # discover -s cloud/tests 时把项目根目录补进 sys.path + sys.path.insert(0, str(Path(__file__).resolve().parents[2])) + from cloud.server import ( + CloudControlPlaneHandler, + _validate_task_goal_options, + ) + + +class ValidateTaskGoalOptionsTestCase(unittest.TestCase): + def test_new_goal_mode(self): + self.assertEqual( + _validate_task_goal_options( + {"goal_mode": 1}, "做 X", None, 0), + (1, "做 X", None, 0, None)) + + def test_goal_command_target_is_new_goal_mode(self): + self.assertEqual( + _validate_task_goal_options( + {}, "做 X", "做 X", 0), + (1, "做 X", None, 0, None)) + + def test_new_goal_mode_conflicts(self): + self.assertEqual( + _validate_task_goal_options( + {"goal_mode": 1}, "做 X", None, 1)[4], + "plan 模式与 goal 模式冲突(只读 vs 改文件)") + self.assertEqual( + _validate_task_goal_options( + {"goal_mode": 1, "verify_command": "make test"}, + "做 X", None, 0)[4], + "goal 模式不需要验收命令(达标由模型宣告)") + + def test_old_goal_defaults(self): + self.assertEqual( + _validate_task_goal_options({}, "p", None, 0), + (0, None, None, 3, None)) + + def test_old_goal_explicit(self): + self.assertEqual( + _validate_task_goal_options( + {"goal": "G", "verify_command": "make test", + "goal_max_rounds": 5}, "p", None, 0), + (0, "G", "make test", 5, None)) + + def test_old_goal_errors(self): + # (body, plan, want_error):plan+verify 冲突用例传 plan=1, + # 其余 plan=0。 + cases = [ + ({"goal": 123}, 0, "goal must be a string"), + ({"verify_command": 123}, 0, "verify_command must be a string"), + ({"verify_command": "make test"}, 1, + "plan 模式与 goal 模式冲突(只读 vs 改文件)"), + ({"goal_max_rounds": "x"}, 0, + "goal_max_rounds must be an integer"), + ({"goal_max_rounds": 0}, 0, "goal_max_rounds must be >= 1"), + ({"goal_max_rounds": 101}, 0, "goal_max_rounds 超过上限 100 轮"), + ] + for body, plan, want in cases: + self.assertEqual( + _validate_task_goal_options(body, "p", None, plan)[4], + want, body) + + def test_split_helpers_exist(self): + # 结构锚点:两个路由助手须挂在 handler 上(行为由既有 + # dispatch/任务/订阅测试覆盖,本文件不重复搭 HTTP 骨架)。 + self.assertTrue(callable( + getattr(CloudControlPlaneHandler, "_dispatch_task_route"))) + self.assertTrue(callable( + getattr(CloudControlPlaneHandler, + "_project_push_subscription_delete"))) + + +if __name__ == "__main__": + unittest.main()