python-codex 0.2.2__py3-none-any.whl → 0.2.4__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.
- pycodex/cli.py +21 -13
- pycodex/context.py +4 -1
- pycodex/feishu_link.py +32 -14
- {python_codex-0.2.2.dist-info → python_codex-0.2.4.dist-info}/METADATA +21 -4
- {python_codex-0.2.2.dist-info → python_codex-0.2.4.dist-info}/RECORD +13 -11
- workspace_server/__init__.py +19 -3
- workspace_server/app.py +546 -373
- workspace_server/workspace.html +53 -3
- workspace_server/workspaces.html +551 -0
- workspace_server/workspaces.py +561 -0
- {python_codex-0.2.2.dist-info → python_codex-0.2.4.dist-info}/WHEEL +0 -0
- {python_codex-0.2.2.dist-info → python_codex-0.2.4.dist-info}/entry_points.txt +0 -0
- {python_codex-0.2.2.dist-info → python_codex-0.2.4.dist-info}/licenses/LICENSE +0 -0
workspace_server/app.py
CHANGED
|
@@ -3,9 +3,8 @@ import asyncio
|
|
|
3
3
|
import html
|
|
4
4
|
import json
|
|
5
5
|
import os
|
|
6
|
+
import secrets
|
|
6
7
|
import threading
|
|
7
|
-
import tempfile
|
|
8
|
-
from uuid import uuid4
|
|
9
8
|
from dataclasses import asdict, is_dataclass
|
|
10
9
|
try:
|
|
11
10
|
from contextlib import asynccontextmanager
|
|
@@ -13,8 +12,8 @@ except ImportError: # pragma: no cover - Python 3.6 compatibility
|
|
|
13
12
|
asynccontextmanager = None
|
|
14
13
|
from pathlib import Path
|
|
15
14
|
|
|
16
|
-
from fastapi import FastAPI, HTTPException, WebSocket, WebSocketDisconnect
|
|
17
|
-
from fastapi.responses import HTMLResponse, JSONResponse, Response
|
|
15
|
+
from fastapi import FastAPI, HTTPException, Request, WebSocket, WebSocketDisconnect
|
|
16
|
+
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse, Response
|
|
18
17
|
|
|
19
18
|
from pycodex.cli import build_agent, build_cli_queue, build_model, configure_loguru
|
|
20
19
|
from pycodex.interactive_session import run_interactive_session
|
|
@@ -31,6 +30,14 @@ from pycodex.utils.visualize import (
|
|
|
31
30
|
shorten_title,
|
|
32
31
|
tool_summary,
|
|
33
32
|
)
|
|
33
|
+
from .workspaces import (
|
|
34
|
+
WorkspaceDefinition,
|
|
35
|
+
WorkspaceEntry,
|
|
36
|
+
WorkspaceRegistry,
|
|
37
|
+
WorkspaceSessionManager,
|
|
38
|
+
load_workspace_definitions,
|
|
39
|
+
session_snapshot,
|
|
40
|
+
)
|
|
34
41
|
import typing
|
|
35
42
|
|
|
36
43
|
|
|
@@ -45,55 +52,6 @@ JSONValue = typing.Union[
|
|
|
45
52
|
]
|
|
46
53
|
|
|
47
54
|
|
|
48
|
-
class WorkspaceStateStore:
|
|
49
|
-
def __init__(self, board_path: "typing.Union[Path, None]") -> None:
|
|
50
|
-
self.path = None if board_path is None else board_path.with_suffix(".pycodex-ws.json")
|
|
51
|
-
|
|
52
|
-
def load_tabs(self) -> "typing.List[typing.Dict[str, str]]":
|
|
53
|
-
if self.path is None or not self.path.is_file():
|
|
54
|
-
return []
|
|
55
|
-
|
|
56
|
-
try:
|
|
57
|
-
payload = json.loads(
|
|
58
|
-
self.path.read_text(encoding="utf-8", errors="replace") or "{}"
|
|
59
|
-
)
|
|
60
|
-
except (OSError, ValueError):
|
|
61
|
-
return []
|
|
62
|
-
|
|
63
|
-
tabs = payload.get("tabs") if isinstance(payload, dict) else None
|
|
64
|
-
if not isinstance(tabs, list):
|
|
65
|
-
return []
|
|
66
|
-
|
|
67
|
-
result = []
|
|
68
|
-
for tab in tabs:
|
|
69
|
-
if not isinstance(tab, dict):
|
|
70
|
-
continue
|
|
71
|
-
title = str(tab.get("title") or "").strip()
|
|
72
|
-
rollout_path = str(tab.get("rollout_path") or "").strip()
|
|
73
|
-
if title or rollout_path:
|
|
74
|
-
result.append({"title": title, "rollout_path": rollout_path})
|
|
75
|
-
return result
|
|
76
|
-
|
|
77
|
-
def save_tabs(self, tabs: "typing.Iterable[typing.Dict[str, str]]") -> None:
|
|
78
|
-
if self.path is None:
|
|
79
|
-
return
|
|
80
|
-
|
|
81
|
-
state_tabs = [
|
|
82
|
-
{
|
|
83
|
-
"title": str(tab.get("title") or ""),
|
|
84
|
-
"rollout_path": str(tab.get("rollout_path") or ""),
|
|
85
|
-
}
|
|
86
|
-
for tab in tabs
|
|
87
|
-
]
|
|
88
|
-
payload = json.dumps(
|
|
89
|
-
{"version": 1, "tabs": state_tabs},
|
|
90
|
-
ensure_ascii=False,
|
|
91
|
-
indent=2,
|
|
92
|
-
) + "\n"
|
|
93
|
-
self.path.parent.mkdir(parents=True, exist_ok=True)
|
|
94
|
-
self.path.write_text(payload, encoding="utf-8")
|
|
95
|
-
|
|
96
|
-
|
|
97
55
|
def build_parser() -> "argparse.ArgumentParser":
|
|
98
56
|
parser = argparse.ArgumentParser(
|
|
99
57
|
prog="pycodex-ws",
|
|
@@ -105,9 +63,17 @@ def build_parser() -> "argparse.ArgumentParser":
|
|
|
105
63
|
help="Bind address as host:port, for example 0.0.0.0:6007.",
|
|
106
64
|
)
|
|
107
65
|
parser.add_argument(
|
|
108
|
-
"--
|
|
66
|
+
"--workspace-config",
|
|
67
|
+
default="./workspaces.json",
|
|
68
|
+
help=(
|
|
69
|
+
"Optional JSON file listing workspaces. Each entry needs `id`, "
|
|
70
|
+
"`board`, and `work_dir`; routes are served under /w/<id>/."
|
|
71
|
+
),
|
|
72
|
+
)
|
|
73
|
+
parser.add_argument(
|
|
74
|
+
"--password",
|
|
109
75
|
default=None,
|
|
110
|
-
help="Optional
|
|
76
|
+
help="Optional password required to open the workspace server.",
|
|
111
77
|
)
|
|
112
78
|
parser.add_argument(
|
|
113
79
|
"--config",
|
|
@@ -146,32 +112,24 @@ def build_parser() -> "argparse.ArgumentParser":
|
|
|
146
112
|
return parser
|
|
147
113
|
|
|
148
114
|
|
|
149
|
-
def
|
|
150
|
-
target: str,
|
|
151
|
-
board: "typing.Union[str, None]" = None,
|
|
152
|
-
) -> "typing.Tuple[str, int, typing.Union[Path, None]]":
|
|
115
|
+
def parse_listen(target: str) -> "typing.Tuple[str, int]":
|
|
153
116
|
target_text = str(target or "").strip() or "127.0.0.1:6007"
|
|
154
|
-
board_text = board
|
|
155
|
-
if "+" in target_text:
|
|
156
|
-
target_text, suffix = target_text.split("+", 1)
|
|
157
|
-
if board_text is None:
|
|
158
|
-
board_text = suffix
|
|
159
117
|
if ":" not in target_text:
|
|
160
|
-
raise ValueError("workspace target must look like host:port")
|
|
118
|
+
raise ValueError("workspace listen target must look like host:port")
|
|
161
119
|
host, port_text = target_text.rsplit(":", 1)
|
|
162
120
|
host = host.strip() or "127.0.0.1"
|
|
163
121
|
try:
|
|
164
122
|
port = int(port_text)
|
|
165
123
|
except ValueError as exc:
|
|
166
124
|
raise ValueError("workspace port must be an integer") from exc
|
|
167
|
-
|
|
168
|
-
return host, port, board_path
|
|
125
|
+
return host, port
|
|
169
126
|
|
|
170
127
|
|
|
171
128
|
SessionFactory = typing.Callable[[], object]
|
|
172
129
|
ThreadedSessionFactory = typing.Callable[[], "WorkspaceInteractiveSession"]
|
|
173
130
|
SESSION_CLOSE_TIMEOUT_SECONDS = 2.0
|
|
174
131
|
SPINNER_STATUS_PREVIEW_LIMIT = 180
|
|
132
|
+
AUTH_COOKIE_NAME = "pycodex_ws_auth"
|
|
175
133
|
|
|
176
134
|
|
|
177
135
|
class WebSessionView:
|
|
@@ -870,183 +828,307 @@ class ThreadedWorkspaceInteractiveSession:
|
|
|
870
828
|
await asyncio.wrap_future(future)
|
|
871
829
|
|
|
872
830
|
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
state_tabs = self._state_store.load_tabs()
|
|
889
|
-
if not state_tabs:
|
|
890
|
-
await self.create_session()
|
|
891
|
-
return
|
|
892
|
-
for tab in state_tabs:
|
|
893
|
-
await self.create_session(
|
|
894
|
-
title=str(tab.get("title") or ""),
|
|
895
|
-
rollout_path=str(tab.get("rollout_path") or ""),
|
|
896
|
-
)
|
|
831
|
+
def create_app(
|
|
832
|
+
session_source: "typing.Union[WorkspaceSessionManager, SessionFactory]",
|
|
833
|
+
board_path: "typing.Union[Path, None]",
|
|
834
|
+
password: "typing.Union[str, None]" = None,
|
|
835
|
+
) -> FastAPI:
|
|
836
|
+
manager = (
|
|
837
|
+
session_source
|
|
838
|
+
if isinstance(session_source, WorkspaceSessionManager)
|
|
839
|
+
else WorkspaceSessionManager(session_source, board_path)
|
|
840
|
+
)
|
|
841
|
+
app = _create_lifespan_app(manager.start, manager.close)
|
|
842
|
+
auth_token = _install_auth(app, password)
|
|
843
|
+
_install_workspace_routes(app, manager, board_path)
|
|
844
|
+
app.state.workspace_auth_token = auth_token
|
|
845
|
+
return app
|
|
897
846
|
|
|
898
|
-
async def close(self) -> None:
|
|
899
|
-
sessions = list(self._sessions.values())
|
|
900
|
-
watchers = list(self._state_watchers.values())
|
|
901
|
-
self._sessions.clear()
|
|
902
|
-
self._session_order = []
|
|
903
|
-
self._state_watchers.clear()
|
|
904
|
-
self._persisted_titles.clear()
|
|
905
|
-
for watcher in watchers:
|
|
906
|
-
watcher.cancel()
|
|
907
|
-
if watchers:
|
|
908
|
-
await asyncio.gather(*watchers, return_exceptions=True)
|
|
909
|
-
for session in sessions:
|
|
910
|
-
await session.close()
|
|
911
|
-
|
|
912
|
-
async def create_session(
|
|
913
|
-
self,
|
|
914
|
-
title: str = "",
|
|
915
|
-
rollout_path: str = "",
|
|
916
|
-
) -> str:
|
|
917
|
-
async with self._lock:
|
|
918
|
-
session_id = uuid7_string()
|
|
919
|
-
session = self._session_factory()
|
|
920
|
-
await session.start()
|
|
921
847
|
|
|
922
|
-
|
|
923
|
-
|
|
848
|
+
def create_multi_workspace_app(
|
|
849
|
+
registry: 'WorkspaceRegistry',
|
|
850
|
+
password: "typing.Union[str, None]" = None,
|
|
851
|
+
) -> FastAPI:
|
|
852
|
+
app = _create_lifespan_app(registry.start, registry.close)
|
|
853
|
+
auth_token = _install_auth(app, password)
|
|
924
854
|
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
855
|
+
@app.get("/")
|
|
856
|
+
async def index() -> Response:
|
|
857
|
+
return _html_response(_render_workspaces_manager_shell())
|
|
858
|
+
|
|
859
|
+
@app.get("/favicon.ico")
|
|
860
|
+
async def favicon() -> Response:
|
|
861
|
+
return Response(status_code=204)
|
|
862
|
+
|
|
863
|
+
@app.get("/api/workspaces")
|
|
864
|
+
async def workspaces() -> JSONResponse:
|
|
865
|
+
return JSONResponse({"workspaces": registry.list_workspaces()})
|
|
866
|
+
|
|
867
|
+
@app.post("/api/workspaces")
|
|
868
|
+
async def add_workspace(payload: "typing.Dict[str, object]") -> JSONResponse:
|
|
869
|
+
try:
|
|
870
|
+
entry = await registry.add_workspace(
|
|
871
|
+
str(payload.get("name") or ""),
|
|
872
|
+
work_dir=str(payload.get("dir") or "./"),
|
|
873
|
+
board=(
|
|
874
|
+
None
|
|
875
|
+
if payload.get("board") in (None, "")
|
|
876
|
+
else str(payload.get("board"))
|
|
877
|
+
),
|
|
932
878
|
)
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
item for item in self._session_order if item != session_id
|
|
946
|
-
]
|
|
947
|
-
if watcher is not None:
|
|
948
|
-
watcher.cancel()
|
|
949
|
-
await asyncio.gather(watcher, return_exceptions=True)
|
|
950
|
-
await session.close()
|
|
951
|
-
self.persist_workspace_state()
|
|
952
|
-
|
|
953
|
-
async def _watch_session_title(self, session_id: str, session) -> None:
|
|
954
|
-
subscriber = session.subscribe()
|
|
879
|
+
except ValueError as exc:
|
|
880
|
+
return JSONResponse({"ok": False, "error": str(exc)}, status_code=400)
|
|
881
|
+
return JSONResponse(
|
|
882
|
+
{
|
|
883
|
+
"ok": True,
|
|
884
|
+
"workspace": entry.to_dict(),
|
|
885
|
+
"workspaces": registry.list_workspaces(),
|
|
886
|
+
}
|
|
887
|
+
)
|
|
888
|
+
|
|
889
|
+
@app.delete("/api/workspaces/{workspace_id}")
|
|
890
|
+
async def delete_workspace(workspace_id: str) -> JSONResponse:
|
|
955
891
|
try:
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
892
|
+
await registry.delete_workspace(workspace_id)
|
|
893
|
+
except KeyError:
|
|
894
|
+
raise HTTPException(status_code=404, detail="workspace not found")
|
|
895
|
+
return JSONResponse({"ok": True, "workspaces": registry.list_workspaces()})
|
|
896
|
+
|
|
897
|
+
@app.api_route("/w/{workspace_id}", methods=["GET", "HEAD"])
|
|
898
|
+
async def workspace_index_redirect(workspace_id: str) -> RedirectResponse:
|
|
899
|
+
_workspace_entry_or_404(registry, workspace_id)
|
|
900
|
+
return RedirectResponse(url="/w/{0}/".format(workspace_id), status_code=307)
|
|
901
|
+
|
|
902
|
+
@app.api_route("/w/{workspace_id}/", methods=["GET", "HEAD"])
|
|
903
|
+
async def workspace_index(workspace_id: str) -> HTMLResponse:
|
|
904
|
+
entry = _workspace_entry_or_404(registry, workspace_id)
|
|
905
|
+
return _html_response(
|
|
906
|
+
_render_workspace_shell(
|
|
907
|
+
entry.definition.board_path,
|
|
908
|
+
title=entry.definition.workspace_id,
|
|
909
|
+
work_dir=entry.definition.work_dir,
|
|
910
|
+
)
|
|
911
|
+
)
|
|
969
912
|
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
913
|
+
@app.api_route("/w/{workspace_id}/board", methods=["GET", "HEAD"])
|
|
914
|
+
async def workspace_board(workspace_id: str) -> Response:
|
|
915
|
+
entry = _workspace_entry_or_404(registry, workspace_id)
|
|
916
|
+
return _board_response(entry.definition.board_path)
|
|
917
|
+
|
|
918
|
+
@app.get("/w/{workspace_id}/api/board")
|
|
919
|
+
async def workspace_board_status(workspace_id: str) -> JSONResponse:
|
|
920
|
+
entry = _workspace_entry_or_404(registry, workspace_id)
|
|
921
|
+
return _board_status_response(entry.definition.board_path)
|
|
922
|
+
|
|
923
|
+
@app.get("/w/{workspace_id}/ws/session")
|
|
924
|
+
async def workspace_websocket_backend_hint(workspace_id: str) -> JSONResponse:
|
|
925
|
+
_workspace_entry_or_404(registry, workspace_id)
|
|
926
|
+
return _websocket_backend_hint_response()
|
|
927
|
+
|
|
928
|
+
@app.get("/w/{workspace_id}/api/sessions")
|
|
929
|
+
async def workspace_sessions(workspace_id: str) -> JSONResponse:
|
|
930
|
+
entry = _workspace_entry_or_404(registry, workspace_id)
|
|
931
|
+
return _sessions_response(entry.manager)
|
|
932
|
+
|
|
933
|
+
@app.post("/w/{workspace_id}/api/sessions")
|
|
934
|
+
async def workspace_new_session(workspace_id: str) -> JSONResponse:
|
|
935
|
+
entry = _workspace_entry_or_404(registry, workspace_id)
|
|
936
|
+
return await _new_session_response(entry.manager)
|
|
937
|
+
|
|
938
|
+
@app.delete("/w/{workspace_id}/api/sessions/{session_id}")
|
|
939
|
+
async def workspace_delete_session(workspace_id: str, session_id: str) -> JSONResponse:
|
|
940
|
+
entry = _workspace_entry_or_404(registry, workspace_id)
|
|
941
|
+
return await _delete_session_response(entry.manager, session_id)
|
|
942
|
+
|
|
943
|
+
@app.get("/w/{workspace_id}/api/session")
|
|
944
|
+
async def workspace_session(
|
|
945
|
+
workspace_id: str,
|
|
946
|
+
session_id: "typing.Union[str, None]" = None,
|
|
947
|
+
) -> JSONResponse:
|
|
948
|
+
entry = _workspace_entry_or_404(registry, workspace_id)
|
|
949
|
+
return _session_response(entry.manager, session_id)
|
|
950
|
+
|
|
951
|
+
@app.post("/w/{workspace_id}/api/session/message")
|
|
952
|
+
async def workspace_message(
|
|
953
|
+
workspace_id: str,
|
|
954
|
+
payload: "typing.Dict[str, object]",
|
|
955
|
+
) -> JSONResponse:
|
|
956
|
+
entry = _workspace_entry_or_404(registry, workspace_id)
|
|
957
|
+
return await _message_response(entry.manager, payload)
|
|
983
958
|
|
|
984
|
-
|
|
985
|
-
|
|
959
|
+
@app.websocket("/w/{workspace_id}/ws/session")
|
|
960
|
+
async def workspace_websocket_session(workspace_id: str, websocket: WebSocket) -> None:
|
|
961
|
+
if not _auth_cookie_matches(auth_token, websocket.cookies.get(AUTH_COOKIE_NAME)):
|
|
962
|
+
await websocket.close(code=1008)
|
|
963
|
+
return
|
|
986
964
|
try:
|
|
987
|
-
|
|
988
|
-
except KeyError:
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
}
|
|
965
|
+
entry = registry.get(workspace_id)
|
|
966
|
+
except (KeyError, ValueError):
|
|
967
|
+
await websocket.close(code=1008)
|
|
968
|
+
return
|
|
969
|
+
await _websocket_session_handler(entry.manager, websocket)
|
|
970
|
+
|
|
971
|
+
return app
|
|
972
|
+
|
|
973
|
+
|
|
974
|
+
def _install_auth(app: FastAPI, password: "typing.Union[str, None]") -> str:
|
|
975
|
+
password_text = str(password or "")
|
|
976
|
+
if not password_text:
|
|
977
|
+
return ""
|
|
978
|
+
token = secrets.token_urlsafe(32)
|
|
979
|
+
|
|
980
|
+
@app.middleware("http")
|
|
981
|
+
async def auth_middleware(request: Request, call_next):
|
|
982
|
+
path = request.url.path
|
|
983
|
+
if path in {"/favicon.ico", "/login"} or _auth_cookie_matches(
|
|
984
|
+
token,
|
|
985
|
+
request.cookies.get(AUTH_COOKIE_NAME),
|
|
986
|
+
):
|
|
987
|
+
return await call_next(request)
|
|
988
|
+
if path.startswith("/api/") or "/api/" in path:
|
|
989
|
+
return JSONResponse(
|
|
990
|
+
{"ok": False, "error": "authentication required"},
|
|
991
|
+
status_code=401,
|
|
1015
992
|
)
|
|
1016
|
-
return
|
|
993
|
+
return RedirectResponse(url="/login", status_code=303)
|
|
994
|
+
|
|
995
|
+
@app.get("/login")
|
|
996
|
+
async def login_page() -> HTMLResponse:
|
|
997
|
+
return _html_response(_render_login_shell())
|
|
998
|
+
|
|
999
|
+
@app.post("/login")
|
|
1000
|
+
async def login(payload: "typing.Dict[str, object]") -> JSONResponse:
|
|
1001
|
+
if not secrets.compare_digest(str(payload.get("password") or ""), password_text):
|
|
1002
|
+
return JSONResponse(
|
|
1003
|
+
{"ok": False, "error": "invalid password"},
|
|
1004
|
+
status_code=401,
|
|
1005
|
+
)
|
|
1006
|
+
response = JSONResponse({"ok": True})
|
|
1007
|
+
response.set_cookie(
|
|
1008
|
+
AUTH_COOKIE_NAME,
|
|
1009
|
+
token,
|
|
1010
|
+
httponly=True,
|
|
1011
|
+
samesite="lax",
|
|
1012
|
+
)
|
|
1013
|
+
return response
|
|
1017
1014
|
|
|
1015
|
+
return token
|
|
1018
1016
|
|
|
1019
|
-
def create_app(
|
|
1020
|
-
session_source: "typing.Union[WorkspaceSessionManager, SessionFactory]",
|
|
1021
|
-
board_path: "typing.Union[Path, None]",
|
|
1022
|
-
) -> FastAPI:
|
|
1023
|
-
manager = (
|
|
1024
|
-
session_source
|
|
1025
|
-
if isinstance(session_source, WorkspaceSessionManager)
|
|
1026
|
-
else WorkspaceSessionManager(session_source, board_path)
|
|
1027
|
-
)
|
|
1028
1017
|
|
|
1018
|
+
def _auth_cookie_matches(token: str, cookie: "typing.Union[str, None]") -> bool:
|
|
1019
|
+
if not token:
|
|
1020
|
+
return True
|
|
1021
|
+
return bool(cookie) and secrets.compare_digest(str(cookie), token)
|
|
1022
|
+
|
|
1023
|
+
|
|
1024
|
+
def _render_login_shell() -> str:
|
|
1025
|
+
return """<!doctype html>
|
|
1026
|
+
<html>
|
|
1027
|
+
<head>
|
|
1028
|
+
<meta charset="utf-8">
|
|
1029
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
1030
|
+
<title>pycodex login</title>
|
|
1031
|
+
<style>
|
|
1032
|
+
:root {
|
|
1033
|
+
color-scheme: light dark;
|
|
1034
|
+
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
|
1035
|
+
}
|
|
1036
|
+
body {
|
|
1037
|
+
min-height: 100vh;
|
|
1038
|
+
margin: 0;
|
|
1039
|
+
display: grid;
|
|
1040
|
+
place-items: center;
|
|
1041
|
+
background: Canvas;
|
|
1042
|
+
color: CanvasText;
|
|
1043
|
+
}
|
|
1044
|
+
form {
|
|
1045
|
+
width: min(360px, calc(100vw - 32px));
|
|
1046
|
+
display: grid;
|
|
1047
|
+
gap: 12px;
|
|
1048
|
+
}
|
|
1049
|
+
h1 {
|
|
1050
|
+
margin: 0;
|
|
1051
|
+
font-size: 22px;
|
|
1052
|
+
}
|
|
1053
|
+
input, button {
|
|
1054
|
+
min-height: 38px;
|
|
1055
|
+
border-radius: 7px;
|
|
1056
|
+
border: 1px solid color-mix(in srgb, CanvasText 18%, Canvas 82%);
|
|
1057
|
+
padding: 8px 10px;
|
|
1058
|
+
font: inherit;
|
|
1059
|
+
}
|
|
1060
|
+
button {
|
|
1061
|
+
cursor: pointer;
|
|
1062
|
+
}
|
|
1063
|
+
.status {
|
|
1064
|
+
min-height: 20px;
|
|
1065
|
+
color: #b42318;
|
|
1066
|
+
font-size: 13px;
|
|
1067
|
+
}
|
|
1068
|
+
</style>
|
|
1069
|
+
</head>
|
|
1070
|
+
<body>
|
|
1071
|
+
<form id="loginForm">
|
|
1072
|
+
<h1>pycodex workspace</h1>
|
|
1073
|
+
<input id="passwordInput" type="password" autocomplete="current-password" autofocus>
|
|
1074
|
+
<button type="submit">Open</button>
|
|
1075
|
+
<div id="status" class="status" role="status"></div>
|
|
1076
|
+
</form>
|
|
1077
|
+
<script>
|
|
1078
|
+
const form = document.getElementById("loginForm");
|
|
1079
|
+
const passwordInput = document.getElementById("passwordInput");
|
|
1080
|
+
const statusEl = document.getElementById("status");
|
|
1081
|
+
form.addEventListener("submit", async function(event) {
|
|
1082
|
+
event.preventDefault();
|
|
1083
|
+
statusEl.textContent = "";
|
|
1084
|
+
const response = await fetch("login", {
|
|
1085
|
+
method: "POST",
|
|
1086
|
+
headers: {"Content-Type": "application/json"},
|
|
1087
|
+
body: JSON.stringify({password: passwordInput.value}),
|
|
1088
|
+
});
|
|
1089
|
+
if (response.ok) {
|
|
1090
|
+
window.location.href = "/";
|
|
1091
|
+
return;
|
|
1092
|
+
}
|
|
1093
|
+
statusEl.textContent = "Invalid password";
|
|
1094
|
+
});
|
|
1095
|
+
</script>
|
|
1096
|
+
</body>
|
|
1097
|
+
</html>"""
|
|
1098
|
+
|
|
1099
|
+
|
|
1100
|
+
def _create_lifespan_app(
|
|
1101
|
+
start: "typing.Callable[[], typing.Awaitable[None]]",
|
|
1102
|
+
close: "typing.Callable[[], typing.Awaitable[None]]",
|
|
1103
|
+
) -> FastAPI:
|
|
1029
1104
|
if asynccontextmanager is not None:
|
|
1030
1105
|
@asynccontextmanager
|
|
1031
1106
|
async def lifespan(_app):
|
|
1032
|
-
await
|
|
1107
|
+
await start()
|
|
1033
1108
|
try:
|
|
1034
1109
|
yield
|
|
1035
1110
|
finally:
|
|
1036
|
-
await
|
|
1111
|
+
await close()
|
|
1112
|
+
|
|
1113
|
+
return FastAPI(lifespan=lifespan)
|
|
1037
1114
|
|
|
1038
|
-
|
|
1039
|
-
else:
|
|
1040
|
-
app = FastAPI()
|
|
1115
|
+
app = FastAPI()
|
|
1041
1116
|
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1117
|
+
@app.on_event("startup")
|
|
1118
|
+
async def startup() -> None:
|
|
1119
|
+
await start()
|
|
1120
|
+
|
|
1121
|
+
@app.on_event("shutdown")
|
|
1122
|
+
async def shutdown() -> None:
|
|
1123
|
+
await close()
|
|
1124
|
+
return app
|
|
1045
1125
|
|
|
1046
|
-
@app.on_event("shutdown")
|
|
1047
|
-
async def shutdown() -> None:
|
|
1048
|
-
await manager.close()
|
|
1049
1126
|
|
|
1127
|
+
def _install_workspace_routes(
|
|
1128
|
+
app: FastAPI,
|
|
1129
|
+
manager: WorkspaceSessionManager,
|
|
1130
|
+
board_path: "typing.Union[Path, None]",
|
|
1131
|
+
) -> None:
|
|
1050
1132
|
@app.get("/")
|
|
1051
1133
|
async def index() -> HTMLResponse:
|
|
1052
1134
|
return _html_response(_render_workspace_shell(board_path))
|
|
@@ -1059,165 +1141,179 @@ def create_app(
|
|
|
1059
1141
|
async def board() -> Response:
|
|
1060
1142
|
return _board_response(board_path)
|
|
1061
1143
|
|
|
1062
|
-
@app.api_route("/{path_name}", methods=["GET", "HEAD"])
|
|
1063
|
-
async def legacy_board_path(path_name: str) -> Response:
|
|
1064
|
-
if board_path is not None and path_name == board_path.name:
|
|
1065
|
-
return _board_response(board_path)
|
|
1066
|
-
raise HTTPException(status_code=404, detail="not found")
|
|
1067
|
-
|
|
1068
1144
|
@app.get("/api/board")
|
|
1069
1145
|
async def board_status() -> JSONResponse:
|
|
1070
|
-
|
|
1071
|
-
return JSONResponse({"exists": False})
|
|
1072
|
-
stat = board_path.stat()
|
|
1073
|
-
return JSONResponse(
|
|
1074
|
-
{
|
|
1075
|
-
"exists": True,
|
|
1076
|
-
"path": str(board_path),
|
|
1077
|
-
"mtime_ns": stat.st_mtime_ns,
|
|
1078
|
-
"size": stat.st_size,
|
|
1079
|
-
}
|
|
1080
|
-
)
|
|
1146
|
+
return _board_status_response(board_path)
|
|
1081
1147
|
|
|
1082
1148
|
@app.get("/ws/session")
|
|
1083
1149
|
async def websocket_backend_hint() -> JSONResponse:
|
|
1084
|
-
return
|
|
1085
|
-
{
|
|
1086
|
-
"error": "websocket backend is unavailable; HTTP polling is active",
|
|
1087
|
-
},
|
|
1088
|
-
status_code=426,
|
|
1089
|
-
)
|
|
1090
|
-
|
|
1091
|
-
def _board_response(board_path: "typing.Union[Path, None]") -> Response:
|
|
1092
|
-
if board_path is None:
|
|
1093
|
-
return _html_response(_render_empty_board())
|
|
1094
|
-
if not board_path.is_file():
|
|
1095
|
-
return _html_response(_render_missing_board(board_path))
|
|
1096
|
-
return _html_response(board_path.read_text(encoding="utf-8", errors="replace"))
|
|
1150
|
+
return _websocket_backend_hint_response()
|
|
1097
1151
|
|
|
1098
1152
|
@app.get("/api/sessions")
|
|
1099
1153
|
async def sessions() -> JSONResponse:
|
|
1100
|
-
return
|
|
1154
|
+
return _sessions_response(manager)
|
|
1101
1155
|
|
|
1102
1156
|
@app.post("/api/sessions")
|
|
1103
1157
|
async def new_session() -> JSONResponse:
|
|
1104
|
-
|
|
1105
|
-
return JSONResponse(
|
|
1106
|
-
{
|
|
1107
|
-
"ok": True,
|
|
1108
|
-
"session_id": session_id,
|
|
1109
|
-
"sessions": manager.list_sessions(),
|
|
1110
|
-
"snapshot": _session_snapshot(manager.get(session_id)),
|
|
1111
|
-
}
|
|
1112
|
-
)
|
|
1158
|
+
return await _new_session_response(manager)
|
|
1113
1159
|
|
|
1114
1160
|
@app.delete("/api/sessions/{session_id}")
|
|
1115
1161
|
async def delete_session(session_id: str) -> JSONResponse:
|
|
1116
|
-
|
|
1117
|
-
await manager.close_session(session_id)
|
|
1118
|
-
except KeyError:
|
|
1119
|
-
raise HTTPException(status_code=404, detail="session not found")
|
|
1120
|
-
except ValueError as exc:
|
|
1121
|
-
return JSONResponse({"ok": False, "error": str(exc)}, status_code=400)
|
|
1122
|
-
return JSONResponse({"ok": True, "sessions": manager.list_sessions()})
|
|
1162
|
+
return await _delete_session_response(manager, session_id)
|
|
1123
1163
|
|
|
1124
1164
|
@app.get("/api/session")
|
|
1125
1165
|
async def session(
|
|
1126
1166
|
session_id: "typing.Union[str, None]" = None,
|
|
1127
1167
|
) -> JSONResponse:
|
|
1128
|
-
|
|
1129
|
-
resolved_id = manager.resolve_session_id(session_id)
|
|
1130
|
-
link = manager.get(resolved_id)
|
|
1131
|
-
except KeyError:
|
|
1132
|
-
raise HTTPException(status_code=404, detail="session not found")
|
|
1133
|
-
return JSONResponse(
|
|
1134
|
-
{
|
|
1135
|
-
"session_id": resolved_id,
|
|
1136
|
-
"sessions": manager.list_sessions(),
|
|
1137
|
-
"snapshot": _session_snapshot(link),
|
|
1138
|
-
}
|
|
1139
|
-
)
|
|
1168
|
+
return _session_response(manager, session_id)
|
|
1140
1169
|
|
|
1141
1170
|
@app.post("/api/session/message")
|
|
1142
1171
|
async def message(
|
|
1143
1172
|
payload: "typing.Dict[str, object]",
|
|
1144
1173
|
) -> JSONResponse:
|
|
1145
|
-
|
|
1146
|
-
try:
|
|
1147
|
-
link = manager.get(session_id or None)
|
|
1148
|
-
except KeyError:
|
|
1149
|
-
raise HTTPException(status_code=404, detail="session not found")
|
|
1150
|
-
result = await link.submit(str(payload.get("prompt") or ""))
|
|
1151
|
-
if isinstance(result, dict):
|
|
1152
|
-
result.setdefault("sessions", manager.list_sessions())
|
|
1153
|
-
status = 200 if result.get("ok") else 400
|
|
1154
|
-
return JSONResponse(result, status_code=status)
|
|
1174
|
+
return await _message_response(manager, payload)
|
|
1155
1175
|
|
|
1156
1176
|
@app.websocket("/ws/session")
|
|
1157
1177
|
async def websocket_session(websocket: WebSocket) -> None:
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
try:
|
|
1161
|
-
link = manager.get(session_id or None)
|
|
1162
|
-
except KeyError:
|
|
1178
|
+
auth_token = typing.cast(str, app.state.workspace_auth_token)
|
|
1179
|
+
if not _auth_cookie_matches(auth_token, websocket.cookies.get(AUTH_COOKIE_NAME)):
|
|
1163
1180
|
await websocket.close(code=1008)
|
|
1164
1181
|
return
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
except KeyError:
|
|
1181
|
-
await websocket.send_json(
|
|
1182
|
-
{"type": "error", "error": "session not found"}
|
|
1183
|
-
)
|
|
1184
|
-
continue
|
|
1185
|
-
result = await target_link.submit(
|
|
1186
|
-
str(payload.get("prompt") or ""),
|
|
1187
|
-
sender=str(payload.get("sender") or "web"),
|
|
1188
|
-
)
|
|
1189
|
-
await websocket.send_json({"type": "send_result", "result": result})
|
|
1190
|
-
elif action == "ping":
|
|
1191
|
-
await websocket.send_json({"type": "pong"})
|
|
1192
|
-
else:
|
|
1193
|
-
await websocket.send_json({"type": "error", "error": "unknown action"})
|
|
1194
|
-
except WebSocketDisconnect:
|
|
1195
|
-
pass
|
|
1196
|
-
finally:
|
|
1197
|
-
link.unsubscribe(subscriber)
|
|
1198
|
-
sender.cancel()
|
|
1199
|
-
await asyncio.gather(sender, return_exceptions=True)
|
|
1182
|
+
await _websocket_session_handler(manager, websocket)
|
|
1183
|
+
|
|
1184
|
+
|
|
1185
|
+
def _board_status_response(board_path: "typing.Union[Path, None]") -> JSONResponse:
|
|
1186
|
+
if board_path is None or not board_path.is_file():
|
|
1187
|
+
return JSONResponse({"exists": False})
|
|
1188
|
+
stat = board_path.stat()
|
|
1189
|
+
return JSONResponse(
|
|
1190
|
+
{
|
|
1191
|
+
"exists": True,
|
|
1192
|
+
"path": str(board_path),
|
|
1193
|
+
"mtime_ns": stat.st_mtime_ns,
|
|
1194
|
+
"size": stat.st_size,
|
|
1195
|
+
}
|
|
1196
|
+
)
|
|
1200
1197
|
|
|
1201
|
-
|
|
1198
|
+
|
|
1199
|
+
def _websocket_backend_hint_response() -> JSONResponse:
|
|
1200
|
+
return JSONResponse(
|
|
1201
|
+
{
|
|
1202
|
+
"error": "websocket backend is unavailable; HTTP polling is active",
|
|
1203
|
+
},
|
|
1204
|
+
status_code=426,
|
|
1205
|
+
)
|
|
1202
1206
|
|
|
1203
1207
|
|
|
1204
|
-
def
|
|
1205
|
-
return
|
|
1208
|
+
def _sessions_response(manager: 'WorkspaceSessionManager') -> JSONResponse:
|
|
1209
|
+
return JSONResponse({"sessions": manager.list_sessions()})
|
|
1206
1210
|
|
|
1207
1211
|
|
|
1208
|
-
def
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1212
|
+
async def _new_session_response(manager: 'WorkspaceSessionManager') -> JSONResponse:
|
|
1213
|
+
session_id = await manager.create_session()
|
|
1214
|
+
return JSONResponse(
|
|
1215
|
+
{
|
|
1216
|
+
"ok": True,
|
|
1217
|
+
"session_id": session_id,
|
|
1218
|
+
"sessions": manager.list_sessions(),
|
|
1219
|
+
"snapshot": session_snapshot(manager.get(session_id)),
|
|
1220
|
+
}
|
|
1221
|
+
)
|
|
1222
|
+
|
|
1223
|
+
|
|
1224
|
+
async def _delete_session_response(
|
|
1225
|
+
manager: 'WorkspaceSessionManager',
|
|
1226
|
+
session_id: str,
|
|
1227
|
+
) -> JSONResponse:
|
|
1228
|
+
try:
|
|
1229
|
+
await manager.close_session(session_id)
|
|
1230
|
+
except KeyError:
|
|
1231
|
+
raise HTTPException(status_code=404, detail="session not found")
|
|
1232
|
+
except ValueError as exc:
|
|
1233
|
+
return JSONResponse({"ok": False, "error": str(exc)}, status_code=400)
|
|
1234
|
+
return JSONResponse({"ok": True, "sessions": manager.list_sessions()})
|
|
1235
|
+
|
|
1236
|
+
|
|
1237
|
+
def _session_response(
|
|
1238
|
+
manager: 'WorkspaceSessionManager',
|
|
1239
|
+
session_id: "typing.Union[str, None]" = None,
|
|
1240
|
+
) -> JSONResponse:
|
|
1241
|
+
try:
|
|
1242
|
+
resolved_id = manager.resolve_session_id(session_id)
|
|
1243
|
+
link = manager.get(resolved_id)
|
|
1244
|
+
except KeyError:
|
|
1245
|
+
raise HTTPException(status_code=404, detail="session not found")
|
|
1246
|
+
return JSONResponse(
|
|
1247
|
+
{
|
|
1248
|
+
"session_id": resolved_id,
|
|
1249
|
+
"sessions": manager.list_sessions(),
|
|
1250
|
+
"snapshot": session_snapshot(link),
|
|
1251
|
+
}
|
|
1252
|
+
)
|
|
1253
|
+
|
|
1254
|
+
|
|
1255
|
+
async def _message_response(
|
|
1256
|
+
manager: 'WorkspaceSessionManager',
|
|
1257
|
+
payload: "typing.Dict[str, object]",
|
|
1258
|
+
) -> JSONResponse:
|
|
1259
|
+
session_id = str(payload.get("session_id") or "")
|
|
1260
|
+
try:
|
|
1261
|
+
link = manager.get(session_id or None)
|
|
1262
|
+
except KeyError:
|
|
1263
|
+
raise HTTPException(status_code=404, detail="session not found")
|
|
1264
|
+
result = await link.submit(str(payload.get("prompt") or ""))
|
|
1265
|
+
if isinstance(result, dict):
|
|
1266
|
+
result.setdefault("sessions", manager.list_sessions())
|
|
1267
|
+
status = 200 if result.get("ok") else 400
|
|
1268
|
+
return JSONResponse(result, status_code=status)
|
|
1269
|
+
|
|
1270
|
+
|
|
1271
|
+
async def _websocket_session_handler(
|
|
1272
|
+
manager: 'WorkspaceSessionManager',
|
|
1273
|
+
websocket: WebSocket,
|
|
1274
|
+
) -> None:
|
|
1275
|
+
await websocket.accept()
|
|
1276
|
+
session_id = str(websocket.query_params.get("session_id") or "")
|
|
1277
|
+
try:
|
|
1278
|
+
link = manager.get(session_id or None)
|
|
1279
|
+
except KeyError:
|
|
1280
|
+
await websocket.close(code=1008)
|
|
1281
|
+
return
|
|
1282
|
+
subscriber = link.subscribe()
|
|
1283
|
+
sender = asyncio.create_task(_send_ws_events(websocket, subscriber))
|
|
1284
|
+
try:
|
|
1285
|
+
while True:
|
|
1286
|
+
data = await websocket.receive_text()
|
|
1287
|
+
try:
|
|
1288
|
+
payload = json.loads(data)
|
|
1289
|
+
except ValueError:
|
|
1290
|
+
await websocket.send_json({"type": "error", "error": "invalid json"})
|
|
1291
|
+
continue
|
|
1292
|
+
action = str(payload.get("type") or payload.get("action") or "")
|
|
1293
|
+
if action == "send":
|
|
1294
|
+
target_session_id = str(payload.get("session_id") or session_id or "")
|
|
1295
|
+
try:
|
|
1296
|
+
target_link = manager.get(target_session_id or None)
|
|
1297
|
+
except KeyError:
|
|
1298
|
+
await websocket.send_json(
|
|
1299
|
+
{"type": "error", "error": "session not found"}
|
|
1300
|
+
)
|
|
1301
|
+
continue
|
|
1302
|
+
result = await target_link.submit(
|
|
1303
|
+
str(payload.get("prompt") or ""),
|
|
1304
|
+
sender=str(payload.get("sender") or "web"),
|
|
1305
|
+
)
|
|
1306
|
+
await websocket.send_json({"type": "send_result", "result": result})
|
|
1307
|
+
elif action == "ping":
|
|
1308
|
+
await websocket.send_json({"type": "pong"})
|
|
1309
|
+
else:
|
|
1310
|
+
await websocket.send_json({"type": "error", "error": "unknown action"})
|
|
1311
|
+
except WebSocketDisconnect:
|
|
1312
|
+
pass
|
|
1313
|
+
finally:
|
|
1314
|
+
link.unsubscribe(subscriber)
|
|
1315
|
+
sender.cancel()
|
|
1316
|
+
await asyncio.gather(sender, return_exceptions=True)
|
|
1221
1317
|
|
|
1222
1318
|
|
|
1223
1319
|
def _last_assistant_text(turns: "typing.Iterable[typing.Dict[str, object]]") -> str:
|
|
@@ -1284,15 +1380,70 @@ async def _send_ws_events(websocket: WebSocket, subscriber: "asyncio.Queue") ->
|
|
|
1284
1380
|
await websocket.send_json(event)
|
|
1285
1381
|
|
|
1286
1382
|
|
|
1383
|
+
def _board_response(board_path: "typing.Union[Path, None]") -> Response:
|
|
1384
|
+
if board_path is None:
|
|
1385
|
+
return _html_response(_render_empty_board())
|
|
1386
|
+
if not board_path.is_file():
|
|
1387
|
+
return _html_response(_render_missing_board(board_path))
|
|
1388
|
+
return _html_response(board_path.read_text(encoding="utf-8", errors="replace"))
|
|
1389
|
+
|
|
1390
|
+
|
|
1391
|
+
def _workspace_entry_or_404(
|
|
1392
|
+
registry: 'WorkspaceRegistry',
|
|
1393
|
+
workspace_id: str,
|
|
1394
|
+
) -> 'WorkspaceEntry':
|
|
1395
|
+
try:
|
|
1396
|
+
return registry.get(workspace_id)
|
|
1397
|
+
except (KeyError, ValueError):
|
|
1398
|
+
raise HTTPException(status_code=404, detail="workspace not found")
|
|
1399
|
+
|
|
1400
|
+
|
|
1287
1401
|
def run_serve_cli(args: "argparse.Namespace") -> int:
|
|
1288
1402
|
import uvicorn
|
|
1289
1403
|
|
|
1290
|
-
|
|
1291
|
-
host, port, board_path = parse_target(args.listen, board_arg)
|
|
1292
|
-
if board_path is not None and not board_path.parent.is_dir():
|
|
1293
|
-
raise ValueError("board parent directory does not exist: {0}".format(board_path.parent))
|
|
1294
|
-
|
|
1404
|
+
host, port = parse_listen(args.listen)
|
|
1295
1405
|
configure_loguru()
|
|
1406
|
+
|
|
1407
|
+
definitions = load_workspace_definitions(args.workspace_config)
|
|
1408
|
+
entries = [
|
|
1409
|
+
_build_workspace_entry(definition, args)
|
|
1410
|
+
for definition in definitions
|
|
1411
|
+
]
|
|
1412
|
+
registry = WorkspaceRegistry(
|
|
1413
|
+
entries,
|
|
1414
|
+
config_path=args.workspace_config,
|
|
1415
|
+
entry_factory=lambda definition, persist_callback: _build_workspace_entry(
|
|
1416
|
+
definition,
|
|
1417
|
+
args,
|
|
1418
|
+
persist_callback,
|
|
1419
|
+
),
|
|
1420
|
+
)
|
|
1421
|
+
app = create_multi_workspace_app(registry, password=args.password)
|
|
1422
|
+
|
|
1423
|
+
print(
|
|
1424
|
+
"pycodex workspace listening on http://{0}:{1}".format(host, port),
|
|
1425
|
+
flush=True,
|
|
1426
|
+
)
|
|
1427
|
+
for definition in definitions:
|
|
1428
|
+
print(
|
|
1429
|
+
"workspace {0}: board={1} work_dir={2} url=http://{3}:{4}/w/{0}/".format(
|
|
1430
|
+
definition.workspace_id,
|
|
1431
|
+
definition.board_path or "",
|
|
1432
|
+
definition.work_dir,
|
|
1433
|
+
host,
|
|
1434
|
+
port,
|
|
1435
|
+
),
|
|
1436
|
+
flush=True,
|
|
1437
|
+
)
|
|
1438
|
+
uvicorn.run(app, host=host, port=port, loop="asyncio")
|
|
1439
|
+
return 0
|
|
1440
|
+
|
|
1441
|
+
|
|
1442
|
+
def _build_workspace_entry(
|
|
1443
|
+
definition: 'WorkspaceDefinition',
|
|
1444
|
+
args: "argparse.Namespace",
|
|
1445
|
+
persist_callback: "typing.Union[typing.Callable[[], None], None]" = None,
|
|
1446
|
+
) -> 'WorkspaceEntry':
|
|
1296
1447
|
def build_session() -> "WorkspaceInteractiveSession":
|
|
1297
1448
|
model = build_model(
|
|
1298
1449
|
config_path=args.config,
|
|
@@ -1309,8 +1460,11 @@ def run_serve_cli(args: "argparse.Namespace") -> int:
|
|
|
1309
1460
|
system_prompt=args.system_prompt,
|
|
1310
1461
|
session_mode="tui",
|
|
1311
1462
|
extra_contextual_user_messages=(
|
|
1312
|
-
[_board_context_text(board_path)]
|
|
1463
|
+
[_board_context_text(definition.board_path, definition.work_dir)]
|
|
1464
|
+
if definition.board_path is not None
|
|
1465
|
+
else []
|
|
1313
1466
|
),
|
|
1467
|
+
cwd=definition.work_dir,
|
|
1314
1468
|
)
|
|
1315
1469
|
return WorkspaceInteractiveSession(
|
|
1316
1470
|
build_cli_queue(agent),
|
|
@@ -1320,33 +1474,37 @@ def run_serve_cli(args: "argparse.Namespace") -> int:
|
|
|
1320
1474
|
def session_factory() -> "ThreadedWorkspaceInteractiveSession":
|
|
1321
1475
|
return ThreadedWorkspaceInteractiveSession(build_session, asyncio.get_running_loop())
|
|
1322
1476
|
|
|
1323
|
-
|
|
1324
|
-
|
|
1325
|
-
|
|
1326
|
-
|
|
1477
|
+
return WorkspaceEntry(
|
|
1478
|
+
definition=definition,
|
|
1479
|
+
manager=WorkspaceSessionManager(
|
|
1480
|
+
session_factory,
|
|
1481
|
+
definition.board_path,
|
|
1482
|
+
persist_callback=persist_callback,
|
|
1483
|
+
),
|
|
1327
1484
|
)
|
|
1328
|
-
if board_path is not None:
|
|
1329
|
-
print("board: {0}".format(board_path), flush=True)
|
|
1330
|
-
uvicorn.run(app, host=host, port=port, loop="asyncio")
|
|
1331
|
-
return 0
|
|
1332
1485
|
|
|
1333
1486
|
|
|
1334
|
-
def _board_context_text(
|
|
1487
|
+
def _board_context_text(
|
|
1488
|
+
board_path: Path,
|
|
1489
|
+
work_dir: "typing.Union[Path, None]" = None,
|
|
1490
|
+
) -> str:
|
|
1335
1491
|
return (
|
|
1336
1492
|
"Current workspace board file: {0}. "
|
|
1337
1493
|
"Changes you make to this file are shown to the user in real time. "
|
|
1338
1494
|
"You can create or modify this file anytime."
|
|
1339
|
-
).format(_format_board_path_for_prompt(board_path))
|
|
1340
|
-
|
|
1341
|
-
|
|
1342
|
-
def _default_board_path() -> Path:
|
|
1343
|
-
return Path(tempfile.gettempdir()) / "pcws-{0}.html".format(uuid4().hex[:8])
|
|
1495
|
+
).format(_format_board_path_for_prompt(board_path, work_dir=work_dir))
|
|
1344
1496
|
|
|
1345
1497
|
|
|
1346
|
-
def _format_board_path_for_prompt(
|
|
1498
|
+
def _format_board_path_for_prompt(
|
|
1499
|
+
board_path: Path,
|
|
1500
|
+
work_dir: "typing.Union[Path, None]" = None,
|
|
1501
|
+
) -> str:
|
|
1347
1502
|
resolved = board_path.resolve()
|
|
1348
1503
|
try:
|
|
1349
|
-
relative = os.path.relpath(
|
|
1504
|
+
relative = os.path.relpath(
|
|
1505
|
+
str(resolved),
|
|
1506
|
+
str(Path(work_dir or Path.cwd()).resolve()),
|
|
1507
|
+
)
|
|
1350
1508
|
except ValueError:
|
|
1351
1509
|
return str(resolved)
|
|
1352
1510
|
if relative == ".":
|
|
@@ -1356,20 +1514,35 @@ def _format_board_path_for_prompt(board_path: Path) -> str:
|
|
|
1356
1514
|
return "./{0}".format(relative)
|
|
1357
1515
|
|
|
1358
1516
|
|
|
1359
|
-
def _render_workspace_shell(
|
|
1517
|
+
def _render_workspace_shell(
|
|
1518
|
+
board_path: "typing.Union[Path, None]",
|
|
1519
|
+
title: "typing.Union[str, None]" = None,
|
|
1520
|
+
work_dir: "typing.Union[Path, None]" = None,
|
|
1521
|
+
) -> str:
|
|
1360
1522
|
board_label = str(board_path) if board_path is not None else "No board"
|
|
1523
|
+
cwd_label = str(work_dir or Path.cwd())
|
|
1524
|
+
page_title = str(title or "pycodex workspace")
|
|
1361
1525
|
template = (Path(__file__).with_name("workspace.html")).read_text(
|
|
1362
1526
|
encoding="utf-8"
|
|
1363
1527
|
)
|
|
1364
|
-
return
|
|
1528
|
+
return (
|
|
1529
|
+
template
|
|
1530
|
+
.replace("__WORKSPACE_TITLE__", html.escape(page_title))
|
|
1531
|
+
.replace("__BOARD_LABEL__", html.escape(board_label))
|
|
1532
|
+
.replace("__CWD_LABEL__", html.escape(cwd_label))
|
|
1533
|
+
)
|
|
1534
|
+
|
|
1535
|
+
|
|
1536
|
+
def _render_workspaces_manager_shell() -> str:
|
|
1537
|
+
return (Path(__file__).with_name("workspaces.html")).read_text(encoding="utf-8")
|
|
1365
1538
|
|
|
1366
1539
|
|
|
1367
1540
|
def _render_empty_board() -> str:
|
|
1368
1541
|
return """<!doctype html>
|
|
1369
1542
|
<html><head><meta charset="utf-8"><title>No board</title></head>
|
|
1370
1543
|
<body style="font:14px -apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;padding:24px">
|
|
1371
|
-
<h1>No board
|
|
1372
|
-
<p>
|
|
1544
|
+
<h1>No board</h1>
|
|
1545
|
+
<p>Add a workspace from the workspaces manager page.</p>
|
|
1373
1546
|
</body></html>"""
|
|
1374
1547
|
|
|
1375
1548
|
|