feat: project directory tree + first-round snapshot

- cloud/tree.py: structured worktree tree (dirs first, ignore
  .git/node_modules, symlinks not followed, entry/depth
  truncation flags, text render for injection)
- GET /tasks/{id}/tree with /files auth + worktree resolution,
  max_depth 1..5, max_entries 1..500, escape 400, missing 404,
  cloud/cross-host 409
- MCP get_task_tree translating to the new endpoint
- worker first-round injection of a repo-checkout top-level
  snapshot (2 layers, 120 entries) after repo resolution,
  labelled as checkout snapshot not worktree; skipped for
  coordinator/resume/fork, silent when no host source
- docs/project-tree.md; tests for tree, endpoint, injection,
  MCP translation
This commit is contained in:
host committed 2026-10-30 14:46:11 +08:00
1 parent 85f280e5e7
commit ce7fb3afd6
9 files changed
+865

No files matched your search

+60
View File
@@ -74,6 +74,9 @@ 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.tree import DEFAULT_MAX_DEPTH as _TREE_DEFAULT_DEPTH
from cloud.tree import DEFAULT_MAX_ENTRIES as _TREE_DEFAULT_ENTRIES
from cloud.tree import build_tree as _build_tree
from cloud.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
@@ -2316,6 +2319,8 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler):
self._task_timeline(user_id, rest[: -len("/timeline")])
elif rest.endswith("/files") and method == "GET":
self._task_files(user_id, rest[: -len("/files")], query)
elif rest.endswith("/tree") and method == "GET":
self._task_tree(user_id, rest[: -len("/tree")], query)
elif rest.endswith("/browser/state") and method == "GET":
self._browser_state(user_id,
rest[: -len("/browser/state")])
@@ -5214,6 +5219,61 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler):
self._send_json(200, {"task_id": task_id, "path": rel,
"size": size, "content": content})
def _task_tree(self, user_id, task_id, query):
"""GET /tasks/{id}/tree:结构化目录树(不读文件内容)。
与 /files 同款权限与 worktree 解析:viewer+ 可读,
cloud 任务与跨机 worktree 一律 409,解析不到时 404。
?path=<相对路径>:不传列 worktree 根;传目录列该目录。
?max_depth:1..5,缺省 3;?max_entries:1..500,缺省
200。非法整数一律 400,不静默回落缺省。路径逃逸
400,不存在或非目录 404。条数到顶时 truncated=True,
被 max_depth 截断的目录 children=[] 且
children_truncated=True,空目录则是 children=[] 且
children_truncated=False,两者可区分。
"""
task, err = self._thread_task(user_id, task_id, "read")
if err is not None:
self._send_json(err[0], {"error": err[1]})
return
worktree, werr = _task_worktree_for_files(self.server, task)
if werr is not None:
self._send_json(werr[0], {"error": werr[1]})
return
try:
max_depth = int((query.get("max_depth")
or [str(_TREE_DEFAULT_DEPTH)])[0])
max_entries = int((query.get("max_entries")
or [str(_TREE_DEFAULT_ENTRIES)])[0])
except (TypeError, ValueError):
self._send_json(400, {"error":
"max_depth/max_entries must be integers"})
return
if not 1 <= max_depth <= 5 or not 1 <= max_entries <= 500:
self._send_json(400, {"error":
"max_depth must be 1..5 and "
"max_entries must be 1..500"})
return
rel = (query.get("path") or [""])[0]
try:
tree = _build_tree(worktree, path=rel,
max_depth=max_depth,
max_entries=max_entries)
except ValueError:
self._send_json(400, {"error": "path escapes task worktree"})
return
except FileNotFoundError:
self._send_json(404, {"error": "no such path"})
return
except NotADirectoryError:
self._send_json(404, {"error": "path is not a directory"})
return
tree = dict(tree)
tree["task_id"] = task_id
tree["max_depth"] = max_depth
tree["max_entries"] = max_entries
self._send_json(200, tree)
def _browser_worktree_for(self, user_id, task_id):
"""Auth + worktree resolution shared by browser endpoints.