usta 0.2.0__py3-none-any.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.
- usta/__init__.py +0 -0
- usta/agent.py +319 -0
- usta/api.py +834 -0
- usta/apis.py +103 -0
- usta/attachments.py +60 -0
- usta/audit.py +47 -0
- usta/auth.py +55 -0
- usta/bench.py +904 -0
- usta/bench_tasks/add-feature/check/test_todo.py +37 -0
- usta/bench_tasks/add-feature/repo/test_todo.py +11 -0
- usta/bench_tasks/add-feature/repo/todo.py +10 -0
- usta/bench_tasks/add-feature/task.json +10 -0
- usta/bench_tasks/cli-args/check/test_cli.py +44 -0
- usta/bench_tasks/cli-args/repo/cli.py +2 -0
- usta/bench_tasks/cli-args/task.json +10 -0
- usta/bench_tasks/cross-module-bug/check/test_cart.py +19 -0
- usta/bench_tasks/cross-module-bug/check/test_pricing.py +12 -0
- usta/bench_tasks/cross-module-bug/repo/cart.py +7 -0
- usta/bench_tasks/cross-module-bug/repo/pricing.py +5 -0
- usta/bench_tasks/cross-module-bug/repo/test_cart.py +8 -0
- usta/bench_tasks/cross-module-bug/repo/test_pricing.py +12 -0
- usta/bench_tasks/cross-module-bug/task.json +10 -0
- usta/bench_tasks/csv-quotes/check/test_parse.py +22 -0
- usta/bench_tasks/csv-quotes/repo/parse.py +2 -0
- usta/bench_tasks/csv-quotes/task.json +10 -0
- usta/bench_tasks/deep-merge/check/test_config.py +31 -0
- usta/bench_tasks/deep-merge/repo/config.py +2 -0
- usta/bench_tasks/deep-merge/task.json +10 -0
- usta/bench_tasks/expr-eval/check/test_calc.py +37 -0
- usta/bench_tasks/expr-eval/repo/calc.py +2 -0
- usta/bench_tasks/expr-eval/task.json +10 -0
- usta/bench_tasks/implement-slugify/check/test_slug.py +19 -0
- usta/bench_tasks/implement-slugify/repo/slug.py +2 -0
- usta/bench_tasks/implement-slugify/task.json +10 -0
- usta/bench_tasks/interval-merge/check/test_intervals.py +30 -0
- usta/bench_tasks/interval-merge/repo/intervals.py +2 -0
- usta/bench_tasks/interval-merge/task.json +10 -0
- usta/bench_tasks/leap-dates/check/test_dates.py +34 -0
- usta/bench_tasks/leap-dates/repo/dates.py +15 -0
- usta/bench_tasks/leap-dates/repo/test_dates.py +9 -0
- usta/bench_tasks/leap-dates/task.json +10 -0
- usta/bench_tasks/lru-cache/check/test_lru.py +50 -0
- usta/bench_tasks/lru-cache/repo/lru.py +3 -0
- usta/bench_tasks/lru-cache/task.json +10 -0
- usta/bench_tasks/money-rounding/check/test_money.py +27 -0
- usta/bench_tasks/money-rounding/repo/money.py +7 -0
- usta/bench_tasks/money-rounding/repo/test_money.py +8 -0
- usta/bench_tasks/money-rounding/task.json +10 -0
- usta/bench_tasks/multi-file-rename/check/test_users.py +21 -0
- usta/bench_tasks/multi-file-rename/repo/api.py +5 -0
- usta/bench_tasks/multi-file-rename/repo/greeting.py +5 -0
- usta/bench_tasks/multi-file-rename/repo/report.py +5 -0
- usta/bench_tasks/multi-file-rename/repo/test_users.py +8 -0
- usta/bench_tasks/multi-file-rename/repo/users.py +2 -0
- usta/bench_tasks/multi-file-rename/task.json +10 -0
- usta/bench_tasks/off-by-one/check/test_stats.py +21 -0
- usta/bench_tasks/off-by-one/repo/stats.py +8 -0
- usta/bench_tasks/off-by-one/repo/test_stats.py +8 -0
- usta/bench_tasks/off-by-one/task.json +10 -0
- usta/bench_tasks/text-wrap/check/test_wrap.py +28 -0
- usta/bench_tasks/text-wrap/repo/wrap.py +2 -0
- usta/bench_tasks/text-wrap/task.json +10 -0
- usta/bench_tasks/tiny-case-insensitive/check/test_find.py +17 -0
- usta/bench_tasks/tiny-case-insensitive/repo/find.py +3 -0
- usta/bench_tasks/tiny-case-insensitive/repo/test_find.py +8 -0
- usta/bench_tasks/tiny-case-insensitive/task.json +11 -0
- usta/bench_tasks/tiny-default-sep/check/test_words.py +17 -0
- usta/bench_tasks/tiny-default-sep/repo/test_words.py +8 -0
- usta/bench_tasks/tiny-default-sep/repo/words.py +3 -0
- usta/bench_tasks/tiny-default-sep/task.json +11 -0
- usta/bench_tasks/tiny-greeting/check/test_greet.py +14 -0
- usta/bench_tasks/tiny-greeting/repo/greet.py +3 -0
- usta/bench_tasks/tiny-greeting/repo/test_greet.py +8 -0
- usta/bench_tasks/tiny-greeting/task.json +11 -0
- usta/bench_tasks/tiny-is-even/check/test_parity.py +18 -0
- usta/bench_tasks/tiny-is-even/repo/parity.py +5 -0
- usta/bench_tasks/tiny-is-even/repo/test_parity.py +9 -0
- usta/bench_tasks/tiny-is-even/task.json +11 -0
- usta/bench_tasks/tiny-missing-return/check/test_nums.py +19 -0
- usta/bench_tasks/tiny-missing-return/repo/nums.py +5 -0
- usta/bench_tasks/tiny-missing-return/repo/test_nums.py +8 -0
- usta/bench_tasks/tiny-missing-return/task.json +11 -0
- usta/bench_tasks/tiny-strip-names/check/test_names.py +17 -0
- usta/bench_tasks/tiny-strip-names/repo/names.py +3 -0
- usta/bench_tasks/tiny-strip-names/repo/test_names.py +8 -0
- usta/bench_tasks/tiny-strip-names/task.json +11 -0
- usta/bench_tasks/tiny-swap-args/check/test_pct.py +17 -0
- usta/bench_tasks/tiny-swap-args/repo/pct.py +5 -0
- usta/bench_tasks/tiny-swap-args/repo/test_pct.py +8 -0
- usta/bench_tasks/tiny-swap-args/task.json +11 -0
- usta/bench_tasks/tiny-wrong-operator/check/test_avg.py +17 -0
- usta/bench_tasks/tiny-wrong-operator/repo/avg.py +5 -0
- usta/bench_tasks/tiny-wrong-operator/repo/test_avg.py +8 -0
- usta/bench_tasks/tiny-wrong-operator/task.json +11 -0
- usta/bench_tasks/word-count-fix/check/test_text.py +25 -0
- usta/bench_tasks/word-count-fix/repo/test_text.py +10 -0
- usta/bench_tasks/word-count-fix/repo/text.py +5 -0
- usta/bench_tasks/word-count-fix/task.json +10 -0
- usta/breaker.py +132 -0
- usta/capability.py +643 -0
- usta/cli.py +1358 -0
- usta/context.py +122 -0
- usta/fetch.py +406 -0
- usta/fetch_inner.py +112 -0
- usta/filetree.py +134 -0
- usta/jsonproto.py +155 -0
- usta/lab/__init__.py +4 -0
- usta/lab/env.py +127 -0
- usta/lab/evaluator.py +108 -0
- usta/lab/metrics.py +109 -0
- usta/lab/report.py +124 -0
- usta/lab/runner.py +242 -0
- usta/lab/scenario.py +266 -0
- usta/lab/scenarios/v1.jsonl +25 -0
- usta/lab/scripted.py +47 -0
- usta/mcp.py +344 -0
- usta/mcp_runtime.py +362 -0
- usta/mcp_tools.py +162 -0
- usta/mcpcatalog.py +39 -0
- usta/mcpconfig.py +306 -0
- usta/memory.py +218 -0
- usta/modelctl.py +124 -0
- usta/modelinfo.py +19 -0
- usta/models.py +170 -0
- usta/outline.py +191 -0
- usta/pdf_inner.py +59 -0
- usta/phases.py +267 -0
- usta/policy.py +104 -0
- usta/portal.py +107 -0
- usta/profile.py +177 -0
- usta/projectcfg.py +90 -0
- usta/projects.py +67 -0
- usta/proxy.py +302 -0
- usta/repl.py +590 -0
- usta/rescue.py +66 -0
- usta/sandbox.py +359 -0
- usta/sessions.py +120 -0
- usta/settings.py +93 -0
- usta/skills.py +79 -0
- usta/symbols.py +618 -0
- usta/tasks.py +633 -0
- usta/taxonomy.py +169 -0
- usta/textproto.py +149 -0
- usta/theme.py +104 -0
- usta/tools.py +369 -0
- usta/verify.py +233 -0
- usta/web/app.css +160 -0
- usta/web/app.js +1064 -0
- usta/web/index.html +197 -0
- usta/web/logic.js +175 -0
- usta/workspace.py +236 -0
- usta-0.2.0.dist-info/METADATA +211 -0
- usta-0.2.0.dist-info/RECORD +156 -0
- usta-0.2.0.dist-info/WHEEL +4 -0
- usta-0.2.0.dist-info/entry_points.txt +2 -0
- usta-0.2.0.dist-info/licenses/LICENSE +202 -0
usta/api.py
ADDED
|
@@ -0,0 +1,834 @@
|
|
|
1
|
+
"""HTTP API (FastAPI): çalışma alanları, görevler, SSE olay akışı, onaylar, diff, denetim."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import contextlib
|
|
6
|
+
import hashlib
|
|
7
|
+
import hmac
|
|
8
|
+
import json
|
|
9
|
+
import mimetypes
|
|
10
|
+
import os
|
|
11
|
+
import re
|
|
12
|
+
import secrets
|
|
13
|
+
from collections import deque
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Annotated, Literal
|
|
16
|
+
|
|
17
|
+
from fastapi import APIRouter, Depends, FastAPI, Header, HTTPException, Query
|
|
18
|
+
from fastapi import Path as PathParam
|
|
19
|
+
from fastapi.responses import PlainTextResponse, Response, StreamingResponse
|
|
20
|
+
from pydantic import BaseModel, Field
|
|
21
|
+
from starlette.middleware.trustedhost import TrustedHostMiddleware
|
|
22
|
+
|
|
23
|
+
from usta import apis, attachments, filetree, mcp_runtime, mcpcatalog, mcpconfig, memory, skills
|
|
24
|
+
from usta import settings as usta_settings
|
|
25
|
+
from usta.audit import AuditLog
|
|
26
|
+
from usta.auth import LoginCodes
|
|
27
|
+
from usta.mcp import McpClient, McpError
|
|
28
|
+
from usta.modelctl import ModelSwitchError
|
|
29
|
+
from usta.policy import PathEscapeError, resolve_path
|
|
30
|
+
from usta.tasks import PlanNotFound, RevertRefused, TaskFinished, TaskManager, WorkspaceBusy
|
|
31
|
+
|
|
32
|
+
ID_PATTERN = r"^[a-z0-9][a-z0-9_-]{0,39}$"
|
|
33
|
+
MAX_FILE = 1_000_000
|
|
34
|
+
KEEPALIVE = 15.0
|
|
35
|
+
|
|
36
|
+
WEB_DIR = Path(__file__).parent / "web"
|
|
37
|
+
UI_FILES = {
|
|
38
|
+
"/": ("index.html", "text/html; charset=utf-8"),
|
|
39
|
+
"/app.js": ("app.js", "text/javascript; charset=utf-8"),
|
|
40
|
+
"/logic.js": ("logic.js", "text/javascript; charset=utf-8"),
|
|
41
|
+
"/app.css": ("app.css", "text/css; charset=utf-8"),
|
|
42
|
+
}
|
|
43
|
+
UI_HEADERS = {
|
|
44
|
+
"Content-Security-Policy": (
|
|
45
|
+
"default-src 'none'; script-src 'self'; style-src 'self'; connect-src 'self'; "
|
|
46
|
+
"img-src 'self'; frame-src 'self'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'"
|
|
47
|
+
),
|
|
48
|
+
"X-Content-Type-Options": "nosniff",
|
|
49
|
+
"Referrer-Policy": "no-referrer",
|
|
50
|
+
"Cache-Control": "no-store",
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
WorkspaceId = Annotated[str, PathParam(pattern=ID_PATTERN)]
|
|
54
|
+
ServerName = Annotated[str, PathParam(pattern=r"^[a-z0-9][a-z0-9_-]{0,29}$")]
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class WorkspaceIn(BaseModel):
|
|
58
|
+
name: str = Field(pattern=ID_PATTERN)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class AttachIn(BaseModel):
|
|
62
|
+
name: str = Field(min_length=1, max_length=200)
|
|
63
|
+
content: str = Field(max_length=attachments.MAX_FILE * 2)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class TaskIn(BaseModel):
|
|
67
|
+
prompt: str = Field(min_length=1, max_length=20_000)
|
|
68
|
+
mode: Literal["manual", "auto", "plan"] = "manual"
|
|
69
|
+
guard: bool | None = None # None: sunucu varsayılanı
|
|
70
|
+
files: list[AttachIn] = Field(default_factory=list, max_length=attachments.MAX_FILES)
|
|
71
|
+
apply_plan: str | None = Field(
|
|
72
|
+
default=None, pattern=r"^[a-f0-9]{12}$"
|
|
73
|
+
) # bitmiş plan görevinin kimliği
|
|
74
|
+
phased: bool = (
|
|
75
|
+
False # planı PLAN.md aşamalarına bölüp her aşamayı taze bağlamla ayrı görev koştur
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class MessageIn(BaseModel):
|
|
80
|
+
text: str = Field(min_length=1, max_length=20_000)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
class ModelIn(BaseModel):
|
|
84
|
+
model: str = Field(min_length=1, max_length=200)
|
|
85
|
+
api: str | None = Field(default=None, max_length=30)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
class ApiIn(BaseModel):
|
|
89
|
+
name: str = Field(min_length=1, max_length=30)
|
|
90
|
+
endpoint: str = Field(min_length=1, max_length=500)
|
|
91
|
+
key: str | None = Field(default=None, max_length=8192)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
class KeyIn(BaseModel):
|
|
95
|
+
key: str | None = Field(default=None, max_length=8192)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
class ImportFile(BaseModel):
|
|
99
|
+
path: str = Field(min_length=1, max_length=500)
|
|
100
|
+
b64: str = Field(max_length=filetree.MAX_IMPORT_FILE * 2)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
class ImportIn(BaseModel):
|
|
104
|
+
files: list[ImportFile] = Field(max_length=filetree.MAX_IMPORT_FILES)
|
|
105
|
+
overwrite: bool = False
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
class FsOp(BaseModel):
|
|
109
|
+
op: Literal["create_file", "create_dir", "rename", "delete"]
|
|
110
|
+
path: str = Field(min_length=1, max_length=500)
|
|
111
|
+
to: str | None = Field(default=None, max_length=500)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
class SkillIn(BaseModel):
|
|
115
|
+
content: str = Field(min_length=1, max_length=skills.MAX_SIZE * 2)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
PREVIEW_MAX = 5_000_000
|
|
119
|
+
PREVIEW_HEADERS = {
|
|
120
|
+
# Ajanın yazdığı içerik güvenilmezdir: opak kaynakta (sandbox, allow-same-origin YOK) çalışır,
|
|
121
|
+
# portalın jetonuna/oturumuna erişemez ve ağa çıkamaz.
|
|
122
|
+
"Content-Security-Policy": (
|
|
123
|
+
"sandbox allow-scripts; default-src 'self' data: blob:; "
|
|
124
|
+
"script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; "
|
|
125
|
+
"connect-src 'none'; form-action 'none'; frame-ancestors 'self'"
|
|
126
|
+
),
|
|
127
|
+
"X-Content-Type-Options": "nosniff",
|
|
128
|
+
"Referrer-Policy": "no-referrer",
|
|
129
|
+
"Cache-Control": "no-store",
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
class SettingsIn(BaseModel):
|
|
134
|
+
"""Portaldan yalnız görünüm ve mod değiştirilebilir; uç nokta/model/sırlar buradan değişmez."""
|
|
135
|
+
|
|
136
|
+
theme: Literal["beyaz", "gri", "siyah"] | None = None
|
|
137
|
+
mode: Literal["manual", "auto", "plan"] | None = None
|
|
138
|
+
guard: bool | None = None
|
|
139
|
+
zoom: Literal["auto", "0.9", "1", "1.1", "1.25", "1.5", "1.75"] | None = None
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
class CodeIn(BaseModel):
|
|
143
|
+
code: str = Field(max_length=200)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
class SecretIn(BaseModel):
|
|
147
|
+
value: str = Field(min_length=1, max_length=8192)
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
class EnabledIn(BaseModel):
|
|
151
|
+
enabled: bool
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
class FileIn(BaseModel):
|
|
155
|
+
content: str = Field(max_length=MAX_FILE)
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
class ApprovalIn(BaseModel):
|
|
159
|
+
decision: Literal["allow", "deny"]
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def load_or_create_token(home: Path) -> str:
|
|
163
|
+
"""Yönetici jetonu home/token dosyasında (0600) tutulur; repoya girmez."""
|
|
164
|
+
path = Path(home) / "token"
|
|
165
|
+
if path.exists():
|
|
166
|
+
return path.read_text().strip()
|
|
167
|
+
path.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
|
|
168
|
+
token = secrets.token_urlsafe(32)
|
|
169
|
+
fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
|
|
170
|
+
with os.fdopen(fd, "w") as f:
|
|
171
|
+
f.write(token + "\n")
|
|
172
|
+
return token
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
MAX_BODY = 10_000_000 # varsayılan istek gövdesi sınırı (kimlik doğrulamadan ÖNCE uygulanır)
|
|
176
|
+
MAX_BODY_IMPORT = 32_000_000
|
|
177
|
+
MAX_BODY_AUTH = 4_096
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def _body_limit(path: str) -> int:
|
|
181
|
+
if path == "/auth/exchange":
|
|
182
|
+
return MAX_BODY_AUTH
|
|
183
|
+
return MAX_BODY_IMPORT if re.fullmatch(r"/workspaces/[^/]+/import", path) else MAX_BODY
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
class BodyLimitMiddleware:
|
|
187
|
+
"""İstek gövdesini boyuta göre keser (yetki denetimi gövdeyi okumadan önce çalışsın diye ASGI düzeyinde)."""
|
|
188
|
+
|
|
189
|
+
def __init__(self, app):
|
|
190
|
+
self.app = app
|
|
191
|
+
|
|
192
|
+
async def __call__(self, scope, receive, send):
|
|
193
|
+
if scope["type"] != "http":
|
|
194
|
+
await self.app(scope, receive, send)
|
|
195
|
+
return
|
|
196
|
+
limit = _body_limit(scope.get("path", ""))
|
|
197
|
+
declared = dict(scope.get("headers") or []).get(b"content-length", b"")
|
|
198
|
+
responded = False
|
|
199
|
+
|
|
200
|
+
async def too_large():
|
|
201
|
+
nonlocal responded
|
|
202
|
+
if responded:
|
|
203
|
+
return
|
|
204
|
+
responded = True
|
|
205
|
+
body = b'{"detail":"istek \xc3\xa7ok b\xc3\xbcy\xc3\xbck"}'
|
|
206
|
+
await send(
|
|
207
|
+
{
|
|
208
|
+
"type": "http.response.start",
|
|
209
|
+
"status": 413,
|
|
210
|
+
"headers": [
|
|
211
|
+
(b"content-type", b"application/json"),
|
|
212
|
+
(b"content-length", str(len(body)).encode()),
|
|
213
|
+
(b"connection", b"close"),
|
|
214
|
+
],
|
|
215
|
+
}
|
|
216
|
+
)
|
|
217
|
+
await send({"type": "http.response.body", "body": body})
|
|
218
|
+
|
|
219
|
+
if declared.isdigit() and int(declared) > limit:
|
|
220
|
+
await too_large()
|
|
221
|
+
return
|
|
222
|
+
received = 0
|
|
223
|
+
|
|
224
|
+
async def limited_receive():
|
|
225
|
+
nonlocal received
|
|
226
|
+
if responded:
|
|
227
|
+
return {"type": "http.disconnect"}
|
|
228
|
+
msg = await receive()
|
|
229
|
+
if msg["type"] == "http.request":
|
|
230
|
+
received += len(msg.get("body", b""))
|
|
231
|
+
if received > limit:
|
|
232
|
+
await too_large()
|
|
233
|
+
return {"type": "http.disconnect"}
|
|
234
|
+
return msg
|
|
235
|
+
|
|
236
|
+
async def guarded_send(msg):
|
|
237
|
+
if not responded:
|
|
238
|
+
await send(msg)
|
|
239
|
+
|
|
240
|
+
await self.app(scope, limited_receive, guarded_send)
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def create_app(
|
|
244
|
+
manager: TaskManager,
|
|
245
|
+
home: Path,
|
|
246
|
+
token: str,
|
|
247
|
+
login: LoginCodes | None = None,
|
|
248
|
+
workspace: str | None = None,
|
|
249
|
+
trusted_hosts: list[str] | None = None,
|
|
250
|
+
) -> FastAPI:
|
|
251
|
+
home = Path(home)
|
|
252
|
+
|
|
253
|
+
def auth(authorization: Annotated[str | None, Header()] = None) -> None:
|
|
254
|
+
given = authorization.removeprefix("Bearer ") if authorization else ""
|
|
255
|
+
if not given or not hmac.compare_digest(given.encode(), token.encode()):
|
|
256
|
+
raise HTTPException(401, "yetkisiz", headers={"WWW-Authenticate": "Bearer"})
|
|
257
|
+
|
|
258
|
+
app = FastAPI(title="Usta", docs_url=None, redoc_url=None, openapi_url=None)
|
|
259
|
+
app.add_middleware(BodyLimitMiddleware)
|
|
260
|
+
if trusted_hosts:
|
|
261
|
+
app.add_middleware(TrustedHostMiddleware, allowed_hosts=trusted_hosts)
|
|
262
|
+
api = APIRouter(dependencies=[Depends(auth)])
|
|
263
|
+
|
|
264
|
+
def ws_root(workspace: str) -> Path:
|
|
265
|
+
root = home / "workspaces" / workspace
|
|
266
|
+
if not root.is_dir():
|
|
267
|
+
raise HTTPException(404, "çalışma alanı yok")
|
|
268
|
+
return root
|
|
269
|
+
|
|
270
|
+
def get_task(task_id: str):
|
|
271
|
+
try:
|
|
272
|
+
return manager.get(task_id)
|
|
273
|
+
except KeyError:
|
|
274
|
+
raise HTTPException(404, "görev yok") from None
|
|
275
|
+
|
|
276
|
+
@api.post("/workspaces", status_code=201)
|
|
277
|
+
def create_workspace(body: WorkspaceIn) -> dict:
|
|
278
|
+
root = home / "workspaces" / body.name
|
|
279
|
+
try:
|
|
280
|
+
root.mkdir(parents=True)
|
|
281
|
+
except FileExistsError:
|
|
282
|
+
raise HTTPException(409, "çalışma alanı zaten var") from None
|
|
283
|
+
return {"id": body.name}
|
|
284
|
+
|
|
285
|
+
@api.get("/workspaces")
|
|
286
|
+
def list_workspaces() -> list[dict]:
|
|
287
|
+
base = home / "workspaces"
|
|
288
|
+
return (
|
|
289
|
+
[{"id": p.name} for p in sorted(base.iterdir()) if p.is_dir()] if base.is_dir() else []
|
|
290
|
+
)
|
|
291
|
+
|
|
292
|
+
def preview_key(workspace: str) -> str:
|
|
293
|
+
return hmac.new(
|
|
294
|
+
token.encode(), f"preview:{workspace}".encode(), hashlib.sha256
|
|
295
|
+
).hexdigest()[:32]
|
|
296
|
+
|
|
297
|
+
@api.get("/workspaces/{workspace}/preview")
|
|
298
|
+
def preview_info(workspace: WorkspaceId) -> dict:
|
|
299
|
+
ws_root(workspace)
|
|
300
|
+
return {"base": f"/preview/{workspace}/{preview_key(workspace)}/"}
|
|
301
|
+
|
|
302
|
+
@api.get("/workspaces/{workspace}/tree")
|
|
303
|
+
def tree(workspace: WorkspaceId, path: str = "") -> dict:
|
|
304
|
+
root = ws_root(workspace)
|
|
305
|
+
try:
|
|
306
|
+
full = resolve_path(root, path or ".")
|
|
307
|
+
except PathEscapeError:
|
|
308
|
+
raise HTTPException(400, "geçersiz yol") from None
|
|
309
|
+
if not full.is_dir():
|
|
310
|
+
raise HTTPException(404, "klasör yok")
|
|
311
|
+
try:
|
|
312
|
+
return {"path": path, "entries": filetree.list_dir(full)}
|
|
313
|
+
except OSError:
|
|
314
|
+
raise HTTPException(400, "klasör okunamadı") from None
|
|
315
|
+
|
|
316
|
+
@api.post("/workspaces/{workspace}/import")
|
|
317
|
+
def import_files(workspace: WorkspaceId, body: ImportIn) -> dict:
|
|
318
|
+
root = idle_ws(workspace)
|
|
319
|
+
try:
|
|
320
|
+
decoded = filetree.decode_files([(f.path, f.b64) for f in body.files])
|
|
321
|
+
except filetree.ImportError_ as e:
|
|
322
|
+
raise HTTPException(400, str(e)) from None
|
|
323
|
+
written, skipped = [], []
|
|
324
|
+
for rel, data in decoded:
|
|
325
|
+
try:
|
|
326
|
+
full = resolve_path(root, rel)
|
|
327
|
+
except PathEscapeError:
|
|
328
|
+
raise HTTPException(400, f"geçersiz yol: {rel}") from None
|
|
329
|
+
if full.exists() and not body.overwrite:
|
|
330
|
+
skipped.append(rel)
|
|
331
|
+
continue
|
|
332
|
+
try:
|
|
333
|
+
full.parent.mkdir(parents=True, exist_ok=True)
|
|
334
|
+
full.write_bytes(data)
|
|
335
|
+
except OSError:
|
|
336
|
+
raise HTTPException(400, f"yazılamadı: {rel}") from None
|
|
337
|
+
written.append(rel)
|
|
338
|
+
return {"written": written, "skipped": skipped}
|
|
339
|
+
|
|
340
|
+
@api.post("/workspaces/{workspace}/fs")
|
|
341
|
+
def fs_op(workspace: WorkspaceId, body: FsOp) -> dict:
|
|
342
|
+
root = idle_ws(workspace)
|
|
343
|
+
try:
|
|
344
|
+
if body.op == "create_file":
|
|
345
|
+
filetree.create(root, body.path, is_dir=False)
|
|
346
|
+
elif body.op == "create_dir":
|
|
347
|
+
filetree.create(root, body.path, is_dir=True)
|
|
348
|
+
elif body.op == "rename":
|
|
349
|
+
if not body.to:
|
|
350
|
+
raise HTTPException(400, "yeni ad gerekli")
|
|
351
|
+
filetree.rename(root, body.path, body.to)
|
|
352
|
+
else:
|
|
353
|
+
filetree.delete(root, body.path)
|
|
354
|
+
except filetree.FsError as e:
|
|
355
|
+
raise HTTPException(e.status, str(e)) from None
|
|
356
|
+
with contextlib.suppress(OSError):
|
|
357
|
+
AuditLog(home / "audit" / f"{workspace}.jsonl").record(
|
|
358
|
+
"fs_op", op=body.op, path=body.path, to=body.to
|
|
359
|
+
)
|
|
360
|
+
return {"ok": True}
|
|
361
|
+
|
|
362
|
+
@api.get("/skills")
|
|
363
|
+
def list_skills() -> list[dict]:
|
|
364
|
+
return skills.list_skills(home)
|
|
365
|
+
|
|
366
|
+
@api.put("/skills/{name}")
|
|
367
|
+
def put_skill(name: str, body: SkillIn) -> dict:
|
|
368
|
+
try:
|
|
369
|
+
skills.put(home, name, body.content)
|
|
370
|
+
except skills.SkillError as e:
|
|
371
|
+
raise HTTPException(400, str(e)) from None
|
|
372
|
+
return {"name": name}
|
|
373
|
+
|
|
374
|
+
@api.delete("/skills/{name}")
|
|
375
|
+
def delete_skill(name: str) -> dict:
|
|
376
|
+
try:
|
|
377
|
+
skills.delete(home, name)
|
|
378
|
+
except skills.SkillError as e:
|
|
379
|
+
raise HTTPException(404, str(e)) from None
|
|
380
|
+
return {"removed": name}
|
|
381
|
+
|
|
382
|
+
@api.get("/workspaces/{workspace}/files")
|
|
383
|
+
def read_file(workspace: WorkspaceId, path: str) -> dict:
|
|
384
|
+
root = ws_root(workspace)
|
|
385
|
+
try:
|
|
386
|
+
full = resolve_path(root, path)
|
|
387
|
+
except PathEscapeError:
|
|
388
|
+
raise HTTPException(400, "geçersiz yol") from None
|
|
389
|
+
if not full.exists():
|
|
390
|
+
raise HTTPException(404, "dosya yok")
|
|
391
|
+
if not full.is_file():
|
|
392
|
+
raise HTTPException(400, "dosya değil")
|
|
393
|
+
if full.stat().st_size > MAX_FILE:
|
|
394
|
+
raise HTTPException(413, "dosya çok büyük")
|
|
395
|
+
raw = full.read_bytes()
|
|
396
|
+
if b"\x00" in raw[:8192]:
|
|
397
|
+
raise HTTPException(415, "ikili dosya gösterilemez")
|
|
398
|
+
return {"path": path, "content": raw.decode(errors="replace")}
|
|
399
|
+
|
|
400
|
+
@api.put("/workspaces/{workspace}/files")
|
|
401
|
+
def write_file(workspace: WorkspaceId, path: str, body: FileIn) -> dict:
|
|
402
|
+
root = ws_root(workspace)
|
|
403
|
+
try:
|
|
404
|
+
full = resolve_path(root, path)
|
|
405
|
+
except PathEscapeError:
|
|
406
|
+
raise HTTPException(400, "geçersiz yol") from None
|
|
407
|
+
if manager.is_busy(workspace):
|
|
408
|
+
raise HTTPException(409, "çalışma alanında görev sürüyor")
|
|
409
|
+
try:
|
|
410
|
+
full.parent.mkdir(parents=True, exist_ok=True)
|
|
411
|
+
full.write_text(body.content)
|
|
412
|
+
except OSError:
|
|
413
|
+
raise HTTPException(400, "yazılamadı") from None
|
|
414
|
+
return {"path": path, "bytes": len(body.content.encode())}
|
|
415
|
+
|
|
416
|
+
def idle_ws(workspace: str) -> Path:
|
|
417
|
+
root = ws_root(workspace)
|
|
418
|
+
if manager.is_busy(workspace):
|
|
419
|
+
raise HTTPException(409, "çalışma alanında görev sürüyor")
|
|
420
|
+
return root
|
|
421
|
+
|
|
422
|
+
@api.get("/workspaces/{workspace}/memory")
|
|
423
|
+
def get_memory(workspace: WorkspaceId) -> dict:
|
|
424
|
+
root = ws_root(workspace)
|
|
425
|
+
return {"notes": memory.list_notes(root), "history": memory.recent_history(root, 50)}
|
|
426
|
+
|
|
427
|
+
@api.delete("/workspaces/{workspace}/memory/notes/{index}")
|
|
428
|
+
def delete_note(workspace: WorkspaceId, index: Annotated[int, PathParam(ge=0)]) -> dict:
|
|
429
|
+
root = idle_ws(workspace)
|
|
430
|
+
try:
|
|
431
|
+
memory.delete_note(root, index)
|
|
432
|
+
except IndexError:
|
|
433
|
+
raise HTTPException(404, "not yok") from None
|
|
434
|
+
except (OSError, ValueError):
|
|
435
|
+
raise HTTPException(400, "hafıza yazılamadı") from None
|
|
436
|
+
return {"deleted": index}
|
|
437
|
+
|
|
438
|
+
@api.delete("/workspaces/{workspace}/memory/history")
|
|
439
|
+
def clear_memory_history(workspace: WorkspaceId) -> dict:
|
|
440
|
+
root = idle_ws(workspace)
|
|
441
|
+
try:
|
|
442
|
+
memory.clear_history(root)
|
|
443
|
+
except (OSError, ValueError):
|
|
444
|
+
raise HTTPException(400, "hafıza yazılamadı") from None
|
|
445
|
+
return {"cleared": True}
|
|
446
|
+
|
|
447
|
+
@api.get("/settings")
|
|
448
|
+
def get_settings() -> dict:
|
|
449
|
+
cur = usta_settings.load(home)
|
|
450
|
+
return {
|
|
451
|
+
"theme": cur.theme,
|
|
452
|
+
"mode": cur.mode,
|
|
453
|
+
"guard": cur.guard,
|
|
454
|
+
"zoom": cur.zoom,
|
|
455
|
+
"workspace": workspace,
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
@api.put("/settings")
|
|
459
|
+
def put_settings(body: SettingsIn) -> dict:
|
|
460
|
+
import dataclasses
|
|
461
|
+
|
|
462
|
+
cur = usta_settings.load(home)
|
|
463
|
+
changes = {k: v for k, v in body.model_dump().items() if v is not None}
|
|
464
|
+
new = dataclasses.replace(cur, **changes)
|
|
465
|
+
try:
|
|
466
|
+
usta_settings.save(home, new)
|
|
467
|
+
except OSError:
|
|
468
|
+
raise HTTPException(500, "ayar kaydedilemedi") from None
|
|
469
|
+
return {"theme": new.theme, "mode": new.mode, "guard": new.guard, "zoom": new.zoom}
|
|
470
|
+
|
|
471
|
+
# --- MCP: yalnız katalogdan ekleme; serbest komut yok (bkz. mcpcatalog) -------------------
|
|
472
|
+
def mcp_servers() -> dict[str, mcpconfig.Server]:
|
|
473
|
+
try:
|
|
474
|
+
return mcpconfig.load(home)
|
|
475
|
+
except mcpconfig.McpConfigError as e:
|
|
476
|
+
raise HTTPException(500, f"MCP yapılandırması: {e}") from None
|
|
477
|
+
|
|
478
|
+
def mcp_server(name: str) -> mcpconfig.Server:
|
|
479
|
+
server = mcp_servers().get(name)
|
|
480
|
+
if server is None:
|
|
481
|
+
raise HTTPException(404, "sunucu yok")
|
|
482
|
+
return server
|
|
483
|
+
|
|
484
|
+
@api.get("/mcp")
|
|
485
|
+
def mcp_list() -> dict:
|
|
486
|
+
servers = mcp_servers()
|
|
487
|
+
out = []
|
|
488
|
+
for s in servers.values():
|
|
489
|
+
have = mcpconfig.resolve_env(home, s, os.environ)
|
|
490
|
+
out.append(
|
|
491
|
+
{
|
|
492
|
+
"name": s.name,
|
|
493
|
+
"enabled": s.enabled,
|
|
494
|
+
"approve": s.approve,
|
|
495
|
+
"command": list(s.command),
|
|
496
|
+
"env": [{"name": k, "set": k in have} for k in s.env],
|
|
497
|
+
"isolation": s.isolation,
|
|
498
|
+
"image": s.image,
|
|
499
|
+
"allow_hosts": list(s.allow_hosts),
|
|
500
|
+
"pinned": bool(s.pin_image and s.pin_cache),
|
|
501
|
+
}
|
|
502
|
+
)
|
|
503
|
+
catalog = [
|
|
504
|
+
{
|
|
505
|
+
"id": e.id,
|
|
506
|
+
"title": e.title,
|
|
507
|
+
"description": e.description,
|
|
508
|
+
"homepage": e.homepage,
|
|
509
|
+
"command": list(e.command),
|
|
510
|
+
"env": list(e.env),
|
|
511
|
+
"image": e.image,
|
|
512
|
+
"allow_hosts": list(e.allow_hosts),
|
|
513
|
+
"added": e.id in servers,
|
|
514
|
+
}
|
|
515
|
+
for e in mcpcatalog.CATALOG
|
|
516
|
+
]
|
|
517
|
+
return {"servers": out, "catalog": catalog}
|
|
518
|
+
|
|
519
|
+
@api.post("/mcp/catalog/{entry_id}", status_code=201)
|
|
520
|
+
def mcp_add_from_catalog(entry_id: str) -> dict:
|
|
521
|
+
entry = mcpcatalog.get(entry_id)
|
|
522
|
+
if entry is None:
|
|
523
|
+
raise HTTPException(404, "katalogda yok")
|
|
524
|
+
try:
|
|
525
|
+
mcpconfig.add_server(
|
|
526
|
+
home,
|
|
527
|
+
entry.id,
|
|
528
|
+
list(entry.command),
|
|
529
|
+
list(entry.env),
|
|
530
|
+
"ask",
|
|
531
|
+
isolation="container", # katalog sunucuları her zaman yalıtılmış çalışır
|
|
532
|
+
image=entry.image,
|
|
533
|
+
allow_hosts=list(entry.allow_hosts),
|
|
534
|
+
)
|
|
535
|
+
except mcpconfig.McpConfigError as e:
|
|
536
|
+
raise HTTPException(409 if "zaten" in str(e) else 400, str(e)) from None
|
|
537
|
+
return {"name": entry.id}
|
|
538
|
+
|
|
539
|
+
@api.put("/mcp/{name}/secrets/{key}")
|
|
540
|
+
def mcp_set_secret(name: ServerName, key: str, body: SecretIn) -> dict:
|
|
541
|
+
mcp_server(name)
|
|
542
|
+
try:
|
|
543
|
+
mcpconfig.set_secret(home, name, key, body.value)
|
|
544
|
+
except mcpconfig.McpConfigError as e:
|
|
545
|
+
raise HTTPException(404 if "tanımlı değil" in str(e) else 400, str(e)) from None
|
|
546
|
+
return {"set": True}
|
|
547
|
+
|
|
548
|
+
@api.post("/mcp/{name}/enabled")
|
|
549
|
+
def mcp_enable(name: ServerName, body: EnabledIn) -> dict:
|
|
550
|
+
mcp_server(name)
|
|
551
|
+
mcpconfig.set_enabled(home, name, body.enabled)
|
|
552
|
+
return {"enabled": body.enabled}
|
|
553
|
+
|
|
554
|
+
@api.delete("/mcp/{name}")
|
|
555
|
+
def mcp_remove(name: ServerName) -> dict:
|
|
556
|
+
mcp_server(name)
|
|
557
|
+
mcpconfig.remove_server(home, name)
|
|
558
|
+
return {"removed": name}
|
|
559
|
+
|
|
560
|
+
@api.post("/mcp/{name}/test")
|
|
561
|
+
def mcp_test(name: ServerName) -> dict:
|
|
562
|
+
server = mcp_server(name)
|
|
563
|
+
env = mcpconfig.resolve_env(home, server, os.environ)
|
|
564
|
+
try:
|
|
565
|
+
if server.isolation == "host": # yalnız CLI'dan açıkça eklenmiş uyumluluk modu
|
|
566
|
+
with McpClient(list(server.command), env, timeout=90) as client:
|
|
567
|
+
tools = client.list_tools()
|
|
568
|
+
else:
|
|
569
|
+
handle = mcp_runtime.start(server, home=home, env=env, workspace=None, timeout=90)
|
|
570
|
+
try:
|
|
571
|
+
tools = handle.client.list_tools()
|
|
572
|
+
finally:
|
|
573
|
+
handle.close()
|
|
574
|
+
except McpError as e:
|
|
575
|
+
raise HTTPException(502, str(e)) from None
|
|
576
|
+
return {"tools": [{"name": t.name, "description": t.description[:200]} for t in tools]}
|
|
577
|
+
|
|
578
|
+
@api.post("/mcp/{name}/prepare")
|
|
579
|
+
def mcp_prepare(name: ServerName) -> dict:
|
|
580
|
+
server = mcp_server(name)
|
|
581
|
+
if server.isolation != "container":
|
|
582
|
+
raise HTTPException(400, "yalnız konteyner izolasyonlu sunucular hazırlanır")
|
|
583
|
+
try:
|
|
584
|
+
iid, digest = mcp_runtime.prepare(
|
|
585
|
+
server, home=home, env=mcpconfig.resolve_env(home, server, os.environ)
|
|
586
|
+
)
|
|
587
|
+
mcpconfig.set_pin(home, name, iid, digest)
|
|
588
|
+
except McpError as e:
|
|
589
|
+
raise HTTPException(502, str(e)) from None
|
|
590
|
+
return {"pinned": True}
|
|
591
|
+
|
|
592
|
+
@api.post("/workspaces/{workspace}/tasks", status_code=202)
|
|
593
|
+
def create_task(workspace: WorkspaceId, body: TaskIn) -> dict:
|
|
594
|
+
root = ws_root(workspace)
|
|
595
|
+
try:
|
|
596
|
+
files = [(f.name, f.content) for f in body.files]
|
|
597
|
+
prompt = body.prompt if body.apply_plan else skills.expand(home, body.prompt)
|
|
598
|
+
return manager.start(
|
|
599
|
+
workspace, root, prompt, body.mode, body.guard, files, body.apply_plan, body.phased
|
|
600
|
+
).public()
|
|
601
|
+
except PlanNotFound:
|
|
602
|
+
raise HTTPException(
|
|
603
|
+
404, "uygulanacak plan yok (plan modunda başarıyla bitmiş olmalı)"
|
|
604
|
+
) from None
|
|
605
|
+
except ValueError as e:
|
|
606
|
+
raise HTTPException(400, str(e)) from None
|
|
607
|
+
except attachments.AttachmentError as e:
|
|
608
|
+
raise HTTPException(400, str(e)) from None
|
|
609
|
+
except WorkspaceBusy:
|
|
610
|
+
raise HTTPException(409, "çalışma alanında görev sürüyor") from None
|
|
611
|
+
|
|
612
|
+
@api.get("/workspaces/{workspace}/sessions")
|
|
613
|
+
def list_sessions(workspace: WorkspaceId) -> list[dict]:
|
|
614
|
+
ws_root(workspace)
|
|
615
|
+
return manager.sessions.list(workspace)
|
|
616
|
+
|
|
617
|
+
@api.get("/workspaces/{workspace}/sessions/{task_id}")
|
|
618
|
+
def get_session(workspace: WorkspaceId, task_id: str) -> dict:
|
|
619
|
+
ws_root(workspace)
|
|
620
|
+
found = manager.sessions.load(workspace, task_id)
|
|
621
|
+
if found is None:
|
|
622
|
+
raise HTTPException(404, "oturum yok")
|
|
623
|
+
return found
|
|
624
|
+
|
|
625
|
+
@api.post("/tasks/{task_id}/messages", status_code=202)
|
|
626
|
+
def task_message(task_id: str, body: MessageIn) -> dict:
|
|
627
|
+
get_task(task_id)
|
|
628
|
+
try:
|
|
629
|
+
manager.post_message(task_id, body.text)
|
|
630
|
+
except TaskFinished:
|
|
631
|
+
raise HTTPException(409, "görev bitti") from None
|
|
632
|
+
return {"queued": True}
|
|
633
|
+
|
|
634
|
+
@api.get("/model")
|
|
635
|
+
def get_model() -> dict:
|
|
636
|
+
ctl = manager.model_control
|
|
637
|
+
if ctl is None:
|
|
638
|
+
raise HTTPException(404, "model değiştirme bu sunucuda yok")
|
|
639
|
+
return ctl.info()
|
|
640
|
+
|
|
641
|
+
@api.put("/model")
|
|
642
|
+
def put_model(body: ModelIn) -> dict:
|
|
643
|
+
ctl = manager.model_control
|
|
644
|
+
if ctl is None:
|
|
645
|
+
raise HTTPException(404, "model değiştirme bu sunucuda yok")
|
|
646
|
+
if manager.any_busy():
|
|
647
|
+
raise HTTPException(409, "görev sürerken model değiştirilemez")
|
|
648
|
+
try:
|
|
649
|
+
return ctl.switch(body.model, body.api)
|
|
650
|
+
except ModelSwitchError as e:
|
|
651
|
+
raise HTTPException(e.status, e.message) from None
|
|
652
|
+
|
|
653
|
+
# --- bağlı API'ler (anahtarlar geri verilmez) --------------------------------------------
|
|
654
|
+
def api_rows() -> list[dict]:
|
|
655
|
+
ctl = manager.model_control
|
|
656
|
+
rows = (
|
|
657
|
+
[
|
|
658
|
+
{
|
|
659
|
+
"name": "varsayılan",
|
|
660
|
+
"endpoint": ctl.default.endpoint if ctl else "",
|
|
661
|
+
"has_key": bool(ctl and ctl.default.key),
|
|
662
|
+
"builtin": True,
|
|
663
|
+
}
|
|
664
|
+
]
|
|
665
|
+
if ctl
|
|
666
|
+
else []
|
|
667
|
+
)
|
|
668
|
+
try:
|
|
669
|
+
stored = apis.load(home)
|
|
670
|
+
except apis.ApiError as e:
|
|
671
|
+
raise HTTPException(500, str(e)) from None
|
|
672
|
+
rows += [
|
|
673
|
+
{"name": a.name, "endpoint": a.endpoint, "has_key": bool(a.key), "builtin": False}
|
|
674
|
+
for a in stored.values()
|
|
675
|
+
]
|
|
676
|
+
return rows
|
|
677
|
+
|
|
678
|
+
@api.get("/apis")
|
|
679
|
+
def list_apis() -> list[dict]:
|
|
680
|
+
return api_rows()
|
|
681
|
+
|
|
682
|
+
@api.post("/apis", status_code=201)
|
|
683
|
+
def add_api(body: ApiIn) -> dict:
|
|
684
|
+
try:
|
|
685
|
+
a = apis.add(home, body.name, body.endpoint, body.key)
|
|
686
|
+
except apis.ApiError as e:
|
|
687
|
+
raise HTTPException(409 if "zaten" in str(e) else 400, str(e)) from None
|
|
688
|
+
return {"name": a.name}
|
|
689
|
+
|
|
690
|
+
@api.put("/apis/{name}/key")
|
|
691
|
+
def set_api_key(name: ServerName, body: KeyIn) -> dict:
|
|
692
|
+
try:
|
|
693
|
+
apis.set_key(home, name, body.key)
|
|
694
|
+
except apis.ApiError as e:
|
|
695
|
+
raise HTTPException(404 if "yok" in str(e) else 400, str(e)) from None
|
|
696
|
+
return {"has_key": bool(body.key)}
|
|
697
|
+
|
|
698
|
+
@api.delete("/apis/{name}")
|
|
699
|
+
def delete_api(name: ServerName) -> dict:
|
|
700
|
+
ctl = manager.model_control
|
|
701
|
+
if ctl is not None and ctl.api == name:
|
|
702
|
+
raise HTTPException(409, "kullanılan API silinemez; önce başka bir model seç")
|
|
703
|
+
try:
|
|
704
|
+
apis.remove(home, name)
|
|
705
|
+
except apis.ApiError as e:
|
|
706
|
+
raise HTTPException(404, str(e)) from None
|
|
707
|
+
return {"removed": name}
|
|
708
|
+
|
|
709
|
+
@api.get("/tasks/{task_id}")
|
|
710
|
+
def task_info(task_id: str) -> dict:
|
|
711
|
+
return get_task(task_id).public()
|
|
712
|
+
|
|
713
|
+
@api.get("/tasks/{task_id}/events")
|
|
714
|
+
def task_events(task_id: str, last_event_id: Annotated[str | None, Header()] = None):
|
|
715
|
+
task = get_task(task_id)
|
|
716
|
+
try:
|
|
717
|
+
nxt = int(last_event_id) + 1 if last_event_id is not None else 0
|
|
718
|
+
except ValueError:
|
|
719
|
+
raise HTTPException(400, "Last-Event-ID geçersiz") from None
|
|
720
|
+
|
|
721
|
+
def stream():
|
|
722
|
+
i = max(nxt, 0)
|
|
723
|
+
while True:
|
|
724
|
+
events, finished = task.wait_events(i, KEEPALIVE)
|
|
725
|
+
for e in events:
|
|
726
|
+
data = json.dumps(e["data"], ensure_ascii=False)
|
|
727
|
+
yield f"id: {e['i']}\nevent: {e['type']}\ndata: {data}\n\n"
|
|
728
|
+
i += len(events)
|
|
729
|
+
if finished and not events:
|
|
730
|
+
return
|
|
731
|
+
if not events:
|
|
732
|
+
yield ": keepalive\n\n"
|
|
733
|
+
|
|
734
|
+
return StreamingResponse(
|
|
735
|
+
stream(), media_type="text/event-stream", headers={"Cache-Control": "no-cache"}
|
|
736
|
+
)
|
|
737
|
+
|
|
738
|
+
@api.post("/tasks/{task_id}/cancel", status_code=202)
|
|
739
|
+
def cancel_task(task_id: str) -> dict:
|
|
740
|
+
get_task(task_id)
|
|
741
|
+
manager.cancel(task_id)
|
|
742
|
+
return get_task(task_id).public()
|
|
743
|
+
|
|
744
|
+
@api.post("/tasks/{task_id}/revert")
|
|
745
|
+
def revert_task(task_id: str) -> dict:
|
|
746
|
+
task = get_task(task_id)
|
|
747
|
+
if not task.finished.is_set():
|
|
748
|
+
raise HTTPException(409, "görev sürüyor")
|
|
749
|
+
try:
|
|
750
|
+
manager.revert(task_id)
|
|
751
|
+
except WorkspaceBusy:
|
|
752
|
+
raise HTTPException(409, "çalışma alanında görev sürüyor") from None
|
|
753
|
+
except RevertRefused as e:
|
|
754
|
+
raise HTTPException(409, str(e)) from None
|
|
755
|
+
return {"reverted": task_id}
|
|
756
|
+
|
|
757
|
+
@api.get("/tasks/{task_id}/diff", response_class=PlainTextResponse)
|
|
758
|
+
def task_diff(task_id: str) -> str:
|
|
759
|
+
task = get_task(task_id)
|
|
760
|
+
if not task.finished.is_set():
|
|
761
|
+
raise HTTPException(409, "görev sürüyor")
|
|
762
|
+
return task.diff
|
|
763
|
+
|
|
764
|
+
@api.post("/approvals/{approval_id}")
|
|
765
|
+
def resolve_approval(approval_id: str, body: ApprovalIn) -> dict:
|
|
766
|
+
try:
|
|
767
|
+
manager.resolve_approval(approval_id, body.decision == "allow")
|
|
768
|
+
except KeyError:
|
|
769
|
+
raise HTTPException(404, "onay isteği yok") from None
|
|
770
|
+
return {"decision": body.decision}
|
|
771
|
+
|
|
772
|
+
@api.get("/audit")
|
|
773
|
+
def audit(
|
|
774
|
+
workspace: Annotated[str, Query(pattern=ID_PATTERN)],
|
|
775
|
+
limit: Annotated[int, Query(ge=1, le=500)] = 100,
|
|
776
|
+
) -> list[dict]:
|
|
777
|
+
path = home / "audit" / f"{workspace}.jsonl"
|
|
778
|
+
if not path.is_file():
|
|
779
|
+
return []
|
|
780
|
+
with path.open() as f:
|
|
781
|
+
return [json.loads(line) for line in deque(f, maxlen=limit)]
|
|
782
|
+
|
|
783
|
+
@app.get("/preview/{workspace}/{key}/{path:path}", include_in_schema=False)
|
|
784
|
+
def preview(workspace: WorkspaceId, key: str, path: str = "") -> Response:
|
|
785
|
+
"""Çalışma alanındaki statik dosyaları önizler (yalnız GET, anahtar çalışma alanına bağlı)."""
|
|
786
|
+
if not hmac.compare_digest(key.encode(), preview_key(workspace).encode()):
|
|
787
|
+
raise HTTPException(403, "önizleme anahtarı geçersiz")
|
|
788
|
+
root = ws_root(workspace)
|
|
789
|
+
try:
|
|
790
|
+
full = resolve_path(root, path or ".")
|
|
791
|
+
except PathEscapeError:
|
|
792
|
+
raise HTTPException(404, "yok") from None
|
|
793
|
+
if full.is_dir():
|
|
794
|
+
try: # index.html de yeniden çözülür: sembolik bağ ile dışarı kaçamaz
|
|
795
|
+
full = resolve_path(root, f"{path.rstrip('/') or '.'}/index.html")
|
|
796
|
+
except PathEscapeError:
|
|
797
|
+
raise HTTPException(404, "yok") from None
|
|
798
|
+
parts = full.relative_to(root.resolve()).parts
|
|
799
|
+
if not full.is_file() or any(p in filetree.HIDDEN for p in parts):
|
|
800
|
+
raise HTTPException(404, "yok")
|
|
801
|
+
if full.stat().st_size > PREVIEW_MAX:
|
|
802
|
+
raise HTTPException(413, "dosya çok büyük")
|
|
803
|
+
ctype = mimetypes.guess_type(full.name)[0] or "application/octet-stream"
|
|
804
|
+
if ctype.startswith("text/") or ctype in ("application/javascript", "application/json"):
|
|
805
|
+
ctype += "; charset=utf-8"
|
|
806
|
+
return Response(full.read_bytes(), media_type=ctype, headers=PREVIEW_HEADERS)
|
|
807
|
+
|
|
808
|
+
app.include_router(api)
|
|
809
|
+
|
|
810
|
+
if login is not None: # terminalin açtığı portal: tek kullanımlık kodu jetonla takas eder
|
|
811
|
+
|
|
812
|
+
@app.post("/auth/exchange")
|
|
813
|
+
def exchange(body: CodeIn) -> dict:
|
|
814
|
+
if not login.redeem(body.code):
|
|
815
|
+
raise HTTPException(401, "kod geçersiz, süresi dolmuş veya kullanılmış")
|
|
816
|
+
return {"token": token}
|
|
817
|
+
|
|
818
|
+
# Arayüz dosyaları jetonsuz sunulur (içlerinde sır yok); sabit rota listesi, dizin gezinmesi yok.
|
|
819
|
+
for route, (name, ctype) in UI_FILES.items():
|
|
820
|
+
body = (WEB_DIR / name).read_bytes()
|
|
821
|
+
|
|
822
|
+
def ui(body: bytes = body, ctype: str = ctype) -> Response:
|
|
823
|
+
return Response(body, media_type=ctype, headers=UI_HEADERS)
|
|
824
|
+
|
|
825
|
+
app.add_api_route(route, ui, methods=["GET"], include_in_schema=False)
|
|
826
|
+
|
|
827
|
+
app.add_api_route(
|
|
828
|
+
"/favicon.ico",
|
|
829
|
+
lambda: Response(status_code=204, headers=UI_HEADERS),
|
|
830
|
+
methods=["GET"],
|
|
831
|
+
include_in_schema=False,
|
|
832
|
+
)
|
|
833
|
+
|
|
834
|
+
return app
|