feat: resident watch tasks with pause/resume/end
Long-running website monitoring: new cloud/resident.py layer on top of the automations claim/quota model (no task state-machine change). States running/paused/ended; pause preserves the content-hash cursor and stops polling, resume re-checks from the cursor, end is terminal (resume after end is 409, a new watch is required). Polling is a cheap fetch + hash comparison; only a real change creates a queued model task. Quota exhaustion pauses (quota_exceeded) instead of ending and keeps the cursor. Driver is the worker run_loop round boundary; server exposes create/list/get/update/pause/resume/end with audit. Console UI intentionally not wired this round (see verification summary for deviations).
This commit is contained in:
1 parent
622c51e7fb
commit
0722ca3fd8
6 files changed
+1516
No files matched your search
+187
@@ -110,6 +110,14 @@ from cloud.wait_ops import worktree_status as _wait_worktree_status
|
||||
from cloud.shared_home_mcp import sync_shared_home_mcp as _sync_shared_home_mcp
|
||||
from cloud.automations import ensure_automations_tables as _auto_ensure
|
||||
from cloud.automations import list_automations as _auto_list
|
||||
from cloud.resident import create_watch as _res_create
|
||||
from cloud.resident import end_watch as _res_end
|
||||
from cloud.resident import get_watch as _res_get
|
||||
from cloud.resident import list_polls as _res_polls
|
||||
from cloud.resident import list_watches as _res_list
|
||||
from cloud.resident import pause_watch as _res_pause
|
||||
from cloud.resident import resume_watch as _res_resume
|
||||
from cloud.resident import update_watch as _res_update
|
||||
from cloud.todos import create_todo as _todo_create
|
||||
from cloud.todos import delete_todo as _todo_delete
|
||||
from cloud.todos import get_todo as _todo_get
|
||||
@@ -3371,6 +3379,54 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler):
|
||||
return
|
||||
self._triage_inbox(project_id)
|
||||
return
|
||||
# /projects/{id}/resident-watches:常驻监听任务。
|
||||
# 创建/更新/暂停/恢复/结束用 create_task,读用 read;
|
||||
# 状态机见 cloud/resident.py(running/paused/ended,
|
||||
# ended 是终态,恢复已结束的监听必须新建)。
|
||||
if len(parts) == 2 and parts[1] == "resident-watches":
|
||||
if method == "POST":
|
||||
err = self._check_project(user_id, project_id, "create_task")
|
||||
if err:
|
||||
self._send_json(err[0], {"error": err[1]})
|
||||
return
|
||||
self._resident_create(user_id, project_id)
|
||||
elif method == "GET":
|
||||
err = self._check_project(user_id, project_id, "read")
|
||||
if err:
|
||||
self._send_json(err[0], {"error": err[1]})
|
||||
return
|
||||
self._resident_list(project_id)
|
||||
else:
|
||||
self._send_json(404, {"error": "not found"})
|
||||
return
|
||||
if len(parts) == 3 and parts[1] == "resident-watches":
|
||||
if method == "GET":
|
||||
err = self._check_project(user_id, project_id, "read")
|
||||
if err:
|
||||
self._send_json(err[0], {"error": err[1]})
|
||||
return
|
||||
self._resident_get(user_id, project_id, parts[2])
|
||||
elif method == "PATCH":
|
||||
err = self._check_project(user_id, project_id, "create_task")
|
||||
if err:
|
||||
self._send_json(err[0], {"error": err[1]})
|
||||
return
|
||||
self._resident_update(user_id, project_id, parts[2])
|
||||
else:
|
||||
self._send_json(404, {"error": "not found"})
|
||||
return
|
||||
if len(parts) == 4 and parts[1] == "resident-watches" \
|
||||
and parts[3] in ("pause", "resume", "end"):
|
||||
if method != "POST":
|
||||
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
|
||||
self._resident_transition(user_id, project_id, parts[2],
|
||||
parts[3])
|
||||
return
|
||||
# /projects/{id}/todos GET 列表 / POST 新建;单条
|
||||
# GET / PATCH / DELETE(项目 read / create_task)
|
||||
if len(parts) == 2 and parts[1] == "todos":
|
||||
@@ -10067,6 +10123,137 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler):
|
||||
automation_id)
|
||||
self._send_json(200, auto)
|
||||
|
||||
# -- 常驻监听任务(见 cloud/resident.py)-------------------
|
||||
# 权限口径与自动化同款:创建/更新/暂停/恢复/结束用
|
||||
# create_task,读用 read;状态变更记审计。创建不走配额门
|
||||
# (轮询不花模型额度),配额只在变化建任务时由驱动过,
|
||||
# 耗尽时转暂停而非拒绝创建。
|
||||
def _resident_create(self, user_id, project_id):
|
||||
body, err = self._read_json()
|
||||
if err:
|
||||
self._send_json(400, {"error": err})
|
||||
return
|
||||
interval = body.get("interval_seconds", 3600)
|
||||
if interval is None:
|
||||
interval = 3600
|
||||
conn = self._db()
|
||||
try:
|
||||
try:
|
||||
watch = _res_create(
|
||||
conn, project_id, body.get("name"),
|
||||
body.get("target_url"), body.get("prompt"), user_id,
|
||||
interval, body.get("fetch_timeout_seconds"))
|
||||
except ValueError as exc:
|
||||
self._send_json(400, {"error": str(exc)})
|
||||
return
|
||||
except Exception as exc:
|
||||
self._send_json(502, {"error": str(exc)[:500]})
|
||||
return
|
||||
finally:
|
||||
conn.close()
|
||||
with _auth_store(self.server.db_path) as store:
|
||||
store.audit(user_id, project_id, "resident_watch_created",
|
||||
watch["id"])
|
||||
self._send_json(201, watch)
|
||||
|
||||
def _resident_list(self, project_id):
|
||||
conn = self._db()
|
||||
try:
|
||||
try:
|
||||
watches = _res_list(conn, project_id)
|
||||
except Exception as exc:
|
||||
self._send_json(502, {"error": str(exc)[:500]})
|
||||
return
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
self._send_json(200, {
|
||||
"project_id": project_id, "resident_watches": watches,
|
||||
"due_count": sum(1 for w in watches if w["due"])})
|
||||
|
||||
def _resident_get(self, user_id, project_id, watch_id):
|
||||
conn = self._db()
|
||||
try:
|
||||
watch = _res_get(conn, watch_id)
|
||||
polls = []
|
||||
polls_note = ""
|
||||
if watch is not None:
|
||||
try:
|
||||
polls = _res_polls(conn, watch_id)
|
||||
except Exception:
|
||||
polls_note = "polls unavailable"
|
||||
finally:
|
||||
conn.close()
|
||||
if watch is None or watch["project_id"] != project_id:
|
||||
self._send_json(404, {"error": "resident watch not found"})
|
||||
return
|
||||
self._send_json(200, dict(watch, polls=polls,
|
||||
polls_note=polls_note))
|
||||
|
||||
def _resident_update(self, user_id, project_id, watch_id):
|
||||
body, jerr = self._read_json()
|
||||
if jerr:
|
||||
self._send_json(400, {"error": jerr})
|
||||
return
|
||||
conn = self._db()
|
||||
try:
|
||||
try:
|
||||
ok, watch = _res_update(conn, watch_id, body)
|
||||
except ValueError as exc:
|
||||
self._send_json(400, {"error": str(exc)})
|
||||
return
|
||||
except Exception as exc:
|
||||
self._send_json(502, {"error": str(exc)[:500]})
|
||||
return
|
||||
finally:
|
||||
conn.close()
|
||||
if watch is None or watch["project_id"] != project_id:
|
||||
self._send_json(404, {"error": "resident watch not found"})
|
||||
return
|
||||
if not ok:
|
||||
self._send_json(409, {
|
||||
"error": "ended resident watch cannot be updated;"
|
||||
" create a new watch instead"})
|
||||
return
|
||||
with _auth_store(self.server.db_path) as store:
|
||||
store.audit(user_id, project_id, "resident_watch_updated",
|
||||
watch_id)
|
||||
self._send_json(200, watch)
|
||||
|
||||
def _resident_transition(self, user_id, project_id, watch_id, action):
|
||||
fn = {"pause": _res_pause, "resume": _res_resume,
|
||||
"end": _res_end}[action]
|
||||
conn = self._db()
|
||||
try:
|
||||
try:
|
||||
ok, watch = fn(conn, watch_id)
|
||||
except Exception as exc:
|
||||
self._send_json(502, {"error": str(exc)[:500]})
|
||||
return
|
||||
finally:
|
||||
conn.close()
|
||||
if watch is None or watch["project_id"] != project_id:
|
||||
self._send_json(404, {"error": "resident watch not found"})
|
||||
return
|
||||
if not ok:
|
||||
if watch["status"] == "ended":
|
||||
self._send_json(409, {
|
||||
"error": "ended resident watch is terminal;"
|
||||
" create a new watch instead"})
|
||||
else:
|
||||
self._send_json(409, {
|
||||
"error": "resident watch in status %s cannot %s"
|
||||
% (watch["status"], action)})
|
||||
return
|
||||
with _auth_store(self.server.db_path) as store:
|
||||
store.audit(user_id, project_id,
|
||||
"resident_watch_%s" % ("ended" if action == "end"
|
||||
else action + "d"
|
||||
if action == "pause"
|
||||
else "resumed"),
|
||||
watch_id)
|
||||
self._send_json(200, watch)
|
||||
|
||||
def _triage_inbox(self, project_id):
|
||||
"""分诊收件箱(只读):到期自动化、失败/中断任务、
|
||||
待处理后续消息、最近自动化产出。见 automations 模块
|
||||
|
||||
Reference in new issue
Block a user