(cherry picked from commit 633cdbc94e7a7b0c5d3b0b135189faf3de6673f4) Reconcile-port: handler_admin.py hunks redirected into cloud/server.py _audit_scorecard (target keeps 6-mixin split; scorecard module itself applied cleanly).
9208 lines
420 KiB
Python
9208 lines
420 KiB
Python
"""云端控制面 HTTP server(首版)。
|
||
|
||
零第三方依赖,只用 http.server + sqlite3 + 标准库。
|
||
|
||
分工与边界(以实际代码为准,不夸大):
|
||
- 本 server 只做控制面:项目/任务的创建、查询、取消、心跳、
|
||
stale 任务回收,以及 GitHub/Slack webhook 的验签与解析。
|
||
- 本版不直接跑模型回合。任务状态机与 host.py 对齐:
|
||
queued -> running -> completed/failed/interrupted/merged/merge-failed,
|
||
其中 completed/merged 由 worker/控制面后续调用方更新(例如 worker
|
||
调 host.py 跑完回合后回写),本 server 不提供“跑模型”端点。
|
||
- 其它已就绪模块(queue 的 order_tasks/ready_tasks、sandbox 的执行器、
|
||
memory 的 MemoryStore、kb 的 KBStore、models 的 ModelRouter、
|
||
cost 的 CostStore、webhooks 的 NotificationService)本版尚未接成
|
||
HTTP 端点,留给 worker 接线时调用;本 server 只接入其中两处:
|
||
AuthStore(认证/权限/审计)与 webhooks 的验签/解析函数。
|
||
- DockerExecutor 已接通(见 sandbox.py),CloudExecutor 目前仍是占位
|
||
(见 sandbox.py),本 server 不假装已接通云端执行。
|
||
|
||
导入规则:只用绝对包导入(from cloud.auth import ...)。绝不要把
|
||
cloud/ 目录加进 sys.path:标准库已有 queue 模块,会和 cloud/queue.py
|
||
撞名,导致 stdlib queue 被错误模块覆盖。
|
||
|
||
状态库:单一 SQLite 文件。AuthStore 用 users/project_members/
|
||
audit_log 三张表;本 server 用 projects/tasks 两张表,表名不冲突。
|
||
并发:ThreadingHTTPServer 多线程处理请求,每个请求内新建 SQLite
|
||
连接(server 表用新连接,AuthStore 每次请求新建一个实例),绝不在
|
||
多线程 handler 间共用一个连接。
|
||
|
||
启动:
|
||
python3 -m cloud.server [--host 127.0.0.1] [--port 8765] [--db <路径>]
|
||
后端由 CLOUD_DB_BACKEND 选(sqlite 默认;postgres 已接通,
|
||
驱动缺失时启动即明确报错,见 cloud/db.py)。
|
||
|
||
测试用 create_server(db_path, host, port) 只返回 ThreadingHTTPServer
|
||
实例,不自动 serve。
|
||
"""
|
||
|
||
import argparse
|
||
import hashlib
|
||
import json
|
||
import logging
|
||
import os
|
||
import re
|
||
import shutil
|
||
import socket
|
||
import sqlite3
|
||
import subprocess
|
||
import sys
|
||
import threading
|
||
import time
|
||
import uuid
|
||
from collections import deque
|
||
from decimal import Decimal
|
||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||
from urllib.parse import parse_qs, urlparse
|
||
|
||
from cloud.coordinator import validate_parts as _coord_validate_parts
|
||
from cloud.cost import CostStore
|
||
from cloud.cost import unpriced_token_cap
|
||
from cloud.gates import merge_state_decision as _merge_state_decision
|
||
from cloud.gates import resolve_inside as _gate_resolve_inside
|
||
from cloud.ledger import export_chain as _ledger_export
|
||
from cloud.ledger import verify_chain as _ledger_verify
|
||
from cloud.quota import decide as _quota_decide
|
||
from cloud.quota import monthly_usage as _quota_monthly_usage
|
||
from cloud.quota import quota_for_owner as _quota_for_owner
|
||
from cloud.timeouts import STALE_SWEEP_SECONDS as _STALE_SWEEP
|
||
from cloud.timeouts import approval_default_seconds as _approval_default
|
||
from cloud.timeouts import validate_server_timeout as _validate_timeout
|
||
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
|
||
from cloud.db import PostgresIntegrityError as _PgIntegrityError
|
||
from cloud.db import PostgresOperationalError as _PgOperationalError
|
||
from cloud.goal_command import parse_goal_command as _parse_goal_command
|
||
from cloud.peer_messages import deliver_peer_message as _deliver_peer_message
|
||
from cloud.peer_messages import ensure_followups_table as _peer_ensure_followups
|
||
from cloud.peer_messages import peer_message_prefix as _peer_prefix
|
||
from cloud.peer_messages import row_get as _row_get
|
||
from cloud.marker_tools import parse_spawn_defaults as _parse_spawn_defaults
|
||
from cloud.evidence_binding import EvidenceNonceStore as _EvidenceNonceStore
|
||
from cloud.evidence_binding import binding_presence as _evidence_presence
|
||
from cloud.evidence_binding import validate_bound_evidence as _validate_evidence
|
||
from cloud.merge_gate import protection_from_env as _merge_gate_protection
|
||
from cloud.audit_wiring import pushback_block_for_task as _pushback_block
|
||
from cloud.audit_loop_wiring import build_quota_check as _loop_quota_check
|
||
from cloud.audit_loop_wiring import cost_threshold_from_env as _loop_cost_threshold
|
||
from cloud.audit_loop_wiring import evaluate_release_gate as _release_gate_evaluate
|
||
from cloud.audit_loop_wiring import process_audit_verdict as _loop_verdict
|
||
from cloud.audit_loop_wiring import process_correction as _loop_correction
|
||
from cloud.audit_loop_wiring import process_fix_completion as _loop_fix
|
||
from cloud.audit_loop_wiring import process_rebuild_confirmation as _loop_confirm
|
||
from cloud.audit_loop_wiring import state_to_dict as _loop_state_to_dict
|
||
from cloud.audit_loop_wiring import load_loop_state as _loop_load_state
|
||
from cloud.ledger import LedgerStore as _LedgerStore
|
||
from cloud.merge_gate_hard import blocked_audit_detail as _gate_blocked_detail
|
||
from cloud.merge_gate_hard import evaluate_hard_merge_gate as _merge_gate_hard
|
||
from cloud.tree import DEFAULT_MAX_DEPTH as _TREE_DEFAULT_DEPTH
|
||
from cloud.tree import DEFAULT_MAX_ENTRIES as _TREE_DEFAULT_ENTRIES
|
||
from cloud.tree import build_tree as _build_tree
|
||
from cloud.lifecycle import add_ref as _life_add_ref
|
||
from cloud.lifecycle import create_snapshot as _life_snapshot
|
||
from cloud.lifecycle import delete_ref as _life_delete_ref
|
||
from cloud.lifecycle import latest_snapshot as _life_latest_snapshot
|
||
from cloud.lifecycle import list_refs as _life_list_refs
|
||
from cloud.lifecycle import list_snapshots as _life_list_snapshots
|
||
from cloud.lifecycle import mark_restored as _life_mark_restored
|
||
from cloud.wait_ops import build_pr_diagnostics as _wait_build_diag
|
||
from cloud.wait_ops import create_handoff as _handoff_create
|
||
from cloud.wait_ops import decode_wait_cursor as _wait_decode_cursor
|
||
from cloud.wait_ops import encode_wait_cursor as _wait_encode_cursor
|
||
from cloud.wait_ops import poll_for_first_change as _wait_poll_first
|
||
from cloud.wait_ops import significant_now as _wait_significant_now
|
||
from cloud.wait_ops import get_handoff as _handoff_get
|
||
from cloud.wait_ops import interruptible_sleep as _wait_sleep
|
||
from cloud.wait_ops import latest_handoff as _handoff_latest
|
||
from cloud.wait_ops import poll_task_statuses as _wait_poll # noqa: F401
|
||
# poll_task_statuses 保留给“等全部终态”的调用方与单测;
|
||
# /wait 本身走首变轮询(_wait_poll_first),见 _task_wait。
|
||
from cloud.wait_ops import report_handoff as _handoff_report
|
||
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.resident import create_watch as _res_create
|
||
from cloud.resident import end_watch as _res_end
|
||
from cloud.resident import ensure_resident_tables as _res_ensure
|
||
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 resident_metrics as _res_metrics
|
||
from cloud.resident import resume_watch as _res_resume
|
||
from cloud.resident import update_watch as _res_update
|
||
from cloud.approval_timeout import approval_timeout_stats as _appr_timeout_stats
|
||
from cloud.approval_timeout import evaluate_approval_timeout as _appr_timeout_eval
|
||
from cloud.approval_timeout import recommend_approval_timeout as _appr_timeout_recommend
|
||
from cloud.approvals import create_approval as _appr_create
|
||
from cloud.approvals import decide_approval as _appr_decide
|
||
from cloud.approvals import ensure_approvals_table as _appr_ensure
|
||
from cloud.approvals import expire_overdue as _appr_expire
|
||
from cloud.approvals import get_approval as _appr_get
|
||
from cloud.approvals import list_approvals as _appr_list
|
||
from cloud.attempt_ledger import aggregate_attempts as _aggregate_attempts
|
||
from cloud.attempt_ledger import apply_to_records as _apply_attempts
|
||
from cloud.attempt_ledger import read_attempt_rows as _read_attempt_rows
|
||
from cloud.metrics import build_report as _build_metrics_report
|
||
from cloud.metrics import compute_metrics as _compute_metrics
|
||
from cloud.pr import PrSkipped, PrStepError
|
||
from cloud.uploads import (
|
||
BUNDLE_TTL_SECONDS,
|
||
MAX_RAW_BYTES,
|
||
parse_multipart,
|
||
parse_paths_field,
|
||
save_bundle,
|
||
upload_root as _upload_root,
|
||
validate_bundle,
|
||
)
|
||
from cloud.pr_exec import (enqueue_pr_job, execute_task_pr,
|
||
find_open_pr_job)
|
||
from cloud.workers import (
|
||
heartbeat_worker as _registry_heartbeat,
|
||
list_workers as _registry_list,
|
||
)
|
||
from cloud.sub2api import Sub2apiError as _Sub2apiError
|
||
from cloud.sub2api import ensure_gateway_keys_table as _ensure_gateway_keys
|
||
from cloud.sub2api import gateway_name as _gateway_name
|
||
from cloud.sub2api import rotate_user_gateway_key as _rotate_sub2api_key
|
||
from cloud.sub2api import validate_or_rotate_gateway_key as _validate_sub2api_key
|
||
from cloud.push import (
|
||
dispatch_event as _push_dispatch,
|
||
list_subscriptions as _push_list,
|
||
)
|
||
from cloud.github_app import (
|
||
claim_fix_review,
|
||
get_app_config,
|
||
parse_installation_event,
|
||
record_installation_repos,
|
||
remove_installation,
|
||
remove_installation_repos,
|
||
set_fix_review_task_id,
|
||
)
|
||
from cloud.webhooks import (
|
||
WebhookDeliveryStore,
|
||
is_trusted_fix_review_author,
|
||
parse_github_check_run,
|
||
parse_github_pr_comment,
|
||
parse_slack_command,
|
||
verify_github_signature_any,
|
||
verify_slack_signature,
|
||
)
|
||
from cloud.handler_support import (
|
||
_apply_default_quota as _apply_default_quota,
|
||
_auth_store as _auth_store,
|
||
_default_quota_usd_micros as _default_quota_usd_micros,
|
||
_ensure_followups_table as _ensure_followups_table,
|
||
_ensure_quotas_table as _ensure_quotas_table,
|
||
_env_int as _env_int,
|
||
_environment_to_dict as _environment_to_dict,
|
||
_global_admin_ids as _global_admin_ids,
|
||
_task_to_dict as _task_to_dict,
|
||
MAX_PROJECTS_PER_USER as MAX_PROJECTS_PER_USER,
|
||
MAX_PROJECT_ID_LEN as MAX_PROJECT_ID_LEN,
|
||
MAX_PROJECT_NAME_LEN as MAX_PROJECT_NAME_LEN,
|
||
)
|
||
from cloud.handler_project import _validate_env_branch as _validate_env_branch # noqa: F401 # tests import it from cloud.server
|
||
from cloud.handler_oauth import OAuthMixin
|
||
from cloud.handler_identity import IdentityMemoryMixin
|
||
from cloud.handler_project import ProjectMixin
|
||
from cloud.handler_automation import AutomationMixin
|
||
from cloud.handler_fullauto import FullAutoMixin
|
||
|
||
# PR 事件推送失败时记日志(systemd 下进 journal);handler 的
|
||
# log_message 被重写为静默(测试输出干净),所以不用它。
|
||
_server_log = logging.getLogger("cloud.server")
|
||
|
||
TERMINAL_STATUSES = frozenset(
|
||
{"completed", "failed", "interrupted", "merged", "merge-failed"}
|
||
)
|
||
# 线程改操作(rename/archive/unarchive/compact)只允许这三个终态:
|
||
# 比 TERMINAL_STATUSES 窄——merged/merge-failed 的 thread 已随任务
|
||
# 终局,不再改;running/queued 一律 409。
|
||
THREAD_MUTATION_STATUSES = frozenset(
|
||
{"completed", "failed", "interrupted"}
|
||
)
|
||
# 实时链路(任务过程事件流):host 在回合进行中把 app-server
|
||
# 通知摘要成条目追加写 jsonl,服务端 tail 文件经 SSE 推送。
|
||
TRACE_STREAM_MAX_SECONDS = 3600
|
||
TRACE_STREAM_POLL_SECONDS = 1.0
|
||
TRACE_DEFAULT_DIR = "/opt/codex-app-server/work/host-traces"
|
||
|
||
# P1 控制面限额(建项目时校验,超限 400 并在错误里写明数值):
|
||
# 单用户项目数上限、项目名长度上限、自定义项目 id 长度上限。
|
||
# 均可经环境变量调(运维侧),缺省值见下方;改动只影响新项目,
|
||
# 已有超限项目不受影响(不删、不改名)。
|
||
|
||
|
||
# 协调员父任务预留:每分工至少预留的美元额度(微美元)。
|
||
# 父任务准入只查一次时,N 个分工可解锁 N 倍消费;本预留要求
|
||
# 剩余配额 >= 分工数 × 本值,否则父任务直接 402(见
|
||
# _check_coordinator_reserve)。缺省 $1/分工,可经环境变量调;
|
||
# 它是准入下限估计,不是实际计费——实际花费仍按子任务
|
||
# created_by=父任务创建人记到创建人账上(worker 建子任务时
|
||
# created_by 直接取父任务创建人,成本 JOIN 据此归属)。
|
||
COORDINATOR_RESERVE_PER_PART_USD_MICROS = _env_int(
|
||
"CODEX_COORDINATOR_RESERVE_PER_PART_USD_MICROS", 1000000)
|
||
|
||
|
||
def _trace_file_name(task_id):
|
||
"""云任务 id -> 链路文件名:与 cloud.sandbox 的 _host_name
|
||
同款清洗(只保留 [A-Za-z0-9_.-],其余替 -),两边算出同一名字。"""
|
||
return re.sub(r"[^A-Za-z0-9_.-]", "-", str(task_id))
|
||
|
||
|
||
def _trace_dir_for(server):
|
||
"""链路目录解析:server 属性(单测注入)> 环境变量 > 默认路径。"""
|
||
d = getattr(server, "trace_dir", None)
|
||
if d:
|
||
return str(d)
|
||
return os.environ.get("CODEX_TRACE_DIR") or TRACE_DEFAULT_DIR
|
||
|
||
|
||
TENANTS_DEFAULT_ROOT = "/var/lib/codex-app-server/tenants"
|
||
|
||
|
||
def _tenants_root_for(server):
|
||
"""租户根目录:server 属性(单测注入)> 环境变量 > 默认路径。"""
|
||
d = getattr(server, "tenants_root", None)
|
||
if d:
|
||
return str(d)
|
||
return os.environ.get("CODEX_TENANTS_ROOT") or TENANTS_DEFAULT_ROOT
|
||
|
||
|
||
def _tenant_trace_dir(server, project_id):
|
||
"""项目对应的租户链路目录(租户 Docker 任务的链路文件落在这里)。
|
||
|
||
目录名清洗与 worker._tenant_safe_name 同规则;项目 id 映射
|
||
不出安全名时返回 None(只读共享目录)。
|
||
"""
|
||
if not project_id:
|
||
return None
|
||
safe = re.sub(r"[^A-Za-z0-9_.-]", "-", str(project_id)).strip(".")
|
||
if not safe or safe in (".", ".."):
|
||
return None
|
||
return os.path.join(_tenants_root_for(server), safe, "work",
|
||
"host-traces")
|
||
|
||
|
||
def _trace_dirs_for_task(server, task):
|
||
"""任务的链路目录列表:租户目录优先、共享目录兜底。
|
||
|
||
任务可能在不同执行器上跑过(重试换执行器),两个目录的
|
||
文件都读、按 id / (thread, id) 去重合并;通常只有一个目录
|
||
里有文件,行为与以前完全一致。
|
||
"""
|
||
dirs = []
|
||
tenant = _tenant_trace_dir(server, task.get("project_id"))
|
||
if tenant:
|
||
dirs.append(tenant)
|
||
shared = _trace_dir_for(server)
|
||
if shared not in dirs:
|
||
dirs.append(shared)
|
||
return dirs
|
||
|
||
|
||
# -- 线程 / skills / MCP 元数据操作(经 host.py CLI 透传)--------------
|
||
# 元数据操作,不跑模型回合:只调 thread/list、thread/read、
|
||
# thread/name/set、thread/archive、thread/unarchive、
|
||
# thread/compact/start、skills/list、app/list、
|
||
# mcpServerStatus/list、config/mcpServer/reload、
|
||
# mcpServer/oauth/login 这类只读或线程管理方法,不开 turn,
|
||
# 因此不违反本文件开头“不直接跑模型回合”的原则。
|
||
#
|
||
# host.py CLI 契约(与正在加 thread-* 子命令的另一路对齐,见
|
||
# 各 handler 的 argv 注释):子命令只往 stdout 打一个 JSON
|
||
# 对象;失败时 exit 非零且 stdout 是 {"error": ...}。本模块
|
||
# 解析 whole-stdout JSON,解析失败或 host.py 缺失一律 502,
|
||
# 绝不伪造成功。argv 里的子命令名与参数名是本批的约定,
|
||
# host.py 侧实现时须与之一致(单测用 stub 脚本按此约定伪造)。
|
||
def _host_py_path():
|
||
"""host.py 路径:环境变量 CODEX_HOST_PY 优先,否则取
|
||
cloud/server.py 所在目录的上一级 host.py(与 worker 的
|
||
--host-py 缺省规则同款)。"""
|
||
env_path = os.environ.get("CODEX_HOST_PY")
|
||
if env_path:
|
||
return env_path
|
||
here = os.path.dirname(os.path.abspath(__file__))
|
||
return os.path.join(os.path.dirname(here), "host.py")
|
||
|
||
|
||
def _run_host_cli(argv, codex_home, timeout=60, model_provider=None):
|
||
"""调 host.py CLI 子命令,返回 (ok, result_or_error)。
|
||
|
||
ok 为 True 时 result 是 stdout 解析出的 JSON 对象;ok 为
|
||
False 时 result 是可直接放进 502 error 字段的错误文本
|
||
(CLI 自带的 error 原样透传,解析失败/缺文件/超时则给
|
||
明确的服务端错误,绝不把 stderr 或原始 key 混进响应)。
|
||
model_provider 是 host.py 顶层参数,必须放在子命令之前:
|
||
app-server 按进程配置的 provider 过滤 thread/list,不带
|
||
注入时 sub2api 下建的 thread 在列表里根本看不到(见
|
||
_list_task_threads 的 provider 重试),只在解析处传,
|
||
绝不由调用方把 provider 拼进 argv(子命令解析器不认)。
|
||
"""
|
||
host_py = _host_py_path()
|
||
if not host_py or not os.path.isfile(host_py):
|
||
return False, "host.py not found"
|
||
cmd = [sys.executable, host_py]
|
||
if codex_home:
|
||
cmd += ["--codex-home", codex_home]
|
||
if model_provider:
|
||
cmd += ["--model-provider", model_provider]
|
||
cmd += list(argv)
|
||
try:
|
||
proc = subprocess.run(cmd, capture_output=True, text=True,
|
||
timeout=timeout)
|
||
except subprocess.TimeoutExpired:
|
||
return False, "host.py timed out"
|
||
except OSError as exc:
|
||
return False, "cannot run host.py: %s" % exc
|
||
stdout = (proc.stdout or "").strip()
|
||
try:
|
||
parsed = json.loads(stdout) if stdout else None
|
||
except ValueError:
|
||
return False, "host.py output is not valid JSON"
|
||
if proc.returncode != 0:
|
||
err = None
|
||
if isinstance(parsed, dict):
|
||
err = parsed.get("error")
|
||
return False, str(err) if err else (
|
||
"host.py failed with exit %d" % proc.returncode)
|
||
if not isinstance(parsed, dict):
|
||
return False, "host.py output is not a JSON object"
|
||
if parsed.get("error"):
|
||
return False, str(parsed["error"])
|
||
return True, parsed
|
||
|
||
|
||
def _tenant_safe_project_name(project_id):
|
||
"""项目 id -> 租户目录安全名,与 worker._tenant_safe_name
|
||
同规则;映射不出安全名时返回 None。"""
|
||
if not project_id:
|
||
return None
|
||
safe = re.sub(r"[^A-Za-z0-9_.-]", "-", str(project_id)).strip(".")
|
||
if not safe or safe in (".", ".."):
|
||
return None
|
||
return safe
|
||
|
||
|
||
def _tenant_codex_home(server, project_id):
|
||
"""项目对应的租户 codex-home:tenants_root/<安全项目名>/
|
||
codex-home。线程持久化在 CODEX_HOME 里,租户按项目分
|
||
home,因此线程操作必须带这个 home,不能用共享 home。"""
|
||
safe = _tenant_safe_project_name(project_id)
|
||
if not safe:
|
||
return None
|
||
return os.path.join(_tenants_root_for(server), safe, "codex-home")
|
||
|
||
|
||
def _local_worker_codex_home():
|
||
"""本地 worker 的共享 codex-home(兜底解析源)。
|
||
|
||
本地 worker 按 host-config-host.json 跑,codex_home 是该
|
||
配置里的共享目录,不在租户 home 里;服务端主解析先查
|
||
租户 home,空时才查这里——只读该配置文件的 codex_home
|
||
一个键,文件不存在、解析失败或键为空时回 None(调用方
|
||
跳过该候选,不因此 502)。相对路径按配置文件所在目录
|
||
解析(与 host.py 的 _resolve_path 同规则)。
|
||
"""
|
||
try:
|
||
cfg_path = os.path.join(
|
||
os.path.dirname(_host_py_path()), "host-config-host.json")
|
||
with open(cfg_path, encoding="utf-8") as f:
|
||
cfg = json.load(f)
|
||
home = cfg.get("codex_home") if isinstance(cfg, dict) else None
|
||
if not home or not isinstance(home, str):
|
||
return None
|
||
home = home.strip()
|
||
if not home:
|
||
return None
|
||
if not os.path.isabs(home):
|
||
home = os.path.join(os.path.dirname(cfg_path), home)
|
||
return home
|
||
except Exception:
|
||
return None
|
||
|
||
|
||
def _resolve_shared_home_mcp(server=None):
|
||
"""共享 home 解析:供服务端启动时的 MCP 脚本同步用。
|
||
|
||
优先级(生产路径绝不硬编码在这里,全部来自已有解析):
|
||
1. _local_worker_codex_home():读 host-config-host.json 的
|
||
codex_home,生产即共享 home;
|
||
2. 兜底:tenants_root 的父目录 + codex-home(tenants_root
|
||
本身来自 server 属性 / CODEX_TENANTS_ROOT / 默认值)。
|
||
任一解析不出时返回 None,调用方跳过同步、不因此报错。
|
||
注意:本函数只解析路径,不做文件同步;文件清单与同步
|
||
语义只在 cloud/shared_home_mcp.py 里。
|
||
"""
|
||
local = _local_worker_codex_home()
|
||
if local:
|
||
return local
|
||
try:
|
||
root = _tenants_root_for(server)
|
||
except Exception:
|
||
return None
|
||
if not root:
|
||
return None
|
||
return os.path.join(os.path.dirname(root), "codex-home")
|
||
|
||
|
||
def _tenant_work_dir(server, project_id):
|
||
"""项目对应的租户工作目录:tenants_root/<安全项目名>/work,
|
||
skills/list 的 cwds 过滤用它(不传 cwd 时 app-server 会
|
||
默认用自身 cwd,那是错的 home 目录)。"""
|
||
safe = _tenant_safe_project_name(project_id)
|
||
if not safe:
|
||
return None
|
||
return os.path.join(_tenants_root_for(server), safe, "work")
|
||
|
||
|
||
def _thread_worktree_for_task(server, task):
|
||
"""本地任务的 worktree 推导:host.py dispatch 有仓库时在
|
||
<host.py 同级>/work/host-worktrees/<host 任务名> 建 worktree,
|
||
host 任务名 = 任务 id 的安全字符清洗(与 sandbox 的
|
||
_host_name 同规则)。无仓库任务的 thread cwd 是 worker 的
|
||
仓库路径或 host.py 当前目录,服务端表里没有、推不出来,
|
||
此时按此路径过滤会返回零个 thread,调用方拿到 404——
|
||
这是诚实结果,不是静默错指。部署可注入
|
||
server.host_worktrees_dir(/pr 路径同款属性)或环境变量
|
||
CODEX_HOST_WORKTREES_DIR 覆盖 worktree 根。"""
|
||
base = getattr(server, "host_worktrees_dir", None)
|
||
if not base:
|
||
base = os.environ.get("CODEX_HOST_WORKTREES_DIR")
|
||
if not base:
|
||
base = os.path.join(os.path.dirname(_host_py_path()),
|
||
"work", "host-worktrees")
|
||
safe = re.sub(r"[^A-Za-z0-9_.-]", "-", str(task["id"]))
|
||
return os.path.join(base, safe)
|
||
|
||
|
||
def _task_worktree_for_files(server, task):
|
||
"""文件端点用的任务 worktree 解析。
|
||
|
||
本地任务走 _thread_worktree_for_task;租户 Docker 任务走
|
||
tenants_root/<安全项目名>/work/host-worktrees/<安全任务名>——
|
||
worker 在租户 work 目录下按任务建子目录(生产实查 wwww 租户
|
||
下 62 个任务目录皆如此,安全名规则与 worker 侧一致)。绝不
|
||
直接 serves 共享的 /app/work 挂载点本身,只 serves 任务级子
|
||
目录,所以不存在按任务错指。cloud 任务与跨机 worktree 一律
|
||
409;两个候选路径都没有时 404(任务未启动或目录已清理)。
|
||
返回 (path, None) 或 (None, (status, msg))。
|
||
"""
|
||
if task.get("executor") == "cloud":
|
||
return None, (409, "cloud task worktrees are not on this host")
|
||
worktree_host = task.get("worktree_host")
|
||
if worktree_host and worktree_host != socket.gethostname():
|
||
return None, (409, "task worktree is on host %s;"
|
||
" files must be read there" % worktree_host)
|
||
safe_task = re.sub(r"[^A-Za-z0-9_.-]", "-", str(task["id"]))
|
||
safe_project = _tenant_safe_project_name(task.get("project_id"))
|
||
if safe_project:
|
||
tenant_path = os.path.join(
|
||
_tenants_root_for(server), safe_project, "work",
|
||
"host-worktrees", safe_task)
|
||
if os.path.isdir(tenant_path):
|
||
return tenant_path, None
|
||
local_path = _thread_worktree_for_task(server, task)
|
||
if os.path.isdir(local_path):
|
||
return local_path, None
|
||
return None, (404, "no local worktree for this task")
|
||
|
||
|
||
def _safe_worktree_path(worktree, rel):
|
||
"""worktree 内相对路径的安全解析:判定收敛到 cloud/gates.py
|
||
(空串返回 worktree 本身;绝对路径、.. 逃逸、符号链接
|
||
逃逸一律返回 None;目录内的符号链接指回内部时放行,
|
||
指出去时拦住;目录违规无任何覆盖通道)。"""
|
||
return _gate_resolve_inside(worktree, rel)
|
||
|
||
|
||
def _extract_thread_list(result):
|
||
"""从 thread-list 的 CLI 结果里取 thread 列表:标准形态是
|
||
ThreadListResponse 的 data;threads 键与 result 包裹是
|
||
容忍形态(CLI 形状未定前不因键名不同就 502)。取不到
|
||
列表时返回 None(调用方按 502 处理)。"""
|
||
if not isinstance(result, dict):
|
||
return None
|
||
for key in ("data", "threads"):
|
||
value = result.get(key)
|
||
if isinstance(value, list):
|
||
return value
|
||
inner = result.get("result")
|
||
if isinstance(inner, dict):
|
||
for key in ("data", "threads"):
|
||
value = inner.get(key)
|
||
if isinstance(value, list):
|
||
return value
|
||
return None
|
||
|
||
|
||
def _extract_thread(result):
|
||
"""从 thread-read 的 CLI 结果里取 thread 对象:标准形态是
|
||
ThreadReadResponse 的 thread;结果本身就是 thread 对象
|
||
时也接受。取不到时返回 None。"""
|
||
if not isinstance(result, dict):
|
||
return None
|
||
thread = result.get("thread")
|
||
if isinstance(thread, dict):
|
||
return thread
|
||
if isinstance(result.get("id"), str):
|
||
return result
|
||
inner = result.get("result")
|
||
if isinstance(inner, dict):
|
||
thread = inner.get("thread")
|
||
if isinstance(thread, dict):
|
||
return thread
|
||
if isinstance(inner.get("id"), str):
|
||
return inner
|
||
return None
|
||
|
||
|
||
def _pick_latest_thread(threads, include_archived=False):
|
||
"""cwd 过滤返回多个 thread 时的取用规则:取最新一个非
|
||
archived。协议说明:Thread 对象本身没有 archived 字段,
|
||
非归档由 thread/list 的 archived 过滤保证(不传 archived
|
||
时只回非归档 thread);这里只防御性地跳过显式带
|
||
archived 真值的条目(CLI 形状若补了该字段时仍正确)。
|
||
include_archived 为真时不跳过(unarchive 解析专用的
|
||
已归档列表;实测 archived 列表里条目的 archived 字段
|
||
为 None,不跳过也能命中,但显式放行更稳)。
|
||
最新 = updatedAt 最大,缺失时退 createdAt,再缺失时按
|
||
列表原顺序取后者(同秒时后到的更新)。"""
|
||
best = None
|
||
best_key = None
|
||
for idx, item in enumerate(threads):
|
||
if not isinstance(item, dict):
|
||
continue
|
||
if item.get("archived") and not include_archived:
|
||
continue
|
||
ts = item.get("updatedAt")
|
||
if ts is None:
|
||
ts = item.get("createdAt")
|
||
try:
|
||
ts = float(ts)
|
||
except (TypeError, ValueError):
|
||
ts = 0
|
||
key = (ts, idx)
|
||
if best_key is None or key > best_key:
|
||
best = item
|
||
best_key = key
|
||
return best
|
||
|
||
|
||
def _extract_result_list(result, keys):
|
||
"""从 skills/apps/mcp 列表类 CLI 结果里取列表:标准形态
|
||
是各 Response 的 data;传入的备用键是容忍形态。取不到
|
||
时返回 None。"""
|
||
if not isinstance(result, dict):
|
||
return None
|
||
for key in keys:
|
||
value = result.get(key)
|
||
if isinstance(value, list):
|
||
return value
|
||
inner = result.get("result")
|
||
if isinstance(inner, dict):
|
||
for key in keys:
|
||
value = inner.get(key)
|
||
if isinstance(value, list):
|
||
return value
|
||
return None
|
||
|
||
|
||
def _read_trace_entries(path):
|
||
"""读 host 写的 jsonl 链路文件:跳过坏行;按 id 去重,后到的
|
||
完成态覆盖先到的开始态,保首次顺序。文件不存在返回空列表。"""
|
||
entries = []
|
||
index = {}
|
||
try:
|
||
with open(path, "r", encoding="utf-8") as f:
|
||
for line in f:
|
||
line = line.strip()
|
||
if not line:
|
||
continue
|
||
try:
|
||
entry = json.loads(line)
|
||
except ValueError:
|
||
continue
|
||
if not isinstance(entry, dict) or "id" not in entry:
|
||
continue
|
||
eid = entry["id"]
|
||
if eid in index:
|
||
entries[index[eid]] = entry
|
||
else:
|
||
index[eid] = len(entries)
|
||
entries.append(entry)
|
||
except OSError:
|
||
return []
|
||
return entries
|
||
|
||
|
||
def _read_trace_entries_multi(paths):
|
||
"""多文件合并读(flat trace 用):按 id 去重,后到的覆盖先到的,
|
||
保首次顺序;文件不存在视为空。"""
|
||
entries = []
|
||
index = {}
|
||
for path in paths:
|
||
for e in _read_trace_entries(path):
|
||
if e["id"] in index:
|
||
entries[index[e["id"]]] = e
|
||
else:
|
||
index[e["id"]] = len(entries)
|
||
entries.append(e)
|
||
return entries
|
||
|
||
|
||
AGENT_CALL_LIMIT = 1000
|
||
|
||
|
||
def _read_agent_entries(path):
|
||
"""读链路文件供子代理聚合:按 (thread, id) 去重,后到的完成态
|
||
覆盖先到的开始态,保首次顺序;坏行与无 id 行跳过;文件不
|
||
存在为空。同一 item id 在不同线程出现时不互相覆盖(模型内
|
||
子代理与主任务各有自己的线程)。"""
|
||
entries = []
|
||
index = {}
|
||
try:
|
||
with open(path, "r", encoding="utf-8") as f:
|
||
for line in f:
|
||
line = line.strip()
|
||
if not line:
|
||
continue
|
||
try:
|
||
entry = json.loads(line)
|
||
except ValueError:
|
||
continue
|
||
if not isinstance(entry, dict) or "id" not in entry:
|
||
continue
|
||
key = (entry.get("thread") or "", entry["id"])
|
||
if key in index:
|
||
entries[index[key]] = entry
|
||
else:
|
||
index[key] = len(entries)
|
||
entries.append(entry)
|
||
except OSError:
|
||
return []
|
||
return entries
|
||
|
||
|
||
def _read_agent_entries_multi(paths):
|
||
"""多文件合并读(agents 聚合用):按 (thread, id) 去重,后到的
|
||
覆盖先到的,保首次顺序;文件不存在视为空。"""
|
||
entries = []
|
||
index = {}
|
||
for path in paths:
|
||
for e in _read_agent_entries(path):
|
||
key = (e.get("thread") or "", e["id"])
|
||
if key in index:
|
||
entries[index[key]] = e
|
||
else:
|
||
index[key] = len(entries)
|
||
entries.append(e)
|
||
return entries
|
||
|
||
|
||
def _latest_ts(values):
|
||
vals = [v for v in values if isinstance(v, (int, float))]
|
||
return max(vals) if vals else None
|
||
|
||
|
||
def _agent_status_from_task(status):
|
||
return {"queued": "running", "running": "running",
|
||
"completed": "completed", "merged": "completed",
|
||
"failed": "failed", "merge-failed": "failed",
|
||
"interrupted": "interrupted"}.get(status, "unknown")
|
||
|
||
|
||
def _build_agent_chain(task, trace_dirs):
|
||
"""任务 → 子代理链:主任务 + 模型内子代理(同文件多线程)+
|
||
委派子任务(父链路里的 agent-start/done 标记 + 子链路文件)。
|
||
|
||
trace_dirs 是 _trace_dirs_for_task 给的目录列表(租户优先、
|
||
共享兜底);父链路与子链路文件都在这些目录里找、合并读。
|
||
状态诚实口径:委派子任务以 agent-done 标记为准,无标记即
|
||
进行中——委派进程与父任务相互独立,父任务结束不代表子任务
|
||
停;模型内子代理无明确结束信号,父任务运行中即进行中,
|
||
否则未知。无 thread 的旧条目一律归主任务。
|
||
"""
|
||
parent_paths = [os.path.join(d, _trace_file_name(task["id"]) + ".jsonl")
|
||
for d in trace_dirs]
|
||
entries = _read_agent_entries_multi(parent_paths)
|
||
calls = [e for e in entries
|
||
if e.get("kind") not in ("agent-start", "agent-done")]
|
||
main_thread = None
|
||
for e in calls:
|
||
if e.get("thread"):
|
||
main_thread = e["thread"]
|
||
break
|
||
root_calls = [e for e in calls
|
||
if not e.get("thread") or e.get("thread") == main_thread]
|
||
agents = [{
|
||
"id": "main", "name": "主任务", "kind": "main", "parent": None,
|
||
"status": _agent_status_from_task(task.get("status")),
|
||
"prompt": None, "trace": None,
|
||
"calls": root_calls[:AGENT_CALL_LIMIT],
|
||
"updated_ts": _latest_ts([e.get("ts") for e in root_calls]),
|
||
}]
|
||
task_running = task.get("status") in ("queued", "running")
|
||
threads = {}
|
||
for e in calls:
|
||
t = e.get("thread")
|
||
if t and t != main_thread:
|
||
threads.setdefault(t, []).append(e)
|
||
for t, es in threads.items():
|
||
agents.append({
|
||
"id": t, "name": "模型子代理 " + t[:12], "kind": "model",
|
||
"parent": "main",
|
||
"status": "running" if task_running else "unknown",
|
||
"prompt": None, "trace": None,
|
||
"calls": es[:AGENT_CALL_LIMIT],
|
||
"updated_ts": _latest_ts([e.get("ts") for e in es]),
|
||
})
|
||
starts = {}
|
||
dones = {}
|
||
for e in entries:
|
||
if e.get("kind") == "agent-start" and e.get("name"):
|
||
starts[e["name"]] = e
|
||
elif e.get("kind") == "agent-done" and e.get("name"):
|
||
dones[e["name"]] = e
|
||
for name, s in starts.items():
|
||
trace_name = s.get("trace") or dones.get(name, {}).get("trace")
|
||
child_calls = []
|
||
if trace_name:
|
||
child_paths = [os.path.join(d, os.path.basename(trace_name))
|
||
for d in trace_dirs]
|
||
child_calls = [e for e in _read_agent_entries_multi(child_paths)
|
||
if e.get("kind") not in
|
||
("agent-start", "agent-done")]
|
||
done = dones.get(name)
|
||
status = "running"
|
||
if done and done.get("status") in (
|
||
"completed", "failed", "interrupted"):
|
||
status = done["status"]
|
||
agents.append({
|
||
"id": "delegate:" + name, "name": name, "kind": "delegate",
|
||
"parent": "main", "status": status,
|
||
"prompt": s.get("prompt"), "trace": trace_name,
|
||
"calls": child_calls[:AGENT_CALL_LIMIT],
|
||
"updated_ts": _latest_ts(
|
||
[e.get("ts") for e in child_calls] +
|
||
[s.get("ts"), done.get("ts") if done else None]),
|
||
})
|
||
return agents
|
||
# 陈旧回收阈值默认值收敛到 cloud/timeouts.py(别名保留)。
|
||
DEFAULT_SWEEP_THRESHOLD_SECONDS = _STALE_SWEEP
|
||
# 限流默认值:每 IP / 每 API key 每分钟 120 次;环境变量
|
||
# CLOUD_RATE_LIMIT_PER_MINUTE / CLOUD_RATE_LIMIT_WINDOW_SECONDS 可调。
|
||
# 限流默认内存滑动窗口;Postgres 下改走 DB 共享计数(多 server
|
||
# 共用,见 DbRateLimiter),并发下可能多放行少量请求(非原子计数)。
|
||
DEFAULT_RATE_LIMIT_PER_MINUTE = 120
|
||
DEFAULT_RATE_LIMIT_WINDOW_SECONDS = 60
|
||
TASK_STATUS_KEYS = (
|
||
"queued", "running", "completed", "failed",
|
||
"interrupted", "merged", "merge-failed",
|
||
)
|
||
|
||
|
||
def _json_default(obj):
|
||
"""json 兜底:Postgres 的 SUM(bigint) 返回 NUMERIC,psycopg2
|
||
映成 Decimal,不处理它在 _send_json 里直接炸 TypeError、请求
|
||
线程死掉、客户端只看到连接断开(2026-10-26 项目成本按天实弹
|
||
就是这么断的)。整数值 Decimal 转 int,非整数转 float;其它
|
||
类型照旧抛 TypeError,不静默吞掉真正的类型错误。"""
|
||
if isinstance(obj, Decimal):
|
||
return int(obj) if obj == obj.to_integral_value() else float(obj)
|
||
raise TypeError("Object of type %s is not JSON serializable"
|
||
% obj.__class__.__name__)
|
||
|
||
|
||
def _rate_limit_from_env(name, default):
|
||
try:
|
||
value = int(os.environ.get(name) or default)
|
||
except (TypeError, ValueError):
|
||
return default
|
||
return value if value > 0 else default
|
||
|
||
|
||
class RateLimiter:
|
||
"""内存滑动窗口限流:每 key 每窗口最多 per_minute 次。
|
||
|
||
只在 allow() 内部持一把短锁做 deque 修剪与计数,不持锁做
|
||
任何 I/O,不会把正常请求串行化。key 由调用方给(本 server
|
||
传“key:<API key 的 sha256>”或“ip:<客户端 IP>”,原始 API key
|
||
绝不存进限流结构里)。窗口与阈值可在实例上改(测试注入小
|
||
阈值验证 429 就靠改这两个属性)。
|
||
"""
|
||
|
||
def __init__(self, per_minute=DEFAULT_RATE_LIMIT_PER_MINUTE,
|
||
window_seconds=DEFAULT_RATE_LIMIT_WINDOW_SECONDS,
|
||
time_fn=None):
|
||
self.per_minute = per_minute
|
||
self.window_seconds = window_seconds
|
||
self._time_fn = time_fn or time.time
|
||
self._lock = threading.Lock()
|
||
self._hits = {}
|
||
|
||
def allow(self, key):
|
||
now = self._time_fn()
|
||
window = self.window_seconds
|
||
limit = self.per_minute
|
||
with self._lock:
|
||
queue = self._hits.get(key)
|
||
if queue is None:
|
||
queue = deque()
|
||
self._hits[key] = queue
|
||
cutoff = now - window
|
||
while queue and queue[0] <= cutoff:
|
||
queue.popleft()
|
||
if len(queue) >= limit:
|
||
return False
|
||
queue.append(now)
|
||
# 键数兜底:长期运行的 server 不应让 _hits 无限增长;
|
||
# 超过 10000 个 key 时清掉最久未用的空队列(不影响计数)。
|
||
if len(self._hits) > 10000:
|
||
for old_key in list(self._hits):
|
||
if not self._hits[old_key]:
|
||
del self._hits[old_key]
|
||
break
|
||
return True
|
||
|
||
|
||
class DbRateLimiter:
|
||
"""DB 共享滑动窗口限流(HA 阶段 2,多 server 共用计数)。
|
||
|
||
与 RateLimiter 同接口(allow(key),per_minute/window_seconds
|
||
属性可改,测试注入小阈值验证 429 的路子不变)。Postgres 下多
|
||
server 共享同一张表,计数跨进程;SQLite 下与内存版等价(单测用)。
|
||
并发下计数与写入非原子,可能多放行少量请求——限流是 abuse
|
||
缓解,不是精确配额。表与索引懒建(workers 表同款模式),
|
||
不进 POSTGRES_SCHEMA。列名用 hit_key(key 在部分方言里是
|
||
保留字,避开它)。
|
||
|
||
结构要点(2026-10-27 死锁修复):allow() 只做 INSERT + 窗口内
|
||
COUNT,不再 DELETE——旧实现每请求在同一事务里 DELETE 旧行 +
|
||
INSERT + COUNT,控制台并发加载 + 双 server 下两个事务在
|
||
rate_limit_hits 表上死锁,未捕获的异常直接断连、nginx 报
|
||
502。过期行改由后台 sweep_rate_limit_hits() 定期清(见下),
|
||
请求路径上只剩 INSERT + COUNT,不存在 DELETE↔DELETE/INSERT
|
||
的死锁形状;COUNT 本身按窗口过滤,过期行残留不影响计数
|
||
正确性,只影响表大小。
|
||
"""
|
||
|
||
def __init__(self, db_path, per_minute=DEFAULT_RATE_LIMIT_PER_MINUTE,
|
||
window_seconds=DEFAULT_RATE_LIMIT_WINDOW_SECONDS,
|
||
time_fn=None):
|
||
self.db_path = str(db_path)
|
||
self.per_minute = per_minute
|
||
self.window_seconds = window_seconds
|
||
self._time_fn = time_fn or time.time
|
||
self._ensure_lock = threading.Lock()
|
||
self._ensured = False
|
||
|
||
def _ensure_table(self, conn):
|
||
"""建表与索引:每实例只做一次(首个 allow 时),不是每请求
|
||
都跑 DDL——DDL 也会拿锁,没必要让每个请求都经过它。"""
|
||
if self._ensured:
|
||
return
|
||
with self._ensure_lock:
|
||
if self._ensured:
|
||
return
|
||
conn.execute(
|
||
"CREATE TABLE IF NOT EXISTS rate_limit_hits("
|
||
"hit_key TEXT, ts REAL)")
|
||
conn.execute(
|
||
"CREATE INDEX IF NOT EXISTS rate_limit_hits_key_ts"
|
||
" ON rate_limit_hits(hit_key, ts)")
|
||
conn.commit()
|
||
self._ensured = True
|
||
|
||
def allow(self, key):
|
||
now = self._time_fn()
|
||
window = self.window_seconds
|
||
limit = self.per_minute
|
||
conn = _db_connect(self.db_path)
|
||
try:
|
||
self._ensure_table(conn)
|
||
conn.execute(
|
||
"INSERT INTO rate_limit_hits(hit_key, ts) VALUES(?, ?)",
|
||
(key, now))
|
||
row = conn.execute(
|
||
"SELECT COUNT(*) FROM rate_limit_hits"
|
||
" WHERE hit_key = ? AND ts > ?",
|
||
(key, now - window)).fetchone()
|
||
conn.commit()
|
||
return row[0] <= limit
|
||
finally:
|
||
conn.close()
|
||
|
||
|
||
def _make_rate_limiter(db_path):
|
||
"""建限流器:Postgres 下用 DB 共享计数(多 server 共用),
|
||
否则用内存版(单机与单测)。后端只看环境变量,与其它分发一致。"""
|
||
per_minute = _rate_limit_from_env(
|
||
"CLOUD_RATE_LIMIT_PER_MINUTE", DEFAULT_RATE_LIMIT_PER_MINUTE)
|
||
window_seconds = _rate_limit_from_env(
|
||
"CLOUD_RATE_LIMIT_WINDOW_SECONDS",
|
||
DEFAULT_RATE_LIMIT_WINDOW_SECONDS)
|
||
if _db_backend_name() == "postgres":
|
||
return DbRateLimiter(db_path, per_minute=per_minute,
|
||
window_seconds=window_seconds)
|
||
return RateLimiter(per_minute=per_minute,
|
||
window_seconds=window_seconds)
|
||
|
||
|
||
RATE_LIMIT_SWEEP_INTERVAL_SECONDS = 60
|
||
# Postgres advisory lock key:两个 server 各起一个 sweeper 时,只
|
||
# 有拿到锁的那个真正 DELETE,另一个直接跳过——清理者之间也不
|
||
# 会互相死锁。本项目其它地方不用 advisory lock,不会撞 key。
|
||
RATE_LIMIT_SWEEP_ADVISORY_KEY = 20261027
|
||
|
||
|
||
def sweep_rate_limit_hits(db_path, window_seconds, now=None):
|
||
"""删过期 hit 行:只由后台定期调用,不在请求路径上。
|
||
|
||
返回 True 表示本次真正执行了删除(表为空时也算),False
|
||
表示 Postgres 下没拿到 advisory 锁、另一台 server 正在清,
|
||
本次跳过。清理是纯卫生(防表无限增长):allow() 的 COUNT
|
||
本身按窗口过滤,过期行残留不影响计数,所以清理失败或
|
||
跳过都不影响限流正确性,只影响表大小。
|
||
"""
|
||
now = time.time() if now is None else now
|
||
conn = _db_connect(db_path)
|
||
try:
|
||
conn.execute(
|
||
"CREATE TABLE IF NOT EXISTS rate_limit_hits("
|
||
"hit_key TEXT, ts REAL)")
|
||
conn.execute(
|
||
"CREATE INDEX IF NOT EXISTS rate_limit_hits_key_ts"
|
||
" ON rate_limit_hits(hit_key, ts)")
|
||
if _db_backend_name() == "postgres":
|
||
row = conn.execute(
|
||
"SELECT pg_try_advisory_xact_lock(?)",
|
||
(RATE_LIMIT_SWEEP_ADVISORY_KEY,)).fetchone()
|
||
if not row or not row[0]:
|
||
return False
|
||
conn.execute(
|
||
"DELETE FROM rate_limit_hits WHERE ts <= ?",
|
||
(now - window_seconds,))
|
||
conn.commit()
|
||
return True
|
||
finally:
|
||
conn.close()
|
||
|
||
|
||
def start_rate_limit_sweeper(limiter, db_path):
|
||
"""给 server 主进程起后台清理线程:每 60 秒清一次过期行。
|
||
|
||
只对 DbRateLimiter 起(内存版在 allow() 里自行修剪,不需要)。
|
||
线程 daemon、异常自愈(记 warning 后继续下一轮),绝不因
|
||
清理失败拖垮 server。调用方只在 main() 里调一次,测试不调。
|
||
"""
|
||
if not isinstance(limiter, DbRateLimiter):
|
||
return None
|
||
|
||
def _loop():
|
||
while True:
|
||
time.sleep(RATE_LIMIT_SWEEP_INTERVAL_SECONDS)
|
||
try:
|
||
sweep_rate_limit_hits(db_path, limiter.window_seconds)
|
||
except Exception as exc: # noqa: BLE001 -- 清理是卫生操作
|
||
_server_log.warning("rate limit sweep failed: %s", exc)
|
||
|
||
thread = threading.Thread(target=_loop, daemon=True,
|
||
name="rate-limit-sweeper")
|
||
thread.start()
|
||
return thread
|
||
|
||
|
||
def _db_backend_name():
|
||
"""当前数据库后端名:环境变量,未设则 sqlite;绝不返回连接串。"""
|
||
return (os.environ.get("CLOUD_DB_BACKEND") or "sqlite").strip().lower()
|
||
|
||
|
||
def _deployed_commit():
|
||
"""当前上线代码的 commit:deploy.py 部署时在包根写的 DEPLOYED_COMMIT。
|
||
|
||
文件不存在、读失败、内容为空时返回 None(老包、本地开发都走这支),
|
||
health 绝不因此失败。commit 是公开的版本标识,不是密钥。
|
||
路径取 cloud/ 的父目录(即仓库根),不依赖当前工作目录。"""
|
||
path = os.path.join(
|
||
os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
|
||
"DEPLOYED_COMMIT")
|
||
try:
|
||
with open(path, encoding="utf-8") as f:
|
||
value = f.read().strip()
|
||
except Exception:
|
||
return None
|
||
return value or None
|
||
|
||
|
||
def _worker_heartbeat_status(server=None):
|
||
"""读 worker 运维心跳,返回 (age_seconds_or_None, worker_ok)。
|
||
|
||
优先读 workers 注册表(多机前提):所有 worker 每轮都往
|
||
workers 表写 last_seen(见 worker._registry_heartbeat),而文件
|
||
心跳只在本机存在——多 server 下 server 读不到别机 worker 的
|
||
文件,注册表是唯一跨机可靠的心跳源。表不存在、无行、查库
|
||
失败时回落文件心跳(单机旧行为与单测保持兼容)。
|
||
|
||
文件心跳路径:测试注入优先(server.worker_heartbeat_file),
|
||
其次环境变量 CLOUD_WORKER_HEARTBEAT_FILE,未设则默认
|
||
./worker-heartbeat.json——与 worker 的默认路径保持一致,主机
|
||
部署时两边都要指到同一个主机路径(见 deploy/README)。
|
||
阈值:server.worker_heartbeat_max_age_seconds(测试注入)或
|
||
环境变量 CLOUD_WORKER_HEARTBEAT_MAX_AGE_SECONDS,默认 180 秒。
|
||
文件不存在、读失败、JSON 非法、last_seen 非数字时一律返回
|
||
(None, False),不抛异常、不影响 health 本身的 200。
|
||
"""
|
||
max_age = 180
|
||
if server is not None:
|
||
override = getattr(server, "worker_heartbeat_max_age_seconds", None)
|
||
if override is not None:
|
||
try:
|
||
max_age = float(override)
|
||
except (TypeError, ValueError):
|
||
max_age = 180
|
||
if max_age == 180:
|
||
try:
|
||
max_age = float(os.environ.get(
|
||
"CLOUD_WORKER_HEARTBEAT_MAX_AGE_SECONDS") or 180)
|
||
except (TypeError, ValueError):
|
||
max_age = 180
|
||
db_path = getattr(server, "db_path", None) if server is not None else None
|
||
if db_path:
|
||
try:
|
||
conn = _connect(db_path)
|
||
try:
|
||
row = conn.execute(
|
||
"SELECT MAX(last_seen) FROM workers").fetchone()
|
||
finally:
|
||
conn.close()
|
||
if row is not None and row[0] is not None:
|
||
age = time.time() - float(row[0])
|
||
return round(age, 1), bool(age <= max_age)
|
||
except Exception:
|
||
pass
|
||
path = None
|
||
if server is not None:
|
||
path = getattr(server, "worker_heartbeat_file", None)
|
||
if not path:
|
||
path = os.environ.get("CLOUD_WORKER_HEARTBEAT_FILE") or \
|
||
"./worker-heartbeat.json"
|
||
try:
|
||
with open(path, encoding="utf-8") as f:
|
||
payload = json.load(f)
|
||
last_seen = float(payload.get("last_seen"))
|
||
except Exception:
|
||
return None, False
|
||
age = time.time() - last_seen
|
||
return round(age, 1), bool(age <= max_age)
|
||
|
||
|
||
def _connect(db_path):
|
||
"""每个请求新建一个连接;后端按 CLOUD_DB_BACKEND 分发。
|
||
|
||
cloud.db.connect() 本身已按环境变量分发:sqlite 时是
|
||
WAL + busy_timeout 的 SQLite 连接;postgres 时是 psycopg
|
||
适配连接(db_path 被忽略,连接参数只走 CLOUD_DB_*
|
||
环境变量,驱动缺失时明确报错,绝不回落)。因此 server
|
||
自身的表与 AuthStore / MemoryStore 等沿用 connect() 的
|
||
调用点在 PG 模式下同样走 Postgres,无需逐一改写。
|
||
"""
|
||
return _db_connect(str(db_path))
|
||
|
||
|
||
def migrate_tasks_table(conn):
|
||
"""幂等迁移:给老库 tasks 表补新列,不丢数据。
|
||
|
||
新列:result_text、error_text、actual_model、started_at、
|
||
finished_at、child_pid、executor、pr_url、pr_number,以及
|
||
goal、verify_command、goal_max_rounds(goal 模式用)、
|
||
goal_mode(新 goal 语义:1 时 prompt 即目标,无验收命令、
|
||
不限轮次;老行 NULL 按 0 计)。
|
||
PRAGMA table_info 查现有列,缺哪个补哪个;已迁移的库再跑
|
||
一次是 no-op。worker 写执行结果、server 读任务时都依赖
|
||
这些列存在。
|
||
"""
|
||
cols = {row[1] for row in conn.execute(
|
||
"PRAGMA table_info(tasks)").fetchall()}
|
||
new_cols = {
|
||
"result_text": "TEXT",
|
||
"result_text_full": "TEXT",
|
||
"error_text": "TEXT",
|
||
"actual_model": "TEXT",
|
||
"started_at": "REAL",
|
||
"finished_at": "REAL",
|
||
"child_pid": "INTEGER",
|
||
"executor": "TEXT",
|
||
"pr_url": "TEXT",
|
||
"pr_number": "INTEGER",
|
||
"goal": "TEXT",
|
||
"verify_command": "TEXT",
|
||
"goal_max_rounds": "INTEGER",
|
||
"goal_mode": "INTEGER",
|
||
# HA 阶段 1:认领任务的 worker 所在主机名;/pr 据此判断
|
||
# worktree 在本机(同步跑)还是别机(排 pr 任务给该主机)。
|
||
"worktree_host": "TEXT",
|
||
# 沙箱规模:认领任务的 worker_id(worker._claim 在 CAS
|
||
# 认领时写);失联容器识别的 owner 依据,见
|
||
# workers.orphaned_containers。老行 NULL = 无 owner 记录。
|
||
"claimed_by_worker": "TEXT",
|
||
# 任务级选项(console 选模型/强度/权限用):NULL = 跟随
|
||
# worker 配置默认值;model 不做白名单(中转目录会变,
|
||
# 下拉只是建议值),effort/approval 由 _create_task 校验。
|
||
"model": "TEXT",
|
||
"effort": "TEXT",
|
||
"approval": "TEXT",
|
||
# 派生默认(子任务模型/强度):NULL = 没有单独声明,
|
||
# 派生时先取它、再取本任务的 model / effort;显式
|
||
# 传 child_model / child_effort,或建任务 prompt 里
|
||
# 写明“子任务用 X max”这类显式指令时由服务端解析
|
||
# 填入(见 marker_tools.parse_spawn_defaults)。
|
||
"child_model": "TEXT",
|
||
"child_effort": "TEXT",
|
||
# 计划模式(只读)与分支来源:plan=1 时 worker 用
|
||
# read-only 沙箱;forked_from 记源任务 id(非分支为 NULL)。
|
||
"plan": "INTEGER",
|
||
"forked_from": "TEXT",
|
||
# 环境:任务绑定的环境 id(NULL = 不指定环境,走原有
|
||
# 项目仓库路径);环境本身是项目仓库的一个命名基准检出
|
||
# (分支 + 执行器),任务 worktree 从它建,不改任务隔离。
|
||
"environment_id": "TEXT",
|
||
# 续跑消息:旧任务继续会话时本轮要说的话(NULL = 没有待
|
||
# 续跑消息)。worker 认领后把 prompt 换成它跑一轮,跑完
|
||
# 清掉;任务详情里照常显示原 prompt,续跑消息只作本轮输入。
|
||
"resume_message": "TEXT",
|
||
# 任务列表显示 turn 用:最近一轮的 turn_id 与
|
||
# root_turn_id(worker 写回时只在新值非空时覆盖)。
|
||
"turn_id": "TEXT",
|
||
"root_turn_id": "TEXT",
|
||
# 协调员:coordinator_parts 记分工 JSON(普通任务为 NULL,
|
||
# worker 认领后按它建并行子任务、等齐、合流);parent_task_id
|
||
# 与 coordinator_part 记在子任务行上(父任务 id 与分工名,
|
||
# 续跑按分工名复用已有子任务,不重复建)。
|
||
"coordinator_parts": "TEXT",
|
||
"parent_task_id": "TEXT",
|
||
"coordinator_part": "TEXT",
|
||
# G10:goal 断点续跑待处理标记(1 = 已调 /goal-resume、
|
||
# 待 worker 按断点续跑;worker 认领后清掉,跑完不再
|
||
# 重复触发)。与 resume_message 不同:后者是“给旧会
|
||
# 话发一条新消息、开新循环”,本标记是“从
|
||
# goal_rounds_done 的下一轮接着跑同一循环”。
|
||
"goal_resume_pending": "INTEGER DEFAULT 0",
|
||
# 长任务自动恢复:paused_by_user=1 只由用户暂停/
|
||
# 取消(/pause、/cancel)写,系统中断(sweep、worker
|
||
# 退出、配额除外)绝不写它;interrupted_reason 记
|
||
# 中断来源(user_pause/user_cancel/sweep/quota/
|
||
# coordinator_orphan/worker_shutdown),NULL = 老行或
|
||
# 未分类系统中断,按可自动恢复处理;auto_resume_count
|
||
# 记自动续跑次数(防确定性失败无限循环的上限门);
|
||
# no_time_limit=1 的任务 worker 级不限时(见 worker
|
||
# _run_attempt,host 单轮 600 秒仍是单轮护栏)。
|
||
"paused_by_user": "INTEGER DEFAULT 0",
|
||
"interrupted_reason": "TEXT",
|
||
"auto_resume_count": "INTEGER DEFAULT 0",
|
||
"no_time_limit": "INTEGER DEFAULT 0",
|
||
# P0 三件:自动恢复退避与无进展熔断(见 worker
|
||
# auto_resume_interrupted):resume_after 是下次允许
|
||
# 重排队的时间戳(指数退避);auto_resume_stall_count
|
||
# 记连续无进展重排队次数(到 3 即停、标
|
||
# auto_resume_stalled 等人工);checkpoint_round_at_last_
|
||
# resume 记上次重排队时库中断点轮次(无进展判定的
|
||
# 基线,NULL = 从未记过);last_error_signature 记
|
||
# 最近一次终局的错误签名(permanent/transient/None,
|
||
# 供排障,不作恢复判定——判定在 sandbox 终态里)。
|
||
"resume_after": "DOUBLE PRECISION DEFAULT 0",
|
||
"auto_resume_stall_count": "INTEGER DEFAULT 0",
|
||
"checkpoint_round_at_last_resume": "INTEGER",
|
||
"last_error_signature": "TEXT",
|
||
}
|
||
for name, coltype in new_cols.items():
|
||
if name not in cols:
|
||
conn.execute(
|
||
"ALTER TABLE tasks ADD COLUMN %s %s" % (name, coltype))
|
||
|
||
|
||
def migrate_projects_table(conn):
|
||
"""幂等迁移:给老库 projects 表补 repo_url 列,不丢数据。
|
||
|
||
与 migrate_tasks_table 同款写法:PRAGMA table_info 查现有列
|
||
(Postgres 适配层把它翻译成 information_schema 查询,调用方
|
||
取 row[1] 即列名),缺 repo_url 时 ALTER TABLE 补列,已迁移
|
||
的库再跑一次是 no-op。SQLite 与 Postgres 两支都走它。
|
||
"""
|
||
cols = {row[1] for row in conn.execute(
|
||
"PRAGMA table_info(projects)").fetchall()}
|
||
if "repo_url" not in cols:
|
||
conn.execute("ALTER TABLE projects ADD COLUMN repo_url TEXT")
|
||
if "auto_pr" not in cols:
|
||
conn.execute("ALTER TABLE projects ADD COLUMN auto_pr INTEGER")
|
||
|
||
|
||
def _upload_dir_for(server):
|
||
"""附件暂存根目录:server 属性(单测注入)> 环境变量 > 默认路径
|
||
(与 cloud.uploads.upload_root 同款解析,worker 侧也用它)。"""
|
||
d = getattr(server, "upload_dir", None)
|
||
if d:
|
||
return str(d)
|
||
return _upload_root()
|
||
|
||
|
||
def _ensure_uploads_tables(conn):
|
||
"""幂等建附件包与任务附件表:server init_db 调它,老库第一次
|
||
跑到这里时自动补表,不丢数据。uploads 记包的归属与状态
|
||
(pending/used,任务绑定时置 used,防一包多用);
|
||
task_attachments 记任务绑定的每个文件(相对路径 + 大小),
|
||
worker 派发时按 bundle_id 找到暂存目录。"""
|
||
conn.execute(
|
||
"CREATE TABLE IF NOT EXISTS uploads("
|
||
"id TEXT PRIMARY KEY, "
|
||
"created_by TEXT, "
|
||
"created_at DOUBLE PRECISION, "
|
||
"status TEXT)"
|
||
)
|
||
conn.execute(
|
||
"CREATE TABLE IF NOT EXISTS task_attachments("
|
||
"task_id TEXT, "
|
||
"bundle_id TEXT, "
|
||
"rel_path TEXT, "
|
||
"size INTEGER, "
|
||
"PRIMARY KEY(task_id, rel_path))"
|
||
)
|
||
conn.execute(
|
||
"CREATE INDEX IF NOT EXISTS idx_attachments_task"
|
||
" ON task_attachments(task_id)")
|
||
|
||
|
||
def _attachments_map(conn, task_ids):
|
||
"""一批任务的附件相对路径(按路径排序):{task_id: [rel_path]}。
|
||
任务列表接口用一次查询取全量,不按任务逐条查。"""
|
||
out = {tid: [] for tid in task_ids}
|
||
if not task_ids:
|
||
return out
|
||
placeholders = ", ".join("?" * len(task_ids))
|
||
rows = conn.execute(
|
||
"SELECT task_id, rel_path FROM task_attachments"
|
||
" WHERE task_id IN (%s) ORDER BY rel_path" % placeholders,
|
||
tuple(task_ids),
|
||
).fetchall()
|
||
for row in rows:
|
||
tid = row[0] if isinstance(row, tuple) else row["task_id"]
|
||
rel = row[1] if isinstance(row, tuple) else row["rel_path"]
|
||
if tid in out:
|
||
out[tid].append(rel)
|
||
return out
|
||
|
||
|
||
def _task_tokens_map(conn, task_ids):
|
||
"""一批任务的 cost_records token 合计:{task_id: total_tokens}。
|
||
|
||
任务列表接口用一次查询取全量,不按任务逐条调成本接口
|
||
(一屏 N 个任务就是 N 个请求,会拖慢列表刷新、还容易撞
|
||
限流)。无成本记录的任务计 0(不是缺失);cost_records
|
||
表还不存在时(全新库、CostStore 未建过表)同样全计 0,
|
||
列表绝不因此失败——0 在这里就是“未记录到任何 token”,
|
||
与 _metrics 的缺表按空统计口径一致。
|
||
"""
|
||
out = {tid: 0 for tid in task_ids}
|
||
if not task_ids:
|
||
return out
|
||
placeholders = ", ".join("?" * len(task_ids))
|
||
try:
|
||
rows = conn.execute(
|
||
"SELECT task_id, COALESCE(SUM(total_tokens),0) AS t"
|
||
" FROM cost_records WHERE task_id IN (%s)"
|
||
" GROUP BY task_id" % placeholders,
|
||
tuple(task_ids),
|
||
).fetchall()
|
||
except (sqlite3.OperationalError, _PgOperationalError):
|
||
return out
|
||
for row in rows:
|
||
tid = row[0] if isinstance(row, tuple) else row["task_id"]
|
||
if tid in out:
|
||
out[tid] = int(row[1] if isinstance(row, tuple)
|
||
else row["t"] or 0)
|
||
return out
|
||
|
||
|
||
def _ensure_task_checkpoints_table(conn):
|
||
"""幂等建断点表(P0 三件其一):server init_db(PG 与 SQLite
|
||
两支)与 worker 迁移兜底共用。DOUBLE PRECISION 在两后端都可用。
|
||
"""
|
||
conn.execute(
|
||
"CREATE TABLE IF NOT EXISTS task_checkpoints("
|
||
"task_id TEXT, "
|
||
"round_no INTEGER, "
|
||
"thread_id TEXT, "
|
||
"goal_status TEXT, "
|
||
"state_sha TEXT, "
|
||
"verify_out TEXT, "
|
||
"created_at DOUBLE PRECISION, "
|
||
"PRIMARY KEY(task_id, round_no))")
|
||
|
||
|
||
def _ensure_task_steps_table(conn):
|
||
"""幂等建步骤表(P0 三件其三):timeline 接口直读此表。
|
||
id 由写入方给 uuid hex(两后端都不依赖自增语义)。
|
||
"""
|
||
conn.execute(
|
||
"CREATE TABLE IF NOT EXISTS task_steps("
|
||
"id TEXT PRIMARY KEY, "
|
||
"task_id TEXT, "
|
||
"step_no INTEGER, "
|
||
"kind TEXT, "
|
||
"summary TEXT, "
|
||
"state_sha TEXT, "
|
||
"created_at DOUBLE PRECISION)")
|
||
conn.execute(
|
||
"CREATE INDEX IF NOT EXISTS idx_task_steps_task"
|
||
" ON task_steps(task_id, step_no)")
|
||
|
||
|
||
def _followup_to_dict(row):
|
||
return {
|
||
"id": row["id"],
|
||
"task_id": row["task_id"],
|
||
"project_id": row["project_id"],
|
||
"body": row["body"],
|
||
"mode": row["mode"],
|
||
"status": row["status"],
|
||
"created_by": row["created_by"],
|
||
"sender_task_id": _row_get(row, "sender_task_id"),
|
||
"created_at": row["created_at"],
|
||
"updated_at": row["updated_at"],
|
||
}
|
||
|
||
|
||
def _ensure_continue_messages_table(conn):
|
||
"""幂等建继续消息表:POST /continue 接受的每条用户消息都在
|
||
与状态改回 queued 的同一事务里落盘(见 _continue_task),
|
||
否则继续消息只活在 resume_message 列里、worker 跑完即清,
|
||
聊天历史永远拼不出用户那一半。老库第一次跑到这里时自动
|
||
补表,不丢数据;此前已发生的继续(audit 里 task_continued
|
||
有、表里没有)无法追回,chat-history 会如实标注缺口数,
|
||
不编造。列写法与 followups 表一致(DOUBLE PRECISION 在
|
||
SQLite 与 Postgres 下都可用)。"""
|
||
conn.execute(
|
||
"CREATE TABLE IF NOT EXISTS task_continue_messages("
|
||
"id TEXT PRIMARY KEY, "
|
||
"task_id TEXT, "
|
||
"project_id TEXT, "
|
||
"body TEXT, "
|
||
"created_by TEXT, "
|
||
"created_at DOUBLE PRECISION)"
|
||
)
|
||
conn.execute(
|
||
"CREATE INDEX IF NOT EXISTS idx_continue_messages_task"
|
||
" ON task_continue_messages(task_id, created_at, id)")
|
||
|
||
|
||
def _continue_message_to_dict(row):
|
||
return {
|
||
"id": row["id"],
|
||
"task_id": row["task_id"],
|
||
"project_id": row["project_id"],
|
||
"body": row["body"],
|
||
"created_by": row["created_by"],
|
||
"created_at": row["created_at"],
|
||
}
|
||
|
||
|
||
def _chat_message_from_trace(entry):
|
||
"""链路条目 -> 聊天消息(与 React 的 mapTrace.ts 同款映射,
|
||
两边改动要同步):message 是助手回复,exec/file/tool/call
|
||
是工具气泡,plan/reasoning/agent-start/agent-done 是居中
|
||
状态提示。链路条目本身没有发送方字段,这里不编造角色,
|
||
只按 kind 分流。"""
|
||
kind = entry.get("kind") or ""
|
||
eid = entry.get("id") or ""
|
||
if kind == "message":
|
||
text = ((("[" + entry["phase"] + "] ") if entry.get("phase")
|
||
else "") + (entry.get("text") or ""))
|
||
return {"id": eid, "role": "assistant", "source": "trace",
|
||
"kind": kind, "label": "助手", "text": text}
|
||
if kind in ("exec", "file", "tool", "call"):
|
||
label = {"exec": "命令", "file": "文件",
|
||
"tool": "工具"}.get(kind, "调用")
|
||
if kind == "exec":
|
||
# 与 React traceLineText 同款:缺失字段为空,不渲染 None。
|
||
def _s(v):
|
||
return "" if v is None else str(v)
|
||
text = "%s(exit=%s,%sms,cwd=%s)" % (
|
||
entry.get("command") or "", _s(entry.get("exitCode")),
|
||
_s(entry.get("durationMs")), entry.get("cwd") or "")
|
||
elif kind == "file":
|
||
text = "%s %s %s" % (entry.get("path") or "",
|
||
entry.get("change") or "",
|
||
entry.get("status") or "")
|
||
elif kind == "tool":
|
||
text = "%s/%s %s" % (entry.get("server") or "",
|
||
entry.get("tool") or "",
|
||
entry.get("status") or "")
|
||
else:
|
||
text = entry.get("name") or ""
|
||
return {"id": eid, "role": "tool", "source": "trace",
|
||
"kind": kind, "label": label, "text": text}
|
||
if kind == "plan":
|
||
return {"id": eid, "role": "status", "source": "trace",
|
||
"kind": kind, "text": entry.get("text") or ""}
|
||
if kind == "reasoning":
|
||
return {"id": eid, "role": "status", "source": "trace",
|
||
"kind": kind,
|
||
"text": "思考:" + (entry.get("summary") or "")}
|
||
if kind == "agent-start":
|
||
return {"id": eid, "role": "status", "source": "trace",
|
||
"kind": kind,
|
||
"text": "子代理开始:" + (entry.get("name") or "")
|
||
+ (("(" + entry["prompt"] + ")")
|
||
if entry.get("prompt") else "")}
|
||
if kind == "agent-done":
|
||
return {"id": eid, "role": "status", "source": "trace",
|
||
"kind": kind,
|
||
"text": "子代理结束:" + (entry.get("name") or "")
|
||
+ " " + (entry.get("status") or "")}
|
||
return {"id": eid, "role": "status", "source": "trace",
|
||
"kind": kind, "text": json.dumps(entry)[:300]}
|
||
|
||
|
||
def _ensure_environments_table(conn):
|
||
"""幂等建环境表:server init_db 与各环境接口都可调它,老库
|
||
第一次跑到这里时自动补表,不丢数据。类型写法与 followups
|
||
表一致(DOUBLE PRECISION 在 SQLite 与 Postgres 下都可用)。
|
||
|
||
环境 = 项目仓库的一个命名基准检出:repo_url 是创建时从
|
||
项目快照来的(之后项目改 repo_url 不影响已建环境),branch
|
||
为空表示默认分支,executor 为空表示不限执行器(任务创建时
|
||
若指定执行器须与它一致,否则继承它)。状态机:creating
|
||
(已记录、等 worker 准备检出)-> ready / failed;archive
|
||
后新任务不能再选它,旧任务结果不受影响。检出目录本身不
|
||
存绝对路径(worker 按 repos_dir / 租户目录推导,见 worker
|
||
的 _env_host_dir),避免 server 与 worker 机器路径不一致。
|
||
"""
|
||
conn.execute(
|
||
"CREATE TABLE IF NOT EXISTS environments("
|
||
"id TEXT PRIMARY KEY, "
|
||
"project_id TEXT, "
|
||
"name TEXT, "
|
||
"branch TEXT, "
|
||
"executor TEXT, "
|
||
"status TEXT, "
|
||
"error_text TEXT, "
|
||
"repo_url TEXT, "
|
||
"created_by TEXT, "
|
||
"created_at DOUBLE PRECISION, "
|
||
"updated_at DOUBLE PRECISION)"
|
||
)
|
||
conn.execute(
|
||
"CREATE INDEX IF NOT EXISTS idx_env_project"
|
||
" ON environments(project_id, status)")
|
||
# 同项目未归档环境名唯一:用部分唯一索引(SQLite 与 Postgres
|
||
# 都支持 WHERE 子句),归档过的名字可重用——全表 UNIQUE
|
||
# 会挡住重用,与 _create_environment 的 409 规则矛盾。
|
||
conn.execute(
|
||
"CREATE UNIQUE INDEX IF NOT EXISTS idx_env_name"
|
||
" ON environments(project_id, name)"
|
||
" WHERE status != 'archived'")
|
||
|
||
|
||
def _default_executor():
|
||
"""新任务未指定执行器时的默认值:docker。
|
||
|
||
空 executor(任意 worker 可认领)是多用户下的隔离缺口:
|
||
任务可能落到隔离较弱的 local worker 上。默认 docker 后,
|
||
只有显式 local/cloud 或系统创建(webhook/修复任务,executor
|
||
为 NULL)才走别的路。环境变量 CLOUD_DEFAULT_EXECUTOR 可
|
||
覆盖:设为空字符串即恢复旧行为(None,任意 worker),
|
||
供单机运维在没有 docker worker 时兜底;非法值按 docker 计。
|
||
"""
|
||
raw = os.environ.get("CLOUD_DEFAULT_EXECUTOR", "docker")
|
||
if raw is None:
|
||
return "docker"
|
||
raw = raw.strip()
|
||
if raw == "":
|
||
return None
|
||
if raw in ("local", "docker", "cloud"):
|
||
return raw
|
||
return "docker"
|
||
|
||
|
||
# -- 全自动执行器 HTTP 接线(cloud/full_auto.py 的薄端点层) --------
|
||
# 只做接线,不发明新护栏:开关、动作白名单、分支保护、脱敏、
|
||
# 限次修复全部复用 full_auto.py;本层只负责认证、管理员门、
|
||
# installation 验证、token 解析与审计落盘。token 只进请求头,
|
||
# 绝不进命令行、文件、审计记录与 HTTP 响应。
|
||
|
||
|
||
def _backfill_default_quotas(conn):
|
||
"""给已有无行用户回填默认配额行(init_db 时调,幂等)。
|
||
|
||
只补“users 里有、user_quotas 里无”的用户,已有行(含
|
||
管理员显式设为 NULL 的不限额行)一律不动。返回补写行数。
|
||
|
||
失败只回滚到保存点,绝不回滚调用方的整个事务:init_db
|
||
里 migrate 的 ALTER 与它同事务,旧版 except 里整事务
|
||
rollback 会把 ALTER 一并丢掉(2026-10-31 新鲜 PG 库实测:
|
||
init 后 tasks 缺 paused_by_user / interrupted_reason,合流
|
||
随后在 PG“语句失败即 abort 整个事务”的语义下连环报
|
||
InFailedSqlTransaction)。成功时也不再由本函数提交,提交
|
||
由调用方负责(init_db 结尾统一提交;旧版成功时提交等于
|
||
提前提交 init 的全部 DDL,改后 init 恢复原子性)。调用方
|
||
只有 init_db 两处(均以提交收尾),改动不影响其它路径。
|
||
保存点在 SQLite 与 PG 下都支持;保存点本身建不起来时
|
||
(如连接已处 abort 态)退化为整事务 rollback 再返回 0。
|
||
"""
|
||
_ensure_quotas_table(conn)
|
||
try:
|
||
conn.execute("SAVEPOINT backfill_default_quotas")
|
||
except Exception:
|
||
try:
|
||
conn.rollback()
|
||
except Exception:
|
||
pass
|
||
return 0
|
||
try:
|
||
cur = conn.execute(
|
||
"INSERT OR IGNORE INTO user_quotas(user_id,"
|
||
" quota_usd_micros, updated_at)"
|
||
" SELECT u.id, ?, ? FROM users u"
|
||
" LEFT JOIN user_quotas q ON q.user_id = u.id"
|
||
" WHERE q.user_id IS NULL",
|
||
(_default_quota_usd_micros(), time.time()))
|
||
conn.execute("RELEASE SAVEPOINT backfill_default_quotas")
|
||
return cur.rowcount or 0
|
||
except Exception:
|
||
try:
|
||
conn.execute("ROLLBACK TO SAVEPOINT backfill_default_quotas")
|
||
conn.execute("RELEASE SAVEPOINT backfill_default_quotas")
|
||
except Exception:
|
||
try:
|
||
conn.rollback()
|
||
except Exception:
|
||
pass
|
||
return 0
|
||
|
||
|
||
def _is_postgres_backend():
|
||
backend = os.environ.get("CLOUD_DB_BACKEND") or "sqlite"
|
||
return str(backend).strip().lower() in ("postgres", "postgresql")
|
||
|
||
|
||
def init_db(db_path):
|
||
"""创建 server 自己的 projects/tasks 表(AuthStore 的表由其自建)。
|
||
|
||
Postgres 模式:建 cloud.db.POSTGRES_SCHEMA 的全部表(其中
|
||
projects/tasks 已含 migrate_tasks_table 的 6 列),再跑一次
|
||
migrate_tasks_table——PRAGMA 翻译后在 PG 上是 no-op,老库
|
||
迁移仍走同一函数。SQLite 模式:原行为不变。
|
||
"""
|
||
conn = _connect(db_path)
|
||
try:
|
||
if _is_postgres_backend():
|
||
_init_postgres_schema(conn)
|
||
migrate_tasks_table(conn)
|
||
migrate_projects_table(conn)
|
||
_ensure_followups_table(conn)
|
||
_ensure_continue_messages_table(conn)
|
||
_ensure_environments_table(conn)
|
||
_ensure_quotas_table(conn)
|
||
_backfill_default_quotas(conn)
|
||
_ensure_gateway_keys(conn)
|
||
_ensure_uploads_tables(conn)
|
||
_ensure_task_checkpoints_table(conn)
|
||
_ensure_task_steps_table(conn)
|
||
conn.commit()
|
||
return
|
||
conn.execute(
|
||
"CREATE TABLE IF NOT EXISTS projects("
|
||
"id TEXT PRIMARY KEY, "
|
||
"name TEXT, "
|
||
"owner_id TEXT, "
|
||
"created_at REAL, "
|
||
"repo_url TEXT, "
|
||
"auto_pr INTEGER)"
|
||
)
|
||
conn.execute(
|
||
"CREATE TABLE IF NOT EXISTS tasks("
|
||
"id TEXT PRIMARY KEY, "
|
||
"project_id TEXT, "
|
||
"prompt TEXT, "
|
||
"status TEXT, "
|
||
"idempotency_key TEXT, "
|
||
"depends_on TEXT, "
|
||
"priority INTEGER, "
|
||
"created_by TEXT, "
|
||
"created_at REAL, "
|
||
"updated_at REAL, "
|
||
"last_heartbeat REAL, "
|
||
"UNIQUE(project_id, idempotency_key))"
|
||
)
|
||
migrate_tasks_table(conn)
|
||
migrate_projects_table(conn)
|
||
_ensure_followups_table(conn)
|
||
_ensure_continue_messages_table(conn)
|
||
_ensure_environments_table(conn)
|
||
_ensure_quotas_table(conn)
|
||
_backfill_default_quotas(conn)
|
||
_ensure_uploads_tables(conn)
|
||
_ensure_task_checkpoints_table(conn)
|
||
_ensure_task_steps_table(conn)
|
||
ensure_kill_requests_table(conn)
|
||
# 常驻监听表(P0 S3 的 SQLite 侧):启动时建一次,
|
||
# 热路径(claim/run_due/批量)不再逐条 ensure,
|
||
# 详见 cloud/resident.py ensure 的调用约定。
|
||
_res_ensure(conn)
|
||
# worker 每轮 poll 都按 status 取任务(见 load_tasks 的
|
||
# WHERE status IN (...)),无索引时是全表扫描;任务表随
|
||
# 时间变大后索引是必须的。IF NOT EXISTS 保证老库幂等。
|
||
conn.execute(
|
||
"CREATE INDEX IF NOT EXISTS idx_tasks_status"
|
||
" ON tasks(status)")
|
||
conn.commit()
|
||
finally:
|
||
conn.close()
|
||
|
||
|
||
# 注意:此处曾有一份与 cloud.peer_messages.row_get 逐行相同的本地
|
||
# _row_get(try return row[name] except KeyError/IndexError/TypeError
|
||
# -> None),它遮蔽了文件顶部的同名 import(F811 + mypy no-redef)。
|
||
# 两份实现语义完全相同,删本地定义后全部调用点解析到 import 的
|
||
# 同一份实现,行为不变;见 test_server_row_get_unified 回归测试。
|
||
|
||
|
||
def ensure_kill_requests_table(conn):
|
||
"""建 kill_requests 表(幂等,SQLite/PG 通用写法)。"""
|
||
conn.execute(
|
||
"CREATE TABLE IF NOT EXISTS kill_requests("
|
||
"id TEXT PRIMARY KEY, "
|
||
"task_id TEXT, "
|
||
"requested_by TEXT, "
|
||
"status TEXT, "
|
||
"requested_at DOUBLE PRECISION, "
|
||
"done_at DOUBLE PRECISION)")
|
||
|
||
|
||
def request_kill(db_path, task_id, requested_by):
|
||
"""写一条 kill 请求:同一任务只留一条 pending,返回 True/False。
|
||
|
||
已有 pending 时不重复写(返回 False);否则写一行 pending
|
||
并返回 True。调用方(取消/sweep)必须 best-effort 调用:
|
||
写请求失败绝不能挡住取消本身——任务状态已是 interrupted,
|
||
owner 活着时的 1 秒看门狗仍会杀,收殓器只是补 owner 已死
|
||
的情形。表不存在的老库先建表(与 worker 侧同款守卫)。
|
||
"""
|
||
try:
|
||
conn = _connect(db_path)
|
||
except Exception:
|
||
return False
|
||
try:
|
||
ensure_kill_requests_table(conn)
|
||
row = conn.execute(
|
||
"SELECT id FROM kill_requests"
|
||
" WHERE task_id = ? AND status = 'pending' LIMIT 1",
|
||
(task_id,)).fetchone()
|
||
if row is not None:
|
||
return False
|
||
conn.execute(
|
||
"INSERT INTO kill_requests(id, task_id, requested_by,"
|
||
" status, requested_at, done_at)"
|
||
" VALUES(?, ?, ?, 'pending', ?, NULL)",
|
||
(uuid.uuid4().hex, task_id, requested_by, time.time()))
|
||
conn.commit()
|
||
return True
|
||
except Exception:
|
||
try:
|
||
conn.rollback()
|
||
except Exception:
|
||
pass
|
||
return False
|
||
finally:
|
||
conn.close()
|
||
|
||
|
||
def sweep_stale_tasks(db_path, threshold_seconds=_STALE_SWEEP):
|
||
"""把 running 且心跳过期的任务改为 interrupted,返回被扫任务 id 列表。
|
||
|
||
心跳取 last_heartbeat,无心跳则用 updated_at;早于
|
||
now - threshold_seconds 即过期。审计记一条 "sweep_interrupted"
|
||
(user_id 记 "system",因为回收不是某个用户的操作)。
|
||
这是系统中断:interrupted_reason='sweep'、paused_by_user=0,
|
||
长任务自动恢复可接它(用户暂停绝不走这条路)。
|
||
独立可单测,不依赖 HTTP。
|
||
"""
|
||
now = time.time()
|
||
cutoff = now - threshold_seconds
|
||
conn = _connect(db_path)
|
||
try:
|
||
conn.row_factory = sqlite3.Row
|
||
rows = conn.execute(
|
||
"SELECT id, project_id FROM tasks WHERE status = 'running'"
|
||
" AND COALESCE(last_heartbeat, updated_at) < ?",
|
||
(cutoff,),
|
||
).fetchall()
|
||
swept = []
|
||
for row in rows:
|
||
cur = conn.execute(
|
||
"UPDATE tasks SET status = 'interrupted',"
|
||
" interrupted_reason = 'sweep', paused_by_user = 0,"
|
||
" updated_at = ?"
|
||
" WHERE id = ? AND status = 'running'",
|
||
(now, row["id"]),
|
||
)
|
||
if cur.rowcount:
|
||
swept.append(row["id"])
|
||
# 被扫任务的 owner 可能已死:光改库状态杀不掉它遗留的
|
||
# 容器,顺手写 kill 请求,活着的 worker 在收殓器里按
|
||
# 任务派生容器名在本机真杀(见 worker._reap_kill_requests)。
|
||
# 同一事务内写,状态与请求要么一起落盘、要么一起丢。
|
||
if swept:
|
||
ensure_kill_requests_table(conn)
|
||
for task_id in swept:
|
||
exists = conn.execute(
|
||
"SELECT id FROM kill_requests"
|
||
" WHERE task_id = ? AND status = 'pending' LIMIT 1",
|
||
(task_id,)).fetchone()
|
||
if exists is None:
|
||
conn.execute(
|
||
"INSERT INTO kill_requests(id, task_id,"
|
||
" requested_by, status, requested_at, done_at)"
|
||
" VALUES(?, ?, 'system', 'pending', ?, NULL)",
|
||
(uuid.uuid4().hex, task_id, now))
|
||
conn.commit()
|
||
stale = [(row["id"], row["project_id"]) for row in rows
|
||
if row["id"] in swept]
|
||
finally:
|
||
conn.close()
|
||
with _auth_store(db_path) as store:
|
||
for task_id, project_id in stale:
|
||
store.audit("system", project_id, "sweep_interrupted", task_id)
|
||
return swept
|
||
|
||
|
||
def _validate_hard_gate_body(body):
|
||
"""硬门禁请求体类型校验:畸形字段回 400,不进门禁判定。
|
||
|
||
服务端只有 hard 模式,该校验一律适用。缺失字段不是畸形,
|
||
是无证据,交门禁判 409。返回错误串,无错返回 None。
|
||
"""
|
||
for field in ("ci_checks", "required_checks", "approved_exceptions"):
|
||
value = body.get(field)
|
||
if value is not None and not isinstance(value, list):
|
||
return "%s must be a list" % field
|
||
checks = body.get("ci_checks")
|
||
if isinstance(checks, list):
|
||
for entry in checks:
|
||
if not isinstance(entry, dict) \
|
||
or not isinstance(entry.get("name"), str) \
|
||
or not isinstance(entry.get("status"), str):
|
||
return "ci_checks entries must be objects " \
|
||
"with name and status"
|
||
for field in ("contract_frozen", "coordinator_approved"):
|
||
value = body.get(field)
|
||
if value is not None and not isinstance(value, bool):
|
||
return "%s must be a boolean" % field
|
||
version = body.get("contract_version")
|
||
if version is not None and not isinstance(version, str):
|
||
return "contract_version must be a string"
|
||
expected = body.get("contract_expected_version")
|
||
if expected is not None and not isinstance(expected, str):
|
||
return "contract_expected_version must be a string"
|
||
reviews = body.get("unresolved_review_comments")
|
||
if reviews is not None and not isinstance(reviews, list) \
|
||
and not (isinstance(reviews, int)
|
||
and not isinstance(reviews, bool)):
|
||
return "unresolved_review_comments must be a list or an integer"
|
||
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)
|
||
|
||
|
||
def _validate_continue_options(body, task, goal_target):
|
||
"""_continue_task 的选项校验块(纯函数,原样平移)。
|
||
|
||
返回 (opts, error):error 非空时调用方按原样发 400;
|
||
opts 含 model / effort / child_model / child_model_present /
|
||
child_effort / child_effort_present / approval / plan /
|
||
goal_mode,错误文案逐字不变。
|
||
"""
|
||
model = body.get("model")
|
||
if model is not None:
|
||
if not isinstance(model, str):
|
||
return None, "model must be a string"
|
||
model = model.strip() or None
|
||
effort = body.get("effort")
|
||
if effort is not None and effort not in (
|
||
"low", "medium", "high", "xhigh", "max"):
|
||
return None, "effort must be low, medium, high, xhigh or max"
|
||
# 派生默认同款:child_model 空串 = 清空(跟随本任务
|
||
# model / effort),child_effort 空串 = 清空;不传沿用原值。
|
||
child_model = None
|
||
child_model_present = "child_model" in body
|
||
if child_model_present:
|
||
if body.get("child_model") is not None \
|
||
and not isinstance(body.get("child_model"), str):
|
||
return None, "child_model must be a string"
|
||
raw_child_model = body.get("child_model")
|
||
child_model = raw_child_model.strip() \
|
||
if isinstance(raw_child_model, str) else None
|
||
if child_model == "":
|
||
child_model = None
|
||
child_effort = None
|
||
child_effort_present = "child_effort" in body
|
||
if child_effort_present:
|
||
raw_child_effort = body.get("child_effort")
|
||
if raw_child_effort is None or raw_child_effort == "":
|
||
child_effort = None
|
||
elif raw_child_effort not in (
|
||
"low", "medium", "high", "xhigh", "max"):
|
||
return None, ("child_effort must be low, medium, high,"
|
||
" xhigh or max")
|
||
else:
|
||
child_effort = raw_child_effort
|
||
approval = body.get("approval")
|
||
if approval is not None and approval not in (
|
||
"ask", "auto", "full"):
|
||
return None, "approval must be ask, auto or full"
|
||
plan = None
|
||
if "plan" in body:
|
||
plan = 1 if body["plan"] in (True, 1) else 0
|
||
goal_mode = None
|
||
if "goal_mode" in body:
|
||
goal_mode = 1 if body["goal_mode"] in (True, 1) else 0
|
||
if goal_target is not None:
|
||
# /goal 命令本身就是显式 goal 请求,优先于 body 里的
|
||
# goal_mode=0(消息比选项更具体),plan 冲突仍在下面 400。
|
||
goal_mode = 1
|
||
plan_eff = plan if plan is not None else (task["plan"] or 0)
|
||
goal_eff = goal_mode if goal_mode is not None \
|
||
else (task["goal_mode"] or 0)
|
||
approval_eff = approval if approval is not None \
|
||
else task["approval"]
|
||
if plan_eff and approval_eff == "full":
|
||
return None, "plan 模式与 approval=full 冲突(只读 vs 完全访问)"
|
||
if plan_eff and goal_eff:
|
||
return None, "plan 模式与 goal 模式冲突"
|
||
return {
|
||
"model": model, "effort": effort,
|
||
"child_model": child_model,
|
||
"child_model_present": child_model_present,
|
||
"child_effort": child_effort,
|
||
"child_effort_present": child_effort_present,
|
||
"approval": approval, "plan": plan, "goal_mode": goal_mode,
|
||
}, None
|
||
|
||
|
||
def _validate_wait_params(body):
|
||
"""_task_wait 的参数校验块(纯函数,原样平移)。
|
||
|
||
返回 (timeout, requested, error_payload):error_payload 非空
|
||
时调用方按原样发 400;requested 为 None 表示缺省(等全部
|
||
coordinator 子任务),其余错误文案与 count 键逐字不变。
|
||
"""
|
||
timeout = body.get("timeout_seconds", 30)
|
||
if isinstance(timeout, bool) or not isinstance(
|
||
timeout, (int, float)):
|
||
return None, None, {"error": "timeout_seconds must be a number"}
|
||
if timeout < 0 or timeout > 120:
|
||
return None, None, {
|
||
"error": "timeout_seconds must be between 0 and 120"}
|
||
requested = body.get("task_ids")
|
||
if requested is not None and (
|
||
not isinstance(requested, list) or any(
|
||
not isinstance(x, str) for x in requested)):
|
||
return None, None, {"error": "task_ids must be a list"}
|
||
if requested is not None and len(requested) == 0:
|
||
return None, None, {"error": "task_ids must not be empty"}
|
||
if requested is not None and len(requested) > 8:
|
||
return None, None, {
|
||
"error": "at most 8 threads per wait",
|
||
"count": len(requested)}
|
||
return timeout, requested, None
|
||
|
||
|
||
def _trace_activity(entry):
|
||
"""_task_thread_read 的单条链路活动摘要(纯函数,原样平移)。"""
|
||
if not isinstance(entry, dict):
|
||
return {"kind": "unknown"}
|
||
if entry.get("kind") == "tool":
|
||
# tool 条目没有 text/command,旧代码只取 tool 名,
|
||
# 丢了 server 与 status;拼成可读的一行。
|
||
text = "%s/%s: %s" % (
|
||
entry.get("server") or "",
|
||
entry.get("tool") or "",
|
||
entry.get("status") or "")
|
||
else:
|
||
text = (entry.get("text") or entry.get("command")
|
||
or entry.get("path") or entry.get("summary")
|
||
or entry.get("tool") or "")
|
||
return {"kind": entry.get("kind"),
|
||
"id": entry.get("id"),
|
||
"text": str(text)[:300]}
|
||
|
||
|
||
def _summarize_trace_entries(entries):
|
||
"""_task_thread_read 的链路摘要块(纯函数,原样平移)。
|
||
|
||
返回 (changed_files, commands, recent_activity):改动文件
|
||
去重取后 50、命令取后 20、最近动态取后 10,截断口径不变。
|
||
"""
|
||
# 改动文件 / 命令 / 最近动态:只取链路里已有的事实,
|
||
# 去重与截断在服务端做,不回整条链路原文。
|
||
changed = {}
|
||
for entry in entries:
|
||
if isinstance(entry, dict) and entry.get("kind") == "file" \
|
||
and entry.get("path"):
|
||
changed[entry["path"]] = {
|
||
"path": entry["path"],
|
||
"change": entry.get("change") or "",
|
||
"status": entry.get("status") or ""}
|
||
changed_files = list(changed.values())[-50:]
|
||
commands = [{
|
||
"command": (e.get("command") or "")[:500],
|
||
"exit_code": e.get("exitCode"),
|
||
"duration_ms": e.get("durationMs"),
|
||
} for e in entries if isinstance(e, dict)
|
||
and e.get("kind") == "exec"][-20:]
|
||
recent_activity = [_trace_activity(e) for e in entries[-10:]]
|
||
return changed_files, commands, recent_activity
|
||
|
||
|
||
def _summarize_metrics_tasks(rows, member_ids, now):
|
||
"""_metrics 的任务行聚合块(纯函数,原样平移,两遍合一遍)。
|
||
|
||
返回 (by_status, durations, failures_last_24h,
|
||
running_over_300s):按状态分计数(七个状态键恒存在)、
|
||
完成耗时、近 24h 失败数、跑超 300 秒的 running 数,
|
||
口径与原两遍循环逐字一致。
|
||
"""
|
||
# 按状态分计数:七个状态键恒存在(缺失为 0),调用方
|
||
# 不用再猜某个状态是“零”还是“没统计”。
|
||
by_status = {key: 0 for key in TASK_STATUS_KEYS}
|
||
durations = []
|
||
failures_last_24h = 0
|
||
running_over_300s = 0
|
||
for row in rows:
|
||
if row["project_id"] not in member_ids:
|
||
continue
|
||
by_status[row["status"]] = by_status.get(row["status"], 0) + 1
|
||
if row["started_at"] and row["finished_at"]:
|
||
durations.append(row["finished_at"] - row["started_at"])
|
||
if row["status"] in ("failed", "merge-failed") \
|
||
and row["finished_at"] \
|
||
and now - row["finished_at"] <= 86400:
|
||
failures_last_24h += 1
|
||
if row["status"] == "running" and row["started_at"] \
|
||
and now - row["started_at"] > 300:
|
||
running_over_300s += 1
|
||
return by_status, durations, failures_last_24h, running_over_300s
|
||
|
||
|
||
def _parse_github_comment_with_review_fallback(payload):
|
||
"""GitHub 评论解析:issue_comment 直解,解不出且是
|
||
pull_request_review_comment 时把 pull_request.number 归一化
|
||
成 parser 要的 issue.number 再解一次。纯解析,无 DB、
|
||
无审计、无外部调用;_handle_github_webhook 的原样抽出。"""
|
||
result = parse_github_pr_comment(payload)
|
||
if result is None and payload.get("pull_request") and \
|
||
payload.get("comment"):
|
||
normalized = dict(payload)
|
||
normalized["issue"] = {
|
||
"number": (payload["pull_request"] or {}).get("number")}
|
||
result = parse_github_pr_comment(normalized)
|
||
return result
|
||
|
||
|
||
def _webhook_fix_review_prompt(result):
|
||
"""webhook /fix-review 建任务 prompt:固定模板 + 截断后的
|
||
评论摘录(上限 2000 字),不原样贴评论全文。纯构造。"""
|
||
return (
|
||
"[webhook /fix-review] repo=%s pr=%s author=%s\n%s"
|
||
% (result.get("repo"), result.get("pr_number"),
|
||
result.get("author"),
|
||
(result.get("comment") or "")[:2000]))
|
||
|
||
|
||
def _validate_followup_body(body):
|
||
"""后续消息请求体校验(纯计算):返回 (text, mode, error),
|
||
error 为 None 表示通过,否则为 (http_code, payload),调用方
|
||
按原样发响应并返回。文案与状态码逐字取自 _create_followup
|
||
原代码,不含 DB、审计与建任务。"""
|
||
text = body.get("body")
|
||
if not text or not isinstance(text, str) or not text.strip():
|
||
return None, None, (400, {"error": "body required"})
|
||
text = text.strip()
|
||
if len(text) > 20000:
|
||
return None, None, (400, {"error": "body too long"})
|
||
mode = body.get("mode", "queued")
|
||
if mode not in ("queued", "direct"):
|
||
return None, None, (400, {
|
||
"error": "mode must be queued or direct"})
|
||
return text, mode, None
|
||
|
||
|
||
def _classify_goal_followup(task, goal_target):
|
||
"""后续消息的 /goal 前置判定(纯计算:_create_followup 全量
|
||
用它;_deliver_to_thread 只在 goal_target 非空时用它——
|
||
两者的 goal 前置判定逐字相同,且 queued 与终态互斥、merged
|
||
已先于终态排除,判定顺序差异不可观测;非 goal 路两者语义
|
||
不同,thread 的非 goal 路不进本函数,见调用方注释)。
|
||
|
||
返回 (action, code, payload):
|
||
- ("error", code, payload):调用方按原样发响应/返回;
|
||
- ("convert_queued", None, None):调用方走 queued 原地转 goal
|
||
的副作用路(建任务/审计仍在调用方);
|
||
- ("revive_terminal", None, None):调用方走终态复活转 goal
|
||
的副作用路;
|
||
- ("continue", None, None):落到普通 followup 插入路
|
||
(running 的 /goal 即此路,响应里再标 goal_queued)。
|
||
本函数只读 task 字典与 goal_target,绝不调 DB、审计与
|
||
建任务,调用一次无副作用重复风险。"""
|
||
if goal_target is not None:
|
||
if not goal_target:
|
||
return "error", 400, {"error": "goal 目标不能为空"}
|
||
if task.get("goal_mode") or task.get("verify_command"):
|
||
return "error", 409, {
|
||
"error": "主线已有 goal,/goal 不会重复创建",
|
||
"status": task["status"],
|
||
"goal": task.get("goal") or task.get("prompt"),
|
||
}
|
||
if task.get("plan"):
|
||
return "error", 400, {
|
||
"error": "plan 模式与 goal 模式冲突"}
|
||
if task["status"] == "merged":
|
||
return "error", 409, {
|
||
"error": "已合并任务不能继续(worktree 已删),请分支",
|
||
"status": task["status"],
|
||
}
|
||
if task["status"] == "queued":
|
||
return "convert_queued", None, None
|
||
if task["status"] in TERMINAL_STATUSES:
|
||
return "revive_terminal", None, None
|
||
if task["status"] not in ("queued", "running"):
|
||
return "error", 409, {
|
||
"error": "后续消息仅支持进行中任务",
|
||
"status": task["status"],
|
||
}
|
||
return "continue", None, None
|
||
if task["status"] not in ("queued", "running"):
|
||
return "error", 409, {
|
||
"error": "后续消息仅支持进行中任务",
|
||
"status": task["status"],
|
||
}
|
||
return "continue", None, None
|
||
|
||
|
||
def _seconds_to_ms(seconds):
|
||
"""秒 -> 毫秒整数时间戳,非法/缺失返回 None(不伪造时间戳)。
|
||
_task_chat_history 内嵌 _ms 的原样抽出,纯计算。"""
|
||
try:
|
||
return int(float(seconds) * 1000)
|
||
except (TypeError, ValueError):
|
||
return None
|
||
|
||
|
||
def _chat_history_user_entries(task, cont_rows, fu_rows):
|
||
"""交替历史的用户侧条目(纯构造):首条 prompt、继续消息、
|
||
后续消息,各标 (0, ts_ms, dict)——用户项在同一毫秒排在链路
|
||
项之前靠排序键第一元组 0 保证。行转字典用原有
|
||
_continue_message_to_dict / _followup_to_dict。"""
|
||
merged = []
|
||
if task.get("prompt"):
|
||
merged.append((0, _seconds_to_ms(task.get("created_at")), {
|
||
"id": "task-prompt", "role": "user", "source": "prompt",
|
||
"label": "用户", "text": task["prompt"],
|
||
"ts_ms": _seconds_to_ms(task.get("created_at"))}))
|
||
for r in cont_rows:
|
||
d = _continue_message_to_dict(r)
|
||
merged.append((0, _seconds_to_ms(d["created_at"]), {
|
||
"id": "continue-" + d["id"], "role": "user",
|
||
"source": "continue", "label": "用户",
|
||
"text": d["body"],
|
||
"ts_ms": _seconds_to_ms(d["created_at"])}))
|
||
for r in fu_rows:
|
||
d = _followup_to_dict(r)
|
||
merged.append((0, _seconds_to_ms(d["created_at"]), {
|
||
"id": "followup-" + d["id"], "role": "user",
|
||
"source": "followup", "label": "后续消息",
|
||
"text": d["body"], "ts_ms": _seconds_to_ms(d["created_at"])}))
|
||
return merged
|
||
|
||
|
||
def _chat_history_trace_entries(entries, start_ms):
|
||
"""交替历史的链路侧条目(纯构造):跳过直接发送后续消息在
|
||
链路里留的 fu- 回声(followups 表里同一条已作用户消息返回,
|
||
否则同一条会先用户、再助手各出现一次);有 ts 的条目按 ts
|
||
排序,无 ts 的按链路顺序贴在上一条有 ts 的条目之后、输出
|
||
ts_ms 为 null,不伪造时间戳。返回 (1, sort_ms, dict) 列表。"""
|
||
merged = []
|
||
last_trace_ms = start_ms
|
||
for e in entries:
|
||
if not isinstance(e, dict):
|
||
continue
|
||
if str(e.get("id") or "").startswith("fu-"):
|
||
continue
|
||
msg = _chat_message_from_trace(e)
|
||
ts = e.get("ts")
|
||
if isinstance(ts, (int, float)):
|
||
last_trace_ms = int(ts)
|
||
sort_ms = int(ts)
|
||
else:
|
||
sort_ms = last_trace_ms
|
||
msg["ts_ms"] = int(ts) if isinstance(ts, (int, float)) else None
|
||
merged.append((1, sort_ms, msg))
|
||
return merged
|
||
|
||
|
||
def _thread_non_goal_guard(task):
|
||
"""_deliver_to_thread 在 /goal 判定落空后的非 goal 门禁
|
||
(纯计算):goal 任务与 merged 各 409,返回 (code, payload);
|
||
通过返回 None。注意 merged 在前置判定里已先于终态排除,
|
||
这里是普通(非 goal)路的第二次 merged 门禁,原文顺序
|
||
是 goal 判定 -> 本门禁 -> 终态 CAS 复活,调用方保持该顺序。"""
|
||
if task.get("verify_command") or task.get("goal_mode"):
|
||
return 409, {"error": "goal 任务暂不支持后续消息"}
|
||
if task["status"] == "merged":
|
||
return 409, {
|
||
"error": "已合并任务不能继续(worktree 已删),请分支",
|
||
"status": task["status"]}
|
||
return None
|
||
|
||
class CloudControlPlaneHandler(OAuthMixin, IdentityMemoryMixin, ProjectMixin, AutomationMixin, FullAutoMixin,
|
||
BaseHTTPRequestHandler):
|
||
"""控制面请求处理:除 /health 与 /webhooks/* 外一律要求 X-API-Key。"""
|
||
|
||
protocol_version = "HTTP/1.1"
|
||
|
||
# -- 通用工具 ------------------------------------------------------
|
||
def log_message(self, *args): # 测试输出保持干净
|
||
pass
|
||
|
||
def _send_json(self, status, obj, extra_headers=None):
|
||
body = json.dumps(obj, default=_json_default).encode("utf-8")
|
||
self.send_response(status)
|
||
self.send_header("Content-Type", "application/json")
|
||
self.send_header("Content-Length", str(len(body)))
|
||
for header_name, header_value in (extra_headers or {}).items():
|
||
self.send_header(header_name, header_value)
|
||
self.end_headers()
|
||
# 发头即算已响应:之后 wfile 写失败(客户端已断)也不应再
|
||
# 尝试回 500——响应一旦开始就不能再发第二个状态行。
|
||
self._responded = True
|
||
self.wfile.write(body)
|
||
|
||
def _send_html(self, status, html_body):
|
||
body = html_body.encode("utf-8")
|
||
self.send_response(status)
|
||
self.send_header("Content-Type", "text/html; charset=utf-8")
|
||
self.send_header("Content-Length", str(len(body)))
|
||
self.end_headers()
|
||
self._responded = True
|
||
self.wfile.write(body)
|
||
|
||
def _read_body(self):
|
||
try:
|
||
length = int(self.headers.get("Content-Length") or 0)
|
||
except ValueError:
|
||
length = 0
|
||
if length <= 0:
|
||
return b""
|
||
return self.rfile.read(length)
|
||
|
||
def _read_json(self):
|
||
"""返回 (obj, error):空 body 视为 {};非法 JSON 返回 error。"""
|
||
raw = self._read_body()
|
||
if not raw:
|
||
return {}, None
|
||
try:
|
||
obj = json.loads(raw.decode("utf-8"))
|
||
except (ValueError, UnicodeDecodeError):
|
||
return None, "invalid json"
|
||
if not isinstance(obj, dict):
|
||
return None, "body must be a JSON object"
|
||
return obj, None
|
||
|
||
def _db(self):
|
||
conn = _connect(self.server.db_path)
|
||
conn.row_factory = sqlite3.Row
|
||
return conn
|
||
|
||
def _authenticate(self):
|
||
"""X-API-Key -> user_id;无 key 或 key 错返回 None。"""
|
||
api_key = self.headers.get("X-API-Key")
|
||
if not api_key:
|
||
return None
|
||
with _auth_store(self.server.db_path) as store:
|
||
return store.authenticate(api_key)
|
||
|
||
# -- 入口 ----------------------------------------------------------
|
||
def do_GET(self):
|
||
self._dispatch_safe("GET")
|
||
|
||
def do_POST(self):
|
||
self._dispatch_safe("POST")
|
||
|
||
def do_DELETE(self):
|
||
self._dispatch_safe("DELETE")
|
||
|
||
def do_PATCH(self):
|
||
# PATCH 之前没有入口(BaseHTTPRequestHandler 默认回 501),
|
||
# 项目补仓库是第一个 PATCH 路由;与其它方法一样走 _dispatch。
|
||
self._dispatch_safe("PATCH")
|
||
|
||
def do_PUT(self):
|
||
# PUT 与 PATCH 同理:此前无入口,/users/me/profile 是
|
||
# 第一个 PUT 路由;未知 PUT 路径仍由 _dispatch 回 404。
|
||
self._dispatch_safe("PUT")
|
||
|
||
def _dispatch_safe(self, method):
|
||
"""分发兜底:_dispatch 里任何未捕获异常都回 500 JSON,而不是
|
||
让请求线程直接断连(2026-10-27 死锁事件就是这么变成前端
|
||
“HTTP 502”的:异常逃出 handle_one_request 后连接无响应,
|
||
nginx 只能报 502,客户端拿不到任何可读错误)。
|
||
|
||
500 体只写固定 internal_error,不把堆栈泄漏给客户端;
|
||
堆栈走 _server_log.exception 进服务端日志。已开始响应
|
||
(SSE 流、_send_json/_send_html 已发头)时不再二次发送,
|
||
只记日志——响应一旦开始就不能再发第二个状态行。
|
||
_responded 每请求重置(同一连接 keep-alive 复用 handler
|
||
对象时不串请求)。
|
||
"""
|
||
self._responded = False
|
||
try:
|
||
self._dispatch(method)
|
||
except Exception:
|
||
_server_log.exception("unhandled error in %s %s",
|
||
method, self.path)
|
||
if not self._responded:
|
||
try:
|
||
self._send_json(500, {"error": "internal_error"})
|
||
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("/release") and method == "POST":
|
||
self._release_task(user_id, rest[: -len("/release")])
|
||
elif rest.endswith("/audit-verdict") and method == "POST":
|
||
self._audit_loop_verdict(user_id,
|
||
rest[: -len("/audit-verdict")])
|
||
elif rest.endswith("/audit-fix") and method == "POST":
|
||
self._audit_loop_fix(user_id, rest[: -len("/audit-fix")])
|
||
elif rest.endswith("/audit-correction") and method == "POST":
|
||
self._audit_loop_correction(
|
||
user_id, rest[: -len("/audit-correction")])
|
||
elif rest.endswith("/audit-rebuild-confirm") and method == "POST":
|
||
self._audit_loop_confirm(
|
||
user_id, rest[: -len("/audit-rebuild-confirm")])
|
||
elif rest.endswith("/audit-loop") and method == "GET":
|
||
self._audit_loop_get(user_id, rest[: -len("/audit-loop")],
|
||
query)
|
||
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
|
||
if len(path) > 1 and path.endswith("/"):
|
||
path = path.rstrip("/")
|
||
query = parse_qs(parsed.query)
|
||
|
||
if path == "/health" and method == "GET":
|
||
# 健康检查不走限流与认证;db_ok 只做一次轻量查询,
|
||
# 绝不返回连接串、密码或任何凭据。
|
||
db_ok = False
|
||
try:
|
||
conn = _connect(self.server.db_path)
|
||
try:
|
||
conn.execute("SELECT 1").fetchone()
|
||
db_ok = True
|
||
finally:
|
||
conn.close()
|
||
except Exception:
|
||
db_ok = False
|
||
# worker 字段:worker_ok 只表示“worker 心跳新鲜”,worker
|
||
# 不 ok 不代表 server 挂了,所以 health 本身仍返回 200,
|
||
# 调用方看 worker_ok 字段判断,不要把 health 改成 503
|
||
# 破坏已有调用(monitor.sh 也是按字段判断,不按状态码)。
|
||
age, worker_ok = _worker_heartbeat_status(self.server)
|
||
self._send_json(200, {
|
||
"ok": True, "service": "cloud-control-plane",
|
||
"backend": _db_backend_name(), "db_ok": db_ok,
|
||
"worker_last_seen_seconds_ago": age,
|
||
"worker_ok": worker_ok,
|
||
# 上线证据:health 通过不再单独证明新代码,
|
||
# 部署验真必须同时核对 commit 等于期望值(见 deploy.py)。
|
||
"commit": _deployed_commit(),
|
||
})
|
||
return
|
||
if path == "/console" and method == "GET":
|
||
# 命令台 v1:只出静态壳,不走鉴权与限流(与 /health 同款
|
||
# pre-auth 待遇)。壳里没有任何数据;数据一律凭用户在
|
||
# 浏览器里填的 X-API-Key 调接口,key 只存在用户浏览器
|
||
# localStorage。页面文件与 server.py 同目录,改动随部署走。
|
||
try:
|
||
here = os.path.dirname(os.path.abspath(__file__))
|
||
with open(os.path.join(here, "console.html"),
|
||
"r", encoding="utf-8") as f:
|
||
self._send_html(200, f.read())
|
||
except OSError:
|
||
self._send_json(404, {"error": "console not found"})
|
||
return
|
||
if path.startswith("/oauth/"):
|
||
# OAuth 登录/回调是 pre-auth 端点(用户还没本站 API key,
|
||
# 这正是 OAuth 要解决的问题),靠 state + client_secret
|
||
# 认证,不走 X-API-Key;不要把它当成无认证后门。
|
||
# 绑定端点例外:绑定必须知道绑到哪个已有账号,所以在
|
||
# 处理器内验 X-API-Key(无 key 401),分发仍走这支。
|
||
bind = self._oauth_bind_route(path)
|
||
if bind is not None:
|
||
provider, action = bind
|
||
if method == "POST" and action == "bind_initiate":
|
||
self._oauth_bind_initiate(provider)
|
||
elif method == "GET" and action == "bindings":
|
||
self._oauth_bindings_get(provider)
|
||
elif method == "DELETE" and action == "bindings":
|
||
self._oauth_bindings_delete(provider)
|
||
else:
|
||
self._send_json(404, {"error": "not found"})
|
||
return
|
||
if method == "GET" and path.endswith("/login"):
|
||
self._oauth_login(path)
|
||
elif method == "GET" and path.endswith("/callback"):
|
||
# 真实浏览器流程:provider 重定向到这里,query 带
|
||
# code + state(不带 client_id,靠 state 记录取回)。
|
||
self._oauth_callback_get(path)
|
||
elif method == "POST" and path.endswith("/callback"):
|
||
self._oauth_callback(path)
|
||
else:
|
||
self._send_json(404, {"error": "not found"})
|
||
return
|
||
if path == "/users/me/gateway/rotate" and method == "POST":
|
||
self._user_gateway_rotate()
|
||
return
|
||
if path.startswith("/users/me/keys"):
|
||
# API keys 管理:全部只操作认证用户自己的 key。
|
||
if method == "GET" and path == "/users/me/keys":
|
||
self._user_keys_list()
|
||
elif method == "POST" and path == "/users/me/keys":
|
||
self._user_keys_create()
|
||
elif method == "POST" and \
|
||
path == "/users/me/keys/revoke-others":
|
||
self._user_keys_revoke_others()
|
||
elif method == "DELETE" and \
|
||
path.startswith("/users/me/keys/"):
|
||
key_id = path[len("/users/me/keys/"):]
|
||
if key_id and "/" not in key_id:
|
||
self._user_keys_revoke(key_id)
|
||
else:
|
||
self._send_json(404, {"error": "not found"})
|
||
else:
|
||
self._send_json(404, {"error": "not found"})
|
||
return
|
||
if path.startswith("/github-app/"):
|
||
# GitHub App 一键安装:setup/state 与 status 须持本站 key
|
||
# (在处理器内验);setup 表单页与 callback 靠 setup
|
||
# state 凭证(一次性、1 小时),不走 X-API-Key(浏览器
|
||
# 回调带不了请求头,state 就是凭证)。
|
||
if method == "POST" and path == "/github-app/setup/state":
|
||
self._github_app_setup_state()
|
||
elif method == "GET" and path == "/github-app/setup":
|
||
self._github_app_setup_page()
|
||
elif method == "GET" and path == "/github-app/callback":
|
||
self._github_app_callback()
|
||
elif method == "GET" and path == "/github-app/status":
|
||
self._github_app_status()
|
||
else:
|
||
self._send_json(404, {"error": "not found"})
|
||
return
|
||
if path.startswith("/webhooks/"):
|
||
# webhook 靠签名认证(外部服务无法持 X-API-Key),不走 API key。
|
||
if method != "POST":
|
||
self._send_json(404, {"error": "not found"})
|
||
return
|
||
if path == "/webhooks/github":
|
||
self._handle_github_webhook()
|
||
elif path == "/webhooks/slack":
|
||
self._handle_slack_webhook()
|
||
else:
|
||
self._send_json(404, {"error": "not found"})
|
||
return
|
||
|
||
# 限流在认证之前、健康/OAuth/webhook 之后:超限返回 429,
|
||
# 不是 401(不要把“请求太频繁”记成“认证失败”)。
|
||
# key:有 X-API-Key 时按 key 的 sha256 计(原始 key 不存),
|
||
# 无 key 时按客户端 IP 计。
|
||
limiter = getattr(self.server, "rate_limiter", None)
|
||
if limiter is None:
|
||
limiter = _make_rate_limiter(self.server.db_path)
|
||
self.server.rate_limiter = limiter
|
||
# 测试注入:server.rate_limit_per_minute / rate_limit_window_seconds
|
||
# 改动后即时生效,不用重启 server。
|
||
if getattr(self.server, "rate_limit_per_minute", None) is not None:
|
||
limiter.per_minute = self.server.rate_limit_per_minute
|
||
if getattr(self.server, "rate_limit_window_seconds", None) is not None:
|
||
limiter.window_seconds = self.server.rate_limit_window_seconds
|
||
api_key_header = self.headers.get("X-API-Key")
|
||
if api_key_header:
|
||
limit_key = "key:" + hashlib.sha256(
|
||
api_key_header.encode("utf-8")).hexdigest()
|
||
else:
|
||
limit_key = "ip:" + (self.client_address[0]
|
||
if self.client_address else "unknown")
|
||
if not limiter.allow(limit_key):
|
||
# 429 限流:只加 Retry-After 头(值为限流窗口秒数,
|
||
# 即 limiter.window_seconds,默认 60),不改阈值、
|
||
# 不改计数逻辑、不加白名单。客户端据此退避一个完整
|
||
# 窗口,而不是以原速率继续 hammer 把自己钉在限流里
|
||
# (Postgres 下 DbRateLimiter 先 INSERT 再 COUNT,
|
||
# 被拒请求本身也计入窗口,这是 abuse 缓解的本意)。
|
||
self._send_json(
|
||
429, {"error": "rate_limited"},
|
||
extra_headers={"Retry-After": str(limiter.window_seconds)})
|
||
return
|
||
|
||
user_id = self._authenticate()
|
||
if user_id is None:
|
||
self._send_json(401, {"error": "unauthorized"})
|
||
return
|
||
|
||
if path == "/full-auto/status" and method == "GET":
|
||
self._full_auto_status(user_id, query)
|
||
elif path == "/full-auto/config-plan" and method == "GET":
|
||
self._full_auto_config_plan(user_id, query)
|
||
elif path == "/full-auto/validate-action" and method == "POST":
|
||
self._full_auto_validate_action(user_id)
|
||
elif path == "/full-auto/merge-gate" and method == "POST":
|
||
self._full_auto_merge_gate(user_id)
|
||
elif path == "/full-auto/ci-fix" and method == "POST":
|
||
self._full_auto_ci_fix(user_id)
|
||
elif path == "/full-auto/release-plan" and method == "POST":
|
||
self._full_auto_release_plan(user_id)
|
||
elif path == "/full-auto/rollback-plan" and method == "POST":
|
||
self._full_auto_rollback_plan(user_id)
|
||
elif path == "/full-auto/execute" and method == "POST":
|
||
self._full_auto_execute(user_id)
|
||
elif path == "/full-auto/audit" and method == "GET":
|
||
self._full_auto_audit_list(user_id, query)
|
||
elif path == "/projects" and method == "POST":
|
||
self._create_project(user_id)
|
||
elif path == "/projects" and method == "GET":
|
||
self._list_projects(user_id)
|
||
elif path == "/workers" and method == "GET":
|
||
self._workers_list(user_id)
|
||
elif path == "/workers/heartbeat" and method == "POST":
|
||
self._workers_heartbeat(user_id)
|
||
elif path == "/metrics" and method == "GET":
|
||
self._metrics(user_id)
|
||
elif path == "/metrics-report" and method == "GET":
|
||
self._metrics_report(user_id)
|
||
elif path == "/audit-scorecard" and method == "GET":
|
||
self._audit_scorecard(user_id)
|
||
elif path == "/models" and method == "GET":
|
||
self._models_overview(user_id)
|
||
elif path == "/relay/models" and method == "GET":
|
||
self._relay_models(user_id)
|
||
elif path == "/github/repos" and method == "GET":
|
||
self._github_repos_list(user_id)
|
||
elif path.startswith("/projects/"):
|
||
self._dispatch_project(user_id, method, path, query)
|
||
elif path == "/environments" and method == "POST":
|
||
self._create_environment(user_id)
|
||
elif path == "/environments" and method == "GET":
|
||
self._list_environments(user_id, query)
|
||
elif path.startswith("/environments/"):
|
||
rest = path[len("/environments/"):]
|
||
if "/" not in rest and method == "GET":
|
||
self._get_environment(user_id, rest)
|
||
elif rest.endswith("/archive") and method == "POST":
|
||
self._archive_environment(user_id, rest[: -len("/archive")])
|
||
else:
|
||
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/cost" and method == "GET":
|
||
self._user_cost_self(user_id, query)
|
||
elif path == "/admin/cost-by-creator" and method == "GET":
|
||
self._admin_cost_by_creator(user_id, query)
|
||
elif path == "/admin/ops/approval-timeout" and method == "GET":
|
||
self._admin_approval_timeout_ops(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"):
|
||
self._identity_profile_upsert(user_id)
|
||
elif path == "/users/me/memories" and method == "GET":
|
||
self._identity_user_memories_list(user_id)
|
||
elif path == "/users/me/memories" and method == "POST":
|
||
self._identity_user_memory_create(user_id)
|
||
elif path == "/users/me/identity-context" and method == "GET":
|
||
self._identity_context(user_id, query)
|
||
elif path == "/users/me/auto-memory-settings" and method == "GET":
|
||
self._auto_memory_settings_get(user_id)
|
||
elif path == "/users/me/auto-memory-settings" and \
|
||
method in ("PUT", "POST"):
|
||
self._auto_memory_settings_upsert(user_id)
|
||
elif path == "/users/me/auto-memory-log" and method == "GET":
|
||
self._auto_memory_log(user_id, query)
|
||
elif path == "/users/me/auto-memory/run" and method == "POST":
|
||
self._auto_memory_run(user_id)
|
||
elif path.startswith("/users/me/memories/"):
|
||
_mid = path[len("/users/me/memories/"):]
|
||
if _mid and "/" not in _mid and method == "DELETE":
|
||
self._identity_user_memory_delete(user_id, _mid)
|
||
elif _mid.endswith("/supersede") and method == "POST":
|
||
self._identity_user_memory_supersede(
|
||
user_id, _mid[: -len("/supersede")].rstrip("/"))
|
||
else:
|
||
self._send_json(404, {"error": "not found"})
|
||
elif path == "/tasks" and method == "POST":
|
||
self._create_task(user_id)
|
||
elif path == "/uploads" and method == "POST":
|
||
self._create_upload(user_id)
|
||
elif path == "/tasks" and method == "GET":
|
||
self._list_tasks(user_id, query)
|
||
elif path == "/admin/sweep" and method == "POST":
|
||
self._admin_sweep(user_id)
|
||
elif path == "/admin/backup" and method == "POST":
|
||
self._admin_backup(user_id)
|
||
elif path == "/admin/quotas" and method == "GET":
|
||
self._admin_quota_get(user_id, query)
|
||
elif path == "/admin/quotas" and method == "POST":
|
||
self._admin_quota_set(user_id)
|
||
elif path == "/admin/audit-ledger/verify" and method == "GET":
|
||
self._admin_ledger_verify(user_id)
|
||
elif path == "/admin/audit-ledger/export" and method == "GET":
|
||
self._admin_ledger_export(user_id, query)
|
||
elif path == "/admin/push/dispatch" and method == "POST":
|
||
self._push_dispatch_endpoint(user_id)
|
||
elif path.startswith("/tasks/"):
|
||
self._dispatch_task_route(user_id, method, path, query)
|
||
else:
|
||
self._send_json(404, {"error": "not found"})
|
||
|
||
|
||
def _dispatch_project(self, user_id, method, path, query):
|
||
rest = path[len("/projects/"):]
|
||
parts = rest.split("/")
|
||
project_id = parts[0] if parts else ""
|
||
if not project_id:
|
||
self._send_json(404, {"error": "not found"})
|
||
return
|
||
if len(parts) == 1 and method == "PATCH":
|
||
self._update_project(user_id, project_id)
|
||
return
|
||
# /projects/{id}/members:GET 列表 / POST 邀请
|
||
if len(parts) == 2 and parts[1] == "members":
|
||
if method == "GET":
|
||
self._project_members_list(user_id, project_id)
|
||
elif method == "POST":
|
||
self._project_member_invite(user_id, project_id)
|
||
else:
|
||
self._send_json(404, {"error": "not found"})
|
||
return
|
||
# /projects/{id}/members/{user_id}:PATCH 改角色 / DELETE 删成员
|
||
if len(parts) == 3 and parts[1] == "members":
|
||
if method == "PATCH":
|
||
self._project_member_patch(user_id, project_id, parts[2])
|
||
elif method == "DELETE":
|
||
self._project_member_delete(user_id, project_id, parts[2])
|
||
else:
|
||
self._send_json(404, {"error": "not found"})
|
||
return
|
||
# /projects/{id}/identity-memories (project-scope system memory)
|
||
if len(parts) == 2 and parts[1] == "identity-memories":
|
||
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._project_identity_memory_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._project_identity_memories_list(project_id, query)
|
||
else:
|
||
self._send_json(404, {"error": "not found"})
|
||
return
|
||
# /projects/{id}/identity-memories/{mid}/supersede
|
||
if len(parts) == 4 and parts[1] == "identity-memories" \
|
||
and parts[3] == "supersede":
|
||
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._project_identity_memory_supersede(
|
||
user_id, project_id, parts[2])
|
||
return
|
||
# /projects/{id}/identity-memories/{mid}
|
||
if len(parts) == 3 and parts[1] == "identity-memories":
|
||
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
|
||
self._project_identity_memory_delete(
|
||
user_id, project_id, parts[2])
|
||
return
|
||
# /projects/{id}/memory
|
||
if len(parts) == 2 and parts[1] == "memory":
|
||
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._memory_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._memory_list(project_id, query)
|
||
else:
|
||
self._send_json(404, {"error": "not found"})
|
||
return
|
||
# /projects/{id}/memory/{mid}/supersede
|
||
if len(parts) == 4 and parts[1] == "memory" and parts[3] == "supersede":
|
||
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._memory_supersede(user_id, project_id, parts[2])
|
||
return
|
||
# /projects/{id}/kb/index
|
||
if len(parts) == 3 and parts[1] == "kb" and parts[2] == "index":
|
||
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._kb_index(user_id, project_id)
|
||
return
|
||
# /projects/{id}/kb/search
|
||
if len(parts) == 3 and parts[1] == "kb" and parts[2] == "search":
|
||
if method != "GET":
|
||
self._send_json(404, {"error": "not found"})
|
||
return
|
||
err = self._check_project(user_id, project_id, "read")
|
||
if err:
|
||
self._send_json(err[0], {"error": err[1]})
|
||
return
|
||
self._kb_search(project_id, query)
|
||
return
|
||
# /projects/{id}/cost
|
||
if len(parts) == 2 and parts[1] == "cost":
|
||
if method != "GET":
|
||
self._send_json(404, {"error": "not found"})
|
||
return
|
||
err = self._check_project(user_id, project_id, "read")
|
||
if err:
|
||
self._send_json(err[0], {"error": err[1]})
|
||
return
|
||
self._project_cost(project_id, query)
|
||
return
|
||
# /projects/{id}/push/subscriptions
|
||
if len(parts) == 3 and parts[1] == "push" and parts[2] == "subscriptions":
|
||
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._push_create_endpoint(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._send_json(200, {
|
||
"subscriptions": _push_list(
|
||
self.server.db_path, project_id, user_id=user_id)})
|
||
else:
|
||
self._send_json(404, {"error": "not found"})
|
||
return
|
||
# /projects/{id}/push/subscriptions/{sid} DELETE
|
||
if len(parts) == 4 and parts[1] == "push" and parts[2] == "subscriptions":
|
||
self._project_push_subscription_delete(
|
||
user_id, method, project_id, parts)
|
||
return
|
||
# /projects/{id}/automations
|
||
if len(parts) == 2 and parts[1] == "automations":
|
||
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._automation_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._automation_list(project_id)
|
||
else:
|
||
self._send_json(404, {"error": "not found"})
|
||
return
|
||
# /projects/{id}/automations/{aid}:GET 单条(含最近产出)
|
||
# / PATCH 更新(automation_update)
|
||
if len(parts) == 3 and parts[1] == "automations":
|
||
if method == "GET":
|
||
err = self._check_project(user_id, project_id, "read")
|
||
if err:
|
||
self._send_json(err[0], {"error": err[1]})
|
||
return
|
||
self._automation_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._automation_update(user_id, project_id, parts[2])
|
||
else:
|
||
self._send_json(404, {"error": "not found"})
|
||
return
|
||
# /projects/{id}/automations/{aid}/cancel
|
||
if len(parts) == 4 and parts[1] == "automations" \
|
||
and parts[3] == "cancel":
|
||
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._automation_cancel(user_id, project_id, parts[2])
|
||
return
|
||
# /projects/{id}/automations/{aid}/bind(GET/POST/DELETE)
|
||
# / /heartbeat(POST):心跳绑定与唤醒,见 handlers。
|
||
if len(parts) == 4 and parts[1] == "automations" \
|
||
and parts[3] in ("bind", "heartbeat"):
|
||
if parts[3] == "bind" and method == "GET":
|
||
err = self._check_project(user_id, project_id, "read")
|
||
if err:
|
||
self._send_json(err[0], {"error": err[1]})
|
||
return
|
||
self._automation_bind_get(user_id, project_id, parts[2])
|
||
elif parts[3] == "bind" and method == "POST":
|
||
err = self._check_project(user_id, project_id,
|
||
"create_task")
|
||
if err:
|
||
self._send_json(err[0], {"error": err[1]})
|
||
return
|
||
self._automation_bind(user_id, project_id, parts[2])
|
||
elif parts[3] == "bind" and method == "DELETE":
|
||
err = self._check_project(user_id, project_id,
|
||
"create_task")
|
||
if err:
|
||
self._send_json(err[0], {"error": err[1]})
|
||
return
|
||
self._automation_unbind(user_id, project_id, parts[2])
|
||
elif parts[3] == "heartbeat" and method == "POST":
|
||
err = self._check_project(user_id, project_id,
|
||
"create_task")
|
||
if err:
|
||
self._send_json(err[0], {"error": err[1]})
|
||
return
|
||
self._automation_heartbeat(user_id, project_id, parts[2])
|
||
else:
|
||
self._send_json(404, {"error": "not found"})
|
||
return
|
||
# /projects/{id}/triage
|
||
if len(parts) == 2 and parts[1] == "triage":
|
||
if method != "GET":
|
||
self._send_json(404, {"error": "not found"})
|
||
return
|
||
err = self._check_project(user_id, project_id, "read")
|
||
if err:
|
||
self._send_json(err[0], {"error": err[1]})
|
||
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":
|
||
if method == "GET":
|
||
err = self._check_project(user_id, project_id, "read")
|
||
if err:
|
||
self._send_json(err[0], {"error": err[1]})
|
||
return
|
||
self._todos_list(project_id, query)
|
||
elif method == "POST":
|
||
err = self._check_project(
|
||
user_id, project_id, "create_task")
|
||
if err:
|
||
self._send_json(err[0], {"error": err[1]})
|
||
return
|
||
self._todos_create(user_id, project_id)
|
||
else:
|
||
self._send_json(404, {"error": "not found"})
|
||
return
|
||
if len(parts) == 3 and parts[1] == "todos":
|
||
if method == "GET":
|
||
err = self._check_project(user_id, project_id, "read")
|
||
if err:
|
||
self._send_json(err[0], {"error": err[1]})
|
||
return
|
||
self._todos_get(project_id, parts[2])
|
||
elif method in ("PATCH", "DELETE"):
|
||
err = self._check_project(
|
||
user_id, project_id, "create_task")
|
||
if err:
|
||
self._send_json(err[0], {"error": err[1]})
|
||
return
|
||
if method == "PATCH":
|
||
self._todos_update(user_id, project_id, parts[2])
|
||
else:
|
||
self._todos_delete(user_id, project_id, parts[2])
|
||
else:
|
||
self._send_json(404, {"error": "not found"})
|
||
return
|
||
# /projects/{id}/skills GET(项目 read)
|
||
if len(parts) == 2 and parts[1] == "skills":
|
||
if method != "GET":
|
||
self._send_json(404, {"error": "not found"})
|
||
return
|
||
err = self._check_project(user_id, project_id, "read")
|
||
if err:
|
||
self._send_json(err[0], {"error": err[1]})
|
||
return
|
||
self._skills_list(project_id)
|
||
return
|
||
# /projects/{id}/apps GET(项目 read)
|
||
if len(parts) == 2 and parts[1] == "apps":
|
||
if method != "GET":
|
||
self._send_json(404, {"error": "not found"})
|
||
return
|
||
err = self._check_project(user_id, project_id, "read")
|
||
if err:
|
||
self._send_json(err[0], {"error": err[1]})
|
||
return
|
||
self._apps_list(project_id)
|
||
return
|
||
# /projects/{id}/mcp-servers GET(项目 read)
|
||
if len(parts) == 2 and parts[1] == "mcp-servers":
|
||
if method != "GET":
|
||
self._send_json(404, {"error": "not found"})
|
||
return
|
||
err = self._check_project(user_id, project_id, "read")
|
||
if err:
|
||
self._send_json(err[0], {"error": err[1]})
|
||
return
|
||
self._mcp_servers_list(project_id)
|
||
return
|
||
# /projects/{id}/mcp-servers/reload POST(项目 manage_project)
|
||
if len(parts) == 3 and parts[1] == "mcp-servers" \
|
||
and parts[2] == "reload":
|
||
if method != "POST":
|
||
self._send_json(404, {"error": "not found"})
|
||
return
|
||
err = self._check_project(user_id, project_id,
|
||
"manage_project")
|
||
if err:
|
||
self._send_json(err[0], {"error": err[1]})
|
||
return
|
||
self._mcp_servers_reload(user_id, project_id)
|
||
return
|
||
# /projects/{id}/mcp-servers/{name}/oauth-login POST
|
||
# (项目 manage_project;name 为空或穿越时 404)
|
||
if len(parts) == 4 and parts[1] == "mcp-servers" \
|
||
and parts[3] == "oauth-login":
|
||
if method != "POST":
|
||
self._send_json(404, {"error": "not found"})
|
||
return
|
||
if not parts[2] or parts[2] in (".", ".."):
|
||
self._send_json(404, {"error": "not found"})
|
||
return
|
||
err = self._check_project(user_id, project_id,
|
||
"manage_project")
|
||
if err:
|
||
self._send_json(err[0], {"error": err[1]})
|
||
return
|
||
self._mcp_server_oauth_login(user_id, project_id, parts[2])
|
||
return
|
||
self._send_json(404, {"error": "not found"})
|
||
|
||
|
||
# -- 任务 ----------------------------------------------------------
|
||
# -- 任务附件 ------------------------------------------------------
|
||
def _create_upload(self, user_id):
|
||
"""上传一包附件,返回 upload_id(建任务时绑定)。
|
||
|
||
权限:任何持有效 key 的用户都可上传(包本身不挂项目,
|
||
归属在建任务时校验);包不直接进任务,必须经 _create_task
|
||
绑定才生效。413 预检在读 body 之前按 Content-Length 卡,
|
||
防止超大 body 先占内存;文件级上限由 validate_bundle 卡。
|
||
"""
|
||
content_type = self.headers.get("Content-Type", "")
|
||
if "multipart/form-data" not in content_type:
|
||
self._send_json(400, {
|
||
"error": "Content-Type 必须是 multipart/form-data"})
|
||
return
|
||
try:
|
||
length = int(self.headers.get("Content-Length") or 0)
|
||
except ValueError:
|
||
length = 0
|
||
if length > MAX_RAW_BYTES:
|
||
self._send_json(413, {"error": "upload too large"})
|
||
return
|
||
raw = self._read_body()
|
||
try:
|
||
fields, files = parse_multipart(raw, content_type)
|
||
paths = parse_paths_field(fields, files)
|
||
except ValueError as exc:
|
||
self._send_json(400, {"error": str(exc)})
|
||
return
|
||
if not files:
|
||
self._send_json(400, {"error": "no files uploaded"})
|
||
return
|
||
if len(paths) != len(files):
|
||
self._send_json(400, {
|
||
"error": "附件路径与文件数量不一致"})
|
||
return
|
||
err = validate_bundle(paths, [len(data) for _name, data in files])
|
||
if err:
|
||
self._send_json(400, {"error": err})
|
||
return
|
||
root = _upload_dir_for(self.server)
|
||
os.makedirs(root, exist_ok=True)
|
||
bundle_id, saved = save_bundle(
|
||
root, paths, [data for _name, data in files])
|
||
conn = self._db()
|
||
try:
|
||
conn.execute(
|
||
"INSERT INTO uploads(id, created_by, created_at, status)"
|
||
" VALUES(?, ?, ?, 'pending')",
|
||
(bundle_id, user_id, time.time()),
|
||
)
|
||
conn.commit()
|
||
finally:
|
||
conn.close()
|
||
self._send_json(201, {
|
||
"upload_id": bundle_id,
|
||
"files": [{"path": path, "size": size}
|
||
for path, size in saved],
|
||
})
|
||
|
||
def _create_task(self, user_id):
|
||
body, err = self._read_json()
|
||
if err:
|
||
self._send_json(400, {"error": err})
|
||
return
|
||
project_id = body.get("project_id")
|
||
prompt = body.get("prompt")
|
||
if not project_id or not isinstance(project_id, str):
|
||
self._send_json(400, {"error": "project_id required"})
|
||
return
|
||
if not prompt or not isinstance(prompt, str):
|
||
self._send_json(400, {"error": "prompt required"})
|
||
return
|
||
# 新建目标:prompt 里直接写 /goal 也算新建 goal,不必
|
||
# 先勾选项——例如 prompt="/goal 收xxx" 或“请开启一个
|
||
# 持久 /goal,目标是收xxx”。解析在 cloud/goal_command.py,
|
||
# 与主线转换同款(讨论里提到 /goal 不算)。目标即新
|
||
# 任务的 prompt 与 goal;空目标 400,不建普通任务。
|
||
prompt_goal_target = _parse_goal_command(prompt)
|
||
if prompt_goal_target is not None and not prompt_goal_target:
|
||
self._send_json(400, {"error": "goal 目标不能为空"})
|
||
return
|
||
if prompt_goal_target:
|
||
prompt = prompt_goal_target
|
||
depends_on = body.get("depends_on") or []
|
||
if isinstance(depends_on, str):
|
||
depends_on = [depends_on]
|
||
if not isinstance(depends_on, list):
|
||
self._send_json(400, {"error": "depends_on must be a list"})
|
||
return
|
||
try:
|
||
priority = int(body.get("priority", 0) or 0)
|
||
except (TypeError, ValueError):
|
||
self._send_json(400, {"error": "priority must be an integer"})
|
||
return
|
||
# executor 可选:None 表示任意 worker 可认领;指定时只能是
|
||
# local/docker/cloud,非法值 400 且不建任务。
|
||
executor = body.get("executor")
|
||
if executor is not None and executor not in (
|
||
"local", "docker", "cloud"):
|
||
self._send_json(400, {
|
||
"error": "executor must be local, docker or cloud"})
|
||
return
|
||
# 任务级选项:model 不做白名单(中转目录会变,下拉只是
|
||
# 建议值);effort 必须是 codex 的 effort 档位;approval
|
||
# 是 console 三档(ask/auto/full),非法值 400 且不建任务。
|
||
# 三者都缺省(NULL)时 worker 走配置默认值,行为与过去一致。
|
||
model = body.get("model")
|
||
if model is not None:
|
||
if not isinstance(model, str) or not model.strip():
|
||
self._send_json(400, {
|
||
"error": "model must be a non-empty string"})
|
||
return
|
||
model = model.strip()
|
||
effort = body.get("effort")
|
||
if effort is not None and effort not in (
|
||
"low", "medium", "high", "xhigh", "max"):
|
||
self._send_json(400, {
|
||
"error": "effort must be low, medium, high, xhigh or max"})
|
||
return
|
||
approval = body.get("approval")
|
||
if approval is not None and approval not in ("ask", "auto", "full"):
|
||
self._send_json(400, {
|
||
"error": "approval must be ask, auto or full"})
|
||
return
|
||
# 派生默认:本任务派生的子任务(marker task-create、
|
||
# 协调员分工)未显式写模型/强度时用的模型/强度。
|
||
# 显式字段优先;缺省时若 prompt 里有“子任务用 X max”
|
||
# 这类显式指令,由 parse_spawn_defaults 解析填入——
|
||
# 只认带派生主语的显式指令,裸说“主线用 X”不算,
|
||
# 那是本任务自己的 model,不应被误当成派生默认。
|
||
# 两者都缺省(NULL)时派生走本任务 model / effort,
|
||
# 再缺省才走 worker 默认,行为与过去一致。
|
||
child_model = body.get("child_model")
|
||
if child_model is not None:
|
||
if not isinstance(child_model, str) or not child_model.strip():
|
||
self._send_json(400, {
|
||
"error": "child_model must be a non-empty string"})
|
||
return
|
||
child_model = child_model.strip()
|
||
child_effort = body.get("child_effort")
|
||
if child_effort is not None and child_effort not in (
|
||
"low", "medium", "high", "xhigh", "max"):
|
||
self._send_json(400, {
|
||
"error": "child_effort must be low, medium, high,"
|
||
" xhigh or max"})
|
||
return
|
||
if child_model is None or child_effort is None:
|
||
parsed = _parse_spawn_defaults(prompt)
|
||
if child_model is None:
|
||
child_model = parsed.get("model")
|
||
if child_effort is None:
|
||
child_effort = parsed.get("effort")
|
||
# 计划模式:只读沙箱,不改任何文件;与“完全访问”
|
||
# (danger-full-access)语义直接冲突,拒绝建任务。
|
||
plan = 1 if body.get("plan") in (True, 1) else 0
|
||
if plan and approval == "full":
|
||
self._send_json(400, {
|
||
"error": "plan 模式与 approval=full 冲突(只读 vs 完全访问)"})
|
||
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/验收命令语义冲突(那些都是“本任务跑模型”的意思),
|
||
# 同用时 400 且不建任务。
|
||
coord_parts = None
|
||
if body.get("coordinator_parts") is not None:
|
||
coord_parts, coord_err = _coord_validate_parts(
|
||
body.get("coordinator_parts"))
|
||
if coord_err:
|
||
self._send_json(400, {"error": coord_err})
|
||
return
|
||
if goal_mode or plan or verify_command:
|
||
self._send_json(400, {
|
||
"error": "协调员任务不跑模型回合,不能与"
|
||
" goal/plan/验收命令同用"})
|
||
return
|
||
idempotency_key = body.get("idempotency_key")
|
||
if idempotency_key is not None and not isinstance(idempotency_key, str):
|
||
self._send_json(400, {"error": "idempotency_key must be a string"})
|
||
return
|
||
with _auth_store(self.server.db_path) as store:
|
||
if not store.check_permission(user_id, project_id, "create_task"):
|
||
self._send_json(403, {"error": "forbidden"})
|
||
return
|
||
if self._reject_if_over_quota(user_id):
|
||
return
|
||
# 协调员预留(P1):父任务准入只查一次时,N 个分工会
|
||
# 解锁 N 倍消费。父任务创建时要求剩余美元配额至少覆盖
|
||
# 分工数 × 每分工预留额,否则直接 402、父任务不建;
|
||
# 子任务归属 = 父任务创建人(worker 建子任务时
|
||
# created_by 直接取父任务创建人,成本 JOIN 据此归属,
|
||
# 子任务认领时 worker 侧配额门再查一次)。不限额用户
|
||
# (quota 为 None)不受此预留约束。
|
||
if coord_parts and not self._check_coordinator_reserve(
|
||
user_id, len(coord_parts)):
|
||
return
|
||
# sub2api 懒开通/健康检查(P1):建任务前先真校验网关
|
||
# key——无行则开通,死 key 则轮换重开;网关不可用时
|
||
# 502 且不建任务(不把死 key/待开通当成已开通发给
|
||
# worker)。xcmapi 网关下跳过本段。
|
||
if _gateway_name() == "sub2api":
|
||
_gw_conn = self._db()
|
||
try:
|
||
try:
|
||
_validate_sub2api_key(_gw_conn, user_id)
|
||
except _Sub2apiError as exc:
|
||
self._send_json(502, {"error": str(exc)})
|
||
return
|
||
except Exception as exc:
|
||
self._send_json(502, {"error": str(exc)})
|
||
return
|
||
finally:
|
||
_gw_conn.close()
|
||
# 环境绑定:environment_id 非空时必须是本项目、已 ready
|
||
# 的环境;creating/failed/archived 一律拒绝并把状态写进
|
||
# 报错(creating 时用户就知道该等 worker 准备好再建任务,
|
||
# 而不是任务排队后才在 worker 侧失败)。执行器规则:
|
||
# 环境 executor 非空且任务 executor 已指定时必须一致;
|
||
# 任务 executor 未指定时继承环境 executor(认领过滤靠它,
|
||
# 否则 local 任务可能被 docker worker 认领、反之亦然);
|
||
# cloud 执行器没有环境检出路径,一律拒绝。
|
||
environment_id = body.get("environment_id")
|
||
if environment_id is not None:
|
||
if not isinstance(environment_id, str) \
|
||
or not environment_id.strip():
|
||
self._send_json(400, {
|
||
"error": "environment_id must be a non-empty string"})
|
||
return
|
||
environment_id = environment_id.strip()
|
||
env_row = self._fetch_environment(environment_id)
|
||
if env_row is None:
|
||
self._send_json(404, {"error": "environment not found"})
|
||
return
|
||
env = _environment_to_dict(env_row)
|
||
if env["project_id"] != project_id:
|
||
self._send_json(400, {
|
||
"error": "environment belongs to another project"})
|
||
return
|
||
if env["status"] != "ready":
|
||
self._send_json(400, {
|
||
"error": "environment not ready: %s" % env["status"]})
|
||
return
|
||
if executor == "cloud":
|
||
self._send_json(400, {
|
||
"error": "environment does not support cloud executor"})
|
||
return
|
||
if env["executor"] and executor \
|
||
and env["executor"] != executor:
|
||
self._send_json(400, {
|
||
"error": "executor does not match environment executor"})
|
||
return
|
||
if executor is None and env["executor"]:
|
||
executor = env["executor"]
|
||
if executor is None:
|
||
# 未指定且无环境继承时默认 docker:空 executor(任意
|
||
# worker 可认领)会让任务落到隔离较弱的 local worker,
|
||
# 是多用户下的隔离缺口;显式 local/cloud 不受影响。
|
||
executor = _default_executor()
|
||
# 附件绑定(upload_id 由 POST /uploads 发出):包必须存在、
|
||
# 归属当前用户、未使用、24 小时内、暂存目录还在;cloud
|
||
# 执行器没有 worktree 可 seed,直接拒绝。校验在 INSERT
|
||
# 之前做,绑定写与任务 INSERT 同一事务,失败不留半截。
|
||
bundle_files = []
|
||
upload_id = body.get("upload_id")
|
||
if upload_id is not None:
|
||
if not isinstance(upload_id, str) or not upload_id:
|
||
self._send_json(400, {
|
||
"error": "upload_id must be a non-empty string"})
|
||
return
|
||
if executor == "cloud":
|
||
self._send_json(400, {
|
||
"error": "附件不支持 cloud 执行器"})
|
||
return
|
||
conn0 = self._db()
|
||
try:
|
||
brow = conn0.execute(
|
||
"SELECT * FROM uploads WHERE id = ?",
|
||
(upload_id,),
|
||
).fetchone()
|
||
finally:
|
||
conn0.close()
|
||
if brow is None:
|
||
self._send_json(400, {"error": "upload_id 不存在"})
|
||
return
|
||
if brow["created_by"] != user_id:
|
||
self._send_json(403, {"error": "forbidden"})
|
||
return
|
||
if brow["status"] == "used":
|
||
self._send_json(409, {"error": "附件包已使用"})
|
||
return
|
||
if time.time() - brow["created_at"] > BUNDLE_TTL_SECONDS:
|
||
self._send_json(400, {"error": "附件包已过期"})
|
||
return
|
||
bundle_dir = os.path.join(
|
||
_upload_dir_for(self.server), upload_id)
|
||
if not os.path.isdir(bundle_dir):
|
||
self._send_json(400, {"error": "附件暂存目录缺失"})
|
||
return
|
||
for dirpath, _dirnames, filenames in os.walk(bundle_dir):
|
||
for fn in sorted(filenames):
|
||
full = os.path.join(dirpath, fn)
|
||
rel = os.path.relpath(full, bundle_dir)
|
||
bundle_files.append((rel, os.path.getsize(full)))
|
||
bundle_files.sort()
|
||
if not bundle_files:
|
||
self._send_json(400, {"error": "附件包为空"})
|
||
return
|
||
now = time.time()
|
||
conn = self._db()
|
||
try:
|
||
if idempotency_key is not None:
|
||
row = conn.execute(
|
||
"SELECT * FROM tasks WHERE project_id = ?"
|
||
" AND idempotency_key = ?",
|
||
(project_id, idempotency_key),
|
||
).fetchone()
|
||
if row:
|
||
self._send_json(200, _task_to_dict(
|
||
row, _attachments_map(
|
||
conn, [row["id"]])[row["id"]]))
|
||
return
|
||
task_id = uuid.uuid4().hex
|
||
try:
|
||
conn.execute(
|
||
"INSERT INTO tasks(id, project_id, prompt, status,"
|
||
" idempotency_key, depends_on, priority, created_by,"
|
||
" created_at, updated_at, last_heartbeat, executor,"
|
||
" goal, verify_command, goal_max_rounds,"
|
||
" model, effort, approval, plan, goal_mode,"
|
||
" environment_id, coordinator_parts,"
|
||
" parent_task_id, coordinator_part,"
|
||
" child_model, child_effort)"
|
||
" VALUES(?, ?, ?, 'queued', ?, ?, ?, ?, ?, ?, NULL, ?,"
|
||
" ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||
(task_id, project_id, prompt, idempotency_key,
|
||
json.dumps(depends_on), priority, user_id, now, now,
|
||
executor, goal, verify_command, goal_max_rounds,
|
||
model, effort, approval, plan, goal_mode,
|
||
environment_id,
|
||
json.dumps(coord_parts) if coord_parts else None,
|
||
None, None, child_model, child_effort),
|
||
)
|
||
# 长任务不限时:API 字段 no_time_limit=1 时写标记,
|
||
# worker 认领后按不限时跑(单轮 host 600 秒仍在);
|
||
# 不传或非真值时保持默认 0,现有任务行为不变。
|
||
if body.get("no_time_limit") in (True, 1):
|
||
conn.execute(
|
||
"UPDATE tasks SET no_time_limit = 1"
|
||
" WHERE id = ?", (task_id,))
|
||
for rel_path, size in bundle_files:
|
||
conn.execute(
|
||
"INSERT INTO task_attachments"
|
||
"(task_id, bundle_id, rel_path, size)"
|
||
" VALUES(?, ?, ?, ?)",
|
||
(task_id, upload_id, rel_path, size),
|
||
)
|
||
if upload_id is not None:
|
||
conn.execute(
|
||
"UPDATE uploads SET status = 'used' WHERE id = ?",
|
||
(upload_id,),
|
||
)
|
||
conn.commit()
|
||
except (sqlite3.IntegrityError, _PgIntegrityError):
|
||
# 并发下同幂等键抢建:回读已有任务,不建重复。
|
||
row = conn.execute(
|
||
"SELECT * FROM tasks WHERE project_id = ?"
|
||
" AND idempotency_key = ?",
|
||
(project_id, idempotency_key),
|
||
).fetchone()
|
||
if row:
|
||
self._send_json(200, _task_to_dict(
|
||
row, _attachments_map(
|
||
conn, [row["id"]])[row["id"]]))
|
||
return
|
||
raise
|
||
row = conn.execute(
|
||
"SELECT * FROM tasks WHERE id = ?", (task_id,)
|
||
).fetchone()
|
||
task = _task_to_dict(
|
||
row, [rel_path for rel_path, _size in bundle_files])
|
||
finally:
|
||
conn.close()
|
||
with _auth_store(self.server.db_path) as store:
|
||
store.audit(user_id, project_id, "create_task", task_id)
|
||
if coord_parts:
|
||
# 创建响应里写明子任务配额归属(与 worker 建子任务时
|
||
# created_by=父任务创建人一致),不是只写文档宣称。
|
||
task["quota_attribution"] = (
|
||
"coordinator subtasks are charged to the parent"
|
||
" task creator")
|
||
task["quota_reserved_usd_micros"] = (
|
||
len(coord_parts)
|
||
* COORDINATOR_RESERVE_PER_PART_USD_MICROS)
|
||
self._send_json(201, task)
|
||
|
||
def _list_tasks(self, user_id, query):
|
||
# GET /tasks: server-side pagination + filtering.
|
||
# - No limit param: old behaviour, all rows in created_at
|
||
# order (coord children and existing callers rely on it).
|
||
# - limit param: limit 1..100, offset >= 0 (invalid -> 400),
|
||
# order=desc (newest first, the list's display order),
|
||
# status/executor exact match, q case-insensitive
|
||
# substring over id + prompt (same rule the frontend
|
||
# used to apply client-side). total is the filtered
|
||
# count before slicing, status_counts is over all
|
||
# project rows (so the data card's breakdown is not
|
||
# skewed by the current filter or page).
|
||
project_ids = query.get("project_id")
|
||
project_id = project_ids[0] if project_ids else None
|
||
if not project_id:
|
||
self._send_json(400, {"error": "project_id required"})
|
||
return
|
||
with _auth_store(self.server.db_path) as store:
|
||
if not store.check_permission(user_id, project_id, "read"):
|
||
self._send_json(403, {"error": "forbidden"})
|
||
return
|
||
parent_task_id = None
|
||
parent_ids = query.get("parent_task_id")
|
||
if parent_ids:
|
||
parent_task_id = parent_ids[0]
|
||
limit = None
|
||
offset = 0
|
||
if query.get("limit"):
|
||
try:
|
||
limit = int(query["limit"][0])
|
||
offset = int(query["offset"][0]) if query.get("offset") else 0
|
||
except (TypeError, ValueError):
|
||
self._send_json(400, {"error": "limit/offset must be integers"})
|
||
return
|
||
if limit < 1 or limit > 100 or offset < 0:
|
||
self._send_json(400, {"error": "limit must be 1..100, offset >= 0"})
|
||
return
|
||
order_desc = bool(query.get("order") and query["order"][0] == "desc")
|
||
status_f = query["status"][0] if query.get("status") else None
|
||
executor_f = query["executor"][0] if query.get("executor") else None
|
||
q_f = query["q"][0].lower() if query.get("q") else None
|
||
conn = self._db()
|
||
try:
|
||
if parent_task_id:
|
||
rows = conn.execute(
|
||
"SELECT * FROM tasks WHERE project_id = ?"
|
||
" AND parent_task_id = ?"
|
||
" ORDER BY created_at, id",
|
||
(project_id, parent_task_id),
|
||
).fetchall()
|
||
else:
|
||
rows = conn.execute(
|
||
"SELECT * FROM tasks WHERE project_id = ?"
|
||
" ORDER BY created_at, id",
|
||
(project_id,),
|
||
).fetchall()
|
||
status_counts = {}
|
||
for r in rows:
|
||
s = r["status"]
|
||
status_counts[s] = status_counts.get(s, 0) + 1
|
||
if parent_task_id:
|
||
filtered = rows
|
||
else:
|
||
filtered = []
|
||
for r in rows:
|
||
d = _task_to_dict(r, [], 0)
|
||
if status_f and d["status"] != status_f:
|
||
continue
|
||
if executor_f and (d["executor"] or "") != executor_f:
|
||
continue
|
||
if q_f and q_f not in ((d["id"] or "") + " " + (d["prompt"] or "")).lower():
|
||
continue
|
||
filtered.append(r)
|
||
total = len(filtered)
|
||
if order_desc:
|
||
filtered = list(reversed(filtered))
|
||
page_rows = filtered[offset:offset + limit] if limit is not None else filtered
|
||
amap = _attachments_map(conn, [r["id"] for r in page_rows])
|
||
tmap = _task_tokens_map(conn, [r["id"] for r in page_rows])
|
||
finally:
|
||
conn.close()
|
||
out = {"tasks": [
|
||
_task_to_dict(r, amap[r["id"]], tmap.get(r["id"], 0))
|
||
for r in page_rows], "total": total, "status_counts": status_counts}
|
||
if limit is not None:
|
||
out["limit"] = limit
|
||
out["offset"] = offset
|
||
self._send_json(200, out)
|
||
|
||
def _fetch_task(self, task_id):
|
||
conn = self._db()
|
||
try:
|
||
return conn.execute(
|
||
"SELECT * FROM tasks WHERE id = ?", (task_id,)
|
||
).fetchone()
|
||
finally:
|
||
conn.close()
|
||
|
||
def _goal_convert_queued(self, task_id, target):
|
||
"""把尚未认领的 queued 主线原地转成新语义 goal 任务。
|
||
|
||
CAS 只改仍是 queued 的行:worker 在本方法执行间认领
|
||
时 rowcount 为 0,调用方回 409 请重试,绝不把已在跑
|
||
的任务悄悄改模式(在跑任务的转换走 followup 排队 +
|
||
worker 轮次边界转换,见 worker._convert_running_task_to_goal)。
|
||
成功返回新任务 dict,失败返回 None。"""
|
||
conn = self._db()
|
||
try:
|
||
cur = conn.execute(
|
||
"UPDATE tasks SET goal_mode = 1, goal = ?,"
|
||
" verify_command = NULL, goal_max_rounds = 0,"
|
||
" updated_at = ? WHERE id = ? AND status = 'queued'",
|
||
(target, time.time(), task_id))
|
||
conn.commit()
|
||
if cur.rowcount != 1:
|
||
return None
|
||
return _task_to_dict(conn.execute(
|
||
"SELECT * FROM tasks WHERE id = ?", (task_id,)
|
||
).fetchone())
|
||
finally:
|
||
conn.close()
|
||
|
||
def _goal_revive_terminal(self, task_id, target):
|
||
"""把已终态主线复活并转成新语义 goal 任务(/continue 同款)。
|
||
|
||
CAS 要求状态在终态四态且 resume_message 为空:已有待续跑
|
||
消息时不覆盖它(那条消息会先跑,goal 请求不应把它顶掉)。
|
||
成功返回新任务 dict,失败返回 None。merged 不在此列,
|
||
调用方须先按 merged 409 拦掉。"""
|
||
conn = self._db()
|
||
try:
|
||
cur = conn.execute(
|
||
"UPDATE tasks SET status = 'queued',"
|
||
" resume_message = ?, goal_mode = 1, goal = ?,"
|
||
" verify_command = NULL, goal_max_rounds = 0,"
|
||
" result_text = NULL, error_text = NULL,"
|
||
" actual_model = NULL, updated_at = ?"
|
||
" WHERE id = ? AND status IN ('completed', 'failed',"
|
||
" 'interrupted', 'merge-failed')"
|
||
" AND resume_message IS NULL",
|
||
(target, target, time.time(), task_id))
|
||
conn.commit()
|
||
if cur.rowcount != 1:
|
||
return None
|
||
return _task_to_dict(conn.execute(
|
||
"SELECT * FROM tasks WHERE id = ?", (task_id,)
|
||
).fetchone())
|
||
finally:
|
||
conn.close()
|
||
|
||
def _get_task(self, user_id, task_id):
|
||
row = self._fetch_task(task_id)
|
||
if row is None:
|
||
self._send_json(404, {"error": "task not found"})
|
||
return
|
||
conn = self._db()
|
||
try:
|
||
atts = _attachments_map(conn, [task_id])[task_id]
|
||
# 详情与列表口径一致:单任务也一次聚合回 total_tokens,
|
||
# 否则详情里的 token 栏只能显示占位。
|
||
tok = _task_tokens_map(conn, [task_id]).get(task_id, 0)
|
||
finally:
|
||
conn.close()
|
||
task = _task_to_dict(row, atts, tok)
|
||
with _auth_store(self.server.db_path) as store:
|
||
if not store.check_permission(user_id, task["project_id"], "read"):
|
||
self._send_json(403, {"error": "forbidden"})
|
||
return
|
||
self._send_json(200, task)
|
||
|
||
# -- 任务实时链路 --------------------------------------------------
|
||
def _trace_snapshot(self, task):
|
||
"""当前链路快照 (entries, note):worktree 在别机时读不到
|
||
文件,返回空链路 + 说明(多机诚实降级,不谎报无过程)。
|
||
租户目录与共享目录都读、合并(租户 Docker 任务的链路
|
||
文件只在租户目录里,旧版只读共享目录会误报为空)。"""
|
||
worktree_host = task.get("worktree_host")
|
||
if worktree_host and worktree_host != socket.gethostname():
|
||
return [], ("链路文件在 worktree 主机 %s 上,本机读不到"
|
||
% worktree_host)
|
||
paths = [os.path.join(d, _trace_file_name(task["id"]) + ".jsonl")
|
||
for d in _trace_dirs_for_task(self.server, task)]
|
||
return _read_trace_entries_multi(paths), ""
|
||
|
||
def _task_result_full(self, user_id, task_id):
|
||
"""完整结果端点:result_text 仍是 4000 字摘要,完整版
|
||
在 result_text_full 里;无完整版时 404(老任务、goal
|
||
任务、或本轮没拿到 stdout_full 的任务),不回摘要
|
||
冒充完整。权限与 _get_task 同款 read 校验。"""
|
||
row = self._fetch_task(task_id)
|
||
if row is None:
|
||
self._send_json(404, {"error": "task not found"})
|
||
return
|
||
task = _task_to_dict(row)
|
||
with _auth_store(self.server.db_path) as store:
|
||
if not store.check_permission(user_id, task["project_id"], "read"):
|
||
self._send_json(403, {"error": "forbidden"})
|
||
return
|
||
full = _row_get(row, "result_text_full")
|
||
if not full:
|
||
self._send_json(404, {"error": "result full not available"})
|
||
return
|
||
self._send_json(200, {"task_id": task_id,
|
||
"status": task["status"],
|
||
"result_text_full": full})
|
||
|
||
def _task_trace(self, user_id, task_id):
|
||
row = self._fetch_task(task_id)
|
||
if row is None:
|
||
self._send_json(404, {"error": "task not found"})
|
||
return
|
||
task = _task_to_dict(row)
|
||
with _auth_store(self.server.db_path) as store:
|
||
if not store.check_permission(user_id, task["project_id"], "read"):
|
||
self._send_json(403, {"error": "forbidden"})
|
||
return
|
||
entries, note = self._trace_snapshot(task)
|
||
self._send_json(200, {"task_id": task_id, "status": task["status"],
|
||
"entries": entries, "note": note})
|
||
|
||
def _task_agents(self, user_id, task_id):
|
||
row = self._fetch_task(task_id)
|
||
if row is None:
|
||
self._send_json(404, {"error": "task not found"})
|
||
return
|
||
task = _task_to_dict(row)
|
||
with _auth_store(self.server.db_path) as store:
|
||
if not store.check_permission(user_id, task["project_id"], "read"):
|
||
self._send_json(403, {"error": "forbidden"})
|
||
return
|
||
worktree_host = task.get("worktree_host")
|
||
if worktree_host and worktree_host != socket.gethostname():
|
||
self._send_json(200, {"task_id": task_id, "status": task["status"],
|
||
"agents": [],
|
||
"note": ("链路文件在 worktree 主机 %s 上,本机读不到"
|
||
% worktree_host)})
|
||
return
|
||
agents = _build_agent_chain(
|
||
task, _trace_dirs_for_task(self.server, task))
|
||
self._send_json(200, {"task_id": task_id, "status": task["status"],
|
||
"agents": agents, "note": ""})
|
||
|
||
def _task_trace_stream(self, user_id, task_id):
|
||
row = self._fetch_task(task_id)
|
||
if row is None:
|
||
self._send_json(404, {"error": "task not found"})
|
||
return
|
||
task = _task_to_dict(row)
|
||
with _auth_store(self.server.db_path) as store:
|
||
if not store.check_permission(user_id, task["project_id"], "read"):
|
||
self._send_json(403, {"error": "forbidden"})
|
||
return
|
||
entries, note = self._trace_snapshot(task)
|
||
try:
|
||
self.send_response(200)
|
||
self.send_header("Content-Type", "text/event-stream")
|
||
self.send_header("Cache-Control", "no-cache")
|
||
self.send_header("Connection", "keep-alive")
|
||
self.end_headers()
|
||
except (BrokenPipeError, ConnectionResetError):
|
||
return
|
||
self._responded = True
|
||
# SSE 无 Content-Length,done 后靠关连接界定流结束;注意
|
||
# send_header("Connection", "keep-alive") 会把
|
||
# close_connection 改回 False,所以必须在 end_headers 之后
|
||
# 再置 True(CPython send_header 的 Connection 分支行为)。
|
||
self.close_connection = True
|
||
sent = {}
|
||
|
||
def _emit(obj):
|
||
try:
|
||
self.wfile.write(
|
||
("data: " + json.dumps(obj, ensure_ascii=False,
|
||
default=_json_default) +
|
||
"\n\n").encode("utf-8"))
|
||
self.wfile.flush()
|
||
except (BrokenPipeError, ConnectionResetError):
|
||
return False
|
||
return True
|
||
|
||
if not _emit({"type": "snapshot", "task_id": task_id,
|
||
"status": task["status"], "entries": entries,
|
||
"note": note}):
|
||
return
|
||
for entry in entries:
|
||
sent[entry["id"]] = json.dumps(entry, sort_keys=True,
|
||
ensure_ascii=False)
|
||
if task["status"] in TERMINAL_STATUSES:
|
||
_emit({"type": "done", "status": task["status"]})
|
||
return
|
||
deadline = time.time() + TRACE_STREAM_MAX_SECONDS
|
||
try:
|
||
while time.time() < deadline:
|
||
time.sleep(TRACE_STREAM_POLL_SECONDS)
|
||
row = self._fetch_task(task_id)
|
||
if row is None:
|
||
_emit({"type": "done", "status": "deleted"})
|
||
return
|
||
task = _task_to_dict(row)
|
||
entries, _note = self._trace_snapshot(task)
|
||
for entry in entries:
|
||
blob = json.dumps(entry, sort_keys=True,
|
||
ensure_ascii=False)
|
||
if sent.get(entry["id"]) != blob:
|
||
sent[entry["id"]] = blob
|
||
if not _emit({"type": "entry", "entry": entry}):
|
||
return
|
||
if task["status"] in TERMINAL_STATUSES:
|
||
_emit({"type": "done", "status": task["status"]})
|
||
return
|
||
except (BrokenPipeError, ConnectionResetError):
|
||
return
|
||
_emit({"type": "done", "status": task["status"],
|
||
"reason": "timeout"})
|
||
|
||
def _create_followup(self, user_id, task_id):
|
||
"""给进行中任务发后续消息:排队 / 直接发送。
|
||
|
||
语义(用户定稿):排队进队尾,不打断本轮,本轮结束
|
||
后按序每条再跑一轮;直接发送也不中断本轮——消息立即
|
||
送达(链路里可见),本轮跑完后立刻以它开下一轮,且
|
||
优先于排队中的消息。v1 只做运行时(含排队中)的任务:
|
||
已终态的任务 409;goal 任务 409(goal 循环自有轮次)。
|
||
例外:消息是 /goal 命令时(见 cloud/goal_command.py),
|
||
不走普通后续消息——queued 原地转 goal、running 排队后
|
||
由 worker 在轮次边界转 goal、已终态复活并转 goal;
|
||
已是 goal、plan 任务、merged、空目标各按 409/400 拒绝。
|
||
权限与取消同款:项目 create_task(写操作,不能只读)。
|
||
"""
|
||
body, err = self._read_json()
|
||
if err:
|
||
self._send_json(400, {"error": err})
|
||
return
|
||
row = self._fetch_task(task_id)
|
||
if row is None:
|
||
self._send_json(404, {"error": "task not found"})
|
||
return
|
||
task = _task_to_dict(row)
|
||
with _auth_store(self.server.db_path) as store:
|
||
if not store.check_permission(
|
||
user_id, task["project_id"], "create_task"):
|
||
self._send_json(403, {"error": "forbidden"})
|
||
return
|
||
if self._reject_if_over_quota_for_task(user_id, task):
|
||
return
|
||
text, mode, body_err = _validate_followup_body(body)
|
||
if body_err is not None:
|
||
self._send_json(body_err[0], body_err[1])
|
||
return
|
||
goal_target = _parse_goal_command(text)
|
||
# /goal 前置判定见 _classify_goal_followup(纯计算);
|
||
# 下面只剩各判定对应的副作用路,顺序与原文一致:
|
||
# error 先返回,queued 原地转 goal,终态复活转 goal,
|
||
# running 落到普通 followup 插入路(不打断本轮,只排队;
|
||
# worker 在本轮完成、排空到这条时把主线转成 goal,见
|
||
# worker 排空段;本轮若失败则这条留在 pending,等下次
|
||
# 成功轮次再转),响应里标明是 goal。
|
||
goal_action, goal_code, goal_payload = _classify_goal_followup(
|
||
task, goal_target)
|
||
if goal_action == "error":
|
||
self._send_json(goal_code, goal_payload)
|
||
return
|
||
if goal_action == "convert_queued":
|
||
new_task = self._goal_convert_queued(task_id, goal_target)
|
||
if new_task is None:
|
||
self._send_json(409, {
|
||
"error": "任务状态已变化,请刷新后重试",
|
||
"status": task["status"],
|
||
})
|
||
return
|
||
with _auth_store(self.server.db_path) as store:
|
||
store.audit(user_id, task["project_id"],
|
||
"mainline_goal_created",
|
||
"%s:goal=%s" % (task_id, goal_target))
|
||
self._send_json(200, dict(
|
||
new_task, delivery="goal_converted",
|
||
goal=goal_target))
|
||
return
|
||
if goal_action == "revive_terminal":
|
||
new_task = self._goal_revive_terminal(task_id, goal_target)
|
||
if new_task is None:
|
||
self._send_json(409, {
|
||
"error": "任务已有待续跑消息或状态已变化,"
|
||
"请刷新后重试",
|
||
"status": task["status"],
|
||
})
|
||
return
|
||
with _auth_store(self.server.db_path) as store:
|
||
store.audit(user_id, task["project_id"],
|
||
"mainline_goal_created",
|
||
"%s:goal_revived=%s"
|
||
% (task_id, goal_target))
|
||
self._send_json(200, dict(
|
||
new_task, delivery="goal_revived",
|
||
goal=goal_target))
|
||
return
|
||
if task.get("verify_command") and goal_target is None:
|
||
self._send_json(409, {"error": "goal 任务暂不支持后续消息"})
|
||
return
|
||
now = time.time()
|
||
conn = self._db()
|
||
try:
|
||
_ensure_followups_table(conn)
|
||
fu_id = uuid.uuid4().hex
|
||
conn.execute(
|
||
"INSERT INTO task_followups(id, task_id, project_id,"
|
||
" body, mode, status, created_by, created_at,"
|
||
" updated_at) VALUES(?, ?, ?, ?, ?, 'pending', ?, ?, ?)",
|
||
(fu_id, task_id, task["project_id"], text, mode,
|
||
user_id, now, now),
|
||
)
|
||
conn.commit()
|
||
fu = _followup_to_dict(
|
||
conn.execute(
|
||
"SELECT * FROM task_followups WHERE id = ?",
|
||
(fu_id,),
|
||
).fetchone()
|
||
)
|
||
finally:
|
||
conn.close()
|
||
if mode == "direct":
|
||
self._append_direct_trace(task, fu_id, text)
|
||
with _auth_store(self.server.db_path) as store:
|
||
store.audit(user_id, task["project_id"],
|
||
"followup_created", "%s:%s" % (task_id, fu_id))
|
||
if goal_target is not None:
|
||
store.audit(user_id, task["project_id"],
|
||
"mainline_goal_created",
|
||
"%s:goal_queued=%s" % (task_id, goal_target))
|
||
if goal_target is not None:
|
||
self._send_json(201, dict(
|
||
fu, delivery="goal_queued", goal=goal_target))
|
||
return
|
||
self._send_json(201, fu)
|
||
|
||
def _append_direct_trace(self, task, fu_id, text):
|
||
"""直接发送的消息立即进链路:往 host 的 jsonl 里追加一条
|
||
message 条目(phase=followup-direct),运行中的链路流
|
||
下一轮 poll 就能看到它。只尽力而为:worktree 在别机时
|
||
本机文件不是 worker 写的那份,跳过(与 _trace_snapshot
|
||
的多机降级同规则);写失败也绝不让 POST 失败。"""
|
||
try:
|
||
worktree_host = task.get("worktree_host")
|
||
if worktree_host and worktree_host != socket.gethostname():
|
||
return
|
||
# 续写已有链路文件所在的目录:租户 Docker 任务的链路
|
||
# 文件在租户目录里,续写共享目录会把一条任务的链路
|
||
# 拆成两处;还没有任何链路文件时默认写共享目录。
|
||
d = _trace_dir_for(self.server)
|
||
tenant = _tenant_trace_dir(self.server, task.get("project_id"))
|
||
if tenant and os.path.exists(os.path.join(
|
||
tenant, _trace_file_name(task["id"]) + ".jsonl")):
|
||
d = tenant
|
||
os.makedirs(d, exist_ok=True)
|
||
entry = {"kind": "message", "id": "fu-" + fu_id,
|
||
"ts": int(time.time() * 1000),
|
||
"phase": "followup-direct", "text": text[:300]}
|
||
with open(os.path.join(
|
||
d, _trace_file_name(task["id"]) + ".jsonl"),
|
||
"a", encoding="utf-8") as f:
|
||
f.write(json.dumps(entry, ensure_ascii=False) + "\n")
|
||
except Exception:
|
||
pass
|
||
|
||
def _list_followups(self, user_id, task_id):
|
||
row = self._fetch_task(task_id)
|
||
if row is None:
|
||
self._send_json(404, {"error": "task not found"})
|
||
return
|
||
task = _task_to_dict(row)
|
||
with _auth_store(self.server.db_path) as store:
|
||
if not store.check_permission(
|
||
user_id, task["project_id"], "read"):
|
||
self._send_json(403, {"error": "forbidden"})
|
||
return
|
||
conn = self._db()
|
||
try:
|
||
_ensure_followups_table(conn)
|
||
rows = conn.execute(
|
||
"SELECT * FROM task_followups WHERE task_id = ?"
|
||
" ORDER BY created_at, id",
|
||
(task_id,),
|
||
).fetchall()
|
||
finally:
|
||
conn.close()
|
||
self._send_json(200, {
|
||
"followups": [_followup_to_dict(r) for r in rows]})
|
||
|
||
def _task_timeline(self, user_id, task_id):
|
||
"""GET /tasks/{id}/timeline(P0 三件其三):一个任务一张
|
||
时间线。steps 直读 task_steps(claim/resume/round/final),
|
||
checkpoints 直读 task_checkpoints(每轮断点快照);旧
|
||
任务(部署前跑完、无步骤记录)返回空数组,不编造。
|
||
"""
|
||
row = self._fetch_task(task_id)
|
||
if row is None:
|
||
self._send_json(404, {"error": "task not found"})
|
||
return
|
||
task = _task_to_dict(row)
|
||
with _auth_store(self.server.db_path) as store:
|
||
if not store.check_permission(
|
||
user_id, task["project_id"], "read"):
|
||
self._send_json(403, {"error": "forbidden"})
|
||
return
|
||
conn = self._db()
|
||
try:
|
||
_ensure_task_checkpoints_table(conn)
|
||
_ensure_task_steps_table(conn)
|
||
step_rows = conn.execute(
|
||
"SELECT step_no, kind, summary, state_sha, created_at"
|
||
" FROM task_steps WHERE task_id = ?"
|
||
" ORDER BY step_no, created_at, id",
|
||
(task_id,)).fetchall()
|
||
cp_rows = conn.execute(
|
||
"SELECT round_no, thread_id, goal_status, state_sha,"
|
||
" verify_out, created_at FROM task_checkpoints"
|
||
" WHERE task_id = ? ORDER BY round_no",
|
||
(task_id,)).fetchall()
|
||
finally:
|
||
conn.close()
|
||
self._send_json(200, {
|
||
"task_id": task_id,
|
||
"steps": [{"step_no": r[0], "kind": r[1], "summary": r[2],
|
||
"state_sha": r[3], "created_at": r[4]}
|
||
for r in step_rows],
|
||
"checkpoints": [{"round_no": r[0], "thread_id": r[1],
|
||
"goal_status": r[2], "state_sha": r[3],
|
||
"verify_out": r[4], "created_at": r[5]}
|
||
for r in cp_rows],
|
||
})
|
||
|
||
def _task_files(self, user_id, task_id, query):
|
||
"""GET /tasks/{id}/files:读任务 worktree 里的文件。
|
||
|
||
解析见 _task_worktree_for_files:本地任务走本地 worktree,
|
||
租户 Docker 任务走 tenants 下的任务级子目录(只 serves
|
||
host-worktrees/<任务id>,绝不 serves 共享的 /app/work 本
|
||
身);cloud 任务与跨机 worktree 一律 409,解析不到时 404。
|
||
|
||
?path=<相对路径>:不传列 worktree 顶层(递归、最多 200
|
||
条,只含文件路径与大小,不跟符号链接);传目录列该目录
|
||
(非递归,含 is_dir);传文件读文本内容(UTF-8、≤256KB,
|
||
二进制或超大一律 409/413 明说,不截断冒充全文)。路径
|
||
解析一律走 _safe_worktree_path,防 .. 与符号链接逃逸。
|
||
读操作不限制任务状态(进行中任务读到的是中间态)。
|
||
"""
|
||
task, err = self._thread_task(user_id, task_id, "read")
|
||
if err is not None:
|
||
self._send_json(err[0], {"error": err[1]})
|
||
return
|
||
worktree, werr = _task_worktree_for_files(self.server, task)
|
||
if werr is not None:
|
||
self._send_json(werr[0], {"error": werr[1]})
|
||
return
|
||
rel = (query.get("path") or [""])[0]
|
||
target = _safe_worktree_path(worktree, rel)
|
||
if target is None:
|
||
self._send_json(400, {"error": "path escapes task worktree"})
|
||
return
|
||
if os.path.isdir(target):
|
||
entries = []
|
||
if not rel:
|
||
for root, dirs, files in os.walk(target):
|
||
dirs[:] = [d for d in dirs
|
||
if not os.path.islink(os.path.join(root, d))]
|
||
dirs.sort()
|
||
for name in sorted(files):
|
||
fpath = os.path.join(root, name)
|
||
if os.path.islink(fpath):
|
||
continue
|
||
entries.append({
|
||
"path": os.path.relpath(fpath, worktree),
|
||
"size": os.path.getsize(fpath),
|
||
})
|
||
if len(entries) >= 200:
|
||
break
|
||
if len(entries) >= 200:
|
||
break
|
||
else:
|
||
for name in sorted(os.listdir(target)):
|
||
fpath = os.path.join(target, name)
|
||
is_link = os.path.islink(fpath)
|
||
entries.append({
|
||
"path": os.path.relpath(fpath, worktree),
|
||
"size": os.path.getsize(fpath)
|
||
if os.path.isfile(fpath) and not is_link else 0,
|
||
"is_dir": os.path.isdir(fpath) and not is_link,
|
||
})
|
||
self._send_json(200, {"task_id": task_id, "path": rel,
|
||
"entries": entries})
|
||
return
|
||
if not os.path.isfile(target):
|
||
self._send_json(404, {"error": "no such file"})
|
||
return
|
||
size = os.path.getsize(target)
|
||
if size > 256 * 1024:
|
||
self._send_json(413, {"error": "file too large for inline read"})
|
||
return
|
||
try:
|
||
with open(target, encoding="utf-8") as fh:
|
||
content = fh.read()
|
||
except (OSError, UnicodeDecodeError):
|
||
self._send_json(409, {"error": "file is not readable UTF-8 text"})
|
||
return
|
||
self._send_json(200, {"task_id": task_id, "path": rel,
|
||
"size": size, "content": content})
|
||
|
||
def _task_tree(self, user_id, task_id, query):
|
||
"""GET /tasks/{id}/tree:结构化目录树(不读文件内容)。
|
||
|
||
与 /files 同款权限与 worktree 解析:viewer+ 可读,
|
||
cloud 任务与跨机 worktree 一律 409,解析不到时 404。
|
||
?path=<相对路径>:不传列 worktree 根;传目录列该目录。
|
||
?max_depth:1..5,缺省 3;?max_entries:1..500,缺省
|
||
200。非法整数一律 400,不静默回落缺省。路径逃逸
|
||
400,不存在或非目录 404。条数到顶时 truncated=True,
|
||
被 max_depth 截断的目录 children=[] 且
|
||
children_truncated=True,空目录则是 children=[] 且
|
||
children_truncated=False,两者可区分。
|
||
"""
|
||
task, err = self._thread_task(user_id, task_id, "read")
|
||
if err is not None:
|
||
self._send_json(err[0], {"error": err[1]})
|
||
return
|
||
worktree, werr = _task_worktree_for_files(self.server, task)
|
||
if werr is not None:
|
||
self._send_json(werr[0], {"error": werr[1]})
|
||
return
|
||
try:
|
||
max_depth = int((query.get("max_depth")
|
||
or [str(_TREE_DEFAULT_DEPTH)])[0])
|
||
max_entries = int((query.get("max_entries")
|
||
or [str(_TREE_DEFAULT_ENTRIES)])[0])
|
||
except (TypeError, ValueError):
|
||
self._send_json(400, {"error":
|
||
"max_depth/max_entries must be integers"})
|
||
return
|
||
if not 1 <= max_depth <= 5 or not 1 <= max_entries <= 500:
|
||
self._send_json(400, {"error":
|
||
"max_depth must be 1..5 and "
|
||
"max_entries must be 1..500"})
|
||
return
|
||
rel = (query.get("path") or [""])[0]
|
||
try:
|
||
tree = _build_tree(worktree, path=rel,
|
||
max_depth=max_depth,
|
||
max_entries=max_entries)
|
||
except ValueError:
|
||
self._send_json(400, {"error": "path escapes task worktree"})
|
||
return
|
||
except FileNotFoundError:
|
||
self._send_json(404, {"error": "no such path"})
|
||
return
|
||
except NotADirectoryError:
|
||
self._send_json(404, {"error": "path is not a directory"})
|
||
return
|
||
tree = dict(tree)
|
||
tree["task_id"] = task_id
|
||
tree["max_depth"] = max_depth
|
||
tree["max_entries"] = max_entries
|
||
self._send_json(200, tree)
|
||
|
||
def _browser_worktree_for(self, user_id, task_id):
|
||
"""Auth + worktree resolution shared by browser endpoints.
|
||
|
||
Same rules as /files. Returns (worktree, None) or
|
||
(None, (status, msg)).
|
||
"""
|
||
task, err = self._thread_task(user_id, task_id, "read")
|
||
if err is not None:
|
||
return None, err
|
||
worktree, werr = _task_worktree_for_files(self.server, task)
|
||
if werr is not None:
|
||
return None, werr
|
||
return worktree, None
|
||
|
||
def _browser_body_dict(self, body):
|
||
"""Parse a raw POST body (bytes from _read_body) into a
|
||
dict; None for empty/invalid JSON (handlers map it to
|
||
their own 400)."""
|
||
if isinstance(body, dict):
|
||
return body
|
||
if not body:
|
||
return None
|
||
try:
|
||
obj = json.loads(body.decode("utf-8"))
|
||
except (ValueError, UnicodeDecodeError):
|
||
return None
|
||
return obj if isinstance(obj, dict) else None
|
||
|
||
def _browser_state(self, user_id, task_id):
|
||
"""GET /tasks/{id}/browser/state: fresh bridge state file."""
|
||
from cloud.playwright_remote import read_state
|
||
worktree, err = self._browser_worktree_for(user_id, task_id)
|
||
if err is not None:
|
||
self._send_json(err[0], {"error": err[1]})
|
||
return
|
||
state, serr = read_state(worktree)
|
||
if serr is not None:
|
||
self._send_json(serr[0], {"error": serr[1]})
|
||
return
|
||
self._send_json(200, state)
|
||
|
||
def _browser_input(self, user_id, task_id, body):
|
||
"""POST /tasks/{id}/browser/input: append to the bridge log.
|
||
|
||
Holder is enforced here (409 unless the state file says
|
||
"user") and again by the MCP at execution time, so a
|
||
release racing an append can only drop the input, never
|
||
execute it under the wrong holder.
|
||
"""
|
||
from cloud.playwright_remote import (
|
||
append_input, effective_holder, read_state, validate_input)
|
||
worktree, err = self._browser_worktree_for(user_id, task_id)
|
||
if err is not None:
|
||
self._send_json(err[0], {"error": err[1]})
|
||
return
|
||
state, serr = read_state(worktree)
|
||
if serr is not None:
|
||
self._send_json(serr[0], {"error": serr[1]})
|
||
return
|
||
holder = effective_holder(worktree, state)
|
||
if holder != "user":
|
||
self._send_json(409, {"error": "user does not hold control "
|
||
"(holder=%s); take over first"
|
||
% holder})
|
||
return
|
||
item, ierr = validate_input(self._browser_body_dict(body))
|
||
if ierr is not None:
|
||
self._send_json(ierr[0], {"error": ierr[1]})
|
||
return
|
||
try:
|
||
append_input(worktree, item)
|
||
except OSError as exc:
|
||
self._send_json(503, {"error": "browser input unreachable: "
|
||
"%s" % exc})
|
||
return
|
||
self._send_json(200, {"queued": True})
|
||
|
||
def _browser_control(self, user_id, task_id, body):
|
||
"""POST /tasks/{id}/browser/control: write the control file.
|
||
|
||
Works even before the browser starts: the MCP applies the
|
||
file on its first poll, so an early takeover sticks.
|
||
"""
|
||
from cloud.playwright_remote import write_control
|
||
worktree, err = self._browser_worktree_for(user_id, task_id)
|
||
if err is not None:
|
||
self._send_json(err[0], {"error": err[1]})
|
||
return
|
||
parsed = self._browser_body_dict(body)
|
||
holder = parsed.get("holder") if parsed else None
|
||
try:
|
||
holder = write_control(worktree, holder)
|
||
except ValueError as exc:
|
||
self._send_json(400, {"error": str(exc)})
|
||
return
|
||
except OSError as exc:
|
||
self._send_json(503, {"error": "browser control unreachable: "
|
||
"%s" % exc})
|
||
return
|
||
self._send_json(200, {"holder": holder})
|
||
|
||
def _browser_stream(self, user_id, task_id):
|
||
"""GET /tasks/{id}/browser/stream: SSE from bridge files.
|
||
|
||
Polls the frame file every 20ms and emits a data line
|
||
whenever its bytes change (plus a heartbeat with the
|
||
current holder at least every 5s). The payload also
|
||
carries the session url/title, so the console panel can
|
||
track the header from the stream alone instead of
|
||
polling the state endpoint while watching.
|
||
|
||
Stale state does NOT end the stream while the task is
|
||
still alive: between rounds (and across a worker
|
||
restart) the MCP is gone for a while and the state file
|
||
ages past the 30s staleness mark, which used to kill
|
||
every watch at the first round gap. Now a stale state
|
||
only switches the stream to waiting mode -- a heartbeat
|
||
with waiting:true every 5s, task status checked at the
|
||
same cadence -- and frames resume when the next round's
|
||
MCP starts polling again. The stream ends only when the
|
||
task reaches a terminal state (completed, merged,
|
||
failed), the task row disappears, or the state stays
|
||
stale for 10 minutes straight (abandoned watch).
|
||
"""
|
||
import time
|
||
from cloud.playwright_remote import (
|
||
bridge_paths, effective_holder, read_state)
|
||
worktree, err = self._browser_worktree_for(user_id, task_id)
|
||
if err is not None:
|
||
self._send_json(err[0], {"error": err[1]})
|
||
return
|
||
_state, serr = read_state(worktree)
|
||
if serr is not None:
|
||
self._send_json(serr[0], {"error": serr[1]})
|
||
return
|
||
self.send_response(200)
|
||
self.send_header("Content-Type", "text/event-stream")
|
||
self.send_header("Cache-Control", "no-cache")
|
||
self.end_headers()
|
||
self._responded = True
|
||
paths = bridge_paths(worktree)
|
||
last_frame = None
|
||
last_heartbeat = 0.0
|
||
last_holder = ""
|
||
stale_since = None
|
||
last_status_check = 0.0
|
||
task_status = None
|
||
try:
|
||
while True:
|
||
state, serr = read_state(worktree)
|
||
now = time.time()
|
||
if serr is not None:
|
||
# No live session right now (round gap,
|
||
# worker restart, or a blocked drain loop).
|
||
# Wait it out while the task is still alive
|
||
# instead of dropping the watch.
|
||
if stale_since is None:
|
||
stale_since = now
|
||
if now - stale_since > 600:
|
||
break # abandoned watch
|
||
if now - last_status_check >= 5:
|
||
last_status_check = now
|
||
try:
|
||
row = self._fetch_task(task_id)
|
||
except Exception: # noqa: BLE001 - keep waiting
|
||
row = False # transient error, not deletion
|
||
if row is None:
|
||
break # task row gone
|
||
if row is not False:
|
||
task_status = row["status"]
|
||
if task_status in (
|
||
"completed", "merged", "failed"):
|
||
break
|
||
if now - last_heartbeat >= 5:
|
||
last_heartbeat = now
|
||
payload = json.dumps(
|
||
{"image": "",
|
||
"holder": last_holder,
|
||
"waiting": True,
|
||
"task_status": task_status or "",
|
||
"ts": int(now)}, ensure_ascii=False)
|
||
self.wfile.write(
|
||
("data: %s\n\n" % payload).encode("utf-8"))
|
||
self.wfile.flush()
|
||
time.sleep(0.02)
|
||
continue
|
||
stale_since = None
|
||
try:
|
||
with open(paths["frame"], "rb") as fh:
|
||
frame = fh.read().decode("utf-8")
|
||
except (OSError, UnicodeDecodeError):
|
||
frame = ""
|
||
if frame != last_frame or now - last_heartbeat >= 5:
|
||
last_frame = frame
|
||
last_heartbeat = now
|
||
last_holder = effective_holder(worktree, state)
|
||
payload = json.dumps(
|
||
{"image": frame,
|
||
"holder": last_holder,
|
||
"url": state.get("url", ""),
|
||
"title": state.get("title", ""),
|
||
"ts": int(now)}, ensure_ascii=False)
|
||
self.wfile.write(
|
||
("data: %s\n\n" % payload).encode("utf-8"))
|
||
self.wfile.flush()
|
||
time.sleep(0.02)
|
||
except (BrokenPipeError, ConnectionResetError, OSError):
|
||
pass
|
||
# An SSE stream has no Content-Length, so the client can
|
||
# only tell "stream ended" from the connection closing.
|
||
# HTTP/1.1 keep-alive would otherwise leave it hanging
|
||
# after a terminal break (completed/failed/cap) with no
|
||
# more bytes ever coming. Force the close on stream end.
|
||
self.close_connection = True
|
||
|
||
def _task_chat_history(self, user_id, task_id):
|
||
"""GET /tasks/{id}/chat-history:真正的交替历史,一次性
|
||
按时间交错拼好返回,前端不再猜顺序。
|
||
|
||
来源(全部是后端已有记录,不编造):
|
||
- 首条 prompt:任务行 prompt + created_at(用户)
|
||
- 继续消息:task_continue_messages(用户,部署后才入库)
|
||
- 后续消息:task_followups(用户,含任务间互发)
|
||
- 助手/工具/状态:链路 message/exec/file/tool/call/
|
||
plan/reasoning/agent-* 条目(trace 的 ts 是毫秒时间戳,
|
||
无 ts 的条目按链路顺序贴在上一条有 ts 的条目之后,
|
||
不给它伪造时间戳——输出里 ts_ms 为 null)
|
||
|
||
排序:按 ts_ms 升序,同一毫秒用户消息在链路条目之前
|
||
(用户消息是触发该轮的原因),其余按原顺序稳定排序。
|
||
旧任务缺口:部署前发生的继续只在 audit 里留了
|
||
task_continued(只有任务 id,没有正文),无法追回;
|
||
legacy_continue_count = audit 次数 - 已入库条数,>0 时
|
||
前端必须在聊天窗上标“此前继续消息未入库,历史不完整”,
|
||
不能假装这就是全部对话。异机 worktree 时链路读不到,
|
||
messages 里只有用户侧 + note,不谎报无过程。"""
|
||
row = self._fetch_task(task_id)
|
||
if row is None:
|
||
self._send_json(404, {"error": "task not found"})
|
||
return
|
||
task = _task_to_dict(row)
|
||
with _auth_store(self.server.db_path) as store:
|
||
if not store.check_permission(
|
||
user_id, task["project_id"], "read"):
|
||
self._send_json(403, {"error": "forbidden"})
|
||
return
|
||
entries, trace_note = self._trace_snapshot(task)
|
||
conn = self._db()
|
||
try:
|
||
_ensure_continue_messages_table(conn)
|
||
_ensure_followups_table(conn)
|
||
cont_rows = conn.execute(
|
||
"SELECT * FROM task_continue_messages WHERE task_id = ?"
|
||
" ORDER BY created_at, id",
|
||
(task_id,),
|
||
).fetchall()
|
||
fu_rows = conn.execute(
|
||
"SELECT * FROM task_followups WHERE task_id = ?"
|
||
" ORDER BY created_at, id",
|
||
(task_id,),
|
||
).fetchall()
|
||
legacy = None
|
||
try:
|
||
aud = conn.execute(
|
||
"SELECT COUNT(*) FROM audit_log"
|
||
" WHERE action = 'task_continued' AND detail = ?",
|
||
(task_id,),
|
||
).fetchone()
|
||
legacy = max(0, int(aud[0]) - len(cont_rows))
|
||
except Exception:
|
||
legacy = None
|
||
finally:
|
||
conn.close()
|
||
|
||
# 用户侧与链路侧条目构造见 _chat_history_user_entries /
|
||
# _chat_history_trace_entries(纯构造);DB 读、权限、
|
||
# legacy 计数与下面的排序/响应仍在本函数。
|
||
merged = _chat_history_user_entries(task, cont_rows, fu_rows)
|
||
merged += _chat_history_trace_entries(
|
||
entries, _seconds_to_ms(task.get("created_at")))
|
||
# 排序键:(时间, 用户优先, 原顺序)。merged 里用户项的
|
||
# 第一元组是 0、链路项是 1;Python 排序稳定,同一
|
||
# (时间, 优先级) 内保持插入顺序。
|
||
merged.sort(key=lambda item: (
|
||
item[1] if item[1] is not None else 0, item[0]))
|
||
self._send_json(200, {
|
||
"task_id": task_id,
|
||
"status": task["status"],
|
||
"messages": [m for _p, _t, m in merged],
|
||
"continue_messages_stored": len(cont_rows),
|
||
"legacy_continue_count": legacy,
|
||
"trace_note": trace_note,
|
||
})
|
||
|
||
def _create_peer_message(self, user_id, task_id):
|
||
"""任务间互发消息(API 发送方):以 from_task_id 任务的名义
|
||
给目标任务发消息。投递语义与 worker 路径共用 deliver 层,
|
||
同样不中断目标任务:运行中/排队中排成后续消息,终态则
|
||
续跑复活。权限:调用方须对目标项目有 create_task、对
|
||
发送任务项目有 read(防冒名);from_task_id 必填——用户
|
||
给任务发消息走 /followups,这里只做任务对任务。"""
|
||
body, err = self._read_json()
|
||
if err:
|
||
self._send_json(400, {"error": err})
|
||
return
|
||
target_row = self._fetch_task(task_id)
|
||
if target_row is None:
|
||
self._send_json(404, {"error": "task not found"})
|
||
return
|
||
target = _task_to_dict(target_row)
|
||
with _auth_store(self.server.db_path) as store:
|
||
if not store.check_permission(
|
||
user_id, target["project_id"], "create_task"):
|
||
self._send_json(403, {"error": "forbidden"})
|
||
return
|
||
from_task_id = body.get("from_task_id")
|
||
if not from_task_id or not isinstance(from_task_id, str):
|
||
self._send_json(400, {"error": "from_task_id required"})
|
||
return
|
||
sender_row = self._fetch_task(from_task_id)
|
||
if sender_row is None:
|
||
self._send_json(404, {"error": "sender task not found"})
|
||
return
|
||
sender = _task_to_dict(sender_row)
|
||
with _auth_store(self.server.db_path) as store:
|
||
if not store.check_permission(
|
||
user_id, sender["project_id"], "read"):
|
||
self._send_json(403, {"error": "forbidden"})
|
||
return
|
||
if self._reject_if_over_quota_for_task(user_id, target):
|
||
return
|
||
text = body.get("body")
|
||
if not text or not isinstance(text, str) or not text.strip():
|
||
self._send_json(400, {"error": "body required"})
|
||
return
|
||
text = text.strip()
|
||
if len(text) > 20000:
|
||
self._send_json(400, {"error": "body too long"})
|
||
return
|
||
mode = body.get("mode", "queued")
|
||
if mode not in ("queued", "direct"):
|
||
self._send_json(400, {
|
||
"error": "mode must be queued or direct"})
|
||
return
|
||
conn = self._db()
|
||
try:
|
||
_ensure_followups_table(conn)
|
||
status, detail = _deliver_peer_message(
|
||
conn, from_task_id, task_id, text, mode)
|
||
finally:
|
||
conn.close()
|
||
if status == "rejected":
|
||
self._send_json(409, {"error": detail})
|
||
return
|
||
if status == "delivered" and mode == "direct":
|
||
# 直接发送的链路可见性与用户后续消息同款:消息立即
|
||
# 进链路,但当前轮仍跑完才开下一轮,不中断。
|
||
self._append_direct_trace(
|
||
target, detail, _peer_prefix(from_task_id) + text)
|
||
with _auth_store(self.server.db_path) as store:
|
||
store.audit(user_id, target["project_id"],
|
||
"peer_message_sent",
|
||
"%s -> %s (%s)" % (from_task_id, task_id, status))
|
||
self._send_json(201, {
|
||
"from_task_id": from_task_id, "to_task_id": task_id,
|
||
"mode": mode, "delivery": status, "followup_id": (
|
||
detail if status == "delivered" else None)})
|
||
|
||
def _list_peer_messages(self, user_id, task_id):
|
||
"""列出某任务发出的互发消息(收到的消息在 /followups 里,
|
||
带 sender_task_id)。只含排成后续消息的那部分;复活投递
|
||
的那部分没有 followup 行,查审计 peer_message_sent 与目标
|
||
任务的续跑轮次。权限与 followup 列表同款:项目 read。"""
|
||
row = self._fetch_task(task_id)
|
||
if row is None:
|
||
self._send_json(404, {"error": "task not found"})
|
||
return
|
||
task = _task_to_dict(row)
|
||
with _auth_store(self.server.db_path) as store:
|
||
if not store.check_permission(
|
||
user_id, task["project_id"], "read"):
|
||
self._send_json(403, {"error": "forbidden"})
|
||
return
|
||
conn = self._db()
|
||
try:
|
||
_ensure_followups_table(conn)
|
||
rows = conn.execute(
|
||
"SELECT * FROM task_followups WHERE sender_task_id = ?"
|
||
" ORDER BY created_at, id",
|
||
(task_id,),
|
||
).fetchall()
|
||
finally:
|
||
conn.close()
|
||
self._send_json(200, {
|
||
"messages": [_followup_to_dict(r) for r in rows]})
|
||
|
||
def _create_review(self, user_id, task_id):
|
||
"""发起评审:对某任务登记一条评审队列记录(status
|
||
requested),不跑模型、不伪造 findings——执行方跑完
|
||
后经 /findings 写回。权限:项目 create_task;目标组合
|
||
非法时 400,表里不留脏行;记审计 review_requested。"""
|
||
from cloud.review import build_review_target
|
||
from cloud.review import create_review as _review_create
|
||
body, err = self._read_json()
|
||
if err:
|
||
self._send_json(400, {"error": err})
|
||
return
|
||
row = self._fetch_task(task_id)
|
||
if row is None:
|
||
self._send_json(404, {"error": "task not found"})
|
||
return
|
||
task = _task_to_dict(row)
|
||
err = self._check_project(user_id, task["project_id"],
|
||
"create_task")
|
||
if err:
|
||
self._send_json(err[0], {"error": err[1]})
|
||
return
|
||
target_type = body.get("target_type", "uncommittedChanges")
|
||
branch = body.get("branch")
|
||
sha = body.get("sha")
|
||
instructions = body.get("instructions")
|
||
for name, value in (("branch", branch), ("sha", sha),
|
||
("instructions", instructions)):
|
||
if value is not None and not isinstance(value, str):
|
||
self._send_json(400, {"error": "%s must be a string" % name})
|
||
return
|
||
try:
|
||
build_review_target(target_type, branch, sha, instructions)
|
||
except ValueError as exc:
|
||
self._send_json(400, {"error": str(exc)})
|
||
return
|
||
conn = self._db()
|
||
try:
|
||
record = _review_create(
|
||
conn, task_id, task["project_id"], target_type,
|
||
user_id, branch=branch, sha=sha,
|
||
instructions=instructions)
|
||
finally:
|
||
conn.close()
|
||
with _auth_store(self.server.db_path) as store:
|
||
store.audit(user_id, task["project_id"],
|
||
"review_requested",
|
||
"%s -> %s" % (task_id, record["id"]))
|
||
self._send_json(201, record)
|
||
|
||
def _list_reviews(self, user_id, task_id):
|
||
"""评审队列查看:列出某任务的全部评审记录(含 findings,
|
||
未写回的 findings 为 null,如实展示不编造)。权限:
|
||
项目 read。"""
|
||
from cloud.review import list_reviews as _review_list
|
||
row = self._fetch_task(task_id)
|
||
if row is None:
|
||
self._send_json(404, {"error": "task not found"})
|
||
return
|
||
task = _task_to_dict(row)
|
||
err = self._check_project(user_id, task["project_id"], "read")
|
||
if err:
|
||
self._send_json(err[0], {"error": err[1]})
|
||
return
|
||
conn = self._db()
|
||
try:
|
||
reviews = _review_list(conn, task_id)
|
||
finally:
|
||
conn.close()
|
||
self._send_json(200, {"reviews": reviews})
|
||
|
||
def _update_review_findings(self, user_id, task_id, review_id):
|
||
"""评审结果写回:执行方把 findings 记到指定记录上。
|
||
不传 status 时 findings 写回默认落 completed;显式传
|
||
failed 可标失败。权限:项目 create_task;记录不属于
|
||
该任务时 404;记审计 review_findings_updated。"""
|
||
from cloud.review import get_review as _review_get
|
||
from cloud.review import update_review as _review_update
|
||
body, err = self._read_json()
|
||
if err:
|
||
self._send_json(400, {"error": err})
|
||
return
|
||
row = self._fetch_task(task_id)
|
||
if row is None:
|
||
self._send_json(404, {"error": "task not found"})
|
||
return
|
||
task = _task_to_dict(row)
|
||
err = self._check_project(user_id, task["project_id"],
|
||
"create_task")
|
||
if err:
|
||
self._send_json(err[0], {"error": err[1]})
|
||
return
|
||
findings = body.get("findings")
|
||
status = body.get("status")
|
||
if findings is None and status is None:
|
||
self._send_json(400, {
|
||
"error": "findings or status required"})
|
||
return
|
||
if findings is not None and not isinstance(findings, str):
|
||
self._send_json(400, {"error": "findings must be a string"})
|
||
return
|
||
conn = self._db()
|
||
try:
|
||
record = _review_get(conn, review_id)
|
||
if record is None or record["task_id"] != task_id:
|
||
self._send_json(404, {"error": "review not found"})
|
||
return
|
||
try:
|
||
updated = _review_update(conn, review_id,
|
||
status=status,
|
||
findings=findings)
|
||
except ValueError as exc:
|
||
self._send_json(400, {"error": str(exc)})
|
||
return
|
||
finally:
|
||
conn.close()
|
||
with _auth_store(self.server.db_path) as store:
|
||
store.audit(user_id, task["project_id"],
|
||
"review_findings_updated",
|
||
"%s -> %s" % (task_id, review_id))
|
||
self._send_json(200, updated)
|
||
# -- 任务线程操作(经 host.py CLI 透传)----------------------------
|
||
# 本批限制(P2 切片 C 接线时如实保留,见 cherry-pick 说明):
|
||
# Docker/异机任务线程操作一律 409(见 _thread_task_guard);
|
||
# 改操作仅 completed/failed/interrupted,merged/merge-failed
|
||
# 409(见 THREAD_MUTATION_STATUSES,控制台门控与此一致);
|
||
# turn/steer 与 review/start 不在本批(review 端点是 P1 已有
|
||
# 功能,不由本批新增或改动)。
|
||
def _thread_task(self, user_id, task_id, action):
|
||
"""取任务并验项目权限:返回 (task, None),或 (None, err),
|
||
err 为 (status, msg),调用方直接 _send_json。任务不存在
|
||
404 在先(与 peer 消息同款顺序),权限不足 403。"""
|
||
row = self._fetch_task(task_id)
|
||
if row is None:
|
||
return None, (404, "task not found")
|
||
task = _task_to_dict(row)
|
||
with _auth_store(self.server.db_path) as store:
|
||
if not store.check_permission(
|
||
user_id, task["project_id"], action):
|
||
return None, (403, "forbidden")
|
||
return task, None
|
||
|
||
def _thread_task_guard(self, task):
|
||
"""线程操作的执行器门:只做 local 执行器任务。Docker
|
||
任务的 thread 在容器内、cloud 任务的 thread 不在本机,
|
||
本机租户 codex-home 里没有,查了也是错指,一律 409
|
||
明说,不回空结果假装无 thread。executor 为空(建任务
|
||
时未指定)时放行:老任务多为 NULL 且实际由 local
|
||
worker 认领,卡死它们等于端点对老任务不可用;若实际
|
||
是 Docker 跑的,thread/list 会返回零个而得 404。
|
||
worktree 在别机时同样 409(与链路的跨机诚实降级同款,
|
||
但改操作绝不能在错的主机 home 上执行,所以不用 200
|
||
空结果)。返回 None 表示通过,否则为 (status, msg)。"""
|
||
executor = task.get("executor")
|
||
if executor == "docker":
|
||
return (409, "thread ops are local-executor only;"
|
||
" docker task threads live inside the container")
|
||
if executor == "cloud":
|
||
return (409, "thread ops are local-executor only;"
|
||
" cloud task threads are not on this host")
|
||
worktree_host = task.get("worktree_host")
|
||
if worktree_host and worktree_host != socket.gethostname():
|
||
return (409, "task worktree is on host %s;"
|
||
" thread ops must run there" % worktree_host)
|
||
return None
|
||
|
||
def _list_task_threads(self, task, archived=False):
|
||
"""对任务调 thread-list:返回 (threads, codex_home, cwd,
|
||
err, provider),err 为 (status, msg) 或 None。cwd 过滤
|
||
用任务 worktree(见 _thread_worktree_for_task)。
|
||
archived 为真时传 --archived,只列已归档 thread
|
||
(unarchive 解析专用;不传时只列非归档)。
|
||
|
||
候选按序穷尽,首个非空即停(thread 操作不是热路径,
|
||
多次 CLI 调用可接受):
|
||
- home:租户 home(见 _tenant_codex_home)优先,本地
|
||
worker 共享 home(见 _local_worker_codex_home)兜底
|
||
——本地派发至今仍用共享 home,旧 thread 全在那里,
|
||
只查租户 home 永远解析不到;
|
||
- provider:缺省(配置 provider)优先,sub2api 次之
|
||
——app-server 按进程 provider 过滤列表,不带注入时
|
||
sub2api 下建的 thread 在列表里不存在;未配
|
||
SUB2API_BASE_URL 时跳过次选(host.py 会 loud fail,
|
||
不能把配置缺失变成 502)。
|
||
任一候选成功返回空列表时不再继续(空就是空);全部
|
||
候选都报错(host.py 本身不可用)时回首个错误 502。"""
|
||
codex_home = _tenant_codex_home(self.server,
|
||
task["project_id"])
|
||
if codex_home is None:
|
||
return None, None, None, (
|
||
502, "cannot resolve tenant codex-home"), None
|
||
cwd = _thread_worktree_for_task(self.server, task)
|
||
homes = [codex_home]
|
||
shared = _local_worker_codex_home()
|
||
if shared and shared != codex_home:
|
||
homes.append(shared)
|
||
providers = [None]
|
||
if os.environ.get("SUB2API_BASE_URL", "").strip():
|
||
providers.append("sub2api")
|
||
first_error = None
|
||
any_success = False
|
||
list_argv = ["thread-list", "--cwd", cwd]
|
||
if archived:
|
||
list_argv.append("--archived")
|
||
for home in homes:
|
||
for provider in providers:
|
||
ok, result = _run_host_cli(
|
||
list_argv, home,
|
||
model_provider=provider)
|
||
if not ok:
|
||
if first_error is None:
|
||
first_error = result
|
||
continue
|
||
threads = _extract_thread_list(result)
|
||
if threads is None:
|
||
if first_error is None:
|
||
first_error = ("host.py thread-list output has"
|
||
" no thread list")
|
||
continue
|
||
any_success = True
|
||
if threads:
|
||
return threads, home, cwd, None, provider
|
||
if not any_success and first_error is not None:
|
||
return None, None, None, (502, first_error), None
|
||
return [], codex_home, cwd, None, None
|
||
|
||
def _resolve_task_thread(self, task, archived=False):
|
||
"""单线程端点的解析:thread-list 后按 _pick_latest_thread
|
||
取最新一个非 archived(archived 为真时取最新一个已归档,
|
||
unarchive 专用——不传时只列非归档,刚归档的 thread
|
||
永远解析不到,unarchive 也就永远 404)。返回 (thread,
|
||
codex_home, cwd, err, provider);零个 thread 时 err 为
|
||
(404, "thread not found")。provider 必须原样带给后续的
|
||
thread-read / rename / archive CLI——同一 thread 在另一
|
||
provider 进程里同样看不到,丢了它改操作会 502。"""
|
||
threads, codex_home, cwd, err, provider = \
|
||
self._list_task_threads(task, archived=archived)
|
||
if err is not None:
|
||
return None, None, None, err, None
|
||
thread = _pick_latest_thread(threads,
|
||
include_archived=archived)
|
||
if thread is None:
|
||
return None, None, None, (404, "thread not found"), None
|
||
return thread, codex_home, cwd, None, provider
|
||
|
||
def _task_threads(self, user_id, task_id):
|
||
"""GET /tasks/{id}/threads:thread/list 原样返回列表。
|
||
读权限用项目 read;零个 thread 时返回空列表(原样),
|
||
不像单线程端点那样 404——列表端点没有“解析不到”的
|
||
概念,空就是空。"""
|
||
task, err = self._thread_task(user_id, task_id, "read")
|
||
if err is not None:
|
||
self._send_json(err[0], {"error": err[1]})
|
||
return
|
||
guard = self._thread_task_guard(task)
|
||
if guard is not None:
|
||
self._send_json(guard[0], {"error": guard[1]})
|
||
return
|
||
threads, _home, cwd, err, _provider = self._list_task_threads(task)
|
||
if err is not None:
|
||
self._send_json(err[0], {"error": err[1]})
|
||
return
|
||
self._send_json(200, {"task_id": task_id, "cwd": cwd,
|
||
"threads": threads})
|
||
|
||
def _task_thread(self, user_id, task_id, query):
|
||
"""GET /tasks/{id}/thread:先 thread/list 解析出 thread,
|
||
再 thread/read。默认只要元数据(includeTurns 不传);
|
||
?include_turns=1 时才传 includeTurns。读操作不限制
|
||
任务状态;执行错误原样透传为 502。"""
|
||
task, err = self._thread_task(user_id, task_id, "read")
|
||
if err is not None:
|
||
self._send_json(err[0], {"error": err[1]})
|
||
return
|
||
guard = self._thread_task_guard(task)
|
||
if guard is not None:
|
||
self._send_json(guard[0], {"error": guard[1]})
|
||
return
|
||
thread, codex_home, cwd, err, provider = \
|
||
self._resolve_task_thread(task)
|
||
if err is not None:
|
||
self._send_json(err[0], {"error": err[1]})
|
||
return
|
||
argv = ["thread-read", "--thread-id", thread["id"]]
|
||
include_turns = (query.get("include_turns") or ["0"])[0]
|
||
if include_turns == "1":
|
||
argv.append("--include-turns")
|
||
ok, result = _run_host_cli(argv, codex_home,
|
||
model_provider=provider)
|
||
if not ok:
|
||
self._send_json(502, {"error": result})
|
||
return
|
||
full = _extract_thread(result)
|
||
if full is None:
|
||
self._send_json(502, {
|
||
"error": "host.py thread-read output has no thread"})
|
||
return
|
||
self._send_json(200, {"task_id": task_id, "cwd": cwd,
|
||
"thread_id": thread["id"],
|
||
"thread": full})
|
||
|
||
def _task_thread_mutate(self, user_id, task_id, argv, audit_action,
|
||
audit_detail, timeout=60,
|
||
resolve_archived=False):
|
||
"""线程改操作的公共流程:权限用项目 create_task;只允许
|
||
终态任务(completed/failed/interrupted),running/queued
|
||
一律 409;解析 thread 后调对应 CLI,成功记审计。
|
||
resolve_archived 只供 unarchive:已归档 thread 只能在
|
||
--archived 列表里找到,走普通解析永远 404。"""
|
||
task, err = self._thread_task(user_id, task_id, "create_task")
|
||
if err is not None:
|
||
self._send_json(err[0], {"error": err[1]})
|
||
return
|
||
guard = self._thread_task_guard(task)
|
||
if guard is not None:
|
||
self._send_json(guard[0], {"error": guard[1]})
|
||
return
|
||
if task["status"] not in THREAD_MUTATION_STATUSES:
|
||
self._send_json(409, {
|
||
"error": "thread ops require a terminal task",
|
||
"status": task["status"]})
|
||
return
|
||
thread, codex_home, _cwd, err, provider = \
|
||
self._resolve_task_thread(task, archived=resolve_archived)
|
||
if err is not None:
|
||
self._send_json(err[0], {"error": err[1]})
|
||
return
|
||
ok, result = _run_host_cli(
|
||
list(argv) + ["--thread-id", thread["id"]],
|
||
codex_home, timeout=timeout, model_provider=provider)
|
||
if not ok:
|
||
self._send_json(502, {"error": result})
|
||
return
|
||
with _auth_store(self.server.db_path) as store:
|
||
store.audit(user_id, task["project_id"], audit_action,
|
||
audit_detail % thread["id"])
|
||
self._send_json(200, {"task_id": task_id,
|
||
"thread_id": thread["id"],
|
||
"result": result})
|
||
|
||
def _task_thread_rename(self, user_id, task_id):
|
||
"""POST /tasks/{id}/thread/rename {name}:thread/name/set。
|
||
name 必填、非空、200 字内;记 thread_renamed 审计。"""
|
||
body, jerr = self._read_json()
|
||
if jerr:
|
||
self._send_json(400, {"error": jerr})
|
||
return
|
||
name = body.get("name")
|
||
if not name or not isinstance(name, str) or not name.strip():
|
||
self._send_json(400, {"error": "name required"})
|
||
return
|
||
name = name.strip()
|
||
if len(name) > 200:
|
||
self._send_json(400, {"error": "name too long"})
|
||
return
|
||
self._task_thread_mutate(
|
||
user_id, task_id, ["thread-rename", "--name", name],
|
||
"thread_renamed", "%s renamed to " + name)
|
||
|
||
def _task_thread_archive(self, user_id, task_id):
|
||
"""POST /tasks/{id}/thread/archive:thread/archive。
|
||
记 thread_archived 审计。"""
|
||
self._task_thread_mutate(user_id, task_id, ["thread-archive"],
|
||
"thread_archived", "%s archived")
|
||
|
||
def _task_thread_unarchive(self, user_id, task_id):
|
||
"""POST /tasks/{id}/thread/unarchive:thread/unarchive。
|
||
记 thread_unarchived 审计。注意:必须走已归档解析
|
||
(resolve_archived)——刚归档的 thread 只在
|
||
--archived 列表里,普通解析永远 404,unarchive 也就
|
||
永远跑不通;这是 #103 时代的逻辑 bug,本批修。"""
|
||
self._task_thread_mutate(user_id, task_id, ["thread-unarchive"],
|
||
"thread_unarchived", "%s unarchived",
|
||
resolve_archived=True)
|
||
|
||
def _task_thread_compact(self, user_id, task_id):
|
||
"""POST /tasks/{id}/thread/compact:thread/compact/start。
|
||
压缩可能耗时,CLI 超时放宽到 120 秒;记 thread_compacted
|
||
审计。注意:这是线程压缩,不是开新回合,不跑模型
|
||
轮次之外的 turn(压缩本身由 app-server 内部完成)。"""
|
||
self._task_thread_mutate(user_id, task_id, ["thread-compact"],
|
||
"thread_compacted", "%s compacted",
|
||
timeout=120)
|
||
|
||
# -- 项目 skills / apps / MCP(经 host.py CLI 透传)--------------
|
||
# codex-home 按项目分租户目录,所以这批端点是项目 scope:
|
||
# 读用项目 read,改用项目 manage_project(owner)。
|
||
def _project_host_home(self, project_id):
|
||
"""项目端点的租户 home 与工作目录:返回 (codex_home,
|
||
work_dir),任一解析不出时返回 (None, None)。注意:这里
|
||
反映的是宿主租户 home,容器内 home 可能不同,skills/MCP
|
||
面看到的是宿主侧视图,不代表容器内视图。"""
|
||
codex_home = _tenant_codex_home(self.server, project_id)
|
||
work_dir = _tenant_work_dir(self.server, project_id)
|
||
if codex_home is None or work_dir is None:
|
||
return None, None
|
||
return codex_home, work_dir
|
||
|
||
def _skills_list(self, project_id):
|
||
"""GET /projects/{id}/skills:skills/list 原样返回。"""
|
||
codex_home, work_dir = self._project_host_home(project_id)
|
||
if codex_home is None:
|
||
self._send_json(502, {
|
||
"error": "cannot resolve tenant codex-home"})
|
||
return
|
||
ok, result = _run_host_cli(["skills-list", "--cwd", work_dir],
|
||
codex_home)
|
||
if not ok:
|
||
self._send_json(502, {"error": result})
|
||
return
|
||
skills = _extract_result_list(result, ("data", "skills"))
|
||
if skills is None:
|
||
self._send_json(502, {
|
||
"error": "host.py skills-list output has no list"})
|
||
return
|
||
self._send_json(200, {"project_id": project_id,
|
||
"skills": skills})
|
||
|
||
def _apps_list(self, project_id):
|
||
"""GET /projects/{id}/apps:app/list 原样返回(schema 里
|
||
是 AppsListParams/AppsListResponse;项目 scope 下不带
|
||
threadId,不做按线程的特性门控)。"""
|
||
codex_home, _work = self._project_host_home(project_id)
|
||
if codex_home is None:
|
||
self._send_json(502, {
|
||
"error": "cannot resolve tenant codex-home"})
|
||
return
|
||
ok, result = _run_host_cli(["apps-list"], codex_home)
|
||
if not ok:
|
||
self._send_json(502, {"error": result})
|
||
return
|
||
apps = _extract_result_list(result, ("data", "apps"))
|
||
if apps is None:
|
||
self._send_json(502, {
|
||
"error": "host.py apps-list output has no list"})
|
||
return
|
||
body = {"project_id": project_id, "apps": apps}
|
||
cursor = result.get("nextCursor")
|
||
if cursor is not None:
|
||
body["nextCursor"] = cursor
|
||
self._send_json(200, body)
|
||
|
||
def _mcp_servers_list(self, project_id):
|
||
"""GET /projects/{id}/mcp-servers:mcpServerStatus/list
|
||
原样返回(schema 里是 ListMcpServerStatusParams/
|
||
Response;不带 threadId,走配置发现)。"""
|
||
codex_home, _work = self._project_host_home(project_id)
|
||
if codex_home is None:
|
||
self._send_json(502, {
|
||
"error": "cannot resolve tenant codex-home"})
|
||
return
|
||
ok, result = _run_host_cli(["mcp-servers-list"], codex_home)
|
||
if not ok:
|
||
self._send_json(502, {"error": result})
|
||
return
|
||
servers = _extract_result_list(result, ("data", "servers"))
|
||
if servers is None:
|
||
self._send_json(502, {
|
||
"error": "host.py mcp-servers-list output has no list"})
|
||
return
|
||
body = {"project_id": project_id, "servers": servers}
|
||
cursor = result.get("nextCursor")
|
||
if cursor is not None:
|
||
body["nextCursor"] = cursor
|
||
self._send_json(200, body)
|
||
|
||
def _mcp_servers_reload(self, user_id, project_id):
|
||
"""POST /projects/{id}/mcp-servers/reload:config/mcpServer/
|
||
reload。核对结论:该方法在协议 schema 里真实存在——
|
||
v1 与 v2 的 ClientRequest 方法枚举里都有它,params
|
||
为 null,没有专用的响应定义(结果通常为空),所以
|
||
正常调、结果原样透传;只有 CLI 本身报错时才 502,
|
||
绝不伪造“不支持”也不伪造成功。记审计。"""
|
||
codex_home, _work = self._project_host_home(project_id)
|
||
if codex_home is None:
|
||
self._send_json(502, {
|
||
"error": "cannot resolve tenant codex-home"})
|
||
return
|
||
ok, result = _run_host_cli(["mcp-servers-reload"], codex_home)
|
||
if not ok:
|
||
self._send_json(502, {"error": result})
|
||
return
|
||
with _auth_store(self.server.db_path) as store:
|
||
store.audit(user_id, project_id, "mcp_servers_reloaded",
|
||
"mcp servers reloaded")
|
||
self._send_json(200, {"project_id": project_id,
|
||
"reloaded": True, "result": result})
|
||
|
||
def _mcp_server_oauth_login(self, user_id, project_id, name):
|
||
"""POST /projects/{id}/mcp-servers/{name}/oauth-login:
|
||
mcpServer/oauth/login。协议返回(McpServerOauthLoginResponse:
|
||
authorizationUrl 必填、loginId 可空)原样透传给调用方,
|
||
浏览器里完成登录的 URL/步骤由调用方展示。完成态轮询
|
||
不做:协议里完成信号是 McpServerOauthLoginCompletedNotification
|
||
(服务端通知,不是请求/响应轮询),本端点只做到“发起
|
||
登录并返回授权 URL”这一步,绝不假装登录已完成。
|
||
记审计时只记服务器名与 loginId,不记授权 URL。"""
|
||
body, jerr = self._read_json()
|
||
if jerr:
|
||
self._send_json(400, {"error": jerr})
|
||
return
|
||
argv = ["mcp-server-oauth-login", "--server-name", name]
|
||
scopes = body.get("scopes")
|
||
if scopes is not None:
|
||
if not isinstance(scopes, list) or any(
|
||
not isinstance(s, str) for s in scopes):
|
||
self._send_json(400, {
|
||
"error": "scopes must be a list of strings"})
|
||
return
|
||
if scopes:
|
||
argv += ["--scopes", ",".join(scopes)]
|
||
timeout_secs = body.get("timeoutSecs")
|
||
if timeout_secs is not None:
|
||
if not isinstance(timeout_secs, int) or timeout_secs <= 0:
|
||
self._send_json(400, {
|
||
"error": "timeoutSecs must be a positive integer"})
|
||
return
|
||
argv += ["--timeout-secs", str(timeout_secs)]
|
||
codex_home, _work = self._project_host_home(project_id)
|
||
if codex_home is None:
|
||
self._send_json(502, {
|
||
"error": "cannot resolve tenant codex-home"})
|
||
return
|
||
ok, result = _run_host_cli(argv, codex_home, timeout=120)
|
||
if not ok:
|
||
self._send_json(502, {"error": result})
|
||
return
|
||
if not isinstance(result.get("authorizationUrl"), str):
|
||
self._send_json(502, {
|
||
"error": "host.py oauth-login output has no"
|
||
" authorizationUrl"})
|
||
return
|
||
with _auth_store(self.server.db_path) as store:
|
||
store.audit(user_id, project_id,
|
||
"mcp_server_oauth_login_started",
|
||
"%s loginId=%s" % (
|
||
name, result.get("loginId") or ""))
|
||
response = dict(result)
|
||
response["project_id"] = project_id
|
||
response["server_name"] = name
|
||
self._send_json(200, response)
|
||
|
||
# -- 线程等待与移交操作(对照 codex app 8 项) -----------------
|
||
def _task_thread_read(self, user_id, task_id):
|
||
"""GET /tasks/{id}/thread/read(read_thread):合成摘要,
|
||
不是 /thread 的原样转发。任务状态、结果、心跳来自任务行;
|
||
进度来自链路快照(条目数 + 最后一条);thread 元数据
|
||
尽力取(guard 挡住的 Docker / cloud / 异机任务、以及
|
||
thread-read 失败时,thread 回 null + note,仍回 200——
|
||
摘要不能因为 thread 读不到就把任务状态也藏起来,这
|
||
正是它与 /thread(409 / 502)的区别)。
|
||
详情来自链路条目本身:kind=file 是改动文件、
|
||
kind=exec 是执行命令(含退出码)、kind=message /
|
||
plan / reasoning 是最近动态;测试结果在链路里没有
|
||
单独记录(test_results 回 null + note,不编造“测试
|
||
通过”——测试通常就是命令,见 commands 的 exitCode
|
||
与 recent_activity 的 agent 消息)。阻塞判断来自
|
||
待审批(decision)与待处理后续消息(processing),
|
||
再加上“终态且可复活”时的 needs_instruction。读权限
|
||
read;审批扫荡(过期变 expired)与 /approvals 列表
|
||
同款,读摘要时一并做。"""
|
||
task, err = self._thread_task(user_id, task_id, "read")
|
||
if err is not None:
|
||
self._send_json(err[0], {"error": err[1]})
|
||
return
|
||
entries, trace_note = self._trace_snapshot(task)
|
||
last = entries[-1] if entries else None
|
||
last_text = ""
|
||
if isinstance(last, dict):
|
||
last_text = str(last.get("text") or last.get("message")
|
||
or "")[:500]
|
||
now = time.time()
|
||
heartbeat_age = None
|
||
if task.get("last_heartbeat"):
|
||
heartbeat_age = now - task["last_heartbeat"]
|
||
thread = None
|
||
thread_id = None
|
||
note = trace_note
|
||
guard = self._thread_task_guard(task)
|
||
if guard is not None:
|
||
note = guard[1]
|
||
else:
|
||
resolved, _home, _cwd, rerr, provider = \
|
||
self._resolve_task_thread(task)
|
||
if rerr is not None:
|
||
note = rerr[1]
|
||
else:
|
||
thread_id = resolved.get("id")
|
||
ok, result = _run_host_cli(
|
||
["thread-read", "--thread-id", thread_id],
|
||
_home, model_provider=provider)
|
||
if not ok:
|
||
note = result
|
||
else:
|
||
full = _extract_thread(result)
|
||
if full is None:
|
||
note = ("host.py thread-read output has"
|
||
" no thread")
|
||
else:
|
||
thread = full
|
||
changed_files, commands, recent_activity = \
|
||
_summarize_trace_entries(entries)
|
||
# 待审批与待处理后续消息(阻塞与“需不需要人”的来源)。
|
||
conn = self._db()
|
||
try:
|
||
_appr_ensure(conn)
|
||
_appr_expire(conn)
|
||
pending_approvals = [
|
||
{"id": a["id"], "kind": a["kind"],
|
||
"summary": (a["summary"] or "")[:300]}
|
||
for a in _appr_list(conn, task_id=task_id,
|
||
status="pending")]
|
||
_peer_ensure_followups(conn)
|
||
pending_followups = [{
|
||
"id": r[0], "mode": r[1],
|
||
"body": (r[2] or "")[:200]}
|
||
for r in conn.execute(
|
||
"SELECT id, mode, body FROM task_followups"
|
||
" WHERE task_id = ? AND status = 'pending'"
|
||
" ORDER BY created_at, id",
|
||
(task_id,)).fetchall()]
|
||
finally:
|
||
conn.close()
|
||
worktree_host = task.get("worktree_host")
|
||
remote = worktree_host and worktree_host != socket.gethostname()
|
||
agents = []
|
||
if not remote:
|
||
try:
|
||
agents = [{
|
||
"id": a.get("id"), "kind": a.get("kind"),
|
||
"status": a.get("status")}
|
||
for a in _build_agent_chain(
|
||
task, _trace_dirs_for_task(self.server, task))]
|
||
except Exception:
|
||
agents = []
|
||
state = {"queued": "waiting", "running": "running",
|
||
"completed": "completed", "merged": "completed",
|
||
"failed": "failed", "merge-failed": "failed",
|
||
"interrupted": "interrupted"}.get(
|
||
task["status"], "unknown")
|
||
needs_instruction = task["status"] in (
|
||
"completed", "failed", "interrupted") \
|
||
and not task.get("resume_message")
|
||
reasons = []
|
||
if pending_approvals:
|
||
reasons.append("pending approvals need a decision")
|
||
if pending_followups:
|
||
reasons.append("pending followups need processing")
|
||
if needs_instruction:
|
||
reasons.append("terminal task can be continued"
|
||
" with a new instruction")
|
||
if task.get("error_text"):
|
||
reasons.append("task has an error_text")
|
||
self._send_json(200, {
|
||
"task_id": task_id,
|
||
"project_id": task["project_id"],
|
||
"status": task["status"],
|
||
"state": state,
|
||
"thread_id": thread_id,
|
||
"thread": thread,
|
||
"progress": {
|
||
"trace_entries": len(entries),
|
||
"last_trace": ({"kind": last.get("kind"),
|
||
"id": last.get("id"),
|
||
"text": last_text}
|
||
if isinstance(last, dict) else None),
|
||
"heartbeat_age_seconds": heartbeat_age,
|
||
"worktree_host": worktree_host,
|
||
},
|
||
"result": {
|
||
"text": task.get("result_text"),
|
||
"error_text": task.get("error_text"),
|
||
"source": ("result_text" if task.get("result_text")
|
||
else None),
|
||
},
|
||
"changed_files": changed_files,
|
||
"commands": commands,
|
||
"recent_activity": recent_activity,
|
||
"agents": agents,
|
||
"test_results": None,
|
||
"test_results_note": (
|
||
"no separate test-result record in the trace;"
|
||
" tests usually appear as commands (see exit_code)"
|
||
" and agent messages (see recent_activity)"),
|
||
"pending_approvals": pending_approvals,
|
||
"pending_followups": pending_followups,
|
||
"attention": {
|
||
"blocked": bool(pending_approvals),
|
||
"needs_decision": bool(pending_approvals),
|
||
"needs_processing": bool(pending_followups),
|
||
"needs_instruction": needs_instruction,
|
||
"reasons": reasons,
|
||
},
|
||
"note": note,
|
||
})
|
||
|
||
def _task_thread_message(self, user_id, task_id):
|
||
"""POST /tasks/{id}/thread/message(send_message_to_thread):
|
||
调用方以自己名义给另一会话的 thread 发后续指令。
|
||
授权模型:调用方须对目标项目有 create_task(项目写
|
||
权限)——“用户在对话里让我协调”本身不是 API 权限,
|
||
API 只认 key + 项目权限,跨项目写一律 403,审计
|
||
thread_message_sent 留痕;这就是“向其他任务发消息
|
||
需要明确授权”在本系统的落地(当前会话的协调仍须
|
||
持写权限的 key,不能凭一句话越权)。
|
||
投递分两路:进行中任务复用 /followups 的队列
|
||
(queued / direct,direct 同样立即进链路、不中断
|
||
本轮);终态任务(completed / failed / interrupted /
|
||
merge-failed)用 resume_message 复活——与 /continue
|
||
同款 CAS(状态 + resume_message 为空才改行),并
|
||
清掉旧 result/error(新一轮的指令不应顶着旧结果,
|
||
这点与 /messages 的 peer 复活不同,后者不清旧结果);
|
||
复活后 worker 认领即在同一 thread 上再跑一轮,这
|
||
正是“对失败任务发修复指令”的路径。merged 一律
|
||
409(worktree 已删,请分支);goal 任务一律 409
|
||
(goal 循环自有轮次,终态 goal 走 /continue)。"""
|
||
body, jerr = self._read_json()
|
||
if jerr:
|
||
self._send_json(400, {"error": jerr})
|
||
return
|
||
task, err = self._thread_task(user_id, task_id, "create_task")
|
||
if err is not None:
|
||
self._send_json(err[0], {"error": err[1]})
|
||
return
|
||
if self._reject_if_over_quota_for_task(user_id, task):
|
||
return
|
||
text = body.get("body")
|
||
if not text or not isinstance(text, str) or not text.strip():
|
||
self._send_json(400, {"error": "body required"})
|
||
return
|
||
text = text.strip()
|
||
if len(text) > 20000:
|
||
self._send_json(400, {"error": "body too long"})
|
||
return
|
||
mode = body.get("mode", "queued")
|
||
if mode not in ("queued", "direct"):
|
||
self._send_json(400, {"error": "mode must be queued or direct"})
|
||
return
|
||
code, payload = self._deliver_to_thread(
|
||
user_id, task, text, mode)
|
||
self._send_json(code, payload)
|
||
|
||
def _deliver_to_thread(self, user_id, task, text, mode,
|
||
audit_action="thread_message_sent"):
|
||
"""给线程投递后续指令的共用实现(thread/message 与
|
||
自动化 heartbeat 共用同一路,不各写一套复活逻辑):
|
||
goal 任务 409、merged 409、终态任务 CAS 复活(清旧
|
||
结果/旧错误)、进行中任务写 followup(direct 再追加
|
||
直接 trace)。例外:文本是 /goal 命令时(见
|
||
cloud/goal_command.py),按 /followups 的 goal 分支
|
||
处理——queued 原地转 goal、running 排队后由 worker
|
||
在轮次边界转 goal、已终态复活并转 goal。返回
|
||
(http_code, payload),不直接 _send_json;配额检查
|
||
由调用方在调本方法前做。"""
|
||
task_id = task["id"]
|
||
goal_target = _parse_goal_command(text)
|
||
# /goal 前置判定见 _classify_goal_followup(纯计算,与
|
||
# _create_followup 共用,但只用于 goal_target 非空时:
|
||
# 非 goal 路的终态与 merged 语义与 followup 不同——终态
|
||
# 走下面 CAS 复活而非 409,merged 走门禁文案而非
|
||
# “仅支持进行中任务”,故非 goal 绝不能进分类器)。
|
||
# 顺序与原文一致:goal error -> queued 原地转 goal ->
|
||
# 终态复活转 goal -> running 落到普通 followup 插入路
|
||
# (响应里标 delivery=goal_queued,见本函数末尾)->
|
||
# 非 goal 门禁 -> 终态 CAS 复活。
|
||
if goal_target is not None:
|
||
goal_action, goal_code, goal_payload = _classify_goal_followup(
|
||
task, goal_target)
|
||
if goal_action == "error":
|
||
return goal_code, goal_payload
|
||
if goal_action == "convert_queued":
|
||
new_task = self._goal_convert_queued(task_id, goal_target)
|
||
if new_task is None:
|
||
return 409, {
|
||
"error": "任务状态已变化,请刷新后重试",
|
||
"status": task["status"]}
|
||
with _auth_store(self.server.db_path) as store:
|
||
store.audit(user_id, task["project_id"], audit_action,
|
||
"%s:goal_converted" % task_id)
|
||
return 201, {
|
||
"task_id": task_id, "delivery": "goal_converted",
|
||
"followup_id": None, "status": "queued",
|
||
"mode": mode, "goal": goal_target}
|
||
if goal_action == "revive_terminal":
|
||
new_task = self._goal_revive_terminal(task_id, goal_target)
|
||
if new_task is None:
|
||
return 409, {
|
||
"error": "target has a pending resume message"
|
||
" or status changed",
|
||
"status": task["status"]}
|
||
with _auth_store(self.server.db_path) as store:
|
||
store.audit(user_id, task["project_id"], audit_action,
|
||
"%s:goal_revived" % task_id)
|
||
return 201, {
|
||
"task_id": task_id, "delivery": "goal_revived",
|
||
"followup_id": None, "status": "queued",
|
||
"mode": mode, "goal": goal_target}
|
||
guard = _thread_non_goal_guard(task)
|
||
if guard is not None:
|
||
return guard
|
||
if task["status"] in TERMINAL_STATUSES:
|
||
# 复活路:与 /continue 同款终态集(merged 已在上
|
||
# 面排除),CAS 防“已有待续跑消息”时重复复活。
|
||
now = time.time()
|
||
conn = self._db()
|
||
try:
|
||
cur = conn.execute(
|
||
"UPDATE tasks SET status = 'queued',"
|
||
" resume_message = ?, result_text = NULL,"
|
||
" error_text = NULL, actual_model = NULL,"
|
||
" updated_at = ? WHERE id = ? AND status IN"
|
||
" ('completed', 'failed', 'interrupted',"
|
||
" 'merge-failed') AND resume_message IS NULL",
|
||
(text, now, task_id))
|
||
conn.commit()
|
||
if cur.rowcount != 1:
|
||
return 409, {
|
||
"error": "target has a pending resume message"
|
||
" or status changed",
|
||
"status": task["status"]}
|
||
finally:
|
||
conn.close()
|
||
with _auth_store(self.server.db_path) as store:
|
||
store.audit(user_id, task["project_id"], audit_action,
|
||
"%s:revived" % task_id)
|
||
return 201, {
|
||
"task_id": task_id, "delivery": "revived",
|
||
"followup_id": None, "status": "queued",
|
||
"mode": mode}
|
||
now = time.time()
|
||
conn = self._db()
|
||
try:
|
||
_peer_ensure_followups(conn)
|
||
fu_id = uuid.uuid4().hex
|
||
conn.execute(
|
||
"INSERT INTO task_followups(id, task_id, project_id,"
|
||
" body, mode, status, created_by, created_at,"
|
||
" updated_at) VALUES(?, ?, ?, ?, ?, 'pending', ?, ?, ?)",
|
||
(fu_id, task_id, task["project_id"], text, mode,
|
||
user_id, now, now))
|
||
conn.commit()
|
||
fu = _followup_to_dict(
|
||
conn.execute(
|
||
"SELECT * FROM task_followups WHERE id = ?",
|
||
(fu_id,)).fetchone())
|
||
finally:
|
||
conn.close()
|
||
if mode == "direct":
|
||
self._append_direct_trace(task, fu_id, text)
|
||
with _auth_store(self.server.db_path) as store:
|
||
store.audit(user_id, task["project_id"], audit_action,
|
||
"%s:%s" % (task_id, fu_id))
|
||
if goal_target is not None:
|
||
store.audit(user_id, task["project_id"],
|
||
"mainline_goal_created",
|
||
"%s:goal_queued=%s" % (task_id, goal_target))
|
||
if goal_target is not None:
|
||
return 201, dict(fu, delivery="goal_queued",
|
||
goal=goal_target)
|
||
return 201, dict(fu, delivery="queued"
|
||
if mode == "queued" else "direct")
|
||
|
||
def _task_handoff_create(self, user_id, task_id):
|
||
"""POST /tasks/{id}/handoff:登记一次移交意图
|
||
(get_handoff_status 的写侧)。target_host 必填;
|
||
同一任务已有未终态移交时 409(见 wait_ops)。
|
||
权限 create_task;记 handoff_created 审计。"""
|
||
body, jerr = self._read_json()
|
||
if jerr:
|
||
self._send_json(400, {"error": jerr})
|
||
return
|
||
task, err = self._thread_task(user_id, task_id, "create_task")
|
||
if err is not None:
|
||
self._send_json(err[0], {"error": err[1]})
|
||
return
|
||
conn = self._db()
|
||
try:
|
||
try:
|
||
handoff = _handoff_create(
|
||
conn, task_id, task["project_id"],
|
||
body.get("target_host"), user_id,
|
||
source_host=task.get("worktree_host")
|
||
or socket.gethostname(),
|
||
detail=body.get("detail") or "")
|
||
except ValueError as exc:
|
||
self._send_json(409 if "already has an open" in str(exc)
|
||
else 400, {"error": str(exc)})
|
||
return
|
||
finally:
|
||
conn.close()
|
||
with _auth_store(self.server.db_path) as store:
|
||
store.audit(user_id, task["project_id"],
|
||
"handoff_created",
|
||
"%s -> %s" % (task_id, handoff["id"]))
|
||
self._send_json(201, handoff)
|
||
|
||
def _task_handoff_get(self, user_id, task_id):
|
||
"""GET /tasks/{id}/handoff(get_handoff_status):轮询
|
||
最新移交。无移交记录时 handoff 回 null,同时回任务
|
||
当前状态与当前主机——轮询方据此判断“还没交”还是
|
||
“已交但对方未回报”。读权限 read。"""
|
||
task, err = self._thread_task(user_id, task_id, "read")
|
||
if err is not None:
|
||
self._send_json(err[0], {"error": err[1]})
|
||
return
|
||
conn = self._db()
|
||
try:
|
||
handoff = _handoff_latest(conn, task_id)
|
||
finally:
|
||
conn.close()
|
||
current_host = task.get("worktree_host") or socket.gethostname()
|
||
self._send_json(200, {
|
||
"task_id": task_id,
|
||
"task_status": task["status"],
|
||
"current_host": current_host,
|
||
"handoff": handoff,
|
||
"arrived": bool(
|
||
handoff is not None
|
||
and current_host == handoff["target_host"]),
|
||
"note": ("" if handoff is not None
|
||
else "no handoff recorded for this task"),
|
||
})
|
||
|
||
def _task_handoff_report(self, user_id, task_id, handoff_id):
|
||
"""POST /tasks/{id}/handoff/{hid}/report:对方主机
|
||
回报移交进度。body:status(必填)、progress(可选
|
||
0-100)、detail(可选)。跨项目移交一律 404(不
|
||
泄露存在性);非法状态 / 进度 400;非法转移与终态
|
||
重报 409。权限 create_task(与任务心跳同款,回报
|
||
方持项目写权限);记 handoff_reported 审计。"""
|
||
body, jerr = self._read_json()
|
||
if jerr:
|
||
self._send_json(400, {"error": jerr})
|
||
return
|
||
task, err = self._thread_task(user_id, task_id, "create_task")
|
||
if err is not None:
|
||
self._send_json(err[0], {"error": err[1]})
|
||
return
|
||
conn = self._db()
|
||
try:
|
||
existing = _handoff_get(conn, handoff_id)
|
||
if existing is None or existing["project_id"] != \
|
||
task["project_id"] or existing["task_id"] != task_id:
|
||
self._send_json(404, {"error": "handoff not found"})
|
||
return
|
||
ok, result = _handoff_report(
|
||
conn, handoff_id, body.get("status"),
|
||
progress=body.get("progress"),
|
||
detail=body.get("detail"))
|
||
finally:
|
||
conn.close()
|
||
if not ok:
|
||
if result == "handoff not found":
|
||
self._send_json(404, {"error": result})
|
||
elif result.startswith("cannot move"):
|
||
self._send_json(409, {"error": result})
|
||
else:
|
||
self._send_json(400, {"error": result})
|
||
return
|
||
if result.get("status") == "completed":
|
||
# 移交完成即执行位置切换的落点:任务的 worktree_host
|
||
# 改成目标主机,后续 /worktree、/pr/checks、线程解析
|
||
# 都按新主机判断;失败/进行中的上报不改主机。
|
||
conn = self._db()
|
||
try:
|
||
conn.execute(
|
||
"UPDATE tasks SET worktree_host = ?,"
|
||
" updated_at = ? WHERE id = ?",
|
||
(result.get("target_host"), time.time(), task_id))
|
||
conn.commit()
|
||
finally:
|
||
conn.close()
|
||
with _auth_store(self.server.db_path) as store:
|
||
store.audit(user_id, task["project_id"],
|
||
"handoff_reported",
|
||
"%s:%s" % (task_id, handoff_id))
|
||
self._send_json(200, result)
|
||
|
||
def _worktree_summary(self, task):
|
||
"""worktree 摘要(bindings 与 /worktree 共用的读逻辑):
|
||
remote 时 exists/is_git/branch 一律 null(本机查不到,
|
||
不谎报);本机时读目录 + .git + 分支(尽力,失败 null)。"""
|
||
worktree_host = task.get("worktree_host")
|
||
remote = worktree_host if (
|
||
worktree_host and worktree_host != socket.gethostname()
|
||
) else None
|
||
path = _thread_worktree_for_task(self.server, task)
|
||
exists = None
|
||
is_git = None
|
||
branch = None
|
||
if remote is None:
|
||
exists = os.path.isdir(path)
|
||
is_git = os.path.exists(os.path.join(path, ".git")) \
|
||
if exists else False
|
||
if exists and is_git:
|
||
try:
|
||
proc = subprocess.run(
|
||
["git", "-C", path, "branch", "--show-current"],
|
||
capture_output=True, text=True, timeout=5)
|
||
if proc.returncode == 0:
|
||
branch = (proc.stdout or "").strip() or None
|
||
except Exception:
|
||
branch = None
|
||
env_status = None
|
||
if task.get("environment_id"):
|
||
env_row = self._fetch_environment(task["environment_id"])
|
||
if env_row is not None:
|
||
env_status = _environment_to_dict(env_row)["status"]
|
||
status = _wait_worktree_status(
|
||
task["status"], path, exists, is_git,
|
||
environment_status=env_status, remote_host=remote)
|
||
return dict(status, path=path, branch=branch,
|
||
environment_id=task.get("environment_id"),
|
||
remote_host=remote)
|
||
|
||
def _task_bindings(self, user_id, task_id):
|
||
"""GET /tasks/{id}/bindings(任务与 Git 状态绑定 +
|
||
项目/主机上下文):把散在任务行、项目行、环境行、
|
||
worktree、PR、附件、外部引用里的一行拼成一张总表,
|
||
供跨线程协调时一次取全。只读聚合,不产生新状态;
|
||
读权限 read。local/remote 按 worktree_host 与本机
|
||
主机名判定;default_executor 是全局缺省,任务行
|
||
的 executor 为空时才生效。"""
|
||
task, err = self._thread_task(user_id, task_id, "read")
|
||
if err is not None:
|
||
self._send_json(err[0], {"error": err[1]})
|
||
return
|
||
conn = self._db()
|
||
try:
|
||
proj = conn.execute(
|
||
"SELECT id, name, repo_url FROM projects WHERE id = ?",
|
||
(task["project_id"],)).fetchone()
|
||
atts = _attachments_map(conn, [task_id])[task_id]
|
||
refs = _life_list_refs(conn, task_id)
|
||
snaps = _life_list_snapshots(conn, task_id)
|
||
finally:
|
||
conn.close()
|
||
env = None
|
||
if task.get("environment_id"):
|
||
env_row = self._fetch_environment(task["environment_id"])
|
||
if env_row is not None:
|
||
env = _environment_to_dict(env_row)
|
||
worktree = self._worktree_summary(task)
|
||
self._send_json(200, {
|
||
"task_id": task_id,
|
||
"task_status": task["status"],
|
||
"project": ({
|
||
"id": _row_get(proj, "id"),
|
||
"name": _row_get(proj, "name"),
|
||
"repo_url": _row_get(proj, "repo_url"),
|
||
} if proj is not None else None),
|
||
"environment": env,
|
||
"host": {
|
||
"worktree_host": task.get("worktree_host"),
|
||
"local": worktree["remote_host"] is None,
|
||
"current_host": socket.gethostname(),
|
||
},
|
||
"worktree": worktree,
|
||
"branch": worktree["branch"],
|
||
"pull_request": ({
|
||
"url": task.get("pr_url"),
|
||
"number": task.get("pr_number"),
|
||
} if task.get("pr_number") else None),
|
||
"executor": task.get("executor") or _default_executor(),
|
||
"attachments": {
|
||
"files": atts,
|
||
"refs": refs,
|
||
},
|
||
"worktree_snapshots": [{
|
||
"id": s["id"], "branch": s["branch"],
|
||
"head_sha": s["head_sha"],
|
||
"created_at": s["created_at"],
|
||
"restored_at": s["restored_at"],
|
||
} for s in snaps],
|
||
"lineage": {
|
||
"parent_task_id": task.get("parent_task_id"),
|
||
"forked_from": task.get("forked_from"),
|
||
"coordinator_part": task.get("coordinator_part"),
|
||
},
|
||
})
|
||
|
||
def _task_refs_list(self, user_id, task_id):
|
||
task, err = self._thread_task(user_id, task_id, "read")
|
||
if err is not None:
|
||
self._send_json(err[0], {"error": err[1]})
|
||
return
|
||
conn = self._db()
|
||
try:
|
||
refs = _life_list_refs(conn, task_id)
|
||
finally:
|
||
conn.close()
|
||
self._send_json(200, {"task_id": task_id, "refs": refs})
|
||
|
||
def _task_refs_create(self, user_id, task_id):
|
||
"""POST /tasks/{id}/refs:记一条外部引用(生成的文档 /
|
||
构建结果 / 外部服务结果)。只记引用,不存内容、不
|
||
校验链接可达;kind 白名单见 lifecycle.REF_KINDS。
|
||
权限 create_task。"""
|
||
task, err = self._thread_task(user_id, task_id, "create_task")
|
||
if err is not None:
|
||
self._send_json(err[0], {"error": err[1]})
|
||
return
|
||
body, jerr = self._read_json()
|
||
if jerr:
|
||
self._send_json(400, {"error": jerr})
|
||
return
|
||
conn = self._db()
|
||
try:
|
||
try:
|
||
ref = _life_add_ref(
|
||
conn, task_id, task["project_id"],
|
||
body.get("kind"), body.get("ref"),
|
||
body.get("note"), user_id)
|
||
except ValueError as exc:
|
||
self._send_json(400, {"error": str(exc)})
|
||
return
|
||
finally:
|
||
conn.close()
|
||
with _auth_store(self.server.db_path) as store:
|
||
store.audit(user_id, task["project_id"], "task_ref_added",
|
||
"%s:%s" % (task_id, ref["id"]))
|
||
self._send_json(201, ref)
|
||
|
||
def _task_refs_delete(self, user_id, task_id, ref_id):
|
||
task, err = self._thread_task(user_id, task_id, "create_task")
|
||
if err is not None:
|
||
self._send_json(err[0], {"error": err[1]})
|
||
return
|
||
conn = self._db()
|
||
try:
|
||
ok = _life_delete_ref(conn, task_id, ref_id)
|
||
finally:
|
||
conn.close()
|
||
if not ok:
|
||
self._send_json(404, {"error": "ref not found"})
|
||
return
|
||
with _auth_store(self.server.db_path) as store:
|
||
store.audit(user_id, task["project_id"], "task_ref_deleted",
|
||
"%s:%s" % (task_id, ref_id))
|
||
self._send_json(200, {"task_id": task_id, "ref_id": ref_id,
|
||
"deleted": True})
|
||
|
||
def _git_text(self, path, args, limit):
|
||
"""worktree 上尽力跑一条只读 git 命令;git 不在、
|
||
不是仓库、超时都回空串,不拖垮快照。"""
|
||
try:
|
||
proc = subprocess.run(
|
||
["git", "-C", path] + args,
|
||
capture_output=True, text=True, timeout=10)
|
||
if proc.returncode != 0:
|
||
return ""
|
||
return (proc.stdout or "")[:limit]
|
||
except Exception:
|
||
return ""
|
||
|
||
def _task_worktree_archive(self, user_id, task_id):
|
||
"""POST /tasks/{id}/worktree/archive:给当前 worktree
|
||
拍一张快照(branch / head / status / diff / untracked
|
||
路径),行进 task_worktree_snapshots。快照不删目录、
|
||
不删分支——删目录是 merge/discard 的职责,归档只是
|
||
“先把证据存下来”。worktree 在别机时 409(本机
|
||
git 读不到对方目录);目录不存在时 409(无可拍)。
|
||
权限 create_task;记 worktree_snapshot_created 审计。"""
|
||
task, err = self._thread_task(user_id, task_id, "create_task")
|
||
if err is not None:
|
||
self._send_json(err[0], {"error": err[1]})
|
||
return
|
||
summary = self._worktree_summary(task)
|
||
if summary["remote_host"] is not None:
|
||
self._send_json(409, {
|
||
"error": "task worktree is on host %s;"
|
||
" snapshot must run there"
|
||
% summary["remote_host"],
|
||
"worktree_host": summary["remote_host"]})
|
||
return
|
||
path = summary["path"]
|
||
if not summary["exists"]:
|
||
self._send_json(409, {
|
||
"error": "worktree directory does not exist",
|
||
"path": path})
|
||
return
|
||
status_text = self._git_text(
|
||
path, ["status", "--porcelain"], 4000) \
|
||
if summary["is_git"] else ""
|
||
diff_text = self._git_text(
|
||
path, ["diff", "HEAD"], 20000) if summary["is_git"] else ""
|
||
head_sha = None
|
||
if summary["is_git"]:
|
||
head_sha = self._git_text(
|
||
path, ["rev-parse", "HEAD"], 100).strip() or None
|
||
untracked = [line[3:] for line in status_text.splitlines()
|
||
if line.startswith("?? ")]
|
||
conn = self._db()
|
||
try:
|
||
snap = _life_snapshot(
|
||
conn, task_id, task["project_id"], path,
|
||
summary["branch"], head_sha, status_text, diff_text,
|
||
untracked)
|
||
finally:
|
||
conn.close()
|
||
with _auth_store(self.server.db_path) as store:
|
||
store.audit(user_id, task["project_id"],
|
||
"worktree_snapshot_created",
|
||
"%s:%s" % (task_id, snap["id"]))
|
||
self._send_json(201, {
|
||
"task_id": task_id, "snapshot_id": snap["id"],
|
||
"branch": snap["branch"], "head_sha": snap["head_sha"],
|
||
"untracked_count": len(snap["untracked"]),
|
||
"status_lines": len(
|
||
(snap["status_text"] or "").splitlines()),
|
||
"note": ("snapshot only: untracked files are listed,"
|
||
" not copied; restore does not recreate a"
|
||
" deleted directory"),
|
||
})
|
||
|
||
def _task_worktree_snapshots(self, user_id, task_id):
|
||
task, err = self._thread_task(user_id, task_id, "read")
|
||
if err is not None:
|
||
self._send_json(err[0], {"error": err[1]})
|
||
return
|
||
conn = self._db()
|
||
try:
|
||
snaps = _life_list_snapshots(conn, task_id)
|
||
finally:
|
||
conn.close()
|
||
self._send_json(200, {"task_id": task_id, "snapshots": snaps})
|
||
|
||
def _task_worktree_restore(self, user_id, task_id):
|
||
"""POST /tasks/{id}/worktree/restore:解归档最新快照。
|
||
目录还在时只把快照标 restored(目录本身没动过,谈
|
||
不上“还原”);目录已删时 409 并附快照(重建需要
|
||
仓库路径与分支检出,服务端没有,不编一个假恢复)。
|
||
权限 create_task。"""
|
||
task, err = self._thread_task(user_id, task_id, "create_task")
|
||
if err is not None:
|
||
self._send_json(err[0], {"error": err[1]})
|
||
return
|
||
conn = self._db()
|
||
try:
|
||
snap = _life_latest_snapshot(conn, task_id)
|
||
finally:
|
||
conn.close()
|
||
if snap is None:
|
||
self._send_json(409, {"error": "no snapshot to restore"})
|
||
return
|
||
if not os.path.isdir(snap["worktree_path"]):
|
||
self._send_json(409, {
|
||
"error": "worktree directory is gone; snapshot"
|
||
" cannot recreate it without the repo",
|
||
"snapshot": snap})
|
||
return
|
||
conn = self._db()
|
||
try:
|
||
snap = _life_mark_restored(
|
||
conn, snap["id"],
|
||
"worktree directory still present; snapshot"
|
||
" unarchived only")
|
||
finally:
|
||
conn.close()
|
||
with _auth_store(self.server.db_path) as store:
|
||
store.audit(user_id, task["project_id"],
|
||
"worktree_snapshot_restored",
|
||
"%s:%s" % (task_id, snap["id"]))
|
||
self._send_json(200, {"task_id": task_id, "snapshot": snap})
|
||
|
||
def _task_worktree_status(self, user_id, task_id):
|
||
"""GET /tasks/{id}/worktree(get_worktree_creation_status):
|
||
派生状态,不是异步创建任务的轮询——云端 worktree 由
|
||
worker 认领后同步建,建的过程没有可查的 job 行。
|
||
worktree 在别机时 status=remote 且 exists=null(本机
|
||
查不到,不谎报不存在);任务绑 environment 时一并回
|
||
environment_status(那才是真异步的创建过程)。读权限
|
||
read。"""
|
||
task, err = self._thread_task(user_id, task_id, "read")
|
||
if err is not None:
|
||
self._send_json(err[0], {"error": err[1]})
|
||
return
|
||
summary = self._worktree_summary(task)
|
||
self._send_json(200, dict(summary, task_id=task_id))
|
||
|
||
def _task_pr_checks(self, user_id, task_id):
|
||
"""GET /tasks/{id}/pr/checks(read_pull_request_check_diagnostics):
|
||
只读诊断,绝不重跑检查、不批准、不合并(与 /pr 的
|
||
建 PR、/merge 的合并门是三条线)。无 pr_number 时
|
||
409(还没建 PR,查无可查);worktree 在别机时 409
|
||
(gh 必须在 worktree 所在主机跑,与 /pr 的队列路
|
||
不同——诊断是只读拉取,不排队等对方 worker 顺带跑,
|
||
避免“查一次 CI 就排一个 PR 任务”的副作用)。
|
||
取数分两层:先 `gh pr checks --json`(workflow、
|
||
link、description 即“哪个 Action 失败”与失败摘要),
|
||
JSON 失败时回落制表符解析;有仓库 + head sha 且有
|
||
失败项时,再走 check-runs API 拿失败日志原文
|
||
(output.text,截 4000 字)与文件行号注释
|
||
(annotations:path / start_line / message,上限
|
||
20 条)。check-runs 一层失败时不 502 整单——
|
||
checks 本身已够回答“哪个检查失败”,日志是加分项,
|
||
此时 check_runs 回 available=False + note。读权限
|
||
read。"""
|
||
task, err = self._thread_task(user_id, task_id, "read")
|
||
if err is not None:
|
||
self._send_json(err[0], {"error": err[1]})
|
||
return
|
||
if not task.get("pr_number"):
|
||
self._send_json(409, {
|
||
"error": "task has no pull request yet",
|
||
"pr_number": None})
|
||
return
|
||
worktree_host = task.get("worktree_host")
|
||
if worktree_host and worktree_host != socket.gethostname():
|
||
self._send_json(409, {
|
||
"error": "task worktree is on host %s;"
|
||
" pr checks must run there" % worktree_host,
|
||
"worktree_host": worktree_host})
|
||
return
|
||
from cloud.pr import _run as _pr_run
|
||
from cloud.pr import parse_git_remote_owner_repo as _parse_repo
|
||
worktree_path = _thread_worktree_for_task(self.server, task)
|
||
runner = getattr(self.server, "pr_gh_runner", None)
|
||
|
||
def _run(cmd):
|
||
result = _pr_run(cmd, cwd=worktree_path, runner=runner)
|
||
return result.returncode, result.stdout or "", \
|
||
result.stderr or ""
|
||
|
||
owner_repo = None
|
||
conn = self._db()
|
||
try:
|
||
row = conn.execute(
|
||
"SELECT repo_url FROM projects WHERE id = ?",
|
||
(task["project_id"],)).fetchone()
|
||
repo_url = row[0] if row is not None else None
|
||
finally:
|
||
conn.close()
|
||
if repo_url:
|
||
parsed = _parse_repo(repo_url)
|
||
if parsed is not None:
|
||
owner_repo = "%s/%s" % (parsed[0], parsed[1])
|
||
head_sha = None
|
||
if owner_repo:
|
||
rc, out, _e = _run(["gh", "pr", "view",
|
||
str(task["pr_number"]),
|
||
"--json", "headRefOid",
|
||
"--jq", ".headRefOid"])
|
||
head_sha = (out or "").strip() or None
|
||
if not head_sha:
|
||
rc, out, _e = _run(["git", "-C", worktree_path,
|
||
"rev-parse", "HEAD"])
|
||
head_sha = (out or "").strip() or None
|
||
try:
|
||
diag = _wait_build_diag(_run, task["pr_number"],
|
||
owner_repo=owner_repo,
|
||
head_sha=head_sha)
|
||
except RuntimeError as exc:
|
||
self._send_json(502, {"error": str(exc)[:500]})
|
||
return
|
||
pending = [c["name"] for c in diag["checks"]
|
||
if c["state"] == "pending"]
|
||
self._send_json(200, {
|
||
"task_id": task_id,
|
||
"pr_url": task.get("pr_url"),
|
||
"pr_number": task["pr_number"],
|
||
"head_sha": head_sha,
|
||
"checks": diag["checks"],
|
||
"all_pass": diag["all_pass"],
|
||
"failed": diag["failed"],
|
||
"pending": pending,
|
||
"diagnostics": diag["diagnostics"],
|
||
"check_runs": diag["check_runs"],
|
||
})
|
||
|
||
def _task_sleep(self, user_id, task_id):
|
||
"""POST /tasks/{id}/sleep(clock.sleep 的控制面形态):
|
||
在服务端睡一段(默认 5 秒,上限 60 秒)再回任务快照,
|
||
供轮询客户端“等一会儿再继续”而不用自己计时。睡的
|
||
是调用方的等待,不是任务的执行——任务仍由 worker
|
||
跑,sleep 绝不暂停、加速或改动任务。读权限 read;
|
||
seconds 非法时 400(不是静默钳制,调用方应知道
|
||
自己传错了)。"""
|
||
body, jerr = self._read_json()
|
||
if jerr:
|
||
self._send_json(400, {"error": jerr})
|
||
return
|
||
task, err = self._thread_task(user_id, task_id, "read")
|
||
if err is not None:
|
||
self._send_json(err[0], {"error": err[1]})
|
||
return
|
||
seconds = body.get("seconds", 5)
|
||
if isinstance(seconds, bool) or not isinstance(
|
||
seconds, (int, float)):
|
||
self._send_json(400, {"error": "seconds must be a number"})
|
||
return
|
||
if seconds < 0 or seconds > 60:
|
||
self._send_json(400, {
|
||
"error": "seconds must be between 0 and 60"})
|
||
return
|
||
slept, _interrupted = _wait_sleep(seconds)
|
||
row = self._fetch_task(task_id)
|
||
task = _task_to_dict(row) if row is not None else task
|
||
self._send_json(200, {
|
||
"task_id": task_id, "slept_seconds": slept,
|
||
"status": task["status"],
|
||
"result_text": task.get("result_text"),
|
||
})
|
||
|
||
def _task_wait(self, user_id, task_id):
|
||
"""POST /tasks/{id}/wait(wait_threads):等第一个
|
||
重要变化,不是等全部终态。一次最多 8 个线程(超限
|
||
400,调用方自己选子集);body 可选 task_ids(缺省
|
||
时等该任务的全部 coordinator 子任务,超过 8 个同样
|
||
400);timeout_seconds 缺省 30、上限 120;轮询间
|
||
隔 0.5 秒。
|
||
重要变化(见 wait_ops.detect_significant_change):
|
||
终态(completed / failed / interrupted / merged /
|
||
merge-failed)、待用户输入(kind=elicitation 的待
|
||
审批新增)、待审批(其它 kind 的待审批新增)、待
|
||
继续处理(待处理后续消息新增)。running / queued
|
||
流转与链路条目增加不算变化,不会把等待刷屏。
|
||
after_cursor:上一轮返回的游标;传了它,本轮只报
|
||
相对游标的新变化(已终态的线程不会再报一遍);
|
||
非法游标 400(不静默当无游标)。不传游标时,已
|
||
处重要状态的线程立即返回(不用等一轮)。
|
||
超时回 timed_out=true + 当时进度 + 新游标,不是
|
||
错误——调用方拿新游标再等一轮,或先处理进度里
|
||
已终态 / 已阻塞的线程。返回里同时保留 children
|
||
(= progress 的别名)与 all_terminal,方便“等
|
||
全部完成”的调用方直接判断。
|
||
权限:锚任务 read,且每个等待任务都要 read(跨
|
||
项目任务无 read 时 403,任务不存在时 404,不
|
||
泄露存在性以外的信息);只读轮询,不改任何任务,
|
||
不创建任务、不发指令。"""
|
||
body, jerr = self._read_json()
|
||
if jerr:
|
||
self._send_json(400, {"error": jerr})
|
||
return
|
||
task, err = self._thread_task(user_id, task_id, "read")
|
||
if err is not None:
|
||
self._send_json(err[0], {"error": err[1]})
|
||
return
|
||
timeout, requested, werr = _validate_wait_params(body)
|
||
if werr is not None:
|
||
self._send_json(400, werr)
|
||
return
|
||
conn = self._db()
|
||
try:
|
||
if requested is None:
|
||
rows = conn.execute(
|
||
"SELECT id FROM tasks WHERE parent_task_id = ?",
|
||
(task_id,)).fetchall()
|
||
child_ids = [r[0] if not isinstance(r, dict)
|
||
and not hasattr(r, "keys") else r["id"]
|
||
for r in rows]
|
||
else:
|
||
child_ids = list(requested)
|
||
finally:
|
||
conn.close()
|
||
if len(child_ids) > 8:
|
||
self._send_json(400, {
|
||
"error": "at most 8 threads per wait;"
|
||
" this task has %d children, pass task_ids"
|
||
" to select a subset" % len(child_ids),
|
||
"count": len(child_ids)})
|
||
return
|
||
# 每个等待任务先验存在与 read 权限(顺序即 task_ids
|
||
# 顺序,第一个不合规的先报错,不静默跳过)。
|
||
for cid in child_ids:
|
||
_t, terr = self._thread_task(user_id, cid, "read")
|
||
if terr is not None:
|
||
self._send_json(terr[0], {"error": terr[1]})
|
||
return
|
||
baseline = None
|
||
if body.get("after_cursor") is not None:
|
||
try:
|
||
baseline = _wait_decode_cursor(body["after_cursor"])
|
||
except ValueError as exc:
|
||
self._send_json(400, {"error": str(exc)})
|
||
return
|
||
|
||
def _fetch_summaries():
|
||
conn = self._db()
|
||
try:
|
||
_appr_ensure(conn)
|
||
_peer_ensure_followups(conn)
|
||
out = {}
|
||
for cid in child_ids:
|
||
row = conn.execute(
|
||
"SELECT * FROM tasks WHERE id = ?",
|
||
(cid,)).fetchone()
|
||
if row is None:
|
||
# 验过存在后理论上不会走到这里;走到
|
||
# 时按“无状态”处理,不当成终态。
|
||
out[cid] = {"status": "not_found",
|
||
"pending_approvals": 0,
|
||
"pending_input": 0,
|
||
"pending_followups": 0}
|
||
continue
|
||
status = _task_to_dict(row)["status"]
|
||
appr_rows = conn.execute(
|
||
"SELECT kind FROM task_approvals"
|
||
" WHERE task_id = ? AND status = 'pending'",
|
||
(cid,)).fetchall()
|
||
kinds = [r[0] if not isinstance(r, dict)
|
||
and not hasattr(r, "keys") else r["kind"]
|
||
for r in appr_rows]
|
||
followups = conn.execute(
|
||
"SELECT COUNT(*) FROM task_followups"
|
||
" WHERE task_id = ? AND status = 'pending'",
|
||
(cid,)).fetchone()[0]
|
||
out[cid] = {
|
||
"status": status,
|
||
"pending_approvals": sum(
|
||
1 for k in kinds if k != "elicitation"),
|
||
"pending_input": sum(
|
||
1 for k in kinds if k == "elicitation"),
|
||
"pending_followups": int(followups or 0),
|
||
}
|
||
return out
|
||
finally:
|
||
conn.close()
|
||
|
||
changed, summaries, waited, timed_out = _wait_poll_first(
|
||
_fetch_summaries, child_ids, baseline=baseline,
|
||
timeout_seconds=timeout)
|
||
progress = []
|
||
for cid in child_ids:
|
||
summary = summaries.get(cid, {})
|
||
status = summary.get("status")
|
||
progress.append({
|
||
"task_id": cid, "status": status,
|
||
"terminal": status in TERMINAL_STATUSES,
|
||
"pending_approvals": summary.get("pending_approvals", 0),
|
||
"pending_input": summary.get("pending_input", 0),
|
||
"pending_followups": summary.get("pending_followups", 0),
|
||
"significant_now": _wait_significant_now(summary),
|
||
})
|
||
self._send_json(200, {
|
||
"task_id": task_id, "changed_task": changed,
|
||
"progress": progress, "children": progress,
|
||
"all_terminal": all(
|
||
p["terminal"] for p in progress),
|
||
"timed_out": timed_out,
|
||
"waited_seconds": waited,
|
||
"cursor": _wait_encode_cursor(summaries),
|
||
})
|
||
|
||
def _fork_task(self, user_id, task_id):
|
||
"""会话分支:从已结束任务的 thread 分叉出新任务。
|
||
|
||
新任务继承源任务的 executor/模型/强度/权限/计划模式,
|
||
以及派生默认 child_model / child_effort(分支会话自己
|
||
再派生时仍用同一派生默认),不继承 goal(分支是普通
|
||
任务,首轮 prompt 就是这里传的 message)。worker 认领
|
||
后先调 host.py fork(分叉 thread 历史 + 拷 worktree
|
||
文件),再以 message 跑首轮。
|
||
只能从终态任务分支(进行中的 thread 正在变,分叉快照
|
||
不可信);message 必填——分支没有首条消息就没有方向。
|
||
"""
|
||
body, err = self._read_json()
|
||
if err:
|
||
self._send_json(400, {"error": err})
|
||
return
|
||
row = self._fetch_task(task_id)
|
||
if row is None:
|
||
self._send_json(404, {"error": "task not found"})
|
||
return
|
||
task = _task_to_dict(row)
|
||
with _auth_store(self.server.db_path) as store:
|
||
if not store.check_permission(
|
||
user_id, task["project_id"], "create_task"):
|
||
self._send_json(403, {"error": "forbidden"})
|
||
return
|
||
if self._reject_if_over_quota(user_id):
|
||
return
|
||
message = body.get("message")
|
||
if not message or not isinstance(message, str) \
|
||
or not message.strip():
|
||
self._send_json(400, {"error": "message required"})
|
||
return
|
||
message = message.strip()
|
||
if len(message) > 20000:
|
||
self._send_json(400, {"error": "message too long"})
|
||
return
|
||
if task["status"] not in TERMINAL_STATUSES:
|
||
self._send_json(409, {
|
||
"error": "只能从已结束的任务分支",
|
||
"status": task["status"],
|
||
})
|
||
return
|
||
now = time.time()
|
||
new_id = uuid.uuid4().hex
|
||
conn = self._db()
|
||
try:
|
||
conn.execute(
|
||
"INSERT INTO tasks(id, project_id, prompt, status,"
|
||
" idempotency_key, depends_on, priority, created_by,"
|
||
" created_at, updated_at, last_heartbeat, executor,"
|
||
" goal, verify_command, goal_max_rounds,"
|
||
" model, effort, approval, plan, forked_from,"
|
||
" child_model, child_effort)"
|
||
" VALUES(?, ?, ?, 'queued', NULL, '[]', ?, ?, ?, ?,"
|
||
" NULL, ?, NULL, NULL, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||
(new_id, task["project_id"], message, task["priority"],
|
||
user_id, now, now,
|
||
task["executor"] or _default_executor(),
|
||
task["goal_max_rounds"] or 3, task["model"],
|
||
task["effort"], task["approval"], task["plan"] or 0,
|
||
task_id, task["child_model"], task["child_effort"]),
|
||
)
|
||
# 分支带走附件清单(worktree 本身已含这些文件,分支
|
||
# 不再 seed;清单只作展示,bundle 删了也不影响分支)。
|
||
conn.execute(
|
||
"INSERT INTO task_attachments"
|
||
"(task_id, bundle_id, rel_path, size)"
|
||
" SELECT ?, bundle_id, rel_path, size"
|
||
" FROM task_attachments WHERE task_id = ?",
|
||
(new_id, task_id),
|
||
)
|
||
conn.commit()
|
||
new_task = _task_to_dict(
|
||
conn.execute(
|
||
"SELECT * FROM tasks WHERE id = ?", (new_id,)
|
||
).fetchone(),
|
||
_attachments_map(conn, [new_id])[new_id],
|
||
)
|
||
finally:
|
||
conn.close()
|
||
with _auth_store(self.server.db_path) as store:
|
||
store.audit(user_id, task["project_id"], "task_forked",
|
||
"%s -> %s" % (task_id, new_id))
|
||
self._send_json(201, new_task)
|
||
|
||
def _cancel_task(self, user_id, task_id):
|
||
row = self._fetch_task(task_id)
|
||
if row is None:
|
||
self._send_json(404, {"error": "task not found"})
|
||
return
|
||
task = _task_to_dict(row)
|
||
with _auth_store(self.server.db_path) as store:
|
||
if not store.check_permission(
|
||
user_id, task["project_id"], "create_task"):
|
||
self._send_json(403, {"error": "forbidden"})
|
||
return
|
||
if task["status"] in TERMINAL_STATUSES:
|
||
self._send_json(400, {"error": "task already terminal"})
|
||
return
|
||
now = time.time()
|
||
conn = self._db()
|
||
try:
|
||
# 用户取消 = 用户暂停:自动恢复绝不接它,人工
|
||
# /retry 或 /goal-resume 才能再跑(那两处会清标记)。
|
||
conn.execute(
|
||
"UPDATE tasks SET status = 'interrupted',"
|
||
" paused_by_user = 1, interrupted_reason = 'user_cancel',"
|
||
" updated_at = ?"
|
||
" WHERE id = ?",
|
||
(now, task_id),
|
||
)
|
||
conn.commit()
|
||
task = _task_to_dict(
|
||
conn.execute(
|
||
"SELECT * FROM tasks WHERE id = ?", (task_id,)
|
||
).fetchone()
|
||
)
|
||
# owner 活着时它自己的 1 秒看门狗会杀;owner 已死时
|
||
# 靠这条 kill 请求让别的活着的 worker 在收殓器里杀
|
||
# 遗留容器。写请求失败绝不挡取消本身(best-effort)。
|
||
try:
|
||
request_kill(self.server.db_path, task_id, user_id)
|
||
except Exception:
|
||
pass
|
||
# 取消后 worker 不会再跑它:暂存包没人 seed 了,顺手删掉
|
||
# (任务终局后 worker 侧也会删,两边删同一目录都容忍
|
||
# 不存在;删失败不挡取消本身)。
|
||
try:
|
||
brow = conn.execute(
|
||
"SELECT bundle_id FROM task_attachments"
|
||
" WHERE task_id = ? LIMIT 1",
|
||
(task_id,),
|
||
).fetchone()
|
||
if brow is not None:
|
||
bundle_id = brow[0]
|
||
shutil.rmtree(
|
||
os.path.join(
|
||
_upload_dir_for(self.server), bundle_id),
|
||
ignore_errors=True)
|
||
except Exception:
|
||
pass
|
||
finally:
|
||
conn.close()
|
||
with _auth_store(self.server.db_path) as store:
|
||
store.audit(user_id, task["project_id"], "cancel_task", task_id)
|
||
self._send_json(200, task)
|
||
|
||
def _pause_task(self, user_id, task_id):
|
||
"""用户暂停:与 /cancel 同终态、不同语义标记。
|
||
|
||
只有用户暂停才暂停:本端点把任务标 interrupted 并置
|
||
paused_by_user=1、interrupted_reason='user_pause',worker
|
||
自动恢复(auto_resume_interrupted)绝不接它;人工
|
||
/retry 或 /goal-resume 才能再跑(那两处会清标记)。
|
||
权限与 /cancel 同款(create_task),终态任务 400。
|
||
"""
|
||
row = self._fetch_task(task_id)
|
||
if row is None:
|
||
self._send_json(404, {"error": "task not found"})
|
||
return
|
||
task = _task_to_dict(row)
|
||
with _auth_store(self.server.db_path) as store:
|
||
if not store.check_permission(
|
||
user_id, task["project_id"], "create_task"):
|
||
self._send_json(403, {"error": "forbidden"})
|
||
return
|
||
if task["status"] in TERMINAL_STATUSES:
|
||
self._send_json(400, {"error": "task already terminal"})
|
||
return
|
||
now = time.time()
|
||
conn = self._db()
|
||
try:
|
||
conn.execute(
|
||
"UPDATE tasks SET status = 'interrupted',"
|
||
" paused_by_user = 1, interrupted_reason = 'user_pause',"
|
||
" updated_at = ? WHERE id = ?",
|
||
(now, task_id),
|
||
)
|
||
# P0 三件其三:时间线 pause 步骤(best-effort)。
|
||
try:
|
||
_ensure_task_steps_table(conn)
|
||
_max = conn.execute(
|
||
"SELECT MAX(step_no) FROM task_steps"
|
||
" WHERE task_id = ?", (task_id,)).fetchone()
|
||
conn.execute(
|
||
"INSERT INTO task_steps(id, task_id, step_no, kind,"
|
||
" summary, state_sha, created_at)"
|
||
" VALUES(?, ?, ?, 'pause', '用户暂停', NULL, ?)",
|
||
(uuid.uuid4().hex, task_id,
|
||
((_max[0] or 0) + 1 if _max and _max[0] else 1),
|
||
now))
|
||
except Exception:
|
||
pass
|
||
conn.commit()
|
||
task = _task_to_dict(
|
||
conn.execute(
|
||
"SELECT * FROM tasks WHERE id = ?", (task_id,)
|
||
).fetchone()
|
||
)
|
||
finally:
|
||
conn.close()
|
||
with _auth_store(self.server.db_path) as store:
|
||
store.audit(user_id, task["project_id"], "pause_task", task_id)
|
||
self._send_json(200, task)
|
||
|
||
def _merge_task(self, user_id, task_id):
|
||
"""把云端任务标记为已合并:只补状态,不执行合并本身。
|
||
|
||
本接口不做 git 合并。自动 PR 的正确顺序是:任务
|
||
completed -> 调本接口改状态 -> 调 POST /tasks/{id}/pr
|
||
推分支建 PR -> 再在 GitHub 上合 PR 或本地 host.py merge。
|
||
本地 host.py merge 成功后会删分支和 worktree,此后调
|
||
PR 会跳过 no_worktree,所以 PR 必须在本地 git merge
|
||
之前做,而不是之后。规则:任务必须存在(404)、调用方
|
||
必须是项目 maintainer 以上(approve_merge,403)、任务
|
||
必须是 completed(其它状态一律 409,含已 merged 的重复
|
||
调用,避免二次合并混淆)。成功后写审计 merge_task。
|
||
|
||
合并门禁(cloud/merge_gate_hard,唯一服务端门禁,补 v0.3
|
||
技能文本检查在服务端缺失的缺口;v0.3 之前本接口只改状态,
|
||
不查目录、不查保护):
|
||
服务端只有 hard 一种模式:环境变量 CLOUD_MERGE_GATE_MODE
|
||
与请求体里的 merge_gate_mode 在服务端一律被忽略(旧值
|
||
record/enforce/off 自 A1 起不再是服务端可选项,设了也等同
|
||
hard;此前 record 下“无证据放行”、record 下“未保护 + 确认
|
||
放行并记 merge_gate_risk”、off 下“只记风险不阻断”三条
|
||
服务端路径已随删模式一并消失,不存在受控降级口)。
|
||
hard:五项全查,
|
||
缺一 409 且不改状态,并写 merge_gate_blocked 审计。五项:
|
||
① CI 全绿(ci_status=success + ci_checks 全 success +
|
||
required_checks 非空且全 success);② 目录隔离(v0.3 口径,
|
||
files_changed 缺失即无证据阻断,越目录只能靠
|
||
approved_exceptions 放行);③ 合同版本(contract_version
|
||
非空 + contract_frozen=true,与 contract_expected_version
|
||
给出时须相等);④ 协调员批准(coordinator_approved=true);
|
||
⑤ 无未解决评审意见(unresolved_review_comments=0 或空列表)。
|
||
另:branch_protection 必须为 protected;unprotected 即使带
|
||
merge_gate_confirm 也阻断,unknown 同样阻断(unknown
|
||
不等于 protected)。无证据不放行(fail-closed)。
|
||
门禁阻断都写 merge_gate_blocked 审计后才回 409,
|
||
这是第 3 轨自动推回的阻塞依据:推回包取响应 merge_gate 的
|
||
blocks 码,不要解析审计文本;本接口不建推回任务、不改状态机。
|
||
审计推回接线(cloud/audit_wiring,第 1 项接线缺口):五项
|
||
检查之外再与链上推回阻塞做“与”:链上任一模块仍阻塞
|
||
(merge_blocked)时,即使五项全过也 409、状态不变,并在
|
||
merge_gate.blocks 里追加 audit_pushback_blocked 码、响应
|
||
里附 audit_pushback 对象(merge_blocked / release_blocked
|
||
/ blocked_by="audit_pushback" / round / failed_checks)。
|
||
推回阻塞与门禁无关:显式确认在 hard 下不覆盖任何一项,
|
||
绝不覆盖推回阻塞;链读不到(audit_ledger 表
|
||
不存在)视为无推回状态,其它读链失败一律 fail-closed
|
||
409,不放行。注意:本接口没有独立的发布端点,
|
||
release_blocked 随响应返回、由未来的发布端点用同一
|
||
reader 强制;deploy.py 是运维脚本,不在本接口门禁内。
|
||
本服务没有可信渠道直连 GitHub 核实 CI/保护:门禁强制的是
|
||
“调用方提交的证据齐全且全绿”,谎报证据由审计与 PR/Checks
|
||
只读核对覆盖,不在本接口的验证范围内。集成 v2 补充
|
||
(cloud/gate_verification):只读核对器本身已实现
|
||
(注入式 reader、逐项比对、mismatch 列出),但当前
|
||
合并/发布路径因请求体与任务行均无 repo/ref、且无可
|
||
信求证通道保证而接不上实时求证——见该模块头的不可
|
||
实现证明;当前真实控制仍是“证据齐全全绿 + 审计记
|
||
outcome/blocks”,审计 detail 不存证据体全文,谎报
|
||
发现靠事后比对,不是当场发现。
|
||
"""
|
||
row = self._fetch_task(task_id)
|
||
if row is None:
|
||
self._send_json(404, {"error": "task not found"})
|
||
return
|
||
task = _task_to_dict(row)
|
||
with _auth_store(self.server.db_path) as store:
|
||
if not store.check_permission(
|
||
user_id, task["project_id"], "approve_merge"):
|
||
self._send_json(403, {"error": "forbidden"})
|
||
return
|
||
_merge_ok, _merge_reason = _merge_state_decision(
|
||
task["status"])
|
||
if not _merge_ok:
|
||
self._send_json(409, {
|
||
"error": _merge_reason,
|
||
"status": task["status"],
|
||
})
|
||
return
|
||
body, jerr = self._read_json()
|
||
if jerr:
|
||
self._send_json(400, {"error": jerr})
|
||
return
|
||
if body.get("files_changed") is not None and not isinstance(
|
||
body.get("files_changed"), list):
|
||
self._send_json(400, {"error": "files_changed must be a list"})
|
||
return
|
||
protection = body.get("branch_protection") \
|
||
or _merge_gate_protection()
|
||
hard_err = _validate_hard_gate_body(body)
|
||
if hard_err:
|
||
self._send_json(400, {"error": hard_err})
|
||
return
|
||
gate = _merge_gate_hard(
|
||
files_changed=body.get("files_changed"),
|
||
owned_dir=body.get("owned_dir"),
|
||
approved_exceptions=body.get("approved_exceptions") or (),
|
||
protection=protection,
|
||
ci_status=body.get("ci_status"),
|
||
ci_checks=body.get("ci_checks"),
|
||
required_checks=body.get("required_checks"),
|
||
contract_version=body.get("contract_version"),
|
||
contract_frozen=body.get("contract_frozen", False),
|
||
contract_expected_version=body.get(
|
||
"contract_expected_version"),
|
||
coordinator_approved=body.get("coordinator_approved"),
|
||
unresolved_review_comments=body.get(
|
||
"unresolved_review_comments"))
|
||
# 审计推回接线:链上阻塞与门禁五项为“与”关系,且与模式
|
||
# 无关。读链失败(表不存在除外,见 audit_wiring)时
|
||
# fail-closed:不证明无阻塞就不放行。
|
||
pushback = None
|
||
try:
|
||
conn = self._db()
|
||
try:
|
||
pushback = _pushback_block(conn, task_id)
|
||
finally:
|
||
conn.close()
|
||
except Exception:
|
||
pushback = None
|
||
if pushback is None:
|
||
try:
|
||
with _auth_store(self.server.db_path) as store:
|
||
store.audit(
|
||
user_id, task["project_id"],
|
||
"merge_gate_blocked",
|
||
"%s mode=%s blocks=audit_pushback_state_unavailable"
|
||
% (task_id, gate.get("mode", "hard")))
|
||
except Exception:
|
||
pass
|
||
self._send_json(409, {
|
||
"error": "audit pushback state unavailable",
|
||
"merge_gate": gate,
|
||
"audit_pushback": {
|
||
"merge_blocked": True,
|
||
"release_blocked": True,
|
||
"blocked_by": "audit_pushback_state_unavailable",
|
||
"round": None,
|
||
"failed_checks": [],
|
||
},
|
||
})
|
||
return
|
||
gate["audit_pushback"] = pushback
|
||
if pushback["merge_blocked"]:
|
||
gate["allowed"] = False
|
||
blocks = gate.setdefault("blocks", [])
|
||
if "audit_pushback_blocked" not in blocks:
|
||
blocks.append("audit_pushback_blocked")
|
||
if not gate["allowed"]:
|
||
with _auth_store(self.server.db_path) as store:
|
||
store.audit(user_id, task["project_id"],
|
||
"merge_gate_blocked",
|
||
_gate_blocked_detail(task_id, gate))
|
||
self._send_json(409, {
|
||
"error": "merge gate blocked",
|
||
"merge_gate": gate,
|
||
})
|
||
return
|
||
now = time.time()
|
||
conn = self._db()
|
||
try:
|
||
conn.execute(
|
||
"UPDATE tasks SET status = 'merged', updated_at = ?"
|
||
" WHERE id = ?",
|
||
(now, task_id),
|
||
)
|
||
conn.commit()
|
||
task = _task_to_dict(
|
||
conn.execute(
|
||
"SELECT * FROM tasks WHERE id = ?", (task_id,)
|
||
).fetchone()
|
||
)
|
||
finally:
|
||
conn.close()
|
||
with _auth_store(self.server.db_path) as store:
|
||
store.audit(user_id, task["project_id"], "merge_task", task_id)
|
||
task["merge_gate"] = gate
|
||
self._send_json(200, task)
|
||
|
||
# -- 项目级审计循环接线(#260 驱动的服务端执行层) --------------
|
||
# 决策一律来自 cloud/audit_loop_driver,本节只做执行:持久化
|
||
# 循环状态、经 #241/#247 写链、派生修复/重做子任务、拦合并与
|
||
# 发布。合并端点本身不改:它继续用 #247 reader + 五项硬门禁
|
||
# 做 AND,本节不给合并造第二套 reader。原任务状态在本节的
|
||
# 任何一步都不被回退:fail 只阻塞 + 派生子任务,completed
|
||
# 保持 completed,直到合并端点按门禁把它标 merged。
|
||
def _loop_task_for_body(self, task, body):
|
||
"""把请求体里的模块/合同声明并入任务 dict,供接线层按
|
||
(module_id, contract_version) 解析循环键。任务行本身没
|
||
有 module 列,所以模块归属必须由提交方在每次调用里显
|
||
式声明(或沿用任务 id 回落);接线层绝不悄悄猜模块。"""
|
||
merged = dict(task)
|
||
if isinstance(body.get("module_id"), str) \
|
||
and body.get("module_id").strip():
|
||
merged["module_id"] = body["module_id"].strip()
|
||
if isinstance(body.get("contract_version"), str) \
|
||
and body.get("contract_version").strip():
|
||
merged["contract_version"] = body["contract_version"].strip()
|
||
return merged
|
||
|
||
def _loop_quota_check(self, task, caller_id):
|
||
"""服务端配额 -> 驱动 quota_check:配额归属取原任务
|
||
创建人(成本归属口径与子任务一致),无创建人时取调用
|
||
人。_quota_status 抛错时闭包同样抛错,驱动 fail-closed
|
||
为 quota_blocked,绝不把查不到配额当成放行。"""
|
||
quota_user = task.get("created_by") or caller_id
|
||
|
||
def _check(estimate):
|
||
quota, used, _month = self._quota_status(quota_user)
|
||
return _loop_quota_check(quota, used)(estimate)
|
||
return _check
|
||
|
||
def _loop_root_task_id(self, task):
|
||
"""循环根任务 id:沿 forked_from 回溯到链头(最多 20
|
||
步防环)。链行必须归属根任务,否则 #247 的按任务读链
|
||
在修复子任务上写下的通过结论永远解不开父任务的合并
|
||
——这是本节最容易踩错的归属点,单列一处 helper。"""
|
||
current = task
|
||
for _step in range(20):
|
||
parent_id = current.get("forked_from")
|
||
if not parent_id:
|
||
return current["id"]
|
||
parent_row = self._fetch_task(parent_id)
|
||
if parent_row is None:
|
||
return current["id"]
|
||
current = _task_to_dict(parent_row)
|
||
return current["id"]
|
||
|
||
def _loop_ledger_store(self):
|
||
"""#241/#247 写路径的 store:LedgerStore 自带连接与
|
||
ensure_schema,每条 record_* 调用内提交。调用方用完
|
||
必须 close(各 handler 的 finally 里做)。"""
|
||
return _LedgerStore(self.server.db_path)
|
||
|
||
def _loop_create_subtask(self, descriptor, parent_task, caller_id):
|
||
"""把接线层返回的子任务描述符落成一条 queued 任务行:
|
||
同项目、prompt 写明模块/合同/失败项与完整重审标准、
|
||
created_by 取原任务创建人(成本归属)、forked_from 记
|
||
父任务。返回新任务 dict;INSERT 失败时抛错,由调用方
|
||
按 503 回执(循环已推进、描述符在响应里,调用方可按
|
||
描述符经 POST /tasks 手工补建,绝不悄悄重跑决策)。"""
|
||
package = descriptor.get("pushback_package") or {}
|
||
failures = package.get("failures") or []
|
||
criteria = package.get("reaudit_criteria") or []
|
||
lines = [
|
||
"Audit %s subtask for module %s@%s (parent task %s)."
|
||
% (descriptor.get("kind"), descriptor.get("module_id"),
|
||
descriptor.get("contract_version"),
|
||
descriptor.get("parent_task_id")),
|
||
"Scope: %s." % descriptor.get("scope"),
|
||
"Failures: %s" % json.dumps(failures, ensure_ascii=False)[:2000],
|
||
"Full re-audit criteria (all must pass, not only the "
|
||
"failed items): %s" % ", ".join(str(c) for c in criteria),
|
||
]
|
||
if descriptor.get("targeted_items"):
|
||
lines.append("Targeted items only: %s"
|
||
% json.dumps(descriptor["targeted_items"],
|
||
ensure_ascii=False)[:1000])
|
||
if package.get("retained_assets"):
|
||
lines.append("Retained assets (do not rebuild these): %s"
|
||
% json.dumps(package["retained_assets"],
|
||
ensure_ascii=False)[:2000])
|
||
prompt = "\n".join(lines)
|
||
subtask_id = uuid.uuid4().hex
|
||
now = time.time()
|
||
conn = self._db()
|
||
try:
|
||
conn.execute(
|
||
"INSERT INTO tasks(id, project_id, prompt, status,"
|
||
" created_by, created_at, updated_at, forked_from)"
|
||
" VALUES(?, ?, ?, 'queued', ?, ?, ?, ?)",
|
||
(subtask_id, parent_task["project_id"], prompt,
|
||
parent_task.get("created_by") or caller_id, now, now,
|
||
parent_task["id"]))
|
||
conn.commit()
|
||
row = conn.execute(
|
||
"SELECT * FROM tasks WHERE id = ?", (subtask_id,)
|
||
).fetchone()
|
||
return _task_to_dict(row)
|
||
finally:
|
||
conn.close()
|
||
|
||
def _loop_run(self, user_id, task_id, body, kind):
|
||
"""循环端点的公共骨架:404 / approve_merge 403 / 体校验 /
|
||
接线调用 / 子任务落行。ValueError/TypeError(驱动拒绝、
|
||
映射缺失、写链预校验)回 400;其它异常(账本写失败等)
|
||
回 503 且状态未推进(接线层先写链后存状态的顺序保证
|
||
了这一点);子任务落行失败同样 503,但循环已推进的事
|
||
实在响应里明说,不谎称“未推进”。"""
|
||
row = self._fetch_task(task_id)
|
||
if row is None:
|
||
self._send_json(404, {"error": "task not found"})
|
||
return
|
||
task = _task_to_dict(row)
|
||
with _auth_store(self.server.db_path) as store:
|
||
if not store.check_permission(
|
||
user_id, task["project_id"], "approve_merge"):
|
||
self._send_json(403, {"error": "forbidden"})
|
||
return
|
||
loop_task = self._loop_task_for_body(task, body)
|
||
store = self._loop_ledger_store()
|
||
loop_conn = self._db()
|
||
try:
|
||
if kind == "verdict":
|
||
verdict = body.get("verdict")
|
||
if not isinstance(verdict, dict) \
|
||
or not verdict.get("verdict"):
|
||
self._send_json(400, {
|
||
"error": "verdict dict with a verdict "
|
||
"field is required"})
|
||
return
|
||
result = _loop_verdict(
|
||
loop_conn, store, loop_task, verdict,
|
||
quota_check=self._loop_quota_check(task, user_id),
|
||
cost_threshold=_loop_cost_threshold(),
|
||
old_impl_ref=body.get("old_impl_ref"),
|
||
user_id=user_id,
|
||
ledger_task_id=self._loop_root_task_id(task))
|
||
elif kind == "fix":
|
||
fix_result = body.get("fix_result")
|
||
if not isinstance(fix_result, dict):
|
||
self._send_json(400, {
|
||
"error": "fix_result dict is required"})
|
||
return
|
||
result = _loop_fix(
|
||
loop_conn, store, loop_task, fix_result,
|
||
automated=body.get("automated"),
|
||
quota_check=self._loop_quota_check(task, user_id),
|
||
cost_threshold=_loop_cost_threshold(),
|
||
old_impl_ref=body.get("old_impl_ref"),
|
||
user_id=user_id,
|
||
ledger_task_id=self._loop_root_task_id(task))
|
||
elif kind == "correction":
|
||
correction = body.get("correction")
|
||
if not isinstance(correction, dict):
|
||
self._send_json(400, {
|
||
"error": "correction dict is required"})
|
||
return
|
||
if isinstance(body.get("contract_version"), str):
|
||
correction = dict(
|
||
correction,
|
||
contract_version=body["contract_version"])
|
||
result = _loop_correction(
|
||
loop_conn, store, loop_task, correction,
|
||
user_id=user_id,
|
||
ledger_task_id=self._loop_root_task_id(task))
|
||
else:
|
||
raise ValueError("unknown loop kind: %r" % (kind,))
|
||
except (ValueError, TypeError) as exc:
|
||
self._send_json(400, {"error": str(exc)})
|
||
return
|
||
except Exception as exc:
|
||
self._send_json(503, {
|
||
"error": "audit_loop_unavailable",
|
||
"detail": "ledger or loop state write failed; "
|
||
"loop state was not advanced: %s" % exc})
|
||
return
|
||
finally:
|
||
try:
|
||
store.close()
|
||
except Exception:
|
||
pass
|
||
try:
|
||
loop_conn.close()
|
||
except Exception:
|
||
pass
|
||
subtask_task = None
|
||
subtask_error = None
|
||
descriptor = result.get("subtask")
|
||
if descriptor is not None and descriptor.get("kind") != "reaudit":
|
||
try:
|
||
subtask_task = self._loop_create_subtask(
|
||
descriptor, task, user_id)
|
||
except Exception as exc:
|
||
subtask_error = str(exc)
|
||
payload = {
|
||
"task": task,
|
||
"loop_state": _loop_state_to_dict(result["next_state"]),
|
||
"action_kind": result["action_kind"],
|
||
"reason": result["reason"],
|
||
"merge_blocked": result["merge_blocked"],
|
||
"release_blocked": result["release_blocked"],
|
||
"requires_reaudit": result["requires_reaudit"],
|
||
"escalation_grants_user_acceptance":
|
||
result["escalation_grants_user_acceptance"],
|
||
"subtask": descriptor,
|
||
"subtask_task": subtask_task,
|
||
"notification": result["notification"],
|
||
}
|
||
if "automated_checks" in result:
|
||
payload["automated_checks"] = result["automated_checks"]
|
||
if "correction_action" in result:
|
||
payload["correction_action"] = result["correction_action"]
|
||
if subtask_error is not None:
|
||
payload["subtask_error"] = subtask_error
|
||
payload["subtask_note"] = (
|
||
"loop state advanced and ledger written, but the "
|
||
"subtask row was not created; create it from the "
|
||
"subtask descriptor via POST /tasks, do not "
|
||
"re-submit the verdict")
|
||
self._send_json(503, payload)
|
||
return
|
||
self._send_json(200, payload)
|
||
|
||
def _audit_loop_verdict(self, user_id, task_id):
|
||
"""POST /tasks/{id}/audit-verdict:提交一次独立审计或
|
||
验收结论。权限:项目 approve_merge(审计提交方须是
|
||
maintainer 以上,与合并权同级;普通 contributor 不能
|
||
凭空给循环喂结论)。体:{module_id, contract_version,
|
||
verdict: {verdict, source, failures, ...},
|
||
old_impl_ref?}。signer 分离(A3):提交方本人即 signer,
|
||
verdict 须另声明 executor / coordinator_approver /
|
||
qa_tester 且均与提交方不同,缺字段、自审或体里 signer
|
||
与调用方不符一律 400 且不落链不推进。原任务状态不动;fail 自动派生修复子
|
||
任务并经链阻塞合并/发布。"""
|
||
body, jerr = self._read_json()
|
||
if jerr:
|
||
self._send_json(400, {"error": jerr})
|
||
return
|
||
self._loop_run(user_id, task_id, body, "verdict")
|
||
|
||
def _audit_loop_fix(self, user_id, task_id):
|
||
"""POST /tasks/{id}/audit-fix:修复完成事件。体:
|
||
{module_id, contract_version, fix_result:
|
||
{reaudit_verdict: {...}}, automated?: {hard_gate: {...},
|
||
extra_checks: {...}}, old_impl_ref?}。注意“自动完整
|
||
重审”的准确含义:本端点把修复完成推进到重审(跑自动
|
||
化检查 + 要求完整重审结论),而不是自动判通过——
|
||
reaudit_verdict 仍须审计方提交,缺失或 diff_only 由
|
||
驱动以 400 拒绝;自动化检查全过也绝不等于审计通过。
|
||
本端点调用的任务应为修复子任务(其 module 与父任务
|
||
相同,体里显式声明),原任务与修复子任务的状态本端
|
||
点都不改(修复子任务的 completed 由 worker/调用方写
|
||
回,本端点只推进循环)。"""
|
||
body, jerr = self._read_json()
|
||
if jerr:
|
||
self._send_json(400, {"error": jerr})
|
||
return
|
||
self._loop_run(user_id, task_id, body, "fix")
|
||
|
||
def _audit_loop_correction(self, user_id, task_id):
|
||
"""POST /tasks/{id}/audit-correction:审计的审计纠错。
|
||
体:{module_id, contract_version, correction:
|
||
{original_verdict, corrected_verdict, reviewer,
|
||
original_entry_id, ...}}。纠正为 fail 立即重拦;纠正
|
||
为 pass 不解封、只安排重审(原链行永不改动,只追加
|
||
meta-review 行)。"""
|
||
body, jerr = self._read_json()
|
||
if jerr:
|
||
self._send_json(400, {"error": jerr})
|
||
return
|
||
self._loop_run(user_id, task_id, body, "correction")
|
||
|
||
def _audit_loop_confirm(self, user_id, task_id):
|
||
"""POST /tasks/{id}/audit-rebuild-confirm:验收触发推
|
||
倒的用户确认。体必须显式带 user_confirmed(true/false)
|
||
与 confirmed_by,且 confirmed_by 必须等于认证调用方
|
||
本人:协调员或系统没有任何路径可以代用户确认(本端
|
||
点之外没有任何代码调用确认流程;pending 状态下不建
|
||
重做任务、不耗推倒次数)。确认后才进 rebuilding,拒
|
||
绝则 escalate 且不耗预算;确认不等于 G6 验收。"""
|
||
row = self._fetch_task(task_id)
|
||
if row is None:
|
||
self._send_json(404, {"error": "task not found"})
|
||
return
|
||
task = _task_to_dict(row)
|
||
with _auth_store(self.server.db_path) as store:
|
||
if not store.check_permission(
|
||
user_id, task["project_id"], "approve_merge"):
|
||
self._send_json(403, {"error": "forbidden"})
|
||
return
|
||
body, jerr = self._read_json()
|
||
if jerr:
|
||
self._send_json(400, {"error": jerr})
|
||
return
|
||
if "user_confirmed" not in body \
|
||
or not isinstance(body.get("user_confirmed"), bool):
|
||
self._send_json(400, {
|
||
"error": "user_confirmed boolean is required; "
|
||
"confirmation must be explicit"})
|
||
return
|
||
if body.get("confirmed_by") != user_id:
|
||
self._send_json(403, {
|
||
"error": "confirmed_by must equal the authenticated "
|
||
"caller; no coordinator or system may "
|
||
"confirm on a user's behalf"})
|
||
return
|
||
loop_task = self._loop_task_for_body(task, body)
|
||
store = self._loop_ledger_store()
|
||
loop_conn = self._db()
|
||
try:
|
||
result = _loop_confirm(
|
||
loop_conn, store, loop_task,
|
||
user_confirmed=body["user_confirmed"],
|
||
confirmed_by=body["confirmed_by"],
|
||
quota_check=self._loop_quota_check(task, user_id),
|
||
cost_threshold=_loop_cost_threshold(),
|
||
cost_estimate=body.get("cost_estimate"),
|
||
old_impl_ref=body.get("old_impl_ref"),
|
||
user_id=user_id,
|
||
ledger_task_id=self._loop_root_task_id(task))
|
||
except (ValueError, TypeError) as exc:
|
||
self._send_json(400, {"error": str(exc)})
|
||
return
|
||
except Exception as exc:
|
||
self._send_json(503, {
|
||
"error": "audit_loop_unavailable",
|
||
"detail": "ledger or loop state write failed; "
|
||
"loop state was not advanced: %s" % exc})
|
||
return
|
||
finally:
|
||
try:
|
||
store.close()
|
||
except Exception:
|
||
pass
|
||
try:
|
||
loop_conn.close()
|
||
except Exception:
|
||
pass
|
||
subtask_task = None
|
||
subtask_error = None
|
||
descriptor = result.get("subtask")
|
||
if descriptor is not None:
|
||
try:
|
||
subtask_task = self._loop_create_subtask(
|
||
descriptor, task, user_id)
|
||
except Exception as exc:
|
||
subtask_error = str(exc)
|
||
payload = {
|
||
"task": task,
|
||
"loop_state": _loop_state_to_dict(result["next_state"]),
|
||
"action_kind": result["action_kind"],
|
||
"reason": result["reason"],
|
||
"merge_blocked": result["merge_blocked"],
|
||
"release_blocked": result["release_blocked"],
|
||
"subtask": descriptor,
|
||
"subtask_task": subtask_task,
|
||
"notification": result["notification"],
|
||
}
|
||
if subtask_error is not None:
|
||
payload["subtask_error"] = subtask_error
|
||
self._send_json(503, payload)
|
||
return
|
||
self._send_json(200, payload)
|
||
|
||
def _audit_loop_get(self, user_id, task_id, query):
|
||
"""GET /tasks/{id}/audit-loop?module_id=&contract_version=:
|
||
读循环持久化状态 + 同一 #247 reader 的链阻塞视图。
|
||
权限:项目 read。读链失败(表不存在除外)回 503、不
|
||
谎称无阻塞。"""
|
||
row = self._fetch_task(task_id)
|
||
if row is None:
|
||
self._send_json(404, {"error": "task not found"})
|
||
return
|
||
task = _task_to_dict(row)
|
||
with _auth_store(self.server.db_path) as store:
|
||
if not store.check_permission(
|
||
user_id, task["project_id"], "read"):
|
||
self._send_json(403, {"error": "forbidden"})
|
||
return
|
||
module_id = (query.get("module_id") or [None])[0]
|
||
contract_version = (query.get("contract_version") or [None])[0]
|
||
if not module_id or not contract_version:
|
||
self._send_json(400, {
|
||
"error": "module_id and contract_version query "
|
||
"parameters are required"})
|
||
return
|
||
conn = self._db()
|
||
try:
|
||
state = _loop_load_state(conn, module_id, contract_version)
|
||
finally:
|
||
conn.close()
|
||
if state is None:
|
||
self._send_json(404, {"error": "no audit loop state"})
|
||
return
|
||
try:
|
||
conn = self._db()
|
||
try:
|
||
pushback = _pushback_block(conn, task_id)
|
||
finally:
|
||
conn.close()
|
||
except Exception:
|
||
self._send_json(503, {
|
||
"error": "audit_pushback_state_unavailable"})
|
||
return
|
||
self._send_json(200, {
|
||
"task": task,
|
||
"loop_state": _loop_state_to_dict(state),
|
||
"audit_pushback": pushback,
|
||
})
|
||
|
||
def _release_task(self, user_id, task_id):
|
||
"""POST /tasks/{id}/release:发布门禁 + 发布授权(#259
|
||
契约正式建路由)。权限:只认 CODEX_GLOBAL_ADMINS 的
|
||
全局管理员写(与 verify/export 同身份来源,但 verify/
|
||
export 是只读、发布是写;项目 approve_merge 不能放行
|
||
发布)。三者 AND:① 发布时刻重读 #247 同一 reader 的
|
||
链;② 发布时刻调用方新提交的硬门禁证据重算五项(合
|
||
并时的 200 永远不作输入;硬门禁一律按 hard 算,模式环
|
||
境变量不能把发布降级);③ 任务状态须为 merged。
|
||
阻塞源区分见 release_gate:blocked_sources 列出全部,
|
||
code/blocked_by 取求值顺序第一项。409 阻塞不写任何审
|
||
计(检查写链会移动链头、使锚点失效);403 被拒沿用
|
||
_audit_admin 惯例记审计。200 不改任务状态(本站没有
|
||
released 状态):200 是“发布已授权”,真正的部署动作
|
||
在平台部署通道(deploy.py)里,它部署平台本身、无任
|
||
务 id、不走服务端 HTTP 鉴权,因此不在任务门禁内——
|
||
把它绑到任务链上会在最需要部署修复与回滚时反锁部署。
|
||
200 时写一条显式的 release_task 审计作为发布后动作记
|
||
录(检查本身是只读的,这条是检查完成后的显式动作,
|
||
不是检查的副产品;锚点须在这条之后重取)。
|
||
证据绑定(集成 v2,cloud/evidence_binding):请求体成
|
||
对带 evidence_ts / evidence_nonce 时,服务端校验新鲜
|
||
度窗口并单次消费 nonce,原样重交即 409
|
||
release_blocked_evidence_replayed,陈旧/未来时间戳即
|
||
409 release_blocked_evidence_stale;两者皆无时走旧路径
|
||
并在成功响应标注 evidence_binding=absent。绑定防原样
|
||
重放与陈旧证据,不防重盖戳(无签名);实时求证见
|
||
cloud/gate_verification(当前路径因无 repo/ref 且无
|
||
可信求证通道而接不上,见该模块头的不可实现证明)。"""
|
||
row = self._fetch_task(task_id)
|
||
if row is None:
|
||
self._send_json(404, {"error": "task not found"})
|
||
return
|
||
task = _task_to_dict(row)
|
||
if not self._is_global_admin(user_id):
|
||
self._audit_admin(user_id, "release_denied", task_id)
|
||
self._send_json(403, {"error": "forbidden"})
|
||
return
|
||
body, jerr = self._read_json()
|
||
if jerr:
|
||
self._send_json(400, {"error": jerr})
|
||
return
|
||
if body.get("files_changed") is not None and not isinstance(
|
||
body.get("files_changed"), list):
|
||
self._send_json(400, {"error": "files_changed must be a list"})
|
||
return
|
||
hard_err = _validate_hard_gate_body(body)
|
||
if hard_err:
|
||
self._send_json(400, {"error": hard_err})
|
||
return
|
||
# 证据新鲜度与单次使用绑定(集成 v2,cloud/evidence_binding):
|
||
# 体里成对带 evidence_ts / evidence_nonce 时才校验;两者
|
||
# 皆无走旧路径(#259 契约兼容,成功响应标注 absent);
|
||
# 只带其一是格式错误 400。校验通过的 nonce 在此次提交
|
||
# 即消费(无论后续门禁放行与否),原样重交同一 nonce
|
||
# 即 409 evidence_replayed;陈旧/未来时间戳即 409
|
||
# evidence_stale。409 按本接口规则不写审计;nonce
|
||
# 消费写 evidence_nonces 表(与审计链无关,不移动链头)。
|
||
# 绑定防原样重放与陈旧证据,不防重盖戳(无签名证据
|
||
# 无法识破旧内容盖新戳——见 evidence_binding 模块头)。
|
||
binding = _evidence_presence(body)
|
||
if binding == "partial":
|
||
self._send_json(400, {"error":
|
||
"evidence_ts and evidence_nonce"
|
||
" must be supplied together"})
|
||
return
|
||
if binding == "bound":
|
||
ok, reason = _validate_evidence(body)
|
||
if not ok:
|
||
if reason in ("evidence_too_old",
|
||
"evidence_from_future"):
|
||
self._send_json(409, {
|
||
"error": "release blocked",
|
||
"release_gate": {
|
||
"allowed": False, "http_status": 409,
|
||
"code": "release_blocked_evidence_stale",
|
||
"blocked_by": "evidence_binding",
|
||
"blocked_sources": ["evidence_binding"],
|
||
},
|
||
})
|
||
else:
|
||
self._send_json(400, {"error": reason})
|
||
return
|
||
_bconn = self._db()
|
||
try:
|
||
_store = _EvidenceNonceStore(_bconn)
|
||
if not _store.consume(task_id,
|
||
body["evidence_nonce"],
|
||
body["evidence_ts"]):
|
||
self._send_json(409, {
|
||
"error": "release blocked",
|
||
"release_gate": {
|
||
"allowed": False, "http_status": 409,
|
||
"code": "release_blocked_evidence_replayed",
|
||
"blocked_by": "evidence_binding",
|
||
"blocked_sources": ["evidence_binding"],
|
||
},
|
||
})
|
||
return
|
||
_bconn.commit()
|
||
finally:
|
||
_bconn.close()
|
||
# 发布时刻的新证据:体里没给任何硬门禁字段时,hard_gate
|
||
# 为 None -> 证据缺失 409,而不是用合并时的旧结论补。
|
||
has_evidence = any(
|
||
body.get(field) is not None
|
||
for field in ("files_changed", "ci_status", "ci_checks",
|
||
"required_checks", "contract_version",
|
||
"contract_frozen", "coordinator_approved",
|
||
"unresolved_review_comments",
|
||
"branch_protection"))
|
||
hard_gate = None
|
||
if has_evidence:
|
||
protection = body.get("branch_protection") \
|
||
or _merge_gate_protection()
|
||
hard_gate = _merge_gate_hard(
|
||
files_changed=body.get("files_changed"),
|
||
owned_dir=body.get("owned_dir"),
|
||
approved_exceptions=body.get("approved_exceptions") or (),
|
||
protection=protection,
|
||
ci_status=body.get("ci_status"),
|
||
ci_checks=body.get("ci_checks"),
|
||
required_checks=body.get("required_checks"),
|
||
contract_version=body.get("contract_version"),
|
||
contract_frozen=body.get("contract_frozen", False),
|
||
contract_expected_version=body.get(
|
||
"contract_expected_version"),
|
||
coordinator_approved=body.get("coordinator_approved"),
|
||
unresolved_review_comments=body.get(
|
||
"unresolved_review_comments"))
|
||
conn = self._db()
|
||
try:
|
||
decision = _release_gate_evaluate(
|
||
task_id, conn, task_status=task["status"],
|
||
hard_gate=hard_gate)
|
||
finally:
|
||
conn.close()
|
||
if not decision["allowed"]:
|
||
# 409 不写审计:见本方法 docstring。
|
||
self._send_json(409, {
|
||
"error": "release blocked",
|
||
"release_gate": decision,
|
||
})
|
||
return
|
||
with _auth_store(self.server.db_path) as store:
|
||
store.audit(user_id, task["project_id"], "release_task",
|
||
task_id)
|
||
task["release_gate"] = decision
|
||
# 绑定出示标注(绝不谎称):bound = 本次提交带时间戳
|
||
# /nonce 且通过单次校验;absent = 未出示绑定,走 #259
|
||
# 旧路径(证据仍经硬门禁重算,但新鲜度未绑定)。
|
||
task["evidence_binding"] = binding
|
||
self._send_json(200, task)
|
||
|
||
# -- 运行时审批队列 ------------------------------------------------
|
||
def _list_approvals(self, user_id, task_id):
|
||
"""列出某任务的审批项(含已决定的,回执一并带出)。
|
||
|
||
权限:项目 read。列之前先扫荡过期:已到期的 pending
|
||
在这里变成 expired(effective decision 为 denied),
|
||
列表里看到的就是最终态,不会把已过期项继续显示为
|
||
待审批。下游异常不拦:抛给 _dispatch_safe 回 500,
|
||
绝不回一条伪造的空列表或成功。"""
|
||
row = self._fetch_task(task_id)
|
||
if row is None:
|
||
self._send_json(404, {"error": "task not found"})
|
||
return
|
||
task = _task_to_dict(row)
|
||
with _auth_store(self.server.db_path) as store:
|
||
if not store.check_permission(user_id, task["project_id"],
|
||
"read"):
|
||
self._send_json(403, {"error": "forbidden"})
|
||
return
|
||
conn = self._db()
|
||
try:
|
||
_appr_ensure(conn)
|
||
_appr_expire(conn)
|
||
items = _appr_list(conn, task_id=task_id)
|
||
finally:
|
||
conn.close()
|
||
self._send_json(200, {"approvals": items})
|
||
|
||
def _create_approval(self, user_id, task_id):
|
||
"""给某任务建一条待审批项(系统入口:未来 host hook 与
|
||
手动补录都走这里,实时 elicitation 本片未接,见
|
||
approvals 模块 docstring 的诚实边界)。
|
||
|
||
权限:项目 create_task(建审批项是写动作,与后续消息
|
||
同级)。kind 缺省 elicitation;summary 必填;
|
||
timeout_seconds 只接受 60 到 86400 秒整数,缺省 3600,
|
||
超出范围回 400(校验收敛到 cloud/timeouts.py,文案
|
||
不变)。成功记审计 approval_created。"""
|
||
row = self._fetch_task(task_id)
|
||
if row is None:
|
||
self._send_json(404, {"error": "task not found"})
|
||
return
|
||
task = _task_to_dict(row)
|
||
with _auth_store(self.server.db_path) as store:
|
||
if not store.check_permission(user_id, task["project_id"],
|
||
"create_task"):
|
||
self._send_json(403, {"error": "forbidden"})
|
||
return
|
||
body, err = self._read_json()
|
||
if err:
|
||
self._send_json(400, {"error": err})
|
||
return
|
||
kind = body.get("kind", "elicitation")
|
||
summary = body.get("summary")
|
||
detail = body.get("detail")
|
||
if detail is not None and not isinstance(
|
||
detail, (dict, list, str)):
|
||
self._send_json(400, {"error": "detail must be an object"})
|
||
return
|
||
timeout = body.get("timeout_seconds", _approval_default())
|
||
try:
|
||
timeout = _validate_timeout(timeout)
|
||
except ValueError:
|
||
self._send_json(
|
||
400, {"error": "timeout_seconds must be 60..86400"})
|
||
return
|
||
conn = self._db()
|
||
try:
|
||
try:
|
||
item = _appr_create(
|
||
conn, task_id, task["project_id"], kind=kind,
|
||
summary=summary, detail=detail,
|
||
requested_by=body.get("requested_by"),
|
||
timeout_seconds=timeout)
|
||
except ValueError as exc:
|
||
self._send_json(400, {"error": str(exc)})
|
||
return
|
||
finally:
|
||
conn.close()
|
||
with _auth_store(self.server.db_path) as store:
|
||
store.audit(user_id, task["project_id"],
|
||
"approval_created",
|
||
"%s %s" % (item["id"], item["kind"]))
|
||
self._send_json(201, {"approval": item})
|
||
|
||
def _decide_approval(self, user_id, prefix, decision):
|
||
"""批准或拒绝一条审批项:prefix 形如
|
||
<task_id>/approvals/<approval_id>(路由从 /approve
|
||
或 /deny 后缀剥出来的剩余部分)。
|
||
|
||
权限:项目 create_task(决定是写动作)。审批项不属于
|
||
该任务时回 404,不跨任务决定。已终态(批准、拒绝或
|
||
过期)时回 409 并带回执,原决定绝不被覆盖。成功记
|
||
审计 approval_decided,回执里有 decided_by 与
|
||
decided_at。"""
|
||
parts = prefix.split("/approvals/")
|
||
if len(parts) != 2 or not parts[0] or not parts[1]:
|
||
self._send_json(404, {"error": "not found"})
|
||
return
|
||
task_id, approval_id = parts
|
||
row = self._fetch_task(task_id)
|
||
if row is None:
|
||
self._send_json(404, {"error": "task not found"})
|
||
return
|
||
task = _task_to_dict(row)
|
||
with _auth_store(self.server.db_path) as store:
|
||
if not store.check_permission(user_id, task["project_id"],
|
||
"create_task"):
|
||
self._send_json(403, {"error": "forbidden"})
|
||
return
|
||
conn = self._db()
|
||
try:
|
||
_appr_ensure(conn)
|
||
_appr_expire(conn)
|
||
existing = _appr_get(conn, approval_id)
|
||
if existing is None or existing["task_id"] != task_id:
|
||
self._send_json(404, {"error": "approval not found"})
|
||
return
|
||
status, item = _appr_decide(
|
||
conn, approval_id, decision, user_id)
|
||
finally:
|
||
conn.close()
|
||
if status == "already_decided":
|
||
self._send_json(409, {"error": "already decided",
|
||
"approval": item})
|
||
return
|
||
with _auth_store(self.server.db_path) as store:
|
||
store.audit(user_id, task["project_id"],
|
||
"approval_decided",
|
||
"%s %s by %s" % (approval_id, decision, user_id))
|
||
self._send_json(200, {"approval": item})
|
||
|
||
def _continue_task(self, user_id, task_id):
|
||
"""旧任务接回来继续会话:终端任务(completed/failed/
|
||
interrupted/merge-failed)带一条新消息 + 全部选项重排队,
|
||
worker 认领后在同一会话线程上再跑一轮(不是新任务、不
|
||
是分支)。新消息存在 resume_message 列,worker 跑完清掉;
|
||
原 prompt 不动,详情里仍显示原 prompt。
|
||
|
||
规则:任务必须存在(404)、调用方须有 create_task 权限
|
||
(与后续消息同级:这本质上是“给旧会话发消息”)、任务
|
||
必须是终端且非 merged(queued/running 请用后续消息,
|
||
merged 的 worktree 已删、请分支,各 409)。选项与新建
|
||
任务同款校验:model(空串 = 跟随配置)、effort、approval、
|
||
plan、goal_mode,以及派生默认 child_model / child_effort
|
||
(空串 = 清空);不传的选项沿用任务原值。冲突与新建
|
||
一致:plan + approval=full、plan + goal_mode 各 400。
|
||
goal_mode=1 时同时把任务转成新 goal 语义(goal=新消息、
|
||
verify_command 清空、轮次 0),worker 走 goal-resume
|
||
在旧线程上开新目标循环;goal_mode 不传时 goal 任务沿用
|
||
原语义续跑。例外:message 本身是 /goal 命令时强制
|
||
goal_mode=1,goal 与 resume_message 都取解析出的目标
|
||
而非整句(见 cloud/goal_command.py)。状态回 queued 时上一轮 result/error 清空
|
||
(与重试一致,否则排队中的任务还挂着旧结果)。
|
||
配额:全部校验通过后、写库之前按任务创建者查一次
|
||
(与重试同口径,费用记创建者名下);超限回 402,不
|
||
改状态——以前是先回 200 排队、随后 worker 认领门把
|
||
任务悄悄标 interrupted 且不推送。后续消息创建不查
|
||
配额(排队不花钱),本接口不是 followup,不要混淆。
|
||
并发下用 CAS(WHERE status IN 终端四态),没改到行
|
||
说明状态已变,返回 409 而不是假装成功。写审计
|
||
task_continued。
|
||
"""
|
||
row = self._fetch_task(task_id)
|
||
if row is None:
|
||
self._send_json(404, {"error": "task not found"})
|
||
return
|
||
task = _task_to_dict(row)
|
||
with _auth_store(self.server.db_path) as store:
|
||
if not store.check_permission(
|
||
user_id, task["project_id"], "create_task"):
|
||
self._send_json(403, {"error": "forbidden"})
|
||
return
|
||
if self._reject_if_over_quota_for_task(user_id, task):
|
||
return
|
||
if task["status"] in ("queued", "running"):
|
||
self._send_json(409, {
|
||
"error": "进行中任务请用后续消息继续",
|
||
"status": task["status"],
|
||
})
|
||
return
|
||
if task["status"] == "merged":
|
||
self._send_json(409, {
|
||
"error": "已合并任务不能继续(worktree 已删),请分支",
|
||
"status": task["status"],
|
||
})
|
||
return
|
||
if task["status"] not in TERMINAL_STATUSES:
|
||
self._send_json(409, {
|
||
"error": "only terminal tasks can be continued",
|
||
"status": task["status"],
|
||
})
|
||
return
|
||
body, err = self._read_json()
|
||
if err:
|
||
self._send_json(400, {"error": err})
|
||
return
|
||
message = body.get("message")
|
||
if not isinstance(message, str) or not message.strip():
|
||
self._send_json(400, {"error": "message 必填"})
|
||
return
|
||
message = message.strip()
|
||
if len(message) > 20000:
|
||
self._send_json(400, {"error": "message too long"})
|
||
return
|
||
goal_target = _parse_goal_command(message)
|
||
if goal_target is not None and not goal_target:
|
||
self._send_json(400, {"error": "goal 目标不能为空"})
|
||
return
|
||
resume_text = goal_target if goal_target else message
|
||
opts, cerr = _validate_continue_options(body, task, goal_target)
|
||
if cerr:
|
||
self._send_json(400, {"error": cerr})
|
||
return
|
||
model = opts["model"]
|
||
effort = opts["effort"]
|
||
child_model = opts["child_model"]
|
||
child_model_present = opts["child_model_present"]
|
||
child_effort = opts["child_effort"]
|
||
child_effort_present = opts["child_effort_present"]
|
||
approval = opts["approval"]
|
||
plan = opts["plan"]
|
||
goal_mode = opts["goal_mode"]
|
||
sets = ["status = 'queued'", "resume_message = ?",
|
||
"result_text = NULL", "error_text = NULL",
|
||
"actual_model = NULL", "updated_at = ?"]
|
||
vals = [resume_text, time.time()]
|
||
if model is not None or "model" in body:
|
||
sets.append("model = ?")
|
||
vals.append(model)
|
||
if effort is not None:
|
||
sets.append("effort = ?")
|
||
vals.append(effort)
|
||
if child_model_present:
|
||
sets.append("child_model = ?")
|
||
vals.append(child_model)
|
||
if child_effort_present:
|
||
sets.append("child_effort = ?")
|
||
vals.append(child_effort)
|
||
if approval is not None:
|
||
sets.append("approval = ?")
|
||
vals.append(approval)
|
||
if plan is not None:
|
||
sets.append("plan = ?")
|
||
vals.append(plan)
|
||
if goal_mode is not None:
|
||
sets.append("goal_mode = ?")
|
||
vals.append(goal_mode)
|
||
if goal_mode == 1:
|
||
sets.append("goal = ?")
|
||
vals.append(resume_text)
|
||
sets.append("verify_command = NULL")
|
||
sets.append("goal_max_rounds = 0")
|
||
vals.append(task_id)
|
||
# 续跑也要花模型钱:校验全过后、写库之前按创建者查
|
||
# 配额,超限回 402 且不改状态(见本函数 docstring)。
|
||
if self._reject_if_over_quota(
|
||
user_id, quota_user_id=task.get("created_by") or user_id):
|
||
return
|
||
conn = self._db()
|
||
try:
|
||
cur = conn.execute(
|
||
"UPDATE tasks SET %s WHERE id = ? AND status IN"
|
||
" ('completed', 'failed', 'interrupted', 'merge-failed')"
|
||
% ", ".join(sets),
|
||
tuple(vals),
|
||
)
|
||
if cur.rowcount != 1:
|
||
# 状态已变化:更新 0 行,同一事务里什么都不留——
|
||
# 尤其不能先把继续消息落盘再报 409,那会凭空多
|
||
# 一条从未被执行的“用户消息”。
|
||
conn.rollback()
|
||
self._send_json(409, {
|
||
"error": "任务状态已变化,请刷新后重试",
|
||
"status": task["status"],
|
||
})
|
||
return
|
||
# 继续消息与状态改回 queued 同一事务:要么一起生效,
|
||
# 要么一起不生效,绝不出现“任务已重排队、消息却丢了”
|
||
# 的半截状态。created_at 用本请求的 updated_at 时间戳
|
||
# (vals[1]),与任务行的时间源一致,便于按时间交错
|
||
# 拼聊天历史。
|
||
_ensure_continue_messages_table(conn)
|
||
conn.execute(
|
||
"INSERT INTO task_continue_messages(id, task_id,"
|
||
" project_id, body, created_by, created_at)"
|
||
" VALUES(?, ?, ?, ?, ?, ?)",
|
||
(uuid.uuid4().hex, task_id, task["project_id"],
|
||
message, user_id, vals[1]),
|
||
)
|
||
conn.commit()
|
||
new_row = conn.execute(
|
||
"SELECT * FROM tasks WHERE id = ?", (task_id,)
|
||
).fetchone()
|
||
finally:
|
||
conn.close()
|
||
with _auth_store(self.server.db_path) as store:
|
||
store.audit(user_id, task["project_id"], "task_continued",
|
||
task_id)
|
||
self._send_json(200, _task_to_dict(new_row))
|
||
|
||
def _goal_resume_task(self, user_id, task_id):
|
||
"""G10:云端 goal 断点续跑——只接受 interrupted 的 goal 任务。
|
||
|
||
与 /continue 的区别(绝不能混):/continue 是“给旧会话
|
||
发一条新消息”,goal 任务走 goal-resume --goal-text 在旧
|
||
线程上开一个新目标循环;本端点走 host.py goal-resume
|
||
(无 --goal-text)的断点语义:从 host 任务的
|
||
goal_rounds_done 的下一轮接着跑同一循环,保留原线程
|
||
与 worktree,不新建任务、不重置轮次。
|
||
|
||
拒绝口径:任务不存在 404;调用方须有 approve_merge
|
||
(同重试:断点续跑要再花模型钱);状态非 interrupted
|
||
一律 409(goal-met/goal-unmet 已终局、failed 按旧规则
|
||
新建任务、running/queued 本来就在跑/等);非 goal 任务
|
||
409。配额按任务创建者查(与重试/续跑同款),超限 402
|
||
且不改状态。成功后状态回 queued 并置
|
||
goal_resume_pending=1(worker 认领后清掉并按断点跑),
|
||
上一轮 result/error/actual_model 清空,写审计
|
||
task_goal_resumed。CAS 更新,没改到行说明状态已变,
|
||
回 409 而不是假装成功。
|
||
"""
|
||
row = self._fetch_task(task_id)
|
||
if row is None:
|
||
self._send_json(404, {"error": "task not found"})
|
||
return
|
||
task = _task_to_dict(row)
|
||
with _auth_store(self.server.db_path) as store:
|
||
if not store.check_permission(
|
||
user_id, task["project_id"], "approve_merge"):
|
||
self._send_json(403, {"error": "forbidden"})
|
||
return
|
||
if self._reject_if_over_quota(
|
||
user_id, quota_user_id=task.get("created_by") or user_id):
|
||
return
|
||
if task["status"] != "interrupted":
|
||
self._send_json(409, {
|
||
"error": "only interrupted goal tasks can goal-resume",
|
||
"status": task["status"],
|
||
})
|
||
return
|
||
if not (task.get("goal_mode") or task.get("verify_command")):
|
||
self._send_json(409, {
|
||
"error": "not a goal task",
|
||
"status": task["status"],
|
||
})
|
||
return
|
||
now = time.time()
|
||
conn = self._db()
|
||
try:
|
||
migrate_tasks_table(conn)
|
||
cur = conn.execute(
|
||
"UPDATE tasks SET status = 'queued',"
|
||
" goal_resume_pending = 1, result_text = NULL,"
|
||
" error_text = NULL, actual_model = NULL,"
|
||
" paused_by_user = 0, interrupted_reason = NULL,"
|
||
" updated_at = ?"
|
||
" WHERE id = ? AND status = 'interrupted'",
|
||
(now, task_id),
|
||
)
|
||
conn.commit()
|
||
if cur.rowcount != 1:
|
||
self._send_json(409, {
|
||
"error": "task status changed, goal-resume aborted",
|
||
"status": task["status"],
|
||
})
|
||
return
|
||
task = _task_to_dict(
|
||
conn.execute(
|
||
"SELECT * FROM tasks WHERE id = ?", (task_id,)
|
||
).fetchone()
|
||
)
|
||
finally:
|
||
conn.close()
|
||
with _auth_store(self.server.db_path) as store:
|
||
store.audit(user_id, task["project_id"],
|
||
"task_goal_resumed", task_id)
|
||
self._send_json(200, task)
|
||
|
||
def _retry_task(self, user_id, task_id):
|
||
"""把终态失败任务重新排队:只改状态,不直接执行。
|
||
|
||
规则:任务必须存在(404)、调用方必须是项目 maintainer
|
||
以上(approve_merge,403——重跑要再花模型钱,不能让
|
||
只有读权限或普通贡献者随便点)、任务必须是 failed /
|
||
interrupted / merge-failed(其它状态一律 409:running
|
||
本来就在跑,queued 本来就在等,completed/merged 已有
|
||
结果,重跑应是新任务而不是覆盖结果)。
|
||
配额按双人门查(P0 安全批,见
|
||
_reject_if_over_quota_for_task):触发人与原任务创建
|
||
人任一超限即拦。费用仍记在创建者名下,所以只查创建
|
||
者不够——触发人用自己的额度过门、费用记他人名下,
|
||
仍是绕限;只查触发人也不够——原创建者已超限时,
|
||
替他人重试仍会把费用记到已超限的账上。
|
||
成功后状态回 queued,上一轮的 result_text/error_text/
|
||
actual_model 清空(否则新一轮的结果会和旧结果混在一起
|
||
无法分辨);prompt、模型、优先级、依赖原样保留,依赖
|
||
已完成时重跑任务会立刻就绪。写审计 task_retried。
|
||
更新用 CAS(WHERE status IN (...)),避免与正在完成的
|
||
回合互相覆盖:若 CAS 没改到行,说明状态已变,返回
|
||
409 而不是假装成功。
|
||
"""
|
||
row = self._fetch_task(task_id)
|
||
if row is None:
|
||
self._send_json(404, {"error": "task not found"})
|
||
return
|
||
task = _task_to_dict(row)
|
||
with _auth_store(self.server.db_path) as store:
|
||
if not store.check_permission(
|
||
user_id, task["project_id"], "approve_merge"):
|
||
self._send_json(403, {"error": "forbidden"})
|
||
return
|
||
if self._reject_if_over_quota_for_task(user_id, task):
|
||
return
|
||
if task["status"] not in ("failed", "interrupted", "merge-failed"):
|
||
self._send_json(409, {
|
||
"error": "only failed/interrupted tasks can be retried",
|
||
"status": task["status"],
|
||
})
|
||
return
|
||
now = time.time()
|
||
conn = self._db()
|
||
try:
|
||
cur = conn.execute(
|
||
"UPDATE tasks SET status = 'queued', result_text = NULL,"
|
||
" error_text = NULL, actual_model = NULL,"
|
||
" paused_by_user = 0, interrupted_reason = NULL,"
|
||
" updated_at = ?"
|
||
" WHERE id = ? AND status IN"
|
||
" ('failed', 'interrupted', 'merge-failed')",
|
||
(now, task_id),
|
||
)
|
||
conn.commit()
|
||
if cur.rowcount != 1:
|
||
self._send_json(409, {
|
||
"error": "task status changed, retry aborted",
|
||
"status": task["status"],
|
||
})
|
||
return
|
||
task = _task_to_dict(
|
||
conn.execute(
|
||
"SELECT * FROM tasks WHERE id = ?", (task_id,)
|
||
).fetchone()
|
||
)
|
||
finally:
|
||
conn.close()
|
||
with _auth_store(self.server.db_path) as store:
|
||
store.audit(user_id, task["project_id"], "task_retried", task_id)
|
||
self._send_json(200, task)
|
||
|
||
# -- 租户配额与计费 ----------------------------------------------
|
||
# 计费口径:只算 cost_records 里 usd_micros 非空的实际成本
|
||
# 行(estimated 行 usd 为 NULL,SUM 自动跳过,不算已花
|
||
# 的钱);归属按任务创建者 created_by,不按项目——一个
|
||
# 用户在多个项目里花的钱都算进他自己的月度账单,项目维
|
||
# 度的拆分由 _project_cost 另行提供。窗口是 UTC 自然月,
|
||
# 与 cost_records.day(UTC 日期)同口径。
|
||
def _quota_month(self):
|
||
from datetime import datetime, timezone
|
||
return datetime.now(timezone.utc).strftime("%Y-%m")
|
||
|
||
def _quota_snapshot(self, user_id, month=None):
|
||
"""配额快照:(quota, used_usd, unpriced_actual, month)。
|
||
|
||
取数收敛到 cloud/quota.py:配额行(无行按默认值、
|
||
显式 NULL 行为不限额)与当月用量(USD 全量 +
|
||
无价 actual-only)各只有一处 SQL。CostStore 懒建表
|
||
步骤保留(全新库上 cost_records 不存在时先建表再查)。
|
||
本函数只读:无行时不在此写行(写行仍由
|
||
_apply_default_quota / _backfill_default_quota 负责)。
|
||
"""
|
||
if month is None:
|
||
month = self._quota_month()
|
||
conn = self._db()
|
||
try:
|
||
_ensure_quotas_table(conn)
|
||
conn.commit()
|
||
_cost_store = CostStore(self.server.db_path)
|
||
try:
|
||
_cost_store._conn.close()
|
||
except Exception:
|
||
pass
|
||
quota = _quota_for_owner(conn, user_id)
|
||
used, unpriced = _quota_monthly_usage(conn, user_id, month)
|
||
return quota, used, unpriced, month
|
||
finally:
|
||
conn.close()
|
||
|
||
def _quota_status(self, user_id):
|
||
"""返回 (quota_usd_micros|None, used_usd_micros, month)。
|
||
P0 安全批后:无配额行不再等于不限额——缺行时按默认
|
||
配额处理(只在返回值里合成,不在这里写行;写行由
|
||
_apply_default_quota / _backfill_default_quota 负责)。
|
||
只有管理员显式把行写成 NULL 时,quota 才为 None
|
||
(不限额)。取数见 _quota_snapshot。"""
|
||
quota, used, _unpriced, month = self._quota_snapshot(user_id)
|
||
return quota, used, month
|
||
|
||
def _quota_unpriced_tokens(self, user_id, month=None):
|
||
"""当月无价模型 token 数(actual-only,见下)。
|
||
|
||
收敛说明(改动点,见 cloud/quota.py 模块头):
|
||
旧实现计全部 usd IS NULL 的行(含 estimated 估算行),
|
||
与 cost.py 的 P1 决议、CostStore 报表、worker、
|
||
自动化三处 actual-only 口径不一致;现统一为
|
||
actual-only:估算行不是真实花费,绝不触发上限。
|
||
input + output 之和;cached 已含在 input 里,不重复计。
|
||
month 缺省为当月。与 _quota_status 同款 CostStore
|
||
懒建表做法。无配额行时调用方不应以此挡人(见
|
||
_check_unpriced_token_cap 与 quota.decide 的
|
||
不限额语义)。
|
||
"""
|
||
_quota, _used, unpriced, _month = self._quota_snapshot(
|
||
user_id, month=month)
|
||
return unpriced
|
||
|
||
def _quota_payload(self, user_id, quota, used, month,
|
||
unpriced_actual_tokens=None):
|
||
payload = {"user_id": user_id, "month": month,
|
||
"quota_usd_micros": quota, "used_usd_micros": used,
|
||
"remaining_usd_micros": (quota - used)
|
||
if quota is not None else None}
|
||
if unpriced_actual_tokens is not None:
|
||
payload["unpriced_actual_tokens"] = unpriced_actual_tokens
|
||
return payload
|
||
|
||
def _check_unpriced_token_cap(self, user_id):
|
||
"""无价 token 上限准入:超限返回 (cap, used, month),否则 None。
|
||
|
||
判定收敛到 cloud/quota.py:无配额行用户按默认配额受限;
|
||
显式 NULL(不限额)行用户不受限——与 worker 运行时门
|
||
一致(旧实现不论配额行一律拦,是三处不一致之一,
|
||
本轨随收敛统一)。USD 配额本身不计量无价模型,所
|
||
以这道是补 USD 口径的洞,不是第二套计费。"""
|
||
# 取数经 _quota_status / _quota_unpriced_tokens(两者
|
||
# 内部共用 _quota_snapshot),比较经 quota.decide。
|
||
# _quota_status 在此保留为可 mock 的接缝(503 测试依赖
|
||
# 它抛异常时准入点 fail-closed),故 _check_* 不直连
|
||
# _quota_snapshot。
|
||
quota, _used, month = self._quota_status(user_id)
|
||
unpriced = self._quota_unpriced_tokens(user_id, month)
|
||
decision = _quota_decide(quota, _used, unpriced,
|
||
unpriced_token_cap(), month)
|
||
if decision is not None and decision["kind"] == "unpriced":
|
||
return (decision["unpriced_token_cap"],
|
||
decision["unpriced_tokens_used"], month)
|
||
return None
|
||
|
||
def _check_quota(self, user_id):
|
||
"""配额准入:放行返回 None;超限返回 (quota, used,
|
||
month),调用方一律回 402——这是钱用完了,不是请求
|
||
太快,所以不用 429。
|
||
|
||
准入点:建任务 / 分支 / 重试 / 续跑 / goal-resume,均为权限校验
|
||
通过后、写库之前。P0 安全批后,后续消息与任务间
|
||
消息创建时同样查双人配额门(见
|
||
_reject_if_over_quota_for_task):排队本身不花钱,
|
||
但续跑/复活必然花钱,只靠执行时轮次边界门拦不住
|
||
“超限用户靠他人任务续命”,所以创建时先拦一道,
|
||
执行时轮次边界门仍照常生效。
|
||
|
||
fail-open / fail-closed 分层(G7):服务端准入点查
|
||
配额异常时 fail-closed——_reject_if_over_quota 回
|
||
明确的 503 quota_check_unavailable 且不改状态,绝不
|
||
因查询抖动把“不知道超没超”当成放行。system 操作人
|
||
不豁免,按普通属主走默认额度(G14 的 system 豁免与
|
||
P1 冲突,本次未合入)。
|
||
执行侧查配额异常时分层:认领门(closed)回
|
||
quota_check_unavailable、调用方标 interrupted;轮询与
|
||
轮次边界(open)回 quota_check_failed——新回合边界见
|
||
此串即拒绝,轮询经 _quota_hit 闭包过滤此串、只记
|
||
warning 不杀在跑任务,只有确认超限才掐断。
|
||
|
||
跑中掐断(与旧 docstring 不同,旧说法已过时):
|
||
Local popen 与 Docker 路径的 worker 轮询每 30 秒查
|
||
一次配额,超限杀进程组/容器;同步路径没有轮询钩子,
|
||
只有认领与轮次边界检查,单轮内的超支要到轮次结束
|
||
才停;单轮本身的钱无法按 token 流式截断(用量只在
|
||
回合结束才回传),掐断粒度是轮次边界 + 长轮询中的
|
||
30 秒检查,不是 token 级。"""
|
||
quota, used, month = self._quota_status(user_id)
|
||
unpriced = self._quota_unpriced_tokens(user_id, month)
|
||
decision = _quota_decide(quota, used, unpriced,
|
||
unpriced_token_cap(), month)
|
||
if decision is not None and decision["kind"] == "usd":
|
||
return quota, used, month
|
||
return None
|
||
|
||
def _reject_if_over_quota(self, user_id, quota_user_id=None):
|
||
"""配额超限时回 402 并返回 True,否则 False。
|
||
|
||
quota_user_id:实际查谁的配额。建任务 / 分支时调用
|
||
方就是创建者,缺省即调用方;重试 / 续跑时费用记在
|
||
原任务创建者名下,必须传 task 的 created_by,否则
|
||
维护者替他人重试会用错人的额度(旧 bug:重试按
|
||
操作人查、费用记创建者名下)。
|
||
|
||
查询异常时 fail-closed:回 503 quota_check_unavailable
|
||
且不改状态。调用方看到 503 应重试请求本身,而不是
|
||
认为任务已建好——本函数回 True 时调用方一律直接
|
||
return,不写库。"""
|
||
try:
|
||
blocked = self._check_quota(quota_user_id or user_id)
|
||
except Exception:
|
||
self._send_json(503, {"error": "quota_check_unavailable",
|
||
"detail": "quota lookup failed; "
|
||
"no task was created or queued"})
|
||
return True
|
||
if blocked is not None:
|
||
quota, used, month = blocked
|
||
self._send_json(402, {"error": "quota_exceeded",
|
||
"month": month,
|
||
"quota_usd_micros": quota,
|
||
"used_usd_micros": used})
|
||
return True
|
||
capped = self._check_unpriced_token_cap(
|
||
quota_user_id or user_id)
|
||
if capped is not None:
|
||
cap, used, month = capped
|
||
self._send_json(402, {"error": "quota_exceeded",
|
||
"month": month,
|
||
"unpriced_token_cap": cap,
|
||
"unpriced_tokens_used": used})
|
||
return True
|
||
return False
|
||
|
||
def _reject_if_over_quota_for_task(self, caller_id, task):
|
||
"""花钱路径的双人配额门:触发人 + 原任务创建人。
|
||
|
||
任一超限即 402(USD 配额与无价 token cap 都查)。
|
||
触发人等于创建人时只查一次。调用方须在权限校验后、
|
||
写状态前调它;返回 True 表示已回 402,调用方直接
|
||
return。"""
|
||
if self._reject_if_over_quota(caller_id):
|
||
return True
|
||
creator = (task or {}).get("created_by")
|
||
if creator and creator != caller_id:
|
||
if self._reject_if_over_quota(creator):
|
||
return True
|
||
return False
|
||
|
||
def _check_coordinator_reserve(self, user_id, n_parts):
|
||
"""协调员预留准入:通过返回 True;被挡时已回 402 返回 False。
|
||
|
||
剩余美元配额(quota - used,quota 为 None = 不限额时放行)
|
||
必须 >= 分工数 × COORDINATOR_RESERVE_PER_PART_USD_MICROS。
|
||
这是准入下限估计,不是计费承诺:子任务实际花费仍按
|
||
created_by=父任务创建人全额记创建人账;父任务被挡时
|
||
一个子任务也不会建,不存在“第 2 个子任务才被挡”的
|
||
半截状态。"""
|
||
if not n_parts or n_parts <= 1:
|
||
return True
|
||
quota, used, month = self._quota_status(user_id)
|
||
if quota is None:
|
||
return True
|
||
need = n_parts * COORDINATOR_RESERVE_PER_PART_USD_MICROS
|
||
if quota - used < need:
|
||
self._send_json(402, {
|
||
"error": "coordinator_quota_reserve_exceeded",
|
||
"month": month,
|
||
"quota_usd_micros": quota,
|
||
"used_usd_micros": used,
|
||
"coordinator_parts": n_parts,
|
||
"reserved_usd_micros": need,
|
||
"quota_attribution": "coordinator subtasks are charged"
|
||
" to the parent task creator"})
|
||
return False
|
||
return True
|
||
|
||
def _audit_admin(self, user_id, action, detail):
|
||
"""admin 调用审计(成功与被拒都记,失败绝不挡主流程)。"""
|
||
try:
|
||
with _auth_store(self.server.db_path) as store:
|
||
store.audit(user_id, "", action, detail)
|
||
except Exception:
|
||
pass
|
||
|
||
def _is_global_admin(self, user_id):
|
||
"""真全局管理员:只认 CODEX_GLOBAL_ADMINS。
|
||
|
||
未配置时返回 False(fail-closed)。P0 安全批前“任一
|
||
项目 maintainer 即全局管理”的推导已被废掉:项目角色
|
||
只管项目内权限,不管全系统管理权。部署时运维必须先
|
||
把管理员用户 id 写进 CODEX_GLOBAL_ADMINS 再重启,
|
||
否则 admin 端点对所有人都是 403(数据不丢,改 env
|
||
重启即恢复)。"""
|
||
return user_id is not None and user_id in _global_admin_ids()
|
||
|
||
def _is_global_maintainer(self, user_id):
|
||
"""旧名兼容:等价于 _is_global_admin(推导口径已废)。"""
|
||
return self._is_global_admin(user_id)
|
||
|
||
def _user_exists(self, target):
|
||
conn = self._db()
|
||
try:
|
||
return conn.execute(
|
||
"SELECT 1 FROM users WHERE id = ?",
|
||
(target,)).fetchone() is not None
|
||
finally:
|
||
conn.close()
|
||
|
||
def _user_quota_self(self, user_id):
|
||
quota, used, month = self._quota_status(user_id)
|
||
# 合并取舍(P1 rebase 到 d254240):main 的 _quota_payload
|
||
# 已带 unpriced_actual_tokens,P1 的附加字段也全留,供控制台
|
||
# 同时显示美元与无价 token 两条进度;402 时控制台据
|
||
# 响应字段区分“美元配额已满”与“无价 token 已满”。
|
||
payload = self._quota_payload(
|
||
user_id, quota, used, month,
|
||
self._quota_unpriced_tokens(user_id))
|
||
payload["unpriced_tokens_used"] = self._quota_unpriced_tokens(
|
||
user_id, month)
|
||
payload["unpriced_token_cap"] = unpriced_token_cap()
|
||
self._send_json(200, payload)
|
||
|
||
def _cost_by_creator_payload(self, target, month):
|
||
"""创建者分账双列载荷(自查与管理端共用)。
|
||
|
||
month 为 None 时不过滤月份;否则须为 YYYY-MM,否则
|
||
ValueError(调用方转 400)。配额进度与成本双列同回:
|
||
quota 是当月美元配额(与 _quota_status 同口径),cost
|
||
是该创建者名下任务的成本(经 tasks.created_by JOIN)。
|
||
"""
|
||
quota, used, cur_month = self._quota_status(target)
|
||
# 端点默认当月(与配额窗口一致),不传 month 时成本与
|
||
# 配额同为当月;CostStore 层 month=None 仍表示全部时间。
|
||
eff_month = month if month is not None else cur_month
|
||
store = CostStore(self.server.db_path)
|
||
try:
|
||
cost = store.dual_column_report_by_creator(target,
|
||
month=eff_month)
|
||
finally:
|
||
try:
|
||
store._conn.close()
|
||
except Exception:
|
||
pass
|
||
return {
|
||
"user_id": target,
|
||
"month": eff_month,
|
||
"quota_usd_micros": quota,
|
||
"used_usd_micros": used,
|
||
"remaining_usd_micros": (quota - used)
|
||
if quota is not None else None,
|
||
"unpriced_tokens_used": self._quota_unpriced_tokens(
|
||
target, eff_month),
|
||
"unpriced_token_cap": unpriced_token_cap(),
|
||
"cost": cost,
|
||
}
|
||
|
||
def _user_cost_self(self, user_id, query):
|
||
month = (query.get("month") or [None])[0]
|
||
try:
|
||
payload = self._cost_by_creator_payload(user_id, month)
|
||
except ValueError:
|
||
self._send_json(400, {"error": "month must be YYYY-MM"})
|
||
return
|
||
self._send_json(200, payload)
|
||
|
||
def _admin_cost_by_creator(self, user_id, query):
|
||
if not self._is_global_admin(user_id):
|
||
self._audit_admin(user_id, "admin_quota_denied",
|
||
"get cost by creator")
|
||
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
|
||
month = (query.get("month") or [None])[0]
|
||
try:
|
||
payload = self._cost_by_creator_payload(target, month)
|
||
except ValueError:
|
||
self._send_json(400, {"error": "month must be YYYY-MM"})
|
||
return
|
||
self._send_json(200, payload)
|
||
|
||
def _admin_approval_timeout_ops(self, user_id):
|
||
"""审批超时调阈运维指标(全局管理员只读)。
|
||
|
||
先扫荡过期 pending(与审批列表同款:过期即 expired,
|
||
计入超时率),再统计 + 建议 + 记日志。返回里
|
||
recommended_seconds 是建议值,不是已生效值——默认
|
||
仍 600 秒,调阈生效须运维按建议改配置,见
|
||
cloud/approval_timeout 模块 docstring。
|
||
"""
|
||
if not self._is_global_admin(user_id):
|
||
self._audit_admin(user_id, "admin_quota_denied",
|
||
"get approval timeout ops")
|
||
self._send_json(403, {"error": "forbidden"})
|
||
return
|
||
conn = self._db()
|
||
try:
|
||
_appr_ensure(conn)
|
||
_appr_expire(conn)
|
||
result = _appr_timeout_eval(conn)
|
||
finally:
|
||
conn.close()
|
||
self._send_json(200, result)
|
||
|
||
def _admin_quota_get(self, user_id, query):
|
||
if not self._is_global_admin(user_id):
|
||
self._audit_admin(user_id, "admin_quota_denied",
|
||
"get quotas")
|
||
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
|
||
quota, used, month = self._quota_status(target)
|
||
self._send_json(200,
|
||
self._quota_payload(
|
||
target, quota, used, month,
|
||
self._quota_unpriced_tokens(target)))
|
||
|
||
def _admin_ledger_verify(self, user_id):
|
||
"""GET /admin/audit-ledger/verify:全局管理员只读校验账本。
|
||
|
||
只读:成功路径不写审计(写审计会改链头,校验读数本身
|
||
就不再可复现);被拒才记 admin_ledger_denied。校验失败
|
||
仍回 200,ok=false + failures 供运维判读,不是 HTTP 错。
|
||
"""
|
||
if not self._is_global_admin(user_id):
|
||
self._audit_admin(user_id, "admin_ledger_denied",
|
||
"verify ledger")
|
||
self._send_json(403, {"error": "forbidden"})
|
||
return
|
||
conn = self._db()
|
||
try:
|
||
result = _ledger_verify(conn)
|
||
finally:
|
||
conn.close()
|
||
self._send_json(200, result)
|
||
|
||
def _admin_ledger_export(self, user_id, query):
|
||
"""GET /admin/audit-ledger/export:全局管理员只读导出账本。
|
||
|
||
?limit=N 可截断(运维取头锚点用);limit 非法回 400。
|
||
导出含头哈希,运维应把头哈希存到库外作比对锚点。
|
||
"""
|
||
if not self._is_global_admin(user_id):
|
||
self._audit_admin(user_id, "admin_ledger_denied",
|
||
"export ledger")
|
||
self._send_json(403, {"error": "forbidden"})
|
||
return
|
||
limit_raw = (query.get("limit") or [None])[0]
|
||
limit = None
|
||
if limit_raw is not None:
|
||
try:
|
||
limit = int(limit_raw)
|
||
except (TypeError, ValueError):
|
||
limit = -1
|
||
if limit < 0:
|
||
self._send_json(
|
||
400, {"error": "limit must be a non-negative integer"})
|
||
return
|
||
conn = self._db()
|
||
try:
|
||
result = _ledger_export(conn, limit=limit)
|
||
finally:
|
||
conn.close()
|
||
self._send_json(200, result)
|
||
|
||
def _admin_quota_set(self, user_id):
|
||
if not self._is_global_admin(user_id):
|
||
self._audit_admin(user_id, "admin_quota_denied",
|
||
"set quotas")
|
||
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
|
||
quota = body.get("quota_usd_micros")
|
||
if quota is not None:
|
||
try:
|
||
quota = int(quota)
|
||
except (TypeError, ValueError):
|
||
self._send_json(400, {
|
||
"error": "quota_usd_micros must be an integer or null"})
|
||
return
|
||
if quota < 0:
|
||
self._send_json(400, {
|
||
"error": "quota_usd_micros must be >= 0"})
|
||
return
|
||
now = time.time()
|
||
conn = self._db()
|
||
try:
|
||
cur = conn.execute(
|
||
"UPDATE user_quotas SET quota_usd_micros = ?,"
|
||
" updated_at = ? WHERE user_id = ?",
|
||
(quota, now, target))
|
||
if cur.rowcount != 1:
|
||
conn.execute(
|
||
"INSERT INTO user_quotas(user_id, quota_usd_micros,"
|
||
" updated_at) VALUES(?, ?, ?)",
|
||
(target, quota, now))
|
||
conn.commit()
|
||
finally:
|
||
conn.close()
|
||
with _auth_store(self.server.db_path) as store:
|
||
store.audit(user_id, None, "quota_set",
|
||
"%s:%s" % (target, quota))
|
||
quota, used, month = self._quota_status(target)
|
||
self._send_json(200,
|
||
self._quota_payload(
|
||
target, quota, used, month,
|
||
self._quota_unpriced_tokens(target)))
|
||
|
||
# -- worker 池 -----------------------------------------------------
|
||
def _workers_list(self, user_id):
|
||
"""列出注册 worker:全局管理员看完整清单;其他已认证用户
|
||
只看在线/离线聚合(P1 收紧:worker_id、执行器、容量、
|
||
心跳时间等内部细节不对普通用户暴露,控制台的 Workers
|
||
区据此只显示聚合数字)。"""
|
||
workers = _registry_list(self.server.db_path)
|
||
if self._is_global_admin(user_id):
|
||
self._send_json(200, {"workers": workers})
|
||
return
|
||
online = sum(1 for w in workers if w.get("alive"))
|
||
self._send_json(200, {
|
||
"workers_online": online,
|
||
"workers_offline": len(workers) - online,
|
||
"workers_total": len(workers),
|
||
})
|
||
|
||
def _workers_heartbeat(self, user_id):
|
||
"""worker 心跳:body 含 worker_id(必填,缺了 400)与可选
|
||
executor/capacity/running/host/pid/address。心跳本身就是
|
||
注册:worker_id 不存在时自动注册,绝不 404。running 是
|
||
worker 上报的当前占用槽数(启动中占位也算);未给时保留
|
||
旧值。host/pid/address 只存调用方显式给的值:远程 worker
|
||
的 host/pid 绝不由 server 本机推导(server 的主机名/进程
|
||
号不是该 worker 的),未给时保留旧值;host 须为字符串、
|
||
pid 须为整数、address 须为字符串,否则 400(注册表里的
|
||
寻址信息错了比没有更危险,失联判断会拿错 host/pid 当证据)。"""
|
||
body, err = self._read_json()
|
||
if err:
|
||
self._send_json(400, {"error": err})
|
||
return
|
||
worker_id = body.get("worker_id")
|
||
if not worker_id or not isinstance(worker_id, str):
|
||
self._send_json(400, {"error": "worker_id required"})
|
||
return
|
||
executor = body.get("executor")
|
||
capacity = body.get("capacity")
|
||
if capacity is not None:
|
||
try:
|
||
capacity = int(capacity)
|
||
except (TypeError, ValueError):
|
||
self._send_json(400, {"error": "capacity must be an integer"})
|
||
return
|
||
running = body.get("running")
|
||
if running is not None:
|
||
try:
|
||
running = int(running)
|
||
except (TypeError, ValueError):
|
||
self._send_json(400, {"error": "running must be an integer"})
|
||
return
|
||
if running < 0:
|
||
self._send_json(400, {"error": "running must be >= 0"})
|
||
return
|
||
host = body.get("host")
|
||
if host is not None and not isinstance(host, str):
|
||
self._send_json(400, {"error": "host must be a string"})
|
||
return
|
||
pid = body.get("pid")
|
||
if pid is not None:
|
||
if isinstance(pid, bool) or not isinstance(pid, int):
|
||
self._send_json(400, {"error": "pid must be an integer"})
|
||
return
|
||
if pid <= 0:
|
||
self._send_json(400, {"error": "pid must be > 0"})
|
||
return
|
||
address = body.get("address")
|
||
if address is not None and not isinstance(address, str):
|
||
self._send_json(400, {"error": "address must be a string"})
|
||
return
|
||
try:
|
||
worker = _registry_heartbeat(
|
||
self.server.db_path, worker_id,
|
||
executor=executor, capacity=capacity, running=running,
|
||
host=host, pid=pid, address=address)
|
||
except ValueError as exc:
|
||
self._send_json(400, {"error": str(exc)})
|
||
return
|
||
worker["alive"] = True
|
||
self._send_json(200, worker)
|
||
|
||
# -- 自动 PR(默认关闭,显式触发)----------------------------------
|
||
def _notify_pr_event(self, project_id, event_type, payload):
|
||
"""PR 事件推送:best-effort,失败只记日志,绝不破坏 PR 响应。
|
||
|
||
review-requested = PR 建成功(PR 在等评审);
|
||
merge-failed = 已 merged 任务的落盘(push/建 PR)失败,
|
||
任务状态保持 merged、可重调 /pr——事件说的是落盘失败,
|
||
不是状态变 merge-failed(云端 merge 端点只改状态,
|
||
没有真合并可失败)。
|
||
"""
|
||
try:
|
||
result = _push_dispatch(
|
||
self.server.db_path, event_type, project_id, payload)
|
||
except Exception as exc: # noqa: BLE001 -- 通知失败绝不炸 PR
|
||
_server_log.warning("pr notify %s failed: %s", event_type, exc)
|
||
return
|
||
if result.get("errors"):
|
||
_server_log.warning("pr notify %s errors: %s",
|
||
event_type, result["errors"])
|
||
|
||
def _task_pr(self, user_id, task_id):
|
||
"""给已 merged 的任务建 PR(见 cloud/pr.py 的跳过规则)。
|
||
|
||
权限:项目 maintainer 以上(approve_merge,403);任务
|
||
不存在 404;非 merged 状态 409;跳过时返回 200 +
|
||
pr_skipped 与 reason,任务状态保持 merged 不变;push/pr
|
||
创建失败时返回 502 并写清哪一步失败,任务同样保持 merged。
|
||
成功时把 pr_url/pr_number 写回任务并记审计 pr_created。
|
||
推送:成功时发 review-requested,落盘失败时发 merge-failed
|
||
(均为 best-effort,不影响 PR 响应;跳过时不发)。
|
||
HA 阶段 1:任务的 worktree_host 与本机不同时,不直接跑,
|
||
而是排 pr 任务给该主机(返回 200 + pr_queued,同一任务
|
||
未完成的任务去重),由该主机上的 local worker 执行;
|
||
worktree 在本机(或未记录)时同步跑,与旧行为一致。
|
||
App-token:项目 repo_url 能解析出 github.com 仓库名且
|
||
库里有该仓库的 App 安装记录时,现换 installation token
|
||
走 App 路径(响应里 auth_via=app),否则走原来的 gh +
|
||
PAT 路径(auth_via=pat);换 token 任一步失败都回落
|
||
PAT,不因此报错。token 只在本次请求内存里出现。
|
||
"""
|
||
row = self._fetch_task(task_id)
|
||
if row is None:
|
||
self._send_json(404, {"error": "task not found"})
|
||
return
|
||
task = _task_to_dict(row)
|
||
with _auth_store(self.server.db_path) as store:
|
||
if not store.check_permission(
|
||
user_id, task["project_id"], "approve_merge"):
|
||
self._send_json(403, {"error": "forbidden"})
|
||
return
|
||
if task["status"] != "merged":
|
||
self._send_json(409, {
|
||
"error": "only merged tasks can create a PR",
|
||
"status": task["status"],
|
||
})
|
||
return
|
||
# G13:/pr 不再走 system 豁免。请求用户即费用归属人,
|
||
# 建任务前先过配额门(超限 402、查询异常 503,均不改
|
||
# 状态、不排队);这是有意收紧的口径,见 PR 描述——
|
||
# PR 本身不跑模型回合,纯 git/gh 操作几乎不花钱,合
|
||
# 并前若认为“已完成工作的 PR 不应被配额挡住”,可把
|
||
# 这一门改回豁免而不动归属列(created_by 照常记录)。
|
||
if self._reject_if_over_quota(user_id):
|
||
return
|
||
# HA 阶段 1:worktree 在别机时不直接跑,排 pr 任务给该主机;
|
||
# 本机(或 worktree_host 未记录的老任务)走同步路。
|
||
worktree_host = task.get("worktree_host")
|
||
if worktree_host and worktree_host != socket.gethostname():
|
||
conn = self._db()
|
||
try:
|
||
existing = find_open_pr_job(conn, task_id)
|
||
if existing is not None:
|
||
job_id = existing["id"]
|
||
else:
|
||
job_id = enqueue_pr_job(
|
||
conn, task_id, task["project_id"], worktree_host,
|
||
created_by=user_id)
|
||
finally:
|
||
conn.close()
|
||
self._send_json(200, {
|
||
"task_id": task_id, "status": task["status"],
|
||
"pr_queued": True, "pr_job_id": job_id,
|
||
"worktree_host": worktree_host,
|
||
})
|
||
return
|
||
try:
|
||
result = execute_task_pr(
|
||
self.server.db_path, task_id, user_id,
|
||
worktrees_root=getattr(
|
||
self.server, "host_worktrees_dir", None),
|
||
git_runner=getattr(self.server, "pr_git_runner", None),
|
||
gh_runner=getattr(self.server, "pr_gh_runner", None),
|
||
gh_which=getattr(self.server, "pr_gh_which", None),
|
||
app_token_client=getattr(
|
||
self.server, "pr_app_token_client", None),
|
||
github_api=getattr(self.server, "pr_github_api", None))
|
||
except PrSkipped as exc:
|
||
self._send_json(200, {
|
||
"task_id": task_id, "status": task["status"],
|
||
"pr_skipped": True, "reason": exc.reason,
|
||
})
|
||
return
|
||
except PrStepError as exc:
|
||
# merge-failed 事件已在 execute_task_pr 内发出,这里只映射响应。
|
||
self._send_json(502, {
|
||
"error": exc.message, "step": exc.step,
|
||
"task_id": task_id, "status": task["status"],
|
||
})
|
||
return
|
||
conn = self._db()
|
||
try:
|
||
task = _task_to_dict(
|
||
conn.execute(
|
||
"SELECT * FROM tasks WHERE id = ?",
|
||
(task_id,)).fetchone())
|
||
finally:
|
||
conn.close()
|
||
self._send_json(200, dict(task, pr_url=result["pr_url"],
|
||
pr_number=result["pr_number"],
|
||
branch=result["branch"],
|
||
auth_via=result["auth_via"]))
|
||
|
||
def _heartbeat_task(self, user_id, task_id):
|
||
row = self._fetch_task(task_id)
|
||
if row is None:
|
||
self._send_json(404, {"error": "task not found"})
|
||
return
|
||
task = _task_to_dict(row)
|
||
with _auth_store(self.server.db_path) as store:
|
||
if not store.check_permission(
|
||
user_id, task["project_id"], "create_task"):
|
||
self._send_json(403, {"error": "forbidden"})
|
||
return
|
||
now = time.time()
|
||
new_status = "running" if task["status"] == "queued" else task["status"]
|
||
conn = self._db()
|
||
try:
|
||
conn.execute(
|
||
"UPDATE tasks SET status = ?, last_heartbeat = ?,"
|
||
" updated_at = ? WHERE id = ?",
|
||
(new_status, now, now, task_id),
|
||
)
|
||
conn.commit()
|
||
task = _task_to_dict(
|
||
conn.execute(
|
||
"SELECT * FROM tasks WHERE id = ?", (task_id,)
|
||
).fetchone()
|
||
)
|
||
finally:
|
||
conn.close()
|
||
self._send_json(200, task)
|
||
|
||
# -- 管理 ----------------------------------------------------------
|
||
def _admin_sweep(self, user_id):
|
||
"""POST /admin/sweep:把超阈值的陈旧 running 任务标 interrupted。
|
||
|
||
权限:只认 CODEX_GLOBAL_ADMINS(见 _is_global_admin),
|
||
未配置时一律 403。threshold_seconds 下限钳到 60 秒:
|
||
0 会把所有 running 任务一口气扫掉,只能是管理员的
|
||
显式紧急操作才需要那么小的值,而 60 秒以下的“陈旧”
|
||
本就没有意义。扫完记 admin_sweep 审计,被拒也记审计。
|
||
"""
|
||
if not self._is_global_admin(user_id):
|
||
self._audit_admin(user_id, "admin_sweep_denied", "sweep")
|
||
self._send_json(403, {"error": "forbidden"})
|
||
return
|
||
body, err = self._read_json()
|
||
if err:
|
||
self._send_json(400, {"error": err})
|
||
return
|
||
threshold = body.get(
|
||
"threshold_seconds", DEFAULT_SWEEP_THRESHOLD_SECONDS)
|
||
try:
|
||
threshold = int(threshold)
|
||
except (TypeError, ValueError):
|
||
self._send_json(400, {"error": "threshold_seconds must be an integer"})
|
||
return
|
||
threshold = max(60, threshold)
|
||
swept = sweep_stale_tasks(self.server.db_path, threshold)
|
||
with _auth_store(self.server.db_path) as store:
|
||
store.audit(user_id, "", "admin_sweep",
|
||
"threshold=%d swept=%d" % (threshold, len(swept)))
|
||
self._send_json(200, {"swept": swept})
|
||
|
||
def _admin_backup(self, user_id):
|
||
"""POST /admin/backup:备份当前后端数据库,返回路径与规模信息。
|
||
|
||
权限:只认 CODEX_GLOBAL_ADMINS(见 _is_global_admin),
|
||
未配置时一律 403;非运维调用方根本到不了这里,绝对
|
||
路径只回给全局管理员。out_dir 限白名单目录(P1 收紧):
|
||
白名单根来自 CLOUD_BACKUP_ROOT(逗号分隔多根),未设
|
||
则 CLOUD_BACKUP_DIR,未设则固定根 cloud-backups;请求
|
||
的 out_dir 经实路径解析后必须等于某根或在其下,含 ..
|
||
逃逸、指向 /、/etc、/opt 等系统目录的一律 400(白名单
|
||
外的绝对路径即使存在也不放行)。备份文件落盘后 chmod
|
||
0600。成功与被拒都记审计(调用人由审计的 user_id 列
|
||
记,detail 含目标目录与大小,不记库内容)。
|
||
"""
|
||
if not self._is_global_admin(user_id):
|
||
self._audit_admin(user_id, "admin_backup_denied", "backup")
|
||
self._send_json(403, {"error": "forbidden"})
|
||
return
|
||
body, err = self._read_json()
|
||
if err:
|
||
self._send_json(400, {"error": err})
|
||
return
|
||
requested = body.get("out_dir")
|
||
if requested is not None and (
|
||
not isinstance(requested, str) or not requested):
|
||
self._send_json(400, {"error": "out_dir must be a string"})
|
||
return
|
||
roots_raw = os.environ.get("CLOUD_BACKUP_ROOT", "")
|
||
if roots_raw.strip():
|
||
roots = [os.path.realpath(p.strip())
|
||
for p in roots_raw.split(",") if p.strip()]
|
||
else:
|
||
roots = [os.path.realpath(
|
||
os.environ.get("CLOUD_BACKUP_DIR") or "cloud-backups")]
|
||
if requested:
|
||
candidate = os.path.realpath(requested)
|
||
else:
|
||
candidate = roots[0]
|
||
allowed = any(candidate == root
|
||
or candidate.startswith(root + os.sep)
|
||
for root in roots)
|
||
if not allowed:
|
||
self._audit_admin(
|
||
user_id, "admin_backup_denied",
|
||
"out_dir outside backup roots: %s" % requested)
|
||
self._send_json(400, {
|
||
"error": "out_dir outside allowed backup roots"})
|
||
return
|
||
out_dir = candidate
|
||
backend = _db_backend_name()
|
||
try:
|
||
if backend == "postgres":
|
||
from cloud.backup import backup_postgres as _backup_pg
|
||
dest = _backup_pg(out_dir, keep=3)
|
||
else:
|
||
from cloud.backup import backup_db as _backup_sqlite
|
||
dest = _backup_sqlite(self.server.db_path, out_dir, keep=3)
|
||
try:
|
||
os.chmod(dest, 0o600)
|
||
except OSError:
|
||
pass
|
||
size_bytes = os.path.getsize(dest)
|
||
conn = self._db()
|
||
try:
|
||
task_count = conn.execute(
|
||
"SELECT COUNT(*) FROM tasks").fetchone()[0]
|
||
finally:
|
||
conn.close()
|
||
except Exception as exc:
|
||
self._send_json(500, {"error": "backup failed: %s" % (exc,)})
|
||
return
|
||
self._audit_admin(
|
||
user_id, "admin_backup",
|
||
"caller=%s backend=%s out_dir=%s path=%s size=%d tasks=%d"
|
||
% (user_id, backend, out_dir, dest, size_bytes, task_count))
|
||
self._send_json(200, {
|
||
"backend": backend, "path": dest,
|
||
"size_bytes": size_bytes, "task_count": task_count,
|
||
})
|
||
|
||
def _metrics(self, user_id):
|
||
"""可观测性:任务计数、成功率、平均耗时、fallback、成本合计。
|
||
|
||
权限:任意项目成员即可读(不要求 owner),但只统计调用者
|
||
是成员的项目——不是全库数字,避免跨项目信息泄露。
|
||
成本真数与估算分开:cost_records 的 source 列区分。
|
||
"""
|
||
conn = self._db()
|
||
try:
|
||
member_ids = {
|
||
row["project_id"]
|
||
for row in conn.execute(
|
||
"SELECT project_id FROM project_members WHERE user_id = ?",
|
||
(user_id,),
|
||
).fetchall()
|
||
}
|
||
rows = conn.execute(
|
||
"SELECT project_id, status, started_at, finished_at"
|
||
" FROM tasks"
|
||
).fetchall()
|
||
# 新增可观测字段(只加键,旧键语义不动):队列深度、
|
||
# 近 24h 失败数、跑超 300 秒的 running 任务数。
|
||
now = time.time()
|
||
(by_status, durations, failures_last_24h,
|
||
running_over_300s) = _summarize_metrics_tasks(
|
||
rows, member_ids, now)
|
||
total = sum(by_status.values())
|
||
done = by_status.get("completed", 0) + by_status.get("merged", 0)
|
||
failed = by_status.get("failed", 0) + by_status.get("merge-failed", 0)
|
||
finished = done + failed
|
||
try:
|
||
cost_rows = conn.execute(
|
||
"SELECT project_id, model, source, total_tokens,"
|
||
" usd_micros, fallback_count FROM cost_records"
|
||
).fetchall()
|
||
except (sqlite3.OperationalError, _PgOperationalError):
|
||
cost_rows = []
|
||
actual_tokens = estimated_tokens = 0
|
||
actual_usd = estimated_usd = 0
|
||
fallback_total = 0
|
||
by_model = {}
|
||
unpriced_tokens = 0
|
||
for row in cost_rows:
|
||
if row["project_id"] not in member_ids:
|
||
continue
|
||
if row["source"] == "actual":
|
||
actual_tokens += row["total_tokens"] or 0
|
||
actual_usd += row["usd_micros"] or 0
|
||
else:
|
||
estimated_tokens += row["total_tokens"] or 0
|
||
estimated_usd += row["usd_micros"] or 0
|
||
fallback_total += row["fallback_count"] or 0
|
||
model = row["model"] or "unknown"
|
||
entry = by_model.setdefault(
|
||
model, {"model": model, "total_tokens": 0,
|
||
"usd_micros": 0, "priced": False})
|
||
entry["total_tokens"] += row["total_tokens"] or 0
|
||
if row["usd_micros"] is not None:
|
||
entry["usd_micros"] += row["usd_micros"]
|
||
entry["priced"] = True
|
||
else:
|
||
unpriced_tokens += row["total_tokens"] or 0
|
||
all_tokens = actual_tokens + estimated_tokens
|
||
# 常驻监听口径(P1 G5):只统计调用者是成员的项目,
|
||
# 与任务计数同款成员过滤;表缺失时 resident_metrics
|
||
# 回 available=False,不炸 /metrics。
|
||
try:
|
||
resident_stats = _res_metrics(conn, member_ids, now)
|
||
except Exception: # noqa: BLE001 - 观测端点不因 resident 失败
|
||
resident_stats = {"available": False}
|
||
# G7 运维指标:审批超时调阈(成员项目口径,只加键)。
|
||
# 统计失败时置 None(不是零),绝不把“算不出来”写成
|
||
# “超时率为零”;未扫荡的过期 pending 不计入,见
|
||
# approval_timeout 模块 docstring。这里不用 evaluate
|
||
#(它写日志,/metrics 高频轮询会刷屏),只算不记。
|
||
try:
|
||
_at_stats = _appr_timeout_stats(
|
||
conn, project_ids=sorted(member_ids))
|
||
approval_timeout = _appr_timeout_recommend(
|
||
_at_stats["expired_count"],
|
||
_at_stats["decided_count"])
|
||
approval_timeout.update(_at_stats)
|
||
except Exception:
|
||
approval_timeout = None
|
||
finally:
|
||
conn.close()
|
||
try:
|
||
workers = _registry_list(self.server.db_path)
|
||
except Exception: # noqa: BLE001 - 观测端点不因 worker 表失败
|
||
workers = []
|
||
workers_online = sum(1 for w in workers if w.get("alive"))
|
||
worker_ages = [now - w["last_seen"] for w in workers
|
||
if w.get("last_seen")]
|
||
self._send_json(200, {
|
||
"total_tasks": total,
|
||
"by_status": by_status,
|
||
"success_rate": (done / finished) if finished else None,
|
||
"avg_duration_seconds": (
|
||
sum(durations) / len(durations)) if durations else None,
|
||
"fallback_total": fallback_total,
|
||
"cost": {
|
||
"actual_tokens": actual_tokens,
|
||
"estimated_tokens": estimated_tokens,
|
||
"actual_usd_micros": actual_usd,
|
||
"estimated_usd_micros": estimated_usd,
|
||
"unpriced_tokens": unpriced_tokens,
|
||
"unpriced_share": (
|
||
unpriced_tokens / all_tokens) if all_tokens else None,
|
||
"by_model": sorted(by_model.values(),
|
||
key=lambda e: -e["total_tokens"]),
|
||
},
|
||
"resident": resident_stats,
|
||
"queue_depth": {
|
||
"queued": by_status.get("queued", 0),
|
||
"running": by_status.get("running", 0),
|
||
},
|
||
"approval_timeout": approval_timeout,
|
||
"failures_last_24h": failures_last_24h,
|
||
"running_over_300s": running_over_300s,
|
||
"workers": {
|
||
"online": workers_online,
|
||
"offline": len(workers) - workers_online,
|
||
"total": len(workers),
|
||
"max_last_seen_age_seconds": (
|
||
max(worker_ages) if worker_ages else None),
|
||
},
|
||
})
|
||
|
||
def _metrics_report(self, user_id):
|
||
"""指标闭环报告:首过绿灯率 / 缺陷率 / 返工率 / 每任务成本。
|
||
|
||
权限与 /metrics 同款:只统计调用者是成员的项目。
|
||
台账(audit gap 3,见 ``cloud/attempt_ledger.py``):任务的
|
||
attempts/defects 来自 ``task_attempts`` 台账的聚合,不再
|
||
恒为 None;台账里只有中断行的任务、以及台账里根本没有
|
||
行的老任务,仍传 None——未知率回 None(未知,不报 0),
|
||
老任务绝不被算成首过或无缺陷。台账表尚不存在(老库、
|
||
或生产 PG 未手动执行 ``PG_DDL``)时与无台账行同等处理,
|
||
不因此让端点报错;金任务集未经人工审定,报告恒为
|
||
pending_human_approval、decision_ready False。
|
||
成本走 #212 双列口径:有价合计与无价 actual token 分开,
|
||
总额在成本不完整时为 None,不把无价折成 0。
|
||
"""
|
||
conn = self._db()
|
||
try:
|
||
member_ids = {
|
||
row["project_id"]
|
||
for row in conn.execute(
|
||
"SELECT project_id FROM project_members WHERE user_id = ?",
|
||
(user_id,),
|
||
).fetchall()
|
||
}
|
||
rows = conn.execute(
|
||
"SELECT id, project_id, status FROM tasks").fetchall()
|
||
records = [{"id": row["id"], "status": row["status"],
|
||
"attempts": None, "defects": None}
|
||
for row in rows
|
||
if row["project_id"] in member_ids]
|
||
try:
|
||
aggregates = _aggregate_attempts(
|
||
_read_attempt_rows(conn))
|
||
except (sqlite3.OperationalError, _PgOperationalError):
|
||
# No ledger table yet (old DB, or production PG
|
||
# before the manual PG_DDL step): same as no rows.
|
||
aggregates = {}
|
||
records = _apply_attempts(records, aggregates)
|
||
try:
|
||
cost_rows = conn.execute(
|
||
"SELECT task_id, project_id, source, total_tokens,"
|
||
" usd_micros FROM cost_records").fetchall()
|
||
except (sqlite3.OperationalError, _PgOperationalError):
|
||
cost_rows = []
|
||
costs = {}
|
||
for row in cost_rows:
|
||
if row["project_id"] not in member_ids:
|
||
continue
|
||
entry = costs.setdefault(row["task_id"], {
|
||
"priced_usd_micros": 0, "unpriced_actual_tokens": 0,
|
||
"has_rows": True})
|
||
if row["source"] == "actual" \
|
||
and row["usd_micros"] is None:
|
||
entry["unpriced_actual_tokens"] += \
|
||
row["total_tokens"] or 0
|
||
elif row["usd_micros"] is not None:
|
||
entry["priced_usd_micros"] += row["usd_micros"]
|
||
finally:
|
||
conn.close()
|
||
metrics = _compute_metrics(records, costs)
|
||
self._send_json(200, _build_metrics_report(metrics))
|
||
def _audit_scorecard(self, user_id):
|
||
"""只读 7 维审计评分卡报告端点。
|
||
|
||
权限:与 /metrics 同款,任意项目成员即可读(不要求 owner),
|
||
但本端点不统计任何项目数据、不写库、不写账本、不改链头、
|
||
不触发推回或重审——它只返回评分卡模块的一张历史示例卡。
|
||
该示例卡的证据是 2026-10-31 的快照(见
|
||
``example_scorecard_as_of_2026_10_31``),此后未重新
|
||
实测,响应里以 ``evidence_as_of`` /
|
||
``not_current_system_score`` 明示;调用方绝不能把它读
|
||
成当前系统得分。调用方要拿到真实分数,必须显式把结构化
|
||
证据传给 cloud/audit_scorecard.py 的 build_scorecard(),
|
||
端点本身绝不从测试文件、摘要文档、PR 描述里拼凑分数。
|
||
阈值仍为测试默认值(``test_default_unapproved``),未拍板。
|
||
"""
|
||
try:
|
||
from cloud.audit_scorecard import (
|
||
example_scorecard_as_of_2026_10_31)
|
||
report = example_scorecard_as_of_2026_10_31()
|
||
except ImportError: # pragma: no cover - 导入失败时仍要诚实返回
|
||
report = {
|
||
"dimensions": [], "average_score": None,
|
||
"average_is_not_pass_criterion": True,
|
||
"overall": "not_pass", "overall_pass": False,
|
||
"missing_items": ["audit_scorecard_module_unavailable"],
|
||
"untested_items": [],
|
||
"thresholds_source": "test_default_unapproved",
|
||
"evidence_as_of": "2026-10-31",
|
||
"evidence_kind": "historical_example_snapshot",
|
||
"not_current_system_score": True,
|
||
}
|
||
self._send_json(200, report)
|
||
|
||
|
||
# -- API keys 管理 ---------------------------------------------------
|
||
def _user_keys_list(self):
|
||
"""列出自己的 key:primary(id "legacy")+ 附加 key。
|
||
|
||
只返回 id / label / 后 4 位提示 / 创建 / 最近使用 / 撤销
|
||
时间,绝不返回明文与哈希;只看得到自己的(认证用户)。
|
||
"""
|
||
user_id = self._authenticate()
|
||
if user_id is None:
|
||
self._send_json(401, {"error": "unauthorized"})
|
||
return
|
||
with _auth_store(self.server.db_path) as auth:
|
||
keys = auth.list_api_keys(user_id)
|
||
self._send_json(200, {"keys": keys})
|
||
|
||
def _user_keys_create(self):
|
||
"""给自己签发一个附加 key:明文只在本次响应里出现一次。
|
||
|
||
body 可含 label(字符串,缺省 "api-key");新 key 立刻
|
||
可用,与 primary 及已有附加 key 互不影响。
|
||
"""
|
||
user_id = self._authenticate()
|
||
if user_id is None:
|
||
self._send_json(401, {"error": "unauthorized"})
|
||
return
|
||
body, err = self._read_json()
|
||
if err:
|
||
body = {}
|
||
label = (body or {}).get("label")
|
||
if not isinstance(label, str) or not label:
|
||
label = "api-key"
|
||
with _auth_store(self.server.db_path) as auth:
|
||
key_id, api_key_plain = auth.create_api_key(user_id, label)
|
||
# 审计只记 key_id 与 label,绝不记明文与哈希。
|
||
auth.audit(user_id, "", "api_key_created",
|
||
"key_id=%s label=%s" % (key_id, label))
|
||
self._send_json(201, {"id": key_id, "label": label,
|
||
"hint": api_key_plain[-4:],
|
||
"api_key": api_key_plain})
|
||
|
||
def _user_keys_revoke(self, key_id):
|
||
"""撤销自己的一个 key:附加 key 记 revoked_at,primary
|
||
(id "legacy")清哈希;无此 key 或已撤销返回 404。
|
||
|
||
撤销自己的最后一个 key 后须重新登录拿新 key(登录总能
|
||
签发,不会把自己锁死在账号外)。
|
||
"""
|
||
user_id = self._authenticate()
|
||
if user_id is None:
|
||
self._send_json(401, {"error": "unauthorized"})
|
||
return
|
||
with _auth_store(self.server.db_path) as auth:
|
||
ok = auth.revoke_api_key(user_id, key_id)
|
||
if ok:
|
||
auth.audit(user_id, "", "api_key_revoked",
|
||
"key_id=%s" % key_id)
|
||
if not ok:
|
||
self._send_json(404, {"error": "key not found"})
|
||
return
|
||
self._send_json(200, {"revoked": True, "id": key_id})
|
||
|
||
def _user_keys_revoke_others(self):
|
||
"""撤销除本次请求 key 之外的全部自己的 key(附加 + primary),
|
||
返回撤销数。用于“这台设备之外的 key 全作废”;本次请求
|
||
的 key 本身不受影响。"""
|
||
user_id = self._authenticate()
|
||
if user_id is None:
|
||
self._send_json(401, {"error": "unauthorized"})
|
||
return
|
||
api_key = self.headers.get("X-API-Key") or ""
|
||
keep_hash = hashlib.sha256(api_key.encode()).hexdigest()
|
||
with _auth_store(self.server.db_path) as auth:
|
||
count = auth.revoke_other_api_keys(user_id, keep_hash)
|
||
# 只记撤销数,不记任何 key 明文、哈希或保留 key 的身份。
|
||
auth.audit(user_id, "", "api_key_revoke_others",
|
||
"revoked_count=%d" % count)
|
||
self._send_json(200, {"revoked_count": count})
|
||
|
||
def _user_gateway_rotate(self):
|
||
"""POST /users/me/gateway/rotate:仅限本人重新开通/轮换
|
||
网关 key。非 sub2api 网关时 400;网关不可用时 502 且
|
||
旧行不动(rotate 内部先删旧行再重开,只在重开失败时
|
||
旧 key 已丢——调用方须知:轮换是显式动作,失败后须
|
||
重试本端点重新开通)。审计只记 sub2api_user_id 与
|
||
key_id,绝不记 key 明文;响应不含 key。"""
|
||
user_id = self._authenticate()
|
||
if user_id is None:
|
||
self._send_json(401, {"error": "unauthorized"})
|
||
return
|
||
if _gateway_name() != "sub2api":
|
||
self._send_json(400, {"error": "gateway is not sub2api"})
|
||
return
|
||
conn = self._db()
|
||
try:
|
||
try:
|
||
status, _key, sub_id, key_id = _rotate_sub2api_key(
|
||
conn, user_id)
|
||
except _Sub2apiError as exc:
|
||
self._send_json(502, {"error": str(exc)})
|
||
return
|
||
except Exception as exc:
|
||
self._send_json(502, {"error": str(exc)})
|
||
return
|
||
finally:
|
||
conn.close()
|
||
with _auth_store(self.server.db_path) as store:
|
||
store.audit(user_id, "", "gateway_key_rotated",
|
||
"sub2api_user_id=%s key_id=%s"
|
||
% (sub_id, key_id))
|
||
self._send_json(200, {"rotated": True, "provisioning": status,
|
||
"sub2api_user_id": sub_id})
|
||
|
||
# -- 常驻监听任务(见 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:
|
||
# P2:502 不再透传异常文本(可能含表名/列名等
|
||
# DB 细节),统一固定错误码,详情只记服务端日志。
|
||
_server_log.warning("resident create failed: %s", exc)
|
||
self._send_json(502, {"error": "resident_operation_failed"})
|
||
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:
|
||
_server_log.warning("resident list failed: %s", exc)
|
||
self._send_json(502, {"error": "resident_operation_failed"})
|
||
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 as exc:
|
||
# P2:polls 异常不再静默吞成空串,至少记
|
||
# 服务端日志;响应仍保持 polls=[] + note
|
||
# 的兼容形态(排障信号在日志里,不在响应里丢)。
|
||
_server_log.warning(
|
||
"resident polls unavailable for %s: %s",
|
||
watch_id, exc)
|
||
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:
|
||
_server_log.warning("resident update failed: %s", exc)
|
||
self._send_json(502, {"error": "resident_operation_failed"})
|
||
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:
|
||
_server_log.warning(
|
||
"resident %s failed: %s", action, exc)
|
||
self._send_json(502, {"error": "resident_operation_failed"})
|
||
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
|
||
audit_action = {"pause": "resident_watch_paused",
|
||
"resume": "resident_watch_resumed",
|
||
"end": "resident_watch_ended"}[action]
|
||
with _auth_store(self.server.db_path) as store:
|
||
store.audit(user_id, project_id, audit_action, watch_id)
|
||
self._send_json(200, watch)
|
||
|
||
def _handle_github_webhook(self):
|
||
"""GitHub webhook:验签 -> delivery 去重 -> 解析 -> 执行建任务。
|
||
|
||
顺序是故意的:secret 未配 503、签名无效 401(两者都在
|
||
去重之前,避免未验签的重复送达污染去重表);同一
|
||
X-GitHub-Delivery 第二次送达直接回 200 duplicate,不
|
||
重复建任务、不重复记审计。验签接受两个 secret:App
|
||
webhook secret(库里,App 装好后)与 env 里
|
||
GITHUB_WEBHOOK_SECRET(手动 webhook,过渡期共存;App
|
||
装好、手动 webhook 删掉后自然不再被命中)。
|
||
/fix-review 解析成功后在控制面建一个 queued 任务(prompt
|
||
是固定模板 + 截断后的评论摘录,不是评论原文原样),
|
||
由 worker 认领执行——webhook 本身不跑模型。任务归属:
|
||
按项目名 == repo full_name 匹配项目,created_by 归属
|
||
项目 owner 并走 owner 的配额门,executor 强制 docker;
|
||
没有匹配项目时不凭空建项目、不建任务,返回 task_id
|
||
为 None 并说明原因(worker 的 repo 仍是配置里的 repo,
|
||
不是任意 GitHub 仓库的自动克隆,见 README)。作者
|
||
不可信时同样不建任务(见 is_trusted_fix_review_author)。check_run / check_suite 只做通知解析,
|
||
返回解析结果而不建任务、不改云端任务状态(CI 状态回填
|
||
由 pr-checks 主动拉取)。installation / installation_repositories
|
||
事件记安装记录(created/added 增行、removed 减行、deleted
|
||
清安装),不建任务。手动 webhook 与 App webhook 共存时同一评论
|
||
会送两遍(delivery_id 不同):/fix-review 另有内容去重
|
||
(repo + PR + 评论 id),只建一次任务。
|
||
"""
|
||
raw = self._read_body()
|
||
app_secret = None
|
||
conn = self._db()
|
||
try:
|
||
config = get_app_config(conn)
|
||
if config is not None:
|
||
app_secret = config.get("webhook_secret")
|
||
finally:
|
||
conn.close()
|
||
secrets = [s for s in
|
||
(app_secret, os.environ.get("GITHUB_WEBHOOK_SECRET"))
|
||
if s]
|
||
if not secrets:
|
||
self._send_json(503, {"error": "github webhook secret not configured"})
|
||
return
|
||
signature = self.headers.get("X-Hub-Signature-256")
|
||
if not verify_github_signature_any(secrets, raw, signature):
|
||
self._send_json(401, {"error": "unauthorized"})
|
||
return
|
||
try:
|
||
payload = json.loads(raw.decode("utf-8")) if raw else {}
|
||
except (ValueError, UnicodeDecodeError):
|
||
self._send_json(400, {"error": "invalid json"})
|
||
return
|
||
if not isinstance(payload, dict):
|
||
self._send_json(400, {"error": "body must be a JSON object"})
|
||
return
|
||
event = self.headers.get("X-GitHub-Event")
|
||
delivery_id = self.headers.get("X-GitHub-Delivery")
|
||
repository = payload.get("repository") or {}
|
||
repo_name = repository.get("full_name")
|
||
store = WebhookDeliveryStore(self.server.db_path)
|
||
try:
|
||
if not store.claim(delivery_id, event=event, repo=repo_name):
|
||
self._send_json(200, {
|
||
"duplicate": True, "delivery_id": delivery_id,
|
||
})
|
||
return
|
||
finally:
|
||
try:
|
||
store.close()
|
||
except Exception:
|
||
pass
|
||
# installation / installation_repositories 事件:记安装记录,
|
||
# 不建任务。后者是安装后增减仓库的同步事件(added 增行、
|
||
# removed 减行),与 installation 事件共用同一解析与动作
|
||
# 分发;new_permissions_accepted 无需改行。
|
||
if event in ("installation", "installation_repositories"):
|
||
inst = parse_installation_event(payload)
|
||
if inst is None:
|
||
self._send_json(200, {"result": None})
|
||
return
|
||
conn = self._db()
|
||
try:
|
||
action = inst["action"]
|
||
if action == "created":
|
||
record_installation_repos(
|
||
conn, inst["installation_id"],
|
||
inst["account_login"], inst["repos"])
|
||
elif action == "added":
|
||
record_installation_repos(
|
||
conn, inst["installation_id"],
|
||
inst["account_login"], inst["repos"])
|
||
elif action == "removed":
|
||
remove_installation_repos(
|
||
conn, inst["installation_id"], inst["repos"])
|
||
elif action == "deleted":
|
||
remove_installation(conn, inst["installation_id"])
|
||
# new_permissions_accepted:权限已接受,无需改行。
|
||
finally:
|
||
conn.close()
|
||
with _auth_store(self.server.db_path) as auth:
|
||
auth.audit("system", "", "github_app_installation",
|
||
"%s:%s" % (inst["installation_id"],
|
||
inst["action"]))
|
||
self._send_json(200, {
|
||
"result": "installation_recorded",
|
||
"installation_id": inst["installation_id"],
|
||
"action": inst["action"],
|
||
"repos": inst["repos"],
|
||
})
|
||
return
|
||
# check_run / check_suite:只解析,不建任务、不改状态。
|
||
check = parse_github_check_run(payload)
|
||
if check is not None:
|
||
self._send_json(200, check)
|
||
return
|
||
# issue_comment 与 pull_request_review_comment:后者把
|
||
# pull_request.number 归一化成 parser 要的 issue.number
|
||
# (见 _parse_github_comment_with_review_fallback)。
|
||
result = _parse_github_comment_with_review_fallback(payload)
|
||
if result is None:
|
||
self._send_json(200, {"result": None})
|
||
return
|
||
# /fix-review 执行:建 queued 任务(不跑模型,worker 认领)。
|
||
# P0 安全批后:先验作者、再查配额、最后才建任务。
|
||
# 作者校验用 GitHub 事件自带的 author_association 与
|
||
# PR/issue 作者比对(见 is_trusted_fix_review_author):
|
||
# 不可信一律不建任务、不占去重位,fail-closed。
|
||
conn = self._db()
|
||
try:
|
||
project_row = None
|
||
if repo_name:
|
||
project_row = conn.execute(
|
||
"SELECT id, owner_id FROM projects WHERE name = ?",
|
||
(repo_name,),
|
||
).fetchone()
|
||
if project_row is None:
|
||
self._send_json(200, dict(
|
||
result, task_id=None, project_id=None,
|
||
error="no matching project for repo"))
|
||
return
|
||
project_id = project_row[0]
|
||
project_owner = project_row[1]
|
||
if not is_trusted_fix_review_author(payload):
|
||
with _auth_store(self.server.db_path) as auth:
|
||
auth.audit("system", project_id,
|
||
"webhook_fix_review_rejected",
|
||
"untrusted author %s"
|
||
% (result.get("author"),))
|
||
self._send_json(200, dict(
|
||
result, task_id=None, project_id=project_id,
|
||
error="untrusted fix-review author"))
|
||
return
|
||
if not project_owner:
|
||
self._send_json(200, dict(
|
||
result, task_id=None, project_id=project_id,
|
||
error="project has no owner for quota attribution"))
|
||
return
|
||
if self._reject_if_over_quota(project_owner):
|
||
with _auth_store(self.server.db_path) as auth:
|
||
auth.audit("system", project_id,
|
||
"webhook_fix_review_quota_blocked",
|
||
project_owner)
|
||
return
|
||
# 内容去重:手动 webhook 与 App webhook 共存时同一评论
|
||
# 送两遍(delivery_id 不同),只建一次任务;重复送达
|
||
# 回读当初的 task_id,不重复记审计。
|
||
comment_id = (payload.get("comment") or {}).get("id")
|
||
is_new, existing_task_id = claim_fix_review(
|
||
conn, repo_name, result.get("pr_number"), comment_id)
|
||
if not is_new:
|
||
self._send_json(200, dict(
|
||
result, task_id=existing_task_id,
|
||
project_id=project_id, duplicate=True))
|
||
return
|
||
task_id = uuid.uuid4().hex
|
||
now = time.time()
|
||
# prompt 不再原样贴评论全文:固定模板 + 截断后的
|
||
# 评论摘录(上限 2000 字),防超长评论撑爆 prompt
|
||
# 与指令注入面无限放大(见 _webhook_fix_review_prompt)。
|
||
prompt = _webhook_fix_review_prompt(result)
|
||
try:
|
||
conn.execute(
|
||
"INSERT INTO tasks(id, project_id, prompt, status,"
|
||
" idempotency_key, depends_on, priority, created_by,"
|
||
" created_at, updated_at, last_heartbeat, executor)"
|
||
" VALUES(?, ?, ?, 'queued', ?, '[]', 0, ?,"
|
||
" ?, ?, NULL, 'docker')",
|
||
(task_id, project_id, prompt,
|
||
"webhook:%s" % delivery_id if delivery_id else None,
|
||
project_owner, now, now),
|
||
)
|
||
conn.commit()
|
||
except (sqlite3.IntegrityError, _PgIntegrityError):
|
||
# 并发/去重兜底:同 delivery 已建过任务时回读它,
|
||
# 不建重复任务、不重复记审计。
|
||
row = conn.execute(
|
||
"SELECT id FROM tasks WHERE project_id = ?"
|
||
" AND idempotency_key = ?",
|
||
(project_id,
|
||
"webhook:%s" % delivery_id if delivery_id else None),
|
||
).fetchone()
|
||
if row:
|
||
self._send_json(200, dict(
|
||
result, task_id=row[0], project_id=project_id,
|
||
duplicate=True))
|
||
return
|
||
raise
|
||
set_fix_review_task_id(
|
||
conn, repo_name, result.get("pr_number"), comment_id,
|
||
task_id)
|
||
finally:
|
||
conn.close()
|
||
with _auth_store(self.server.db_path) as auth:
|
||
auth.audit("system", project_id, "webhook_fix_review", task_id)
|
||
self._send_json(200, dict(
|
||
result, task_id=task_id, project_id=project_id))
|
||
|
||
def _handle_slack_webhook(self):
|
||
raw = self._read_body()
|
||
secret = os.environ.get("SLACK_SIGNING_SECRET")
|
||
if not secret:
|
||
self._send_json(503, {"error": "slack webhook secret not configured"})
|
||
return
|
||
signature = self.headers.get("X-Slack-Signature")
|
||
timestamp = self.headers.get("X-Slack-Request-Timestamp")
|
||
if not verify_slack_signature(secret, timestamp, raw, signature):
|
||
self._send_json(401, {"error": "unauthorized"})
|
||
return
|
||
# 只解析不执行任务:Slack 斜杠命令的执行由控制面后续决定。
|
||
content_type = self.headers.get("Content-Type") or ""
|
||
text = ""
|
||
if "application/x-www-form-urlencoded" in content_type:
|
||
parsed = parse_qs(raw.decode("utf-8", "replace"))
|
||
values = parsed.get("text")
|
||
text = values[0] if values else ""
|
||
elif raw:
|
||
try:
|
||
payload = json.loads(raw.decode("utf-8"))
|
||
if isinstance(payload, dict):
|
||
text = payload.get("text", "") or ""
|
||
else:
|
||
text = raw.decode("utf-8", "replace")
|
||
except (ValueError, UnicodeDecodeError):
|
||
text = raw.decode("utf-8", "replace")
|
||
self._send_json(200, parse_slack_command(text))
|
||
|
||
|
||
def create_server(db_path, host="127.0.0.1", port=8765, backend=None):
|
||
"""建 server 实例(不自动 serve,供测试与 __main__ 共用)。
|
||
|
||
backend 为 None 时读环境变量 CLOUD_DB_BACKEND,未设则默认
|
||
sqlite(旧行为不变)。选 postgres 时在建表之前就调用工厂:
|
||
驱动缺失则初始化即抛明确错误(文案含 psycopg),启动即失败,
|
||
绝不在运行时中途才炸、也绝不静默回落到 SQLite;驱动已装
|
||
且能连上服务器时 Postgres 即为可用后端(适配层见
|
||
cloud/db.py,部署步骤见 cloud/README.md)。
|
||
"""
|
||
db_path = str(db_path)
|
||
# 后端校验必须在 init_db 之前:postgres 模式不能先建 SQLite 表
|
||
# 再失败,那会假装 sqlite 已可用。
|
||
_create_database(backend=backend, db_path=db_path)
|
||
init_db(db_path)
|
||
# 确保 AuthStore 的三张表也存在,后续请求才能直接查 project_members。
|
||
with _auth_store(db_path):
|
||
pass
|
||
server = ThreadingHTTPServer((host, port), CloudControlPlaneHandler)
|
||
server.db_path = db_path
|
||
server.daemon_threads = True
|
||
# 限流器:Postgres 下 DB 共享计数(多 server 共用),否则内存
|
||
# 滑动窗口;默认值与环境变量覆盖见 _make_rate_limiter;测试可
|
||
# 直接改 server.rate_limit_per_minute / window_seconds。
|
||
server.rate_limiter = _make_rate_limiter(db_path)
|
||
server.rate_limit_per_minute = None
|
||
server.rate_limit_window_seconds = None
|
||
# OAuth 回调注入点:生产为 None(走真实换 token + 真实 userinfo),
|
||
# 测试可注入 fake http_client / userinfo_client;绝不注入真实 secret。
|
||
server.oauth_http_client = None
|
||
server.oauth_userinfo_client = None
|
||
# 自动 PR 注入点:生产为 None(走真实 git/gh),测试可注入
|
||
# fake git/gh runner 与 worktree 根;绝不注入真实凭证。
|
||
server.pr_git_runner = None
|
||
server.pr_gh_runner = None
|
||
server.pr_gh_which = None
|
||
server.host_worktrees_dir = None
|
||
# 全自动执行器注入点:生产为 None(走 urllib + 真实
|
||
# installation token 解析);测试可注入 fake http_client
|
||
# 与 token 解析器;绝不注入真实 token、真实 secret。
|
||
server.full_auto_http_client = None
|
||
server.full_auto_token_resolver = None
|
||
return server
|
||
|
||
|
||
def main(argv=None):
|
||
parser = argparse.ArgumentParser(description="cloud control plane")
|
||
parser.add_argument("--host", default="127.0.0.1")
|
||
parser.add_argument("--port", type=int, default=8765)
|
||
parser.add_argument("--db", default="cloud-control-plane.db")
|
||
parser.add_argument("--db-backend", default=None,
|
||
help="sqlite(默认)或 postgres;未给则读 "
|
||
"CLOUD_DB_BACKEND 环境变量")
|
||
args = parser.parse_args(argv)
|
||
server = create_server(args.db, args.host, args.port,
|
||
backend=args.db_backend)
|
||
# 共享 home MCP 脚本同步:只在 main() 启动时做,不在
|
||
# create_server() 里做——create_server 是单测入口,单测若
|
||
# 在此同步会按 host-config-host.json 写向生产共享路径。
|
||
# systemd 两个控制面都跑 python3 -m cloud.server,走的
|
||
# 都是 main(),每次部署重启都会同步一次;同步失败只记
|
||
# warning,绝不阻塞启动(sync 本身也不抛异常,外层再
|
||
# 兜一层防解析等意外)。Docker 任务不受影响:容器内
|
||
# 该共享路径是租户 home 的挂载,宿主补齐不进容器。
|
||
try:
|
||
_shared_home = _resolve_shared_home_mcp(server)
|
||
if _shared_home:
|
||
_sync_result = _sync_shared_home_mcp(_shared_home)
|
||
if _sync_result.get("copied") or _sync_result.get("refreshed"):
|
||
_server_log.info(
|
||
"shared home MCP synced: copied=%s refreshed=%s",
|
||
_sync_result.get("copied"),
|
||
_sync_result.get("refreshed"))
|
||
if _sync_result.get("error"):
|
||
_server_log.warning(
|
||
"shared home MCP sync failed (non-fatal): %s",
|
||
_sync_result.get("error"))
|
||
except Exception as exc:
|
||
_server_log.warning(
|
||
"shared home MCP sync failed (non-fatal): %s", exc)
|
||
start_rate_limit_sweeper(server.rate_limiter, args.db)
|
||
print(
|
||
"cloud-control-plane listening on %s:%d db=%s"
|
||
% (args.host, server.server_address[1], args.db),
|
||
flush=True,
|
||
)
|
||
try:
|
||
server.serve_forever()
|
||
except KeyboardInterrupt:
|
||
pass
|
||
|
||
|
||
if __name__ == "__main__":
|
||
main()
|