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:
host committed 2026-11-01 19:01:39 +00:00
1 parent 622c51e7fb
commit 0722ca3fd8
6 files changed
+1516

No files matched your search

+187
View File
@@ -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 模块