python-codex 0.2.7__py3-none-any.whl → 0.3.1__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/__init__.py +14 -14
- pycodex/agent.py +465 -499
- pycodex/bootstrap.py +417 -0
- pycodex/cli.py +236 -510
- pycodex/compat.py +19 -5
- pycodex/context.py +222 -212
- pycodex/doctor.py +52 -48
- pycodex/events.py +857 -0
- pycodex/feishu_card.py +217 -163
- pycodex/feishu_link.py +43 -83
- pycodex/model.py +324 -253
- pycodex/model_metadata.py +19 -7
- pycodex/portable.py +76 -45
- pycodex/portable_server.py +32 -24
- pycodex/prompts/models.json +321 -883
- pycodex/protocol.py +177 -137
- pycodex/runtime.py +579 -176
- pycodex/runtime_services.py +204 -157
- pycodex/tools/__init__.py +1 -1
- pycodex/tools/apply_patch_tool.py +69 -48
- pycodex/tools/base_tool.py +89 -42
- pycodex/tools/clock_tool.py +58 -25
- pycodex/tools/close_agent_tool.py +2 -2
- pycodex/tools/code_mode_manager.py +77 -64
- pycodex/tools/exec_command_tool.py +26 -11
- pycodex/tools/exec_tool.py +4 -4
- pycodex/tools/grep_files_tool.py +12 -10
- pycodex/tools/ipython_tool.py +10 -13
- pycodex/tools/list_dir_tool.py +13 -9
- pycodex/tools/read_file_tool.py +29 -17
- pycodex/tools/request_permissions_tool.py +15 -5
- pycodex/tools/request_user_input_tool.py +13 -104
- pycodex/tools/resume_agent_tool.py +2 -2
- pycodex/tools/send_input_tool.py +11 -8
- pycodex/tools/shell_command_tool.py +7 -5
- pycodex/tools/shell_tool.py +7 -5
- pycodex/tools/spawn_agent_tool.py +7 -4
- pycodex/tools/unified_exec_manager.py +102 -69
- pycodex/tools/update_plan_tool.py +8 -5
- pycodex/tools/view_image_tool.py +7 -5
- pycodex/tools/wait_agent_tool.py +27 -4
- pycodex/tools/wait_tool.py +5 -4
- pycodex/tools/web_search_tool.py +4 -2
- pycodex/tools/write_stdin_tool.py +12 -11
- pycodex/utils/__init__.py +2 -17
- pycodex/utils/compactor.py +41 -72
- pycodex/utils/debug.py +2 -2
- pycodex/utils/dotenv.py +6 -7
- pycodex/utils/event_helpers.py +190 -0
- pycodex/utils/get_env.py +27 -70
- pycodex/{image_utils.py → utils/image_utils.py} +8 -11
- pycodex/utils/random_ids.py +1 -2
- pycodex/utils/session_persist.py +217 -163
- pycodex/utils/truncation.py +21 -45
- python_codex-0.3.1.dist-info/METADATA +743 -0
- python_codex-0.3.1.dist-info/RECORD +91 -0
- responses_server/__init__.py +1 -5
- responses_server/__main__.py +0 -1
- responses_server/app.py +34 -33
- responses_server/config.py +23 -23
- responses_server/messages_api.py +51 -53
- responses_server/payload_processors.py +25 -20
- responses_server/server.py +11 -11
- responses_server/session_store.py +14 -11
- responses_server/stream_router.py +209 -98
- responses_server/tools/custom_adapter.py +17 -16
- responses_server/tools/web_search.py +39 -36
- responses_server/trajectory_dump.py +36 -14
- workspace_server/__main__.py +0 -1
- workspace_server/app.py +465 -377
- workspace_server/owned_process.py +27 -0
- workspace_server/workspace.html +854 -228
- workspace_server/workspaces.html +94 -95
- workspace_server/workspaces.py +141 -84
- pycodex/collaboration.py +0 -20
- pycodex/interactive_session.py +0 -415
- pycodex/prompts/collaboration_default.md +0 -11
- pycodex/prompts/collaboration_plan.md +0 -128
- pycodex/utils/toolcall_visualize.py +0 -713
- pycodex/utils/visualize.py +0 -560
- python_codex-0.2.7.dist-info/METADATA +0 -455
- python_codex-0.2.7.dist-info/RECORD +0 -93
- {python_codex-0.2.7.dist-info → python_codex-0.3.1.dist-info}/WHEEL +0 -0
- {python_codex-0.2.7.dist-info → python_codex-0.3.1.dist-info}/entry_points.txt +0 -0
- {python_codex-0.2.7.dist-info → python_codex-0.3.1.dist-info}/licenses/LICENSE +0 -0
workspace_server/app.py
CHANGED
|
@@ -5,13 +5,17 @@ import json
|
|
|
5
5
|
import mimetypes
|
|
6
6
|
import os
|
|
7
7
|
import secrets
|
|
8
|
+
import sys
|
|
8
9
|
import threading
|
|
9
|
-
from dataclasses import asdict, is_dataclass
|
|
10
|
+
from dataclasses import asdict, fields, is_dataclass
|
|
11
|
+
|
|
10
12
|
try:
|
|
11
13
|
from contextlib import asynccontextmanager
|
|
12
14
|
except ImportError: # pragma: no cover - Python 3.6 compatibility
|
|
13
15
|
asynccontextmanager = None
|
|
16
|
+
import typing
|
|
14
17
|
from pathlib import Path
|
|
18
|
+
from urllib.parse import urlencode
|
|
15
19
|
|
|
16
20
|
from fastapi import FastAPI, HTTPException, Request, WebSocket, WebSocketDisconnect
|
|
17
21
|
from fastapi.responses import (
|
|
@@ -22,22 +26,43 @@ from fastapi.responses import (
|
|
|
22
26
|
Response,
|
|
23
27
|
)
|
|
24
28
|
|
|
25
|
-
from pycodex.
|
|
26
|
-
from pycodex.
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
29
|
+
from pycodex.bootstrap import build_agent, build_model, build_runtime, configure_loguru
|
|
30
|
+
from pycodex.events import (
|
|
31
|
+
IDLE_SLEEPING_STATUS,
|
|
32
|
+
AssistantDeltaEvent,
|
|
33
|
+
AutoCompactCompletedEvent,
|
|
34
|
+
AutoCompactFailedEvent,
|
|
35
|
+
AutoCompactStartedEvent,
|
|
36
|
+
CommandCompletedEvent,
|
|
37
|
+
CommandFailedEvent,
|
|
38
|
+
CompactCompletedEvent,
|
|
39
|
+
CompactFailedEvent,
|
|
40
|
+
CompactStartedEvent,
|
|
41
|
+
Event,
|
|
42
|
+
InputQueuedEvent,
|
|
43
|
+
InputRequestedEvent,
|
|
44
|
+
InputResolvedEvent,
|
|
45
|
+
SessionClosedEvent,
|
|
46
|
+
SessionStateEvent,
|
|
47
|
+
StreamErrorEvent,
|
|
48
|
+
TerminalEvent,
|
|
49
|
+
TokenCountEvent,
|
|
50
|
+
ToolCalledEvent,
|
|
51
|
+
ToolCompletedEvent,
|
|
52
|
+
ToolStartedEvent,
|
|
53
|
+
TurnCompletedEvent,
|
|
54
|
+
TurnEvent,
|
|
55
|
+
TurnFailedEvent,
|
|
56
|
+
TurnInterruptedEvent,
|
|
57
|
+
TurnStartedEvent,
|
|
32
58
|
)
|
|
59
|
+
from pycodex.model import DEFAULT_CODEX_CONFIG_PATH
|
|
33
60
|
from pycodex.utils import uuid7_string
|
|
34
|
-
from pycodex.utils.
|
|
35
|
-
|
|
36
|
-
background_work_count,
|
|
37
|
-
percent_of_context_window_remaining,
|
|
61
|
+
from pycodex.utils.event_helpers import (
|
|
62
|
+
completed_history,
|
|
38
63
|
shorten_title,
|
|
39
|
-
tool_summary,
|
|
40
64
|
)
|
|
65
|
+
|
|
41
66
|
from .workspaces import (
|
|
42
67
|
WorkspaceDefinition,
|
|
43
68
|
WorkspaceEntry,
|
|
@@ -46,8 +71,6 @@ from .workspaces import (
|
|
|
46
71
|
load_workspace_definitions,
|
|
47
72
|
session_snapshot,
|
|
48
73
|
)
|
|
49
|
-
import typing
|
|
50
|
-
|
|
51
74
|
|
|
52
75
|
JSONValue = typing.Union[
|
|
53
76
|
None,
|
|
@@ -64,6 +87,7 @@ def build_parser() -> "argparse.ArgumentParser":
|
|
|
64
87
|
parser = argparse.ArgumentParser(
|
|
65
88
|
prog="pycodex-ws",
|
|
66
89
|
description="Run a local pycodex workspace with a board and chat session.",
|
|
90
|
+
epilog="Stdin EOF gracefully shuts down the server after accepted work finishes.",
|
|
67
91
|
)
|
|
68
92
|
parser.add_argument(
|
|
69
93
|
"--listen",
|
|
@@ -141,93 +165,41 @@ def parse_listen(target: str) -> "typing.Tuple[str, int]":
|
|
|
141
165
|
|
|
142
166
|
SessionFactory = typing.Callable[[], object]
|
|
143
167
|
ThreadedSessionFactory = typing.Callable[[], "WorkspaceInteractiveSession"]
|
|
144
|
-
SESSION_CLOSE_TIMEOUT_SECONDS = 2.0
|
|
145
168
|
SPINNER_STATUS_PREVIEW_LIMIT = 180
|
|
146
169
|
AUTH_COOKIE_NAME = "pycodex_ws_auth"
|
|
147
170
|
|
|
148
171
|
|
|
149
172
|
class WebSessionView:
|
|
150
173
|
def __init__(self) -> None:
|
|
151
|
-
self._input_queue: "asyncio.Queue" = asyncio.Queue()
|
|
152
174
|
self._subscribers: "typing.Set[asyncio.Queue]" = set()
|
|
153
175
|
self._events: "typing.List[typing.Dict[str, object]]" = []
|
|
154
176
|
self._turns: "typing.List[typing.Dict[str, object]]" = []
|
|
155
177
|
self._turns_by_submission_id: "typing.Dict[str, typing.Dict[str, object]]" = {}
|
|
156
178
|
self._turns_by_turn_id: "typing.Dict[str, typing.Dict[str, object]]" = {}
|
|
157
179
|
self._title = ""
|
|
180
|
+
self._model = "pycodex"
|
|
181
|
+
self._rollout_path = ""
|
|
182
|
+
self._recorded_rollout_path = ""
|
|
183
|
+
self._input_request = None
|
|
184
|
+
self._accepts_input = True
|
|
158
185
|
self._spinner_status = ""
|
|
159
186
|
self._stream_buffer = ""
|
|
160
|
-
self.
|
|
161
|
-
self.
|
|
162
|
-
self.
|
|
187
|
+
self._max_context_window: "typing.Union[int, None]" = None
|
|
188
|
+
self._auto_compact_token_limit: "typing.Union[int, None]" = None
|
|
189
|
+
self._usage_tokens: "typing.Union[int, None]" = None
|
|
163
190
|
self._server_loop: "typing.Union[asyncio.AbstractEventLoop, None]" = None
|
|
164
|
-
self._worker_loop: "typing.Union[asyncio.AbstractEventLoop, None]" = None
|
|
165
191
|
self._lock = threading.RLock()
|
|
166
192
|
|
|
167
193
|
def attach_server_loop(self, loop: "asyncio.AbstractEventLoop") -> None:
|
|
168
194
|
self._server_loop = loop
|
|
169
195
|
|
|
170
|
-
def
|
|
171
|
-
self._worker_loop = loop
|
|
172
|
-
|
|
173
|
-
async def submit(self, prompt: str) -> "typing.Dict[str, object]":
|
|
174
|
-
prompt = str(prompt or "").strip()
|
|
175
|
-
if not prompt:
|
|
176
|
-
return {"ok": False, "error": "prompt is empty"}
|
|
177
|
-
await self._put_input(prompt)
|
|
178
|
-
await self._publish(
|
|
179
|
-
{
|
|
180
|
-
"type": "input",
|
|
181
|
-
"prompt": prompt,
|
|
182
|
-
"snapshot": self.snapshot(),
|
|
183
|
-
}
|
|
184
|
-
)
|
|
185
|
-
return {"ok": True, "type": "submitted", "snapshot": self.snapshot()}
|
|
186
|
-
|
|
187
|
-
async def _put_input(self, item: object) -> None:
|
|
188
|
-
worker_loop = self._worker_loop
|
|
189
|
-
try:
|
|
190
|
-
running_loop = asyncio.get_running_loop()
|
|
191
|
-
except RuntimeError:
|
|
192
|
-
running_loop = None
|
|
193
|
-
if worker_loop is None or worker_loop is running_loop:
|
|
194
|
-
await self._input_queue.put(item)
|
|
195
|
-
return
|
|
196
|
-
future = asyncio.run_coroutine_threadsafe(self._input_queue.put(item), worker_loop)
|
|
197
|
-
await asyncio.wrap_future(future)
|
|
198
|
-
|
|
199
|
-
async def poll_prompt(self, prompt: "typing.Union[str, None]" = None) -> "typing.Union[str, None]":
|
|
200
|
-
del prompt
|
|
201
|
-
if self._closed and self._input_queue.empty():
|
|
202
|
-
raise EOFError()
|
|
203
|
-
try:
|
|
204
|
-
item = self._input_queue.get_nowait()
|
|
205
|
-
except asyncio.QueueEmpty:
|
|
206
|
-
return None
|
|
207
|
-
if item is None:
|
|
208
|
-
raise EOFError()
|
|
209
|
-
return str(item)
|
|
210
|
-
|
|
211
|
-
async def get_prompt(self, prompt: "typing.Union[str, None]" = None) -> "str":
|
|
212
|
-
if prompt:
|
|
213
|
-
self.write_line(prompt)
|
|
214
|
-
item = await self._input_queue.get()
|
|
215
|
-
if item is None:
|
|
216
|
-
raise EOFError()
|
|
217
|
-
return str(item)
|
|
218
|
-
|
|
219
|
-
def handle_event(self, event: "AgentEvent") -> None:
|
|
196
|
+
def handle_event(self, event: "Event") -> None:
|
|
220
197
|
with self._lock:
|
|
221
198
|
self._apply_runtime_event(event)
|
|
222
|
-
payload =
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
"
|
|
226
|
-
"payload": _json_safe(getattr(event, "payload", {})),
|
|
227
|
-
"snapshot": self.snapshot(),
|
|
228
|
-
}
|
|
229
|
-
if payload["kind"] == "tool_completed":
|
|
230
|
-
payload["summary"] = tool_summary(getattr(event, "payload", {}))
|
|
199
|
+
payload = _event_data(event)
|
|
200
|
+
payload.update({"type": "event", "snapshot": self.snapshot()})
|
|
201
|
+
if isinstance(event, ToolCompletedEvent):
|
|
202
|
+
payload["summary"] = event.visualize()
|
|
231
203
|
self._publish_nowait(payload)
|
|
232
204
|
|
|
233
205
|
def finish_stream(self) -> None:
|
|
@@ -261,23 +233,6 @@ class WebSessionView:
|
|
|
261
233
|
event = {"type": "snapshot", "snapshot": self.snapshot()}
|
|
262
234
|
self._publish_nowait(event)
|
|
263
235
|
|
|
264
|
-
def show_history(self) -> None:
|
|
265
|
-
assistant_turns = [turn for turn in self._turns if turn.get("kind") != "control"]
|
|
266
|
-
if not assistant_turns:
|
|
267
|
-
self.write_line("No history yet.")
|
|
268
|
-
return
|
|
269
|
-
lines = ["Session: {0}".format(self._title or "untitled")]
|
|
270
|
-
for index, turn in enumerate(assistant_turns, start=1):
|
|
271
|
-
prompt = str(turn.get("prompt") or "")
|
|
272
|
-
response = str(turn.get("response") or turn.get("thinking") or "")
|
|
273
|
-
lines.append("[{0}]U> {1}".format(index, prompt))
|
|
274
|
-
if response:
|
|
275
|
-
lines.append("[{0}]A> {1}".format(index, response))
|
|
276
|
-
self.write_line("\n".join(lines))
|
|
277
|
-
|
|
278
|
-
def show_title(self) -> None:
|
|
279
|
-
self.write_line("Session: {0}".format(self._title or "untitled"))
|
|
280
|
-
|
|
281
236
|
def set_session_title(self, title: str) -> None:
|
|
282
237
|
with self._lock:
|
|
283
238
|
self._set_title(title)
|
|
@@ -288,12 +243,6 @@ class WebSessionView:
|
|
|
288
243
|
}
|
|
289
244
|
self._publish_nowait(event)
|
|
290
245
|
|
|
291
|
-
def show_resumed_session(self, title: str) -> None:
|
|
292
|
-
with self._lock:
|
|
293
|
-
self._set_title(title)
|
|
294
|
-
event = {"type": "snapshot", "snapshot": self.snapshot()}
|
|
295
|
-
self._publish_nowait(event)
|
|
296
|
-
|
|
297
246
|
def load_session_history(
|
|
298
247
|
self,
|
|
299
248
|
title: "typing.Union[str, None]",
|
|
@@ -308,7 +257,9 @@ class WebSessionView:
|
|
|
308
257
|
self._events = []
|
|
309
258
|
for prompt, response in history:
|
|
310
259
|
submission_id = uuid7_string()
|
|
311
|
-
turn = self._ensure_turn(
|
|
260
|
+
turn = self._ensure_turn(
|
|
261
|
+
submission_id, submission_id, str(prompt or "")
|
|
262
|
+
)
|
|
312
263
|
turn["response"] = str(response or "")
|
|
313
264
|
turn["status"] = "completed"
|
|
314
265
|
turn["queue"] = "history"
|
|
@@ -316,22 +267,6 @@ class WebSessionView:
|
|
|
316
267
|
event = {"type": "snapshot", "snapshot": self.snapshot()}
|
|
317
268
|
self._publish_nowait(event)
|
|
318
269
|
|
|
319
|
-
def show_steer_queued(self, turn_id: str, prompt: str) -> None:
|
|
320
|
-
del turn_id, prompt
|
|
321
|
-
|
|
322
|
-
def schedule_steer_inserted(self, turn_id: str, prompt: str) -> None:
|
|
323
|
-
del turn_id, prompt
|
|
324
|
-
|
|
325
|
-
def set_context_window_tokens(
|
|
326
|
-
self,
|
|
327
|
-
context_window_tokens: "typing.Union[int, None]",
|
|
328
|
-
) -> None:
|
|
329
|
-
with self._lock:
|
|
330
|
-
self._context_window_tokens = context_window_tokens
|
|
331
|
-
self._context_remaining_percent = (
|
|
332
|
-
100 if context_window_tokens is not None else None
|
|
333
|
-
)
|
|
334
|
-
|
|
335
270
|
def subscribe(self) -> "asyncio.Queue":
|
|
336
271
|
queue: "asyncio.Queue" = asyncio.Queue()
|
|
337
272
|
with self._lock:
|
|
@@ -350,14 +285,8 @@ class WebSessionView:
|
|
|
350
285
|
|
|
351
286
|
def close(self) -> None:
|
|
352
287
|
with self._lock:
|
|
353
|
-
self._closed = True
|
|
354
288
|
subscribers = tuple(self._subscribers)
|
|
355
289
|
self._subscribers.clear()
|
|
356
|
-
worker_loop = self._worker_loop
|
|
357
|
-
if worker_loop is None:
|
|
358
|
-
self._input_queue.put_nowait(None)
|
|
359
|
-
else:
|
|
360
|
-
asyncio.run_coroutine_threadsafe(self._input_queue.put(None), worker_loop)
|
|
361
290
|
self._publish_to_queues(subscribers, None)
|
|
362
291
|
|
|
363
292
|
def snapshot(self) -> "typing.Dict[str, object]":
|
|
@@ -367,58 +296,120 @@ class WebSessionView:
|
|
|
367
296
|
"status": self._spinner_status,
|
|
368
297
|
"status_kind": "spinner" if self._spinner_status else "idle",
|
|
369
298
|
"spinner": self._spinner_status,
|
|
370
|
-
"model":
|
|
299
|
+
"model": self._model,
|
|
300
|
+
"rollout_path": self._rollout_path,
|
|
301
|
+
"recorded_rollout_path": self._recorded_rollout_path,
|
|
302
|
+
"input_request": _json_safe(self._input_request),
|
|
303
|
+
"queued_inputs": [
|
|
304
|
+
{"queue": turn["queue"], "prompt": turn["prompt"]}
|
|
305
|
+
for turn in self._turns_by_submission_id.values()
|
|
306
|
+
if not turn["turn_id"]
|
|
307
|
+
],
|
|
308
|
+
"accepts_input": self._accepts_input,
|
|
371
309
|
"title": self._title,
|
|
372
|
-
|
|
310
|
+
**self._context_usage(),
|
|
373
311
|
"turns": [_public_turn(turn) for turn in self._turns[-80:]],
|
|
374
312
|
}
|
|
375
313
|
|
|
376
314
|
def summary(self) -> "typing.Dict[str, object]":
|
|
377
315
|
with self._lock:
|
|
378
316
|
return {
|
|
317
|
+
"model": self._model,
|
|
379
318
|
"running": bool(self._spinner_status),
|
|
380
319
|
"spinner": self._spinner_status,
|
|
381
320
|
"title": self._title,
|
|
382
321
|
"turn_count": len(self._turns),
|
|
383
322
|
"last_assistant": _last_assistant_text(self._turns),
|
|
384
|
-
|
|
323
|
+
**self._context_usage(),
|
|
385
324
|
}
|
|
386
325
|
|
|
387
|
-
def _apply_runtime_event(self, event: "
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
self.
|
|
326
|
+
def _apply_runtime_event(self, event: "Event") -> None:
|
|
327
|
+
if isinstance(event, SessionStateEvent):
|
|
328
|
+
state = event.state
|
|
329
|
+
self._model = state["model"]
|
|
330
|
+
self._rollout_path = state["rollout_path"]
|
|
331
|
+
self._recorded_rollout_path = state["recorded_rollout_path"]
|
|
332
|
+
self._input_request = state["input_request"]
|
|
333
|
+
self._accepts_input = state["accepts_input"]
|
|
334
|
+
self._max_context_window = state["max_context_window"]
|
|
335
|
+
self._auto_compact_token_limit = state["auto_compact_token_limit"]
|
|
336
|
+
self._usage_tokens = state["usage_tokens"]
|
|
337
|
+
if event.reason in {"attach", "history"}:
|
|
338
|
+
self.load_session_history(state["title"], completed_history(state))
|
|
339
|
+
active = state["active_turn"]
|
|
340
|
+
if active is not None:
|
|
341
|
+
self._apply_runtime_event(
|
|
342
|
+
TurnStartedEvent(
|
|
343
|
+
active["turn_id"],
|
|
344
|
+
tuple(active["user_texts"]),
|
|
345
|
+
active["submission_id"],
|
|
346
|
+
)
|
|
347
|
+
)
|
|
348
|
+
self._apply_runtime_event(
|
|
349
|
+
AssistantDeltaEvent(
|
|
350
|
+
active["assistant_text"],
|
|
351
|
+
active["turn_id"],
|
|
352
|
+
active["submission_id"],
|
|
353
|
+
)
|
|
354
|
+
)
|
|
355
|
+
elif event.reason == "title":
|
|
356
|
+
self.set_session_title(state["title"])
|
|
357
|
+
else:
|
|
358
|
+
self._set_title(state["title"])
|
|
359
|
+
return
|
|
360
|
+
if isinstance(event, CommandCompletedEvent):
|
|
361
|
+
if event.result["kind"] in {"title_changed", "resumed"}:
|
|
362
|
+
return
|
|
363
|
+
message = event.visualize()
|
|
364
|
+
if message:
|
|
365
|
+
self.write_line(message)
|
|
366
|
+
return
|
|
367
|
+
if isinstance(event, CommandFailedEvent):
|
|
368
|
+
self.show_error(event.visualize())
|
|
369
|
+
return
|
|
370
|
+
if isinstance(event, InputRequestedEvent):
|
|
371
|
+
self._input_request = event
|
|
372
|
+
return
|
|
373
|
+
if isinstance(event, InputResolvedEvent):
|
|
374
|
+
self._input_request = None
|
|
375
|
+
return
|
|
376
|
+
if isinstance(event, SessionClosedEvent):
|
|
377
|
+
self._accepts_input = False
|
|
378
|
+
self._spinner_status = ""
|
|
394
379
|
return
|
|
395
|
-
|
|
396
|
-
|
|
380
|
+
if isinstance(event, InputQueuedEvent):
|
|
381
|
+
turn = self._ensure_turn(event.submission_id, "", "")
|
|
382
|
+
turn["prompt"] += ("\n" if turn["prompt"] else "") + event.prompt
|
|
383
|
+
turn["queue"] = event.queue
|
|
384
|
+
turn["sender"] = event.sender
|
|
385
|
+
return
|
|
386
|
+
if isinstance(event, TokenCountEvent):
|
|
387
|
+
self._usage_tokens = int(event.usage["total_tokens"])
|
|
388
|
+
return
|
|
389
|
+
if isinstance(event, (AutoCompactCompletedEvent, CompactCompletedEvent)):
|
|
390
|
+
self._usage_tokens = None
|
|
391
|
+
if not isinstance(event, TurnEvent):
|
|
392
|
+
return
|
|
393
|
+
turn_id = event.turn_id
|
|
394
|
+
submission_id = event.submission_id or turn_id
|
|
397
395
|
turn = self._turns_by_submission_id.get(submission_id)
|
|
398
|
-
if turn is None and turn_id and not submission_id:
|
|
399
|
-
turn = self._turns_by_turn_id.get(turn_id)
|
|
400
396
|
|
|
401
|
-
if
|
|
402
|
-
self._set_spinner_status(kind)
|
|
403
|
-
|
|
404
|
-
str(item) for item in payload.get("user_texts", []) or []
|
|
405
|
-
)
|
|
406
|
-
if not self._title and str(prompt or "").strip():
|
|
407
|
-
self._set_title(shorten_title(str(prompt or "")))
|
|
408
|
-
turn = self._ensure_turn(submission_id, turn_id, str(prompt or ""))
|
|
397
|
+
if isinstance(event, TurnStartedEvent):
|
|
398
|
+
self._set_spinner_status(event.kind)
|
|
399
|
+
turn = self._ensure_turn(submission_id, turn_id, event.visualize())
|
|
409
400
|
turn["status"] = "running"
|
|
410
401
|
turn["thinking"] = ""
|
|
411
402
|
turn["_thinking_active"] = False
|
|
412
403
|
turn["error"] = ""
|
|
413
404
|
return
|
|
414
405
|
|
|
415
|
-
self._apply_spinner_event(
|
|
406
|
+
self._apply_spinner_event(event)
|
|
416
407
|
if turn is None:
|
|
417
408
|
return
|
|
418
409
|
|
|
419
|
-
if
|
|
410
|
+
if isinstance(event, AssistantDeltaEvent):
|
|
420
411
|
turn["status"] = "responding"
|
|
421
|
-
delta =
|
|
412
|
+
delta = event.visualize()
|
|
422
413
|
self._stream_buffer += delta
|
|
423
414
|
if turn.get("_thinking_active"):
|
|
424
415
|
turn["thinking"] = str(turn.get("thinking") or "") + delta
|
|
@@ -427,19 +418,19 @@ class WebSessionView:
|
|
|
427
418
|
turn["_thinking_active"] = True
|
|
428
419
|
return
|
|
429
420
|
|
|
430
|
-
if
|
|
421
|
+
if isinstance(event, ToolStartedEvent):
|
|
431
422
|
turn["status"] = "tool"
|
|
432
|
-
turn["tool_name"] =
|
|
423
|
+
turn["tool_name"] = event.call.name
|
|
433
424
|
turn["_thinking_active"] = False
|
|
434
425
|
return
|
|
435
426
|
|
|
436
|
-
if
|
|
427
|
+
if isinstance(event, ToolCompletedEvent):
|
|
437
428
|
turn["_thinking_active"] = False
|
|
438
429
|
turn["status"] = "running"
|
|
439
430
|
return
|
|
440
431
|
|
|
441
|
-
if
|
|
442
|
-
response =
|
|
432
|
+
if isinstance(event, TurnCompletedEvent):
|
|
433
|
+
response = event.visualize()
|
|
443
434
|
if response:
|
|
444
435
|
turn["response"] = response
|
|
445
436
|
elif turn.get("thinking"):
|
|
@@ -450,84 +441,76 @@ class WebSessionView:
|
|
|
450
441
|
self._stream_buffer = ""
|
|
451
442
|
return
|
|
452
443
|
|
|
453
|
-
if
|
|
444
|
+
if isinstance(event, TurnFailedEvent):
|
|
454
445
|
turn["status"] = "error"
|
|
455
|
-
turn["error"] =
|
|
446
|
+
turn["error"] = event.visualize()
|
|
456
447
|
self._stream_buffer = ""
|
|
457
448
|
return
|
|
458
449
|
|
|
459
|
-
if
|
|
460
|
-
if
|
|
450
|
+
if isinstance(event, TurnInterruptedEvent):
|
|
451
|
+
if event.output_text:
|
|
452
|
+
turn["response"] = event.output_text
|
|
453
|
+
elif turn.get("thinking") and not turn.get("response"):
|
|
461
454
|
turn["response"] = str(turn.get("thinking") or "")
|
|
462
455
|
turn["thinking"] = ""
|
|
463
456
|
turn["_thinking_active"] = False
|
|
464
457
|
turn["status"] = "interrupted"
|
|
465
458
|
self._stream_buffer = ""
|
|
466
459
|
|
|
467
|
-
def
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
460
|
+
def _context_usage(self) -> "typing.Dict[str, object]":
|
|
461
|
+
limit = self._auto_compact_token_limit
|
|
462
|
+
if limit is None:
|
|
463
|
+
limit = self._max_context_window
|
|
464
|
+
if limit is None:
|
|
465
|
+
remaining_percent = None
|
|
466
|
+
elif self._usage_tokens is None:
|
|
467
|
+
remaining_percent = 100
|
|
468
|
+
elif self._usage_tokens >= limit:
|
|
469
|
+
remaining_percent = 0
|
|
470
|
+
else:
|
|
471
|
+
# Round up so zero means the actual threshold has been reached.
|
|
472
|
+
remaining_percent = min(
|
|
473
|
+
100, ((limit - self._usage_tokens) * 100 + limit - 1) // limit
|
|
474
|
+
)
|
|
475
|
+
return {
|
|
476
|
+
"usage_tokens": self._usage_tokens,
|
|
477
|
+
"auto_compact_token_limit": self._auto_compact_token_limit,
|
|
478
|
+
"max_context_window": self._max_context_window,
|
|
479
|
+
"context_remaining_percent": remaining_percent,
|
|
480
|
+
}
|
|
482
481
|
|
|
483
|
-
def _apply_spinner_event(
|
|
484
|
-
|
|
485
|
-
kind: str,
|
|
486
|
-
payload: "typing.Dict[str, object]",
|
|
487
|
-
) -> None:
|
|
488
|
-
if kind == "assistant_delta":
|
|
482
|
+
def _apply_spinner_event(self, event: "TurnEvent") -> None:
|
|
483
|
+
if isinstance(event, AssistantDeltaEvent):
|
|
489
484
|
self._set_spinner_status("talking")
|
|
490
485
|
return
|
|
491
|
-
if
|
|
486
|
+
if isinstance(event, StreamErrorEvent):
|
|
492
487
|
self._set_spinner_status("reconnecting")
|
|
493
488
|
return
|
|
494
|
-
if
|
|
489
|
+
if isinstance(event, (AutoCompactStartedEvent, CompactStartedEvent)):
|
|
495
490
|
self._set_spinner_status("compacting")
|
|
496
491
|
return
|
|
497
|
-
if
|
|
492
|
+
if isinstance(event, AutoCompactCompletedEvent):
|
|
498
493
|
self._set_spinner_status("compacted")
|
|
499
494
|
return
|
|
500
|
-
if
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
shorten_title(
|
|
506
|
-
"calling {0}({1})".format(tool_name, call.arguments),
|
|
507
|
-
limit=SPINNER_STATUS_PREVIEW_LIMIT,
|
|
508
|
-
)
|
|
495
|
+
if isinstance(event, ToolStartedEvent):
|
|
496
|
+
self._set_spinner_status(
|
|
497
|
+
shorten_title(
|
|
498
|
+
event.visualize(),
|
|
499
|
+
limit=SPINNER_STATUS_PREVIEW_LIMIT,
|
|
509
500
|
)
|
|
510
|
-
|
|
511
|
-
self._set_spinner_status("calling {0}".format(tool_name))
|
|
512
|
-
else:
|
|
513
|
-
self._set_spinner_status("calling provider tools")
|
|
514
|
-
return
|
|
515
|
-
if kind == "tool_completed":
|
|
516
|
-
tool_name = str(payload.get("tool_name") or "").strip()
|
|
517
|
-
if tool_name:
|
|
518
|
-
self._set_spinner_status("called {0}".format(tool_name))
|
|
501
|
+
)
|
|
519
502
|
return
|
|
520
|
-
if
|
|
521
|
-
self.
|
|
503
|
+
if isinstance(event, ToolCompletedEvent):
|
|
504
|
+
self._set_spinner_status("called {0}".format(event.call.name))
|
|
522
505
|
return
|
|
523
|
-
if
|
|
524
|
-
self.
|
|
506
|
+
if isinstance(event, TerminalEvent):
|
|
507
|
+
self._set_idle_spinner_status(event)
|
|
525
508
|
|
|
526
509
|
def _set_spinner_status(self, text: "typing.Union[str, None]") -> None:
|
|
527
510
|
self._spinner_status = str(text or "").strip()
|
|
528
511
|
|
|
529
|
-
def _set_idle_spinner_status(self,
|
|
530
|
-
if background_work_count
|
|
512
|
+
def _set_idle_spinner_status(self, event: "TerminalEvent") -> None:
|
|
513
|
+
if (event.background_work_count or 0) > 0:
|
|
531
514
|
self._set_spinner_status(IDLE_SLEEPING_STATUS)
|
|
532
515
|
else:
|
|
533
516
|
self._set_spinner_status("")
|
|
@@ -539,14 +522,14 @@ class WebSessionView:
|
|
|
539
522
|
prompt: str,
|
|
540
523
|
) -> "typing.Dict[str, object]":
|
|
541
524
|
submission_id = str(submission_id or "").strip()
|
|
542
|
-
turn_id = str(turn_id or
|
|
525
|
+
turn_id = str(turn_id or "").strip()
|
|
543
526
|
turn = self._turns_by_submission_id.get(submission_id)
|
|
544
527
|
if turn is None and turn_id and not submission_id:
|
|
545
528
|
turn = self._turns_by_turn_id.get(turn_id)
|
|
546
529
|
if turn is None:
|
|
547
530
|
turn = {
|
|
548
531
|
"submission_id": submission_id,
|
|
549
|
-
"turn_id":
|
|
532
|
+
"turn_id": "",
|
|
550
533
|
"prompt": prompt,
|
|
551
534
|
"response": "",
|
|
552
535
|
"thinking": "",
|
|
@@ -557,11 +540,13 @@ class WebSessionView:
|
|
|
557
540
|
"queue": "steer",
|
|
558
541
|
"sender": "web",
|
|
559
542
|
}
|
|
560
|
-
self._turns.append(turn)
|
|
561
543
|
if submission_id:
|
|
562
544
|
turn["submission_id"] = submission_id
|
|
563
545
|
self._turns_by_submission_id[submission_id] = turn
|
|
564
546
|
if turn_id:
|
|
547
|
+
if not turn["turn_id"]:
|
|
548
|
+
# Queue admission stays hidden until the turn actually starts.
|
|
549
|
+
self._turns.append(turn)
|
|
565
550
|
turn["turn_id"] = turn_id
|
|
566
551
|
self._turns_by_turn_id[turn_id] = turn
|
|
567
552
|
if prompt:
|
|
@@ -619,58 +604,56 @@ class WebSessionView:
|
|
|
619
604
|
|
|
620
605
|
loop.call_soon_threadsafe(publish)
|
|
621
606
|
|
|
622
|
-
async def _publish(self, event: "typing.Dict[str, object]") -> None:
|
|
623
|
-
self._publish_nowait(event)
|
|
624
|
-
|
|
625
607
|
|
|
626
608
|
class WorkspaceInteractiveSession:
|
|
627
609
|
def __init__(
|
|
628
610
|
self,
|
|
629
|
-
|
|
611
|
+
runtime,
|
|
630
612
|
config_path: "typing.Union[str, None]" = None,
|
|
631
613
|
) -> None:
|
|
632
|
-
self.
|
|
614
|
+
self.runtime = runtime
|
|
633
615
|
self.config_path = config_path
|
|
634
616
|
self.view = WebSessionView()
|
|
635
|
-
self.
|
|
617
|
+
self._frontend_id = None
|
|
636
618
|
|
|
637
619
|
async def start(self) -> "WorkspaceInteractiveSession":
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
self.queue,
|
|
642
|
-
False,
|
|
643
|
-
self.config_path,
|
|
644
|
-
view=self.view,
|
|
645
|
-
show_banner=False,
|
|
646
|
-
)
|
|
647
|
-
)
|
|
620
|
+
await self.runtime.start(self.config_path)
|
|
621
|
+
if self._frontend_id is None:
|
|
622
|
+
self._frontend_id = self.runtime.attach(self.view.handle_event)
|
|
648
623
|
return self
|
|
649
624
|
|
|
650
625
|
async def close(self) -> None:
|
|
651
|
-
self.view.close()
|
|
652
|
-
task = self._task
|
|
653
|
-
if task is None:
|
|
654
|
-
return
|
|
655
626
|
try:
|
|
656
|
-
await
|
|
657
|
-
asyncio.shield(task),
|
|
658
|
-
timeout=SESSION_CLOSE_TIMEOUT_SECONDS,
|
|
659
|
-
)
|
|
660
|
-
except asyncio.TimeoutError:
|
|
661
|
-
cancel_current = getattr(self.queue, "cancel_current", None)
|
|
662
|
-
if callable(cancel_current):
|
|
663
|
-
cancel_current()
|
|
664
|
-
task.cancel()
|
|
665
|
-
await asyncio.gather(task, return_exceptions=True)
|
|
627
|
+
await self.runtime.close()
|
|
666
628
|
finally:
|
|
667
|
-
self.
|
|
629
|
+
self.detach()
|
|
630
|
+
|
|
631
|
+
def detach(self):
|
|
632
|
+
if self._frontend_id is not None:
|
|
633
|
+
self.runtime.detach(self._frontend_id)
|
|
634
|
+
self._frontend_id = None
|
|
635
|
+
self.view.close()
|
|
636
|
+
|
|
637
|
+
async def submit(
|
|
638
|
+
self, prompt: str, sender: str = "web"
|
|
639
|
+
) -> "typing.Dict[str, object]":
|
|
640
|
+
try:
|
|
641
|
+
receipt = await self.runtime.submit_input(prompt, sender)
|
|
642
|
+
except (ValueError, RuntimeError) as exc:
|
|
643
|
+
return {"ok": False, "error": str(exc), "snapshot": self.snapshot()}
|
|
644
|
+
return {
|
|
645
|
+
"ok": True,
|
|
646
|
+
"type": "submitted",
|
|
647
|
+
"submission_id": receipt.submission_id,
|
|
648
|
+
"snapshot": self.snapshot(),
|
|
649
|
+
}
|
|
668
650
|
|
|
669
|
-
async def
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
651
|
+
async def answer_input(self, request_id, answer):
|
|
652
|
+
try:
|
|
653
|
+
self.runtime.answer_input(request_id, answer)
|
|
654
|
+
except ValueError as exc:
|
|
655
|
+
return {"ok": False, "error": str(exc), "snapshot": self.snapshot()}
|
|
656
|
+
return {"ok": True, "type": "answered", "snapshot": self.snapshot()}
|
|
674
657
|
|
|
675
658
|
def subscribe(self) -> "asyncio.Queue":
|
|
676
659
|
return self.view.subscribe()
|
|
@@ -679,34 +662,23 @@ class WorkspaceInteractiveSession:
|
|
|
679
662
|
self.view.unsubscribe(queue)
|
|
680
663
|
|
|
681
664
|
def snapshot(self) -> "typing.Dict[str, object]":
|
|
682
|
-
|
|
683
|
-
agent = getattr(self.queue, "_agent", None)
|
|
684
|
-
snapshot["model"] = getattr(getattr(agent, "_model_client", None), "model", "pycodex")
|
|
685
|
-
return snapshot
|
|
665
|
+
return self.view.snapshot()
|
|
686
666
|
|
|
687
667
|
def summary(self) -> "typing.Dict[str, object]":
|
|
688
|
-
|
|
689
|
-
agent = getattr(self.queue, "_agent", None)
|
|
690
|
-
summary["model"] = getattr(getattr(agent, "_model_client", None), "model", "pycodex")
|
|
691
|
-
return summary
|
|
668
|
+
return self.view.summary()
|
|
692
669
|
|
|
693
670
|
def rollout_path(self) -> str:
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
agent.set_rollout_recorder(SessionRolloutRecorder.resume(resumed["rollout_path"]))
|
|
706
|
-
self.view.load_session_history(
|
|
707
|
-
str(title or resumed["title"]),
|
|
708
|
-
tuple(resumed["turns"]),
|
|
709
|
-
)
|
|
671
|
+
return self.view.snapshot()["rollout_path"]
|
|
672
|
+
|
|
673
|
+
async def restore_from_rollout(
|
|
674
|
+
self, rollout_path: str, title: str = "", fork: bool = False
|
|
675
|
+
) -> None:
|
|
676
|
+
if rollout_path:
|
|
677
|
+
self.runtime.resume(rollout_path, title)
|
|
678
|
+
if fork:
|
|
679
|
+
self.runtime.fork()
|
|
680
|
+
else:
|
|
681
|
+
self.runtime.set_title(title)
|
|
710
682
|
|
|
711
683
|
|
|
712
684
|
class ThreadedWorkspaceInteractiveSession:
|
|
@@ -722,7 +694,6 @@ class ThreadedWorkspaceInteractiveSession:
|
|
|
722
694
|
self._thread: "typing.Union[threading.Thread, None]" = None
|
|
723
695
|
self._worker_loop: "typing.Union[asyncio.AbstractEventLoop, None]" = None
|
|
724
696
|
self._ready = threading.Event()
|
|
725
|
-
self._closed = threading.Event()
|
|
726
697
|
self._startup_error: "typing.Union[BaseException, None]" = None
|
|
727
698
|
self._session: "typing.Union[WorkspaceInteractiveSession, None]" = None
|
|
728
699
|
|
|
@@ -737,66 +708,76 @@ class ThreadedWorkspaceInteractiveSession:
|
|
|
737
708
|
self._thread.start()
|
|
738
709
|
await asyncio.to_thread(self._ready.wait)
|
|
739
710
|
if self._startup_error is not None:
|
|
740
|
-
raise RuntimeError(
|
|
711
|
+
raise RuntimeError(
|
|
712
|
+
"workspace session thread failed to start"
|
|
713
|
+
) from self._startup_error
|
|
741
714
|
return self
|
|
742
715
|
|
|
743
716
|
def _thread_main(self) -> None:
|
|
744
717
|
loop = asyncio.new_event_loop()
|
|
745
718
|
self._worker_loop = loop
|
|
746
|
-
self._view.attach_worker_loop(loop)
|
|
747
719
|
asyncio.set_event_loop(loop)
|
|
748
720
|
try:
|
|
749
721
|
session = self._session_factory()
|
|
750
722
|
session.view = self._view
|
|
751
723
|
self._session = session
|
|
752
|
-
|
|
724
|
+
try:
|
|
725
|
+
loop.run_until_complete(session.start())
|
|
726
|
+
except BaseException:
|
|
727
|
+
loop.run_until_complete(session.close())
|
|
728
|
+
raise
|
|
753
729
|
self._ready.set()
|
|
754
730
|
loop.run_forever()
|
|
755
731
|
except BaseException as exc:
|
|
756
732
|
self._startup_error = exc
|
|
757
733
|
self._ready.set()
|
|
758
734
|
finally:
|
|
759
|
-
session = self._session
|
|
760
|
-
if session is not None:
|
|
761
|
-
try:
|
|
762
|
-
loop.run_until_complete(session.close())
|
|
763
|
-
except BaseException:
|
|
764
|
-
pass
|
|
765
735
|
pending = asyncio.all_tasks(loop)
|
|
766
736
|
for task in pending:
|
|
767
737
|
task.cancel()
|
|
768
738
|
if pending:
|
|
769
|
-
loop.run_until_complete(
|
|
739
|
+
loop.run_until_complete(
|
|
740
|
+
asyncio.gather(*pending, return_exceptions=True)
|
|
741
|
+
)
|
|
770
742
|
asyncio.set_event_loop(None)
|
|
771
743
|
loop.close()
|
|
772
|
-
self._closed.set()
|
|
773
744
|
|
|
774
745
|
async def close(self) -> None:
|
|
775
746
|
session = self._session
|
|
776
747
|
loop = self._worker_loop
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
asyncio.wrap_future(future),
|
|
782
|
-
timeout=SESSION_CLOSE_TIMEOUT_SECONDS + 1.0,
|
|
748
|
+
try:
|
|
749
|
+
if session is not None and loop is not None and loop.is_running():
|
|
750
|
+
future = asyncio.wrap_future(
|
|
751
|
+
asyncio.run_coroutine_threadsafe(session.close(), loop)
|
|
783
752
|
)
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
753
|
+
try:
|
|
754
|
+
await asyncio.shield(future)
|
|
755
|
+
except asyncio.CancelledError:
|
|
756
|
+
await future
|
|
757
|
+
raise
|
|
758
|
+
finally:
|
|
759
|
+
if loop is not None and loop.is_running():
|
|
760
|
+
loop.call_soon_threadsafe(loop.stop)
|
|
761
|
+
thread = self._thread
|
|
762
|
+
if thread is not None:
|
|
763
|
+
await asyncio.to_thread(thread.join)
|
|
764
|
+
self._thread = None
|
|
765
|
+
|
|
766
|
+
async def submit(
|
|
767
|
+
self, prompt: str, sender: str = "web"
|
|
768
|
+
) -> "typing.Dict[str, object]":
|
|
769
|
+
future = asyncio.run_coroutine_threadsafe(
|
|
770
|
+
self._session.submit(prompt, sender),
|
|
771
|
+
self._worker_loop,
|
|
772
|
+
)
|
|
773
|
+
return await asyncio.wrap_future(future)
|
|
774
|
+
|
|
775
|
+
async def answer_input(self, request_id, answer):
|
|
776
|
+
future = asyncio.run_coroutine_threadsafe(
|
|
777
|
+
self._session.answer_input(request_id, answer),
|
|
778
|
+
self._worker_loop,
|
|
779
|
+
)
|
|
780
|
+
return await asyncio.wrap_future(future)
|
|
800
781
|
|
|
801
782
|
def subscribe(self) -> "asyncio.Queue":
|
|
802
783
|
return self._view.subscribe()
|
|
@@ -805,34 +786,26 @@ class ThreadedWorkspaceInteractiveSession:
|
|
|
805
786
|
self._view.unsubscribe(queue)
|
|
806
787
|
|
|
807
788
|
def snapshot(self) -> "typing.Dict[str, object]":
|
|
808
|
-
|
|
809
|
-
session = self._session
|
|
810
|
-
queue = getattr(session, "queue", None)
|
|
811
|
-
agent = getattr(queue, "_agent", None)
|
|
812
|
-
snapshot["model"] = getattr(getattr(agent, "_model_client", None), "model", "pycodex")
|
|
813
|
-
return snapshot
|
|
789
|
+
return self._view.snapshot()
|
|
814
790
|
|
|
815
791
|
def summary(self) -> "typing.Dict[str, object]":
|
|
816
|
-
|
|
817
|
-
session = self._session
|
|
818
|
-
queue = getattr(session, "queue", None)
|
|
819
|
-
agent = getattr(queue, "_agent", None)
|
|
820
|
-
summary["model"] = getattr(getattr(agent, "_model_client", None), "model", "pycodex")
|
|
821
|
-
return summary
|
|
792
|
+
return self._view.summary()
|
|
822
793
|
|
|
823
794
|
def rollout_path(self) -> str:
|
|
824
795
|
if self._session is None:
|
|
825
796
|
return ""
|
|
826
797
|
return self._session.rollout_path()
|
|
827
798
|
|
|
828
|
-
async def restore_from_rollout(
|
|
799
|
+
async def restore_from_rollout(
|
|
800
|
+
self, rollout_path: str, title: str = "", fork: bool = False
|
|
801
|
+
) -> None:
|
|
829
802
|
session = self._session
|
|
830
803
|
loop = self._worker_loop
|
|
831
804
|
if session is None or loop is None:
|
|
832
805
|
return
|
|
833
806
|
|
|
834
807
|
future = asyncio.run_coroutine_threadsafe(
|
|
835
|
-
session.restore_from_rollout(rollout_path, title=title),
|
|
808
|
+
session.restore_from_rollout(rollout_path, title=title, fork=fork),
|
|
836
809
|
loop,
|
|
837
810
|
)
|
|
838
811
|
await asyncio.wrap_future(future)
|
|
@@ -856,7 +829,7 @@ def create_app(
|
|
|
856
829
|
|
|
857
830
|
|
|
858
831
|
def create_multi_workspace_app(
|
|
859
|
-
registry:
|
|
832
|
+
registry: "WorkspaceRegistry",
|
|
860
833
|
password: "typing.Union[str, None]" = None,
|
|
861
834
|
) -> FastAPI:
|
|
862
835
|
app = _create_lifespan_app(registry.start, registry.close)
|
|
@@ -942,7 +915,9 @@ def create_multi_workspace_app(
|
|
|
942
915
|
return await _new_session_response(entry.manager)
|
|
943
916
|
|
|
944
917
|
@app.delete("/w/{workspace_id}/api/sessions/{session_id}")
|
|
945
|
-
async def workspace_delete_session(
|
|
918
|
+
async def workspace_delete_session(
|
|
919
|
+
workspace_id: str, session_id: str
|
|
920
|
+
) -> JSONResponse:
|
|
946
921
|
entry = _workspace_entry_or_404(registry, workspace_id)
|
|
947
922
|
return await _delete_session_response(entry.manager, session_id)
|
|
948
923
|
|
|
@@ -963,8 +938,12 @@ def create_multi_workspace_app(
|
|
|
963
938
|
return await _message_response(entry.manager, payload)
|
|
964
939
|
|
|
965
940
|
@app.websocket("/w/{workspace_id}/ws/session")
|
|
966
|
-
async def workspace_websocket_session(
|
|
967
|
-
|
|
941
|
+
async def workspace_websocket_session(
|
|
942
|
+
workspace_id: str, websocket: WebSocket
|
|
943
|
+
) -> None:
|
|
944
|
+
if not _auth_cookie_matches(
|
|
945
|
+
auth_token, websocket.cookies.get(AUTH_COOKIE_NAME)
|
|
946
|
+
):
|
|
968
947
|
await websocket.close(code=1008)
|
|
969
948
|
return
|
|
970
949
|
try:
|
|
@@ -1004,20 +983,35 @@ def _install_auth(app: FastAPI, password: "typing.Union[str, None]") -> str:
|
|
|
1004
983
|
{"ok": False, "error": "authentication required"},
|
|
1005
984
|
status_code=401,
|
|
1006
985
|
)
|
|
1007
|
-
|
|
986
|
+
target = path + ("?" + request.url.query if request.url.query else "")
|
|
987
|
+
return RedirectResponse(
|
|
988
|
+
url="/login?" + urlencode({"next": target}), status_code=303
|
|
989
|
+
)
|
|
1008
990
|
|
|
1009
991
|
@app.get("/login")
|
|
1010
992
|
async def login_page() -> HTMLResponse:
|
|
1011
993
|
return _html_response(_render_login_shell())
|
|
1012
994
|
|
|
1013
995
|
@app.post("/login")
|
|
1014
|
-
async def login(
|
|
1015
|
-
|
|
996
|
+
async def login(
|
|
997
|
+
request: Request, payload: "typing.Dict[str, object]"
|
|
998
|
+
) -> JSONResponse:
|
|
999
|
+
if not secrets.compare_digest(
|
|
1000
|
+
str(payload.get("password") or ""), password_text
|
|
1001
|
+
):
|
|
1016
1002
|
return JSONResponse(
|
|
1017
1003
|
{"ok": False, "error": "invalid password"},
|
|
1018
1004
|
status_code=401,
|
|
1019
1005
|
)
|
|
1020
|
-
|
|
1006
|
+
target = request.query_params.get("next", "/")
|
|
1007
|
+
if (
|
|
1008
|
+
not target.startswith("/")
|
|
1009
|
+
or target.startswith("//")
|
|
1010
|
+
or "\\" in target
|
|
1011
|
+
or any(ord(char) < 32 for char in target)
|
|
1012
|
+
):
|
|
1013
|
+
target = "/"
|
|
1014
|
+
response = JSONResponse({"ok": True, "redirect": target})
|
|
1021
1015
|
response.set_cookie(
|
|
1022
1016
|
AUTH_COOKIE_NAME,
|
|
1023
1017
|
token,
|
|
@@ -1095,13 +1089,14 @@ def _render_login_shell() -> str:
|
|
|
1095
1089
|
form.addEventListener("submit", async function(event) {
|
|
1096
1090
|
event.preventDefault();
|
|
1097
1091
|
statusEl.textContent = "";
|
|
1098
|
-
const response = await fetch(
|
|
1092
|
+
const response = await fetch(window.location.pathname + window.location.search, {
|
|
1099
1093
|
method: "POST",
|
|
1100
1094
|
headers: {"Content-Type": "application/json"},
|
|
1101
1095
|
body: JSON.stringify({password: passwordInput.value}),
|
|
1102
1096
|
});
|
|
1103
1097
|
if (response.ok) {
|
|
1104
|
-
|
|
1098
|
+
const result = await response.json();
|
|
1099
|
+
window.location.href = result.redirect;
|
|
1105
1100
|
return;
|
|
1106
1101
|
}
|
|
1107
1102
|
statusEl.textContent = "Invalid password";
|
|
@@ -1116,6 +1111,7 @@ def _create_lifespan_app(
|
|
|
1116
1111
|
close: "typing.Callable[[], typing.Awaitable[None]]",
|
|
1117
1112
|
) -> FastAPI:
|
|
1118
1113
|
if asynccontextmanager is not None:
|
|
1114
|
+
|
|
1119
1115
|
@asynccontextmanager
|
|
1120
1116
|
async def lifespan(_app):
|
|
1121
1117
|
await start()
|
|
@@ -1135,6 +1131,7 @@ def _create_lifespan_app(
|
|
|
1135
1131
|
@app.on_event("shutdown")
|
|
1136
1132
|
async def shutdown() -> None:
|
|
1137
1133
|
await close()
|
|
1134
|
+
|
|
1138
1135
|
return app
|
|
1139
1136
|
|
|
1140
1137
|
|
|
@@ -1190,7 +1187,9 @@ def _install_workspace_routes(
|
|
|
1190
1187
|
@app.websocket("/ws/session")
|
|
1191
1188
|
async def websocket_session(websocket: WebSocket) -> None:
|
|
1192
1189
|
auth_token = typing.cast(str, app.state.workspace_auth_token)
|
|
1193
|
-
if not _auth_cookie_matches(
|
|
1190
|
+
if not _auth_cookie_matches(
|
|
1191
|
+
auth_token, websocket.cookies.get(AUTH_COOKIE_NAME)
|
|
1192
|
+
):
|
|
1194
1193
|
await websocket.close(code=1008)
|
|
1195
1194
|
return
|
|
1196
1195
|
await _websocket_session_handler(manager, websocket)
|
|
@@ -1223,11 +1222,11 @@ def _websocket_backend_hint_response() -> JSONResponse:
|
|
|
1223
1222
|
)
|
|
1224
1223
|
|
|
1225
1224
|
|
|
1226
|
-
def _sessions_response(manager:
|
|
1225
|
+
def _sessions_response(manager: "WorkspaceSessionManager") -> JSONResponse:
|
|
1227
1226
|
return JSONResponse({"sessions": manager.list_sessions()})
|
|
1228
1227
|
|
|
1229
1228
|
|
|
1230
|
-
async def _new_session_response(manager:
|
|
1229
|
+
async def _new_session_response(manager: "WorkspaceSessionManager") -> JSONResponse:
|
|
1231
1230
|
session_id = await manager.create_session()
|
|
1232
1231
|
return JSONResponse(
|
|
1233
1232
|
{
|
|
@@ -1240,7 +1239,7 @@ async def _new_session_response(manager: 'WorkspaceSessionManager') -> JSONRespo
|
|
|
1240
1239
|
|
|
1241
1240
|
|
|
1242
1241
|
async def _delete_session_response(
|
|
1243
|
-
manager:
|
|
1242
|
+
manager: "WorkspaceSessionManager",
|
|
1244
1243
|
session_id: str,
|
|
1245
1244
|
) -> JSONResponse:
|
|
1246
1245
|
try:
|
|
@@ -1253,7 +1252,7 @@ async def _delete_session_response(
|
|
|
1253
1252
|
|
|
1254
1253
|
|
|
1255
1254
|
def _session_response(
|
|
1256
|
-
manager:
|
|
1255
|
+
manager: "WorkspaceSessionManager",
|
|
1257
1256
|
session_id: "typing.Union[str, None]" = None,
|
|
1258
1257
|
) -> JSONResponse:
|
|
1259
1258
|
try:
|
|
@@ -1271,7 +1270,7 @@ def _session_response(
|
|
|
1271
1270
|
|
|
1272
1271
|
|
|
1273
1272
|
async def _message_response(
|
|
1274
|
-
manager:
|
|
1273
|
+
manager: "WorkspaceSessionManager",
|
|
1275
1274
|
payload: "typing.Dict[str, object]",
|
|
1276
1275
|
) -> JSONResponse:
|
|
1277
1276
|
session_id = str(payload.get("session_id") or "")
|
|
@@ -1279,7 +1278,13 @@ async def _message_response(
|
|
|
1279
1278
|
link = manager.get(session_id or None)
|
|
1280
1279
|
except KeyError:
|
|
1281
1280
|
raise HTTPException(status_code=404, detail="session not found")
|
|
1282
|
-
|
|
1281
|
+
if "request_id" in payload:
|
|
1282
|
+
result = await link.answer_input(payload["request_id"], payload.get("answer"))
|
|
1283
|
+
else:
|
|
1284
|
+
result = await link.submit(
|
|
1285
|
+
str(payload.get("prompt") or ""),
|
|
1286
|
+
sender=str(payload.get("sender") or "web"),
|
|
1287
|
+
)
|
|
1283
1288
|
if isinstance(result, dict):
|
|
1284
1289
|
result.setdefault("sessions", manager.list_sessions())
|
|
1285
1290
|
status = 200 if result.get("ok") else 400
|
|
@@ -1287,7 +1292,7 @@ async def _message_response(
|
|
|
1287
1292
|
|
|
1288
1293
|
|
|
1289
1294
|
async def _websocket_session_handler(
|
|
1290
|
-
manager:
|
|
1295
|
+
manager: "WorkspaceSessionManager",
|
|
1291
1296
|
websocket: WebSocket,
|
|
1292
1297
|
) -> None:
|
|
1293
1298
|
await websocket.accept()
|
|
@@ -1308,7 +1313,7 @@ async def _websocket_session_handler(
|
|
|
1308
1313
|
await websocket.send_json({"type": "error", "error": "invalid json"})
|
|
1309
1314
|
continue
|
|
1310
1315
|
action = str(payload.get("type") or payload.get("action") or "")
|
|
1311
|
-
if action
|
|
1316
|
+
if action in {"send", "answer"}:
|
|
1312
1317
|
target_session_id = str(payload.get("session_id") or session_id or "")
|
|
1313
1318
|
try:
|
|
1314
1319
|
target_link = manager.get(target_session_id or None)
|
|
@@ -1317,10 +1322,16 @@ async def _websocket_session_handler(
|
|
|
1317
1322
|
{"type": "error", "error": "session not found"}
|
|
1318
1323
|
)
|
|
1319
1324
|
continue
|
|
1320
|
-
|
|
1321
|
-
|
|
1322
|
-
|
|
1323
|
-
|
|
1325
|
+
if action == "answer":
|
|
1326
|
+
result = await target_link.answer_input(
|
|
1327
|
+
payload.get("request_id"),
|
|
1328
|
+
payload.get("answer"),
|
|
1329
|
+
)
|
|
1330
|
+
else:
|
|
1331
|
+
result = await target_link.submit(
|
|
1332
|
+
str(payload.get("prompt") or ""),
|
|
1333
|
+
sender=str(payload.get("sender") or "web"),
|
|
1334
|
+
)
|
|
1324
1335
|
await websocket.send_json({"type": "send_result", "result": result})
|
|
1325
1336
|
elif action == "ping":
|
|
1326
1337
|
await websocket.send_json({"type": "pong"})
|
|
@@ -1364,6 +1375,58 @@ def _public_turn(turn: "typing.Dict[str, object]") -> "typing.Dict[str, object]"
|
|
|
1364
1375
|
)
|
|
1365
1376
|
|
|
1366
1377
|
|
|
1378
|
+
def _event_data(event: "Event") -> "typing.Dict[str, object]":
|
|
1379
|
+
payload = {
|
|
1380
|
+
item.name: getattr(event, item.name)
|
|
1381
|
+
for item in fields(event)
|
|
1382
|
+
if item.name not in {"turn_id", "submission_id"}
|
|
1383
|
+
}
|
|
1384
|
+
turn_id = ""
|
|
1385
|
+
if isinstance(event, TurnEvent):
|
|
1386
|
+
turn_id = event.turn_id
|
|
1387
|
+
if event.submission_id is not None:
|
|
1388
|
+
payload.update(submission_id=event.submission_id, turn_id=event.turn_id)
|
|
1389
|
+
elif isinstance(
|
|
1390
|
+
event, (CommandCompletedEvent, CommandFailedEvent, InputQueuedEvent)
|
|
1391
|
+
):
|
|
1392
|
+
turn_id = event.submission_id
|
|
1393
|
+
if isinstance(event, InputQueuedEvent):
|
|
1394
|
+
payload["submission_id"] = event.submission_id
|
|
1395
|
+
if isinstance(event, TurnStartedEvent):
|
|
1396
|
+
payload["user_text"] = "\n".join(event.user_texts)
|
|
1397
|
+
if isinstance(event, (ToolStartedEvent, ToolCompletedEvent)):
|
|
1398
|
+
payload.update(tool_name=event.call.name, call_id=event.call.call_id)
|
|
1399
|
+
if isinstance(event, ToolCompletedEvent):
|
|
1400
|
+
payload["is_error"] = event.result.is_error
|
|
1401
|
+
if isinstance(event, ToolCalledEvent):
|
|
1402
|
+
payload = {name: value for name, value in payload.items() if value is not None}
|
|
1403
|
+
if isinstance(
|
|
1404
|
+
event,
|
|
1405
|
+
(
|
|
1406
|
+
CompactStartedEvent,
|
|
1407
|
+
CompactCompletedEvent,
|
|
1408
|
+
CompactFailedEvent,
|
|
1409
|
+
AutoCompactStartedEvent,
|
|
1410
|
+
AutoCompactCompletedEvent,
|
|
1411
|
+
AutoCompactFailedEvent,
|
|
1412
|
+
),
|
|
1413
|
+
):
|
|
1414
|
+
for name in ("total_tokens", "token_limit"):
|
|
1415
|
+
if payload[name] is None:
|
|
1416
|
+
del payload[name]
|
|
1417
|
+
if payload.get("pruned_tool_results") == 0:
|
|
1418
|
+
del payload["pruned_tool_results"]
|
|
1419
|
+
if isinstance(event, (CompactCompletedEvent, AutoCompactCompletedEvent)):
|
|
1420
|
+
payload["summary"] = event.summary
|
|
1421
|
+
if isinstance(event, TerminalEvent) and event.background_work_count is None:
|
|
1422
|
+
del payload["background_work_count"]
|
|
1423
|
+
if isinstance(event, InputRequestedEvent):
|
|
1424
|
+
payload["kind"] = payload.pop("request_kind")
|
|
1425
|
+
payload["text"] = event.visualize()
|
|
1426
|
+
payload = {name: value for name, value in payload.items() if value is not None}
|
|
1427
|
+
return {"kind": event.kind, "turn_id": turn_id, "payload": _json_safe(payload)}
|
|
1428
|
+
|
|
1429
|
+
|
|
1367
1430
|
def _json_safe(value: object) -> "JSONValue":
|
|
1368
1431
|
if value is None or isinstance(value, (bool, int, float, str)):
|
|
1369
1432
|
return value
|
|
@@ -1371,6 +1434,8 @@ def _json_safe(value: object) -> "JSONValue":
|
|
|
1371
1434
|
return [_json_safe(item) for item in value]
|
|
1372
1435
|
if isinstance(value, dict):
|
|
1373
1436
|
return {str(key): _json_safe(item) for key, item in value.items()}
|
|
1437
|
+
if isinstance(value, InputRequestedEvent):
|
|
1438
|
+
return _event_data(value)["payload"]
|
|
1374
1439
|
if is_dataclass(value):
|
|
1375
1440
|
return _json_safe(asdict(value))
|
|
1376
1441
|
try:
|
|
@@ -1419,10 +1484,9 @@ def _board_asset_response(
|
|
|
1419
1484
|
try:
|
|
1420
1485
|
board_directory = board_path.parent.resolve()
|
|
1421
1486
|
resolved_asset = (board_directory / asset_path).resolve()
|
|
1422
|
-
within_board_directory = (
|
|
1423
|
-
|
|
1424
|
-
|
|
1425
|
-
)
|
|
1487
|
+
within_board_directory = os.path.commonpath(
|
|
1488
|
+
[str(board_directory), str(resolved_asset)]
|
|
1489
|
+
) == str(board_directory)
|
|
1426
1490
|
except (OSError, RuntimeError, ValueError):
|
|
1427
1491
|
within_board_directory = False
|
|
1428
1492
|
|
|
@@ -1444,9 +1508,9 @@ def _board_asset_response(
|
|
|
1444
1508
|
|
|
1445
1509
|
|
|
1446
1510
|
def _workspace_entry_or_404(
|
|
1447
|
-
registry:
|
|
1511
|
+
registry: "WorkspaceRegistry",
|
|
1448
1512
|
workspace_id: str,
|
|
1449
|
-
) ->
|
|
1513
|
+
) -> "WorkspaceEntry":
|
|
1450
1514
|
try:
|
|
1451
1515
|
return registry.get(workspace_id)
|
|
1452
1516
|
except (KeyError, ValueError):
|
|
@@ -1454,16 +1518,13 @@ def _workspace_entry_or_404(
|
|
|
1454
1518
|
|
|
1455
1519
|
|
|
1456
1520
|
def run_serve_cli(args: "argparse.Namespace") -> int:
|
|
1457
|
-
import
|
|
1521
|
+
from .owned_process import run_owned_server
|
|
1458
1522
|
|
|
1459
1523
|
host, port = parse_listen(args.listen)
|
|
1460
1524
|
configure_loguru()
|
|
1461
1525
|
|
|
1462
1526
|
definitions = load_workspace_definitions(args.workspace_config)
|
|
1463
|
-
entries = [
|
|
1464
|
-
_build_workspace_entry(definition, args)
|
|
1465
|
-
for definition in definitions
|
|
1466
|
-
]
|
|
1527
|
+
entries = [_build_workspace_entry(definition, args) for definition in definitions]
|
|
1467
1528
|
registry = WorkspaceRegistry(
|
|
1468
1529
|
entries,
|
|
1469
1530
|
config_path=args.workspace_config,
|
|
@@ -1490,15 +1551,15 @@ def run_serve_cli(args: "argparse.Namespace") -> int:
|
|
|
1490
1551
|
),
|
|
1491
1552
|
flush=True,
|
|
1492
1553
|
)
|
|
1493
|
-
|
|
1554
|
+
run_owned_server(app, host, port, sys.stdin.fileno())
|
|
1494
1555
|
return 0
|
|
1495
1556
|
|
|
1496
1557
|
|
|
1497
1558
|
def _build_workspace_entry(
|
|
1498
|
-
definition:
|
|
1559
|
+
definition: "WorkspaceDefinition",
|
|
1499
1560
|
args: "argparse.Namespace",
|
|
1500
1561
|
persist_callback: "typing.Union[typing.Callable[[], None], None]" = None,
|
|
1501
|
-
) ->
|
|
1562
|
+
) -> "WorkspaceEntry":
|
|
1502
1563
|
def build_session() -> "WorkspaceInteractiveSession":
|
|
1503
1564
|
model = build_model(
|
|
1504
1565
|
config_path=args.config,
|
|
@@ -1513,7 +1574,6 @@ def _build_workspace_entry(
|
|
|
1513
1574
|
config_path=args.config,
|
|
1514
1575
|
profile=args.profile,
|
|
1515
1576
|
system_prompt=args.system_prompt,
|
|
1516
|
-
session_mode="tui",
|
|
1517
1577
|
extra_contextual_user_messages=(
|
|
1518
1578
|
[_board_context_text(definition.board_path, definition.work_dir)]
|
|
1519
1579
|
if definition.board_path is not None
|
|
@@ -1523,12 +1583,14 @@ def _build_workspace_entry(
|
|
|
1523
1583
|
toolset=args.toolset,
|
|
1524
1584
|
)
|
|
1525
1585
|
return WorkspaceInteractiveSession(
|
|
1526
|
-
|
|
1586
|
+
build_runtime(agent),
|
|
1527
1587
|
config_path=args.config,
|
|
1528
1588
|
)
|
|
1529
1589
|
|
|
1530
1590
|
def session_factory() -> "ThreadedWorkspaceInteractiveSession":
|
|
1531
|
-
return ThreadedWorkspaceInteractiveSession(
|
|
1591
|
+
return ThreadedWorkspaceInteractiveSession(
|
|
1592
|
+
build_session, asyncio.get_running_loop()
|
|
1593
|
+
)
|
|
1532
1594
|
|
|
1533
1595
|
return WorkspaceEntry(
|
|
1534
1596
|
definition=definition,
|
|
@@ -1578,12 +1640,9 @@ def _render_workspace_shell(
|
|
|
1578
1640
|
board_label = str(board_path) if board_path is not None else "No board"
|
|
1579
1641
|
cwd_label = str(work_dir or Path.cwd())
|
|
1580
1642
|
page_title = str(title or "pycodex workspace")
|
|
1581
|
-
template = (Path(__file__).with_name("workspace.html")).read_text(
|
|
1582
|
-
encoding="utf-8"
|
|
1583
|
-
)
|
|
1643
|
+
template = (Path(__file__).with_name("workspace.html")).read_text(encoding="utf-8")
|
|
1584
1644
|
return (
|
|
1585
|
-
template
|
|
1586
|
-
.replace("__WORKSPACE_TITLE__", html.escape(page_title))
|
|
1645
|
+
template.replace("__WORKSPACE_TITLE__", html.escape(page_title))
|
|
1587
1646
|
.replace("__BOARD_LABEL__", html.escape(board_label))
|
|
1588
1647
|
.replace("__CWD_LABEL__", html.escape(cwd_label))
|
|
1589
1648
|
)
|
|
@@ -1594,23 +1653,52 @@ def _render_workspaces_manager_shell() -> str:
|
|
|
1594
1653
|
|
|
1595
1654
|
|
|
1596
1655
|
def _render_empty_board() -> str:
|
|
1597
|
-
return
|
|
1598
|
-
|
|
1599
|
-
|
|
1600
|
-
|
|
1601
|
-
|
|
1602
|
-
</body></html>"""
|
|
1656
|
+
return _render_board_placeholder(
|
|
1657
|
+
"No board",
|
|
1658
|
+
"No board connected",
|
|
1659
|
+
"Add a board from the workspaces page to see your work here.",
|
|
1660
|
+
)
|
|
1603
1661
|
|
|
1604
1662
|
|
|
1605
1663
|
def _render_missing_board(board_path: Path) -> str:
|
|
1606
|
-
|
|
1664
|
+
return _render_board_placeholder(
|
|
1665
|
+
"Board pending",
|
|
1666
|
+
"Your work will appear here",
|
|
1667
|
+
"Ask pycodex to create a page, a report, or a visual. "
|
|
1668
|
+
"This canvas updates as you work.",
|
|
1669
|
+
str(board_path),
|
|
1670
|
+
)
|
|
1671
|
+
|
|
1672
|
+
|
|
1673
|
+
def _render_board_placeholder(
|
|
1674
|
+
title: str, heading: str, description: str, path_label: str = ""
|
|
1675
|
+
) -> str:
|
|
1607
1676
|
return """<!doctype html>
|
|
1608
|
-
<html><head><meta charset="utf-8"
|
|
1609
|
-
<
|
|
1610
|
-
<
|
|
1611
|
-
|
|
1612
|
-
|
|
1613
|
-
|
|
1677
|
+
<html lang="en"><head><meta charset="utf-8">
|
|
1678
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
1679
|
+
<title>{0}</title><style>
|
|
1680
|
+
* {{ box-sizing: border-box; }}
|
|
1681
|
+
body {{ margin: 0; min-height: 100dvh; display: grid; place-items: center;
|
|
1682
|
+
padding: 32px; background: #f8fbfd; color: #172630;
|
|
1683
|
+
font: 14px/1.7 -apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif; }}
|
|
1684
|
+
main {{ max-width: 360px; text-align: center; }}
|
|
1685
|
+
svg {{ width: 40px; height: 40px; color: #aebfc9; margin-bottom: 20px; }}
|
|
1686
|
+
h1 {{ margin: 0 0 12px; font-size: 23px; font-weight: 500; line-height: 1.35;
|
|
1687
|
+
letter-spacing: -0.5px; }}
|
|
1688
|
+
p {{ margin: 0; color: #60727d; }}
|
|
1689
|
+
code {{ display: block; margin-top: 28px; font-size: 11px; color: #60727d;
|
|
1690
|
+
overflow-wrap: anywhere; }}
|
|
1691
|
+
</style></head><body><main>
|
|
1692
|
+
<svg viewBox="0 0 32 32" fill="none" stroke="currentColor" stroke-width="1.25"
|
|
1693
|
+
aria-hidden="true"><rect x="3" y="3" width="26" height="26" rx="5"/>
|
|
1694
|
+
<path d="M3 11h26M11 11v18"/></svg>
|
|
1695
|
+
<h1>{1}</h1><p>{2}</p><code>{3}</code>
|
|
1696
|
+
</main></body></html>""".format(
|
|
1697
|
+
html.escape(title),
|
|
1698
|
+
html.escape(heading),
|
|
1699
|
+
html.escape(description),
|
|
1700
|
+
html.escape(path_label),
|
|
1701
|
+
)
|
|
1614
1702
|
|
|
1615
1703
|
|
|
1616
1704
|
def main(argv: "typing.Union[typing.Sequence[str], None]" = None) -> int:
|