python-codex 0.1.13__py3-none-any.whl → 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- pycodex/agent.py +71 -11
- pycodex/cli.py +16 -356
- pycodex/context.py +12 -0
- pycodex/feishu_card.py +76 -30
- pycodex/feishu_link.py +131 -11
- pycodex/interactive_session.py +397 -0
- pycodex/model.py +11 -22
- pycodex/protocol.py +0 -5
- pycodex/runtime.py +23 -0
- pycodex/runtime_services.py +2 -2
- pycodex/tools/agent_tool_schemas.py +1 -1
- pycodex/tools/apply_patch_tool.py +1 -1
- pycodex/tools/base_tool.py +1 -27
- pycodex/tools/close_agent_tool.py +11 -4
- pycodex/tools/code_mode_manager.py +1 -1
- pycodex/tools/exec_command_tool.py +40 -16
- pycodex/tools/exec_tool.py +18 -2
- pycodex/tools/grep_files_tool.py +19 -6
- pycodex/tools/ipython_tool.py +3 -2
- pycodex/tools/list_dir_tool.py +19 -6
- pycodex/tools/read_file_tool.py +39 -9
- pycodex/tools/request_permissions_tool.py +12 -1
- pycodex/tools/request_user_input_tool.py +28 -1
- pycodex/tools/send_input_tool.py +4 -2
- pycodex/tools/shell_command_tool.py +23 -6
- pycodex/tools/shell_tool.py +13 -4
- pycodex/tools/spawn_agent_tool.py +31 -8
- pycodex/tools/unified_exec_manager.py +49 -93
- pycodex/tools/update_plan_tool.py +14 -6
- pycodex/tools/view_image_tool.py +17 -16
- pycodex/tools/wait_agent_tool.py +15 -3
- pycodex/tools/wait_tool.py +18 -4
- pycodex/tools/web_search_tool.py +2 -1
- pycodex/tools/write_stdin_tool.py +42 -10
- pycodex/utils/compactor.py +7 -1
- pycodex/utils/session_persist.py +42 -1
- pycodex/utils/truncation.py +206 -0
- pycodex/utils/visualize.py +34 -15
- {python_codex-0.1.13.dist-info → python_codex-0.2.0.dist-info}/METADATA +4 -1
- python_codex-0.2.0.dist-info/RECORD +88 -0
- {python_codex-0.1.13.dist-info → python_codex-0.2.0.dist-info}/entry_points.txt +1 -0
- workspace_server/__init__.py +23 -0
- workspace_server/__main__.py +5 -0
- workspace_server/app.py +1347 -0
- workspace_server/workspace.html +866 -0
- pycodex/prompts/exec_tools.json +0 -411
- pycodex/prompts/subagent_tools.json +0 -163
- python_codex-0.1.13.dist-info/RECORD +0 -84
- {python_codex-0.1.13.dist-info → python_codex-0.2.0.dist-info}/WHEEL +0 -0
- {python_codex-0.1.13.dist-info → python_codex-0.2.0.dist-info}/licenses/LICENSE +0 -0
workspace_server/app.py
ADDED
|
@@ -0,0 +1,1347 @@
|
|
|
1
|
+
import argparse
|
|
2
|
+
import asyncio
|
|
3
|
+
import html
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
import threading
|
|
7
|
+
import tempfile
|
|
8
|
+
from uuid import uuid4
|
|
9
|
+
from dataclasses import asdict, is_dataclass
|
|
10
|
+
try:
|
|
11
|
+
from contextlib import asynccontextmanager
|
|
12
|
+
except ImportError: # pragma: no cover - Python 3.6 compatibility
|
|
13
|
+
asynccontextmanager = None
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
from fastapi import FastAPI, HTTPException, WebSocket, WebSocketDisconnect
|
|
17
|
+
from fastapi.responses import HTMLResponse, JSONResponse, Response
|
|
18
|
+
|
|
19
|
+
from pycodex.cli import build_agent, build_cli_queue, build_model, configure_loguru
|
|
20
|
+
from pycodex.interactive_session import run_interactive_session
|
|
21
|
+
from pycodex.model import DEFAULT_CODEX_CONFIG_PATH
|
|
22
|
+
from pycodex.protocol import AgentEvent, ToolCall
|
|
23
|
+
from pycodex.utils.session_persist import (
|
|
24
|
+
SessionRolloutRecorder,
|
|
25
|
+
load_resumed_session_path,
|
|
26
|
+
)
|
|
27
|
+
from pycodex.utils import uuid7_string
|
|
28
|
+
from pycodex.utils.visualize import IDLE_LISTENING_STATUS, shorten_title, tool_summary
|
|
29
|
+
import typing
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
JSONValue = typing.Union[
|
|
33
|
+
None,
|
|
34
|
+
bool,
|
|
35
|
+
int,
|
|
36
|
+
float,
|
|
37
|
+
str,
|
|
38
|
+
typing.List["JSONValue"],
|
|
39
|
+
typing.Dict[str, "JSONValue"],
|
|
40
|
+
]
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class WorkspaceStateStore:
|
|
44
|
+
def __init__(self, board_path: "typing.Union[Path, None]") -> None:
|
|
45
|
+
self.path = None if board_path is None else board_path.with_suffix(".pycodex-ws.json")
|
|
46
|
+
|
|
47
|
+
def load_tabs(self) -> "typing.List[typing.Dict[str, str]]":
|
|
48
|
+
if self.path is None or not self.path.is_file():
|
|
49
|
+
return []
|
|
50
|
+
|
|
51
|
+
try:
|
|
52
|
+
payload = json.loads(
|
|
53
|
+
self.path.read_text(encoding="utf-8", errors="replace") or "{}"
|
|
54
|
+
)
|
|
55
|
+
except (OSError, ValueError):
|
|
56
|
+
return []
|
|
57
|
+
|
|
58
|
+
tabs = payload.get("tabs") if isinstance(payload, dict) else None
|
|
59
|
+
if not isinstance(tabs, list):
|
|
60
|
+
return []
|
|
61
|
+
|
|
62
|
+
result = []
|
|
63
|
+
for tab in tabs:
|
|
64
|
+
if not isinstance(tab, dict):
|
|
65
|
+
continue
|
|
66
|
+
title = str(tab.get("title") or "").strip()
|
|
67
|
+
rollout_path = str(tab.get("rollout_path") or "").strip()
|
|
68
|
+
if title or rollout_path:
|
|
69
|
+
result.append({"title": title, "rollout_path": rollout_path})
|
|
70
|
+
return result
|
|
71
|
+
|
|
72
|
+
def save_tabs(self, tabs: "typing.Iterable[typing.Dict[str, str]]") -> None:
|
|
73
|
+
if self.path is None:
|
|
74
|
+
return
|
|
75
|
+
|
|
76
|
+
state_tabs = [
|
|
77
|
+
{
|
|
78
|
+
"title": str(tab.get("title") or ""),
|
|
79
|
+
"rollout_path": str(tab.get("rollout_path") or ""),
|
|
80
|
+
}
|
|
81
|
+
for tab in tabs
|
|
82
|
+
]
|
|
83
|
+
payload = json.dumps(
|
|
84
|
+
{"version": 1, "tabs": state_tabs},
|
|
85
|
+
ensure_ascii=False,
|
|
86
|
+
indent=2,
|
|
87
|
+
) + "\n"
|
|
88
|
+
self.path.parent.mkdir(parents=True, exist_ok=True)
|
|
89
|
+
self.path.write_text(payload, encoding="utf-8")
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def build_parser() -> "argparse.ArgumentParser":
|
|
93
|
+
parser = argparse.ArgumentParser(
|
|
94
|
+
prog="pycodex-ws",
|
|
95
|
+
description="Run a local pycodex workspace with a board and chat session.",
|
|
96
|
+
)
|
|
97
|
+
parser.add_argument(
|
|
98
|
+
"--listen",
|
|
99
|
+
default="127.0.0.1:6007",
|
|
100
|
+
help="Bind address as host:port, for example 0.0.0.0:6007.",
|
|
101
|
+
)
|
|
102
|
+
parser.add_argument(
|
|
103
|
+
"--board",
|
|
104
|
+
default=None,
|
|
105
|
+
help="Optional board HTML path. Defaults to a writable random path under /tmp.",
|
|
106
|
+
)
|
|
107
|
+
parser.add_argument(
|
|
108
|
+
"--config",
|
|
109
|
+
default=str(DEFAULT_CODEX_CONFIG_PATH),
|
|
110
|
+
help="Path to Codex config.toml.",
|
|
111
|
+
)
|
|
112
|
+
parser.add_argument("--profile", default=None, help="Optional profile name.")
|
|
113
|
+
parser.add_argument(
|
|
114
|
+
"--system-prompt",
|
|
115
|
+
default=None,
|
|
116
|
+
help="Optional base instructions override passed to the model.",
|
|
117
|
+
)
|
|
118
|
+
parser.add_argument(
|
|
119
|
+
"--timeout-seconds",
|
|
120
|
+
type=float,
|
|
121
|
+
default=120.0,
|
|
122
|
+
help="HTTP timeout for one model call.",
|
|
123
|
+
)
|
|
124
|
+
parser.add_argument(
|
|
125
|
+
"--vllm-endpoint",
|
|
126
|
+
default=None,
|
|
127
|
+
help="Optional base URL for a chat-completions-backed vLLM server.",
|
|
128
|
+
)
|
|
129
|
+
parser.add_argument(
|
|
130
|
+
"--use-chat-completion",
|
|
131
|
+
default=False,
|
|
132
|
+
action="store_true",
|
|
133
|
+
help="Start a local responses compat server for this session.",
|
|
134
|
+
)
|
|
135
|
+
parser.add_argument(
|
|
136
|
+
"--use-messages",
|
|
137
|
+
default=False,
|
|
138
|
+
action="store_true",
|
|
139
|
+
help="Route through a downstream /v1/messages backend.",
|
|
140
|
+
)
|
|
141
|
+
return parser
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def parse_target(
|
|
145
|
+
target: str,
|
|
146
|
+
board: "typing.Union[str, None]" = None,
|
|
147
|
+
) -> "typing.Tuple[str, int, typing.Union[Path, None]]":
|
|
148
|
+
target_text = str(target or "").strip() or "127.0.0.1:6007"
|
|
149
|
+
board_text = board
|
|
150
|
+
if "+" in target_text:
|
|
151
|
+
target_text, suffix = target_text.split("+", 1)
|
|
152
|
+
if board_text is None:
|
|
153
|
+
board_text = suffix
|
|
154
|
+
if ":" not in target_text:
|
|
155
|
+
raise ValueError("workspace target must look like host:port")
|
|
156
|
+
host, port_text = target_text.rsplit(":", 1)
|
|
157
|
+
host = host.strip() or "127.0.0.1"
|
|
158
|
+
try:
|
|
159
|
+
port = int(port_text)
|
|
160
|
+
except ValueError as exc:
|
|
161
|
+
raise ValueError("workspace port must be an integer") from exc
|
|
162
|
+
board_path = Path(board_text).expanduser().resolve() if board_text else None
|
|
163
|
+
return host, port, board_path
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
SessionFactory = typing.Callable[[], object]
|
|
167
|
+
ThreadedSessionFactory = typing.Callable[[], "WorkspaceInteractiveSession"]
|
|
168
|
+
SESSION_CLOSE_TIMEOUT_SECONDS = 2.0
|
|
169
|
+
SPINNER_STATUS_PREVIEW_LIMIT = 180
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
class WebSessionView:
|
|
173
|
+
def __init__(self) -> None:
|
|
174
|
+
self._input_queue: "asyncio.Queue" = asyncio.Queue()
|
|
175
|
+
self._subscribers: "typing.Set[asyncio.Queue]" = set()
|
|
176
|
+
self._events: "typing.List[typing.Dict[str, object]]" = []
|
|
177
|
+
self._turns: "typing.List[typing.Dict[str, object]]" = []
|
|
178
|
+
self._turns_by_submission_id: "typing.Dict[str, typing.Dict[str, object]]" = {}
|
|
179
|
+
self._turns_by_turn_id: "typing.Dict[str, typing.Dict[str, object]]" = {}
|
|
180
|
+
self._title = ""
|
|
181
|
+
self._spinner_status = ""
|
|
182
|
+
self._stream_buffer = ""
|
|
183
|
+
self._closed = False
|
|
184
|
+
self._server_loop: "typing.Union[asyncio.AbstractEventLoop, None]" = None
|
|
185
|
+
self._worker_loop: "typing.Union[asyncio.AbstractEventLoop, None]" = None
|
|
186
|
+
self._lock = threading.RLock()
|
|
187
|
+
|
|
188
|
+
def attach_server_loop(self, loop: "asyncio.AbstractEventLoop") -> None:
|
|
189
|
+
self._server_loop = loop
|
|
190
|
+
|
|
191
|
+
def attach_worker_loop(self, loop: "asyncio.AbstractEventLoop") -> None:
|
|
192
|
+
self._worker_loop = loop
|
|
193
|
+
|
|
194
|
+
async def submit(self, prompt: str) -> "typing.Dict[str, object]":
|
|
195
|
+
prompt = str(prompt or "").strip()
|
|
196
|
+
if not prompt:
|
|
197
|
+
return {"ok": False, "error": "prompt is empty"}
|
|
198
|
+
await self._put_input(prompt)
|
|
199
|
+
await self._publish(
|
|
200
|
+
{
|
|
201
|
+
"type": "input",
|
|
202
|
+
"prompt": prompt,
|
|
203
|
+
"snapshot": self.snapshot(),
|
|
204
|
+
}
|
|
205
|
+
)
|
|
206
|
+
return {"ok": True, "type": "submitted", "snapshot": self.snapshot()}
|
|
207
|
+
|
|
208
|
+
async def _put_input(self, item: object) -> None:
|
|
209
|
+
worker_loop = self._worker_loop
|
|
210
|
+
try:
|
|
211
|
+
running_loop = asyncio.get_running_loop()
|
|
212
|
+
except RuntimeError:
|
|
213
|
+
running_loop = None
|
|
214
|
+
if worker_loop is None or worker_loop is running_loop:
|
|
215
|
+
await self._input_queue.put(item)
|
|
216
|
+
return
|
|
217
|
+
future = asyncio.run_coroutine_threadsafe(self._input_queue.put(item), worker_loop)
|
|
218
|
+
await asyncio.wrap_future(future)
|
|
219
|
+
|
|
220
|
+
async def poll_prompt(self, prompt: "typing.Union[str, None]" = None) -> "typing.Union[str, None]":
|
|
221
|
+
del prompt
|
|
222
|
+
if self._closed and self._input_queue.empty():
|
|
223
|
+
raise EOFError()
|
|
224
|
+
try:
|
|
225
|
+
item = self._input_queue.get_nowait()
|
|
226
|
+
except asyncio.QueueEmpty:
|
|
227
|
+
return None
|
|
228
|
+
if item is None:
|
|
229
|
+
raise EOFError()
|
|
230
|
+
return str(item)
|
|
231
|
+
|
|
232
|
+
async def get_prompt(self, prompt: "typing.Union[str, None]" = None) -> "str":
|
|
233
|
+
if prompt:
|
|
234
|
+
self.write_line(prompt)
|
|
235
|
+
item = await self._input_queue.get()
|
|
236
|
+
if item is None:
|
|
237
|
+
raise EOFError()
|
|
238
|
+
return str(item)
|
|
239
|
+
|
|
240
|
+
def handle_event(self, event: "AgentEvent") -> None:
|
|
241
|
+
with self._lock:
|
|
242
|
+
self._apply_runtime_event(event)
|
|
243
|
+
payload = {
|
|
244
|
+
"type": "event",
|
|
245
|
+
"kind": str(getattr(event, "kind", "")),
|
|
246
|
+
"turn_id": str(getattr(event, "turn_id", "")),
|
|
247
|
+
"payload": _json_safe(getattr(event, "payload", {})),
|
|
248
|
+
"snapshot": self.snapshot(),
|
|
249
|
+
}
|
|
250
|
+
if payload["kind"] == "tool_completed":
|
|
251
|
+
payload["summary"] = tool_summary(getattr(event, "payload", {}))
|
|
252
|
+
self._publish_nowait(payload)
|
|
253
|
+
|
|
254
|
+
def finish_stream(self) -> None:
|
|
255
|
+
with self._lock:
|
|
256
|
+
if not self._stream_buffer:
|
|
257
|
+
return
|
|
258
|
+
active_turn = self._last_active_turn()
|
|
259
|
+
if active_turn is not None and not active_turn.get("response"):
|
|
260
|
+
active_turn["response"] = self._stream_buffer
|
|
261
|
+
active_turn["thinking"] = ""
|
|
262
|
+
active_turn["_thinking_active"] = False
|
|
263
|
+
self._stream_buffer = ""
|
|
264
|
+
event = {"type": "snapshot", "snapshot": self.snapshot()}
|
|
265
|
+
self._publish_nowait(event)
|
|
266
|
+
|
|
267
|
+
def write_line(self, text: str) -> None:
|
|
268
|
+
with self._lock:
|
|
269
|
+
text = str(text or "")
|
|
270
|
+
turn = self._new_control_turn(text)
|
|
271
|
+
turn["response"] = text
|
|
272
|
+
turn["status"] = "completed"
|
|
273
|
+
event = {"type": "snapshot", "snapshot": self.snapshot()}
|
|
274
|
+
self._publish_nowait(event)
|
|
275
|
+
|
|
276
|
+
def show_error(self, text: str) -> None:
|
|
277
|
+
self.finish_stream()
|
|
278
|
+
with self._lock:
|
|
279
|
+
turn = self._new_control_turn("")
|
|
280
|
+
turn["error"] = str(text or "")
|
|
281
|
+
turn["status"] = "error"
|
|
282
|
+
event = {"type": "snapshot", "snapshot": self.snapshot()}
|
|
283
|
+
self._publish_nowait(event)
|
|
284
|
+
|
|
285
|
+
def show_history(self) -> None:
|
|
286
|
+
assistant_turns = [turn for turn in self._turns if turn.get("kind") != "control"]
|
|
287
|
+
if not assistant_turns:
|
|
288
|
+
self.write_line("No history yet.")
|
|
289
|
+
return
|
|
290
|
+
lines = ["Session: {0}".format(self._title or "untitled")]
|
|
291
|
+
for index, turn in enumerate(assistant_turns, start=1):
|
|
292
|
+
prompt = str(turn.get("prompt") or "")
|
|
293
|
+
response = str(turn.get("response") or turn.get("thinking") or "")
|
|
294
|
+
lines.append("[{0}]U> {1}".format(index, prompt))
|
|
295
|
+
if response:
|
|
296
|
+
lines.append("[{0}]A> {1}".format(index, response))
|
|
297
|
+
self.write_line("\n".join(lines))
|
|
298
|
+
|
|
299
|
+
def show_title(self) -> None:
|
|
300
|
+
self.write_line("Session: {0}".format(self._title or "untitled"))
|
|
301
|
+
|
|
302
|
+
def set_session_title(self, title: str) -> None:
|
|
303
|
+
with self._lock:
|
|
304
|
+
self._set_title(title)
|
|
305
|
+
event = {
|
|
306
|
+
"type": "title_changed",
|
|
307
|
+
"title": self._title,
|
|
308
|
+
"snapshot": self.snapshot(),
|
|
309
|
+
}
|
|
310
|
+
self._publish_nowait(event)
|
|
311
|
+
|
|
312
|
+
def show_resumed_session(self, title: str) -> None:
|
|
313
|
+
with self._lock:
|
|
314
|
+
self._set_title(title)
|
|
315
|
+
event = {"type": "snapshot", "snapshot": self.snapshot()}
|
|
316
|
+
self._publish_nowait(event)
|
|
317
|
+
|
|
318
|
+
def load_session_history(
|
|
319
|
+
self,
|
|
320
|
+
title: "typing.Union[str, None]",
|
|
321
|
+
history: "typing.Iterable[typing.Tuple[str, str]]",
|
|
322
|
+
) -> None:
|
|
323
|
+
self.finish_stream()
|
|
324
|
+
with self._lock:
|
|
325
|
+
self._set_title(title)
|
|
326
|
+
self._turns = []
|
|
327
|
+
self._turns_by_submission_id = {}
|
|
328
|
+
self._turns_by_turn_id = {}
|
|
329
|
+
self._events = []
|
|
330
|
+
for prompt, response in history:
|
|
331
|
+
submission_id = uuid7_string()
|
|
332
|
+
turn = self._ensure_turn(submission_id, submission_id, str(prompt or ""))
|
|
333
|
+
turn["response"] = str(response or "")
|
|
334
|
+
turn["status"] = "completed"
|
|
335
|
+
turn["queue"] = "history"
|
|
336
|
+
turn["sender"] = "resume"
|
|
337
|
+
event = {"type": "snapshot", "snapshot": self.snapshot()}
|
|
338
|
+
self._publish_nowait(event)
|
|
339
|
+
|
|
340
|
+
def show_steer_queued(self, turn_id: str, prompt: str) -> None:
|
|
341
|
+
del turn_id, prompt
|
|
342
|
+
|
|
343
|
+
def schedule_steer_inserted(self, turn_id: str, prompt: str) -> None:
|
|
344
|
+
del turn_id, prompt
|
|
345
|
+
|
|
346
|
+
def set_context_window_tokens(
|
|
347
|
+
self,
|
|
348
|
+
context_window_tokens: "typing.Union[int, None]",
|
|
349
|
+
) -> None:
|
|
350
|
+
del context_window_tokens
|
|
351
|
+
|
|
352
|
+
def subscribe(self) -> "asyncio.Queue":
|
|
353
|
+
queue: "asyncio.Queue" = asyncio.Queue()
|
|
354
|
+
with self._lock:
|
|
355
|
+
self._subscribers.add(queue)
|
|
356
|
+
event = {
|
|
357
|
+
"type": "hello",
|
|
358
|
+
"events": list(self._events[-200:]),
|
|
359
|
+
"snapshot": self.snapshot(),
|
|
360
|
+
}
|
|
361
|
+
queue.put_nowait(event)
|
|
362
|
+
return queue
|
|
363
|
+
|
|
364
|
+
def unsubscribe(self, queue: "asyncio.Queue") -> None:
|
|
365
|
+
with self._lock:
|
|
366
|
+
self._subscribers.discard(queue)
|
|
367
|
+
|
|
368
|
+
def close(self) -> None:
|
|
369
|
+
with self._lock:
|
|
370
|
+
self._closed = True
|
|
371
|
+
subscribers = tuple(self._subscribers)
|
|
372
|
+
self._subscribers.clear()
|
|
373
|
+
worker_loop = self._worker_loop
|
|
374
|
+
if worker_loop is None:
|
|
375
|
+
self._input_queue.put_nowait(None)
|
|
376
|
+
else:
|
|
377
|
+
asyncio.run_coroutine_threadsafe(self._input_queue.put(None), worker_loop)
|
|
378
|
+
self._publish_to_queues(subscribers, None)
|
|
379
|
+
|
|
380
|
+
def snapshot(self) -> "typing.Dict[str, object]":
|
|
381
|
+
with self._lock:
|
|
382
|
+
return {
|
|
383
|
+
"running": bool(self._spinner_status),
|
|
384
|
+
"status": self._spinner_status,
|
|
385
|
+
"status_kind": "spinner" if self._spinner_status else "idle",
|
|
386
|
+
"spinner": self._spinner_status,
|
|
387
|
+
"model": "pycodex",
|
|
388
|
+
"title": self._title,
|
|
389
|
+
"turns": [_public_turn(turn) for turn in self._turns[-80:]],
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
def summary(self) -> "typing.Dict[str, object]":
|
|
393
|
+
with self._lock:
|
|
394
|
+
return {
|
|
395
|
+
"running": bool(self._spinner_status),
|
|
396
|
+
"spinner": self._spinner_status,
|
|
397
|
+
"title": self._title,
|
|
398
|
+
"turn_count": len(self._turns),
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
def _apply_runtime_event(self, event: "AgentEvent") -> None:
|
|
402
|
+
kind = str(getattr(event, "kind", "") or "")
|
|
403
|
+
payload = getattr(event, "payload", {})
|
|
404
|
+
if not isinstance(payload, dict):
|
|
405
|
+
payload = {}
|
|
406
|
+
turn_id = str(payload.get("turn_id") or getattr(event, "turn_id", "") or "")
|
|
407
|
+
submission_id = str(payload.get("submission_id") or turn_id or "")
|
|
408
|
+
turn = self._turns_by_submission_id.get(submission_id)
|
|
409
|
+
if turn is None and turn_id and not submission_id:
|
|
410
|
+
turn = self._turns_by_turn_id.get(turn_id)
|
|
411
|
+
|
|
412
|
+
if kind == "turn_started":
|
|
413
|
+
self._set_spinner_status(kind)
|
|
414
|
+
prompt = payload.get("user_text") or "\n".join(
|
|
415
|
+
str(item) for item in payload.get("user_texts", []) or []
|
|
416
|
+
)
|
|
417
|
+
if not self._title and str(prompt or "").strip():
|
|
418
|
+
self._set_title(shorten_title(str(prompt or "")))
|
|
419
|
+
turn = self._ensure_turn(submission_id, turn_id, str(prompt or ""))
|
|
420
|
+
turn["status"] = "running"
|
|
421
|
+
turn["thinking"] = ""
|
|
422
|
+
turn["_thinking_active"] = False
|
|
423
|
+
turn["error"] = ""
|
|
424
|
+
return
|
|
425
|
+
|
|
426
|
+
self._apply_spinner_event(kind, payload)
|
|
427
|
+
if turn is None:
|
|
428
|
+
return
|
|
429
|
+
|
|
430
|
+
if kind == "assistant_delta":
|
|
431
|
+
turn["status"] = "responding"
|
|
432
|
+
delta = str(payload.get("delta") or "")
|
|
433
|
+
self._stream_buffer += delta
|
|
434
|
+
if turn.get("_thinking_active"):
|
|
435
|
+
turn["thinking"] = str(turn.get("thinking") or "") + delta
|
|
436
|
+
else:
|
|
437
|
+
turn["thinking"] = delta
|
|
438
|
+
turn["_thinking_active"] = True
|
|
439
|
+
return
|
|
440
|
+
|
|
441
|
+
if kind == "tool_started":
|
|
442
|
+
turn["status"] = "tool"
|
|
443
|
+
turn["tool_name"] = str(payload.get("tool_name") or "")
|
|
444
|
+
turn["_thinking_active"] = False
|
|
445
|
+
return
|
|
446
|
+
|
|
447
|
+
if kind == "tool_completed":
|
|
448
|
+
turn["_thinking_active"] = False
|
|
449
|
+
turn["status"] = "running"
|
|
450
|
+
return
|
|
451
|
+
|
|
452
|
+
if kind == "turn_completed":
|
|
453
|
+
response = str(payload.get("output_text") or "")
|
|
454
|
+
if response:
|
|
455
|
+
turn["response"] = response
|
|
456
|
+
elif turn.get("thinking"):
|
|
457
|
+
turn["response"] = str(turn.get("thinking") or "")
|
|
458
|
+
turn["thinking"] = ""
|
|
459
|
+
turn["_thinking_active"] = False
|
|
460
|
+
turn["status"] = "completed"
|
|
461
|
+
self._stream_buffer = ""
|
|
462
|
+
return
|
|
463
|
+
|
|
464
|
+
if kind in {"turn_failed", "submission_failed"}:
|
|
465
|
+
turn["status"] = "error"
|
|
466
|
+
turn["error"] = str(payload.get("error") or kind)
|
|
467
|
+
self._stream_buffer = ""
|
|
468
|
+
return
|
|
469
|
+
|
|
470
|
+
if kind in {"turn_interrupted", "submission_cancelled"}:
|
|
471
|
+
if turn.get("thinking") and not turn.get("response"):
|
|
472
|
+
turn["response"] = str(turn.get("thinking") or "")
|
|
473
|
+
turn["thinking"] = ""
|
|
474
|
+
turn["_thinking_active"] = False
|
|
475
|
+
turn["status"] = "interrupted"
|
|
476
|
+
self._stream_buffer = ""
|
|
477
|
+
|
|
478
|
+
def _apply_spinner_event(
|
|
479
|
+
self,
|
|
480
|
+
kind: str,
|
|
481
|
+
payload: "typing.Dict[str, object]",
|
|
482
|
+
) -> None:
|
|
483
|
+
if kind == "assistant_delta":
|
|
484
|
+
self._set_spinner_status("talking")
|
|
485
|
+
return
|
|
486
|
+
if kind == "stream_error":
|
|
487
|
+
self._set_spinner_status("reconnecting")
|
|
488
|
+
return
|
|
489
|
+
if kind == "auto_compact_started":
|
|
490
|
+
self._set_spinner_status("compacting")
|
|
491
|
+
return
|
|
492
|
+
if kind == "auto_compact_completed":
|
|
493
|
+
self._set_spinner_status("compacted")
|
|
494
|
+
return
|
|
495
|
+
if kind == "tool_started":
|
|
496
|
+
tool_name = str(payload.get("tool_name") or "").strip()
|
|
497
|
+
call = payload.get("call")
|
|
498
|
+
if tool_name and isinstance(call, ToolCall):
|
|
499
|
+
self._set_spinner_status(
|
|
500
|
+
shorten_title(
|
|
501
|
+
"calling {0}({1})".format(tool_name, call.arguments),
|
|
502
|
+
limit=SPINNER_STATUS_PREVIEW_LIMIT,
|
|
503
|
+
)
|
|
504
|
+
)
|
|
505
|
+
elif tool_name:
|
|
506
|
+
self._set_spinner_status("calling {0}".format(tool_name))
|
|
507
|
+
else:
|
|
508
|
+
self._set_spinner_status("calling provider tools")
|
|
509
|
+
return
|
|
510
|
+
if kind == "tool_completed":
|
|
511
|
+
tool_name = str(payload.get("tool_name") or "").strip()
|
|
512
|
+
if tool_name:
|
|
513
|
+
self._set_spinner_status("called {0}".format(tool_name))
|
|
514
|
+
return
|
|
515
|
+
if kind in {"turn_completed", "turn_failed", "turn_interrupted"}:
|
|
516
|
+
self._set_idle_spinner_status(payload)
|
|
517
|
+
return
|
|
518
|
+
if kind == "submission_failed":
|
|
519
|
+
self._set_spinner_status("")
|
|
520
|
+
|
|
521
|
+
def _set_spinner_status(self, text: "typing.Union[str, None]") -> None:
|
|
522
|
+
self._spinner_status = str(text or "").strip()
|
|
523
|
+
|
|
524
|
+
def _set_idle_spinner_status(self, payload: "typing.Dict[str, object]") -> None:
|
|
525
|
+
try:
|
|
526
|
+
background_work_count = int(payload.get("background_exec_count", 0))
|
|
527
|
+
except (TypeError, ValueError):
|
|
528
|
+
background_work_count = 0
|
|
529
|
+
if background_work_count > 0:
|
|
530
|
+
self._set_spinner_status(IDLE_LISTENING_STATUS)
|
|
531
|
+
else:
|
|
532
|
+
self._set_spinner_status("")
|
|
533
|
+
|
|
534
|
+
def _ensure_turn(
|
|
535
|
+
self,
|
|
536
|
+
submission_id: str,
|
|
537
|
+
turn_id: str,
|
|
538
|
+
prompt: str,
|
|
539
|
+
) -> "typing.Dict[str, object]":
|
|
540
|
+
submission_id = str(submission_id or "").strip()
|
|
541
|
+
turn_id = str(turn_id or submission_id).strip()
|
|
542
|
+
turn = self._turns_by_submission_id.get(submission_id)
|
|
543
|
+
if turn is None and turn_id and not submission_id:
|
|
544
|
+
turn = self._turns_by_turn_id.get(turn_id)
|
|
545
|
+
if turn is None:
|
|
546
|
+
turn = {
|
|
547
|
+
"submission_id": submission_id,
|
|
548
|
+
"turn_id": turn_id,
|
|
549
|
+
"prompt": prompt,
|
|
550
|
+
"response": "",
|
|
551
|
+
"thinking": "",
|
|
552
|
+
"_thinking_active": False,
|
|
553
|
+
"status": "queued",
|
|
554
|
+
"error": "",
|
|
555
|
+
"kind": "assistant",
|
|
556
|
+
"queue": "steer",
|
|
557
|
+
"sender": "web",
|
|
558
|
+
}
|
|
559
|
+
self._turns.append(turn)
|
|
560
|
+
if submission_id:
|
|
561
|
+
turn["submission_id"] = submission_id
|
|
562
|
+
self._turns_by_submission_id[submission_id] = turn
|
|
563
|
+
if turn_id:
|
|
564
|
+
turn["turn_id"] = turn_id
|
|
565
|
+
self._turns_by_turn_id[turn_id] = turn
|
|
566
|
+
if prompt:
|
|
567
|
+
turn["prompt"] = prompt
|
|
568
|
+
return turn
|
|
569
|
+
|
|
570
|
+
def _new_control_turn(self, text: str) -> "typing.Dict[str, object]":
|
|
571
|
+
submission_id = uuid7_string()
|
|
572
|
+
turn = self._ensure_turn(submission_id, submission_id, "")
|
|
573
|
+
turn["kind"] = "control"
|
|
574
|
+
turn["queue"] = "control"
|
|
575
|
+
turn["sender"] = "web"
|
|
576
|
+
turn["status"] = "running"
|
|
577
|
+
turn["error"] = ""
|
|
578
|
+
turn["response"] = ""
|
|
579
|
+
turn["thinking"] = ""
|
|
580
|
+
turn["prompt"] = ""
|
|
581
|
+
return turn
|
|
582
|
+
|
|
583
|
+
def _set_title(self, title: "typing.Union[str, None]") -> None:
|
|
584
|
+
self._title = str(title or "").strip()
|
|
585
|
+
|
|
586
|
+
def _last_active_turn(self) -> "typing.Union[typing.Dict[str, object], None]":
|
|
587
|
+
for turn in reversed(self._turns):
|
|
588
|
+
if turn.get("kind") != "control" and turn.get("status") not in {
|
|
589
|
+
"completed",
|
|
590
|
+
"error",
|
|
591
|
+
"interrupted",
|
|
592
|
+
}:
|
|
593
|
+
return turn
|
|
594
|
+
return None
|
|
595
|
+
|
|
596
|
+
def _publish_nowait(self, event: "typing.Dict[str, object]") -> None:
|
|
597
|
+
with self._lock:
|
|
598
|
+
self._events.append(event)
|
|
599
|
+
if len(self._events) > 500:
|
|
600
|
+
del self._events[:-500]
|
|
601
|
+
subscribers = tuple(self._subscribers)
|
|
602
|
+
self._publish_to_queues(subscribers, event)
|
|
603
|
+
|
|
604
|
+
def _publish_to_queues(
|
|
605
|
+
self,
|
|
606
|
+
queues: "typing.Iterable[asyncio.Queue]",
|
|
607
|
+
event: "typing.Union[typing.Dict[str, object], None]",
|
|
608
|
+
) -> None:
|
|
609
|
+
loop = self._server_loop
|
|
610
|
+
if loop is None:
|
|
611
|
+
for queue in queues:
|
|
612
|
+
queue.put_nowait(event)
|
|
613
|
+
return
|
|
614
|
+
|
|
615
|
+
def publish() -> None:
|
|
616
|
+
for queue in queues:
|
|
617
|
+
queue.put_nowait(event)
|
|
618
|
+
|
|
619
|
+
loop.call_soon_threadsafe(publish)
|
|
620
|
+
|
|
621
|
+
async def _publish(self, event: "typing.Dict[str, object]") -> None:
|
|
622
|
+
self._publish_nowait(event)
|
|
623
|
+
|
|
624
|
+
|
|
625
|
+
class WorkspaceInteractiveSession:
|
|
626
|
+
def __init__(
|
|
627
|
+
self,
|
|
628
|
+
queue,
|
|
629
|
+
config_path: "typing.Union[str, None]" = None,
|
|
630
|
+
) -> None:
|
|
631
|
+
self.queue = queue
|
|
632
|
+
self.config_path = config_path
|
|
633
|
+
self.view = WebSessionView()
|
|
634
|
+
self._task: "typing.Union[asyncio.Task[int], None]" = None
|
|
635
|
+
|
|
636
|
+
async def start(self) -> "WorkspaceInteractiveSession":
|
|
637
|
+
if self._task is None:
|
|
638
|
+
self._task = asyncio.create_task(
|
|
639
|
+
run_interactive_session(
|
|
640
|
+
self.queue,
|
|
641
|
+
False,
|
|
642
|
+
self.config_path,
|
|
643
|
+
view=self.view,
|
|
644
|
+
show_banner=False,
|
|
645
|
+
)
|
|
646
|
+
)
|
|
647
|
+
return self
|
|
648
|
+
|
|
649
|
+
async def close(self) -> None:
|
|
650
|
+
self.view.close()
|
|
651
|
+
task = self._task
|
|
652
|
+
if task is None:
|
|
653
|
+
return
|
|
654
|
+
try:
|
|
655
|
+
await asyncio.wait_for(
|
|
656
|
+
asyncio.shield(task),
|
|
657
|
+
timeout=SESSION_CLOSE_TIMEOUT_SECONDS,
|
|
658
|
+
)
|
|
659
|
+
except asyncio.TimeoutError:
|
|
660
|
+
cancel_current = getattr(self.queue, "cancel_current", None)
|
|
661
|
+
if callable(cancel_current):
|
|
662
|
+
cancel_current()
|
|
663
|
+
task.cancel()
|
|
664
|
+
await asyncio.gather(task, return_exceptions=True)
|
|
665
|
+
finally:
|
|
666
|
+
self._task = None
|
|
667
|
+
|
|
668
|
+
async def submit(self, prompt: str, sender: str = "web") -> "typing.Dict[str, object]":
|
|
669
|
+
del sender
|
|
670
|
+
result = await self.view.submit(prompt)
|
|
671
|
+
result["snapshot"] = self.snapshot()
|
|
672
|
+
return result
|
|
673
|
+
|
|
674
|
+
def subscribe(self) -> "asyncio.Queue":
|
|
675
|
+
return self.view.subscribe()
|
|
676
|
+
|
|
677
|
+
def unsubscribe(self, queue: "asyncio.Queue") -> None:
|
|
678
|
+
self.view.unsubscribe(queue)
|
|
679
|
+
|
|
680
|
+
def snapshot(self) -> "typing.Dict[str, object]":
|
|
681
|
+
snapshot = self.view.snapshot()
|
|
682
|
+
agent = getattr(self.queue, "_agent", None)
|
|
683
|
+
snapshot["model"] = getattr(getattr(agent, "_model_client", None), "model", "pycodex")
|
|
684
|
+
return snapshot
|
|
685
|
+
|
|
686
|
+
def summary(self) -> "typing.Dict[str, object]":
|
|
687
|
+
summary = self.view.summary()
|
|
688
|
+
agent = getattr(self.queue, "_agent", None)
|
|
689
|
+
summary["model"] = getattr(getattr(agent, "_model_client", None), "model", "pycodex")
|
|
690
|
+
return summary
|
|
691
|
+
|
|
692
|
+
def rollout_path(self) -> str:
|
|
693
|
+
recorder = getattr(getattr(self.queue, "_agent", None), "_rollout_recorder", None)
|
|
694
|
+
path = getattr(recorder, "rollout_path", None)
|
|
695
|
+
return "" if path is None else str(path)
|
|
696
|
+
|
|
697
|
+
async def restore_from_rollout(self, rollout_path: str, title: str = "") -> None:
|
|
698
|
+
resumed = load_resumed_session_path(rollout_path, thread_name=title or None)
|
|
699
|
+
agent = self.queue._agent
|
|
700
|
+
agent.replace_history(resumed["history"])
|
|
701
|
+
model_client = getattr(agent, "_model_client", None)
|
|
702
|
+
if hasattr(model_client, "_session_id"):
|
|
703
|
+
model_client._session_id = str(resumed["session_id"])
|
|
704
|
+
agent.set_rollout_recorder(SessionRolloutRecorder.resume(resumed["rollout_path"]))
|
|
705
|
+
self.view.load_session_history(
|
|
706
|
+
str(title or resumed["title"]),
|
|
707
|
+
tuple(resumed["turns"]),
|
|
708
|
+
)
|
|
709
|
+
|
|
710
|
+
|
|
711
|
+
class ThreadedWorkspaceInteractiveSession:
|
|
712
|
+
def __init__(
|
|
713
|
+
self,
|
|
714
|
+
session_factory: "ThreadedSessionFactory",
|
|
715
|
+
server_loop: "asyncio.AbstractEventLoop",
|
|
716
|
+
) -> None:
|
|
717
|
+
self._session_factory = session_factory
|
|
718
|
+
self._server_loop = server_loop
|
|
719
|
+
self._view = WebSessionView()
|
|
720
|
+
self._view.attach_server_loop(server_loop)
|
|
721
|
+
self._thread: "typing.Union[threading.Thread, None]" = None
|
|
722
|
+
self._worker_loop: "typing.Union[asyncio.AbstractEventLoop, None]" = None
|
|
723
|
+
self._ready = threading.Event()
|
|
724
|
+
self._closed = threading.Event()
|
|
725
|
+
self._startup_error: "typing.Union[BaseException, None]" = None
|
|
726
|
+
self._session: "typing.Union[WorkspaceInteractiveSession, None]" = None
|
|
727
|
+
|
|
728
|
+
async def start(self) -> "ThreadedWorkspaceInteractiveSession":
|
|
729
|
+
if self._thread is not None:
|
|
730
|
+
return self
|
|
731
|
+
self._thread = threading.Thread(
|
|
732
|
+
target=self._thread_main,
|
|
733
|
+
name="pycodex-workspace-session",
|
|
734
|
+
daemon=True,
|
|
735
|
+
)
|
|
736
|
+
self._thread.start()
|
|
737
|
+
await asyncio.to_thread(self._ready.wait)
|
|
738
|
+
if self._startup_error is not None:
|
|
739
|
+
raise RuntimeError("workspace session thread failed to start") from self._startup_error
|
|
740
|
+
return self
|
|
741
|
+
|
|
742
|
+
def _thread_main(self) -> None:
|
|
743
|
+
loop = asyncio.new_event_loop()
|
|
744
|
+
self._worker_loop = loop
|
|
745
|
+
self._view.attach_worker_loop(loop)
|
|
746
|
+
asyncio.set_event_loop(loop)
|
|
747
|
+
try:
|
|
748
|
+
session = self._session_factory()
|
|
749
|
+
session.view = self._view
|
|
750
|
+
self._session = session
|
|
751
|
+
loop.run_until_complete(session.start())
|
|
752
|
+
self._ready.set()
|
|
753
|
+
loop.run_forever()
|
|
754
|
+
except BaseException as exc:
|
|
755
|
+
self._startup_error = exc
|
|
756
|
+
self._ready.set()
|
|
757
|
+
finally:
|
|
758
|
+
session = self._session
|
|
759
|
+
if session is not None:
|
|
760
|
+
try:
|
|
761
|
+
loop.run_until_complete(session.close())
|
|
762
|
+
except BaseException:
|
|
763
|
+
pass
|
|
764
|
+
pending = asyncio.all_tasks(loop)
|
|
765
|
+
for task in pending:
|
|
766
|
+
task.cancel()
|
|
767
|
+
if pending:
|
|
768
|
+
loop.run_until_complete(asyncio.gather(*pending, return_exceptions=True))
|
|
769
|
+
asyncio.set_event_loop(None)
|
|
770
|
+
loop.close()
|
|
771
|
+
self._closed.set()
|
|
772
|
+
|
|
773
|
+
async def close(self) -> None:
|
|
774
|
+
session = self._session
|
|
775
|
+
loop = self._worker_loop
|
|
776
|
+
if session is not None and loop is not None and loop.is_running():
|
|
777
|
+
future = asyncio.run_coroutine_threadsafe(session.close(), loop)
|
|
778
|
+
try:
|
|
779
|
+
await asyncio.wait_for(
|
|
780
|
+
asyncio.wrap_future(future),
|
|
781
|
+
timeout=SESSION_CLOSE_TIMEOUT_SECONDS + 1.0,
|
|
782
|
+
)
|
|
783
|
+
except (asyncio.TimeoutError, RuntimeError):
|
|
784
|
+
cancel_current = getattr(getattr(session, "queue", None), "cancel_current", None)
|
|
785
|
+
if callable(cancel_current):
|
|
786
|
+
cancel_current()
|
|
787
|
+
if loop is not None and loop.is_running():
|
|
788
|
+
loop.call_soon_threadsafe(loop.stop)
|
|
789
|
+
thread = self._thread
|
|
790
|
+
if thread is not None:
|
|
791
|
+
await asyncio.to_thread(thread.join, SESSION_CLOSE_TIMEOUT_SECONDS + 1.0)
|
|
792
|
+
self._thread = None
|
|
793
|
+
|
|
794
|
+
async def submit(self, prompt: str, sender: str = "web") -> "typing.Dict[str, object]":
|
|
795
|
+
del sender
|
|
796
|
+
result = await self._view.submit(prompt)
|
|
797
|
+
result["snapshot"] = self.snapshot()
|
|
798
|
+
return result
|
|
799
|
+
|
|
800
|
+
def subscribe(self) -> "asyncio.Queue":
|
|
801
|
+
return self._view.subscribe()
|
|
802
|
+
|
|
803
|
+
def unsubscribe(self, queue: "asyncio.Queue") -> None:
|
|
804
|
+
self._view.unsubscribe(queue)
|
|
805
|
+
|
|
806
|
+
def snapshot(self) -> "typing.Dict[str, object]":
|
|
807
|
+
snapshot = self._view.snapshot()
|
|
808
|
+
session = self._session
|
|
809
|
+
queue = getattr(session, "queue", None)
|
|
810
|
+
agent = getattr(queue, "_agent", None)
|
|
811
|
+
snapshot["model"] = getattr(getattr(agent, "_model_client", None), "model", "pycodex")
|
|
812
|
+
return snapshot
|
|
813
|
+
|
|
814
|
+
def summary(self) -> "typing.Dict[str, object]":
|
|
815
|
+
summary = self._view.summary()
|
|
816
|
+
session = self._session
|
|
817
|
+
queue = getattr(session, "queue", None)
|
|
818
|
+
agent = getattr(queue, "_agent", None)
|
|
819
|
+
summary["model"] = getattr(getattr(agent, "_model_client", None), "model", "pycodex")
|
|
820
|
+
return summary
|
|
821
|
+
|
|
822
|
+
def rollout_path(self) -> str:
|
|
823
|
+
if self._session is None:
|
|
824
|
+
return ""
|
|
825
|
+
return self._session.rollout_path()
|
|
826
|
+
|
|
827
|
+
async def restore_from_rollout(self, rollout_path: str, title: str = "") -> None:
|
|
828
|
+
session = self._session
|
|
829
|
+
loop = self._worker_loop
|
|
830
|
+
if session is None or loop is None:
|
|
831
|
+
return
|
|
832
|
+
|
|
833
|
+
future = asyncio.run_coroutine_threadsafe(
|
|
834
|
+
session.restore_from_rollout(rollout_path, title=title),
|
|
835
|
+
loop,
|
|
836
|
+
)
|
|
837
|
+
await asyncio.wrap_future(future)
|
|
838
|
+
|
|
839
|
+
|
|
840
|
+
class WorkspaceSessionManager:
|
|
841
|
+
def __init__(
|
|
842
|
+
self,
|
|
843
|
+
session_factory: "SessionFactory",
|
|
844
|
+
board_path: "typing.Union[Path, None]" = None,
|
|
845
|
+
) -> None:
|
|
846
|
+
self._session_factory = session_factory
|
|
847
|
+
self._sessions: "typing.Dict[str, WorkspaceInteractiveSession]" = {}
|
|
848
|
+
self._session_order: "typing.List[str]" = []
|
|
849
|
+
self._state_watchers: "typing.Dict[str, asyncio.Task]" = {}
|
|
850
|
+
self._persisted_titles: "typing.Dict[str, str]" = {}
|
|
851
|
+
self._lock = asyncio.Lock()
|
|
852
|
+
self._state_store = WorkspaceStateStore(board_path)
|
|
853
|
+
|
|
854
|
+
async def start(self) -> None:
|
|
855
|
+
state_tabs = self._state_store.load_tabs()
|
|
856
|
+
if not state_tabs:
|
|
857
|
+
await self.create_session()
|
|
858
|
+
return
|
|
859
|
+
for tab in state_tabs:
|
|
860
|
+
await self.create_session(
|
|
861
|
+
title=str(tab.get("title") or ""),
|
|
862
|
+
rollout_path=str(tab.get("rollout_path") or ""),
|
|
863
|
+
)
|
|
864
|
+
|
|
865
|
+
async def close(self) -> None:
|
|
866
|
+
sessions = list(self._sessions.values())
|
|
867
|
+
watchers = list(self._state_watchers.values())
|
|
868
|
+
self._sessions.clear()
|
|
869
|
+
self._session_order = []
|
|
870
|
+
self._state_watchers.clear()
|
|
871
|
+
self._persisted_titles.clear()
|
|
872
|
+
for watcher in watchers:
|
|
873
|
+
watcher.cancel()
|
|
874
|
+
if watchers:
|
|
875
|
+
await asyncio.gather(*watchers, return_exceptions=True)
|
|
876
|
+
for session in sessions:
|
|
877
|
+
await session.close()
|
|
878
|
+
|
|
879
|
+
async def create_session(
|
|
880
|
+
self,
|
|
881
|
+
title: str = "",
|
|
882
|
+
rollout_path: str = "",
|
|
883
|
+
) -> str:
|
|
884
|
+
async with self._lock:
|
|
885
|
+
session_id = uuid7_string()
|
|
886
|
+
session = self._session_factory()
|
|
887
|
+
await session.start()
|
|
888
|
+
|
|
889
|
+
if rollout_path:
|
|
890
|
+
await session.restore_from_rollout(rollout_path, title=title)
|
|
891
|
+
|
|
892
|
+
self._sessions[session_id] = session
|
|
893
|
+
self._session_order.append(session_id)
|
|
894
|
+
self._persisted_titles[session_id] = str(
|
|
895
|
+
_session_summary(session).get("title") or ""
|
|
896
|
+
)
|
|
897
|
+
self._state_watchers[session_id] = asyncio.create_task(
|
|
898
|
+
self._watch_session_title(session_id, session)
|
|
899
|
+
)
|
|
900
|
+
return session_id
|
|
901
|
+
|
|
902
|
+
async def close_session(self, session_id: str) -> None:
|
|
903
|
+
async with self._lock:
|
|
904
|
+
if len(self._session_order) <= 1:
|
|
905
|
+
raise ValueError("cannot close the last session")
|
|
906
|
+
session = self._sessions.pop(session_id, None)
|
|
907
|
+
if session is None:
|
|
908
|
+
raise KeyError(session_id)
|
|
909
|
+
watcher = self._state_watchers.pop(session_id, None)
|
|
910
|
+
self._persisted_titles.pop(session_id, None)
|
|
911
|
+
self._session_order = [
|
|
912
|
+
item for item in self._session_order if item != session_id
|
|
913
|
+
]
|
|
914
|
+
if watcher is not None:
|
|
915
|
+
watcher.cancel()
|
|
916
|
+
await asyncio.gather(watcher, return_exceptions=True)
|
|
917
|
+
await session.close()
|
|
918
|
+
self.persist_workspace_state()
|
|
919
|
+
|
|
920
|
+
async def _watch_session_title(self, session_id: str, session) -> None:
|
|
921
|
+
subscriber = session.subscribe()
|
|
922
|
+
try:
|
|
923
|
+
while True:
|
|
924
|
+
event = await subscriber.get()
|
|
925
|
+
if event is None:
|
|
926
|
+
return
|
|
927
|
+
if not isinstance(event, dict) or event.get("type") != "title_changed":
|
|
928
|
+
continue
|
|
929
|
+
title = str(event.get("title") or "")
|
|
930
|
+
if title == self._persisted_titles.get(session_id, ""):
|
|
931
|
+
continue
|
|
932
|
+
self._persisted_titles[session_id] = title
|
|
933
|
+
self.persist_workspace_state()
|
|
934
|
+
finally:
|
|
935
|
+
session.unsubscribe(subscriber)
|
|
936
|
+
|
|
937
|
+
def persist_workspace_state(self) -> None:
|
|
938
|
+
tabs = []
|
|
939
|
+
for session_id in self._session_order:
|
|
940
|
+
session = self._sessions.get(session_id)
|
|
941
|
+
if session is None:
|
|
942
|
+
continue
|
|
943
|
+
summary = _session_summary(session)
|
|
944
|
+
title = str(summary.get("title") or "").strip()
|
|
945
|
+
rollout_path = str(session.rollout_path() or "")
|
|
946
|
+
if not title and not rollout_path:
|
|
947
|
+
continue
|
|
948
|
+
tabs.append({"title": title, "rollout_path": rollout_path})
|
|
949
|
+
self._state_store.save_tabs(tabs)
|
|
950
|
+
|
|
951
|
+
def get(self, session_id: "typing.Union[str, None]" = None) -> "WorkspaceInteractiveSession":
|
|
952
|
+
resolved_id = self.resolve_session_id(session_id)
|
|
953
|
+
try:
|
|
954
|
+
return self._sessions[resolved_id]
|
|
955
|
+
except KeyError:
|
|
956
|
+
raise KeyError(resolved_id)
|
|
957
|
+
|
|
958
|
+
def resolve_session_id(self, session_id: "typing.Union[str, None]" = None) -> str:
|
|
959
|
+
if session_id:
|
|
960
|
+
return str(session_id)
|
|
961
|
+
if not self._session_order:
|
|
962
|
+
raise KeyError("no sessions")
|
|
963
|
+
return self._session_order[0]
|
|
964
|
+
|
|
965
|
+
def list_sessions(self) -> "typing.List[typing.Dict[str, object]]":
|
|
966
|
+
result = []
|
|
967
|
+
for session_id in self._session_order:
|
|
968
|
+
session = self._sessions[session_id]
|
|
969
|
+
summary = _session_summary(session)
|
|
970
|
+
result.append(
|
|
971
|
+
{
|
|
972
|
+
"id": session_id,
|
|
973
|
+
"title": summary.get("title") or "pycodex",
|
|
974
|
+
"running": bool(summary.get("running")),
|
|
975
|
+
"spinner": summary.get("spinner") or "",
|
|
976
|
+
"turn_count": summary.get("turn_count") or 0,
|
|
977
|
+
}
|
|
978
|
+
)
|
|
979
|
+
return result
|
|
980
|
+
|
|
981
|
+
|
|
982
|
+
def create_app(
|
|
983
|
+
session_source: "typing.Union[WorkspaceSessionManager, SessionFactory]",
|
|
984
|
+
board_path: "typing.Union[Path, None]",
|
|
985
|
+
) -> FastAPI:
|
|
986
|
+
manager = (
|
|
987
|
+
session_source
|
|
988
|
+
if isinstance(session_source, WorkspaceSessionManager)
|
|
989
|
+
else WorkspaceSessionManager(session_source, board_path)
|
|
990
|
+
)
|
|
991
|
+
|
|
992
|
+
if asynccontextmanager is not None:
|
|
993
|
+
@asynccontextmanager
|
|
994
|
+
async def lifespan(_app):
|
|
995
|
+
await manager.start()
|
|
996
|
+
try:
|
|
997
|
+
yield
|
|
998
|
+
finally:
|
|
999
|
+
await manager.close()
|
|
1000
|
+
|
|
1001
|
+
app = FastAPI(lifespan=lifespan)
|
|
1002
|
+
else:
|
|
1003
|
+
app = FastAPI()
|
|
1004
|
+
|
|
1005
|
+
@app.on_event("startup")
|
|
1006
|
+
async def startup() -> None:
|
|
1007
|
+
await manager.start()
|
|
1008
|
+
|
|
1009
|
+
@app.on_event("shutdown")
|
|
1010
|
+
async def shutdown() -> None:
|
|
1011
|
+
await manager.close()
|
|
1012
|
+
|
|
1013
|
+
@app.get("/")
|
|
1014
|
+
async def index() -> HTMLResponse:
|
|
1015
|
+
return _html_response(_render_workspace_shell(board_path))
|
|
1016
|
+
|
|
1017
|
+
@app.get("/favicon.ico")
|
|
1018
|
+
async def favicon() -> Response:
|
|
1019
|
+
return Response(status_code=204)
|
|
1020
|
+
|
|
1021
|
+
@app.api_route("/board", methods=["GET", "HEAD"])
|
|
1022
|
+
async def board() -> Response:
|
|
1023
|
+
return _board_response(board_path)
|
|
1024
|
+
|
|
1025
|
+
@app.api_route("/{path_name}", methods=["GET", "HEAD"])
|
|
1026
|
+
async def legacy_board_path(path_name: str) -> Response:
|
|
1027
|
+
if board_path is not None and path_name == board_path.name:
|
|
1028
|
+
return _board_response(board_path)
|
|
1029
|
+
raise HTTPException(status_code=404, detail="not found")
|
|
1030
|
+
|
|
1031
|
+
@app.get("/api/board")
|
|
1032
|
+
async def board_status() -> JSONResponse:
|
|
1033
|
+
if board_path is None or not board_path.is_file():
|
|
1034
|
+
return JSONResponse({"exists": False})
|
|
1035
|
+
stat = board_path.stat()
|
|
1036
|
+
return JSONResponse(
|
|
1037
|
+
{
|
|
1038
|
+
"exists": True,
|
|
1039
|
+
"path": str(board_path),
|
|
1040
|
+
"mtime_ns": stat.st_mtime_ns,
|
|
1041
|
+
"size": stat.st_size,
|
|
1042
|
+
}
|
|
1043
|
+
)
|
|
1044
|
+
|
|
1045
|
+
@app.get("/ws/session")
|
|
1046
|
+
async def websocket_backend_hint() -> JSONResponse:
|
|
1047
|
+
return JSONResponse(
|
|
1048
|
+
{
|
|
1049
|
+
"error": "websocket backend is unavailable; HTTP polling is active",
|
|
1050
|
+
},
|
|
1051
|
+
status_code=426,
|
|
1052
|
+
)
|
|
1053
|
+
|
|
1054
|
+
def _board_response(board_path: "typing.Union[Path, None]") -> Response:
|
|
1055
|
+
if board_path is None:
|
|
1056
|
+
return _html_response(_render_empty_board())
|
|
1057
|
+
if not board_path.is_file():
|
|
1058
|
+
return _html_response(_render_missing_board(board_path))
|
|
1059
|
+
return _html_response(board_path.read_text(encoding="utf-8", errors="replace"))
|
|
1060
|
+
|
|
1061
|
+
@app.get("/api/sessions")
|
|
1062
|
+
async def sessions() -> JSONResponse:
|
|
1063
|
+
return JSONResponse({"sessions": manager.list_sessions()})
|
|
1064
|
+
|
|
1065
|
+
@app.post("/api/sessions")
|
|
1066
|
+
async def new_session() -> JSONResponse:
|
|
1067
|
+
session_id = await manager.create_session()
|
|
1068
|
+
return JSONResponse(
|
|
1069
|
+
{
|
|
1070
|
+
"ok": True,
|
|
1071
|
+
"session_id": session_id,
|
|
1072
|
+
"sessions": manager.list_sessions(),
|
|
1073
|
+
"snapshot": _session_snapshot(manager.get(session_id)),
|
|
1074
|
+
}
|
|
1075
|
+
)
|
|
1076
|
+
|
|
1077
|
+
@app.delete("/api/sessions/{session_id}")
|
|
1078
|
+
async def delete_session(session_id: str) -> JSONResponse:
|
|
1079
|
+
try:
|
|
1080
|
+
await manager.close_session(session_id)
|
|
1081
|
+
except KeyError:
|
|
1082
|
+
raise HTTPException(status_code=404, detail="session not found")
|
|
1083
|
+
except ValueError as exc:
|
|
1084
|
+
return JSONResponse({"ok": False, "error": str(exc)}, status_code=400)
|
|
1085
|
+
return JSONResponse({"ok": True, "sessions": manager.list_sessions()})
|
|
1086
|
+
|
|
1087
|
+
@app.get("/api/session")
|
|
1088
|
+
async def session(
|
|
1089
|
+
session_id: "typing.Union[str, None]" = None,
|
|
1090
|
+
) -> JSONResponse:
|
|
1091
|
+
try:
|
|
1092
|
+
resolved_id = manager.resolve_session_id(session_id)
|
|
1093
|
+
link = manager.get(resolved_id)
|
|
1094
|
+
except KeyError:
|
|
1095
|
+
raise HTTPException(status_code=404, detail="session not found")
|
|
1096
|
+
return JSONResponse(
|
|
1097
|
+
{
|
|
1098
|
+
"session_id": resolved_id,
|
|
1099
|
+
"sessions": manager.list_sessions(),
|
|
1100
|
+
"snapshot": _session_snapshot(link),
|
|
1101
|
+
}
|
|
1102
|
+
)
|
|
1103
|
+
|
|
1104
|
+
@app.post("/api/session/message")
|
|
1105
|
+
async def message(
|
|
1106
|
+
payload: "typing.Dict[str, object]",
|
|
1107
|
+
) -> JSONResponse:
|
|
1108
|
+
session_id = str(payload.get("session_id") or "")
|
|
1109
|
+
try:
|
|
1110
|
+
link = manager.get(session_id or None)
|
|
1111
|
+
except KeyError:
|
|
1112
|
+
raise HTTPException(status_code=404, detail="session not found")
|
|
1113
|
+
result = await link.submit(str(payload.get("prompt") or ""))
|
|
1114
|
+
if isinstance(result, dict):
|
|
1115
|
+
result.setdefault("sessions", manager.list_sessions())
|
|
1116
|
+
status = 200 if result.get("ok") else 400
|
|
1117
|
+
return JSONResponse(result, status_code=status)
|
|
1118
|
+
|
|
1119
|
+
@app.websocket("/ws/session")
|
|
1120
|
+
async def websocket_session(websocket: WebSocket) -> None:
|
|
1121
|
+
await websocket.accept()
|
|
1122
|
+
session_id = str(websocket.query_params.get("session_id") or "")
|
|
1123
|
+
try:
|
|
1124
|
+
link = manager.get(session_id or None)
|
|
1125
|
+
except KeyError:
|
|
1126
|
+
await websocket.close(code=1008)
|
|
1127
|
+
return
|
|
1128
|
+
subscriber = link.subscribe()
|
|
1129
|
+
sender = asyncio.create_task(_send_ws_events(websocket, subscriber))
|
|
1130
|
+
try:
|
|
1131
|
+
while True:
|
|
1132
|
+
data = await websocket.receive_text()
|
|
1133
|
+
try:
|
|
1134
|
+
payload = json.loads(data)
|
|
1135
|
+
except ValueError:
|
|
1136
|
+
await websocket.send_json({"type": "error", "error": "invalid json"})
|
|
1137
|
+
continue
|
|
1138
|
+
action = str(payload.get("type") or payload.get("action") or "")
|
|
1139
|
+
if action == "send":
|
|
1140
|
+
target_session_id = str(payload.get("session_id") or session_id or "")
|
|
1141
|
+
try:
|
|
1142
|
+
target_link = manager.get(target_session_id or None)
|
|
1143
|
+
except KeyError:
|
|
1144
|
+
await websocket.send_json(
|
|
1145
|
+
{"type": "error", "error": "session not found"}
|
|
1146
|
+
)
|
|
1147
|
+
continue
|
|
1148
|
+
result = await target_link.submit(
|
|
1149
|
+
str(payload.get("prompt") or ""),
|
|
1150
|
+
sender=str(payload.get("sender") or "web"),
|
|
1151
|
+
)
|
|
1152
|
+
await websocket.send_json({"type": "send_result", "result": result})
|
|
1153
|
+
elif action == "ping":
|
|
1154
|
+
await websocket.send_json({"type": "pong"})
|
|
1155
|
+
else:
|
|
1156
|
+
await websocket.send_json({"type": "error", "error": "unknown action"})
|
|
1157
|
+
except WebSocketDisconnect:
|
|
1158
|
+
pass
|
|
1159
|
+
finally:
|
|
1160
|
+
link.unsubscribe(subscriber)
|
|
1161
|
+
sender.cancel()
|
|
1162
|
+
await asyncio.gather(sender, return_exceptions=True)
|
|
1163
|
+
|
|
1164
|
+
return app
|
|
1165
|
+
|
|
1166
|
+
|
|
1167
|
+
def _session_snapshot(session) -> "typing.Dict[str, object]":
|
|
1168
|
+
return typing.cast("typing.Dict[str, object]", session.snapshot())
|
|
1169
|
+
|
|
1170
|
+
|
|
1171
|
+
def _session_summary(session) -> "typing.Dict[str, object]":
|
|
1172
|
+
summary = getattr(session, "summary", None)
|
|
1173
|
+
if callable(summary):
|
|
1174
|
+
return typing.cast("typing.Dict[str, object]", summary())
|
|
1175
|
+
snapshot = _session_snapshot(session)
|
|
1176
|
+
return {
|
|
1177
|
+
"title": snapshot.get("title") or "",
|
|
1178
|
+
"running": bool(snapshot.get("running")),
|
|
1179
|
+
"spinner": snapshot.get("spinner") or "",
|
|
1180
|
+
"turn_count": len(snapshot.get("turns") or []),
|
|
1181
|
+
}
|
|
1182
|
+
|
|
1183
|
+
|
|
1184
|
+
def _public_turn(turn: "typing.Dict[str, object]") -> "typing.Dict[str, object]":
|
|
1185
|
+
return typing.cast(
|
|
1186
|
+
"typing.Dict[str, object]",
|
|
1187
|
+
_json_safe(
|
|
1188
|
+
{
|
|
1189
|
+
"submission_id": turn.get("submission_id", ""),
|
|
1190
|
+
"turn_id": turn.get("turn_id", ""),
|
|
1191
|
+
"prompt": turn.get("prompt", ""),
|
|
1192
|
+
"response": turn.get("response", ""),
|
|
1193
|
+
"thinking": turn.get("thinking", ""),
|
|
1194
|
+
"status": turn.get("status", ""),
|
|
1195
|
+
"error": turn.get("error", ""),
|
|
1196
|
+
"queue": turn.get("queue", ""),
|
|
1197
|
+
"sender": turn.get("sender", ""),
|
|
1198
|
+
"kind": turn.get("kind", "assistant"),
|
|
1199
|
+
}
|
|
1200
|
+
),
|
|
1201
|
+
)
|
|
1202
|
+
|
|
1203
|
+
|
|
1204
|
+
def _json_safe(value: object) -> "JSONValue":
|
|
1205
|
+
if value is None or isinstance(value, (bool, int, float, str)):
|
|
1206
|
+
return value
|
|
1207
|
+
if isinstance(value, (list, tuple)):
|
|
1208
|
+
return [_json_safe(item) for item in value]
|
|
1209
|
+
if isinstance(value, dict):
|
|
1210
|
+
return {str(key): _json_safe(item) for key, item in value.items()}
|
|
1211
|
+
if is_dataclass(value):
|
|
1212
|
+
return _json_safe(asdict(value))
|
|
1213
|
+
try:
|
|
1214
|
+
json.dumps(value)
|
|
1215
|
+
except TypeError:
|
|
1216
|
+
return str(value)
|
|
1217
|
+
return typing.cast(JSONValue, value)
|
|
1218
|
+
|
|
1219
|
+
|
|
1220
|
+
def _html_response(content: str) -> HTMLResponse:
|
|
1221
|
+
return HTMLResponse(
|
|
1222
|
+
content,
|
|
1223
|
+
headers={
|
|
1224
|
+
"Cache-Control": "no-store",
|
|
1225
|
+
"Pragma": "no-cache",
|
|
1226
|
+
},
|
|
1227
|
+
)
|
|
1228
|
+
|
|
1229
|
+
|
|
1230
|
+
async def _send_ws_events(websocket: WebSocket, subscriber: "asyncio.Queue") -> None:
|
|
1231
|
+
while True:
|
|
1232
|
+
event = await subscriber.get()
|
|
1233
|
+
if event is None:
|
|
1234
|
+
return
|
|
1235
|
+
await websocket.send_json(event)
|
|
1236
|
+
|
|
1237
|
+
|
|
1238
|
+
def run_serve_cli(args: "argparse.Namespace") -> int:
|
|
1239
|
+
import uvicorn
|
|
1240
|
+
|
|
1241
|
+
board_arg = args.board if args.board is not None else _default_board_path()
|
|
1242
|
+
host, port, board_path = parse_target(args.listen, board_arg)
|
|
1243
|
+
if board_path is not None and not board_path.parent.is_dir():
|
|
1244
|
+
raise ValueError("board parent directory does not exist: {0}".format(board_path.parent))
|
|
1245
|
+
|
|
1246
|
+
configure_loguru()
|
|
1247
|
+
def build_session() -> "WorkspaceInteractiveSession":
|
|
1248
|
+
model = build_model(
|
|
1249
|
+
config_path=args.config,
|
|
1250
|
+
profile=args.profile,
|
|
1251
|
+
timeout_seconds=args.timeout_seconds,
|
|
1252
|
+
vllm_endpoint=args.vllm_endpoint,
|
|
1253
|
+
use_chat_completion=args.use_chat_completion or None,
|
|
1254
|
+
use_messages=args.use_messages,
|
|
1255
|
+
)
|
|
1256
|
+
agent = build_agent(
|
|
1257
|
+
model,
|
|
1258
|
+
config_path=args.config,
|
|
1259
|
+
profile=args.profile,
|
|
1260
|
+
system_prompt=args.system_prompt,
|
|
1261
|
+
session_mode="tui",
|
|
1262
|
+
extra_contextual_user_messages=(
|
|
1263
|
+
[_board_context_text(board_path)] if board_path is not None else []
|
|
1264
|
+
),
|
|
1265
|
+
)
|
|
1266
|
+
return WorkspaceInteractiveSession(
|
|
1267
|
+
build_cli_queue(agent),
|
|
1268
|
+
config_path=args.config,
|
|
1269
|
+
)
|
|
1270
|
+
|
|
1271
|
+
def session_factory() -> "ThreadedWorkspaceInteractiveSession":
|
|
1272
|
+
return ThreadedWorkspaceInteractiveSession(build_session, asyncio.get_running_loop())
|
|
1273
|
+
|
|
1274
|
+
app = create_app(WorkspaceSessionManager(session_factory, board_path), board_path)
|
|
1275
|
+
print(
|
|
1276
|
+
"pycodex workspace listening on http://{0}:{1}".format(host, port),
|
|
1277
|
+
flush=True,
|
|
1278
|
+
)
|
|
1279
|
+
if board_path is not None:
|
|
1280
|
+
print("board: {0}".format(board_path), flush=True)
|
|
1281
|
+
uvicorn.run(app, host=host, port=port, loop="asyncio")
|
|
1282
|
+
return 0
|
|
1283
|
+
|
|
1284
|
+
|
|
1285
|
+
def _board_context_text(board_path: Path) -> str:
|
|
1286
|
+
return (
|
|
1287
|
+
"Current workspace board file: {0}. "
|
|
1288
|
+
"Changes you make to this file are shown to the user in real time. "
|
|
1289
|
+
"You can create or modify this file anytime."
|
|
1290
|
+
).format(_format_board_path_for_prompt(board_path))
|
|
1291
|
+
|
|
1292
|
+
|
|
1293
|
+
def _default_board_path() -> Path:
|
|
1294
|
+
return Path(tempfile.gettempdir()) / "pcws-{0}.html".format(uuid4().hex[:8])
|
|
1295
|
+
|
|
1296
|
+
|
|
1297
|
+
def _format_board_path_for_prompt(board_path: Path) -> str:
|
|
1298
|
+
resolved = board_path.resolve()
|
|
1299
|
+
try:
|
|
1300
|
+
relative = os.path.relpath(str(resolved), str(Path.cwd().resolve()))
|
|
1301
|
+
except ValueError:
|
|
1302
|
+
return str(resolved)
|
|
1303
|
+
if relative == ".":
|
|
1304
|
+
return "."
|
|
1305
|
+
if relative.startswith(".."):
|
|
1306
|
+
return str(resolved)
|
|
1307
|
+
return "./{0}".format(relative)
|
|
1308
|
+
|
|
1309
|
+
|
|
1310
|
+
def _render_workspace_shell(board_path: "typing.Union[Path, None]") -> str:
|
|
1311
|
+
board_label = str(board_path) if board_path is not None else "No board"
|
|
1312
|
+
template = (Path(__file__).with_name("workspace.html")).read_text(
|
|
1313
|
+
encoding="utf-8"
|
|
1314
|
+
)
|
|
1315
|
+
return template.replace("__BOARD_LABEL__", html.escape(board_label))
|
|
1316
|
+
|
|
1317
|
+
|
|
1318
|
+
def _render_empty_board() -> str:
|
|
1319
|
+
return """<!doctype html>
|
|
1320
|
+
<html><head><meta charset="utf-8"><title>No board</title></head>
|
|
1321
|
+
<body style="font:14px -apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;padding:24px">
|
|
1322
|
+
<h1>No board configured</h1>
|
|
1323
|
+
<p>Start with <code>pycodex-ws --listen 0.0.0.0:6007 --board ./board.html</code>.</p>
|
|
1324
|
+
</body></html>"""
|
|
1325
|
+
|
|
1326
|
+
|
|
1327
|
+
def _render_missing_board(board_path: Path) -> str:
|
|
1328
|
+
escaped_path = html.escape(str(board_path))
|
|
1329
|
+
return """<!doctype html>
|
|
1330
|
+
<html><head><meta charset="utf-8"><title>Board pending</title></head>
|
|
1331
|
+
<body style="font:14px -apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;padding:24px">
|
|
1332
|
+
<h1>Board pending</h1>
|
|
1333
|
+
<p>The board file does not exist yet.</p>
|
|
1334
|
+
<p><code>{0}</code></p>
|
|
1335
|
+
</body></html>""".format(escaped_path)
|
|
1336
|
+
|
|
1337
|
+
|
|
1338
|
+
def main(argv: "typing.Union[typing.Sequence[str], None]" = None) -> int:
|
|
1339
|
+
parser = build_parser()
|
|
1340
|
+
args = parser.parse_args(argv)
|
|
1341
|
+
try:
|
|
1342
|
+
return run_serve_cli(args)
|
|
1343
|
+
except ValueError as exc:
|
|
1344
|
+
parser.error(str(exc))
|
|
1345
|
+
except KeyboardInterrupt:
|
|
1346
|
+
return 130
|
|
1347
|
+
return 0
|