feat: 线程操作与 skills/MCP 管理面暴露(host CLI + 服务端 API + 控制台)
线程:host.py 新增 thread-list/read/rename/archive/unarchive/compact 子命令(纯 JSON stdout 契约,read/rename/unarchive/compact 先 resume);服务端按任务定 scope(thread/list 的 cwd 过滤解析,Docker/cloud/异机 409,改操作仅终态,审计 thread_*);控制台线程区(元数据、列表、重命名、归档、取消归档、压缩,终态门控)。 skills/MCP:host.py 新增 skills-list/apps-list/mcp-servers-list/mcp-servers-reload/mcp-server-oauth-login 子命令;服务端按项目 scope(读 read,改 manage_project,审计 mcp_*);控制台技能与 MCP 区(列表、重载、OAuth 发起展示,绝不谎称登录完成)。 测试:host 84、cloud 687(4 跳过)、deploy 19 全过。诚实边界:Docker 任务线程在容器内故 409;无仓库任务推不出 cwd 时 404;OAuth 只做到发起透传,完成态靠协议通知;merged/merge-failed 改操作 409(比控制台门控窄,服务端报错原文展示)。
This commit is contained in:
1 parent
154c18e25d
commit
ef532a38c2
7 files changed
+2128
-2
No files matched your search
+657
@@ -46,6 +46,8 @@ import re
|
||||
import shutil
|
||||
import socket
|
||||
import sqlite3
|
||||
import subprocess
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
import uuid
|
||||
@@ -165,6 +167,12 @@ _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
|
||||
@@ -254,6 +262,211 @@ def _trace_dirs_for_task(server, task):
|
||||
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):
|
||||
"""调 host.py CLI 子命令,返回 (ok, result_or_error)。
|
||||
|
||||
ok 为 True 时 result 是 stdout 解析出的 JSON 对象;ok 为
|
||||
False 时 result 是可直接放进 502 error 字段的错误文本
|
||||
(CLI 自带的 error 原样透传,解析失败/缺文件/超时则给
|
||||
明确的服务端错误,绝不把 stderr 或原始 key 混进响应)。
|
||||
"""
|
||||
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]
|
||||
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 _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):
|
||||
"""cwd 过滤返回多个 thread 时的取用规则:取最新一个非
|
||||
archived。协议说明:Thread 对象本身没有 archived 字段,
|
||||
非归档由 thread/list 的 archived 过滤保证(不传 archived
|
||||
时只回非归档 thread);这里只防御性地跳过显式带
|
||||
archived 真值的条目(CLI 形状若补了该字段时仍正确)。
|
||||
最新 = updatedAt 最大,缺失时退 createdAt,再缺失时按
|
||||
列表原顺序取后者(同秒时后到的更新)。"""
|
||||
best = None
|
||||
best_key = None
|
||||
for idx, item in enumerate(threads):
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
if item.get("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 去重,后到的
|
||||
完成态覆盖先到的开始态,保首次顺序。文件不存在返回空列表。"""
|
||||
@@ -1649,6 +1862,22 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler):
|
||||
_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("/fork") and method == "POST":
|
||||
self._fork_task(user_id, rest[: -len("/fork")])
|
||||
elif rest.endswith("/approvals") and method == "GET":
|
||||
@@ -2438,6 +2667,69 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler):
|
||||
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):
|
||||
@@ -3694,10 +3986,375 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler):
|
||||
"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):
|
||||
"""对任务调 thread-list:返回 (threads, codex_home, cwd,
|
||||
err),err 为 (status, msg) 或 None。cwd 过滤用任务
|
||||
worktree(见 _thread_worktree_for_task);codex-home
|
||||
用租户 home(见 _tenant_codex_home)。"""
|
||||
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")
|
||||
cwd = _thread_worktree_for_task(self.server, task)
|
||||
ok, result = _run_host_cli(["thread-list", "--cwd", cwd],
|
||||
codex_home)
|
||||
if not ok:
|
||||
return None, None, None, (502, result)
|
||||
threads = _extract_thread_list(result)
|
||||
if threads is None:
|
||||
return None, None, None, (
|
||||
502, "host.py thread-list output has no thread list")
|
||||
return threads, codex_home, cwd, None
|
||||
|
||||
def _resolve_task_thread(self, task):
|
||||
"""单线程端点的解析:thread-list 后按 _pick_latest_thread
|
||||
取最新一个非 archived。返回 (thread, codex_home, cwd,
|
||||
err);零个 thread 时 err 为 (404, "thread not found")。"""
|
||||
threads, codex_home, cwd, err = self._list_task_threads(task)
|
||||
if err is not None:
|
||||
return None, None, None, err
|
||||
thread = _pick_latest_thread(threads)
|
||||
if thread is None:
|
||||
return None, None, None, (404, "thread not found")
|
||||
return thread, codex_home, cwd, None
|
||||
|
||||
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 = 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 = 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)
|
||||
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):
|
||||
"""线程改操作的公共流程:权限用项目 create_task;只允许
|
||||
终态任务(completed/failed/interrupted),running/queued
|
||||
一律 409;解析 thread 后调对应 CLI,成功记审计。"""
|
||||
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 = self._resolve_task_thread(task)
|
||||
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)
|
||||
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 审计。"""
|
||||
self._task_thread_mutate(user_id, task_id, ["thread-unarchive"],
|
||||
"thread_unarchived", "%s unarchived")
|
||||
|
||||
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)
|
||||
|
||||
def _fork_task(self, user_id, task_id):
|
||||
"""会话分支:从已结束任务的 thread 分叉出新任务。
|
||||
|
||||
|
||||
新任务继承源任务的 executor/模型/强度/权限/计划模式,
|
||||
不继承 goal(分支是普通任务,首轮 prompt 就是这里传的
|
||||
message)。worker 认领后先调 host.py fork(分叉 thread
|
||||
|
||||
Reference in new issue
Block a user