- 新增 tasks 列 paused_by_user/interrupted_reason/auto_resume_count/no_time_limit(幂等迁移,server+worker 双侧)
- POST /tasks/{id}/pause:用户暂停标 interrupted + paused_by_user=1;/cancel 同款标记;人工 /retry、/goal-resume 清标记
- sweep 标 sweep、配额标 quota、协调员孤儿标 coordinator_orphan、worker 退出标 worker_shutdown
- worker.run_loop 每轮后 auto_resume_interrupted:只接系统中断的 goal 任务,CAS 回 queued + goal_resume_pending=1(G10 断点续跑),用户暂停/配额/孤儿/非 goal 不接,上限 50 次防无限烧额度
- no_time_limit=1 的任务 worker/Docker 级不限时(其它任务仍默认 1800 秒;host 单轮 600 秒仍是单轮护栏)
- 前置 cherry-pick:G10 断点续跑透传 model_provider(否则 Docker 断点续跑在 sub2api 下必死,自动恢复无法实证;本 PR supersedes #127,勿另行合并 #127)
9210 lines
409 KiB
Python
9210 lines
409 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 html
|
||
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 contextlib import contextmanager
|
||
from decimal import Decimal
|
||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||
from urllib.parse import parse_qs, quote, urlparse
|
||
|
||
from cloud.auth import ROLES as _MEMBER_ROLES
|
||
from cloud.auth import AuthStore
|
||
from cloud.coordinator import parse_parts as _coord_parse_parts
|
||
from cloud.coordinator import validate_parts as _coord_validate_parts
|
||
from cloud.cost import CostStore, unpriced_token_cap
|
||
from cloud.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.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.automations import cancel_automation as _auto_cancel
|
||
from cloud.automations import create_automation as _auto_create
|
||
from cloud.automations import get_automation as _auto_get
|
||
from cloud.automations import list_automation_runs as _auto_runs
|
||
from cloud.automations import update_automation as _auto_update
|
||
from cloud.lifecycle import add_ref as _life_add_ref
|
||
from cloud.lifecycle import bind_automation as _life_bind
|
||
from cloud.lifecycle import create_snapshot as _life_snapshot
|
||
from cloud.lifecycle import delete_ref as _life_delete_ref
|
||
from cloud.lifecycle import get_binding as _life_get_binding
|
||
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.lifecycle import unbind_automation as _life_unbind
|
||
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 ensure_handoffs_table as _handoff_ensure
|
||
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.automations import ensure_automations_tables as _auto_ensure
|
||
from cloud.automations import list_automations as _auto_list
|
||
from cloud.automations import triage_inbox as _auto_triage
|
||
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.auto_memory import AutoMemoryStore
|
||
from cloud.auto_memory import process_due_tasks as _auto_process_due
|
||
from cloud.identity import IdentityStore
|
||
from cloud.kb import KBStore
|
||
from cloud.memory import MemoryStore
|
||
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.oauth import (
|
||
BindError,
|
||
OAuthError,
|
||
OAuthStateStore,
|
||
bind_external_identity,
|
||
build_authorization_url,
|
||
default_userinfo_client,
|
||
find_bound_user,
|
||
handle_oauth_callback,
|
||
list_bindings,
|
||
map_external_identity_to_local_user_with_key,
|
||
start_oauth_login,
|
||
unbind_external_identity,
|
||
)
|
||
from cloud.relay import RelayError as _RelayError
|
||
from cloud.relay import get_relay_models as _get_relay_models
|
||
from cloud.sub2api import Sub2apiError as _Sub2apiError
|
||
from cloud.sub2api import ensure_gateway_keys_table as _ensure_gateway_keys
|
||
from cloud.sub2api import ensure_user_gateway_key as _ensure_sub2api_key
|
||
from cloud.sub2api import gateway_name as _gateway_name
|
||
from cloud.sub2api import gateway_origin as _gateway_origin
|
||
from cloud.sub2api import get_user_gateway_key as _get_sub2api_key
|
||
from cloud.sub2api import list_gateway_models as _list_sub2api_models
|
||
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.repos import validate_repo_url
|
||
from cloud.push import (
|
||
create_subscription as _push_create,
|
||
delete_subscription as _push_delete,
|
||
dispatch_event as _push_dispatch,
|
||
list_subscriptions as _push_list,
|
||
)
|
||
from cloud.github_app import (
|
||
DEFAULT_APP_NAME,
|
||
DEFAULT_APP_URL,
|
||
AppError,
|
||
SetupStateStore,
|
||
build_app_manifest,
|
||
claim_fix_review,
|
||
exchange_manifest_code,
|
||
get_app_config,
|
||
list_installations,
|
||
parse_installation_event,
|
||
public_app_status,
|
||
record_installation_repos,
|
||
remove_installation,
|
||
remove_installation_repos,
|
||
save_app_config,
|
||
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,
|
||
)
|
||
|
||
# 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 长度上限。
|
||
# 均可经环境变量调(运维侧),缺省值见下方;改动只影响新项目,
|
||
# 已有超限项目不受影响(不删、不改名)。
|
||
def _env_int(name, default):
|
||
try:
|
||
value = int(os.environ.get(name, "") or default)
|
||
except (TypeError, ValueError):
|
||
return default
|
||
return value if value > 0 else default
|
||
|
||
|
||
MAX_PROJECTS_PER_USER = _env_int("CODEX_MAX_PROJECTS_PER_USER", 50)
|
||
MAX_PROJECT_NAME_LEN = _env_int("CODEX_MAX_PROJECT_NAME_LEN", 100)
|
||
MAX_PROJECT_ID_LEN = _env_int("CODEX_MAX_PROJECT_ID_LEN", 64)
|
||
# 协调员父任务预留:每分工至少预留的美元额度(微美元)。
|
||
# 父任务准入只查一次时,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 _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 _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
|
||
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
|
||
DEFAULT_SWEEP_THRESHOLD_SECONDS = 300
|
||
# 限流默认值:每 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",
|
||
"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",
|
||
# 任务级选项(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",
|
||
}
|
||
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 _ensure_followups_table(conn):
|
||
"""幂等建后续消息表:委托给 peer_messages(两边同一份 DDL,
|
||
server init_db、各 followup 接口与 worker 投递路径共用)。
|
||
老库第一次跑到这里时自动补表补列,不丢数据。DOUBLE
|
||
PRECISION 在 SQLite 与 Postgres 下都可用(SQLite 按 NUMERIC
|
||
亲和处理)。"""
|
||
_peer_ensure_followups(conn)
|
||
|
||
|
||
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 _ensure_quotas_table(conn):
|
||
"""幂等建配额表:server init_db 与配额查询都可调它,老库
|
||
第一次跑到这里时自动补表,不丢数据。quota_usd_micros 为
|
||
NULL 表示不限额(与 0 区分:0 是一分钱也不能花);金额
|
||
单位与 cost_records 一致,都是 USD 微单位(1 美元 =
|
||
1_000_000),避免浮点。"""
|
||
conn.execute(
|
||
"CREATE TABLE IF NOT EXISTS user_quotas("
|
||
"user_id TEXT PRIMARY KEY, "
|
||
"quota_usd_micros BIGINT, "
|
||
"updated_at DOUBLE PRECISION)")
|
||
|
||
|
||
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"
|
||
|
||
|
||
def _default_quota_usd_micros():
|
||
"""OAuth 新用户建号时写入的默认配额(微美元)。
|
||
|
||
无配额行 = 不限额,所以 OAuth 自动建号过去等于无限额入驻。
|
||
新号现在建号即写一行默认配额(老用户无配额行不受影响,
|
||
行为不变)。环境变量 CLOUD_DEFAULT_QUOTA_USD_MICROS 可
|
||
覆盖,默认 10_000_000($10/月);非法值按默认计。
|
||
"""
|
||
try:
|
||
return max(0, int(os.environ.get(
|
||
"CLOUD_DEFAULT_QUOTA_USD_MICROS", "10000000")))
|
||
except (TypeError, ValueError):
|
||
return 10000000
|
||
|
||
|
||
def _apply_default_quota(conn, user_id):
|
||
"""给用户写默认配额行(已存在则不动,幂等)。
|
||
|
||
INSERT OR IGNORE 保证重复回调不覆盖管理员后来改过的额度。
|
||
返回 True 表示本次写了新行(调用方据此记 quota_default
|
||
审计)。P0 安全批后:OAuth 新建、绑定、已存在用户登录
|
||
三条分支都调它补行,不再只给新建用户补。
|
||
"""
|
||
_ensure_quotas_table(conn)
|
||
cur = conn.execute(
|
||
"INSERT OR IGNORE INTO user_quotas(user_id, quota_usd_micros,"
|
||
" updated_at) VALUES(?, ?, ?)",
|
||
(user_id, _default_quota_usd_micros(), time.time()))
|
||
try:
|
||
conn.commit()
|
||
except Exception:
|
||
pass
|
||
return cur.rowcount == 1
|
||
|
||
|
||
def _global_admin_ids():
|
||
"""真全局管理员清单:环境变量 CODEX_GLOBAL_ADMINS。
|
||
|
||
逗号分隔的用户 id 列表,空白项忽略。未配置或为空时返回
|
||
空集——此时 admin 端点一律 fail-closed,绝不回落到项目
|
||
角色推导(P0 安全批前“任一项目 maintainer 即全局管理”
|
||
的推导已被废掉,见 _is_global_admin)。
|
||
"""
|
||
raw = os.environ.get("CODEX_GLOBAL_ADMINS") or ""
|
||
return {part.strip() for part in raw.split(",") if part.strip()}
|
||
|
||
|
||
def _backfill_default_quotas(conn):
|
||
"""给已有无行用户回填默认配额行(init_db 时调,幂等)。
|
||
|
||
只补“users 里有、user_quotas 里无”的用户,已有行(含
|
||
管理员显式设为 NULL 的不限额行)一律不动。返回补写行数。
|
||
"""
|
||
_ensure_quotas_table(conn)
|
||
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.commit()
|
||
return cur.rowcount or 0
|
||
except Exception:
|
||
try:
|
||
conn.rollback()
|
||
except Exception:
|
||
pass
|
||
return 0
|
||
|
||
|
||
_ENV_BRANCH_RE = re.compile(r"^[A-Za-z0-9_.\-/]+$")
|
||
|
||
|
||
def _validate_env_branch(branch):
|
||
"""环境分支名校验:空/None 表示默认分支;非空时只允许
|
||
字母数字与 _ . - /,且不能以 - 开头、不能含 ..、不能以
|
||
.lock 结尾、长度不超过 200——git ref 名的保守子集,防
|
||
分支名被拼进 shell 或越出目录。返回规范化后的分支或 None,
|
||
非法时抛 ValueError(调用方转 400)。"""
|
||
if branch is None:
|
||
return None
|
||
if not isinstance(branch, str):
|
||
raise ValueError("branch must be a string")
|
||
branch = branch.strip()
|
||
if not branch:
|
||
return None
|
||
if len(branch) > 200 or not _ENV_BRANCH_RE.match(branch) \
|
||
or branch.startswith("-") or ".." in branch \
|
||
or branch.endswith(".lock"):
|
||
raise ValueError("branch is not a valid git branch name")
|
||
return branch
|
||
|
||
|
||
def _environment_to_dict(row):
|
||
return {
|
||
"id": row["id"],
|
||
"project_id": row["project_id"],
|
||
"name": row["name"],
|
||
"branch": _row_get(row, "branch"),
|
||
"executor": _row_get(row, "executor"),
|
||
"status": row["status"],
|
||
"error_text": _row_get(row, "error_text"),
|
||
"repo_url": _row_get(row, "repo_url"),
|
||
"created_by": row["created_by"],
|
||
"created_at": row["created_at"],
|
||
"updated_at": row["updated_at"],
|
||
}
|
||
|
||
|
||
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)
|
||
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)
|
||
# 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()
|
||
|
||
|
||
@contextmanager
|
||
def _auth_store(db_path):
|
||
"""每请求一个 AuthStore 实例(自带新连接),用完即关闭。"""
|
||
store = AuthStore(db_path)
|
||
try:
|
||
yield store
|
||
finally:
|
||
try:
|
||
store.conn.close()
|
||
except Exception:
|
||
pass
|
||
|
||
|
||
def _row_get(row, name):
|
||
"""sqlite Row 的容错取值:老库行缺列时返回 None 不炸。"""
|
||
try:
|
||
return row[name]
|
||
except (KeyError, IndexError, TypeError):
|
||
return None
|
||
|
||
|
||
def _task_to_dict(row, attachments=None):
|
||
depends_on = row["depends_on"]
|
||
try:
|
||
depends_on = json.loads(depends_on) if depends_on else []
|
||
except (TypeError, ValueError):
|
||
depends_on = []
|
||
if not isinstance(depends_on, list):
|
||
depends_on = [depends_on]
|
||
return {
|
||
"id": row["id"],
|
||
"project_id": row["project_id"],
|
||
"prompt": row["prompt"],
|
||
"status": row["status"],
|
||
"idempotency_key": row["idempotency_key"],
|
||
"depends_on": depends_on,
|
||
"priority": row["priority"],
|
||
"created_by": row["created_by"],
|
||
"created_at": row["created_at"],
|
||
"updated_at": row["updated_at"],
|
||
"last_heartbeat": row["last_heartbeat"],
|
||
"result_text": _row_get(row, "result_text"),
|
||
"error_text": _row_get(row, "error_text"),
|
||
"actual_model": _row_get(row, "actual_model"),
|
||
"started_at": _row_get(row, "started_at"),
|
||
"finished_at": _row_get(row, "finished_at"),
|
||
"child_pid": _row_get(row, "child_pid"),
|
||
"executor": _row_get(row, "executor"),
|
||
"pr_url": _row_get(row, "pr_url"),
|
||
"pr_number": _row_get(row, "pr_number"),
|
||
"goal": _row_get(row, "goal"),
|
||
"verify_command": _row_get(row, "verify_command"),
|
||
"goal_max_rounds": _row_get(row, "goal_max_rounds"),
|
||
"goal_mode": _row_get(row, "goal_mode") or 0,
|
||
"attachments": list(attachments or []),
|
||
"resume_message": _row_get(row, "resume_message"),
|
||
"worktree_host": _row_get(row, "worktree_host"),
|
||
"model": _row_get(row, "model"),
|
||
"effort": _row_get(row, "effort"),
|
||
"child_model": _row_get(row, "child_model"),
|
||
"child_effort": _row_get(row, "child_effort"),
|
||
"approval": _row_get(row, "approval"),
|
||
"plan": _row_get(row, "plan") or 0,
|
||
"forked_from": _row_get(row, "forked_from"),
|
||
"environment_id": _row_get(row, "environment_id"),
|
||
"turn_id": _row_get(row, "turn_id"),
|
||
"root_turn_id": _row_get(row, "root_turn_id"),
|
||
"coordinator_parts": (
|
||
_coord_parse_parts(_row_get(row, "coordinator_parts"))
|
||
if _row_get(row, "coordinator_parts") else None),
|
||
"parent_task_id": _row_get(row, "parent_task_id"),
|
||
"coordinator_part": _row_get(row, "coordinator_part"),
|
||
"goal_resume_pending": _row_get(row, "goal_resume_pending") or 0,
|
||
"paused_by_user": _row_get(row, "paused_by_user") or 0,
|
||
"interrupted_reason": _row_get(row, "interrupted_reason"),
|
||
"auto_resume_count": _row_get(row, "auto_resume_count") or 0,
|
||
"no_time_limit": _row_get(row, "no_time_limit") or 0,
|
||
}
|
||
|
||
|
||
def _project_to_dict(row):
|
||
return {
|
||
"id": row["id"],
|
||
"name": row["name"],
|
||
"owner_id": row["owner_id"],
|
||
"created_at": row["created_at"],
|
||
# 老库行缺 repo_url 列时 _row_get 给 None,不炸(列本身由
|
||
# migrate_projects_table 在 init_db 时补,老行读到也是 None)。
|
||
"repo_url": _row_get(row, "repo_url"),
|
||
# auto_pr 默认 0(关闭):老行缺列时 None 按 0 计,绝不因
|
||
# 迁移就把老项目默认改成自动推。
|
||
"auto_pr": _row_get(row, "auto_pr") or 0,
|
||
}
|
||
|
||
|
||
def sweep_stale_tasks(db_path, threshold_seconds=300):
|
||
"""把 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"])
|
||
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
|
||
|
||
|
||
class CloudControlPlaneHandler(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(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 == "/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 == "/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/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/push/dispatch" and method == "POST":
|
||
self._push_dispatch_endpoint(user_id)
|
||
elif path.startswith("/tasks/"):
|
||
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("/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("/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("/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"})
|
||
else:
|
||
self._send_json(404, {"error": "not found"})
|
||
|
||
# -- 环境 ----------------------------------------------------------
|
||
def _fetch_environment(self, env_id):
|
||
conn = self._db()
|
||
try:
|
||
return conn.execute(
|
||
"SELECT * FROM environments WHERE id = ?", (env_id,)
|
||
).fetchone()
|
||
finally:
|
||
conn.close()
|
||
|
||
def _create_environment(self, user_id):
|
||
"""建环境:只记录意图(status=creating),检出由 worker
|
||
在下一轮准备(clone + checkout 分支),准备好后 status
|
||
变 ready,失败变 failed 并带 error_text——创建本身是同步
|
||
返回的记录,准备是异步的,与任务 queued->running 同款语义。
|
||
同项目内未归档环境名唯一(409),避免选环境时分不清;
|
||
归档过的名字可重用。项目没有 repo_url 时 400:环境就是
|
||
仓库的基准检出,没有仓库就没有环境。"""
|
||
body, err = self._read_json()
|
||
if err:
|
||
self._send_json(400, {"error": err})
|
||
return
|
||
project_id = body.get("project_id")
|
||
if not project_id or not isinstance(project_id, str):
|
||
self._send_json(400, {"error": "project_id required"})
|
||
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) > 100:
|
||
self._send_json(400, {"error": "name too long"})
|
||
return
|
||
executor = body.get("executor")
|
||
if executor is not None and executor not in ("local", "docker"):
|
||
self._send_json(400, {
|
||
"error": "executor must be local or docker"})
|
||
return
|
||
try:
|
||
branch = _validate_env_branch(body.get("branch"))
|
||
except ValueError as exc:
|
||
self._send_json(400, {"error": str(exc)})
|
||
return
|
||
err = self._check_project(user_id, project_id, "create_task")
|
||
if err:
|
||
self._send_json(err[0], {"error": err[1]})
|
||
return
|
||
now = time.time()
|
||
env_id = uuid.uuid4().hex
|
||
conn = self._db()
|
||
try:
|
||
proj = conn.execute(
|
||
"SELECT repo_url FROM projects WHERE id = ?",
|
||
(project_id,)).fetchone()
|
||
if proj is None:
|
||
self._send_json(404, {"error": "project not found"})
|
||
return
|
||
repo_url = _row_get(proj, "repo_url")
|
||
if not repo_url:
|
||
self._send_json(400, {
|
||
"error": "project has no repo_url, "
|
||
"environment needs a repo"})
|
||
return
|
||
existing = conn.execute(
|
||
"SELECT id FROM environments WHERE project_id = ?"
|
||
" AND name = ? AND status != 'archived'",
|
||
(project_id, name)).fetchone()
|
||
if existing:
|
||
self._send_json(409, {
|
||
"error": "environment name already exists"
|
||
" in this project"})
|
||
return
|
||
try:
|
||
conn.execute(
|
||
"INSERT INTO environments(id, project_id, name,"
|
||
" branch, executor, status, error_text, repo_url,"
|
||
" created_by, created_at, updated_at)"
|
||
" VALUES(?, ?, ?, ?, ?, 'creating', NULL, ?, ?,"
|
||
" ?, ?)",
|
||
(env_id, project_id, name, branch, executor,
|
||
repo_url, user_id, now, now))
|
||
conn.commit()
|
||
except (sqlite3.IntegrityError, _PgIntegrityError):
|
||
self._send_json(409, {
|
||
"error": "environment name already exists"
|
||
" in this project"})
|
||
return
|
||
row = conn.execute(
|
||
"SELECT * FROM environments WHERE id = ?", (env_id,)
|
||
).fetchone()
|
||
finally:
|
||
conn.close()
|
||
with _auth_store(self.server.db_path) as store:
|
||
store.audit(user_id, project_id, "environment_created",
|
||
env_id)
|
||
self._send_json(201, _environment_to_dict(row))
|
||
|
||
def _list_environments(self, user_id, query):
|
||
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
|
||
err = self._check_project(user_id, project_id, "read")
|
||
if err:
|
||
self._send_json(err[0], {"error": err[1]})
|
||
return
|
||
include_archived = query.get("include_archived") == ["1"]
|
||
conn = self._db()
|
||
try:
|
||
if include_archived:
|
||
rows = conn.execute(
|
||
"SELECT * FROM environments WHERE project_id = ?"
|
||
" ORDER BY created_at, id",
|
||
(project_id,)).fetchall()
|
||
else:
|
||
rows = conn.execute(
|
||
"SELECT * FROM environments WHERE project_id = ?"
|
||
" AND status != 'archived'"
|
||
" ORDER BY created_at, id",
|
||
(project_id,)).fetchall()
|
||
finally:
|
||
conn.close()
|
||
self._send_json(200, {
|
||
"environments": [_environment_to_dict(r) for r in rows]})
|
||
|
||
def _get_environment(self, user_id, env_id):
|
||
row = self._fetch_environment(env_id)
|
||
if row is None:
|
||
self._send_json(404, {"error": "environment not found"})
|
||
return
|
||
env = _environment_to_dict(row)
|
||
err = self._check_project(user_id, env["project_id"], "read")
|
||
if err:
|
||
self._send_json(err[0], {"error": err[1]})
|
||
return
|
||
self._send_json(200, env)
|
||
|
||
def _archive_environment(self, user_id, env_id):
|
||
"""归档环境:已归档时幂等返回 200;有 queued/running
|
||
任务正在用它时 409,绝不让进行中的任务失去基准检出。
|
||
归档只改状态,不删检出目录(已完成任务的 worktree 独立,
|
||
检出留着可查;磁盘清理另行手动做,不在归档里顺手删)。"""
|
||
row = self._fetch_environment(env_id)
|
||
if row is None:
|
||
self._send_json(404, {"error": "environment not found"})
|
||
return
|
||
env = _environment_to_dict(row)
|
||
err = self._check_project(
|
||
user_id, env["project_id"], "create_task")
|
||
if err:
|
||
self._send_json(err[0], {"error": err[1]})
|
||
return
|
||
if env["status"] == "archived":
|
||
self._send_json(200, env)
|
||
return
|
||
now = time.time()
|
||
conn = self._db()
|
||
try:
|
||
using = conn.execute(
|
||
"SELECT id FROM tasks WHERE environment_id = ?"
|
||
" AND status IN ('queued', 'running') LIMIT 1",
|
||
(env_id,)).fetchone()
|
||
if using:
|
||
self._send_json(409, {
|
||
"error": "environment is in use by a queued or"
|
||
" running task"})
|
||
return
|
||
conn.execute(
|
||
"UPDATE environments SET status = 'archived',"
|
||
" updated_at = ? WHERE id = ?",
|
||
(now, env_id))
|
||
conn.commit()
|
||
row = conn.execute(
|
||
"SELECT * FROM environments WHERE id = ?", (env_id,)
|
||
).fetchone()
|
||
finally:
|
||
conn.close()
|
||
with _auth_store(self.server.db_path) as store:
|
||
store.audit(user_id, env["project_id"],
|
||
"environment_archived", env_id)
|
||
self._send_json(200, _environment_to_dict(row))
|
||
|
||
# -- 项目 ----------------------------------------------------------
|
||
def _create_project(self, user_id):
|
||
body, err = self._read_json()
|
||
if err:
|
||
self._send_json(400, {"error": err})
|
||
return
|
||
name = body.get("name")
|
||
if not name or not isinstance(name, str):
|
||
self._send_json(400, {"error": "name required"})
|
||
return
|
||
if len(name) > MAX_PROJECT_NAME_LEN:
|
||
self._send_json(400, {
|
||
"error": "project name too long: max %d characters"
|
||
% MAX_PROJECT_NAME_LEN})
|
||
return
|
||
project_id = body.get("id") or uuid.uuid4().hex
|
||
if not isinstance(project_id, str):
|
||
self._send_json(400, {"error": "id must be a string"})
|
||
return
|
||
if len(project_id) > MAX_PROJECT_ID_LEN:
|
||
self._send_json(400, {
|
||
"error": "project id too long: max %d characters"
|
||
% MAX_PROJECT_ID_LEN})
|
||
return
|
||
# 单用户项目数上限:按 projects.owner_id 计(成员身份加入
|
||
# 的项目不计入自己的上限,避免“被拉进很多项目就建不了
|
||
# 项目”);超限 400 且不建项目。
|
||
_count_conn = self._db()
|
||
try:
|
||
_owned = _count_conn.execute(
|
||
"SELECT COUNT(*) FROM projects WHERE owner_id = ?",
|
||
(user_id,)).fetchone()[0]
|
||
finally:
|
||
_count_conn.close()
|
||
if _owned >= MAX_PROJECTS_PER_USER:
|
||
self._send_json(400, {
|
||
"error": "project limit exceeded: max %d projects"
|
||
" per user" % MAX_PROJECTS_PER_USER})
|
||
return
|
||
# repo_url 可选:不传时为 None(老行为不变);传了则与 worker
|
||
# 共用 cloud.repos.validate_repo_url 的同一套校验,不合法时
|
||
# 400 且不建项目,绝不把带凭证的 URL 存进库。
|
||
repo_url = body.get("repo_url")
|
||
if repo_url is not None:
|
||
try:
|
||
repo_url = validate_repo_url(repo_url)
|
||
except ValueError as exc:
|
||
self._send_json(400, {"error": str(exc)})
|
||
return
|
||
# auto_pr 可选,默认 0(关闭):只接受 0/1(布尔 True/False
|
||
# 按 1/0 计),其它值 400 且不建项目。本轮没有现成的
|
||
# PATCH /projects/{id} 路由,auto_pr 只能在创建时指定,
|
||
# 建后改它需直接改库或重建项目(见 README)。
|
||
auto_pr_raw = body.get("auto_pr", 0)
|
||
if isinstance(auto_pr_raw, bool):
|
||
auto_pr = 1 if auto_pr_raw else 0
|
||
elif isinstance(auto_pr_raw, int) and auto_pr_raw in (0, 1):
|
||
auto_pr = auto_pr_raw
|
||
else:
|
||
self._send_json(400, {"error": "auto_pr must be 0 or 1"})
|
||
return
|
||
now = time.time()
|
||
conn = self._db()
|
||
try:
|
||
existing = conn.execute(
|
||
"SELECT id FROM projects WHERE id = ?", (project_id,)
|
||
).fetchone()
|
||
if existing:
|
||
self._send_json(409, {"error": "project exists"})
|
||
return
|
||
conn.execute(
|
||
"INSERT INTO projects(id, name, owner_id, created_at,"
|
||
" repo_url, auto_pr) VALUES(?, ?, ?, ?, ?, ?)",
|
||
(project_id, name, user_id, now, repo_url, auto_pr),
|
||
)
|
||
conn.commit()
|
||
project = {
|
||
"id": project_id,
|
||
"name": name,
|
||
"owner_id": user_id,
|
||
"created_at": now,
|
||
"repo_url": repo_url,
|
||
"auto_pr": auto_pr,
|
||
}
|
||
except (sqlite3.IntegrityError, _PgIntegrityError):
|
||
self._send_json(409, {"error": "project exists"})
|
||
return
|
||
finally:
|
||
conn.close()
|
||
# AuthStore.create_project 只写成员表(owner 角色),项目记录
|
||
# 本身在上面的 projects 表;两者配合才算一个完整项目。
|
||
with _auth_store(self.server.db_path) as store:
|
||
store.create_project(project_id, user_id)
|
||
store.audit(user_id, project_id, "create_project", name)
|
||
self._send_json(201, project)
|
||
|
||
def _update_project(self, user_id, project_id):
|
||
"""PATCH /projects/{id}:项目 owner 可改 repo_url(建后补仓
|
||
库,解“建项目时没填仓库、之后建环境一直 400”的死结)。
|
||
repo_url 缺 key 时 400;null 或空串表示清空;字符串走与
|
||
创建时同一套 validate_repo_url 校验(带凭证的 URL 拒绝)。
|
||
只改 projects 行,不动已建环境的快照 repo_url——已建环境
|
||
仍用建环境时的仓库,新环境才用新值。改完记 update_project
|
||
审计。"""
|
||
err = self._check_project(user_id, project_id, "manage_project")
|
||
if err:
|
||
self._send_json(err[0], {"error": err[1]})
|
||
return
|
||
body, jerr = self._read_json()
|
||
if jerr:
|
||
self._send_json(400, {"error": jerr})
|
||
return
|
||
if "repo_url" not in body:
|
||
self._send_json(400, {"error": "repo_url required"})
|
||
return
|
||
repo_url = body.get("repo_url")
|
||
if repo_url is not None and repo_url != "":
|
||
if not isinstance(repo_url, str):
|
||
self._send_json(400, {"error": "repo_url must be a string"})
|
||
return
|
||
try:
|
||
repo_url = validate_repo_url(repo_url)
|
||
except ValueError as exc:
|
||
self._send_json(400, {"error": str(exc)})
|
||
return
|
||
else:
|
||
repo_url = None
|
||
conn = self._db()
|
||
try:
|
||
row = conn.execute(
|
||
"SELECT id, name, owner_id, created_at, repo_url, auto_pr"
|
||
" FROM projects WHERE id = ?",
|
||
(project_id,)).fetchone()
|
||
if row is None:
|
||
self._send_json(404, {"error": "project not found"})
|
||
return
|
||
conn.execute(
|
||
"UPDATE projects SET repo_url = ? WHERE id = ?",
|
||
(repo_url, project_id))
|
||
conn.commit()
|
||
row = conn.execute(
|
||
"SELECT id, name, owner_id, created_at, repo_url, auto_pr"
|
||
" FROM projects WHERE id = ?",
|
||
(project_id,)).fetchone()
|
||
finally:
|
||
conn.close()
|
||
with _auth_store(self.server.db_path) as store:
|
||
store.audit(user_id, project_id, "update_project",
|
||
repo_url or "(cleared)")
|
||
self._send_json(200, {"project": _project_to_dict(row)})
|
||
|
||
def _github_repos_list(self, user_id):
|
||
"""GET /github/repos:只返回调用者成员项目对应的仓库。
|
||
|
||
P1 收紧前返回全部安装记录(任一已认证用户可看到全部
|
||
安装仓库名,属跨租户信息泄露)。现按 project_members
|
||
过滤:只保留调用者是成员的项目的 repo_url 对应的仓库
|
||
(clone_url 归一化后比对:去 .git 后缀、比对小写);
|
||
调用者无成员项目或项目未填 repo_url 时返回空列表。
|
||
手填 URL 的能力不变——没进 App 安装的公开仓库仍可手动填。"""
|
||
conn = self._db()
|
||
try:
|
||
rows = list_installations(conn)
|
||
member_ids = {
|
||
row["project_id"]
|
||
for row in conn.execute(
|
||
"SELECT project_id FROM project_members"
|
||
" WHERE user_id = ?",
|
||
(user_id,)).fetchall()
|
||
}
|
||
allowed = set()
|
||
for row in conn.execute(
|
||
"SELECT id, repo_url FROM projects").fetchall():
|
||
if row["id"] not in member_ids or not row["repo_url"]:
|
||
continue
|
||
url = str(row["repo_url"]).strip().lower()
|
||
if url.endswith(".git"):
|
||
url = url[:-4]
|
||
allowed.add(url)
|
||
finally:
|
||
conn.close()
|
||
repos = []
|
||
for r in rows:
|
||
clone_url = "https://github.com/%s.git" % r["repo_full_name"]
|
||
normalized = clone_url.lower()
|
||
if normalized.endswith(".git"):
|
||
normalized = normalized[:-4]
|
||
if normalized not in allowed:
|
||
continue
|
||
repos.append({
|
||
"installation_id": r["installation_id"],
|
||
"account_login": r["account_login"],
|
||
"repo_full_name": r["repo_full_name"],
|
||
"clone_url": clone_url,
|
||
})
|
||
self._send_json(200, {"repos": repos})
|
||
|
||
def _list_projects(self, user_id):
|
||
conn = self._db()
|
||
try:
|
||
member_rows = conn.execute(
|
||
"SELECT project_id, role FROM project_members"
|
||
" WHERE user_id = ?",
|
||
(user_id,)).fetchall()
|
||
member_ids = {row["project_id"] for row in member_rows}
|
||
roles = {row["project_id"]: row["role"]
|
||
for row in member_rows}
|
||
rows = conn.execute(
|
||
"SELECT id, name, owner_id, created_at, repo_url, auto_pr"
|
||
" FROM projects ORDER BY created_at, id"
|
||
).fetchall()
|
||
finally:
|
||
conn.close()
|
||
projects = []
|
||
for r in rows:
|
||
if r["id"] not in member_ids:
|
||
continue
|
||
# 附加字段 my_role(只增字段,不改已有成功语义):
|
||
# 控制台据此按角色禁用重试/合并/提 PR 等按钮;服务端
|
||
# 权限仍以每次请求的 check_permission 为准,前端禁用
|
||
# 只是防误点,不是鉴权。
|
||
item = _project_to_dict(r)
|
||
item["my_role"] = roles.get(r["id"])
|
||
projects.append(item)
|
||
self._send_json(200, {"projects": projects})
|
||
|
||
# -- 项目成员:邀请 / 列表 / 改角色 -------------------------------
|
||
def _can_manage_members(self, user_id, project_id):
|
||
"""成员管理权:项目 owner 或全局管理员。
|
||
|
||
全局管理员(CODEX_GLOBAL_ADMINS)不一定是项目成员,
|
||
故不能只用项目内 manage_project 判定;项目 owner 判定
|
||
复用 check_permission 的 manage_project(仅 owner)。
|
||
"""
|
||
if self._is_global_admin(user_id):
|
||
return True
|
||
with _auth_store(self.server.db_path) as store:
|
||
return store.check_permission(
|
||
user_id, project_id, "manage_project")
|
||
|
||
def _set_member_row(self, store, project_id, actor_id, target_id,
|
||
role):
|
||
"""写成员行并记审计:owner 走 AuthStore.set_member(内含
|
||
owner 校验与审计);非成员的全局管理员走直接写 + 同格式
|
||
审计(set_member 的 owner 校验会挡住全局管理员,故分流)。
|
||
"""
|
||
if store.check_permission(actor_id, project_id,
|
||
"manage_project"):
|
||
store.set_member(project_id, actor_id, target_id, role)
|
||
return
|
||
store.conn.execute(
|
||
"INSERT OR REPLACE INTO project_members"
|
||
"(project_id, user_id, role) VALUES(?, ?, ?)",
|
||
(project_id, target_id, role))
|
||
store.conn.commit()
|
||
store.audit(actor_id, project_id, "set_member",
|
||
"%s:%s" % (target_id, role))
|
||
|
||
def _project_members_list(self, user_id, project_id):
|
||
"""GET /projects/{id}/members:项目成员(read)或全局
|
||
管理员可读。只返回 user_id / username / role,绝不返回
|
||
key 哈希等认证字段。"""
|
||
if not self._project_exists(project_id):
|
||
self._send_json(404, {"error": "project not found"})
|
||
return
|
||
if not self._is_global_admin(user_id):
|
||
err = self._check_project(user_id, project_id, "read")
|
||
if err:
|
||
self._send_json(err[0], {"error": err[1]})
|
||
return
|
||
conn = self._db()
|
||
try:
|
||
rows = conn.execute(
|
||
"SELECT m.user_id, m.role, u.username FROM"
|
||
" project_members m LEFT JOIN users u"
|
||
" ON u.id = m.user_id WHERE m.project_id = ?"
|
||
" ORDER BY u.username, m.user_id",
|
||
(project_id,)).fetchall()
|
||
finally:
|
||
conn.close()
|
||
self._send_json(200, {"project_id": project_id, "members": [
|
||
{"user_id": r["user_id"], "username": r["username"],
|
||
"role": r["role"]} for r in rows]})
|
||
|
||
def _resolve_invite_target(self, body):
|
||
"""邀请目标解析:优先 user_id,其次 username 查 id;
|
||
返回 (target_id, error_status, error)。"""
|
||
target = body.get("user_id")
|
||
username = body.get("username")
|
||
if target is not None and not isinstance(target, str):
|
||
return None, 400, "user_id must be a string"
|
||
if username is not None and not isinstance(username, str):
|
||
return None, 400, "username must be a string"
|
||
if not target and not username:
|
||
return None, 400, "user_id or username required"
|
||
conn = self._db()
|
||
try:
|
||
if target:
|
||
row = conn.execute(
|
||
"SELECT id FROM users WHERE id = ?",
|
||
(target,)).fetchone()
|
||
if row is None:
|
||
return None, 404, "user not found"
|
||
return row["id"], None, None
|
||
row = conn.execute(
|
||
"SELECT id FROM users WHERE username = ?",
|
||
(username,)).fetchone()
|
||
if row is None:
|
||
return None, 404, "user not found"
|
||
return row["id"], None, None
|
||
finally:
|
||
conn.close()
|
||
|
||
def _project_member_invite(self, user_id, project_id):
|
||
"""POST /projects/{id}/members:限项目 owner 或全局管理
|
||
员。已是成员时 409(改角色走 PATCH,不用邀请覆盖);
|
||
成功记 set_member 审计。role 缺省 contributor。"""
|
||
if not self._project_exists(project_id):
|
||
self._send_json(404, {"error": "project not found"})
|
||
return
|
||
if not self._can_manage_members(user_id, project_id):
|
||
self._send_json(403, {"error": "forbidden"})
|
||
return
|
||
body, jerr = self._read_json()
|
||
if jerr:
|
||
self._send_json(400, {"error": jerr})
|
||
return
|
||
role = body.get("role") or "contributor"
|
||
if role not in _MEMBER_ROLES:
|
||
self._send_json(400, {"error": "unknown role"})
|
||
return
|
||
target, estatus, eerr = self._resolve_invite_target(body)
|
||
if eerr:
|
||
self._send_json(estatus, {"error": eerr})
|
||
return
|
||
with _auth_store(self.server.db_path) as store:
|
||
existing = store.conn.execute(
|
||
"SELECT role FROM project_members"
|
||
" WHERE project_id = ? AND user_id = ?",
|
||
(project_id, target)).fetchone()
|
||
if existing is not None:
|
||
self._send_json(409, {"error": "already_member"})
|
||
return
|
||
self._set_member_row(store, project_id, user_id,
|
||
target, role)
|
||
self._send_json(201, {"project_id": project_id,
|
||
"user_id": target, "role": role})
|
||
|
||
def _project_member_patch(self, user_id, project_id, target):
|
||
"""PATCH /projects/{id}/members/{user_id}:限项目 owner
|
||
或全局管理员。唯一 owner 降为非 owner 时 409(无论降
|
||
自己还是降他人——否则项目将无 owner 可管);目标非成员
|
||
时 404。"""
|
||
if not self._project_exists(project_id):
|
||
self._send_json(404, {"error": "project not found"})
|
||
return
|
||
if not self._can_manage_members(user_id, project_id):
|
||
self._send_json(403, {"error": "forbidden"})
|
||
return
|
||
body, jerr = self._read_json()
|
||
if jerr:
|
||
self._send_json(400, {"error": jerr})
|
||
return
|
||
role = body.get("role")
|
||
if role not in _MEMBER_ROLES:
|
||
self._send_json(400, {"error": "unknown role"})
|
||
return
|
||
with _auth_store(self.server.db_path) as store:
|
||
cur = store.conn.execute(
|
||
"SELECT role FROM project_members"
|
||
" WHERE project_id = ? AND user_id = ?",
|
||
(project_id, target)).fetchone()
|
||
if cur is None:
|
||
self._send_json(404, {"error": "member not found"})
|
||
return
|
||
if cur[0] == "owner" and role != "owner":
|
||
owners = store.conn.execute(
|
||
"SELECT COUNT(*) FROM project_members"
|
||
" WHERE project_id = ? AND role = 'owner'",
|
||
(project_id,)).fetchone()[0]
|
||
if owners <= 1:
|
||
self._send_json(
|
||
409,
|
||
{"error": "cannot demote the only owner"})
|
||
return
|
||
self._set_member_row(store, project_id, user_id,
|
||
target, role)
|
||
self._send_json(200, {"project_id": project_id,
|
||
"user_id": target, "role": role})
|
||
|
||
def _project_member_delete(self, user_id, project_id, target):
|
||
"""DELETE /projects/{id}/members/{user_id}:限项目 owner
|
||
或全局管理员。删唯一 owner 时 409(否则项目将无 owner
|
||
可管);目标非成员时 404。成功记 remove_member 审计。"""
|
||
if not self._project_exists(project_id):
|
||
self._send_json(404, {"error": "project not found"})
|
||
return
|
||
if not self._can_manage_members(user_id, project_id):
|
||
self._send_json(403, {"error": "forbidden"})
|
||
return
|
||
with _auth_store(self.server.db_path) as store:
|
||
cur = store.conn.execute(
|
||
"SELECT role FROM project_members"
|
||
" WHERE project_id = ? AND user_id = ?",
|
||
(project_id, target)).fetchone()
|
||
if cur is None:
|
||
self._send_json(404, {"error": "member not found"})
|
||
return
|
||
if cur[0] == "owner":
|
||
owners = store.conn.execute(
|
||
"SELECT COUNT(*) FROM project_members"
|
||
" WHERE project_id = ? AND role = 'owner'",
|
||
(project_id,)).fetchone()[0]
|
||
if owners <= 1:
|
||
self._send_json(
|
||
409,
|
||
{"error": "cannot remove the only owner"})
|
||
return
|
||
self._remove_member_row(store, project_id, user_id, target)
|
||
self._send_json(200, {"project_id": project_id,
|
||
"user_id": target})
|
||
|
||
def _remove_member_row(self, store, project_id, actor_id, target_id):
|
||
"""删成员行并记审计:owner 走 AuthStore.remove_member
|
||
(内含 owner 校验与审计);非成员的全局管理员走直接删
|
||
+ 同格式审计(remove_member 的 owner 校验会挡住全局
|
||
管理员,故分流,与 _set_member_row 同款)。"""
|
||
if store.check_permission(actor_id, project_id,
|
||
"manage_project"):
|
||
store.remove_member(project_id, actor_id, target_id)
|
||
return
|
||
store.conn.execute(
|
||
"DELETE FROM project_members"
|
||
" WHERE project_id = ? AND user_id = ?",
|
||
(project_id, target_id))
|
||
store.conn.commit()
|
||
store.audit(actor_id, project_id, "remove_member", target_id)
|
||
|
||
# -- 项目子资源:记忆 / 知识库 / 成本 -------------------------------
|
||
def _project_exists(self, project_id):
|
||
conn = self._db()
|
||
try:
|
||
row = conn.execute(
|
||
"SELECT id FROM projects WHERE id = ?", (project_id,)
|
||
).fetchone()
|
||
return row is not None
|
||
finally:
|
||
conn.close()
|
||
|
||
def _check_project(self, user_id, project_id, action):
|
||
"""返回 None 表示通过;否则返回 (status, error) 先行响应。"""
|
||
if not self._project_exists(project_id):
|
||
return 404, "project not found"
|
||
with _auth_store(self.server.db_path) as store:
|
||
if not store.check_permission(user_id, project_id, action):
|
||
return 403, "forbidden"
|
||
return None
|
||
|
||
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":
|
||
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
|
||
subs = _push_list(self.server.db_path, project_id)
|
||
mine = [s for s in subs if s["id"] == parts[3]]
|
||
if not mine:
|
||
self._send_json(404, {"error": "subscription not found"})
|
||
return
|
||
# 只能删自己的订阅,项目 owner 可删任意(owner 角色检查)。
|
||
with _auth_store(self.server.db_path) as auth:
|
||
is_owner = auth.check_permission(
|
||
user_id, project_id, "manage_project")
|
||
if mine[0]["user_id"] != user_id and not is_owner:
|
||
self._send_json(403, {"error": "forbidden"})
|
||
return
|
||
deleted = _push_delete(self.server.db_path,
|
||
subscription_id=parts[3])
|
||
if deleted:
|
||
with _auth_store(self.server.db_path) as store:
|
||
store.audit(
|
||
user_id, project_id, "push_subscription_deleted",
|
||
"subscription_id=%s" % parts[3])
|
||
self._send_json(200, {"deleted": bool(deleted)})
|
||
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}/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 _memory_create(self, user_id, project_id):
|
||
body, err = self._read_json()
|
||
if err:
|
||
self._send_json(400, {"error": err})
|
||
return
|
||
goal = body.get("goal")
|
||
conclusion = body.get("conclusion")
|
||
if not goal or not isinstance(goal, str):
|
||
self._send_json(400, {"error": "goal required"})
|
||
return
|
||
if not conclusion or not isinstance(conclusion, str):
|
||
self._send_json(400, {"error": "conclusion required"})
|
||
return
|
||
store = MemoryStore(self.server.db_path)
|
||
try:
|
||
mid = store.add_summary(
|
||
project_id,
|
||
body.get("thread_id") or "",
|
||
goal,
|
||
conclusion,
|
||
files_changed=body.get("files_changed") or [],
|
||
open_issues=body.get("open_issues") or [],
|
||
)
|
||
finally:
|
||
try:
|
||
store.conn.close()
|
||
except Exception:
|
||
pass
|
||
with _auth_store(self.server.db_path) as auth:
|
||
auth.audit(user_id, project_id, "create_memory", str(mid))
|
||
self._send_json(201, {"id": mid, "project_id": project_id})
|
||
|
||
def _memory_list(self, project_id, query):
|
||
try:
|
||
top_k = int((query.get("top_k") or ["5"])[0])
|
||
except (TypeError, ValueError):
|
||
self._send_json(400, {"error": "top_k must be an integer"})
|
||
return
|
||
store = MemoryStore(self.server.db_path)
|
||
try:
|
||
summaries = store.list_summaries(project_id)
|
||
context = store.build_prompt_context(project_id, top_k=top_k)
|
||
finally:
|
||
try:
|
||
store.conn.close()
|
||
except Exception:
|
||
pass
|
||
self._send_json(200, {
|
||
"project_id": project_id,
|
||
"summaries": summaries[:top_k],
|
||
"prompt_context": context,
|
||
})
|
||
|
||
def _memory_supersede(self, user_id, project_id, mid_raw):
|
||
try:
|
||
mid = int(mid_raw)
|
||
except (TypeError, ValueError):
|
||
self._send_json(400, {"error": "id must be an integer"})
|
||
return
|
||
conn = self._db()
|
||
try:
|
||
row = conn.execute(
|
||
"SELECT project_id FROM summaries WHERE id = ?", (mid,)
|
||
).fetchone()
|
||
finally:
|
||
conn.close()
|
||
if row is None or row["project_id"] != project_id:
|
||
self._send_json(404, {"error": "summary not found"})
|
||
return
|
||
store = MemoryStore(self.server.db_path)
|
||
try:
|
||
store.deprecate(mid)
|
||
finally:
|
||
try:
|
||
store.conn.close()
|
||
except Exception:
|
||
pass
|
||
with _auth_store(self.server.db_path) as auth:
|
||
auth.audit(user_id, project_id, "supersede_memory", str(mid))
|
||
self._send_json(200, {"id": mid, "status": "deprecated"})
|
||
|
||
# -- System-level identity / memory (cloud only) ------------------
|
||
# These handlers never touch host.py, CODEX_HOME or any thread:
|
||
# they only read/write the control-plane database. The context
|
||
# endpoint returns text for an explicit caller; it does not
|
||
# inject anything into a task or a thread on its own.
|
||
|
||
def _identity_store(self):
|
||
return IdentityStore(self.server.db_path)
|
||
|
||
def _identity_close(self, store):
|
||
try:
|
||
store.conn.close()
|
||
except Exception:
|
||
pass
|
||
|
||
def _identity_profile_get(self, user_id):
|
||
store = self._identity_store()
|
||
try:
|
||
self._send_json(200, store.get_profile(user_id))
|
||
finally:
|
||
self._identity_close(store)
|
||
|
||
def _identity_profile_upsert(self, user_id):
|
||
body, err = self._read_json()
|
||
if err:
|
||
self._send_json(400, {"error": err})
|
||
return
|
||
store = self._identity_store()
|
||
try:
|
||
try:
|
||
profile = store.upsert_profile(
|
||
user_id,
|
||
display_name=body.get("display_name"),
|
||
timezone=body.get("timezone"),
|
||
preferences=body.get("preferences"))
|
||
except ValueError as exc:
|
||
self._send_json(400, {"error": str(exc)})
|
||
return
|
||
finally:
|
||
self._identity_close(store)
|
||
with _auth_store(self.server.db_path) as auth:
|
||
auth.audit(user_id, "", "profile_updated", user_id)
|
||
self._send_json(200, profile)
|
||
|
||
def _identity_user_memories_list(self, user_id):
|
||
store = self._identity_store()
|
||
try:
|
||
rows = store.list_user_memories(user_id)
|
||
finally:
|
||
self._identity_close(store)
|
||
self._send_json(200, {"user_id": user_id, "memories": rows})
|
||
|
||
def _identity_user_memory_create(self, user_id):
|
||
body, err = self._read_json()
|
||
if err:
|
||
self._send_json(400, {"error": err})
|
||
return
|
||
store = self._identity_store()
|
||
try:
|
||
try:
|
||
mem_id = store.add_memory(
|
||
user_id, "user", body.get("kind"),
|
||
body.get("content"),
|
||
source_thread_id=body.get("source_thread_id"))
|
||
except ValueError as exc:
|
||
self._send_json(400, {"error": str(exc)})
|
||
return
|
||
finally:
|
||
self._identity_close(store)
|
||
with _auth_store(self.server.db_path) as auth:
|
||
auth.audit(user_id, "", "identity_memory_created", mem_id)
|
||
self._send_json(201, {"id": mem_id, "scope": "user"})
|
||
|
||
def _identity_user_memory_supersede(self, user_id, mem_id):
|
||
body, err = self._read_json()
|
||
if err:
|
||
body = {}
|
||
store = self._identity_store()
|
||
try:
|
||
row = store.get_memory(mem_id)
|
||
if row is None or row["scope"] != "user" \
|
||
or row["owner_user_id"] != user_id:
|
||
self._send_json(404, {"error": "memory not found"})
|
||
return
|
||
try:
|
||
_old, new_id = store.supersede_memory(
|
||
mem_id, user_id,
|
||
replacement_content=body.get("replacement_content"),
|
||
replacement_kind=body.get("replacement_kind"))
|
||
except (KeyError, PermissionError):
|
||
self._send_json(404, {"error": "memory not found"})
|
||
return
|
||
except ValueError as exc:
|
||
self._send_json(400, {"error": str(exc)})
|
||
return
|
||
finally:
|
||
self._identity_close(store)
|
||
with _auth_store(self.server.db_path) as auth:
|
||
auth.audit(user_id, "", "identity_memory_superseded", mem_id)
|
||
self._send_json(200, {"id": mem_id, "status": "superseded",
|
||
"replacement_id": new_id})
|
||
|
||
def _identity_user_memory_delete(self, user_id, mem_id):
|
||
store = self._identity_store()
|
||
try:
|
||
row = store.get_memory(mem_id)
|
||
if row is None or row["scope"] != "user" \
|
||
or row["owner_user_id"] != user_id:
|
||
self._send_json(404, {"error": "memory not found"})
|
||
return
|
||
try:
|
||
store.delete_memory(mem_id, user_id)
|
||
except (KeyError, PermissionError):
|
||
self._send_json(404, {"error": "memory not found"})
|
||
return
|
||
finally:
|
||
self._identity_close(store)
|
||
with _auth_store(self.server.db_path) as auth:
|
||
auth.audit(user_id, "", "identity_memory_deleted", mem_id)
|
||
self._send_json(200, {"deleted": True, "id": mem_id})
|
||
|
||
def _identity_context(self, user_id, query):
|
||
project_id = None
|
||
if query:
|
||
vals = query.get("project_id")
|
||
if vals:
|
||
project_id = vals[0]
|
||
if project_id:
|
||
err = self._check_project(user_id, project_id, "read")
|
||
if err:
|
||
self._send_json(err[0], {"error": err[1]})
|
||
return
|
||
store = self._identity_store()
|
||
try:
|
||
profile = store.get_profile(user_id)
|
||
user_rows = store.list_user_memories(user_id)
|
||
proj_rows = store.list_project_memories(project_id) \
|
||
if project_id else []
|
||
context = store.build_identity_context(user_id, project_id)
|
||
finally:
|
||
self._identity_close(store)
|
||
summaries_context = ""
|
||
if project_id:
|
||
mem_store = MemoryStore(self.server.db_path)
|
||
try:
|
||
summaries_context = mem_store.build_prompt_context(
|
||
project_id)
|
||
finally:
|
||
try:
|
||
mem_store.conn.close()
|
||
except Exception:
|
||
pass
|
||
self._send_json(200, {
|
||
"user_id": user_id,
|
||
"project_id": project_id,
|
||
"profile": profile,
|
||
"user_memories": user_rows,
|
||
"project_memories": proj_rows,
|
||
"identity_context": context,
|
||
# Kept as a separate field on purpose: the existing
|
||
# project summaries are NOT merged into identity_context
|
||
# and neither field is injected into any task/thread here.
|
||
"project_summaries_context": summaries_context,
|
||
})
|
||
|
||
# -- 自动记忆(官方 memories 对齐:设置 + 提取触发) ------------
|
||
# 设置与提取日志只归属本人;任务级开关走项目 create_task 门。
|
||
|
||
def _auto_memory_store(self):
|
||
return AutoMemoryStore(self.server.db_path)
|
||
|
||
def _auto_memory_close(self, store):
|
||
try:
|
||
store.close()
|
||
except Exception:
|
||
pass
|
||
|
||
def _auto_memory_settings_get(self, user_id):
|
||
store = self._auto_memory_store()
|
||
try:
|
||
self._send_json(200, store.get_settings(user_id))
|
||
finally:
|
||
self._auto_memory_close(store)
|
||
|
||
def _auto_memory_settings_upsert(self, user_id):
|
||
body, err = self._read_json()
|
||
if err:
|
||
self._send_json(400, {"error": err})
|
||
return
|
||
if not isinstance(body, dict):
|
||
self._send_json(400, {"error": "body must be an object"})
|
||
return
|
||
store = self._auto_memory_store()
|
||
try:
|
||
try:
|
||
settings = store.upsert_settings(user_id, **body)
|
||
except (ValueError, TypeError) as exc:
|
||
self._send_json(400, {"error": str(exc)})
|
||
return
|
||
finally:
|
||
self._auto_memory_close(store)
|
||
with _auth_store(self.server.db_path) as auth:
|
||
auth.audit(user_id, "", "auto_memory_settings_updated", "")
|
||
self._send_json(200, settings)
|
||
|
||
def _auto_memory_log(self, user_id, query):
|
||
try:
|
||
limit = int((query.get("limit") or ["50"])[0])
|
||
except (TypeError, ValueError):
|
||
self._send_json(400, {"error": "limit must be an integer"})
|
||
return
|
||
store = self._auto_memory_store()
|
||
try:
|
||
rows = store.list_log(user_id, limit=limit)
|
||
finally:
|
||
self._auto_memory_close(store)
|
||
self._send_json(200, {"user_id": user_id, "log": rows})
|
||
|
||
def _auto_memory_run(self, user_id):
|
||
body, err = self._read_json()
|
||
if err:
|
||
self._send_json(400, {"error": err})
|
||
return
|
||
force = False
|
||
if isinstance(body, dict) and body.get("force") is not None:
|
||
if body.get("force") not in (True, False, 0, 1):
|
||
self._send_json(400, {"error": "force must be a boolean"})
|
||
return
|
||
force = bool(body.get("force"))
|
||
results = _auto_process_due(
|
||
self.server.db_path, force=force, owner_id=user_id)
|
||
for item in results:
|
||
if item.get("status") == "extracted":
|
||
# 提取日志已在 process_due_tasks 里落库,这里只补
|
||
# 一条审计(不查任务归属:run 只扫本人的任务)。
|
||
task_id = item.get("task_id") or ""
|
||
with _auth_store(self.server.db_path) as auth:
|
||
auth.audit(user_id, "", "auto_memory_extracted",
|
||
task_id)
|
||
self._send_json(200, {"user_id": user_id, "results": results})
|
||
|
||
def _task_auto_memory_flags(self, user_id, task_id):
|
||
body, err = self._read_json()
|
||
if err:
|
||
self._send_json(400, {"error": err})
|
||
return
|
||
if not isinstance(body, dict):
|
||
self._send_json(400, {"error": "body must be an object"})
|
||
return
|
||
task = self._fetch_task(task_id)
|
||
if task is None:
|
||
self._send_json(404, {"error": "task not found"})
|
||
return
|
||
err = self._check_project(
|
||
user_id, task["project_id"], "create_task")
|
||
if err:
|
||
self._send_json(err[0], {"error": err[1]})
|
||
return
|
||
store = self._auto_memory_store()
|
||
try:
|
||
try:
|
||
flags = store.set_task_flags(
|
||
task_id,
|
||
use_memories=body.get("use_memories"),
|
||
generate_memories=body.get("generate_memories"))
|
||
except ValueError as exc:
|
||
self._send_json(400, {"error": str(exc)})
|
||
return
|
||
finally:
|
||
self._auto_memory_close(store)
|
||
with _auth_store(self.server.db_path) as auth:
|
||
auth.audit(user_id, task["project_id"],
|
||
"task_auto_memory_flags", task_id)
|
||
self._send_json(200, {"task_id": task_id, "flags": flags})
|
||
|
||
def _project_identity_memory_create(self, user_id, project_id):
|
||
body, err = self._read_json()
|
||
if err:
|
||
self._send_json(400, {"error": err})
|
||
return
|
||
store = self._identity_store()
|
||
try:
|
||
try:
|
||
mem_id = store.add_memory(
|
||
user_id, "project", body.get("kind"),
|
||
body.get("content"), project_id=project_id,
|
||
source_thread_id=body.get("source_thread_id"))
|
||
except ValueError as exc:
|
||
self._send_json(400, {"error": str(exc)})
|
||
return
|
||
finally:
|
||
self._identity_close(store)
|
||
with _auth_store(self.server.db_path) as auth:
|
||
auth.audit(user_id, project_id,
|
||
"identity_memory_created", mem_id)
|
||
self._send_json(201, {"id": mem_id, "scope": "project",
|
||
"project_id": project_id})
|
||
|
||
def _project_identity_memories_list(self, project_id, query):
|
||
try:
|
||
top_k = int((query.get("top_k") or ["20"])[0])
|
||
except (TypeError, ValueError):
|
||
self._send_json(400, {"error": "top_k must be an integer"})
|
||
return
|
||
store = self._identity_store()
|
||
try:
|
||
rows = store.list_project_memories(project_id)
|
||
finally:
|
||
self._identity_close(store)
|
||
self._send_json(200, {"project_id": project_id,
|
||
"memories": rows[:top_k]})
|
||
|
||
def _project_identity_memory_supersede(self, user_id, project_id,
|
||
mem_id):
|
||
body, err = self._read_json()
|
||
if err:
|
||
body = {}
|
||
store = self._identity_store()
|
||
try:
|
||
row = store.get_memory(mem_id)
|
||
if row is None or row["scope"] != "project" \
|
||
or row["project_id"] != project_id:
|
||
self._send_json(404, {"error": "memory not found"})
|
||
return
|
||
try:
|
||
_old, new_id = store.supersede_memory(
|
||
mem_id, user_id,
|
||
replacement_content=body.get("replacement_content"),
|
||
replacement_kind=body.get("replacement_kind"))
|
||
except (KeyError, PermissionError, ValueError) as exc:
|
||
if isinstance(exc, ValueError):
|
||
self._send_json(400, {"error": str(exc)})
|
||
else:
|
||
self._send_json(404, {"error": "memory not found"})
|
||
return
|
||
finally:
|
||
self._identity_close(store)
|
||
with _auth_store(self.server.db_path) as auth:
|
||
auth.audit(user_id, project_id,
|
||
"identity_memory_superseded", mem_id)
|
||
self._send_json(200, {"id": mem_id, "status": "superseded",
|
||
"replacement_id": new_id})
|
||
|
||
def _project_identity_memory_delete(self, user_id, project_id, mem_id):
|
||
store = self._identity_store()
|
||
try:
|
||
row = store.get_memory(mem_id)
|
||
if row is None or row["scope"] != "project" \
|
||
or row["project_id"] != project_id:
|
||
self._send_json(404, {"error": "memory not found"})
|
||
return
|
||
store.delete_memory(mem_id, user_id)
|
||
finally:
|
||
self._identity_close(store)
|
||
with _auth_store(self.server.db_path) as auth:
|
||
auth.audit(user_id, project_id,
|
||
"identity_memory_deleted", mem_id)
|
||
self._send_json(200, {"deleted": True, "id": mem_id})
|
||
|
||
def _kb_index(self, user_id, project_id):
|
||
body, err = self._read_json()
|
||
if err:
|
||
self._send_json(400, {"error": err})
|
||
return
|
||
path = body.get("path")
|
||
content = body.get("content")
|
||
if not path or not isinstance(path, str):
|
||
self._send_json(400, {"error": "path required"})
|
||
return
|
||
if content is None or not isinstance(content, str):
|
||
self._send_json(400, {"error": "content required"})
|
||
return
|
||
store = KBStore(self.server.db_path)
|
||
try:
|
||
doc_id = store.index_file(
|
||
project_id, path, content,
|
||
kind=body.get("kind") or "file")
|
||
except ValueError as e:
|
||
self._send_json(400, {"error": str(e)})
|
||
return
|
||
finally:
|
||
try:
|
||
store.conn.close()
|
||
except Exception:
|
||
pass
|
||
with _auth_store(self.server.db_path) as auth:
|
||
auth.audit(user_id, project_id, "kb_index", path)
|
||
self._send_json(201, {"id": doc_id, "project_id": project_id,
|
||
"path": path})
|
||
|
||
def _kb_search(self, project_id, query):
|
||
q = (query.get("q") or [""])[0]
|
||
if not q:
|
||
self._send_json(400, {"error": "q required"})
|
||
return
|
||
try:
|
||
top_k = int((query.get("top_k") or ["5"])[0])
|
||
except (TypeError, ValueError):
|
||
self._send_json(400, {"error": "top_k must be an integer"})
|
||
return
|
||
store = KBStore(self.server.db_path)
|
||
try:
|
||
results = store.search(project_id, q, top_k=top_k)
|
||
finally:
|
||
try:
|
||
store.conn.close()
|
||
except Exception:
|
||
pass
|
||
self._send_json(200, {"project_id": project_id, "query": q,
|
||
"results": results})
|
||
|
||
def _project_cost(self, project_id, query):
|
||
store = CostStore(self.server.db_path)
|
||
try:
|
||
overall = store.report_by_project(project_id)
|
||
conn = store._conn
|
||
conn.row_factory = sqlite3.Row
|
||
days = (query.get("days") or [None])[0]
|
||
day = (query.get("day") or [None])[0]
|
||
daily = []
|
||
if day:
|
||
row = conn.execute(
|
||
"SELECT day, COALESCE(SUM(total_tokens),0) AS t,"
|
||
" COALESCE(SUM(usd_micros),0) AS u, COUNT(*) AS c,"
|
||
" COALESCE(SUM(fallback_count),0) AS f,"
|
||
" COALESCE(SUM(cached_input_tokens),0) AS cached,"
|
||
" COALESCE(SUM(CASE WHEN source = 'actual'"
|
||
" AND usd_micros IS NULL THEN total_tokens"
|
||
" ELSE 0 END),0) AS unpriced"
|
||
" FROM cost_records WHERE project_id = ? AND day = ?"
|
||
" GROUP BY day",
|
||
(project_id, day)).fetchone()
|
||
if row:
|
||
daily = [{"day": row["day"],
|
||
"total_tokens": int(row["t"]),
|
||
"usd_micros": int(row["u"]),
|
||
"turns": int(row["c"]),
|
||
"fallback_count": int(row["f"]),
|
||
"cached_input_tokens": int(row["cached"]),
|
||
"unpriced_actual_tokens": int(row["unpriced"])}]
|
||
elif days is not None:
|
||
try:
|
||
n = int(days)
|
||
except (TypeError, ValueError):
|
||
self._send_json(400, {"error": "days must be an integer"})
|
||
return
|
||
from datetime import date, timedelta
|
||
cutoff = (date.today() - timedelta(days=max(n - 1, 0))
|
||
).isoformat()
|
||
rows = conn.execute(
|
||
"SELECT day, COALESCE(SUM(total_tokens),0) AS t,"
|
||
" COALESCE(SUM(usd_micros),0) AS u, COUNT(*) AS c,"
|
||
" COALESCE(SUM(fallback_count),0) AS f,"
|
||
" COALESCE(SUM(cached_input_tokens),0) AS cached,"
|
||
" COALESCE(SUM(CASE WHEN source = 'actual'"
|
||
" AND usd_micros IS NULL THEN total_tokens"
|
||
" ELSE 0 END),0) AS unpriced"
|
||
" FROM cost_records WHERE project_id = ? AND day >= ?"
|
||
" GROUP BY day ORDER BY day",
|
||
(project_id, cutoff)).fetchall()
|
||
daily = [{"day": r["day"],
|
||
"total_tokens": int(r["t"]),
|
||
"usd_micros": int(r["u"]),
|
||
"turns": int(r["c"]),
|
||
"fallback_count": int(r["f"]),
|
||
"cached_input_tokens": int(r["cached"]),
|
||
"unpriced_actual_tokens": int(r["unpriced"])}
|
||
for r in rows]
|
||
finally:
|
||
try:
|
||
store._conn.close()
|
||
except Exception:
|
||
pass
|
||
self._send_json(200, {"project_id": project_id, "overall": overall,
|
||
"daily": daily})
|
||
|
||
# -- 任务成本与模型总览 ----------------------------------------------
|
||
def _task_cost(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 auth:
|
||
if not auth.check_permission(user_id, task["project_id"], "read"):
|
||
self._send_json(403, {"error": "forbidden"})
|
||
return
|
||
store = CostStore(self.server.db_path)
|
||
try:
|
||
report = store.report_by_task(task_id)
|
||
finally:
|
||
try:
|
||
store._conn.close()
|
||
except Exception:
|
||
pass
|
||
self._send_json(200, {"task_id": task_id,
|
||
"project_id": task["project_id"], **report})
|
||
|
||
def _relay_models(self, user_id):
|
||
# 中转可用模型列表:服务端凭环境变量里的 relay key 现取
|
||
# 现回,只把模型 id 交给调用者。key 绝不进响应、日志、
|
||
# 缓存(见 cloud/relay.py)。单测可经 server.relay_fetcher
|
||
# 注入假取数函数;中转挂了或未配 key 时 502,不编造列表。
|
||
# 网关为 sub2api 时:模型目录因用户而异(key 绑定的组
|
||
# 决定)。P1 收紧:首次调模型目录即做真正的懒开通/
|
||
# 健康检查,不再只回 provisioning: pending——无行则开通,
|
||
# 旧 key 经 /v1/models 实测已死(401/403,含余额为 0 的
|
||
# 403)则轮换重开;网关本身不可用时回 502、保留旧行。
|
||
# key 明文绝不进响应、日志、缓存。
|
||
if _gateway_name() == "sub2api":
|
||
conn = self._db()
|
||
try:
|
||
try:
|
||
status, api_key, _sub, _kid = _validate_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()
|
||
try:
|
||
models = _list_sub2api_models(_gateway_origin(), api_key)
|
||
except Exception as exc:
|
||
self._send_json(502, {"error": str(exc)})
|
||
return
|
||
self._send_json(200, {"models": models, "gateway": "sub2api",
|
||
"cached": False, "updated_at": None,
|
||
"provisioning": status})
|
||
return
|
||
fetcher = getattr(self.server, "relay_fetcher", None)
|
||
try:
|
||
models, cached, updated_at = _get_relay_models(fetcher=fetcher)
|
||
except _RelayError as exc:
|
||
self._send_json(502, {"error": str(exc)})
|
||
return
|
||
self._send_json(200, {
|
||
"models": models, "cached": cached, "updated_at": updated_at,
|
||
"gateway": "xcmapi"})
|
||
|
||
def _models_overview(self, user_id):
|
||
# 优先级列表来自环境变量 MODELS_PRIORITY(逗号分隔),未设时
|
||
# 用默认列表;这只是控制面的默认配置,不是 provider 的承诺。
|
||
raw = os.environ.get("MODELS_PRIORITY", "")
|
||
priority = [m.strip() for m in raw.split(",") if m.strip()] or [
|
||
"gpt-6.1-sol", "gpt-6-sol", "gpt-5.6-sol"]
|
||
# 只统计调用者是成员的项目,避免跨项目泄露成本。
|
||
conn = self._db()
|
||
try:
|
||
member_ids = [r["project_id"] for r in conn.execute(
|
||
"SELECT project_id FROM project_members WHERE user_id = ?",
|
||
(user_id,)).fetchall()]
|
||
finally:
|
||
conn.close()
|
||
if not member_ids:
|
||
self._send_json(200, {"priority_list": priority,
|
||
"model_stats": [], "fallback_events": []})
|
||
return
|
||
placeholders = ",".join("?" for _ in member_ids)
|
||
store = CostStore(self.server.db_path)
|
||
try:
|
||
conn = store._conn
|
||
conn.row_factory = sqlite3.Row
|
||
rows = conn.execute(
|
||
"SELECT model, COUNT(*) AS turns,"
|
||
" COALESCE(SUM(total_tokens),0) AS tokens,"
|
||
" COALESCE(SUM(fallback_count),0) AS fallbacks"
|
||
" FROM cost_records WHERE project_id IN (%s)"
|
||
" GROUP BY model ORDER BY model" % placeholders,
|
||
member_ids).fetchall()
|
||
fb_rows = conn.execute(
|
||
"SELECT task_id, project_id, model, fallback_count"
|
||
" FROM cost_records WHERE project_id IN (%s)"
|
||
" AND fallback_count > 0"
|
||
" ORDER BY created_at DESC LIMIT 50" % placeholders,
|
||
member_ids).fetchall()
|
||
finally:
|
||
try:
|
||
store._conn.close()
|
||
except Exception:
|
||
pass
|
||
self._send_json(200, {
|
||
"priority_list": priority,
|
||
"model_stats": [
|
||
{"model": r["model"], "turns": int(r["turns"]),
|
||
"total_tokens": int(r["tokens"]),
|
||
"fallback_count": int(r["fallbacks"])} for r in rows],
|
||
"fallback_events": [
|
||
{"task_id": r["task_id"], "project_id": r["project_id"],
|
||
"model": r["model"],
|
||
"fallback_count": int(r["fallback_count"])}
|
||
for r in fb_rows],
|
||
})
|
||
|
||
# -- 任务 ----------------------------------------------------------
|
||
# -- 任务附件 ------------------------------------------------------
|
||
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
|
||
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 模式分新旧两种语义(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_mode = 1 if body.get("goal_mode") in (True, 1) else 0
|
||
if goal_mode:
|
||
if plan:
|
||
self._send_json(400, {
|
||
"error": "plan 模式与 goal 模式冲突(只读 vs 改文件)"})
|
||
return
|
||
if body.get("verify_command"):
|
||
self._send_json(400, {
|
||
"error": "goal 模式不需要验收命令(达标由模型宣告)"})
|
||
return
|
||
goal = prompt
|
||
verify_command = None
|
||
goal_max_rounds = 0
|
||
else:
|
||
goal = body.get("goal")
|
||
if goal is not None and not isinstance(goal, str):
|
||
self._send_json(400, {"error": "goal must be a string"})
|
||
return
|
||
verify_command = body.get("verify_command")
|
||
if verify_command is not None and not isinstance(
|
||
verify_command, str):
|
||
self._send_json(400, {
|
||
"error": "verify_command must be a string"})
|
||
return
|
||
# 计划模式与 goal 模式也冲突:goal 循环就是要改文件
|
||
# 直到验测通过,只读沙箱下它永远做不成。
|
||
if plan and verify_command:
|
||
self._send_json(400, {
|
||
"error": "plan 模式与 goal 模式冲突(只读 vs 改文件)"})
|
||
return
|
||
try:
|
||
goal_max_rounds = int(body.get("goal_max_rounds", 3))
|
||
except (TypeError, ValueError):
|
||
self._send_json(400, {
|
||
"error": "goal_max_rounds must be an integer"})
|
||
return
|
||
if goal_max_rounds < 1:
|
||
self._send_json(400, {
|
||
"error": "goal_max_rounds must be >= 1"})
|
||
return
|
||
# P1:旧验收形态轮次设上限 100(与 worker/sandbox 两道门
|
||
# 一致;新 goal_mode=1 不走本分支,仍由配额兜底)。
|
||
if goal_max_rounds > 100:
|
||
self._send_json(400, {
|
||
"error": "goal_max_rounds 超过上限 100 轮"})
|
||
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):
|
||
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]
|
||
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()
|
||
amap = _attachments_map(conn, [r["id"] for r in rows])
|
||
finally:
|
||
conn.close()
|
||
self._send_json(200, {"tasks": [
|
||
_task_to_dict(r, amap[r["id"]]) for r in rows]})
|
||
|
||
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 _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]
|
||
finally:
|
||
conn.close()
|
||
task = _task_to_dict(row, atts)
|
||
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_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 循环自有轮次)。
|
||
权限与取消同款:项目 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 = 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
|
||
if task["status"] not in ("queued", "running"):
|
||
self._send_json(409, {
|
||
"error": "后续消息仅支持进行中任务",
|
||
"status": task["status"],
|
||
})
|
||
return
|
||
if task.get("verify_command"):
|
||
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))
|
||
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_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()
|
||
|
||
merged = []
|
||
|
||
def _ms(seconds):
|
||
try:
|
||
return int(float(seconds) * 1000)
|
||
except (TypeError, ValueError):
|
||
return None
|
||
|
||
if task.get("prompt"):
|
||
merged.append((0, _ms(task.get("created_at")), {
|
||
"id": "task-prompt", "role": "user", "source": "prompt",
|
||
"label": "用户", "text": task["prompt"],
|
||
"ts_ms": _ms(task.get("created_at"))}))
|
||
for r in cont_rows:
|
||
d = _continue_message_to_dict(r)
|
||
merged.append((0, _ms(d["created_at"]), {
|
||
"id": "continue-" + d["id"], "role": "user",
|
||
"source": "continue", "label": "用户",
|
||
"text": d["body"], "ts_ms": _ms(d["created_at"])}))
|
||
for r in fu_rows:
|
||
d = _followup_to_dict(r)
|
||
merged.append((0, _ms(d["created_at"]), {
|
||
"id": "followup-" + d["id"], "role": "user",
|
||
"source": "followup", "label": "后续消息",
|
||
"text": d["body"], "ts_ms": _ms(d["created_at"])}))
|
||
last_trace_ms = _ms(task.get("created_at"))
|
||
for e in entries:
|
||
if not isinstance(e, dict):
|
||
continue
|
||
if str(e.get("id") or "").startswith("fu-"):
|
||
# 直接发送的后续消息已在链路里留了一条回声
|
||
# (_append_direct_trace 写 kind=message),
|
||
# followups 表里同一条已作用户消息返回;这里
|
||
# 跳过回声,否则同一条消息会先以用户、再以
|
||
# 助手各出现一次。
|
||
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))
|
||
# 排序键:(时间, 用户优先, 原顺序)。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 = {}
|
||
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:]
|
||
|
||
def _activity(entry):
|
||
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]}
|
||
|
||
recent_activity = [_activity(e) for e in entries[-10:]]
|
||
# 待审批与待处理后续消息(阻塞与“需不需要人”的来源)。
|
||
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)。返回 (http_code, payload),不直接
|
||
_send_json;配额检查由调用方在调本方法前做。"""
|
||
task_id = task["id"]
|
||
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"]}
|
||
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))
|
||
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 = body.get("timeout_seconds", 30)
|
||
if isinstance(timeout, bool) or not isinstance(
|
||
timeout, (int, float)):
|
||
self._send_json(400, {"error": "timeout_seconds must be a number"})
|
||
return
|
||
if timeout < 0 or timeout > 120:
|
||
self._send_json(400, {
|
||
"error": "timeout_seconds must be between 0 and 120"})
|
||
return
|
||
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)):
|
||
self._send_json(400, {"error": "task_ids must be a list"})
|
||
return
|
||
if requested is not None and len(requested) == 0:
|
||
self._send_json(400, {"error": "task_ids must not be empty"})
|
||
return
|
||
if requested is not None and len(requested) > 8:
|
||
self._send_json(400, {
|
||
"error": "at most 8 threads per wait",
|
||
"count": len(requested)})
|
||
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()
|
||
)
|
||
# 取消后 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),
|
||
)
|
||
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。
|
||
"""
|
||
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"] != "completed":
|
||
self._send_json(409, {
|
||
"error": "only completed tasks can be marked merged",
|
||
"status": task["status"],
|
||
})
|
||
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)
|
||
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。成功记审计 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", 3600)
|
||
if not isinstance(timeout, int) or isinstance(timeout, bool) \
|
||
or timeout < 60 or timeout > 86400:
|
||
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 任务沿用
|
||
原语义续跑。状态回 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
|
||
model = body.get("model")
|
||
if model is not None:
|
||
if not isinstance(model, str):
|
||
self._send_json(400, {
|
||
"error": "model must be a string"})
|
||
return
|
||
model = model.strip() or None
|
||
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
|
||
# 派生默认同款: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):
|
||
self._send_json(400, {
|
||
"error": "child_model must be a string"})
|
||
return
|
||
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"):
|
||
self._send_json(400, {
|
||
"error": "child_effort must be low, medium, high,"
|
||
" xhigh or max"})
|
||
return
|
||
else:
|
||
child_effort = raw_child_effort
|
||
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
|
||
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
|
||
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":
|
||
self._send_json(400, {
|
||
"error": "plan 模式与 approval=full 冲突(只读 vs 完全访问)"})
|
||
return
|
||
if plan_eff and goal_eff:
|
||
self._send_json(400, {
|
||
"error": "plan 模式与 goal 模式冲突"})
|
||
return
|
||
sets = ["status = 'queued'", "resume_message = ?",
|
||
"result_text = NULL", "error_text = NULL",
|
||
"actual_model = NULL", "updated_at = ?"]
|
||
vals = [message, 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(message)
|
||
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_status(self, user_id):
|
||
"""返回 (quota_usd_micros|None, used_usd_micros, month)。
|
||
P0 安全批后:无配额行不再等于不限额——缺行时按默认
|
||
配额处理(与 _default_quota_usd_micros() 同值,只在
|
||
返回值里合成,不在这里写行;写行由 _apply_default_quota
|
||
/ _backfill_default_quotas 负责)。只有管理员显式把
|
||
行写成 NULL 时,quota 才为 None(不限额)。"""
|
||
month = self._quota_month()
|
||
conn = self._db()
|
||
try:
|
||
_ensure_quotas_table(conn)
|
||
conn.commit()
|
||
# cost_records 由 CostStore 懒建:全新库上它还不存在,
|
||
# 直接 JOIN 会报 no such table——先实例化一次建表
|
||
# (与 _project_cost 同款做法),再跑配额查询。
|
||
_cost_store = CostStore(self.server.db_path)
|
||
try:
|
||
_cost_store._conn.close()
|
||
except Exception:
|
||
pass
|
||
row = conn.execute(
|
||
"SELECT quota_usd_micros FROM user_quotas"
|
||
" WHERE user_id = ?",
|
||
(user_id,)).fetchone()
|
||
if row is None:
|
||
quota = _default_quota_usd_micros()
|
||
else:
|
||
quota = row[0]
|
||
used = conn.execute(
|
||
"SELECT COALESCE(SUM(c.usd_micros), 0)"
|
||
" FROM cost_records c JOIN tasks t ON c.task_id = t.id"
|
||
" WHERE t.created_by = ? AND c.day LIKE ?",
|
||
(user_id, month + "%")).fetchone()[0]
|
||
return quota, int(used or 0), month
|
||
finally:
|
||
conn.close()
|
||
|
||
def _quota_unpriced_tokens(self, user_id, month=None):
|
||
"""当月无价模型 token 数(usd_micros IS NULL 的成本行,
|
||
input + output 之和;cached 已含在 input 里,不重复计)。
|
||
|
||
合并说明(#86 × #90):两边曾各自定义过一次(#90 的
|
||
只要 user_id 且只计 source='actual',#86 的要 month 且
|
||
计全部 usd IS NULL 行),自动合并后后一个覆盖前一个,
|
||
上报调用点会 TypeError。这里统一成一个方法,以 main
|
||
(#86)的口径为准:计全部 usd IS NULL 行(含 estimated
|
||
行),上报与上限共用同一口径,不再留两套数字;month
|
||
缺省为当月,兼容两边的调用点。与 _quota_status 同款
|
||
CostStore 懒建表做法,全新库上 cost_records 不存在时
|
||
先建表再查。无配额行时调用方不应以此挡人(见
|
||
_check_unpriced_token_cap)。
|
||
"""
|
||
if month is None:
|
||
month = self._quota_month()
|
||
conn = self._db()
|
||
try:
|
||
_cost_store = CostStore(self.server.db_path)
|
||
try:
|
||
_cost_store._conn.close()
|
||
except Exception:
|
||
pass
|
||
row = conn.execute(
|
||
"SELECT COALESCE(SUM(c.input_tokens + c.output_tokens), 0)"
|
||
" FROM cost_records c JOIN tasks t ON c.task_id = t.id"
|
||
" WHERE t.created_by = ? AND c.day LIKE ?"
|
||
" AND c.usd_micros IS NULL",
|
||
(user_id, month + "%")).fetchone()
|
||
return int(row[0] if row is not None else 0)
|
||
finally:
|
||
conn.close()
|
||
|
||
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。
|
||
|
||
P0 安全批后:无配额行用户同样受限(无价 token 上限与
|
||
配额行存在与否无关);USD 配额本身不计量无价模型,所
|
||
以这道是补 USD 口径的洞,不是第二套计费。"""
|
||
month = self._quota_month()
|
||
used = self._quota_unpriced_tokens(user_id, month)
|
||
cap = unpriced_token_cap()
|
||
if used >= cap:
|
||
return cap, 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)
|
||
if quota is not None and used >= quota:
|
||
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 _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_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。心跳本身就是注册:worker_id
|
||
不存在时自动注册,绝不 404。running 是 worker 上报的
|
||
当前占用槽数(启动中占位也算);未给时保留旧值。"""
|
||
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
|
||
try:
|
||
worker = _registry_heartbeat(
|
||
self.server.db_path, worker_id,
|
||
executor=executor, capacity=capacity, running=running)
|
||
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()
|
||
# 按状态分计数:七个状态键恒存在(缺失为 0),调用方
|
||
# 不用再猜某个状态是“零”还是“没统计”。
|
||
by_status = {key: 0 for key in TASK_STATUS_KEYS}
|
||
durations = []
|
||
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"])
|
||
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, 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
|
||
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
|
||
finally:
|
||
conn.close()
|
||
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,
|
||
},
|
||
})
|
||
|
||
# -- OAuth(pre-auth,靠 state + secret,不走 X-API-Key)-----------
|
||
def _oauth_provider(self, path):
|
||
parts = [p for p in path.split("/") if p]
|
||
if len(parts) != 3 or parts[0] != "oauth":
|
||
return None
|
||
return parts[1]
|
||
|
||
def _oauth_login(self, path):
|
||
provider = self._oauth_provider(path)
|
||
if provider is None:
|
||
self._send_json(404, {"error": "not found"})
|
||
return
|
||
query = parse_qs(urlparse(self.path).query)
|
||
client_id = (query.get("client_id") or [None])[0]
|
||
redirect_uri = (query.get("redirect_uri") or [None])[0]
|
||
scope = (query.get("scope") or [None])[0]
|
||
if not client_id or not redirect_uri:
|
||
self._send_json(400, {"error": "client_id and redirect_uri required"})
|
||
return
|
||
store = OAuthStateStore(self.server.db_path)
|
||
try:
|
||
state, url = start_oauth_login(
|
||
store, provider, client_id, redirect_uri, scope=scope)
|
||
except OAuthError as e:
|
||
self._send_json(400, {"error": str(e), "reason": e.reason})
|
||
return
|
||
except ValueError as e:
|
||
self._send_json(400, {"error": str(e)})
|
||
return
|
||
finally:
|
||
try:
|
||
store.conn.close()
|
||
except Exception:
|
||
pass
|
||
# 只返回 URL,不直接跳转到任何登录页;跳转由调用方决定。
|
||
self._send_json(200, {"provider": provider, "state": state,
|
||
"authorization_url": url})
|
||
|
||
def _oauth_clients(self, provider):
|
||
"""回调用的 http/userinfo 客户端:测试注入优先,生产走真实路径。
|
||
|
||
server 属性 oauth_http_client / oauth_userinfo_client 由
|
||
create_server 置为 None(生产)或由测试注入 fake;http_client
|
||
已注入(fake)而 userinfo 未注入时不走真实 userinfo(fake
|
||
token 自带身份字段时不需要它,走真实端点反而会用假 token
|
||
撞真实 GitHub)。生产(两者都未注入)时 userinfo 走真实端点。
|
||
"""
|
||
http_client = getattr(self.server, "oauth_http_client", None)
|
||
userinfo_client = getattr(self.server, "oauth_userinfo_client", None)
|
||
if userinfo_client is None and http_client is None:
|
||
userinfo_client = default_userinfo_client(provider)
|
||
return http_client, userinfo_client
|
||
|
||
def _oauth_finish(self, provider, code, state, client_id, redirect_uri):
|
||
"""回调共用后半段:验 state -> 换 token -> 取身份 -> 分叉。
|
||
|
||
成功返回 6 元组
|
||
``(local_id, created, api_key_plain, rotated, identity, is_bind)``;
|
||
失败抛 OAuthError(调用方按 JSON/HTML 分别呈现,绝不把
|
||
access_token、client_secret、旧 API key 写进任何响应),
|
||
绑定冲突抛 BindError(调用方按 409 呈现)。
|
||
client_id / redirect_uri 为 None 时由 handle_oauth_callback
|
||
从 state 记录取回(真实浏览器回调只带 code + state)。
|
||
|
||
分叉规则(顺序即优先级):
|
||
1. state 带 bind_user_id -> 绑定分支:把身份绑到该本站
|
||
账号,返回 is_bind=True、api_key_plain=None(绑定成功页
|
||
绝不显示任何 key);冲突抛 BindError;
|
||
2. 身份已绑定到某本站账号 -> 登录落到被绑账号,并按
|
||
OAuth 登录规矩签发附加 key(created=False、rotated
|
||
恒为 False),与 oauth 账号登录同一语义,不发明新语义;
|
||
3. 否则走原来的确定性 oauth 账号映射(新建/复用 + 签发
|
||
附加 key)。
|
||
"""
|
||
store = OAuthStateStore(self.server.db_path)
|
||
try:
|
||
http_client, userinfo_client = self._oauth_clients(provider)
|
||
identity = handle_oauth_callback(
|
||
store, provider, code, state, client_id, redirect_uri,
|
||
http_client=http_client, userinfo_client=userinfo_client)
|
||
finally:
|
||
try:
|
||
store.conn.close()
|
||
except Exception:
|
||
pass
|
||
with _auth_store(self.server.db_path) as auth:
|
||
bind_user_id = identity.get("bind_user_id")
|
||
if bind_user_id:
|
||
binding = bind_external_identity(
|
||
auth, provider, identity, bind_user_id)
|
||
auth.audit(bind_user_id, "", "bind_external",
|
||
"%s:%s" % (provider, identity.get("external_id")))
|
||
if _apply_default_quota(auth.conn, bind_user_id):
|
||
auth.audit(bind_user_id, "", "quota_default",
|
||
"quota_usd_micros=%d"
|
||
% _default_quota_usd_micros())
|
||
identity["already_bound"] = binding["already"]
|
||
return (bind_user_id, False, None, False, identity, True)
|
||
bound_user_id = find_bound_user(auth, provider, identity)
|
||
if bound_user_id is not None:
|
||
_key_id, api_key_plain = auth.create_api_key(
|
||
bound_user_id, "oauth:%s" % provider)
|
||
auth.audit(bound_user_id, "", "oauth_login",
|
||
"%s:%s" % (provider, identity.get("external_id")))
|
||
auth.audit(bound_user_id, "", "api_key_issued", provider)
|
||
if _apply_default_quota(auth.conn, bound_user_id):
|
||
auth.audit(bound_user_id, "", "quota_default",
|
||
"quota_usd_micros=%d"
|
||
% _default_quota_usd_micros())
|
||
return (bound_user_id, False, api_key_plain, False, identity,
|
||
False)
|
||
local_id, created, api_key_plain, rotated = \
|
||
map_external_identity_to_local_user_with_key(
|
||
auth, provider, identity)
|
||
auth.audit(local_id, "", "oauth_login",
|
||
"%s:%s" % (provider, identity.get("external_id")))
|
||
if _apply_default_quota(auth.conn, local_id):
|
||
auth.audit(local_id, "", "quota_default",
|
||
"quota_usd_micros=%d"
|
||
% _default_quota_usd_micros())
|
||
if created and _gateway_origin() is not None:
|
||
# sub2api 开通(best-effort):建号不因网关开通失败
|
||
# 而失败,任务时 _ensure_gateway 会懒开通兜底。
|
||
try:
|
||
_ensure_sub2api_key(auth.conn, local_id)
|
||
except Exception as exc:
|
||
auth.audit(local_id, "", "sub2api_provision_failed",
|
||
str(exc)[:200])
|
||
if not created:
|
||
auth.audit(local_id, "", "api_key_issued", provider)
|
||
return local_id, created, api_key_plain, rotated, identity, False
|
||
|
||
def _oauth_callback(self, path):
|
||
provider = self._oauth_provider(path)
|
||
if provider is None:
|
||
self._send_json(404, {"error": "not found"})
|
||
return
|
||
body, err = self._read_json()
|
||
if err:
|
||
self._send_json(400, {"error": err})
|
||
return
|
||
code = body.get("code")
|
||
state = body.get("state")
|
||
client_id = body.get("client_id")
|
||
redirect_uri = body.get("redirect_uri")
|
||
if not code or not state:
|
||
self._send_json(400, {"error": "code and state required"})
|
||
return
|
||
try:
|
||
local_id, created, api_key_plain, rotated, identity, is_bind = \
|
||
self._oauth_finish(provider, code, state, client_id,
|
||
redirect_uri)
|
||
except BindError as e:
|
||
self._send_json(409, {"error": str(e), "reason": e.reason})
|
||
return
|
||
except OAuthError as e:
|
||
self._send_json(400, {"error": str(e), "reason": e.reason})
|
||
return
|
||
if is_bind:
|
||
# 绑定成功:绝不返回任何 key(绑定页的 completer 未必是
|
||
# 发起人,key 一旦上页就可能落到别人手里);发起人用
|
||
# 自己的 key 调 GET /oauth/{provider}/bindings 核对。
|
||
self._send_json(200, {"linked": True, "provider": provider,
|
||
"login": identity.get("login"),
|
||
"external_id": identity.get("external_id"),
|
||
"user_id": local_id,
|
||
"already_bound":
|
||
identity.get("already_bound", False)})
|
||
return
|
||
# api_key 明文只在这里出现一次:调用方须立即保存,
|
||
# 之后任何接口都不再返回它;登录签发的是附加 key,旧 key
|
||
# 仍可继续使用(多 key;不用的旧 key 可在 keys 管理里撤销)。
|
||
self._send_json(200, {"user_id": local_id, "created": created,
|
||
"provider": provider,
|
||
"login": identity.get("login"),
|
||
"email": identity.get("email"),
|
||
"api_key": api_key_plain,
|
||
"api_key_rotated": rotated})
|
||
|
||
def _oauth_error_page(self, reason, message):
|
||
"""GET 回调的 400 错误页:只含 reason 与提示,不含任何密钥、
|
||
token 或旧 API key(message 来自 OAuthError,本身不含密钥,
|
||
仍做 HTML 转义)。"""
|
||
return (
|
||
"<!doctype html><html><head><meta charset='utf-8'>"
|
||
"<title>OAuth login failed</title></head><body>"
|
||
"<h1>OAuth login failed</h1>"
|
||
"<p>reason: %s</p><p>%s</p>"
|
||
"<p>Please restart login from the login page.</p>"
|
||
"</body></html>"
|
||
% (html.escape(str(reason or "error")),
|
||
html.escape(str(message))))
|
||
|
||
def _oauth_success_page(self, provider, local_id, created, login,
|
||
api_key_plain, rotated):
|
||
"""GET 回调的成功页:只显示 login、user_id、created 与新 API key
|
||
一次;绝不显示 access_token、client_secret、旧 API key。
|
||
|
||
rotated 只为向后兼容保留(登录不再轮换,恒为 False):
|
||
新 key 是附加 key,旧 key 仍可继续使用,页面不再写“旧 key
|
||
已失效”,而是说明多 key 与撤销入口。
|
||
"""
|
||
rotation_note = (
|
||
"<p>Your previous API key has been rotated and no longer works.</p>"
|
||
if rotated else
|
||
"<p>Previous keys still work (multi-key). Revoke unused keys "
|
||
"via the API keys management endpoints.</p>"
|
||
if not created else "")
|
||
return (
|
||
"<!doctype html><html><head><meta charset='utf-8'>"
|
||
"<title>OAuth login succeeded</title></head><body>"
|
||
"<h1>OAuth login succeeded</h1>"
|
||
"<p>provider: %s</p>"
|
||
"<p>login: %s</p>"
|
||
"<p>user_id: %s</p>"
|
||
"<p>new user: %s</p>"
|
||
"%s"
|
||
"<p>Your API key (shown only once, save it now):</p>"
|
||
"<p><code>%s</code></p>"
|
||
"<p>Please save it immediately. It will not be shown again "
|
||
"after you leave or refresh this page."
|
||
"</p>"
|
||
"<p>Use it as the X-API-Key header for API calls.</p>"
|
||
"</body></html>"
|
||
% (html.escape(str(provider)),
|
||
html.escape(str(login)),
|
||
html.escape(str(local_id)),
|
||
"yes" if created else "no",
|
||
rotation_note,
|
||
html.escape(str(api_key_plain))))
|
||
|
||
def _oauth_bind_success_page(self, provider, login):
|
||
"""绑定成功页:只显示 provider + 外部 login + 成功提示。
|
||
|
||
绝不显示任何 key,也不显示本站 user_id:回调页的 completer
|
||
未必是绑定发起人,key 与内部 id 一旦上页就可能落到别人
|
||
手里;发起人用自己的 key 调 GET /oauth/{provider}/bindings
|
||
核对绑定。
|
||
"""
|
||
return (
|
||
"<!doctype html><html><head><meta charset='utf-8'>"
|
||
"<title>OAuth binding succeeded</title></head><body>"
|
||
"<h1>OAuth binding succeeded</h1>"
|
||
"<p>provider: %s</p>"
|
||
"<p>login: %s</p>"
|
||
"<p>Your external identity is now bound to your account.</p>"
|
||
"<p>You can verify it from your bindings list.</p>"
|
||
"</body></html>"
|
||
% (html.escape(str(provider)),
|
||
html.escape(str(login))))
|
||
|
||
def _oauth_callback_get(self, path):
|
||
provider = self._oauth_provider(path)
|
||
if provider is None:
|
||
self._send_html(404, self._oauth_error_page(
|
||
"not_found", "unknown oauth path"))
|
||
return
|
||
query = parse_qs(urlparse(self.path).query)
|
||
code = (query.get("code") or [None])[0]
|
||
state = (query.get("state") or [None])[0]
|
||
client_id = (query.get("client_id") or [None])[0]
|
||
redirect_uri = (query.get("redirect_uri") or [None])[0]
|
||
if not code or not state:
|
||
self._send_html(400, self._oauth_error_page(
|
||
"missing_params", "code and state required"))
|
||
return
|
||
try:
|
||
local_id, created, api_key_plain, rotated, identity, is_bind = \
|
||
self._oauth_finish(provider, code, state, client_id,
|
||
redirect_uri)
|
||
except BindError as e:
|
||
self._send_html(409, self._oauth_error_page(
|
||
e.reason or "bind_conflict", str(e)))
|
||
return
|
||
except OAuthError as e:
|
||
self._send_html(400, self._oauth_error_page(
|
||
e.reason or "oauth_failed", str(e)))
|
||
return
|
||
if is_bind:
|
||
self._send_html(200, self._oauth_bind_success_page(
|
||
provider, identity.get("login")))
|
||
return
|
||
self._send_html(200, self._oauth_success_page(
|
||
provider, local_id, created, identity.get("login"),
|
||
api_key_plain, rotated))
|
||
|
||
# -- OAuth 绑定(已有账号绑外部身份)------------------------------
|
||
def _oauth_bind_route(self, path):
|
||
"""绑定相关路径解析:返回 (provider, action) 或 None。
|
||
|
||
POST /oauth/{provider}/bind/initiate -> (provider, "bind_initiate")
|
||
GET|DELETE /oauth/{provider}/bindings -> (provider, "bindings")
|
||
其它 /oauth/ 路径返回 None,走原来的登录/回调分发。
|
||
"""
|
||
parts = [p for p in path.split("/") if p]
|
||
if len(parts) == 4 and parts[0] == "oauth" \
|
||
and parts[2] == "bind" and parts[3] == "initiate":
|
||
return parts[1], "bind_initiate"
|
||
if len(parts) == 3 and parts[0] == "oauth" \
|
||
and parts[2] == "bindings":
|
||
return parts[1], "bindings"
|
||
return None
|
||
|
||
def _oauth_bind_initiate(self, provider):
|
||
"""绑定起点:须持本站 key(与登录不同,绑定必须知道绑到谁)。
|
||
|
||
body 须含 client_id + redirect_uri(与登录同款);state 里
|
||
带 bind_user_id,回调时靠它走绑定分支。只返回授权 URL,
|
||
不直接跳转;跳转由调用方决定。
|
||
"""
|
||
user_id = self._authenticate()
|
||
if user_id is None:
|
||
self._send_json(401, {"error": "unauthorized"})
|
||
return
|
||
body, err = self._read_json()
|
||
if err:
|
||
self._send_json(400, {"error": err})
|
||
return
|
||
client_id = body.get("client_id")
|
||
redirect_uri = body.get("redirect_uri")
|
||
scope = body.get("scope")
|
||
if not client_id or not redirect_uri:
|
||
self._send_json(400, {"error": "client_id and redirect_uri required"})
|
||
return
|
||
store = OAuthStateStore(self.server.db_path)
|
||
try:
|
||
state = store.create_state(
|
||
provider, redirect_uri, client_id=client_id,
|
||
bind_user_id=user_id)
|
||
url = build_authorization_url(
|
||
provider, client_id, redirect_uri, scope=scope, state=state)
|
||
except OAuthError as e:
|
||
self._send_json(400, {"error": str(e), "reason": e.reason})
|
||
return
|
||
except ValueError as e:
|
||
self._send_json(400, {"error": str(e)})
|
||
return
|
||
finally:
|
||
try:
|
||
store.conn.close()
|
||
except Exception:
|
||
pass
|
||
self._send_json(200, {"provider": provider, "state": state,
|
||
"authorization_url": url})
|
||
|
||
def _oauth_bindings_get(self, provider):
|
||
"""列出调用方自己的绑定(只看得到自己的,别人的看不到)。"""
|
||
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:
|
||
bindings = list_bindings(auth, user_id)
|
||
self._send_json(200, {"provider": provider, "bindings": bindings})
|
||
|
||
def _oauth_bindings_delete(self, provider):
|
||
"""解绑调用方自己在该 provider 上的绑定:本来就没有返回 404。
|
||
|
||
解绑后该身份登录恢复原来的 oauth 账号映射(旧 oauth 账号
|
||
若存在,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 = unbind_external_identity(auth, provider, user_id)
|
||
if ok:
|
||
auth.audit(user_id, "", "unbind_external", provider)
|
||
if not ok:
|
||
self._send_json(404, {"error": "no binding for provider"})
|
||
return
|
||
self._send_json(200, {"unbound": True, "provider": provider})
|
||
|
||
# -- GitHub App 一键安装 ------------------------------------------
|
||
def _github_app_public_base(self):
|
||
base = os.environ.get("GITHUB_APP_PUBLIC_BASE") or ""
|
||
return base.rstrip("/") or None
|
||
|
||
def _github_app_error_page(self, reason, message):
|
||
"""App 流程错误页:只含 reason 与提示,不含任何 secret、
|
||
pem、code、state(message 来自流程错误,本身不含密钥,
|
||
仍做 HTML 转义)。"""
|
||
return (
|
||
"<!doctype html><html><head><meta charset='utf-8'>"
|
||
"<title>GitHub App setup failed</title></head><body>"
|
||
"<h1>GitHub App setup failed</h1>"
|
||
"<p>reason: %s</p><p>%s</p>"
|
||
"</body></html>"
|
||
% (html.escape(str(reason or "error")),
|
||
html.escape(str(message))))
|
||
|
||
def _github_app_setup_state(self):
|
||
"""签发 setup state:只限全局管理员(见 _is_global_admin)。
|
||
|
||
P0 安全批前任何持 key 用户都能签发并回调覆盖全局
|
||
App 配置;现在非管理员直接 403 并记审计。要求环境
|
||
变量 GITHUB_APP_PUBLIC_BASE(公网域名,如
|
||
https://api.diante.me):manifest 里的 hook / 回调地址
|
||
都由它拼出。返回 {state, setup_url},setup_url 是用户
|
||
在浏览器里打开的表单页;state 一次性、1 小时过期,
|
||
且绑定签发人。
|
||
"""
|
||
user_id = self._authenticate()
|
||
if user_id is None:
|
||
self._send_json(401, {"error": "unauthorized"})
|
||
return
|
||
if not self._is_global_admin(user_id):
|
||
self._audit_admin(user_id, "github_app_setup_denied",
|
||
"setup state")
|
||
self._send_json(403, {"error": "forbidden"})
|
||
return
|
||
base = self._github_app_public_base()
|
||
if not base:
|
||
self._send_json(400, {
|
||
"error": "GITHUB_APP_PUBLIC_BASE not configured"})
|
||
return
|
||
body, err = self._read_json()
|
||
if err:
|
||
body = {}
|
||
org = (body or {}).get("org")
|
||
store = SetupStateStore(self.server.db_path)
|
||
try:
|
||
state = store.create_state(issued_by=user_id)
|
||
finally:
|
||
try:
|
||
store.conn.close()
|
||
except Exception:
|
||
pass
|
||
setup_url = base + "/github-app/setup?state=" + state
|
||
if org:
|
||
setup_url += "&org=" + quote(str(org))
|
||
self._send_json(200, {"state": state, "setup_url": setup_url})
|
||
|
||
def _github_app_setup_page(self):
|
||
"""建 App 表单页:公开页,但 state 必须是操作方签发的。
|
||
|
||
无 state 或 state 未签发时不渲染表单(state 是回调的
|
||
唯一凭证,公开页自己现发 state 等于谁都能覆盖 App 配置)。
|
||
表单按 GitHub manifest 文档 POST 到 github.com 的建 App
|
||
页(个人账号 /settings/apps/new,组织账号
|
||
/organizations/{org}/settings/apps/new),manifest JSON
|
||
由 JS 填进隐藏字段;用户在 GitHub 页确认 Create 后跳回
|
||
本站 /github-app/callback。
|
||
"""
|
||
query = parse_qs(urlparse(self.path).query)
|
||
state = (query.get("state") or [None])[0]
|
||
org = (query.get("org") or [None])[0]
|
||
if not state:
|
||
self._send_html(400, self._github_app_error_page(
|
||
"missing_state",
|
||
"setup state required: ask the operator to issue one"))
|
||
return
|
||
base = self._github_app_public_base()
|
||
if not base:
|
||
self._send_html(400, self._github_app_error_page(
|
||
"not_configured", "GITHUB_APP_PUBLIC_BASE not configured"))
|
||
return
|
||
manifest = build_app_manifest(
|
||
base,
|
||
app_name=os.environ.get("GITHUB_APP_NAME") or DEFAULT_APP_NAME,
|
||
app_url=os.environ.get("GITHUB_APP_URL") or DEFAULT_APP_URL,
|
||
public=os.environ.get("GITHUB_APP_PUBLIC") == "1")
|
||
manifest_json = json.dumps(manifest).replace("<", "\\u003c")
|
||
if org:
|
||
action = ("https://github.com/organizations/%s"
|
||
"/settings/apps/new?state=%s"
|
||
% (quote(str(org)),
|
||
quote(state)))
|
||
else:
|
||
action = ("https://github.com/settings/apps/new?state=%s"
|
||
% quote(state))
|
||
perms = ", ".join("%s: %s" % kv
|
||
for kv in manifest["default_permissions"].items())
|
||
events = ", ".join(manifest["default_events"])
|
||
page = (
|
||
"<!doctype html><html><head><meta charset='utf-8'>"
|
||
"<title>Create GitHub App</title></head><body>"
|
||
"<h1>Create GitHub App</h1>"
|
||
"<p>App name: %s</p>"
|
||
"<p>Webhook URL: %s</p>"
|
||
"<p>Permissions: %s</p>"
|
||
"<p>Events: %s</p>"
|
||
"<p>Click Submit to create the app on GitHub. GitHub will "
|
||
"redirect back here to finish setup.</p>"
|
||
"<form action=\"%s\" method=\"post\">"
|
||
"<input type=\"hidden\" name=\"manifest\" id=\"manifest\">"
|
||
"<input type=\"submit\" value=\"Create GitHub App\">"
|
||
"</form>"
|
||
"<script>document.getElementById(\"manifest\").value = "
|
||
"JSON.stringify(%s);</script>"
|
||
"</body></html>"
|
||
% (html.escape(manifest["name"]),
|
||
html.escape(manifest["hook_attributes"]["url"]),
|
||
html.escape(perms), html.escape(events),
|
||
html.escape(action), manifest_json))
|
||
self._send_html(200, page)
|
||
|
||
def _github_app_callback(self):
|
||
"""建 App 回调:验 setup state -> 换 code -> 存配置。
|
||
|
||
state 一次性、1 小时过期;换 code 失败或响应缺字段时
|
||
报错误页,不存任何东西。成功页只显示 App 身份(slug、
|
||
id、主页)与安装链接,绝不显示 webhook_secret 与 pem
|
||
(两者只在库里,server 进程内用)。
|
||
P0 安全批后:回调要求签发人仍在 CODEX_GLOBAL_ADMINS
|
||
里,否则 state 不消耗、直接报 invalid_state;覆盖全局
|
||
配置时记审计。诚实边界:回调是浏览器无认证重定向,
|
||
这里核对的是“签发人仍是管理员”,不是“回调者就是
|
||
签发人”(见 SetupStateStore.consume_state 文档)。
|
||
"""
|
||
query = parse_qs(urlparse(self.path).query)
|
||
code = (query.get("code") or [None])[0]
|
||
state = (query.get("state") or [None])[0]
|
||
if not code or not state:
|
||
self._send_html(400, self._github_app_error_page(
|
||
"missing_params", "code and state required"))
|
||
return
|
||
store = SetupStateStore(self.server.db_path)
|
||
try:
|
||
issuer = store.state_issuer(state)
|
||
ok = store.consume_state(
|
||
state, allowed_issuers=_global_admin_ids())
|
||
finally:
|
||
try:
|
||
store.conn.close()
|
||
except Exception:
|
||
pass
|
||
if not ok:
|
||
self._audit_admin(
|
||
issuer or "unknown", "github_app_callback_denied",
|
||
"state issuer not a global admin")
|
||
self._send_html(400, self._github_app_error_page(
|
||
"invalid_state", "setup state invalid or expired"))
|
||
return
|
||
http_client = getattr(self.server, "github_app_exchange_client",
|
||
None)
|
||
try:
|
||
config = exchange_manifest_code(code, http_client=http_client)
|
||
except AppError as e:
|
||
self._send_html(400, self._github_app_error_page(
|
||
e.reason or "exchange_failed", str(e)))
|
||
return
|
||
conn = self._db()
|
||
try:
|
||
save_app_config(conn, config)
|
||
finally:
|
||
conn.close()
|
||
self._audit_admin(
|
||
issuer or "unknown", "github_app_config_replaced",
|
||
"slug=%s app_id=%s" % (config.get("slug"),
|
||
config.get("app_id")))
|
||
slug = config.get("slug") or ""
|
||
page = (
|
||
"<!doctype html><html><head><meta charset='utf-8'>"
|
||
"<title>GitHub App created</title></head><body>"
|
||
"<h1>GitHub App created</h1>"
|
||
"<p>App: %s</p>"
|
||
"<p>App ID: %s</p>"
|
||
"<p>Install it on your repositories:</p>"
|
||
"<p><a href=\"https://github.com/apps/%s/installations/new\">"
|
||
"Install this GitHub App</a></p>"
|
||
"<p>After installing, webhooks from the app are verified "
|
||
"automatically. API actions (push, PR) still use the "
|
||
"existing PAT until the App-token phase; you may remove "
|
||
"the manual webhook once the app is installed.</p>"
|
||
"</body></html>"
|
||
% (html.escape(slug),
|
||
html.escape(str(config.get("app_id"))),
|
||
html.escape(slug)))
|
||
self._send_html(200, page)
|
||
|
||
def _github_app_status(self):
|
||
"""App 配置与安装状态:须持本站 key;绝不返回 secret/pem。"""
|
||
user_id = self._authenticate()
|
||
if user_id is None:
|
||
self._send_json(401, {"error": "unauthorized"})
|
||
return
|
||
conn = self._db()
|
||
try:
|
||
status = public_app_status(conn)
|
||
status["installations"] = list_installations(conn)
|
||
finally:
|
||
conn.close()
|
||
self._send_json(200, status)
|
||
|
||
# -- 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})
|
||
|
||
# -- 推送订阅与分发 ---------------------------------------------------
|
||
def _push_create_endpoint(self, user_id, project_id):
|
||
body, err = self._read_json()
|
||
if err:
|
||
self._send_json(400, {"error": err})
|
||
return
|
||
try:
|
||
sub = _push_create(
|
||
self.server.db_path, project_id, user_id,
|
||
body.get("channel"), body.get("target"),
|
||
body.get("preference") or "instant")
|
||
except ValueError as e:
|
||
self._send_json(400, {"error": str(e)})
|
||
return
|
||
# 审计只记订阅 id 与通道:target 全文不记(Slack webhook
|
||
# URL 本身含 secret 路径,记进审计等于把 secret 落盘)。
|
||
with _auth_store(self.server.db_path) as store:
|
||
store.audit(user_id, project_id, "push_subscription_created",
|
||
"subscription_id=%s channel=%s"
|
||
% (sub["id"], sub["channel"]))
|
||
self._send_json(201, sub)
|
||
|
||
def _push_dispatch_endpoint(self, user_id):
|
||
body, err = self._read_json()
|
||
if err:
|
||
self._send_json(400, {"error": err})
|
||
return
|
||
event_type = body.get("event_type")
|
||
project_id = body.get("project_id")
|
||
if not event_type or not project_id:
|
||
self._send_json(400, {"error": "event_type and project_id required"})
|
||
return
|
||
# P0 安全批后:/admin/push/dispatch 是管理端点,只认
|
||
# CODEX_GLOBAL_ADMINS——该口径已强于 P1 要求的 create_task,
|
||
# 不再降级。被拒与成功都记审计;目标 SSRF 校验在
|
||
# push.dispatch_event / 真实发送层做。
|
||
if not self._is_global_admin(user_id):
|
||
self._audit_admin(user_id, "admin_push_denied",
|
||
"dispatch %s %s" % (event_type, project_id))
|
||
self._send_json(403, {"error": "forbidden"})
|
||
return
|
||
# 分发:sender=None 时用 push 的内置真实发送端
|
||
# (webhook/slack 真 POST,webpush/apns/fcm 仍报缺凭证);
|
||
# 显式注入的 sender 优先于内置端(见 push.dispatch_event)。
|
||
result = _push_dispatch(
|
||
self.server.db_path, event_type, project_id,
|
||
body.get("payload") or {}, sender=None)
|
||
self._audit_admin(user_id, "admin_push_dispatch",
|
||
"dispatch %s %s" % (event_type, project_id))
|
||
self._send_json(200, result)
|
||
|
||
# -- webhooks(签名认证,不走 X-API-Key)---------------------------
|
||
# -- 自动化与分诊收件箱(P1 第 2 项)---------------------------
|
||
# 权限口径与其它项目级写操作同款:创建/取消用 create_task
|
||
# (不是 manage_project,因为自动化是项目成员的日常编排,
|
||
# 不是改项目归属/仓库这类 owner 专属操作;评审/审批等写
|
||
# 操作同样用 create_task);读用 read。状态变更记审计。
|
||
# 失败口径:校验失败 400;模块/数据库层意外失败时回 502
|
||
# 透传错误原文(截断 500 字),绝不回 201 假装建成功——
|
||
# 注意 _dispatch_safe 的兜底是 500,这里显式 502 优先于它。
|
||
def _automation_create(self, user_id, project_id):
|
||
"""创建自动化定义:只登记定义与到期状态,不跑模型、
|
||
不另起调度器(执行器后续走 claim 门,见 automations
|
||
模块文档)。body:name、prompt 必填;interval_seconds
|
||
可选(默认 86400,最小 60);repo_url 可选。
|
||
|
||
P1 配额门:创建时先过与建任务同一道配额门(美元配额
|
||
与无价 token cap,超限 402),避免“定义免费建、到期
|
||
无限跑”;单自动化的 spend 上限即创建人配额本身——
|
||
每次到期产出的任务以 created_by=创建人建任务,走
|
||
POST /tasks 的配额门(触发路径若绕过服务端建任务,
|
||
则不在本门覆盖内,见残留)。本服务端没有自动化触发
|
||
端点,触发侧的门不在本次 server.py 改动内。"""
|
||
body, err = self._read_json()
|
||
if err:
|
||
self._send_json(400, {"error": err})
|
||
return
|
||
if self._reject_if_over_quota(user_id):
|
||
return
|
||
interval = body.get("interval_seconds", 86400)
|
||
if interval is None:
|
||
interval = 86400
|
||
conn = self._db()
|
||
try:
|
||
try:
|
||
auto = _auto_create(
|
||
conn, project_id, body.get("name"),
|
||
body.get("prompt"), user_id, interval,
|
||
body.get("repo_url"))
|
||
except ValueError as exc:
|
||
self._send_json(400, {"error": str(exc)})
|
||
return
|
||
except Exception as exc:
|
||
self._send_json(502, {"error": str(exc)[:500]})
|
||
return
|
||
finally:
|
||
conn.close()
|
||
with _auth_store(self.server.db_path) as store:
|
||
store.audit(user_id, project_id, "automation_created",
|
||
auto["id"])
|
||
self._send_json(201, auto)
|
||
|
||
def _automation_list(self, project_id):
|
||
"""列出某项目全部自动化定义(含已取消),附到期计数。"""
|
||
conn = self._db()
|
||
try:
|
||
try:
|
||
autos = _auto_list(conn, project_id)
|
||
except Exception as exc:
|
||
self._send_json(502, {"error": str(exc)[:500]})
|
||
return
|
||
conn.commit()
|
||
finally:
|
||
conn.close()
|
||
self._send_json(200, {
|
||
"project_id": project_id,
|
||
"automations": autos,
|
||
"due_count": sum(1 for a in autos if a["due"]),
|
||
})
|
||
|
||
def _automation_cancel(self, user_id, project_id, automation_id):
|
||
"""取消自动化(软删除):行不存在或属于别的项目时一
|
||
律 404(不泄露跨项目存在性);已取消时 409。"""
|
||
conn = self._db()
|
||
try:
|
||
try:
|
||
ok, auto = _auto_cancel(conn, automation_id)
|
||
except Exception as exc:
|
||
self._send_json(502, {"error": str(exc)[:500]})
|
||
return
|
||
finally:
|
||
conn.close()
|
||
if auto is None or auto["project_id"] != project_id:
|
||
self._send_json(404, {"error": "automation not found"})
|
||
return
|
||
if not ok:
|
||
self._send_json(409, {
|
||
"error": "automation already cancelled"})
|
||
return
|
||
with _auth_store(self.server.db_path) as store:
|
||
store.audit(user_id, project_id, "automation_cancelled",
|
||
automation_id)
|
||
self._send_json(200, auto)
|
||
|
||
def _automation_get(self, user_id, project_id, automation_id):
|
||
"""GET 单条自动化(轮询用):定义 + 最近产出记录。
|
||
行不存在或属于别的项目时一律 404;产出记录读失败
|
||
时不让整条定义也 502(runs 回空列表 + note)。"""
|
||
conn = self._db()
|
||
try:
|
||
auto = _auto_get(conn, automation_id)
|
||
runs = []
|
||
runs_note = ""
|
||
if auto is not None:
|
||
try:
|
||
runs = _auto_runs(conn, automation_id)
|
||
except Exception:
|
||
runs_note = "runs unavailable"
|
||
finally:
|
||
conn.close()
|
||
if auto is None or auto["project_id"] != project_id:
|
||
self._send_json(404, {"error": "automation not found"})
|
||
return
|
||
conn = self._db()
|
||
try:
|
||
binding = _life_get_binding(conn, automation_id)
|
||
finally:
|
||
conn.close()
|
||
self._send_json(200, dict(auto, runs=runs,
|
||
runs_note=runs_note,
|
||
heartbeat_binding=binding))
|
||
|
||
def _automation_bind_get(self, user_id, project_id, automation_id):
|
||
conn = self._db()
|
||
try:
|
||
auto = _auto_get(conn, automation_id)
|
||
binding = _life_get_binding(conn, automation_id)
|
||
finally:
|
||
conn.close()
|
||
if auto is None or auto["project_id"] != project_id:
|
||
self._send_json(404, {"error": "automation not found"})
|
||
return
|
||
self._send_json(200, {"automation_id": automation_id,
|
||
"binding": binding})
|
||
|
||
def _automation_bind(self, user_id, project_id, automation_id):
|
||
"""POST .../automations/{aid}/bind {task_id, mode}:
|
||
把自动化绑到已有线程。mode=heartbeat 表示到期唤醒
|
||
该线程(走 thread/message 投递路);mode=cron 表示
|
||
到期独立跑新任务(绑定只作标记,不唤醒)。目标任务
|
||
必须在同一项目,否则 404(不泄露跨项目存在性)。
|
||
权限 create_task;记 automation_bound 审计。"""
|
||
body, jerr = self._read_json()
|
||
if jerr:
|
||
self._send_json(400, {"error": jerr})
|
||
return
|
||
conn = self._db()
|
||
try:
|
||
auto = _auto_get(conn, automation_id)
|
||
if auto is None or auto["project_id"] != project_id:
|
||
self._send_json(404, {"error": "automation not found"})
|
||
return
|
||
if auto["status"] != "active":
|
||
self._send_json(409, {
|
||
"error": "cancelled automation cannot be bound"})
|
||
return
|
||
target_id = body.get("task_id")
|
||
target = self._fetch_task(target_id) \
|
||
if isinstance(target_id, str) else None
|
||
if target is None:
|
||
self._send_json(404, {"error": "task not found"})
|
||
return
|
||
target = _task_to_dict(target)
|
||
if target["project_id"] != project_id:
|
||
self._send_json(404, {"error": "task not found"})
|
||
return
|
||
try:
|
||
binding = _life_bind(
|
||
conn, automation_id, project_id, target_id,
|
||
body.get("mode", "heartbeat"))
|
||
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, project_id, "automation_bound",
|
||
"%s:%s" % (automation_id, target_id))
|
||
self._send_json(200, {"automation_id": automation_id,
|
||
"binding": binding})
|
||
|
||
def _automation_unbind(self, user_id, project_id, automation_id):
|
||
conn = self._db()
|
||
try:
|
||
auto = _auto_get(conn, automation_id)
|
||
if auto is None or auto["project_id"] != project_id:
|
||
self._send_json(404, {"error": "automation not found"})
|
||
return
|
||
ok = _life_unbind(conn, automation_id)
|
||
finally:
|
||
conn.close()
|
||
if not ok:
|
||
self._send_json(404, {"error": "no binding"})
|
||
return
|
||
with _auth_store(self.server.db_path) as store:
|
||
store.audit(user_id, project_id, "automation_unbound",
|
||
automation_id)
|
||
self._send_json(200, {"automation_id": automation_id,
|
||
"deleted": True})
|
||
|
||
def _automation_heartbeat(self, user_id, project_id, automation_id):
|
||
"""POST .../automations/{aid}/heartbeat {message?}:
|
||
手动触发一次心跳唤醒——到期触发器(host run-due /
|
||
run_due_automation)调的就是这个口,服务端本身不
|
||
起定时器。无绑定 409;mode=cron 409(cron 不唤醒
|
||
线程);message 缺省用自动化 prompt。投递走
|
||
_deliver_to_thread,与 thread/message 同款复活路,
|
||
配额门同款。记 automation_heartbeat_sent 审计。"""
|
||
body, jerr = self._read_json()
|
||
if jerr:
|
||
self._send_json(400, {"error": jerr})
|
||
return
|
||
conn = self._db()
|
||
try:
|
||
auto = _auto_get(conn, automation_id)
|
||
binding = _life_get_binding(conn, automation_id)
|
||
finally:
|
||
conn.close()
|
||
if auto is None or auto["project_id"] != project_id:
|
||
self._send_json(404, {"error": "automation not found"})
|
||
return
|
||
if binding is None:
|
||
self._send_json(409, {
|
||
"error": "automation has no heartbeat binding"})
|
||
return
|
||
if binding["mode"] != "heartbeat":
|
||
self._send_json(409, {
|
||
"error": "cron binding does not wake a thread;"
|
||
" use the automation run path"})
|
||
return
|
||
target_row = self._fetch_task(binding["task_id"])
|
||
if target_row is None:
|
||
self._send_json(404, {"error": "bound task not found"})
|
||
return
|
||
target = _task_to_dict(target_row)
|
||
if target["project_id"] != project_id:
|
||
self._send_json(404, {"error": "bound task not found"})
|
||
return
|
||
if self._reject_if_over_quota_for_task(user_id, target):
|
||
return
|
||
message = body.get("message") or auto.get("prompt") or ""
|
||
if 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
|
||
code, payload = self._deliver_to_thread(
|
||
user_id, target, message, "queued",
|
||
audit_action="automation_heartbeat_sent")
|
||
self._send_json(code, dict(payload,
|
||
automation_id=automation_id))
|
||
|
||
def _automation_update(self, user_id, project_id, automation_id):
|
||
"""PATCH 单条自动化(automation_update):改名、改
|
||
prompt、改间隔、改 repo_url,至少给一个字段。注意
|
||
语义边界:更新不重置 last_run_at(改间隔只影响下次
|
||
到期,不立即触发);已取消的定义 409(取消是终态,
|
||
改它必须重建);改名撞同项目 active 名时 400;本
|
||
端点不改“变化时通知”本身——到期产出的任务终态仍
|
||
走 worker 现有 push 派发(见 automations 模块文档)。
|
||
权限 create_task;记 automation_updated 审计。"""
|
||
body, jerr = self._read_json()
|
||
if jerr:
|
||
self._send_json(400, {"error": jerr})
|
||
return
|
||
conn = self._db()
|
||
try:
|
||
try:
|
||
ok, auto = _auto_update(conn, automation_id, body)
|
||
except ValueError as exc:
|
||
self._send_json(400, {"error": str(exc)})
|
||
return
|
||
except Exception as exc:
|
||
self._send_json(502, {"error": str(exc)[:500]})
|
||
return
|
||
finally:
|
||
conn.close()
|
||
if auto is None or auto["project_id"] != project_id:
|
||
self._send_json(404, {"error": "automation not found"})
|
||
return
|
||
if not ok:
|
||
self._send_json(409, {
|
||
"error": "automation already cancelled"})
|
||
return
|
||
with _auth_store(self.server.db_path) as store:
|
||
store.audit(user_id, project_id, "automation_updated",
|
||
automation_id)
|
||
self._send_json(200, auto)
|
||
|
||
def _triage_inbox(self, project_id):
|
||
"""分诊收件箱(只读):到期自动化、失败/中断任务、
|
||
待处理后续消息、最近自动化产出。见 automations 模块
|
||
triage_inbox 的字段说明。"""
|
||
conn = self._db()
|
||
try:
|
||
try:
|
||
_auto_ensure(conn)
|
||
_ensure_followups_table(conn)
|
||
inbox = _auto_triage(conn, project_id)
|
||
except Exception as exc:
|
||
self._send_json(502, {"error": str(exc)[:500]})
|
||
return
|
||
conn.commit()
|
||
finally:
|
||
conn.close()
|
||
self._send_json(200, inbox)
|
||
|
||
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。
|
||
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)
|
||
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
|
||
# 与指令注入面无限放大。
|
||
prompt = (
|
||
"[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]))
|
||
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
|
||
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)
|
||
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()
|