crabcode 0.1.6__py3-none-win_amd64.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- crabcode-0.1.6.dist-info/METADATA +63 -0
- crabcode-0.1.6.dist-info/RECORD +205 -0
- crabcode-0.1.6.dist-info/WHEEL +4 -0
- crabcode-0.1.6.dist-info/entry_points.txt +2 -0
- crabcode_cli/__init__.py +7 -0
- crabcode_cli/__main__.py +4 -0
- crabcode_cli/_native/crabcode-computer-use-host.exe +0 -0
- crabcode_cli/_native/runtime.json +6 -0
- crabcode_cli/app.py +776 -0
- crabcode_cli/banner.py +39 -0
- crabcode_cli/computer_use.py +286 -0
- crabcode_cli/model_manager.py +649 -0
- crabcode_cli/model_picker.py +168 -0
- crabcode_cli/pipe.py +148 -0
- crabcode_cli/render/__init__.py +0 -0
- crabcode_cli/render/messages.py +19 -0
- crabcode_cli/repl.py +4841 -0
- crabcode_cli/session_picker.py +239 -0
- crabcode_core/__init__.py +7 -0
- crabcode_core/_version.py +3 -0
- crabcode_core/_windows_process.py +68 -0
- crabcode_core/agent_manager.py +1731 -0
- crabcode_core/api/__init__.py +20 -0
- crabcode_core/api/anthropic_adapter.py +648 -0
- crabcode_core/api/azure_adapter.py +69 -0
- crabcode_core/api/base.py +233 -0
- crabcode_core/api/codex_adapter.py +1327 -0
- crabcode_core/api/error_logging.py +132 -0
- crabcode_core/api/gemini_adapter.py +26 -0
- crabcode_core/api/model_info.py +257 -0
- crabcode_core/api/network.py +62 -0
- crabcode_core/api/ollama_adapter.py +27 -0
- crabcode_core/api/openai_adapter.py +323 -0
- crabcode_core/api/registry.py +58 -0
- crabcode_core/command_line.py +18 -0
- crabcode_core/compact/__init__.py +17 -0
- crabcode_core/compact/compact.py +514 -0
- crabcode_core/compact/context_tokens.py +165 -0
- crabcode_core/computer_use_mode.py +38 -0
- crabcode_core/computer_use_observation.py +144 -0
- crabcode_core/config/__init__.py +3 -0
- crabcode_core/config/claudemd.py +61 -0
- crabcode_core/config/manager.py +237 -0
- crabcode_core/config/model_catalog.py +256 -0
- crabcode_core/events.py +4896 -0
- crabcode_core/file_lock.py +81 -0
- crabcode_core/filesystem.py +22 -0
- crabcode_core/goal.py +149 -0
- crabcode_core/hooks/__init__.py +5 -0
- crabcode_core/hooks/manager.py +290 -0
- crabcode_core/image_io.py +111 -0
- crabcode_core/io_worker.py +211 -0
- crabcode_core/logging_utils.py +168 -0
- crabcode_core/lsp/__init__.py +6 -0
- crabcode_core/lsp/client.py +786 -0
- crabcode_core/lsp/diagnostics.py +150 -0
- crabcode_core/lsp/manager.py +583 -0
- crabcode_core/mcp/__init__.py +4 -0
- crabcode_core/mcp/client.py +169 -0
- crabcode_core/mcp/config.py +57 -0
- crabcode_core/path_validation.py +34 -0
- crabcode_core/paths.py +24 -0
- crabcode_core/peer/__init__.py +10 -0
- crabcode_core/peer/runtime.py +518 -0
- crabcode_core/permissions/__init__.py +7 -0
- crabcode_core/permissions/ai_reviewer.py +184 -0
- crabcode_core/permissions/manager.py +187 -0
- crabcode_core/plan/__init__.py +5 -0
- crabcode_core/plan/executor.py +270 -0
- crabcode_core/plan/types.py +169 -0
- crabcode_core/prompts/__init__.py +0 -0
- crabcode_core/prompts/blocks.py +10 -0
- crabcode_core/prompts/context.py +229 -0
- crabcode_core/prompts/library.py +92 -0
- crabcode_core/prompts/profile.py +79 -0
- crabcode_core/prompts/system.py +356 -0
- crabcode_core/prompts/templates.py +104 -0
- crabcode_core/query/__init__.py +0 -0
- crabcode_core/query/computer_use_history.py +83 -0
- crabcode_core/query/loop.py +2446 -0
- crabcode_core/query/retry.py +116 -0
- crabcode_core/schedule/__init__.py +13 -0
- crabcode_core/schedule/manager.py +631 -0
- crabcode_core/schedule/models.py +204 -0
- crabcode_core/schedule/store.py +558 -0
- crabcode_core/schedule/timing.py +287 -0
- crabcode_core/session/__init__.py +12 -0
- crabcode_core/session/export.py +141 -0
- crabcode_core/session/meta_db.py +684 -0
- crabcode_core/session/storage.py +2032 -0
- crabcode_core/session/title_gen.py +52 -0
- crabcode_core/skills/__init__.py +6 -0
- crabcode_core/skills/context.py +23 -0
- crabcode_core/skills/loader.py +214 -0
- crabcode_core/skills/matcher.py +203 -0
- crabcode_core/snapshot/__init__.py +21 -0
- crabcode_core/snapshot/snapshot.py +436 -0
- crabcode_core/snapshot/tracker.py +359 -0
- crabcode_core/subprocess_utils.py +260 -0
- crabcode_core/team/__init__.py +27 -0
- crabcode_core/team/bridge.py +66 -0
- crabcode_core/team/inbox.py +237 -0
- crabcode_core/team/manager.py +1660 -0
- crabcode_core/team/message_bus.py +821 -0
- crabcode_core/team/models.py +191 -0
- crabcode_core/team/recovery.py +192 -0
- crabcode_core/text_io.py +52 -0
- crabcode_core/tools/__init__.py +112 -0
- crabcode_core/tools/_input_helpers.py +39 -0
- crabcode_core/tools/_input_schema.py +130 -0
- crabcode_core/tools/agent.py +413 -0
- crabcode_core/tools/apply_patch.py +587 -0
- crabcode_core/tools/ask_user.py +122 -0
- crabcode_core/tools/bash.py +154 -0
- crabcode_core/tools/browser.py +678 -0
- crabcode_core/tools/checklist.py +241 -0
- crabcode_core/tools/checkpoint.py +78 -0
- crabcode_core/tools/computer_use.py +599 -0
- crabcode_core/tools/diff_utils.py +123 -0
- crabcode_core/tools/file_edit.py +240 -0
- crabcode_core/tools/file_read.py +187 -0
- crabcode_core/tools/file_write.py +154 -0
- crabcode_core/tools/glob.py +123 -0
- crabcode_core/tools/goal.py +134 -0
- crabcode_core/tools/grep.py +247 -0
- crabcode_core/tools/image.py +173 -0
- crabcode_core/tools/image_generate.py +115 -0
- crabcode_core/tools/lint.py +388 -0
- crabcode_core/tools/loading.py +187 -0
- crabcode_core/tools/memory.py +456 -0
- crabcode_core/tools/monitor.py +677 -0
- crabcode_core/tools/peer.py +103 -0
- crabcode_core/tools/revert.py +90 -0
- crabcode_core/tools/schedule.py +683 -0
- crabcode_core/tools/skill.py +108 -0
- crabcode_core/tools/switch_mode.py +213 -0
- crabcode_core/tools/team.py +329 -0
- crabcode_core/tools/tool_search.py +64 -0
- crabcode_core/tools/web_search.py +444 -0
- crabcode_core/turn_details.py +64 -0
- crabcode_core/types/__init__.py +95 -0
- crabcode_core/types/config.py +434 -0
- crabcode_core/types/event.py +334 -0
- crabcode_core/types/message.py +263 -0
- crabcode_core/types/tool.py +208 -0
- crabcode_core/usage.py +289 -0
- crabcode_core/utf8_sanitize.py +29 -0
- crabcode_debugger/__init__.py +5 -0
- crabcode_debugger/adapters.py +332 -0
- crabcode_debugger/dap.py +304 -0
- crabcode_debugger/memory.py +1806 -0
- crabcode_debugger/process.py +707 -0
- crabcode_debugger/runtime.py +58 -0
- crabcode_debugger/sessions.py +387 -0
- crabcode_debugger/tool.py +731 -0
- crabcode_gateway/__init__.py +18 -0
- crabcode_gateway/acp/__init__.py +13 -0
- crabcode_gateway/acp/agent.py +1581 -0
- crabcode_gateway/acp/session.py +114 -0
- crabcode_gateway/acp/transport.py +39 -0
- crabcode_gateway/acp/types.py +90 -0
- crabcode_gateway/adapter.py +30 -0
- crabcode_gateway/auth.py +139 -0
- crabcode_gateway/computer_use.py +364 -0
- crabcode_gateway/document_engine.py +637 -0
- crabcode_gateway/document_worker.py +301 -0
- crabcode_gateway/event_bus.py +655 -0
- crabcode_gateway/grpc/__init__.py +0 -0
- crabcode_gateway/grpc/crabcode_pb2.py +70 -0
- crabcode_gateway/grpc/crabcode_pb2_grpc.py +474 -0
- crabcode_gateway/grpc/proto/crabcode.proto +160 -0
- crabcode_gateway/grpc/server.py +765 -0
- crabcode_gateway/middleware.py +344 -0
- crabcode_gateway/protocol.py +17 -0
- crabcode_gateway/routes/__init__.py +0 -0
- crabcode_gateway/routes/agent.py +392 -0
- crabcode_gateway/routes/auth.py +150 -0
- crabcode_gateway/routes/computer_use.py +101 -0
- crabcode_gateway/routes/config.py +1698 -0
- crabcode_gateway/routes/document.py +1810 -0
- crabcode_gateway/routes/event.py +3801 -0
- crabcode_gateway/routes/git_review.py +232 -0
- crabcode_gateway/routes/health.py +14 -0
- crabcode_gateway/routes/peer.py +78 -0
- crabcode_gateway/routes/permission.py +121 -0
- crabcode_gateway/routes/schedule.py +300 -0
- crabcode_gateway/routes/session.py +1496 -0
- crabcode_gateway/routes/snapshot.py +211 -0
- crabcode_gateway/routes/tasks.py +318 -0
- crabcode_gateway/routes/team.py +392 -0
- crabcode_gateway/routes/usage.py +49 -0
- crabcode_gateway/routes/workspace.py +299 -0
- crabcode_gateway/safe_file.py +63 -0
- crabcode_gateway/schemas.py +1820 -0
- crabcode_gateway/server.py +514 -0
- crabcode_gateway/session_registry.py +36 -0
- crabcode_gateway/task_registry.py +708 -0
- crabcode_gateway/turn_details.py +60 -0
- crabcode_search/__init__.py +11 -0
- crabcode_search/background.py +368 -0
- crabcode_search/chunker.py +517 -0
- crabcode_search/embedder.py +497 -0
- crabcode_search/indexer.py +215 -0
- crabcode_search/store.py +222 -0
- crabcode_search/tool.py +268 -0
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
"""Read-only Git review scopes for local and remote workspaces."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import difflib
|
|
6
|
+
import os
|
|
7
|
+
import subprocess
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Literal
|
|
10
|
+
|
|
11
|
+
from fastapi import APIRouter, HTTPException, Request
|
|
12
|
+
from pydantic import BaseModel, Field
|
|
13
|
+
|
|
14
|
+
from crabcode_gateway.routes.workspace import _is_within, _resolve_directory, _workspace_roots
|
|
15
|
+
|
|
16
|
+
router = APIRouter(prefix="/workspace/git", tags=["workspace"])
|
|
17
|
+
GitScope = Literal["uncommitted", "unstaged", "staged", "commit", "branch"]
|
|
18
|
+
MAX_FILES = 200
|
|
19
|
+
MAX_PATCH_BYTES = 2 * 1024 * 1024
|
|
20
|
+
MAX_OUTPUT_BYTES = 16 * 1024 * 1024
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class GitCommit(BaseModel):
|
|
24
|
+
id: str
|
|
25
|
+
subject: str
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class GitReviewInfo(BaseModel):
|
|
29
|
+
available: bool
|
|
30
|
+
reason: str | None = None
|
|
31
|
+
root: str | None = None
|
|
32
|
+
head: str | None = None
|
|
33
|
+
branch: str | None = None
|
|
34
|
+
default_base: str | None = None
|
|
35
|
+
branches: list[str] = Field(default_factory=list)
|
|
36
|
+
commits: list[GitCommit] = Field(default_factory=list)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class GitReviewFile(BaseModel):
|
|
40
|
+
path: str
|
|
41
|
+
action: Literal["create", "modify", "delete"]
|
|
42
|
+
added: int = 0
|
|
43
|
+
removed: int = 0
|
|
44
|
+
diff: str | None = None
|
|
45
|
+
note: str | None = None
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class GitReviewDiff(BaseModel):
|
|
49
|
+
files: list[GitReviewFile]
|
|
50
|
+
added: int
|
|
51
|
+
removed: int
|
|
52
|
+
total_files: int
|
|
53
|
+
truncated: bool
|
|
54
|
+
base: str | None = None
|
|
55
|
+
target: str | None = None
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _git(root: Path, *args: str, optional: bool = False) -> bytes | None:
|
|
59
|
+
env = dict(os.environ, GIT_OPTIONAL_LOCKS="0", LC_ALL="C")
|
|
60
|
+
for key in ("GIT_DIR", "GIT_WORK_TREE", "GIT_INDEX_FILE", "GIT_COMMON_DIR"):
|
|
61
|
+
env.pop(key, None)
|
|
62
|
+
try:
|
|
63
|
+
result = subprocess.run(
|
|
64
|
+
["git", "--no-pager", "--literal-pathspecs", "-c", "core.fsmonitor=false",
|
|
65
|
+
"-c", "core.quotePath=false", "-C", str(root), *args],
|
|
66
|
+
stdin=subprocess.DEVNULL, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
|
|
67
|
+
env=env, timeout=15, check=False,
|
|
68
|
+
)
|
|
69
|
+
except FileNotFoundError as exc:
|
|
70
|
+
raise HTTPException(503, "Gateway 未安装 Git") from exc
|
|
71
|
+
except subprocess.TimeoutExpired as exc:
|
|
72
|
+
raise HTTPException(504, "读取 Git 超时,请缩小变更范围后重试") from exc
|
|
73
|
+
if result.returncode:
|
|
74
|
+
if optional:
|
|
75
|
+
return None
|
|
76
|
+
raise HTTPException(400, "无法读取 Git 变更:" + result.stderr.decode("utf-8", "replace").strip()[:500])
|
|
77
|
+
if len(result.stdout) > MAX_OUTPUT_BYTES:
|
|
78
|
+
raise HTTPException(413, "Git 输出过大,请选择更小的变更范围")
|
|
79
|
+
return result.stdout
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _repository(request: Request, path: str) -> Path | None:
|
|
83
|
+
roots = _workspace_roots(request)
|
|
84
|
+
directory = _resolve_directory(path, roots)
|
|
85
|
+
value = _git(directory, "rev-parse", "--show-toplevel", optional=True)
|
|
86
|
+
if value is None:
|
|
87
|
+
return None
|
|
88
|
+
root = Path(os.fsdecode(value.rstrip(b"\n"))).resolve()
|
|
89
|
+
if not _is_within(root, roots):
|
|
90
|
+
raise HTTPException(403, "Git 仓库不在允许浏览的目录内")
|
|
91
|
+
return root
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _revision(root: Path, ref: str, *, optional: bool = False) -> str | None:
|
|
95
|
+
value = _git(root, "rev-parse", "--verify", "--end-of-options", f"{ref}^{{commit}}", optional=optional)
|
|
96
|
+
return value.decode().strip() if value is not None else None
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
@router.get("/info", response_model=GitReviewInfo)
|
|
100
|
+
def git_info(request: Request, path: str) -> GitReviewInfo:
|
|
101
|
+
try:
|
|
102
|
+
root = _repository(request, path)
|
|
103
|
+
except HTTPException as exc:
|
|
104
|
+
if exc.status_code == 503:
|
|
105
|
+
return GitReviewInfo(available=False, reason=str(exc.detail))
|
|
106
|
+
raise
|
|
107
|
+
if root is None:
|
|
108
|
+
return GitReviewInfo(available=False, reason="当前目录不在 Git 仓库中")
|
|
109
|
+
head = _revision(root, "HEAD", optional=True)
|
|
110
|
+
branch_value = _git(root, "symbolic-ref", "--quiet", "--short", "HEAD", optional=True)
|
|
111
|
+
branch = branch_value.decode("utf-8", "replace").strip() if branch_value else None
|
|
112
|
+
refs = _git(root, "for-each-ref", "--format=%(refname:short)", "refs/heads", "refs/remotes") or b""
|
|
113
|
+
branches = [ref for ref in refs.decode("utf-8", "replace").splitlines() if not ref.endswith("/HEAD")]
|
|
114
|
+
remote_head = _git(root, "symbolic-ref", "--quiet", "--short", "refs/remotes/origin/HEAD", optional=True)
|
|
115
|
+
defaults = [remote_head.decode().strip() if remote_head else "", "origin/main", "main", "origin/master", "master"]
|
|
116
|
+
default_base = next((ref for ref in defaults + branches if ref in branches and ref != branch), None)
|
|
117
|
+
default_base = default_base or (branches[0] if branches else None)
|
|
118
|
+
commits: list[GitCommit] = []
|
|
119
|
+
if head:
|
|
120
|
+
log = _git(root, "log", "-100", "--format=%H%x00%s%x00", head, "--") or b""
|
|
121
|
+
fields = log.decode("utf-8", "replace").split("\0")
|
|
122
|
+
commits = [GitCommit(id=fields[i].strip(), subject=fields[i + 1]) for i in range(0, len(fields) - 1, 2)]
|
|
123
|
+
return GitReviewInfo(available=True, root=str(root), head=head, branch=branch,
|
|
124
|
+
default_base=default_base, branches=branches, commits=commits)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _new_file(root: Path, name: str) -> GitReviewFile | None:
|
|
128
|
+
path = root / name
|
|
129
|
+
if not _is_within(path.parent.resolve(), (root,)):
|
|
130
|
+
raise HTTPException(403, "文件不在 Git 工作区内")
|
|
131
|
+
try:
|
|
132
|
+
if path.is_symlink():
|
|
133
|
+
raw = os.fsencode(os.readlink(path))
|
|
134
|
+
else:
|
|
135
|
+
if not path.is_file():
|
|
136
|
+
return None
|
|
137
|
+
with path.open("rb") as handle:
|
|
138
|
+
raw = handle.read(MAX_PATCH_BYTES + 1)
|
|
139
|
+
except FileNotFoundError:
|
|
140
|
+
return None # The working tree can change while the review is loading.
|
|
141
|
+
if len(raw) > MAX_PATCH_BYTES:
|
|
142
|
+
return GitReviewFile(path=name, action="create", note="文件超过 2 MiB,未加载 diff")
|
|
143
|
+
if b"\0" in raw:
|
|
144
|
+
return GitReviewFile(path=name, action="create", note="二进制文件,无法显示文本 diff")
|
|
145
|
+
try:
|
|
146
|
+
text = raw.decode("utf-8")
|
|
147
|
+
except UnicodeDecodeError:
|
|
148
|
+
return GitReviewFile(path=name, action="create", note="非 UTF-8 文件,无法显示文本 diff")
|
|
149
|
+
lines = text.splitlines(keepends=True)
|
|
150
|
+
patch = "".join(difflib.unified_diff([], lines, fromfile="/dev/null", tofile=f"b/{name}"))
|
|
151
|
+
return GitReviewFile(path=name, action="create", added=len(lines), diff=patch,
|
|
152
|
+
note="空文件" if not lines else None)
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
@router.get("/diff", response_model=GitReviewDiff)
|
|
156
|
+
def git_diff(request: Request, path: str, scope: GitScope, ref: str | None = None) -> GitReviewDiff:
|
|
157
|
+
root = _repository(request, path)
|
|
158
|
+
if root is None:
|
|
159
|
+
raise HTTPException(400, "当前目录不在 Git 仓库中")
|
|
160
|
+
head = _revision(root, "HEAD", optional=True)
|
|
161
|
+
base: str | None = None
|
|
162
|
+
target: str | None = None
|
|
163
|
+
unborn_worktree = scope == "uncommitted" and head is None
|
|
164
|
+
if scope == "commit":
|
|
165
|
+
target = _revision(root, ref or head or "HEAD")
|
|
166
|
+
base = _revision(root, f"{target}^", optional=True)
|
|
167
|
+
command = ["diff", base, target] if base else ["diff-tree", "--root", "--no-commit-id", "-r", target]
|
|
168
|
+
elif scope == "branch":
|
|
169
|
+
if not head or not ref:
|
|
170
|
+
raise HTTPException(400, "请选择比较分支,并确保当前分支已有提交")
|
|
171
|
+
reference = _revision(root, ref)
|
|
172
|
+
common = _git(root, "merge-base", reference, head, optional=True)
|
|
173
|
+
if common is None:
|
|
174
|
+
raise HTTPException(400, "所选分支与当前分支没有共同祖先")
|
|
175
|
+
base, target = common.decode().strip(), head
|
|
176
|
+
command = ["diff", base, target]
|
|
177
|
+
elif scope == "staged":
|
|
178
|
+
base = head
|
|
179
|
+
command = ["diff", "--cached"]
|
|
180
|
+
elif scope == "uncommitted":
|
|
181
|
+
base = head
|
|
182
|
+
command = ["diff", head] if head else ["diff", "--cached"]
|
|
183
|
+
else:
|
|
184
|
+
command = ["diff"]
|
|
185
|
+
# Treat renames as deletion + addition, preserving both paths without
|
|
186
|
+
# ambiguous quoted-path parsing. Git never runs external diff drivers.
|
|
187
|
+
options = ["--no-ext-diff", "--no-textconv", "--no-renames", "--ignore-submodules=none", "--no-color"]
|
|
188
|
+
stats = _git(root, *command, *options, "--numstat", "-z", "--") or b""
|
|
189
|
+
entries: dict[str, tuple[int, int, bool]] = {}
|
|
190
|
+
for record in stats.split(b"\0"):
|
|
191
|
+
if not record:
|
|
192
|
+
continue
|
|
193
|
+
added, removed, name = record.split(b"\t", 2)
|
|
194
|
+
binary = added == b"-" or removed == b"-"
|
|
195
|
+
entries[os.fsdecode(name)] = (0 if binary else int(added), 0 if binary else int(removed), binary)
|
|
196
|
+
untracked: set[str] = set()
|
|
197
|
+
if scope in ("uncommitted", "unstaged"):
|
|
198
|
+
raw = _git(root, "ls-files", "--others", "--exclude-standard", "-z") or b""
|
|
199
|
+
untracked = {os.fsdecode(name) for name in raw.split(b"\0") if name}
|
|
200
|
+
for name in sorted(untracked):
|
|
201
|
+
entries.setdefault(name, (0, 0, False))
|
|
202
|
+
files: list[GitReviewFile] = []
|
|
203
|
+
remaining_bytes = MAX_OUTPUT_BYTES
|
|
204
|
+
for name, (added, removed, binary) in list(entries.items())[:MAX_FILES]:
|
|
205
|
+
if name in untracked or unborn_worktree:
|
|
206
|
+
file = _new_file(root, name)
|
|
207
|
+
if file:
|
|
208
|
+
patch_bytes = len((file.diff or "").encode("utf-8"))
|
|
209
|
+
if patch_bytes > min(MAX_PATCH_BYTES, remaining_bytes):
|
|
210
|
+
file.diff = None
|
|
211
|
+
file.note = "diff 过大,仅显示行数"
|
|
212
|
+
else:
|
|
213
|
+
remaining_bytes -= patch_bytes
|
|
214
|
+
files.append(file)
|
|
215
|
+
continue
|
|
216
|
+
patch = _git(root, *command, *options, "--patch", "--", name) or b""
|
|
217
|
+
text = patch.decode("utf-8", "replace")
|
|
218
|
+
action = "create" if "\nnew file mode " in text else "delete" if "\ndeleted file mode " in text else "modify"
|
|
219
|
+
note = None
|
|
220
|
+
if binary:
|
|
221
|
+
note = "二进制文件,无法显示文本 diff"
|
|
222
|
+
elif len(patch) > min(MAX_PATCH_BYTES, remaining_bytes):
|
|
223
|
+
note = "diff 过大,仅显示行数"
|
|
224
|
+
elif "\n@@ " not in text:
|
|
225
|
+
note = "文件属性或子模块变更,无文本差异"
|
|
226
|
+
if note is None:
|
|
227
|
+
remaining_bytes -= len(patch)
|
|
228
|
+
files.append(GitReviewFile(path=name, action=action, added=added, removed=removed,
|
|
229
|
+
diff=None if note else text, note=note))
|
|
230
|
+
return GitReviewDiff(files=files, added=sum(file.added for file in files),
|
|
231
|
+
removed=sum(file.removed for file in files), total_files=len(entries),
|
|
232
|
+
truncated=len(entries) > MAX_FILES, base=base, target=target)
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
"""Health check endpoint."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from fastapi import APIRouter
|
|
6
|
+
|
|
7
|
+
from crabcode_gateway.schemas import HealthResponse
|
|
8
|
+
|
|
9
|
+
router = APIRouter()
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@router.get("/health", response_model=HealthResponse)
|
|
13
|
+
async def health() -> HealthResponse:
|
|
14
|
+
return HealthResponse()
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
"""Cross-session peer discovery and messaging routes."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from fastapi import APIRouter, HTTPException, Request
|
|
8
|
+
|
|
9
|
+
from crabcode_gateway.schemas import PeerDeliveryInfo, PeerInfo, PeerSendRequest
|
|
10
|
+
from crabcode_gateway.task_registry import SessionOperationRejected, run_session_operation
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
router = APIRouter(tags=["peer"])
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _get_session(request: Request, session_id: str | None = None):
|
|
17
|
+
sessions: dict[str, Any] = request.app.state.sessions
|
|
18
|
+
sid = request.app.state.default_session_id if session_id is None else session_id
|
|
19
|
+
session = sessions.get(sid) if sid else None
|
|
20
|
+
if session is None or getattr(session, "session_id", None) in getattr(request.app.state, "closing_sessions", set()):
|
|
21
|
+
return None
|
|
22
|
+
return session
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _peer_info(peer: Any) -> PeerInfo:
|
|
26
|
+
data = peer.model_dump(exclude={"auth_token"}) if hasattr(peer, "model_dump") else dict(peer)
|
|
27
|
+
return PeerInfo.model_validate(data)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@router.get("/peer/list", response_model=list[PeerInfo])
|
|
31
|
+
@router.get("/peers", response_model=list[PeerInfo])
|
|
32
|
+
async def list_peers(request: Request, session_id: str | None = None) -> list[PeerInfo]:
|
|
33
|
+
session = _get_session(request, session_id)
|
|
34
|
+
if not session:
|
|
35
|
+
raise HTTPException(status_code=404, detail="Session not found")
|
|
36
|
+
|
|
37
|
+
async def _list() -> list[PeerInfo]:
|
|
38
|
+
runtime = await session.ensure_peer_runtime()
|
|
39
|
+
if runtime is None:
|
|
40
|
+
return []
|
|
41
|
+
return [_peer_info(peer) for peer in runtime.list_peers()]
|
|
42
|
+
|
|
43
|
+
try:
|
|
44
|
+
return await run_session_operation(request.app.state, session, _list)
|
|
45
|
+
except SessionOperationRejected as exc:
|
|
46
|
+
raise HTTPException(status_code=503, detail=str(exc)) from exc
|
|
47
|
+
except (RuntimeError, OSError, ValueError) as exc:
|
|
48
|
+
raise HTTPException(status_code=503, detail=str(exc)) from exc
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@router.post("/peer/send", response_model=PeerDeliveryInfo)
|
|
52
|
+
@router.post("/peers/send", response_model=PeerDeliveryInfo)
|
|
53
|
+
async def send_peer_message(req: PeerSendRequest, request: Request) -> PeerDeliveryInfo:
|
|
54
|
+
session = _get_session(request, req.session_id)
|
|
55
|
+
if not session:
|
|
56
|
+
raise HTTPException(status_code=404, detail="Session not found")
|
|
57
|
+
|
|
58
|
+
async def _send() -> PeerDeliveryInfo:
|
|
59
|
+
runtime = await session.ensure_peer_runtime()
|
|
60
|
+
if runtime is None:
|
|
61
|
+
raise RuntimeError("Cross-session messaging is disabled")
|
|
62
|
+
delivery = await runtime.send(req.to, req.text)
|
|
63
|
+
return PeerDeliveryInfo.model_validate(
|
|
64
|
+
delivery.model_dump() if hasattr(delivery, "model_dump") else delivery
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
try:
|
|
68
|
+
delivery = await run_session_operation(request.app.state, session, _send)
|
|
69
|
+
except SessionOperationRejected as exc:
|
|
70
|
+
raise HTTPException(status_code=503, detail=str(exc)) from exc
|
|
71
|
+
except (RuntimeError, OSError, ValueError) as exc:
|
|
72
|
+
raise HTTPException(status_code=503, detail=str(exc)) from exc
|
|
73
|
+
if delivery.status in {"failed", "refused"}:
|
|
74
|
+
# Preserve the structured acknowledgement while giving HTTP clients a
|
|
75
|
+
# useful status code for automation.
|
|
76
|
+
return delivery
|
|
77
|
+
return delivery
|
|
78
|
+
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
"""Permission and choice interaction routes."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from fastapi import APIRouter, HTTPException, Request
|
|
6
|
+
|
|
7
|
+
from crabcode_gateway.schemas import (
|
|
8
|
+
ChoiceResponseRequest,
|
|
9
|
+
PermissionResponseRequest,
|
|
10
|
+
)
|
|
11
|
+
from crabcode_gateway.task_registry import SessionOperationRejected, run_session_operation
|
|
12
|
+
|
|
13
|
+
router = APIRouter(tags=["interaction"])
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _get_session(
|
|
17
|
+
request: Request,
|
|
18
|
+
session_id: str | None = None,
|
|
19
|
+
*,
|
|
20
|
+
agent_id: str | None = None,
|
|
21
|
+
):
|
|
22
|
+
sessions: dict = request.app.state.sessions
|
|
23
|
+
closing = getattr(request.app.state, "closing_sessions", set())
|
|
24
|
+
|
|
25
|
+
# Preserve an explicit empty selector as invalid; ``None`` alone means
|
|
26
|
+
# that the legacy default/agent-owner lookup may be used.
|
|
27
|
+
requested = session_id if session_id is not None else None
|
|
28
|
+
|
|
29
|
+
def _owns(candidate: object | None) -> bool:
|
|
30
|
+
if candidate is None:
|
|
31
|
+
return False
|
|
32
|
+
if getattr(candidate, "session_id", None) in closing:
|
|
33
|
+
return False
|
|
34
|
+
if not agent_id:
|
|
35
|
+
return True
|
|
36
|
+
try:
|
|
37
|
+
return bool(candidate.get_agent(agent_id)) # type: ignore[attr-defined]
|
|
38
|
+
except Exception:
|
|
39
|
+
return False
|
|
40
|
+
|
|
41
|
+
# An explicit session selector must not silently fall back to the default
|
|
42
|
+
# session when the agent belongs elsewhere.
|
|
43
|
+
if requested is not None:
|
|
44
|
+
selected = sessions.get(requested)
|
|
45
|
+
return selected if _owns(selected) else None
|
|
46
|
+
|
|
47
|
+
default_id = request.app.state.default_session_id
|
|
48
|
+
selected = sessions.get(default_id) if default_id else None
|
|
49
|
+
if _owns(selected):
|
|
50
|
+
return selected
|
|
51
|
+
|
|
52
|
+
if agent_id:
|
|
53
|
+
for candidate in sessions.values():
|
|
54
|
+
if _owns(candidate):
|
|
55
|
+
return candidate
|
|
56
|
+
return None
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@router.post("/permission/respond")
|
|
60
|
+
async def respond_permission(req: PermissionResponseRequest, request: Request):
|
|
61
|
+
"""Respond to a permission request from the agent."""
|
|
62
|
+
from crabcode_core.types.event import PermissionResponseEvent
|
|
63
|
+
|
|
64
|
+
session = _get_session(
|
|
65
|
+
request,
|
|
66
|
+
req.session_id
|
|
67
|
+
if req.session_id is not None
|
|
68
|
+
else request.query_params.get("session_id"),
|
|
69
|
+
agent_id=req.agent_id,
|
|
70
|
+
)
|
|
71
|
+
if not session:
|
|
72
|
+
raise HTTPException(status_code=404, detail="Session not found")
|
|
73
|
+
|
|
74
|
+
event = PermissionResponseEvent(
|
|
75
|
+
tool_use_id=req.tool_use_id,
|
|
76
|
+
allowed=req.allowed,
|
|
77
|
+
always_allow=req.always_allow,
|
|
78
|
+
agent_id=req.agent_id,
|
|
79
|
+
feedback=req.feedback,
|
|
80
|
+
)
|
|
81
|
+
try:
|
|
82
|
+
await run_session_operation(
|
|
83
|
+
request.app.state,
|
|
84
|
+
session,
|
|
85
|
+
lambda: session.respond_permission(event),
|
|
86
|
+
)
|
|
87
|
+
except SessionOperationRejected as exc:
|
|
88
|
+
raise HTTPException(status_code=503, detail=str(exc)) from exc
|
|
89
|
+
return {"status": "ok"}
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
@router.post("/choice/respond")
|
|
93
|
+
async def respond_choice(req: ChoiceResponseRequest, request: Request):
|
|
94
|
+
"""Respond to a choice request from the agent."""
|
|
95
|
+
from crabcode_core.types.event import ChoiceResponseEvent
|
|
96
|
+
|
|
97
|
+
session = _get_session(
|
|
98
|
+
request,
|
|
99
|
+
req.session_id
|
|
100
|
+
if req.session_id is not None
|
|
101
|
+
else request.query_params.get("session_id"),
|
|
102
|
+
agent_id=req.agent_id,
|
|
103
|
+
)
|
|
104
|
+
if not session:
|
|
105
|
+
raise HTTPException(status_code=404, detail="Session not found")
|
|
106
|
+
|
|
107
|
+
event = ChoiceResponseEvent(
|
|
108
|
+
tool_use_id=req.tool_use_id,
|
|
109
|
+
selected=req.selected,
|
|
110
|
+
cancelled=req.cancelled,
|
|
111
|
+
agent_id=req.agent_id,
|
|
112
|
+
)
|
|
113
|
+
try:
|
|
114
|
+
await run_session_operation(
|
|
115
|
+
request.app.state,
|
|
116
|
+
session,
|
|
117
|
+
lambda: session.respond_choice(event),
|
|
118
|
+
)
|
|
119
|
+
except SessionOperationRejected as exc:
|
|
120
|
+
raise HTTPException(status_code=503, detail=str(exc)) from exc
|
|
121
|
+
return {"status": "ok"}
|