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.
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
- "--board",
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 board HTML path. Defaults to a writable random path under /tmp.",
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 parse_target(
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
- board_path = Path(board_text).expanduser().resolve() if board_text else None
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
- class WorkspaceSessionManager:
874
- def __init__(
875
- self,
876
- session_factory: "SessionFactory",
877
- board_path: "typing.Union[Path, None]" = None,
878
- ) -> None:
879
- self._session_factory = session_factory
880
- self._sessions: "typing.Dict[str, WorkspaceInteractiveSession]" = {}
881
- self._session_order: "typing.List[str]" = []
882
- self._state_watchers: "typing.Dict[str, asyncio.Task]" = {}
883
- self._persisted_titles: "typing.Dict[str, str]" = {}
884
- self._lock = asyncio.Lock()
885
- self._state_store = WorkspaceStateStore(board_path)
886
-
887
- async def start(self) -> None:
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
- if rollout_path:
923
- await session.restore_from_rollout(rollout_path, title=title)
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
- self._sessions[session_id] = session
926
- self._session_order.append(session_id)
927
- self._persisted_titles[session_id] = str(
928
- _session_summary(session).get("title") or ""
929
- )
930
- self._state_watchers[session_id] = asyncio.create_task(
931
- self._watch_session_title(session_id, session)
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
- return session_id
934
-
935
- async def close_session(self, session_id: str) -> None:
936
- async with self._lock:
937
- if len(self._session_order) <= 1:
938
- raise ValueError("cannot close the last session")
939
- session = self._sessions.pop(session_id, None)
940
- if session is None:
941
- raise KeyError(session_id)
942
- watcher = self._state_watchers.pop(session_id, None)
943
- self._persisted_titles.pop(session_id, None)
944
- self._session_order = [
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
- while True:
957
- event = await subscriber.get()
958
- if event is None:
959
- return
960
- if not isinstance(event, dict) or event.get("type") != "title_changed":
961
- continue
962
- title = str(event.get("title") or "")
963
- if title == self._persisted_titles.get(session_id, ""):
964
- continue
965
- self._persisted_titles[session_id] = title
966
- self.persist_workspace_state()
967
- finally:
968
- session.unsubscribe(subscriber)
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
- def persist_workspace_state(self) -> None:
971
- tabs = []
972
- for session_id in self._session_order:
973
- session = self._sessions.get(session_id)
974
- if session is None:
975
- continue
976
- summary = _session_summary(session)
977
- title = str(summary.get("title") or "").strip()
978
- rollout_path = str(session.rollout_path() or "")
979
- if not title and not rollout_path:
980
- continue
981
- tabs.append({"title": title, "rollout_path": rollout_path})
982
- self._state_store.save_tabs(tabs)
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
- def get(self, session_id: "typing.Union[str, None]" = None) -> "WorkspaceInteractiveSession":
985
- resolved_id = self.resolve_session_id(session_id)
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
- return self._sessions[resolved_id]
988
- except KeyError:
989
- raise KeyError(resolved_id)
990
-
991
- def resolve_session_id(self, session_id: "typing.Union[str, None]" = None) -> str:
992
- if session_id:
993
- return str(session_id)
994
- if not self._session_order:
995
- raise KeyError("no sessions")
996
- return self._session_order[0]
997
-
998
- def list_sessions(self) -> "typing.List[typing.Dict[str, object]]":
999
- result = []
1000
- for session_id in self._session_order:
1001
- session = self._sessions[session_id]
1002
- summary = _session_summary(session)
1003
- result.append(
1004
- {
1005
- "id": session_id,
1006
- "title": summary.get("title") or "pycodex",
1007
- "running": bool(summary.get("running")),
1008
- "spinner": summary.get("spinner") or "",
1009
- "turn_count": summary.get("turn_count") or 0,
1010
- "last_assistant": summary.get("last_assistant") or "",
1011
- "context_remaining_percent": summary.get(
1012
- "context_remaining_percent"
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 result
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 manager.start()
1107
+ await start()
1033
1108
  try:
1034
1109
  yield
1035
1110
  finally:
1036
- await manager.close()
1111
+ await close()
1112
+
1113
+ return FastAPI(lifespan=lifespan)
1037
1114
 
1038
- app = FastAPI(lifespan=lifespan)
1039
- else:
1040
- app = FastAPI()
1115
+ app = FastAPI()
1041
1116
 
1042
- @app.on_event("startup")
1043
- async def startup() -> None:
1044
- await manager.start()
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
- if board_path is None or not board_path.is_file():
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 JSONResponse(
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 JSONResponse({"sessions": manager.list_sessions()})
1154
+ return _sessions_response(manager)
1101
1155
 
1102
1156
  @app.post("/api/sessions")
1103
1157
  async def new_session() -> JSONResponse:
1104
- session_id = await manager.create_session()
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
- try:
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
- try:
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
- session_id = str(payload.get("session_id") or "")
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
- await websocket.accept()
1159
- session_id = str(websocket.query_params.get("session_id") or "")
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
- subscriber = link.subscribe()
1166
- sender = asyncio.create_task(_send_ws_events(websocket, subscriber))
1167
- try:
1168
- while True:
1169
- data = await websocket.receive_text()
1170
- try:
1171
- payload = json.loads(data)
1172
- except ValueError:
1173
- await websocket.send_json({"type": "error", "error": "invalid json"})
1174
- continue
1175
- action = str(payload.get("type") or payload.get("action") or "")
1176
- if action == "send":
1177
- target_session_id = str(payload.get("session_id") or session_id or "")
1178
- try:
1179
- target_link = manager.get(target_session_id or None)
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
- return app
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 _session_snapshot(session) -> "typing.Dict[str, object]":
1205
- return typing.cast("typing.Dict[str, object]", session.snapshot())
1208
+ def _sessions_response(manager: 'WorkspaceSessionManager') -> JSONResponse:
1209
+ return JSONResponse({"sessions": manager.list_sessions()})
1206
1210
 
1207
1211
 
1208
- def _session_summary(session) -> "typing.Dict[str, object]":
1209
- summary = getattr(session, "summary", None)
1210
- if callable(summary):
1211
- return typing.cast("typing.Dict[str, object]", summary())
1212
- snapshot = _session_snapshot(session)
1213
- return {
1214
- "title": snapshot.get("title") or "",
1215
- "running": bool(snapshot.get("running")),
1216
- "spinner": snapshot.get("spinner") or "",
1217
- "turn_count": len(snapshot.get("turns") or []),
1218
- "last_assistant": _last_assistant_text(snapshot.get("turns") or []),
1219
- "context_remaining_percent": snapshot.get("context_remaining_percent"),
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
- board_arg = args.board if args.board is not None else _default_board_path()
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)] if board_path is not None else []
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
- app = create_app(WorkspaceSessionManager(session_factory, board_path), board_path)
1324
- print(
1325
- "pycodex workspace listening on http://{0}:{1}".format(host, port),
1326
- flush=True,
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(board_path: Path) -> str:
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(board_path: Path) -> str:
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(str(resolved), str(Path.cwd().resolve()))
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(board_path: "typing.Union[Path, None]") -> str:
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 template.replace("__BOARD_LABEL__", html.escape(board_label))
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 configured</h1>
1372
- <p>Start with <code>pycodex-ws --listen 0.0.0.0:6007 --board ./board.html</code>.</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