1586 lines
68 KiB
Python
1586 lines
68 KiB
Python
"""常驻监听任务(resident watch)。
|
||
|
||
与现有三样东西的关系,先说清楚,避免把旧语义冒充成常驻:
|
||
|
||
- 任务状态机(cloud/server.py):queued -> running ->
|
||
completed / failed / interrupted / merged / merge-failed,
|
||
其中 /pause 只是把进行中回合任务标 interrupted 并置
|
||
paused_by_user=1(见 server._pause_task),不是一个可续跑
|
||
的“已暂停常驻态”。本模块绝不复用那条路:常驻态存在
|
||
resident_watches 表里,与任务表互不干扰。
|
||
- 云端自动化(cloud/automations.py):定义层只有 active /
|
||
cancelled,到期靠外部触发器调 run_due_automation 抢单建任务,
|
||
服务端本身不起定时器。本模块复用它的两道口径而不是另起
|
||
一套:抢单用条件 UPDATE(同一间隔内只有一个执行器能拿到
|
||
rowcount 1),配额门直接调 automations._automation_quota_
|
||
exceeded(与 server / worker 同口径,改口径时只改那一处)。
|
||
- goal 循环(host.py run_goal_loop,max_rounds=0 不限轮次):
|
||
目标是“做完一个目标”,结束条件是模型宣告达成;本模块的
|
||
目标是“一直监听外部变化”,结束条件只能是用户结束(或
|
||
配额耗尽转暂停,见下),两者不是一回事。
|
||
|
||
状态机(本模块自有,与任务状态机不冲突):
|
||
running -> paused -> running(恢复)
|
||
running / paused -> ended(终态)
|
||
ended -> 任何操作一律拒绝(恢复必须新建任务,见 end_watch)
|
||
|
||
暂停保留现场:暂停只改 status / paused_reason / next_poll_at,
|
||
last_content_hash、last_poll_at、last_change_at、last_change_text
|
||
一律不动;暂停期间 due 为 False,驱动绝不轮询。恢复时
|
||
next_poll_at 置为 now(立刻复查一次),用旧游标比对:暂停
|
||
期间网站变了,恢复后第一次轮询就会把那次变化报出来,而不是
|
||
丢掉——“从游标续跑”就是这个意思。
|
||
|
||
结束是终态:ended 后 next_poll_at 为 NULL,驱动的 due 查询
|
||
只认 status='running',所以结束后绝不会静默重启;ended 行
|
||
保留在库里供审计与查询,名字可重用(与自动化 cancelled 的
|
||
名字重用规则同款)。
|
||
|
||
变化才花钱:轮询本身是廉价抓取 + sha256 比对,不跑模型。
|
||
只有三类轮询产出:baseline(首次抓取,只建游标,不上报)、
|
||
unchanged(游标不变,不建任务)、changed(游标变了,才建
|
||
一个 queued 任务,prompt = 用户的处理提示 + 变化摘录)。
|
||
每次轮询都在 resident_polls 留一行,可查。
|
||
|
||
配额边界:配额门只在“变化要建模型任务”那一刻过,不在
|
||
轮询那一刻过(轮询不花模型额度)。配额耗尽时不结束、不丢
|
||
变化:状态转 paused、paused_reason='quota_exceeded',游标不
|
||
推进(恢复后复查仍会报出同一变化),轮询记
|
||
changed_quota_paused。配额检查本身失败(fail-closed 文本)
|
||
时不转暂停,按瞬时失败退避处理,游标同样不推进。
|
||
|
||
驱动:服务端本身不起定时器(与自动化同款结论)。真正的
|
||
驱动是 cloud/worker.py 的 run_loop 轮次边界调
|
||
process_due_resident_watches(见 worker 里的调用点),或
|
||
任何外部触发器直接调本模块的 run_due_resident_watch /
|
||
process_due_resident_watches;两者都走 claim 门,两个驱动
|
||
同时跑也不会在同一间隔内重复轮询。
|
||
|
||
S2 二选一(本批选批量总预算,不新增独立驱动进程,论证
|
||
见验证摘要):轮询间隔只是下限、不是 SLA——模型任务一轮
|
||
最长 1800 秒仍会拖后轮询,任何对外口径都必须写“尽力而
|
||
为、延迟无界”。批量总预算(拟定默认 120 秒,见
|
||
resident_config)把单批最坏耗时 bound 在“预算 + 单条超时
|
||
上限 60 秒”,剩余到期监听保持 due、下批再跑;延迟本身由
|
||
resident_metrics 的 due_count / max_overdue_seconds 度量,
|
||
进 /metrics,不靠间隔数值自证送达。独立驱动进程的运维
|
||
成本(新 unit、独立 DB 连接、启停监管)是审核第 7 节留给
|
||
用户的拍板项,本批不擅自新增;claim 门已保证未来加驱动
|
||
时多驱动不重复轮询。
|
||
|
||
本模块只依赖标准库 + cloud.automations 的复用函数
|
||
(row_get、_audit_row、_automation_quota_exceeded)与
|
||
cloud.db 的完整性错误类型(仅用于把唯一索引冲突转成
|
||
ValueError,不 import 其它 cloud 模块),避免循环 import。
|
||
"""
|
||
|
||
import hashlib
|
||
import http.client
|
||
import ipaddress
|
||
import os
|
||
import re
|
||
import socket
|
||
import sqlite3
|
||
import ssl
|
||
import time
|
||
import uuid
|
||
from urllib.parse import urljoin
|
||
from urllib.parse import urlsplit
|
||
|
||
from cloud.automations import _audit_row as _auto_audit_row
|
||
from cloud.automations import _automation_quota_exceeded
|
||
from cloud.peer_messages import row_get
|
||
from cloud.db import PostgresIntegrityError as _PgIntegrityError
|
||
|
||
MIN_INTERVAL_SECONDS = 60
|
||
MAX_INTERVAL_SECONDS = 30 * 86400
|
||
MAX_NAME_LEN = 100
|
||
MAX_PROMPT_LEN = 20000
|
||
MAX_URL_LEN = 2000
|
||
MIN_FETCH_TIMEOUT_SECONDS = 1
|
||
MAX_FETCH_TIMEOUT_SECONDS = 60
|
||
DEFAULT_FETCH_TIMEOUT_SECONDS = 10
|
||
MAX_FETCH_BYTES = 1000000
|
||
MAX_CHANGE_TEXT_LEN = 2000
|
||
MAX_POLL_DETAIL_LEN = 500
|
||
POLL_LIMIT = 50
|
||
MAX_REDIRECTS = 10
|
||
RESIDENT_USER_AGENT = "codex-app-server-resident-watch/1.0"
|
||
|
||
# P0/P1 拟定默认值(全部是拟定值、供最终验收改,不是用户已
|
||
# 拍板;数值只在这里与 resident_config() 一处定义,环境变量
|
||
# 可覆盖,见下文各 CODEX_RESIDENT_* 变量):
|
||
# - 每监听每日变化任务上限 24 个:3600 秒间隔的监听一天最多
|
||
# 24 次变化,本值即“每小时最多 1 个任务”的量级;60 秒间隔
|
||
# 的抖动页面一天最多也只建 24 个任务,超限的变化记
|
||
# changed_capped 轮询、游标不推进(次日/下次仍可报,不丢)。
|
||
# - 变化抖动冷却 300 秒:上次建任务 300 秒内的新变化记
|
||
# changed_cooldown、游标不推进,冷却过后复查仍可报。
|
||
# - polls 保留期 90 天:批量驱动每批开头 prune 一次,过期行
|
||
# 删除、未过期保留;当日上限计数只依赖当日轮询行,保留期
|
||
# 远大于 1 天,不影响上限计数。
|
||
# - 批量总预算 120 秒:见 S2 论证(模块头“驱动”一段)。
|
||
DEFAULT_MAX_DAILY_CHANGE_TASKS = 24
|
||
DEFAULT_CHANGE_COOLDOWN_SECONDS = 300
|
||
DEFAULT_POLL_RETENTION_DAYS = 90
|
||
DEFAULT_BATCH_BUDGET_SECONDS = 120
|
||
# 结构化失败原因口径(落 poll.failure_reason 列,detail 前缀
|
||
# 同样写 reason=<原因>,老读法只看 detail 也不丢信息):
|
||
# dns / proxy / timeout / http_status / ssrf_blocked / tls /
|
||
# redirect / network / invalid_url / unknown。
|
||
FAILURE_REASONS = frozenset(
|
||
{"dns", "proxy", "timeout", "http_status", "ssrf_blocked", "tls",
|
||
"redirect", "network", "invalid_url", "unknown"})
|
||
|
||
|
||
def _env_int(name, default):
|
||
"""环境变量整数:解析失败或 <=0 时回默认值,绝不因此炸掉
|
||
驱动(配置错误按默认跑,见摘要已知偏差)。"""
|
||
try:
|
||
value = int(os.environ.get(name) or default)
|
||
except (TypeError, ValueError):
|
||
return default
|
||
return value if value > 0 else default
|
||
|
||
|
||
def resident_config():
|
||
"""常驻监听的可配置口径(每次调用时读环境变量,重启与
|
||
改环境变量即生效,不需改代码;数值本身是拟定默认值):
|
||
- proxy:CODEX_RESIDENT_PROXY,未设/空/direct = 直连(默认
|
||
直连,不跟随进程 http_proxy/https_proxy);env = 显式跟随
|
||
进程环境代理(旧 urllib 默认行为,须显式 opt-in);其它值
|
||
= 显式代理 URL(http/https),no_proxy 仍读进程环境。
|
||
- max_daily_change_tasks:CODEX_RESIDENT_MAX_DAILY_CHANGE_TASKS。
|
||
- change_cooldown_seconds:CODEX_RESIDENT_CHANGE_COOLDOWN_SECONDS。
|
||
- poll_retention_days:CODEX_RESIDENT_POLL_RETENTION_DAYS。
|
||
- batch_budget_seconds:CODEX_RESIDENT_BATCH_BUDGET_SECONDS。
|
||
"""
|
||
raw = os.environ.get("CODEX_RESIDENT_PROXY")
|
||
if raw is None or not raw.strip():
|
||
proxy = {"mode": "direct", "url": None}
|
||
elif raw.strip().lower() == "direct":
|
||
proxy = {"mode": "direct", "url": None}
|
||
elif raw.strip().lower() == "env":
|
||
proxy = {"mode": "env", "url": None}
|
||
else:
|
||
proxy = {"mode": "url", "url": raw.strip()}
|
||
return {
|
||
"proxy": proxy,
|
||
"max_daily_change_tasks": _env_int(
|
||
"CODEX_RESIDENT_MAX_DAILY_CHANGE_TASKS",
|
||
DEFAULT_MAX_DAILY_CHANGE_TASKS),
|
||
"change_cooldown_seconds": _env_int(
|
||
"CODEX_RESIDENT_CHANGE_COOLDOWN_SECONDS",
|
||
DEFAULT_CHANGE_COOLDOWN_SECONDS),
|
||
"poll_retention_days": _env_int(
|
||
"CODEX_RESIDENT_POLL_RETENTION_DAYS",
|
||
DEFAULT_POLL_RETENTION_DAYS),
|
||
"batch_budget_seconds": _env_int(
|
||
"CODEX_RESIDENT_BATCH_BUDGET_SECONDS",
|
||
DEFAULT_BATCH_BUDGET_SECONDS),
|
||
}
|
||
|
||
|
||
def resolve_proxy_config(raw=None):
|
||
"""解析代理配置:非法代理 URL 在这里就抛 FetchError
|
||
(reason=proxy),调用方按结构化失败记轮询,不炸驱动。"""
|
||
cfg = resident_config()
|
||
if raw is not None:
|
||
# 显式传入的 raw 只用于测试与未来调用方:与环境变量
|
||
# 同款三态(direct/env/URL),不接受其它形态。
|
||
if raw.strip().lower() in ("direct", "env"):
|
||
return {"mode": raw.strip().lower(), "url": None}
|
||
if raw.strip():
|
||
cfg = dict(cfg)
|
||
cfg["proxy"] = {"mode": "url", "url": raw.strip()}
|
||
else:
|
||
cfg["proxy"] = {"mode": "direct", "url": None}
|
||
proxy = cfg["proxy"]
|
||
if proxy["mode"] == "url":
|
||
parts = urlsplit(proxy["url"])
|
||
if parts.scheme not in ("http", "https") or not parts.hostname:
|
||
raise FetchError(
|
||
"proxy",
|
||
"invalid resident proxy URL: %s" % proxy["url"])
|
||
return proxy
|
||
|
||
|
||
def _host_in_no_proxy(host, no_proxy_value):
|
||
"""no_proxy 匹配:精确、后缀(.example.com 匹配子域)、*
|
||
全匹配;主机名统一小写去尾点,与 validate 口径一致。"""
|
||
host = (host or "").strip().lower().rstrip(".")
|
||
for entry in (no_proxy_value or "").split(","):
|
||
entry = entry.strip().lower().rstrip(".")
|
||
if not entry:
|
||
continue
|
||
if entry == "*":
|
||
return True
|
||
if host == entry or host.endswith("." + entry):
|
||
return True
|
||
return False
|
||
|
||
|
||
def _proxy_for_target(host, scheme, proxy):
|
||
"""本跳的目标代理决策:返回 None = 直连,否则
|
||
(proxy_host, proxy_port)。env 模式读进程环境
|
||
http_proxy/https_proxy(大小写均认);url 模式用配置的
|
||
URL;两种模式都认进程 no_proxy/NO_PROXY。直连模式永远
|
||
返回 None——旧代码跟随进程代理的行为至此结束,改动见
|
||
摘要已知偏差(部署时若宿主出口必须走代理,须显式配
|
||
CODEX_RESIDENT_PROXY=env 或代理 URL)。"""
|
||
no_proxy = os.environ.get("no_proxy") or os.environ.get("NO_PROXY") or ""
|
||
if _host_in_no_proxy(host, no_proxy):
|
||
return None
|
||
if proxy["mode"] == "direct":
|
||
return None
|
||
if proxy["mode"] == "env":
|
||
if scheme == "https":
|
||
raw = os.environ.get("https_proxy") \
|
||
or os.environ.get("HTTPS_PROXY") or ""
|
||
else:
|
||
raw = os.environ.get("http_proxy") \
|
||
or os.environ.get("HTTP_PROXY") or ""
|
||
if not raw.strip():
|
||
return None
|
||
parts = urlsplit(raw.strip())
|
||
if parts.scheme not in ("http", "https") or not parts.hostname:
|
||
raise FetchError("proxy",
|
||
"invalid proxy URL in environment")
|
||
return (parts.hostname,
|
||
parts.port or (443 if parts.scheme == "https" else 80))
|
||
parts = urlsplit(proxy["url"])
|
||
return (parts.hostname,
|
||
parts.port or (443 if parts.scheme == "https" else 80))
|
||
|
||
RUNNING = "running"
|
||
PAUSED = "paused"
|
||
ENDED = "ended"
|
||
|
||
# 变化判定口径(v1):对抓取文本做空白折叠后取 sha256。
|
||
# 动态页面(广告、时间戳)本身会抖动,v1 不做选择器/区域
|
||
# 提取,抖动风险写在验证摘要的已知偏差里,不假装已解决。
|
||
_WS_RE = re.compile(r"\s+")
|
||
|
||
|
||
WATCH_COLUMNS = (
|
||
"id", "project_id", "name", "target_url", "prompt",
|
||
"interval_seconds", "fetch_timeout_seconds", "status",
|
||
"last_poll_at", "last_claimed_at", "next_poll_at",
|
||
"last_content_hash", "last_change_at", "last_change_text",
|
||
"consecutive_failures", "paused_reason", "ended_at",
|
||
"created_by", "created_at", "updated_at")
|
||
|
||
POLL_COLUMNS = (
|
||
"id", "watch_id", "project_id", "status", "observed_hash",
|
||
"detail", "task_id", "created_at", "failure_reason")
|
||
|
||
|
||
def _row_dict(row, columns):
|
||
if row is None or isinstance(row, dict):
|
||
return row
|
||
try:
|
||
row["id"]
|
||
return row
|
||
except (KeyError, IndexError, TypeError):
|
||
# sqlite3.Row 缺列报 IndexError(不是 TypeError):只有
|
||
# SELECT * 的行能原样用,单列查询(如 claim 取 interval)
|
||
# 走 zip 回落,columns 必须与 SELECT 列顺序一致。
|
||
return dict(zip(columns, row))
|
||
|
||
|
||
def _existing_columns(conn, table):
|
||
try:
|
||
return {row[1] for row in conn.execute(
|
||
"PRAGMA table_info(%s)" % table).fetchall()}
|
||
except Exception:
|
||
return set()
|
||
|
||
|
||
def ensure_resident_tables(conn):
|
||
"""幂等建常驻监听表,补齐新增列(仿 automations 写法)。
|
||
|
||
调用约定(G1):本函数只在启动/迁移/API 路径调——
|
||
server init_db、worker 首次批量、create/list/get 等 API
|
||
函数;热路径(claim / run_due / due 列表 / 批量循环)绝
|
||
不调它,批量内每条的 CREATE INDEX DDL 锁与 claim UPDATE
|
||
的锁交互正是旧双 worker 死锁的疑似根因。热路径只在
|
||
表确实缺失时(missing-table 错误)ensure 后重试一次。
|
||
"""
|
||
conn.execute(
|
||
"CREATE TABLE IF NOT EXISTS resident_watches("
|
||
"id TEXT PRIMARY KEY, "
|
||
"project_id TEXT, "
|
||
"name TEXT, "
|
||
"target_url TEXT, "
|
||
"prompt TEXT, "
|
||
"interval_seconds INTEGER, "
|
||
"fetch_timeout_seconds INTEGER, "
|
||
"status TEXT, "
|
||
"last_poll_at DOUBLE PRECISION, "
|
||
"last_claimed_at DOUBLE PRECISION, "
|
||
"next_poll_at DOUBLE PRECISION, "
|
||
"last_content_hash TEXT, "
|
||
"last_change_at DOUBLE PRECISION, "
|
||
"last_change_text TEXT, "
|
||
"consecutive_failures INTEGER, "
|
||
"paused_reason TEXT, "
|
||
"ended_at DOUBLE PRECISION, "
|
||
"created_by TEXT, "
|
||
"created_at DOUBLE PRECISION, "
|
||
"updated_at DOUBLE PRECISION)")
|
||
conn.execute(
|
||
"CREATE TABLE IF NOT EXISTS resident_polls("
|
||
"id TEXT PRIMARY KEY, "
|
||
"watch_id TEXT, "
|
||
"project_id TEXT, "
|
||
"status TEXT, "
|
||
"observed_hash TEXT, "
|
||
"detail TEXT, "
|
||
"task_id TEXT, "
|
||
"created_at DOUBLE PRECISION, "
|
||
"failure_reason TEXT)")
|
||
existing = _existing_columns(conn, "resident_watches")
|
||
for col, ddl in (
|
||
("fetch_timeout_seconds", "INTEGER"),
|
||
("next_poll_at", "DOUBLE PRECISION"),
|
||
("last_content_hash", "TEXT"),
|
||
("last_change_at", "DOUBLE PRECISION"),
|
||
("last_change_text", "TEXT"),
|
||
("consecutive_failures", "INTEGER"),
|
||
("paused_reason", "TEXT"),
|
||
("ended_at", "DOUBLE PRECISION")):
|
||
if col not in existing:
|
||
try:
|
||
conn.execute(
|
||
"ALTER TABLE resident_watches ADD COLUMN %s %s"
|
||
% (col, ddl))
|
||
except Exception:
|
||
pass
|
||
existing_polls = _existing_columns(conn, "resident_polls")
|
||
if "failure_reason" not in existing_polls:
|
||
try:
|
||
conn.execute(
|
||
"ALTER TABLE resident_polls"
|
||
" ADD COLUMN failure_reason TEXT")
|
||
except Exception:
|
||
pass
|
||
conn.execute(
|
||
"CREATE INDEX IF NOT EXISTS idx_resident_watches_project"
|
||
" ON resident_watches(project_id, status)")
|
||
# 同项目未结束名唯一:先查后插挡不住并发,唯一索引才是
|
||
# 真正的关口(server environments 的同款部分唯一索引,
|
||
# WHERE status != 'archived' 的 resident 版);已结束名
|
||
# 不在索引内,可重用。已存在重复名的旧库建索引会失败,
|
||
# 此时不炸掉整次建表调用,SELECT 查重仍挡正常路径。
|
||
try:
|
||
conn.execute(
|
||
"CREATE UNIQUE INDEX IF NOT EXISTS"
|
||
" idx_resident_watches_name"
|
||
" ON resident_watches(project_id, name)"
|
||
" WHERE status != 'ended'")
|
||
except Exception:
|
||
pass
|
||
conn.execute(
|
||
"CREATE INDEX IF NOT EXISTS idx_resident_polls_watch"
|
||
" ON resident_polls(watch_id, created_at)")
|
||
|
||
|
||
def _backoff_seconds(failures):
|
||
"""失败退避增量:30、60、120……秒,上限 3600 秒。
|
||
调用方把它加在间隔之上(下次 = now + interval + backoff),
|
||
失败后绝不比正常间隔更频繁地重试,行为见失败分支测试。"""
|
||
if failures <= 0:
|
||
return 0
|
||
return min(3600, 30 * (2 ** (failures - 1)))
|
||
|
||
|
||
def watch_to_dict(row, now=None):
|
||
row = _row_dict(row, WATCH_COLUMNS)
|
||
if now is None:
|
||
now = time.time()
|
||
status = row["status"]
|
||
nxt = row_get(row, "next_poll_at")
|
||
due = bool(status == RUNNING and (nxt is None or nxt <= now))
|
||
return {
|
||
"id": row["id"],
|
||
"project_id": row["project_id"],
|
||
"name": row["name"],
|
||
"target_url": row["target_url"],
|
||
"prompt": row["prompt"],
|
||
"interval_seconds": row["interval_seconds"],
|
||
"fetch_timeout_seconds": row_get(row, "fetch_timeout_seconds")
|
||
or DEFAULT_FETCH_TIMEOUT_SECONDS,
|
||
"status": status,
|
||
"last_poll_at": row_get(row, "last_poll_at"),
|
||
"last_claimed_at": row_get(row, "last_claimed_at"),
|
||
"next_poll_at": nxt,
|
||
"last_content_hash": row_get(row, "last_content_hash"),
|
||
"last_change_at": row_get(row, "last_change_at"),
|
||
"last_change_text": row_get(row, "last_change_text"),
|
||
"consecutive_failures": row_get(row, "consecutive_failures") or 0,
|
||
"paused_reason": row_get(row, "paused_reason"),
|
||
"ended_at": row_get(row, "ended_at"),
|
||
"created_by": row["created_by"],
|
||
"created_at": row["created_at"],
|
||
"updated_at": row_get(row, "updated_at"),
|
||
"due": due,
|
||
}
|
||
|
||
|
||
def _is_noncanonical_numeric_host(host):
|
||
"""非规范数字 IP 写法:单整数(2130706433)、十六进制
|
||
(0x7f000001)、前导零八进制(0177.0.0.1)、含十六进制段
|
||
的点分写法——urllib/socket 的 inet_aton 会把它们解析成
|
||
数字 IP,ipaddress 却认不出来。规范点分十进制(4 段、
|
||
每段 0-255、无前导零,0 本身除外)不在此列;普通域名
|
||
(含字母的标签)也不在此列。"""
|
||
if re.fullmatch(r"0[xX][0-9a-fA-F]+", host):
|
||
return True
|
||
if re.fullmatch(r"[0-9]+", host):
|
||
return True
|
||
if "." not in host:
|
||
return False
|
||
labels = host.split(".")
|
||
if not all(re.fullmatch(r"0[xX][0-9a-fA-F]+|[0-9]+", lab)
|
||
for lab in labels):
|
||
return False
|
||
if len(labels) != 4:
|
||
return True
|
||
for lab in labels:
|
||
if lab.startswith("0x") or lab.startswith("0X"):
|
||
return True
|
||
if len(lab) > 1 and lab.startswith("0"):
|
||
return True
|
||
if int(lab) > 255:
|
||
return True
|
||
return False
|
||
|
||
|
||
def validate_target_url(url):
|
||
"""目标 URL 字面校验:仅 http/https、无 userinfo、主机非空、
|
||
字面 localhost(含尾点、*.localhost)/ 私有 / 回环 /
|
||
链路本地 IP / 非规范数字 IP 写法拒绝(与
|
||
websearch._is_blocked_fetch_url 的字面 IP 口径同款)。
|
||
本函数只做字面层:创建监听时调它,域名解析到私网的
|
||
情形不在创建时拦(DNS 会变,创建时放行不等于抓取时放
|
||
行);抓取时的 DNS 级门在 default_fetch 里逐跳执行
|
||
(resolve_public_addresses + pin),那才是权威关口。
|
||
私网抓取在抓取层直接失败、不产生摘录、不建任务。"""
|
||
if not url or not isinstance(url, str) or not url.strip():
|
||
raise ValueError("target_url required")
|
||
url = url.strip()
|
||
if len(url) > MAX_URL_LEN:
|
||
raise ValueError("target_url too long")
|
||
parts = urlsplit(url)
|
||
if parts.scheme not in ("http", "https"):
|
||
raise ValueError("target_url must be http or https")
|
||
if parts.username or parts.password:
|
||
raise ValueError("target_url must not contain credentials")
|
||
host = (parts.hostname or "").strip().lower().rstrip(".")
|
||
if not host:
|
||
raise ValueError("target_url host required")
|
||
if host == "localhost" or host.endswith(".localhost"):
|
||
raise ValueError("localhost URLs are blocked")
|
||
if _is_noncanonical_numeric_host(host):
|
||
raise ValueError("non-canonical numeric IP URLs are blocked")
|
||
try:
|
||
addr = ipaddress.ip_address(host)
|
||
except ValueError:
|
||
addr = None
|
||
if addr is not None and (addr.is_private or addr.is_loopback
|
||
or addr.is_link_local or addr.is_reserved
|
||
or addr.is_multicast):
|
||
raise ValueError("private / loopback URLs are blocked")
|
||
return url
|
||
|
||
|
||
def _validate_fields(name, prompt, target_url, interval_seconds,
|
||
fetch_timeout_seconds):
|
||
if not name or not isinstance(name, str) or not name.strip():
|
||
raise ValueError("name required")
|
||
name = name.strip()
|
||
if len(name) > MAX_NAME_LEN:
|
||
raise ValueError("name too long")
|
||
if not prompt or not isinstance(prompt, str) or not prompt.strip():
|
||
raise ValueError("prompt required")
|
||
prompt = prompt.strip()
|
||
if len(prompt) > MAX_PROMPT_LEN:
|
||
raise ValueError("prompt too long")
|
||
target_url = validate_target_url(target_url)
|
||
if isinstance(interval_seconds, bool) \
|
||
or not isinstance(interval_seconds, int):
|
||
raise ValueError("interval_seconds must be an integer")
|
||
if interval_seconds < MIN_INTERVAL_SECONDS:
|
||
raise ValueError(
|
||
"interval_seconds must be at least %d" % MIN_INTERVAL_SECONDS)
|
||
if interval_seconds > MAX_INTERVAL_SECONDS:
|
||
raise ValueError("interval_seconds too large")
|
||
if fetch_timeout_seconds is None:
|
||
fetch_timeout_seconds = DEFAULT_FETCH_TIMEOUT_SECONDS
|
||
if isinstance(fetch_timeout_seconds, bool) \
|
||
or not isinstance(fetch_timeout_seconds, int):
|
||
raise ValueError("fetch_timeout_seconds must be an integer")
|
||
if not MIN_FETCH_TIMEOUT_SECONDS <= fetch_timeout_seconds \
|
||
<= MAX_FETCH_TIMEOUT_SECONDS:
|
||
raise ValueError(
|
||
"fetch_timeout_seconds must be between %d and %d"
|
||
% (MIN_FETCH_TIMEOUT_SECONDS, MAX_FETCH_TIMEOUT_SECONDS))
|
||
return name, prompt, target_url, interval_seconds, fetch_timeout_seconds
|
||
|
||
|
||
def create_watch(conn, project_id, name, target_url, prompt, created_by,
|
||
interval_seconds=3600, fetch_timeout_seconds=None,
|
||
now=None):
|
||
"""新建常驻监听:建完即到期(next_poll_at=now),驱动下
|
||
一轮即可认领;同项目内未结束(running/paused)的名字唯一,
|
||
已结束的名字可重用。创建本身不走配额门(轮询不花模型额度,
|
||
配额只在变化建任务时过,见 run_due_resident_watch)。"""
|
||
(name, prompt, target_url, interval_seconds,
|
||
fetch_timeout_seconds) = _validate_fields(
|
||
name, prompt, target_url, interval_seconds, fetch_timeout_seconds)
|
||
if now is None:
|
||
now = time.time()
|
||
ensure_resident_tables(conn)
|
||
clash = conn.execute(
|
||
"SELECT id FROM resident_watches WHERE project_id = ?"
|
||
" AND name = ? AND status != ?",
|
||
(project_id, name, ENDED)).fetchone()
|
||
if clash is not None:
|
||
raise ValueError(
|
||
"resident watch name already exists in this project")
|
||
watch_id = uuid.uuid4().hex
|
||
try:
|
||
conn.execute(
|
||
"INSERT INTO resident_watches(id, project_id, name, target_url,"
|
||
" prompt, interval_seconds, fetch_timeout_seconds, status,"
|
||
" last_poll_at, last_claimed_at, next_poll_at,"
|
||
" last_content_hash, last_change_at, last_change_text,"
|
||
" consecutive_failures, paused_reason, ended_at,"
|
||
" created_by, created_at, updated_at)"
|
||
" VALUES(?, ?, ?, ?, ?, ?, ?, ?, NULL, NULL, ?,"
|
||
" NULL, NULL, NULL, 0, NULL, NULL, ?, ?, ?)",
|
||
(watch_id, project_id, name, target_url, prompt,
|
||
interval_seconds, fetch_timeout_seconds, RUNNING, now,
|
||
created_by, now, now))
|
||
except (sqlite3.IntegrityError, _PgIntegrityError) as exc:
|
||
# 并发下 SELECT 查重与 INSERT 之间可能插进同名行,
|
||
# 唯一索引是真正的关口:冲突转回与查重同款 ValueError,
|
||
# 调用方(server)据此返回 400,不把数据库错误外泄。
|
||
try:
|
||
conn.rollback()
|
||
except Exception:
|
||
pass
|
||
raise ValueError(
|
||
"resident watch name already exists in this project") from exc
|
||
conn.commit()
|
||
row = conn.execute(
|
||
"SELECT * FROM resident_watches WHERE id = ?", (watch_id,)).fetchone()
|
||
return watch_to_dict(row, now)
|
||
|
||
|
||
def list_watches(conn, project_id, now=None):
|
||
if now is None:
|
||
now = time.time()
|
||
ensure_resident_tables(conn)
|
||
rows = conn.execute(
|
||
"SELECT * FROM resident_watches WHERE project_id = ?"
|
||
" ORDER BY created_at, id",
|
||
(project_id,)).fetchall()
|
||
return [watch_to_dict(r, now) for r in rows]
|
||
|
||
|
||
def get_watch(conn, watch_id, now=None):
|
||
if now is None:
|
||
now = time.time()
|
||
ensure_resident_tables(conn)
|
||
row = conn.execute(
|
||
"SELECT * FROM resident_watches WHERE id = ?", (watch_id,)).fetchone()
|
||
if row is None:
|
||
return None
|
||
return watch_to_dict(row, now)
|
||
|
||
|
||
def list_polls(conn, watch_id, limit=POLL_LIMIT):
|
||
ensure_resident_tables(conn)
|
||
rows = conn.execute(
|
||
"SELECT * FROM resident_polls WHERE watch_id = ?"
|
||
" ORDER BY created_at DESC, id LIMIT ?",
|
||
(watch_id, limit)).fetchall()
|
||
out = []
|
||
for raw in rows:
|
||
r = _row_dict(raw, POLL_COLUMNS)
|
||
out.append({
|
||
"id": r["id"],
|
||
"watch_id": r["watch_id"],
|
||
"project_id": r["project_id"],
|
||
"status": r["status"],
|
||
"observed_hash": row_get(r, "observed_hash"),
|
||
"detail": row_get(r, "detail") or "",
|
||
"task_id": row_get(r, "task_id"),
|
||
"created_at": r["created_at"],
|
||
"failure_reason": row_get(r, "failure_reason"),
|
||
})
|
||
return out
|
||
|
||
|
||
def _transition(conn, watch_id, from_statuses, to_status, now,
|
||
extra_sets=(), extra_vals=()):
|
||
"""条件状态迁移:返回 (ok, dict)。行不存在时 (False, None);
|
||
状态不对时 (False, 旧 dict),调用方据此区分 404 与 409。"""
|
||
ensure_resident_tables(conn)
|
||
row = conn.execute(
|
||
"SELECT * FROM resident_watches WHERE id = ?",
|
||
(watch_id,)).fetchone()
|
||
if row is None:
|
||
return False, None
|
||
row = _row_dict(row, WATCH_COLUMNS)
|
||
if row["status"] not in from_statuses:
|
||
return False, watch_to_dict(row, now)
|
||
placeholders = ",".join("?" for _ in from_statuses)
|
||
sets = ["status = ?", "updated_at = ?"] + list(extra_sets)
|
||
vals = [to_status, now] + list(extra_vals) + [watch_id] \
|
||
+ list(from_statuses)
|
||
cur = conn.execute(
|
||
"UPDATE resident_watches SET %s WHERE id = ? AND status IN (%s)"
|
||
% (", ".join(sets), placeholders), tuple(vals))
|
||
conn.commit()
|
||
if cur.rowcount != 1:
|
||
return False, watch_to_dict(row, now)
|
||
row = conn.execute(
|
||
"SELECT * FROM resident_watches WHERE id = ?", (watch_id,)).fetchone()
|
||
return True, watch_to_dict(row, now)
|
||
|
||
|
||
def pause_watch(conn, watch_id, now=None, reason="user_paused"):
|
||
"""用户暂停:仅 running 能暂停;保留全部现场,只把
|
||
next_poll_at 清掉,驱动即停。"""
|
||
if now is None:
|
||
now = time.time()
|
||
return _transition(conn, watch_id, (RUNNING,), PAUSED, now,
|
||
extra_sets=("paused_reason = ?",
|
||
"next_poll_at = NULL"),
|
||
extra_vals=(reason,))
|
||
|
||
|
||
def resume_watch(conn, watch_id, now=None):
|
||
"""恢复:仅 paused 能恢复;现场不动,next_poll_at=now
|
||
立刻复查一次(用旧游标比对,暂停期间的变化不丢)。"""
|
||
if now is None:
|
||
now = time.time()
|
||
return _transition(conn, watch_id, (PAUSED,), RUNNING, now,
|
||
extra_sets=("paused_reason = NULL",
|
||
"next_poll_at = ?"),
|
||
extra_vals=(now,))
|
||
|
||
|
||
def end_watch(conn, watch_id, now=None):
|
||
"""结束:running / paused -> ended,终态。ended 后
|
||
pause / resume / end 一律 (False, 旧 dict),调用方转 409;
|
||
恢复已结束的监听必须新建任务,不复活本行。"""
|
||
if now is None:
|
||
now = time.time()
|
||
return _transition(conn, watch_id, (RUNNING, PAUSED), ENDED, now,
|
||
extra_sets=("ended_at = ?",
|
||
"next_poll_at = NULL"),
|
||
extra_vals=(now,))
|
||
|
||
|
||
def update_watch(conn, watch_id, fields, now=None):
|
||
"""更新定义:只认 name / prompt / target_url /
|
||
interval_seconds / fetch_timeout_seconds;ended 的行 409
|
||
(终态不能改,改它必须重建);改间隔不重置游标、
|
||
不立即触发:已排好的 next_poll_at 不变,下一次轮询
|
||
完成后才按新间隔重算(想立即复查请暂停后恢复)。
|
||
改目标 URL 则强制走一次 baseline:last_content_hash
|
||
置 NULL,下一轮只建基线、不建任务——旧 hash 与新站
|
||
内容比对必然报一次假变化的旧行为至此结束;改间隔、
|
||
改提示、改名一律不重置游标(语义保留,见摘要)。"""
|
||
if now is None:
|
||
now = time.time()
|
||
ensure_resident_tables(conn)
|
||
row = conn.execute(
|
||
"SELECT * FROM resident_watches WHERE id = ?", (watch_id,)).fetchone()
|
||
if row is None:
|
||
return False, None
|
||
row = _row_dict(row, WATCH_COLUMNS)
|
||
if row["status"] == ENDED:
|
||
return False, watch_to_dict(row, now)
|
||
if not isinstance(fields, dict):
|
||
raise ValueError("fields must be an object")
|
||
if not any(k in fields for k in (
|
||
"name", "prompt", "target_url", "interval_seconds",
|
||
"fetch_timeout_seconds")):
|
||
raise ValueError("no updatable field given")
|
||
current = watch_to_dict(row, now)
|
||
(name, prompt, target_url, interval, timeout) = _validate_fields(
|
||
fields.get("name", current["name"]),
|
||
fields.get("prompt", current["prompt"]),
|
||
fields.get("target_url", current["target_url"]),
|
||
fields.get("interval_seconds", current["interval_seconds"]),
|
||
fields.get("fetch_timeout_seconds",
|
||
current["fetch_timeout_seconds"]))
|
||
if name != current["name"]:
|
||
clash = conn.execute(
|
||
"SELECT id FROM resident_watches WHERE project_id = ?"
|
||
" AND name = ? AND status != ? AND id != ?",
|
||
(row["project_id"], name, ENDED, watch_id)).fetchone()
|
||
if clash is not None:
|
||
raise ValueError(
|
||
"resident watch name already exists in this project")
|
||
target_changed = target_url != current["target_url"]
|
||
try:
|
||
if target_changed:
|
||
conn.execute(
|
||
"UPDATE resident_watches SET name = ?, prompt = ?,"
|
||
" target_url = ?, interval_seconds = ?,"
|
||
" fetch_timeout_seconds = ?, updated_at = ?,"
|
||
" last_content_hash = NULL WHERE id = ?",
|
||
(name, prompt, target_url, interval, timeout, now,
|
||
watch_id))
|
||
else:
|
||
conn.execute(
|
||
"UPDATE resident_watches SET name = ?, prompt = ?,"
|
||
" target_url = ?, interval_seconds = ?,"
|
||
" fetch_timeout_seconds = ?, updated_at = ? WHERE id = ?",
|
||
(name, prompt, target_url, interval, timeout, now,
|
||
watch_id))
|
||
except (sqlite3.IntegrityError, _PgIntegrityError) as exc:
|
||
try:
|
||
conn.rollback()
|
||
except Exception:
|
||
pass
|
||
raise ValueError(
|
||
"resident watch name already exists in this project") from exc
|
||
conn.commit()
|
||
row = conn.execute(
|
||
"SELECT * FROM resident_watches WHERE id = ?", (watch_id,)).fetchone()
|
||
return True, watch_to_dict(row, now)
|
||
|
||
|
||
def _is_missing_table_error(exc):
|
||
"""缺表错误判定(SQLite 与 PG 两种文案):热路径只在这种
|
||
错误上 ensure 后重试一次,其它错误一律不触发 DDL。"""
|
||
msg = str(exc).lower()
|
||
return "no such table" in msg or "does not exist" in msg
|
||
|
||
|
||
def due_watches(conn, now=None):
|
||
"""全部项目当前到期的 running 监听(纯读,不抢单),
|
||
供驱动列出待处理项;执行入口是 claim + run_due。
|
||
热路径不调 ensure(见 ensure_resident_tables 的约定);
|
||
表缺失时由批量入口的 fallback 统一处理。"""
|
||
if now is None:
|
||
now = time.time()
|
||
rows = conn.execute(
|
||
"SELECT * FROM resident_watches WHERE status = ?"
|
||
" AND (next_poll_at IS NULL OR next_poll_at <= ?)"
|
||
" ORDER BY next_poll_at, created_at, id",
|
||
(RUNNING, now)).fetchall()
|
||
return [watch_to_dict(r, now) for r in rows]
|
||
|
||
|
||
def _due_watches_with_fallback(conn, now):
|
||
"""批量入口的 due 列表:正常路径零 DDL;表缺失时
|
||
ensure 一次后重试,仍失败则把原始错误抛给调用方。"""
|
||
try:
|
||
return due_watches(conn, now)
|
||
except Exception as exc:
|
||
if not _is_missing_table_error(exc):
|
||
raise
|
||
try:
|
||
conn.rollback()
|
||
except Exception:
|
||
pass
|
||
ensure_resident_tables(conn)
|
||
return due_watches(conn, now)
|
||
|
||
|
||
def claim_due_watch(conn, watch_id, now=None):
|
||
"""抢单:条件 UPDATE 只有一行能成功。抢到即把
|
||
last_poll_at / last_claimed_at 写成 now,并把 next_poll_at
|
||
预置为 now + interval(崩在抓取中途时,下个间隔仍会重试,
|
||
不会因认领锁死而永久停摆;抓取完成后 run_due 会按成败
|
||
重算 next_poll_at)。抢不到时返回 False,调用方必须放弃。"""
|
||
if now is None:
|
||
now = time.time()
|
||
row = conn.execute(
|
||
"SELECT interval_seconds FROM resident_watches WHERE id = ?",
|
||
(watch_id,)).fetchone()
|
||
if row is None:
|
||
return False
|
||
interval = row_get(_row_dict(row, ("interval_seconds",)),
|
||
"interval_seconds") or MIN_INTERVAL_SECONDS
|
||
cur = conn.execute(
|
||
"UPDATE resident_watches SET last_poll_at = ?,"
|
||
" last_claimed_at = ?, next_poll_at = ?"
|
||
" WHERE id = ? AND status = ?"
|
||
" AND (next_poll_at IS NULL OR next_poll_at <= ?)",
|
||
(now, now, now + interval, watch_id, RUNNING, now))
|
||
conn.commit()
|
||
return cur.rowcount == 1
|
||
|
||
|
||
def _content_hash(text):
|
||
normalized = _WS_RE.sub(" ", text or "").strip()
|
||
return hashlib.sha256(normalized.encode("utf-8")).hexdigest()
|
||
|
||
|
||
class FetchError(ValueError):
|
||
"""结构化抓取失败:reason 见 FAILURE_REASONS,status 仅
|
||
http_status 时有值。ValueError 子类,故旧调用方按
|
||
ValueError 捕获的行为不变;run_due 据此写 failure_reason
|
||
与 reason= 前缀的 detail,不把原始内网响应写进任何记录。"""
|
||
|
||
def __init__(self, reason, message, status=None):
|
||
super().__init__(message)
|
||
self.reason = reason if reason in FAILURE_REASONS else "unknown"
|
||
self.status = status
|
||
|
||
|
||
class SSRFBlockedError(FetchError):
|
||
"""SSRF 拦截:字面或解析后地址命中内网/私有/回环/
|
||
链路本地/保留/组播/未指定地址,或非规范数字写法。"""
|
||
|
||
def __init__(self, message):
|
||
super().__init__("ssrf_blocked", message)
|
||
|
||
|
||
class HttpStatusFetchError(FetchError):
|
||
"""非 200 响应:不把错误页当成“变化”处理,也不把它
|
||
的正文写进变化任务(正文在 default_fetch 里直接丢弃)。"""
|
||
|
||
def __init__(self, status, message=None):
|
||
super().__init__(
|
||
"http_status",
|
||
message or ("HTTP status %s" % status), status=status)
|
||
|
||
|
||
def classify_fetch_error(exc):
|
||
"""异常 -> 结构化原因:FetchError 自带 reason;超时、
|
||
TLS、连接类按类型分;其余一律 unknown(不谎称是网络
|
||
错误,也不把异常文本当原因)。"""
|
||
if isinstance(exc, FetchError):
|
||
return exc.reason
|
||
if isinstance(exc, (socket.timeout, TimeoutError)):
|
||
return "timeout"
|
||
if isinstance(exc, ssl.SSLError):
|
||
return "tls"
|
||
if isinstance(exc, (ConnectionError, OSError)):
|
||
return "network"
|
||
if isinstance(exc, ValueError):
|
||
return "invalid_url"
|
||
return "unknown"
|
||
|
||
|
||
def _default_resolver(host, port):
|
||
"""默认解析器:socket.getaddrinfo,去重保序。返回 IP
|
||
字符串列表;测试可注入同签名的 resolver(见 default_fetch
|
||
的 resolver 参数——生产路径一律不传,默认即本函数)。"""
|
||
infos = socket.getaddrinfo(host, port, proto=socket.IPPROTO_TCP)
|
||
ips = []
|
||
for info in infos:
|
||
try:
|
||
ip = info[4][0]
|
||
except (IndexError, TypeError):
|
||
continue
|
||
if ip not in ips:
|
||
ips.append(ip)
|
||
return ips
|
||
|
||
|
||
def _address_is_blocked(addr):
|
||
"""单个解析地址是否命中拦截面:私有/回环/链路本地/
|
||
保留/组播/未指定;IPv6 的 IPv4 映射地址同样按映射后
|
||
的 IPv4 地址再判一次(::ffff:127.0.0.1 必须拦住)。"""
|
||
if addr.is_private or addr.is_loopback or addr.is_link_local \
|
||
or addr.is_reserved or addr.is_multicast \
|
||
or addr.is_unspecified:
|
||
return True
|
||
mapped = getattr(addr, "ipv4_mapped", None)
|
||
if mapped is not None and (
|
||
mapped.is_private or mapped.is_loopback
|
||
or mapped.is_link_local or mapped.is_reserved
|
||
or mapped.is_multicast or mapped.is_unspecified):
|
||
return True
|
||
return False
|
||
|
||
|
||
def resolve_public_addresses(host, port, resolver=None):
|
||
"""DNS 级 SSRF 门:解析全部 A/AAAA,任一地址命中拦截面
|
||
即整批拒绝(只放行“全部解析结果都是公网地址”的目标,
|
||
不搞“取第一个公网地址蒙混过关”);解析失败、空结果、
|
||
非 IP 结果一律按 dns 失败处理,不放行、不重试建连。
|
||
注意:本门只在直连模式下配合下文的 pin 才是完整封口;
|
||
代理模式下建连由代理方二次解析,见 default_fetch 的
|
||
残留风险说明。"""
|
||
try:
|
||
ips = (resolver or _default_resolver)(host, port)
|
||
except FetchError:
|
||
raise
|
||
except Exception as exc:
|
||
raise FetchError(
|
||
"dns", "DNS resolution failed for %s: %s" % (host, exc))
|
||
if not ips:
|
||
raise FetchError("dns", "DNS resolution returned no address"
|
||
" for %s" % host)
|
||
out = []
|
||
for ipstr in ips:
|
||
try:
|
||
addr = ipaddress.ip_address(str(ipstr))
|
||
except ValueError:
|
||
raise FetchError(
|
||
"dns", "DNS resolution returned non-IP %r for %s"
|
||
% (ipstr, host))
|
||
if _address_is_blocked(addr):
|
||
raise SSRFBlockedError(
|
||
"resolved address %s for %s is not public"
|
||
% (ipstr, host))
|
||
out.append(str(ipstr))
|
||
return out
|
||
|
||
|
||
class _PinnedHTTPConnection(http.client.HTTPConnection):
|
||
"""直连时 pin 住解析结果的连接:建连目标是解析出的 IP,
|
||
而不是再次按主机名解析(解析与建连之间的 TOCTOU 窗口
|
||
至此关闭);Host 头仍用原始主机名(http.client 按
|
||
self.host 生成),服务端看到的 Host 与用户填的目标一致。"""
|
||
|
||
def __init__(self, host, port=None, pinned_ips=(), timeout=None):
|
||
super().__init__(host, port, timeout=timeout)
|
||
self._pinned_ips = list(pinned_ips)
|
||
|
||
def connect(self):
|
||
last = None
|
||
for ip in self._pinned_ips:
|
||
try:
|
||
self.sock = socket.create_connection(
|
||
(ip, self.port), self.timeout)
|
||
return
|
||
except OSError as exc:
|
||
last = exc
|
||
if last is not None:
|
||
raise last
|
||
raise OSError("no pinned address for %s" % self.host)
|
||
|
||
|
||
class _PinnedHTTPSConnection(_PinnedHTTPConnection,
|
||
http.client.HTTPSConnection):
|
||
"""直连 https 的 pin 连接:先按 IP 建裸 socket,再用
|
||
默认校验上下文 wrap,server_hostname 必须是原始主机名
|
||
(不是 IP),否则 SNI 与证书主机名校验都会错——这是
|
||
“为 pin 而破坏 TLS 校验”的反模式,本实现明确不这么做:
|
||
证书仍按原始主机名校验,IP 只决定 TCP 对端。"""
|
||
|
||
def connect(self):
|
||
_PinnedHTTPConnection.connect(self)
|
||
context = ssl.create_default_context()
|
||
self.sock = context.wrap_socket(self.sock,
|
||
server_hostname=self.host)
|
||
|
||
|
||
def _fetch_single_hop(url, timeout, pinned_ips, proxy):
|
||
"""单跳抓取(不跟随重定向):返回 (status, location, raw)。
|
||
直连走 pin 连接;代理走 http.client 的代理连接(https 用
|
||
set_tunnel,CPython 按 tunnel 主机名做 SNI,与直连同款
|
||
证书主机名校验)。transport 异常由调用方按 _wrap 处理,
|
||
本函数不把异常文本加工成原因。"""
|
||
parts = urlsplit(url)
|
||
host = (parts.hostname or "").strip().lower().rstrip(".")
|
||
port = parts.port or (443 if parts.scheme == "https" else 80)
|
||
path = parts.path or "/"
|
||
if parts.query:
|
||
path = path + "?" + parts.query
|
||
headers = {"User-Agent": RESIDENT_USER_AGENT}
|
||
if proxy is None:
|
||
if parts.scheme == "https":
|
||
conn = _PinnedHTTPSConnection(host, port, pinned_ips,
|
||
timeout=timeout)
|
||
else:
|
||
conn = _PinnedHTTPConnection(host, port, pinned_ips,
|
||
timeout=timeout)
|
||
conn.request("GET", path, headers=headers)
|
||
elif parts.scheme == "https":
|
||
conn = http.client.HTTPSConnection(
|
||
proxy[0], proxy[1], timeout=timeout,
|
||
context=ssl.create_default_context())
|
||
conn.set_tunnel(host, port)
|
||
conn.request("GET", path, headers=headers)
|
||
else:
|
||
conn = http.client.HTTPConnection(proxy[0], proxy[1],
|
||
timeout=timeout)
|
||
conn.request("GET", url, headers=headers)
|
||
try:
|
||
resp = conn.getresponse()
|
||
raw = resp.read(MAX_FETCH_BYTES + 1)[:MAX_FETCH_BYTES]
|
||
return resp.status, resp.getheader("Location"), raw
|
||
finally:
|
||
try:
|
||
conn.close()
|
||
except Exception:
|
||
pass
|
||
|
||
|
||
def _wrap_transport_error(exc, via_proxy):
|
||
"""transport 异常 -> FetchError:代理模式下的连接失败记
|
||
proxy(代理本身不可达/拒绝都算代理侧失败,不算目标站
|
||
失败);直连按类型分超时/TLS/网络。"""
|
||
if isinstance(exc, FetchError):
|
||
return exc
|
||
if via_proxy and isinstance(exc, (ConnectionError, OSError,
|
||
socket.timeout, TimeoutError)):
|
||
return FetchError("proxy", "proxy connection failed: %s" % exc)
|
||
if isinstance(exc, (socket.timeout, TimeoutError)):
|
||
return FetchError("timeout", "fetch timed out: %s" % exc)
|
||
if isinstance(exc, ssl.SSLError):
|
||
return FetchError("tls", "TLS failed: %s" % exc)
|
||
if isinstance(exc, (ConnectionError, OSError)):
|
||
return FetchError("network", "fetch failed: %s" % exc)
|
||
return FetchError("unknown", "fetch failed: %s" % exc)
|
||
|
||
|
||
def default_fetch(url, timeout, resolver=None, proxy_config=None):
|
||
"""默认廉价抓取:超时可配,读满 1MB 即断,解码失败按
|
||
replace 处理;只在 200 时返回正文,其它状态一律抛
|
||
HttpStatusFetchError(错误页正文直接丢弃,绝不进入
|
||
变化判定与任务摘录)。
|
||
|
||
SSRF 封口(直连模式,完整):
|
||
1. 每跳先过 validate_target_url 的字面校验;
|
||
2. 再解析全部 A/AAAA 并过 resolve_public_addresses;
|
||
3. 建连只连解析出的 IP(pin),https 的 SNI 与证书主机
|
||
名仍是原始主机名,TLS 校验不被 pin 破坏;
|
||
4. 重定向每跳重复 1-3 步,相对跳转先 urljoin 再校验;
|
||
5. 超过 MAX_REDIRECTS 跳抛 redirect 失败。
|
||
残留风险(诚实写明,不假装已封口):
|
||
- 代理模式下:客户端的解析 gate 只是前置检查,真正建连
|
||
的是代理方、由代理方二次解析;若代理方解析结果与客
|
||
户端不一致(DNS 变化、代理方走不同解析器),pin 不生
|
||
效,须靠代理自身的 egress allowlist 兜底。生产部署若
|
||
用代理模式,必须同时给代理配内网地址拦截。
|
||
- 解析与建连之间仍有理论 TOCTOU:直连模式下 pin 已把
|
||
窗口关到“解析结果本身”这一层(建连不再二次解析),
|
||
但 DNS 记录在解析后、建连前被改写不影响本次建连目
|
||
标(目标就是解析时刻的 IP),残留的是“解析时刻的
|
||
IP 在之后被重新分配给内网用途”的极端情形,不在本次
|
||
封口承诺内。
|
||
- resolver 参数是测试注入点(与 run_due 的 fetcher 注入
|
||
同款):生产路径一律不传;传了自定义 resolver 的调用
|
||
方自行承担该解析器的可信责任。
|
||
- 代理配置默认值是直连(见 resident_config):旧代码跟
|
||
随进程 http_proxy/https_proxy 的行为已改,部署时若宿
|
||
主出口必须走代理,须显式配 CODEX_RESIDENT_PROXY=env
|
||
或代理 URL,否则公网抓取会直连失败(记 proxy/timeout/
|
||
network 失败轮询,不建任务)。
|
||
调用方可注入 fetcher 替换本函数(测试与未来选择器提取
|
||
都走注入点,不改本函数签名;注入的 fetcher 绕过本门,
|
||
生产驱动不用注入)。"""
|
||
if proxy_config is None:
|
||
proxy = resolve_proxy_config()
|
||
elif isinstance(proxy_config, dict):
|
||
proxy = resolve_proxy_config() if proxy_config.get("mode") is None \
|
||
else proxy_config
|
||
if proxy.get("mode") == "url":
|
||
resolve_proxy_config(proxy["url"])
|
||
else:
|
||
proxy = resolve_proxy_config(proxy_config)
|
||
current = url
|
||
for _hop in range(MAX_REDIRECTS + 1):
|
||
try:
|
||
current = validate_target_url(current)
|
||
except ValueError as exc:
|
||
if "blocked" in str(exc):
|
||
raise SSRFBlockedError(str(exc))
|
||
raise FetchError("invalid_url", str(exc))
|
||
parts = urlsplit(current)
|
||
host = (parts.hostname or "").strip().lower().rstrip(".")
|
||
try:
|
||
port = parts.port or (443 if parts.scheme == "https" else 80)
|
||
except ValueError:
|
||
raise FetchError("invalid_url",
|
||
"invalid port in URL: %s" % current)
|
||
ips = resolve_public_addresses(host, port, resolver=resolver)
|
||
target_proxy = _proxy_for_target(host, parts.scheme, proxy)
|
||
try:
|
||
status, location, raw = _fetch_single_hop(
|
||
current, timeout, ips, target_proxy)
|
||
except Exception as exc:
|
||
raise _wrap_transport_error(exc, target_proxy is not None)
|
||
if status in (301, 302, 303, 307, 308):
|
||
if not location:
|
||
raise FetchError(
|
||
"http_status", "redirect without Location")
|
||
current = urljoin(current, location)
|
||
continue
|
||
if status != 200:
|
||
raise HttpStatusFetchError(status)
|
||
return raw.decode("utf-8", errors="replace")
|
||
raise FetchError("redirect", "too many redirects")
|
||
|
||
|
||
def _record_poll(conn, watch_id, project_id, status, observed_hash,
|
||
detail, task_id, now, failure_reason=None):
|
||
"""记轮询行:failure_reason 是结构化失败原因(见
|
||
FAILURE_REASONS),成功/变化类轮询为 None;detail 前缀
|
||
同样写 reason=<原因>(失败类),老读法只看 detail 也不
|
||
丢原因。失败轮询的 detail 只含异常文本,绝不含抓取正文
|
||
(SSRF 拦截时根本没有正文可写,见 default_fetch)。"""
|
||
poll_id = uuid.uuid4().hex
|
||
conn.execute(
|
||
"INSERT INTO resident_polls(id, watch_id, project_id, status,"
|
||
" observed_hash, detail, task_id, created_at, failure_reason)"
|
||
" VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||
(poll_id, watch_id, project_id, status, observed_hash,
|
||
(detail or "")[:MAX_POLL_DETAIL_LEN], task_id, now,
|
||
failure_reason))
|
||
return {
|
||
"id": poll_id,
|
||
"watch_id": watch_id,
|
||
"project_id": project_id,
|
||
"status": status,
|
||
"observed_hash": observed_hash,
|
||
"detail": (detail or "")[:MAX_POLL_DETAIL_LEN],
|
||
"task_id": task_id,
|
||
"created_at": now,
|
||
"failure_reason": failure_reason,
|
||
}
|
||
|
||
|
||
def _utc_day_start(now):
|
||
"""now 所在 UTC 日的起点 epoch 秒:每日变化任务上限按
|
||
UTC 日切分(不按本地时区,避免时区与夏令时切分歧义)。"""
|
||
import datetime
|
||
dt = datetime.datetime.fromtimestamp(now, tz=datetime.timezone.utc)
|
||
start = dt.replace(hour=0, minute=0, second=0, microsecond=0)
|
||
return start.timestamp()
|
||
|
||
|
||
def _changed_tasks_today(conn, watch_id, now):
|
||
"""本 UTC 日内已建的变化任务数:只数 status='changed'
|
||
且 task_id 非空的轮询行(changed_capped / changed_cooldown /
|
||
changed_quota_paused 都不占名额)。表缺失时按 0 计(热路
|
||
径 fallback 之前已建表,正常路径不会走到这里)。"""
|
||
try:
|
||
row = conn.execute(
|
||
"SELECT COUNT(*) FROM resident_polls WHERE watch_id = ?"
|
||
" AND status = ? AND task_id IS NOT NULL AND created_at >= ?",
|
||
(watch_id, "changed", _utc_day_start(now))).fetchone()
|
||
except Exception:
|
||
return 0
|
||
try:
|
||
return int(row[0])
|
||
except (TypeError, ValueError, IndexError):
|
||
return 0
|
||
|
||
|
||
def prune_old_polls(conn, now=None, retention_days=None):
|
||
"""删过期轮询行:created_at 早于 now - 保留期的行全部删,
|
||
返回删除行数;保留期内(包括当日)的行一律不动。
|
||
本函数自己 commit(批量驱动每批开头调它,失败由调用方
|
||
兜底,不炸批量)。"""
|
||
if now is None:
|
||
now = time.time()
|
||
if retention_days is None:
|
||
retention_days = resident_config()["poll_retention_days"]
|
||
cutoff = now - retention_days * 86400
|
||
cur = conn.execute(
|
||
"DELETE FROM resident_polls WHERE created_at < ?", (cutoff,))
|
||
conn.commit()
|
||
try:
|
||
return int(cur.rowcount)
|
||
except (TypeError, ValueError):
|
||
return 0
|
||
|
||
|
||
def _create_change_task(conn, watch, excerpt, now):
|
||
"""变化建任务:列补齐口径与 automations.run_due_automation
|
||
同款;任务 prompt = 用户处理提示 + 变化摘录。"""
|
||
existing_tasks = _existing_columns(conn, "tasks")
|
||
for col, ddl in (
|
||
("executor", "TEXT"),
|
||
("model", "TEXT"),
|
||
("effort", "TEXT"),
|
||
("environment_id", "TEXT"),
|
||
("parent_task_id", "TEXT"),
|
||
("coordinator_part", "TEXT")):
|
||
if col not in existing_tasks:
|
||
try:
|
||
conn.execute(
|
||
"ALTER TABLE tasks ADD COLUMN %s %s" % (col, ddl))
|
||
except Exception:
|
||
pass
|
||
task_id = uuid.uuid4().hex
|
||
prompt = ("%s\n\n[常驻监听变化] %s %s\n变化摘录:\n%s"
|
||
% (watch["prompt"], watch["name"], watch["target_url"],
|
||
excerpt))
|
||
conn.execute(
|
||
"INSERT INTO tasks(id, project_id, prompt, status,"
|
||
" idempotency_key, depends_on, priority, created_by,"
|
||
" created_at, updated_at, last_heartbeat, executor,"
|
||
" model, effort, environment_id, parent_task_id,"
|
||
" coordinator_part)"
|
||
" VALUES(?, ?, ?, 'queued', NULL, '[]', 0, ?, ?, ?,"
|
||
" NULL, NULL, NULL, NULL, NULL, NULL, NULL)",
|
||
(task_id, watch["project_id"], prompt, watch["created_by"],
|
||
now, now))
|
||
return task_id
|
||
|
||
|
||
def _guarded_watch_update(conn, watch_id, sets, vals):
|
||
"""终局 UPDATE 带 status='running' 条件:抓取中途被用户
|
||
暂停/结束时,条件不满足、rowcount 为 0,调用方据此回滚
|
||
并放弃本轮(不建任务、不记轮询),而不是把 paused/ended
|
||
行的 next_poll_at 改回未来值。返回是否成功。"""
|
||
cur = conn.execute(
|
||
"UPDATE resident_watches SET %s WHERE id = ? AND status = ?"
|
||
% ", ".join(sets),
|
||
tuple(vals) + (watch_id, RUNNING))
|
||
return cur.rowcount == 1
|
||
|
||
|
||
def run_due_resident_watch(conn, watch_id, fetcher=None, now=None,
|
||
resolver=None):
|
||
"""单条常驻监听的驱动唯一入口:抢单 -> 廉价抓取 -> hash
|
||
比对 -> 记轮询 -> 仅变化时建任务。
|
||
|
||
抢不到返回 None(不存在、已暂停/已结束、未到期、已被
|
||
别的驱动抢走),不记轮询、不建任务。抓取失败记 failed
|
||
(failure_reason 结构化,见 classify_fetch_error)并退避,
|
||
游标不动。首次抓取记 baseline,只建游标。未变化记
|
||
unchanged,不建任务。变化时依次过三道门:每日上限
|
||
(changed_capped)、抖动冷却(changed_cooldown)、配额门
|
||
(changed_quota_paused / changed_quota_check_failed)——
|
||
前两道被拦时游标不推进(变化不丢,冷却/次日仍可报),
|
||
配额耗尽转 paused(paused_reason='quota_exceeded',游标
|
||
不推进);全部通过才建 queued 任务并推进游标。
|
||
热路径不调 ensure;抢单时表缺失才 ensure 后重试一次。
|
||
fetcher 为 None 时用 default_fetch(resolver 透传给它,
|
||
生产路径 resolver 为 None 即默认 DNS 门)。"""
|
||
if now is None:
|
||
now = time.time()
|
||
try:
|
||
claimed = claim_due_watch(conn, watch_id, now)
|
||
except Exception as exc:
|
||
if not _is_missing_table_error(exc):
|
||
raise
|
||
try:
|
||
conn.rollback()
|
||
except Exception:
|
||
pass
|
||
ensure_resident_tables(conn)
|
||
claimed = claim_due_watch(conn, watch_id, now)
|
||
if not claimed:
|
||
return None
|
||
row = conn.execute(
|
||
"SELECT * FROM resident_watches WHERE id = ?", (watch_id,)).fetchone()
|
||
if row is None:
|
||
return None
|
||
watch = watch_to_dict(row, now)
|
||
if watch["status"] != RUNNING:
|
||
# 认领后、抓取前被用户暂停/结束:不抓取、不建任务、
|
||
# 不改行(暂停/结束已把 next_poll_at 清掉,保持原样)。
|
||
return None
|
||
interval = watch["interval_seconds"]
|
||
timeout = watch["fetch_timeout_seconds"]
|
||
if fetcher is not None:
|
||
fetch = fetcher
|
||
else:
|
||
def fetch(url, timeout, _resolver=resolver):
|
||
return default_fetch(url, timeout, resolver=_resolver)
|
||
try:
|
||
text = fetch(watch["target_url"], timeout)
|
||
except Exception as exc:
|
||
reason = classify_fetch_error(exc)
|
||
failures = watch["consecutive_failures"] + 1
|
||
nxt = now + interval + _backoff_seconds(failures)
|
||
if not _guarded_watch_update(
|
||
conn, watch_id,
|
||
("consecutive_failures = ?", "next_poll_at = ?",
|
||
"updated_at = ?"),
|
||
(failures, nxt, now)):
|
||
conn.rollback()
|
||
return None
|
||
detail = "reason=%s; %s" % (reason, str(exc))
|
||
poll = _record_poll(conn, watch_id, watch["project_id"],
|
||
"failed", None, detail,
|
||
None, now, failure_reason=reason)
|
||
conn.commit()
|
||
_auto_audit_row(conn, watch["project_id"],
|
||
"resident_watch_poll_failed",
|
||
"%s: %s" % (watch_id, str(exc)[:200]), now)
|
||
return poll
|
||
if not isinstance(text, str):
|
||
text = str(text)
|
||
observed = _content_hash(text)
|
||
excerpt = text[:MAX_CHANGE_TEXT_LEN]
|
||
old_hash = watch["last_content_hash"]
|
||
if old_hash is None:
|
||
if not _guarded_watch_update(
|
||
conn, watch_id,
|
||
("last_content_hash = ?", "consecutive_failures = 0",
|
||
"next_poll_at = ?", "updated_at = ?"),
|
||
(observed, now + interval, now)):
|
||
conn.rollback()
|
||
return None
|
||
poll = _record_poll(conn, watch_id, watch["project_id"],
|
||
"baseline", observed, "first poll", None, now)
|
||
conn.commit()
|
||
return poll
|
||
if observed == old_hash:
|
||
if not _guarded_watch_update(
|
||
conn, watch_id,
|
||
("consecutive_failures = 0", "next_poll_at = ?",
|
||
"updated_at = ?"),
|
||
(now + interval, now)):
|
||
conn.rollback()
|
||
return None
|
||
poll = _record_poll(conn, watch_id, watch["project_id"],
|
||
"unchanged", observed, "", None, now)
|
||
conn.commit()
|
||
return poll
|
||
# 真实变化,先过成本上限与抖动冷却(拟定值,见模块头):
|
||
# 两道门被拦时都只记轮询、不建任务、不推进游标(变化不
|
||
# 丢:下次复查内容仍与旧游标不同,冷却过后/次日仍可报),
|
||
# next_poll_at 按正常间隔重算(不是失败退避——这不是失败)。
|
||
cfg = resident_config()
|
||
if _changed_tasks_today(conn, watch_id, now) \
|
||
>= cfg["max_daily_change_tasks"]:
|
||
if not _guarded_watch_update(
|
||
conn, watch_id,
|
||
("consecutive_failures = 0", "next_poll_at = ?",
|
||
"updated_at = ?"),
|
||
(now + interval, now)):
|
||
conn.rollback()
|
||
return None
|
||
poll = _record_poll(conn, watch_id, watch["project_id"],
|
||
"changed_capped", observed,
|
||
"daily change task cap reached", None, now)
|
||
conn.commit()
|
||
return poll
|
||
last_change_at = watch["last_change_at"]
|
||
if last_change_at is not None \
|
||
and now - last_change_at < cfg["change_cooldown_seconds"]:
|
||
if not _guarded_watch_update(
|
||
conn, watch_id,
|
||
("consecutive_failures = 0", "next_poll_at = ?",
|
||
"updated_at = ?"),
|
||
(now + interval, now)):
|
||
conn.rollback()
|
||
return None
|
||
poll = _record_poll(conn, watch_id, watch["project_id"],
|
||
"changed_cooldown", observed,
|
||
"change cooldown active", None, now)
|
||
conn.commit()
|
||
return poll
|
||
# 真实变化:配额门只在这里过。配额门契约是前缀
|
||
# (quota_check_failed: / quota_exceeded: / None),不用
|
||
# 子串判定,以免超限文本含该子串时误判。
|
||
gate = _automation_quota_exceeded(conn, watch["created_by"])
|
||
if gate is not None and not gate.startswith("quota_check_failed"):
|
||
cur = conn.execute(
|
||
"UPDATE resident_watches SET status = ?,"
|
||
" paused_reason = ?, next_poll_at = NULL, updated_at = ?"
|
||
" WHERE id = ? AND status = ?",
|
||
(PAUSED, "quota_exceeded", now, watch_id, RUNNING))
|
||
if cur.rowcount != 1:
|
||
conn.rollback()
|
||
return None
|
||
poll = _record_poll(conn, watch_id, watch["project_id"],
|
||
"changed_quota_paused", observed, gate,
|
||
None, now)
|
||
conn.commit()
|
||
_auto_audit_row(conn, watch["project_id"],
|
||
"resident_watch_paused_quota",
|
||
"%s: %s" % (watch_id, gate[:200]), now)
|
||
return poll
|
||
if gate is not None:
|
||
failures = watch["consecutive_failures"] + 1
|
||
nxt = now + interval + _backoff_seconds(failures)
|
||
if not _guarded_watch_update(
|
||
conn, watch_id,
|
||
("consecutive_failures = ?", "next_poll_at = ?",
|
||
"updated_at = ?"),
|
||
(failures, nxt, now)):
|
||
conn.rollback()
|
||
return None
|
||
poll = _record_poll(conn, watch_id, watch["project_id"],
|
||
"changed_quota_check_failed", observed, gate,
|
||
None, now)
|
||
conn.commit()
|
||
return poll
|
||
# 先受保护地推进游标,再建任务:抓取中途被暂停/结束时,
|
||
# 条件不满足即回滚,本轮不建任务、不记轮询。
|
||
if not _guarded_watch_update(
|
||
conn, watch_id,
|
||
("last_content_hash = ?", "last_change_at = ?",
|
||
"last_change_text = ?", "consecutive_failures = 0",
|
||
"next_poll_at = ?", "updated_at = ?"),
|
||
(observed, now, excerpt, now + interval, now)):
|
||
conn.rollback()
|
||
return None
|
||
task_id = _create_change_task(conn, watch, excerpt, now)
|
||
poll = _record_poll(conn, watch_id, watch["project_id"],
|
||
"changed", observed, excerpt[:MAX_POLL_DETAIL_LEN],
|
||
task_id, now)
|
||
conn.commit()
|
||
_auto_audit_row(conn, watch["project_id"],
|
||
"resident_watch_change_task_created",
|
||
"%s -> %s" % (watch_id, task_id), now)
|
||
return poll
|
||
|
||
|
||
def _is_retryable_lock_error(exc):
|
||
"""可重试的锁错误:PG 死锁/锁不可用(适配层 locked=True)
|
||
与 SQLite 的 database is locked。其它错误重试无用。"""
|
||
if getattr(exc, "locked", False):
|
||
return True
|
||
return "database is locked" in str(exc).lower()
|
||
|
||
|
||
def _run_one_isolated(conn, watch_id, fetcher, now, resolver, errors):
|
||
"""批量内单条隔离:锁错误回滚后重试一次(第一次死锁时
|
||
claim 可能已提交——重试抢不到即干净返回 None;claim 未
|
||
提交时重试可成功),仍失败则记入 errors 并继续下一条,
|
||
绝不让一条死锁 abort 整批剩余监听。非锁异常同样隔离
|
||
(抓取类失败已在 run_due 内记 failed 轮询,不会走到
|
||
这里;走到这里的是 DB/建任务类异常)。"""
|
||
for attempt in range(2):
|
||
try:
|
||
return run_due_resident_watch(conn, watch_id,
|
||
fetcher=fetcher, now=now,
|
||
resolver=resolver)
|
||
except Exception as exc:
|
||
try:
|
||
conn.rollback()
|
||
except Exception:
|
||
pass
|
||
if _is_retryable_lock_error(exc) and attempt == 0:
|
||
continue
|
||
if errors is not None:
|
||
errors.append((watch_id, str(exc)[:200]))
|
||
return None
|
||
return None
|
||
|
||
|
||
def process_due_resident_watches(conn, fetcher=None, now=None, limit=50,
|
||
budget_seconds=None,
|
||
monotonic_fn=None, resolver=None,
|
||
errors=None):
|
||
"""驱动批量入口:列出到期监听,逐条走 run_due(每条自带
|
||
claim 门,批量内重复与并发驱动都不会重复轮询)。返回
|
||
本轮实际产出的轮询记录列表(抢不到的条目不在内)。
|
||
|
||
批量总预算(S2):budget_seconds 缺省取拟定默认值
|
||
(resident_config,120 秒);每条开始前检查单调时钟,
|
||
elapsed >= 预算即停——剩余到期监听保持 due 状态,下批
|
||
再跑,不丢。预算不含“正在跑的最后一条”:最坏单批耗时
|
||
= 预算 + 单条超时上限(60 秒),相对旧的 50×60=3000 秒
|
||
已有界;单条超时本身仍由 fetch_timeout_seconds 卡。
|
||
每批开头 prune 一次过期轮询(失败只回滚、不炸批量)。
|
||
errors 为调用方传入的列表时,逐条隔离的异常与预算耗
|
||
尽信号(watch_id=None 的条目)记入其中,供 worker 记
|
||
日志;不传时静默丢弃(旧调用方行为不变)。"""
|
||
if now is None:
|
||
now = time.time()
|
||
if budget_seconds is None:
|
||
budget_seconds = resident_config()["batch_budget_seconds"]
|
||
mono = monotonic_fn or time.monotonic
|
||
started = mono()
|
||
due = _due_watches_with_fallback(conn, now)[:limit]
|
||
try:
|
||
prune_old_polls(conn, now=now)
|
||
except Exception:
|
||
try:
|
||
conn.rollback()
|
||
except Exception:
|
||
pass
|
||
out = []
|
||
for watch in due:
|
||
if budget_seconds is not None \
|
||
and mono() - started >= budget_seconds:
|
||
if errors is not None:
|
||
errors.append(
|
||
(None, "batch_budget_exceeded after %d polls"
|
||
% len(out)))
|
||
break
|
||
poll = _run_one_isolated(conn, watch["id"], fetcher, now,
|
||
resolver, errors)
|
||
if poll is not None:
|
||
out.append(poll)
|
||
return out
|
||
|
||
|
||
def resident_metrics(conn, project_ids, now=None):
|
||
"""resident 可观测口径(供 /metrics 用,只读不建表):
|
||
返回 dict,表缺失时 {"available": False}(老库未跑过
|
||
init_db/ensure 时不炸 /metrics)。字段:
|
||
- watches_by_status:限定项目内的监听状态计数;
|
||
- due_count:running 且已到期(next_poll_at <= now)的条数;
|
||
- max_overdue_seconds:到期监听的最大逾期秒数(无到期为 0);
|
||
- polls_last_24h / failed_last_24h / changed_last_24h /
|
||
change_tasks_last_24h:近 24h 轮询、失败、变化、变化任务数;
|
||
- config:当前拟定配置值(见 resident_config)。
|
||
延迟口径说明:due_count 与 max_overdue_seconds 直接度量
|
||
“到期未跑”的积压——驱动被模型派发拖住时,这两项会如
|
||
实变大,不存在“间隔写 60 秒就等于 60 秒送达”的谎言。"""
|
||
if now is None:
|
||
now = time.time()
|
||
ids = list(project_ids or [])
|
||
if not ids:
|
||
return {"available": True, "watches_by_status": {},
|
||
"due_count": 0, "max_overdue_seconds": 0,
|
||
"polls_last_24h": 0, "failed_last_24h": 0,
|
||
"changed_last_24h": 0, "change_tasks_last_24h": 0,
|
||
"config": resident_config()}
|
||
placeholders = ",".join("?" for _ in ids)
|
||
try:
|
||
watch_rows = conn.execute(
|
||
"SELECT status, next_poll_at FROM resident_watches"
|
||
" WHERE project_id IN (%s)" % placeholders,
|
||
tuple(ids)).fetchall()
|
||
except Exception:
|
||
return {"available": False}
|
||
by_status = {}
|
||
due_count = 0
|
||
max_overdue = 0
|
||
for row in watch_rows:
|
||
try:
|
||
status = row["status"]
|
||
nxt = row["next_poll_at"]
|
||
except (KeyError, IndexError, TypeError):
|
||
status, nxt = row[0], row[1]
|
||
by_status[status] = by_status.get(status, 0) + 1
|
||
if status == RUNNING and nxt is not None and nxt <= now:
|
||
due_count += 1
|
||
max_overdue = max(max_overdue, now - nxt)
|
||
try:
|
||
poll_rows = conn.execute(
|
||
"SELECT status, task_id FROM resident_polls"
|
||
" WHERE project_id IN (%s) AND created_at >= ?"
|
||
% placeholders,
|
||
tuple(ids) + (now - 86400,)).fetchall()
|
||
except Exception:
|
||
poll_rows = []
|
||
polls_24h = len(poll_rows)
|
||
failed_24h = 0
|
||
changed_24h = 0
|
||
tasks_24h = 0
|
||
for row in poll_rows:
|
||
try:
|
||
status = row["status"]
|
||
task_id = row["task_id"]
|
||
except (KeyError, IndexError, TypeError):
|
||
status, task_id = row[0], row[1]
|
||
if status == "failed":
|
||
failed_24h += 1
|
||
if status == "changed":
|
||
changed_24h += 1
|
||
if task_id:
|
||
tasks_24h += 1
|
||
return {"available": True, "watches_by_status": by_status,
|
||
"due_count": due_count,
|
||
"max_overdue_seconds": max_overdue,
|
||
"polls_last_24h": polls_24h, "failed_last_24h": failed_24h,
|
||
"changed_last_24h": changed_24h,
|
||
"change_tasks_last_24h": tasks_24h,
|
||
"config": resident_config()}
|