feat(chv): 对齐 Muse P11 用量计量与配额执行——四项计量 + 三档档位门 + 哈希链账本
- cloud/usage.py(新):TIER_LIMITS 三档提案(Free $0/Paid $19/Heavy $99, 提案非定价,payload 带 proposal 标注)、TierStore(无档位行不受档位门 约束)、UsageLedger(追加写哈希链,篡改可发现)、UsageMeter(token 读 cost_records、消息读账本、并发/存储注入实测)、QuotaGate(纯准入判定, 402,不碰在跑任务)、80% 提醒每维度每窗口一次 - cloud/server.py:_reject_if_over_quota 内加档位门(同一漏斗,双人门 自动覆盖;查询异常 fail-closed 503)、GET /users/me/usage、 GET/POST /admin/tiers、存储实测复用 _task_worktree_for_files - docs/chv-muse-p11-gap.md:差距表(先写表再动手)+ 诚实边界(USD 路径 30 秒轮询掐断与“不掐断”语义不一致是已知待决;并发即拒与设计文档 排队不同;cloud 任务存储量不到;Heavy 消息/天提案未给,本轨定不限) - 测试 22/22,全量 1889 OK(skipped=8);mypy 在 usage.py 上干净, server.py 5 错均为基线旧错(marker_tools/sub2api/_row_get,与本轨无关) - 真机验证排队在 round-11 与 P4~P9 之后;不接真实计费
This commit is contained in:
1 parent
06c3e172fe
commit
60c371d9d5
5 files changed
+1474
No files matched your search
+183
@@ -62,6 +62,7 @@ from cloud.auth import AuthStore
|
||||
from cloud.coordinator import parse_parts as _coord_parse_parts
|
||||
from cloud.coordinator import validate_parts as _coord_validate_parts
|
||||
from cloud.cost import CostStore, unpriced_token_cap
|
||||
from cloud.usage import QuotaGate, UsageMeter, disk_usage_bytes
|
||||
from cloud.db import connect as _db_connect
|
||||
from cloud.db import create_database as _create_database
|
||||
from cloud.db import init_postgres_schema as _init_postgres_schema
|
||||
@@ -515,6 +516,52 @@ def _task_worktree_for_files(server, task):
|
||||
return None, (404, "no local worktree for this task")
|
||||
|
||||
|
||||
def _measure_user_storage_bytes(server, user_id):
|
||||
"""Per-user storage measurement (P11): bytes, measured, total.
|
||||
|
||||
Sums each of the user's tasks' local worktrees (via
|
||||
``_task_worktree_for_files``) plus the user's task attachment
|
||||
sizes from the DB. Cloud / cloud-hypervisor tasks have no local
|
||||
worktree and are not measurable on this host: they count in
|
||||
``total`` but not in ``measured``, so callers can say so
|
||||
honestly instead of pretending the number is complete.
|
||||
"""
|
||||
conn = _connect(server.db_path)
|
||||
conn.row_factory = sqlite3.Row
|
||||
tasks = []
|
||||
try:
|
||||
rows = conn.execute(
|
||||
"SELECT id, project_id, executor, worktree_host"
|
||||
" FROM tasks WHERE created_by = ?",
|
||||
(user_id,)).fetchall()
|
||||
tasks = [dict(row) for row in rows]
|
||||
finally:
|
||||
conn.close()
|
||||
total = len(tasks)
|
||||
measured = 0
|
||||
size = 0
|
||||
for task in tasks:
|
||||
worktree, _err = _task_worktree_for_files(server, task)
|
||||
if worktree is None:
|
||||
continue
|
||||
measured += 1
|
||||
size += disk_usage_bytes(worktree)
|
||||
try:
|
||||
conn = _connect(server.db_path)
|
||||
try:
|
||||
row = conn.execute(
|
||||
"SELECT COALESCE(SUM(a.size), 0) FROM task_attachments a"
|
||||
" JOIN tasks t ON a.task_id = t.id"
|
||||
" WHERE t.created_by = ?",
|
||||
(user_id,)).fetchone()
|
||||
size += int(row[0] or 0)
|
||||
finally:
|
||||
conn.close()
|
||||
except Exception:
|
||||
pass
|
||||
return size, measured, total
|
||||
|
||||
|
||||
def _safe_worktree_path(worktree, rel):
|
||||
"""worktree 内相对路径的安全解析:空串返回 worktree 本身;
|
||||
绝对路径、.. 逃逸、符号链接逃逸一律返回 None。realpath 后
|
||||
@@ -2294,6 +2341,8 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler):
|
||||
self._send_json(404, {"error": "not found"})
|
||||
elif path == "/users/me/quota" and method == "GET":
|
||||
self._user_quota_self(user_id)
|
||||
elif path == "/users/me/usage" and method == "GET":
|
||||
self._user_usage_self(user_id)
|
||||
elif path == "/users/me/profile" and method == "GET":
|
||||
self._identity_profile_get(user_id)
|
||||
elif path == "/users/me/profile" and method in ("PUT", "POST"):
|
||||
@@ -2336,6 +2385,10 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler):
|
||||
self._admin_quota_get(user_id, query)
|
||||
elif path == "/admin/quotas" and method == "POST":
|
||||
self._admin_quota_set(user_id)
|
||||
elif path == "/admin/tiers" and method == "GET":
|
||||
self._admin_tiers_get(user_id, query)
|
||||
elif path == "/admin/tiers" and method == "POST":
|
||||
self._admin_tiers_set(user_id)
|
||||
elif path == "/admin/push/dispatch" and method == "POST":
|
||||
self._push_dispatch_endpoint(user_id)
|
||||
elif path.startswith("/tasks/"):
|
||||
@@ -8326,6 +8379,16 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler):
|
||||
"unpriced_token_cap": cap,
|
||||
"unpriced_tokens_used": used})
|
||||
return True
|
||||
try:
|
||||
denied = self._tier_gate(quota_user_id or user_id)
|
||||
except Exception:
|
||||
self._send_json(503, {"error": "quota_check_unavailable",
|
||||
"detail": "tier quota lookup failed; "
|
||||
"no task was created or queued"})
|
||||
return True
|
||||
if denied is not None:
|
||||
self._send_json(402, denied)
|
||||
return True
|
||||
return False
|
||||
|
||||
def _reject_if_over_quota_for_task(self, caller_id, task):
|
||||
@@ -8417,6 +8480,126 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler):
|
||||
payload["unpriced_token_cap"] = unpriced_token_cap()
|
||||
self._send_json(200, payload)
|
||||
|
||||
def _tier_running_tasks(self, user_id):
|
||||
"""该用户正在跑的任务数(queued / running),档位门用。"""
|
||||
conn = self._db()
|
||||
try:
|
||||
row = conn.execute(
|
||||
"SELECT COUNT(*) FROM tasks WHERE created_by = ?"
|
||||
" AND status IN ('queued', 'running')",
|
||||
(user_id,)).fetchone()
|
||||
return int(row[0] or 0)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def _tier_meter(self, user_id):
|
||||
"""接好两个实测器的 UsageMeter;存储数只测一次。
|
||||
|
||||
存储实测(磁盘 walk)比较贵:这里先测一次,把字节数与
|
||||
任务计数都交给 meter 的注入函数与 payload,避免一次
|
||||
请求走两遍磁盘。
|
||||
"""
|
||||
measured = {}
|
||||
|
||||
def _storage(uid):
|
||||
if uid not in measured:
|
||||
size, n_measured, n_total = (
|
||||
_measure_user_storage_bytes(self.server, uid))
|
||||
measured[uid] = (size, n_measured, n_total)
|
||||
return measured[uid][0]
|
||||
|
||||
meter = UsageMeter(
|
||||
self.server.db_path,
|
||||
running_task_count=self._tier_running_tasks,
|
||||
storage_bytes=_storage)
|
||||
return meter, measured
|
||||
|
||||
def _tier_gate(self, user_id):
|
||||
"""档位准入:无档位行放行;有则查 token/并发/存储三门。
|
||||
|
||||
返回 None 放行,否则否决 dict(由调用方回 402)。
|
||||
"""
|
||||
meter, _measured = self._tier_meter(user_id)
|
||||
return QuotaGate(meter).check_task_admission(user_id)
|
||||
|
||||
def _user_usage_self(self, user_id):
|
||||
"""GET /users/me/usage:当期用量、剩余额度、提醒、账本校验。
|
||||
|
||||
USD 配额沿用既有 _quota_status 口径并排返回;四项档位
|
||||
用量走 UsageMeter。未定档用户 limits 为 None,只看到
|
||||
用量,看不到档位剩余额度——定档是管理员显式操作。
|
||||
"""
|
||||
meter, measured = self._tier_meter(user_id)
|
||||
payload = meter.summary(user_id)
|
||||
quota, used, month = self._quota_status(user_id)
|
||||
payload["quota_usd_micros"] = quota
|
||||
payload["used_usd_micros"] = used
|
||||
payload["unpriced_tokens_used"] = self._quota_unpriced_tokens(
|
||||
user_id, month)
|
||||
payload["unpriced_token_cap"] = unpriced_token_cap()
|
||||
_size, n_measured, n_total = measured.get(
|
||||
user_id, (payload["used"]["storage_bytes"], 0, 0))
|
||||
payload["storage_measured_tasks"] = n_measured
|
||||
payload["storage_total_tasks"] = n_total
|
||||
self._send_json(200, payload)
|
||||
|
||||
def _admin_tiers_get(self, user_id, query):
|
||||
if not self._is_global_admin(user_id):
|
||||
self._audit_admin(user_id, "admin_tiers_denied",
|
||||
"get tiers")
|
||||
self._send_json(403, {"error": "forbidden"})
|
||||
return
|
||||
target = (query.get("user_id") or [None])[0]
|
||||
if not target or not isinstance(target, str):
|
||||
self._send_json(400, {"error": "user_id required"})
|
||||
return
|
||||
if not self._user_exists(target):
|
||||
self._send_json(404, {"error": "user not found"})
|
||||
return
|
||||
meter = UsageMeter(self.server.db_path)
|
||||
self._send_json(200, {
|
||||
"proposal": True,
|
||||
"user_id": target,
|
||||
"tier": meter.tiers.get_tier(target),
|
||||
"limits": meter.tiers.limits_for(target)})
|
||||
|
||||
def _admin_tiers_set(self, user_id):
|
||||
if not self._is_global_admin(user_id):
|
||||
self._audit_admin(user_id, "admin_tiers_denied",
|
||||
"set tiers")
|
||||
self._send_json(403, {"error": "forbidden"})
|
||||
return
|
||||
body, err = self._read_json()
|
||||
if err:
|
||||
self._send_json(400, {"error": err})
|
||||
return
|
||||
target = body.get("user_id")
|
||||
if not target or not isinstance(target, str):
|
||||
self._send_json(400, {"error": "user_id required"})
|
||||
return
|
||||
if not self._user_exists(target):
|
||||
self._send_json(404, {"error": "user not found"})
|
||||
return
|
||||
tier = body.get("tier")
|
||||
meter = UsageMeter(self.server.db_path)
|
||||
if tier is None:
|
||||
meter.tiers.clear_tier(target)
|
||||
elif not isinstance(tier, str):
|
||||
self._send_json(400, {"error": "tier must be a string"})
|
||||
return
|
||||
else:
|
||||
try:
|
||||
meter.tiers.set_tier(target, tier)
|
||||
except ValueError:
|
||||
self._send_json(400, {
|
||||
"error": "unknown tier (want free, paid, heavy)"})
|
||||
return
|
||||
self._send_json(200, {
|
||||
"proposal": True,
|
||||
"user_id": target,
|
||||
"tier": meter.tiers.get_tier(target),
|
||||
"limits": meter.tiers.limits_for(target)})
|
||||
|
||||
def _admin_quota_get(self, user_id, query):
|
||||
if not self._is_global_admin(user_id):
|
||||
self._audit_admin(user_id, "admin_quota_denied",
|
||||
|
||||
Reference in new issue
Block a user