omega-code 0.4.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.
- omega/__init__.py +0 -0
- omega/__main__.py +589 -0
- omega/artifacts.py +151 -0
- omega/checkpoint.py +246 -0
- omega/compact.py +106 -0
- omega/config.py +285 -0
- omega/eval/__init__.py +3 -0
- omega/eval/cli.py +127 -0
- omega/eval/examples/plan-version-flag.yaml +11 -0
- omega/eval/examples/relative-age-negative-delta.yaml +14 -0
- omega/eval/examples/version-flag.yaml +10 -0
- omega/eval/manifest.py +129 -0
- omega/eval/prices.py +29 -0
- omega/eval/report.py +135 -0
- omega/eval/runner.py +199 -0
- omega/eval/tasks.py +97 -0
- omega/events.py +145 -0
- omega/export.py +80 -0
- omega/gitlog.py +229 -0
- omega/hooks.py +63 -0
- omega/instructions.py +103 -0
- omega/integrations.py +284 -0
- omega/keys.py +173 -0
- omega/llm.py +442 -0
- omega/loop.py +510 -0
- omega/mcp.py +490 -0
- omega/memory/__init__.py +5 -0
- omega/memory/consolidate.py +103 -0
- omega/memory/curate.py +69 -0
- omega/memory/store.py +321 -0
- omega/memory/tools.py +175 -0
- omega/migrate.py +40 -0
- omega/onboarding.py +242 -0
- omega/permissions.py +137 -0
- omega/secrets.py +173 -0
- omega/server/__init__.py +7 -0
- omega/server/__main__.py +18 -0
- omega/server/app.py +71 -0
- omega/server/auth.py +73 -0
- omega/server/manager.py +287 -0
- omega/server/models.py +123 -0
- omega/server/tasks_api.py +311 -0
- omega/server/terminals.py +245 -0
- omega/server/worker.py +186 -0
- omega/session.py +209 -0
- omega/setup.html +281 -0
- omega/setup_server.py +452 -0
- omega/skills.py +158 -0
- omega/subagent.py +98 -0
- omega/tasks.py +195 -0
- omega/tools.py +590 -0
- omega/trace.py +156 -0
- omega/trajectory.py +146 -0
- omega/ui/__init__.py +0 -0
- omega/ui/composer.py +140 -0
- omega/ui/format.py +708 -0
- omega/ui/plain.py +141 -0
- omega/ui/tui/__init__.py +9 -0
- omega/ui/tui/app.py +958 -0
- omega/ui/tui/history.py +50 -0
- omega/ui/tui/modals.py +292 -0
- omega/ui/tui/onboarding.py +367 -0
- omega/ui/tui/prefs.py +25 -0
- omega/ui/tui/sidebar.py +510 -0
- omega/ui/tui/status.py +115 -0
- omega/ui/tui/theme.py +91 -0
- omega/ui/tui/transcript.py +783 -0
- omega/verify.py +133 -0
- omega_code-0.4.0.dist-info/METADATA +479 -0
- omega_code-0.4.0.dist-info/RECORD +73 -0
- omega_code-0.4.0.dist-info/WHEEL +4 -0
- omega_code-0.4.0.dist-info/entry_points.txt +2 -0
- omega_code-0.4.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,311 @@
|
|
|
1
|
+
"""REST + WebSocket routes for tasks, plus the daemon-wide `/api/models`,
|
|
2
|
+
`/api/connections` and `/api/memory` lookups -- see Phase 9 of the plan for
|
|
3
|
+
the exact contract the SwiftUI app is built against."""
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import asyncio
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from fastapi import APIRouter, Depends, HTTPException, Query, Request, WebSocket, WebSocketDisconnect
|
|
11
|
+
|
|
12
|
+
from .. import artifacts, checkpoint, config, gitlog, mcp, session, tasks, trace
|
|
13
|
+
from ..memory import store
|
|
14
|
+
from . import auth
|
|
15
|
+
from .manager import TaskManager
|
|
16
|
+
from .models import (
|
|
17
|
+
AnswerIn,
|
|
18
|
+
ConfirmIn,
|
|
19
|
+
ConnectionOut,
|
|
20
|
+
HealthOut,
|
|
21
|
+
MemoryHitOut,
|
|
22
|
+
ModeIn,
|
|
23
|
+
ModelCatalogEntry,
|
|
24
|
+
ModelIn,
|
|
25
|
+
PrCreateIn,
|
|
26
|
+
PromptIn,
|
|
27
|
+
TaskCreateIn,
|
|
28
|
+
TaskDetailOut,
|
|
29
|
+
TaskOut,
|
|
30
|
+
UndoIn,
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
router = APIRouter(tags=["tasks"])
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _manager(request: Request) -> TaskManager:
|
|
37
|
+
mgr: TaskManager = request.app.state.tasks_manager
|
|
38
|
+
return mgr
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _get_task_or_404(task_id: str) -> tasks.Task:
|
|
42
|
+
task = tasks.get(task_id)
|
|
43
|
+
if task is None:
|
|
44
|
+
raise HTTPException(status_code=404, detail="no such task")
|
|
45
|
+
return task
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@router.get("/api/health")
|
|
49
|
+
async def health(request: Request) -> HealthOut:
|
|
50
|
+
return HealthOut(version=request.app.state.version)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
# -- tasks CRUD -----------------------------------------------------------
|
|
54
|
+
|
|
55
|
+
@router.get("/api/tasks", dependencies=[Depends(auth.require_token)])
|
|
56
|
+
async def list_tasks_route() -> list[TaskOut]:
|
|
57
|
+
return [TaskOut.from_task(t) for t in tasks.list_tasks()]
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@router.post("/api/tasks", dependencies=[Depends(auth.require_token)])
|
|
61
|
+
async def create_task(body: TaskCreateIn, request: Request) -> TaskOut:
|
|
62
|
+
try:
|
|
63
|
+
task = await asyncio.to_thread(
|
|
64
|
+
tasks.create, body.repo, body.prompt, body.worktree, body.model, body.mode)
|
|
65
|
+
except (ValueError, RuntimeError) as e:
|
|
66
|
+
raise HTTPException(status_code=400, detail=str(e)) from e
|
|
67
|
+
_manager(request).publish_overview_task(task)
|
|
68
|
+
if body.prompt:
|
|
69
|
+
await _manager(request).send_prompt(task, body.prompt)
|
|
70
|
+
task = _get_task_or_404(task.id)
|
|
71
|
+
return TaskOut.from_task(task)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
@router.get("/api/tasks/{task_id}", dependencies=[Depends(auth.require_token)])
|
|
75
|
+
async def get_task(task_id: str) -> TaskDetailOut:
|
|
76
|
+
task = _get_task_or_404(task_id)
|
|
77
|
+
sess = session.load(task.id)
|
|
78
|
+
return TaskDetailOut(**TaskOut.from_task(task).model_dump(), history=sess.history)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
@router.delete("/api/tasks/{task_id}", dependencies=[Depends(auth.require_token)])
|
|
82
|
+
async def delete_task(task_id: str, request: Request,
|
|
83
|
+
delete_worktree: bool = Query(False)) -> dict[str, bool]:
|
|
84
|
+
_get_task_or_404(task_id)
|
|
85
|
+
await _manager(request).shutdown_task(task_id)
|
|
86
|
+
ok = tasks.remove(task_id, delete_worktree=delete_worktree)
|
|
87
|
+
return {"deleted": ok}
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
# -- turn control -----------------------------------------------------------
|
|
91
|
+
|
|
92
|
+
@router.post("/api/tasks/{task_id}/prompt", dependencies=[Depends(auth.require_token)])
|
|
93
|
+
async def send_prompt(task_id: str, body: PromptIn, request: Request) -> TaskOut:
|
|
94
|
+
task = _get_task_or_404(task_id)
|
|
95
|
+
if task.title == "(no prompt yet)":
|
|
96
|
+
task = tasks.update(task_id, title=tasks.title_from_prompt(body.text)) or task
|
|
97
|
+
await _manager(request).send_prompt(task, body.text)
|
|
98
|
+
return TaskOut.from_task(_get_task_or_404(task_id))
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
@router.post("/api/tasks/{task_id}/cancel", dependencies=[Depends(auth.require_token)])
|
|
102
|
+
async def cancel_task(task_id: str, request: Request) -> dict[str, bool]:
|
|
103
|
+
_get_task_or_404(task_id)
|
|
104
|
+
ok = await _manager(request).cancel(task_id)
|
|
105
|
+
return {"cancelled": ok}
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
@router.post("/api/tasks/{task_id}/answer", dependencies=[Depends(auth.require_token)])
|
|
109
|
+
async def answer_task(task_id: str, body: AnswerIn, request: Request) -> dict[str, bool]:
|
|
110
|
+
_get_task_or_404(task_id)
|
|
111
|
+
ok = await _manager(request).send_answer(task_id, body.request_id, body.answer)
|
|
112
|
+
return {"sent": ok}
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
@router.post("/api/tasks/{task_id}/confirm", dependencies=[Depends(auth.require_token)])
|
|
116
|
+
async def confirm_task(task_id: str, body: ConfirmIn, request: Request) -> dict[str, bool]:
|
|
117
|
+
_get_task_or_404(task_id)
|
|
118
|
+
ok = await _manager(request).send_confirm(task_id, body.request_id, body.allow)
|
|
119
|
+
return {"sent": ok}
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
@router.post("/api/tasks/{task_id}/model", dependencies=[Depends(auth.require_token)])
|
|
123
|
+
async def set_model(task_id: str, body: ModelIn, request: Request) -> TaskOut:
|
|
124
|
+
task = _get_task_or_404(task_id)
|
|
125
|
+
task = tasks.update(task_id, model=body.model) or task
|
|
126
|
+
await _manager(request).set_model(task_id, body.model)
|
|
127
|
+
return TaskOut.from_task(task)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
@router.post("/api/tasks/{task_id}/mode", dependencies=[Depends(auth.require_token)])
|
|
131
|
+
async def set_mode(task_id: str, body: ModeIn, request: Request) -> TaskOut:
|
|
132
|
+
task = _get_task_or_404(task_id)
|
|
133
|
+
task = tasks.update(task_id, mode=body.mode) or task
|
|
134
|
+
await _manager(request).set_mode(task_id, body.mode)
|
|
135
|
+
return TaskOut.from_task(task)
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
# -- git / checkpoints --------------------------------------------------
|
|
139
|
+
|
|
140
|
+
@router.get("/api/tasks/{task_id}/git", dependencies=[Depends(auth.require_token)])
|
|
141
|
+
async def task_git(task_id: str) -> dict[str, Any]:
|
|
142
|
+
task = _get_task_or_404(task_id)
|
|
143
|
+
repos = await gitlog.discover_repos_async(Path(task.cwd), max_depth=0)
|
|
144
|
+
if not repos:
|
|
145
|
+
return {"branch": None, "dirty": False, "changes": []}
|
|
146
|
+
repo = repos[0]
|
|
147
|
+
changes = await gitlog.working_tree_async(repo)
|
|
148
|
+
return {
|
|
149
|
+
"branch": repo.branch, "dirty": repo.dirty,
|
|
150
|
+
"changes": [{"path": c.path, "status": c.status, "added": c.added, "removed": c.removed}
|
|
151
|
+
for c in changes],
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
@router.get("/api/tasks/{task_id}/diff", dependencies=[Depends(auth.require_token)])
|
|
156
|
+
async def task_diff(task_id: str, path: str | None = None) -> dict[str, str]:
|
|
157
|
+
task = _get_task_or_404(task_id)
|
|
158
|
+
if path:
|
|
159
|
+
repos = await gitlog.discover_repos_async(Path(task.cwd), max_depth=0)
|
|
160
|
+
text = await gitlog.diff_async(repos[0], path) if repos else ""
|
|
161
|
+
else:
|
|
162
|
+
text = await asyncio.to_thread(checkpoint.diff, task.id, None, task.cwd)
|
|
163
|
+
return {"diff": text}
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
@router.post("/api/tasks/{task_id}/undo", dependencies=[Depends(auth.require_token)])
|
|
167
|
+
async def task_undo(task_id: str, body: UndoIn) -> dict[str, str]:
|
|
168
|
+
task = _get_task_or_404(task_id)
|
|
169
|
+
result = await asyncio.to_thread(checkpoint.undo, task.id, body.steps, task.cwd)
|
|
170
|
+
return {"result": result}
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
# -- artifacts / trace / jobs --------------------------------------------
|
|
174
|
+
|
|
175
|
+
@router.get("/api/tasks/{task_id}/artifacts", dependencies=[Depends(auth.require_token)])
|
|
176
|
+
async def task_artifacts(task_id: str) -> list[dict[str, Any]]:
|
|
177
|
+
task = _get_task_or_404(task_id)
|
|
178
|
+
return artifacts.list_artifacts(task.id)
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
@router.get("/api/tasks/{task_id}/artifacts/{artifact_id}", dependencies=[Depends(auth.require_token)])
|
|
182
|
+
async def task_artifact(task_id: str, artifact_id: str,
|
|
183
|
+
offset: int = 0, limit: int = 0) -> dict[str, str]:
|
|
184
|
+
task = _get_task_or_404(task_id)
|
|
185
|
+
text = artifacts.fetch(task.id, artifact_id, offset, limit or artifacts.PAGE_CHARS)
|
|
186
|
+
return {"content": text}
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
@router.get("/api/tasks/{task_id}/trace", dependencies=[Depends(auth.require_token)])
|
|
190
|
+
async def task_trace(task_id: str) -> dict[str, str]:
|
|
191
|
+
task = _get_task_or_404(task_id)
|
|
192
|
+
return {"trace": trace.render_timeline(task.id, raw_json=True)}
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
@router.get("/api/tasks/{task_id}/jobs", dependencies=[Depends(auth.require_token)])
|
|
196
|
+
async def task_jobs(task_id: str, request: Request) -> list[dict[str, Any]]:
|
|
197
|
+
_get_task_or_404(task_id)
|
|
198
|
+
return await _manager(request).get_jobs(task_id)
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
# -- pull requests --------------------------------------------------------
|
|
202
|
+
|
|
203
|
+
@router.post("/api/tasks/{task_id}/pr", dependencies=[Depends(auth.require_token)])
|
|
204
|
+
async def create_pr(task_id: str, body: PrCreateIn) -> dict[str, Any]:
|
|
205
|
+
task = _get_task_or_404(task_id)
|
|
206
|
+
if not task.branch:
|
|
207
|
+
raise HTTPException(status_code=400, detail="task has no branch (not a worktree task)")
|
|
208
|
+
args = ["gh", "pr", "create", "--head", task.branch,
|
|
209
|
+
"--title", body.title or task.title, "--body", body.body or ""]
|
|
210
|
+
if body.draft:
|
|
211
|
+
args.append("--draft")
|
|
212
|
+
proc = await asyncio.create_subprocess_exec(
|
|
213
|
+
*args, cwd=task.repo, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE)
|
|
214
|
+
stdout, stderr = await proc.communicate()
|
|
215
|
+
if proc.returncode != 0:
|
|
216
|
+
raise HTTPException(status_code=502, detail=stderr.decode(errors="replace")[:500])
|
|
217
|
+
info = await asyncio.to_thread(tasks.pr_info, task)
|
|
218
|
+
if info is not None:
|
|
219
|
+
tasks.update(task_id, pr=info)
|
|
220
|
+
return {"url": stdout.decode().strip(), "pr": info}
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
# -- daemon-wide lookups --------------------------------------------------
|
|
224
|
+
|
|
225
|
+
@router.get("/api/models", dependencies=[Depends(auth.require_token)])
|
|
226
|
+
async def list_models() -> list[ModelCatalogEntry]:
|
|
227
|
+
cfg = config.load()
|
|
228
|
+
return [ModelCatalogEntry(alias=alias, model=m.model, provider=m.provider, context=m.context)
|
|
229
|
+
for alias, m in sorted(cfg.models.items())]
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
@router.get("/api/connections", dependencies=[Depends(auth.require_token)])
|
|
233
|
+
async def list_connections() -> list[ConnectionOut]:
|
|
234
|
+
return [ConnectionOut(name=name, enabled=st.enabled, state=st.state, tools=st.tools,
|
|
235
|
+
error=st.error, last_used=st.last_used)
|
|
236
|
+
for name, st in sorted(mcp.status().items())]
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
@router.post("/api/connections/{name}/connect", dependencies=[Depends(auth.require_token)])
|
|
240
|
+
async def connect_connection(name: str) -> ConnectionOut:
|
|
241
|
+
st = await mcp.connect(name)
|
|
242
|
+
return ConnectionOut(name=st.name, enabled=st.enabled, state=st.state, tools=st.tools,
|
|
243
|
+
error=st.error, last_used=st.last_used)
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
@router.get("/api/memory", dependencies=[Depends(auth.require_token)])
|
|
247
|
+
async def query_memory(q: str, scope: str = "both", type: str | None = None,
|
|
248
|
+
limit: int = 8, task_id: str | None = None) -> list[MemoryHitOut]:
|
|
249
|
+
cwd = _get_task_or_404(task_id).cwd if task_id else None
|
|
250
|
+
scopes = ["project", "global"] if scope == "both" else [scope]
|
|
251
|
+
hits: list[MemoryHitOut] = []
|
|
252
|
+
for sc in scopes:
|
|
253
|
+
if sc == "project" and not store.db_exists("project", cwd):
|
|
254
|
+
continue
|
|
255
|
+
try:
|
|
256
|
+
rows = await asyncio.to_thread(store.search, sc, q, type, limit, False, cwd)
|
|
257
|
+
except ValueError as e:
|
|
258
|
+
raise HTTPException(status_code=400, detail=str(e)) from e
|
|
259
|
+
hits.extend(MemoryHitOut(id=r["id"], scope=sc, type=r["type"], title=r["title"],
|
|
260
|
+
body=r["body"], confidence=r["confidence"],
|
|
261
|
+
importance=r["importance"]) for r in rows)
|
|
262
|
+
return hits[:limit]
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
# -- websockets -----------------------------------------------------------
|
|
266
|
+
|
|
267
|
+
async def _relay_events(websocket: WebSocket, queue: asyncio.Queue[str]) -> None:
|
|
268
|
+
"""Both `/ws/tasks/{id}` and `/ws/overview` are server-push-only from the
|
|
269
|
+
app's side. A send on a connection the client has closed raises
|
|
270
|
+
`WebSocketDisconnect` (uvicorn tears the ASGI channel down on the
|
|
271
|
+
client's close frame), which the caller catches -- so the loop ends
|
|
272
|
+
without a separate `receive()`-based watchdog."""
|
|
273
|
+
while True:
|
|
274
|
+
await websocket.send_text(await queue.get())
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
@router.websocket("/ws/tasks/{task_id}")
|
|
278
|
+
async def task_ws(websocket: WebSocket, task_id: str) -> None:
|
|
279
|
+
if not auth.check_ws_token(websocket):
|
|
280
|
+
await websocket.close(code=1008)
|
|
281
|
+
return
|
|
282
|
+
if tasks.get(task_id) is None:
|
|
283
|
+
await websocket.close(code=1008, reason="no such task")
|
|
284
|
+
return
|
|
285
|
+
|
|
286
|
+
mgr: TaskManager = websocket.app.state.tasks_manager
|
|
287
|
+
await websocket.accept()
|
|
288
|
+
queue = mgr.subscribe_task(task_id)
|
|
289
|
+
try:
|
|
290
|
+
await _relay_events(websocket, queue)
|
|
291
|
+
except WebSocketDisconnect:
|
|
292
|
+
pass
|
|
293
|
+
finally:
|
|
294
|
+
mgr.unsubscribe_task(task_id, queue)
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
@router.websocket("/ws/overview")
|
|
298
|
+
async def overview_ws(websocket: WebSocket) -> None:
|
|
299
|
+
if not auth.check_ws_token(websocket):
|
|
300
|
+
await websocket.close(code=1008)
|
|
301
|
+
return
|
|
302
|
+
|
|
303
|
+
mgr: TaskManager = websocket.app.state.tasks_manager
|
|
304
|
+
await websocket.accept()
|
|
305
|
+
queue = mgr.subscribe_overview()
|
|
306
|
+
try:
|
|
307
|
+
await _relay_events(websocket, queue)
|
|
308
|
+
except WebSocketDisconnect:
|
|
309
|
+
pass
|
|
310
|
+
finally:
|
|
311
|
+
mgr.unsubscribe_overview(queue)
|
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
"""Server-side PTY terminals: `pty.fork()` runs the user's login shell, kept
|
|
2
|
+
alive for the life of the daemon (surviving Omega.app restarts, per Phase 9
|
|
3
|
+
of the plan) and relayed byte-for-byte over `/ws/terminals/{id}`."""
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import asyncio
|
|
7
|
+
import fcntl
|
|
8
|
+
import json
|
|
9
|
+
import os
|
|
10
|
+
import pty
|
|
11
|
+
import secrets
|
|
12
|
+
import signal
|
|
13
|
+
import struct
|
|
14
|
+
import termios
|
|
15
|
+
import time
|
|
16
|
+
from dataclasses import dataclass, field
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
|
|
19
|
+
from fastapi import APIRouter, Depends, HTTPException, Request, WebSocket, WebSocketDisconnect
|
|
20
|
+
|
|
21
|
+
from . import auth
|
|
22
|
+
from .models import TerminalCreateIn, TerminalOut
|
|
23
|
+
|
|
24
|
+
READ_CHUNK = 4096
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass
|
|
28
|
+
class Terminal:
|
|
29
|
+
id: str
|
|
30
|
+
task_id: str | None
|
|
31
|
+
cwd: str
|
|
32
|
+
created: float
|
|
33
|
+
pid: int
|
|
34
|
+
master_fd: int
|
|
35
|
+
subscribers: set[asyncio.Queue[bytes]] = field(default_factory=set)
|
|
36
|
+
reader_task: asyncio.Task[None] | None = None
|
|
37
|
+
alive: bool = True
|
|
38
|
+
|
|
39
|
+
def to_out(self) -> TerminalOut:
|
|
40
|
+
return TerminalOut(id=self.id, task_id=self.task_id, pid=self.pid,
|
|
41
|
+
cwd=self.cwd, created=self.created)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class TerminalManager:
|
|
45
|
+
def __init__(self) -> None:
|
|
46
|
+
self._terminals: dict[str, Terminal] = {}
|
|
47
|
+
|
|
48
|
+
def list(self) -> list[Terminal]:
|
|
49
|
+
return sorted(self._terminals.values(), key=lambda t: t.created)
|
|
50
|
+
|
|
51
|
+
def get(self, terminal_id: str) -> Terminal | None:
|
|
52
|
+
return self._terminals.get(terminal_id)
|
|
53
|
+
|
|
54
|
+
def create(self, task_id: str | None, cwd: str | None) -> Terminal:
|
|
55
|
+
work_dir = os.path.expanduser(cwd) if cwd else str(Path.home())
|
|
56
|
+
if not Path(work_dir).is_dir():
|
|
57
|
+
raise ValueError(f"{work_dir!r} is not a directory")
|
|
58
|
+
shell = os.environ.get("SHELL", "/bin/zsh")
|
|
59
|
+
|
|
60
|
+
pid, master_fd = pty.fork()
|
|
61
|
+
if pid == 0:
|
|
62
|
+
# Child: exec the login shell in the requested cwd. os._exit on
|
|
63
|
+
# any failure here -- a raised exception in the forked child would
|
|
64
|
+
# otherwise re-enter the parent's (already-forked) asyncio state.
|
|
65
|
+
try:
|
|
66
|
+
os.chdir(work_dir)
|
|
67
|
+
os.execvp(shell, [shell, "-l"])
|
|
68
|
+
except Exception:
|
|
69
|
+
os._exit(1)
|
|
70
|
+
|
|
71
|
+
term = Terminal(id=secrets.token_hex(6), task_id=task_id, cwd=work_dir,
|
|
72
|
+
created=time.time(), pid=pid, master_fd=master_fd)
|
|
73
|
+
self._terminals[term.id] = term
|
|
74
|
+
term.reader_task = asyncio.create_task(self._pump(term))
|
|
75
|
+
return term
|
|
76
|
+
|
|
77
|
+
async def _pump(self, term: Terminal) -> None:
|
|
78
|
+
"""One background reader per terminal, fanning bytes out to every
|
|
79
|
+
subscribed WS connection -- lets several viewers watch the same shell."""
|
|
80
|
+
loop = asyncio.get_event_loop()
|
|
81
|
+
try:
|
|
82
|
+
while True:
|
|
83
|
+
try:
|
|
84
|
+
data = await loop.run_in_executor(None, os.read, term.master_fd, READ_CHUNK)
|
|
85
|
+
except OSError:
|
|
86
|
+
break
|
|
87
|
+
if not data:
|
|
88
|
+
break
|
|
89
|
+
for q in term.subscribers:
|
|
90
|
+
q.put_nowait(data)
|
|
91
|
+
finally:
|
|
92
|
+
term.alive = False
|
|
93
|
+
|
|
94
|
+
def write(self, terminal_id: str, data: bytes) -> bool:
|
|
95
|
+
term = self._terminals.get(terminal_id)
|
|
96
|
+
if term is None or not term.alive:
|
|
97
|
+
return False
|
|
98
|
+
try:
|
|
99
|
+
os.write(term.master_fd, data)
|
|
100
|
+
except OSError:
|
|
101
|
+
return False
|
|
102
|
+
return True
|
|
103
|
+
|
|
104
|
+
def resize(self, terminal_id: str, cols: int, rows: int) -> bool:
|
|
105
|
+
term = self._terminals.get(terminal_id)
|
|
106
|
+
if term is None or not term.alive:
|
|
107
|
+
return False
|
|
108
|
+
packed = struct.pack("HHHH", rows, cols, 0, 0)
|
|
109
|
+
try:
|
|
110
|
+
fcntl.ioctl(term.master_fd, termios.TIOCSWINSZ, packed)
|
|
111
|
+
except OSError:
|
|
112
|
+
return False
|
|
113
|
+
return True
|
|
114
|
+
|
|
115
|
+
def subscribe(self, terminal_id: str) -> asyncio.Queue[bytes] | None:
|
|
116
|
+
term = self._terminals.get(terminal_id)
|
|
117
|
+
if term is None:
|
|
118
|
+
return None
|
|
119
|
+
q: asyncio.Queue[bytes] = asyncio.Queue()
|
|
120
|
+
term.subscribers.add(q)
|
|
121
|
+
return q
|
|
122
|
+
|
|
123
|
+
def unsubscribe(self, terminal_id: str, q: asyncio.Queue[bytes]) -> None:
|
|
124
|
+
term = self._terminals.get(terminal_id)
|
|
125
|
+
if term is not None:
|
|
126
|
+
term.subscribers.discard(q)
|
|
127
|
+
|
|
128
|
+
def kill(self, terminal_id: str) -> bool:
|
|
129
|
+
term = self._terminals.pop(terminal_id, None)
|
|
130
|
+
if term is None:
|
|
131
|
+
return False
|
|
132
|
+
if term.reader_task is not None:
|
|
133
|
+
term.reader_task.cancel()
|
|
134
|
+
try:
|
|
135
|
+
os.killpg(os.getpgid(term.pid), signal.SIGHUP)
|
|
136
|
+
except (ProcessLookupError, PermissionError):
|
|
137
|
+
pass
|
|
138
|
+
try:
|
|
139
|
+
os.close(term.master_fd)
|
|
140
|
+
except OSError:
|
|
141
|
+
pass
|
|
142
|
+
return True
|
|
143
|
+
|
|
144
|
+
def kill_all(self) -> None:
|
|
145
|
+
for terminal_id in list(self._terminals):
|
|
146
|
+
self.kill(terminal_id)
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
router = APIRouter(tags=["terminals"])
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _manager(request: Request) -> TerminalManager:
|
|
153
|
+
mgr: TerminalManager = request.app.state.terminals
|
|
154
|
+
return mgr
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
@router.get("/api/terminals", dependencies=[Depends(auth.require_token)])
|
|
158
|
+
async def list_terminals(request: Request) -> list[TerminalOut]:
|
|
159
|
+
return [t.to_out() for t in _manager(request).list()]
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
@router.post("/api/terminals", dependencies=[Depends(auth.require_token)])
|
|
163
|
+
async def create_terminal(body: TerminalCreateIn, request: Request) -> TerminalOut:
|
|
164
|
+
try:
|
|
165
|
+
term = _manager(request).create(body.task_id, body.cwd)
|
|
166
|
+
except ValueError as e:
|
|
167
|
+
raise HTTPException(status_code=400, detail=str(e)) from e
|
|
168
|
+
await _publish_overview(request)
|
|
169
|
+
return term.to_out()
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
@router.delete("/api/terminals/{terminal_id}", dependencies=[Depends(auth.require_token)])
|
|
173
|
+
async def delete_terminal(terminal_id: str, request: Request) -> dict[str, bool]:
|
|
174
|
+
ok = _manager(request).kill(terminal_id)
|
|
175
|
+
if not ok:
|
|
176
|
+
raise HTTPException(status_code=404, detail="no such terminal")
|
|
177
|
+
await _publish_overview(request)
|
|
178
|
+
return {"deleted": True}
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
async def _publish_overview(request: Request) -> None:
|
|
182
|
+
from .manager import TaskManager
|
|
183
|
+
tasks_mgr: TaskManager = request.app.state.tasks_manager
|
|
184
|
+
terms = [t.to_out().model_dump() for t in _manager(request).list()]
|
|
185
|
+
tasks_mgr.publish_overview_terminals(terms)
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
@router.websocket("/ws/terminals/{terminal_id}")
|
|
189
|
+
async def terminal_ws(websocket: WebSocket, terminal_id: str) -> None:
|
|
190
|
+
if not auth.check_ws_token(websocket):
|
|
191
|
+
await websocket.close(code=1008)
|
|
192
|
+
return
|
|
193
|
+
mgr: TerminalManager = websocket.app.state.terminals
|
|
194
|
+
term = mgr.get(terminal_id)
|
|
195
|
+
if term is None:
|
|
196
|
+
await websocket.close(code=1008, reason="no such terminal")
|
|
197
|
+
return
|
|
198
|
+
|
|
199
|
+
await websocket.accept()
|
|
200
|
+
queue = mgr.subscribe(terminal_id)
|
|
201
|
+
assert queue is not None
|
|
202
|
+
|
|
203
|
+
async def pump_out() -> None:
|
|
204
|
+
while True:
|
|
205
|
+
data = await queue.get()
|
|
206
|
+
await websocket.send_bytes(data)
|
|
207
|
+
|
|
208
|
+
sender = asyncio.create_task(pump_out())
|
|
209
|
+
try:
|
|
210
|
+
while True:
|
|
211
|
+
message = await websocket.receive()
|
|
212
|
+
if message.get("type") == "websocket.disconnect":
|
|
213
|
+
break
|
|
214
|
+
data = message.get("bytes")
|
|
215
|
+
if data is not None:
|
|
216
|
+
mgr.write(terminal_id, data)
|
|
217
|
+
continue
|
|
218
|
+
text = message.get("text")
|
|
219
|
+
if text is None:
|
|
220
|
+
continue
|
|
221
|
+
resized = _try_resize(text)
|
|
222
|
+
if resized is not None:
|
|
223
|
+
mgr.resize(terminal_id, *resized)
|
|
224
|
+
else:
|
|
225
|
+
mgr.write(terminal_id, text.encode())
|
|
226
|
+
except WebSocketDisconnect:
|
|
227
|
+
pass
|
|
228
|
+
finally:
|
|
229
|
+
sender.cancel()
|
|
230
|
+
mgr.unsubscribe(terminal_id, queue)
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def _try_resize(text: str) -> tuple[int, int] | None:
|
|
234
|
+
try:
|
|
235
|
+
obj = json.loads(text)
|
|
236
|
+
except json.JSONDecodeError:
|
|
237
|
+
return None
|
|
238
|
+
resize = obj.get("resize") if isinstance(obj, dict) else None
|
|
239
|
+
if not (isinstance(resize, list) and len(resize) == 2):
|
|
240
|
+
return None
|
|
241
|
+
try:
|
|
242
|
+
cols, rows = int(resize[0]), int(resize[1])
|
|
243
|
+
except (TypeError, ValueError):
|
|
244
|
+
return None
|
|
245
|
+
return cols, rows
|