python-codex 0.3.2__py3-none-any.whl → 0.4.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. pycodex/__init__.py +3 -27
  2. pycodex/bootstrap.py +5 -45
  3. pycodex/cli.py +1 -14
  4. pycodex/context.py +1 -1
  5. pycodex/events.py +1 -1
  6. pycodex/model.py +2 -2
  7. pycodex/portable/__init__.py +45 -0
  8. pycodex/{portable.py → portable/client.py} +4 -7
  9. pycodex/{portable_server.py → portable/server.py} +3 -3
  10. pycodex/protocol.py +1 -1
  11. pycodex/runtime.py +1 -1
  12. pycodex/tools/base_tool.py +1 -1
  13. pycodex/tools/close_agent_tool.py +1 -1
  14. pycodex/tools/code_mode_manager.py +1 -1
  15. pycodex/tools/request_permissions_tool.py +1 -1
  16. pycodex/tools/resume_agent_tool.py +1 -1
  17. pycodex/{runtime_services.py → tools/runtime_services.py} +5 -5
  18. pycodex/tools/send_input_tool.py +1 -1
  19. pycodex/tools/spawn_agent_tool.py +1 -1
  20. pycodex/tools/unified_exec_manager.py +1 -1
  21. pycodex/tools/update_plan_tool.py +1 -1
  22. pycodex/tools/view_image_tool.py +5 -0
  23. pycodex/tools/wait_agent_tool.py +1 -1
  24. pycodex/utils/__init__.py +0 -4
  25. pycodex/utils/_aes_gcm/LICENSE +56 -0
  26. pycodex/utils/_aes_gcm/README.md +61 -0
  27. pycodex/utils/_aes_gcm/__init__.py +1 -0
  28. pycodex/utils/_aes_gcm/aes.py +175 -0
  29. pycodex/utils/_aes_gcm/gcm.py +73 -0
  30. pycodex/utils/_aes_gcm/source-sha256.json +4 -0
  31. pycodex/{compat.py → utils/compat.py} +2 -0
  32. pycodex/utils/crypto.py +35 -0
  33. pycodex/utils/debug.py +24 -0
  34. pycodex/{doctor.py → utils/doctor.py} +3 -3
  35. pycodex/utils/image_utils.py +3 -2
  36. pycodex/{model_metadata.py → utils/model_metadata.py} +2 -2
  37. {python_codex-0.3.2.dist-info → python_codex-0.4.0.dist-info}/METADATA +165 -42
  38. {python_codex-0.3.2.dist-info → python_codex-0.4.0.dist-info}/RECORD +43 -45
  39. {python_codex-0.3.2.dist-info → python_codex-0.4.0.dist-info}/entry_points.txt +0 -1
  40. responses_server/app.py +28 -15
  41. responses_server/stream_router.py +17 -13
  42. pycodex/feishu_card.py +0 -793
  43. pycodex/feishu_link.py +0 -440
  44. pycodex/tools/ipython_tool.py +0 -142
  45. workspace_server/__init__.py +0 -39
  46. workspace_server/__main__.py +0 -4
  47. workspace_server/app.py +0 -1713
  48. workspace_server/owned_process.py +0 -27
  49. workspace_server/workspace.html +0 -2211
  50. workspace_server/workspaces.html +0 -550
  51. workspace_server/workspaces.py +0 -628
  52. {python_codex-0.3.2.dist-info → python_codex-0.4.0.dist-info}/WHEEL +0 -0
  53. {python_codex-0.3.2.dist-info → python_codex-0.4.0.dist-info}/licenses/LICENSE +0 -0
workspace_server/app.py DELETED
@@ -1,1713 +0,0 @@
1
- import argparse
2
- import asyncio
3
- import html
4
- import json
5
- import mimetypes
6
- import os
7
- import secrets
8
- import sys
9
- import threading
10
- from dataclasses import asdict, fields, is_dataclass
11
-
12
- try:
13
- from contextlib import asynccontextmanager
14
- except ImportError: # pragma: no cover - Python 3.6 compatibility
15
- asynccontextmanager = None
16
- import typing
17
- from pathlib import Path
18
- from urllib.parse import urlencode
19
-
20
- from fastapi import FastAPI, HTTPException, Request, WebSocket, WebSocketDisconnect
21
- from fastapi.responses import (
22
- FileResponse,
23
- HTMLResponse,
24
- JSONResponse,
25
- RedirectResponse,
26
- Response,
27
- )
28
-
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,
58
- )
59
- from pycodex.model import DEFAULT_CODEX_CONFIG_PATH
60
- from pycodex.utils import uuid7_string
61
- from pycodex.utils.event_helpers import (
62
- completed_history,
63
- shorten_title,
64
- )
65
-
66
- from .workspaces import (
67
- WorkspaceDefinition,
68
- WorkspaceEntry,
69
- WorkspaceRegistry,
70
- WorkspaceSessionManager,
71
- load_workspace_definitions,
72
- session_snapshot,
73
- )
74
-
75
- JSONValue = typing.Union[
76
- None,
77
- bool,
78
- int,
79
- float,
80
- str,
81
- typing.List["JSONValue"],
82
- typing.Dict[str, "JSONValue"],
83
- ]
84
-
85
-
86
- def build_parser() -> "argparse.ArgumentParser":
87
- parser = argparse.ArgumentParser(
88
- prog="pycodex-ws",
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.",
91
- )
92
- parser.add_argument(
93
- "--listen",
94
- default="127.0.0.1:6007",
95
- help="Bind address as host:port, for example 0.0.0.0:6007.",
96
- )
97
- parser.add_argument(
98
- "--workspace-config",
99
- default="./workspaces.json",
100
- help=(
101
- "Optional JSON file listing workspaces. Each entry needs `id`, "
102
- "`board`, and `work_dir`; routes are served under /w/<id>/."
103
- ),
104
- )
105
- parser.add_argument(
106
- "--password",
107
- default=None,
108
- help="Optional password required to open the workspace server.",
109
- )
110
- parser.add_argument(
111
- "--config",
112
- default=str(DEFAULT_CODEX_CONFIG_PATH),
113
- help="Path to Codex config.toml.",
114
- )
115
- parser.add_argument("--profile", default=None, help="Optional profile name.")
116
- parser.add_argument(
117
- "--system-prompt",
118
- default=None,
119
- help="Optional base instructions override passed to the model.",
120
- )
121
- parser.add_argument(
122
- "--toolset",
123
- nargs="*",
124
- default=None,
125
- help="Builtin tool names for all sessions; an empty list disables tools.",
126
- )
127
- parser.add_argument(
128
- "--timeout-seconds",
129
- type=float,
130
- default=120.0,
131
- help="HTTP timeout for one model call.",
132
- )
133
- parser.add_argument(
134
- "--vllm-endpoint",
135
- default=None,
136
- help="Optional base URL for a chat-completions-backed vLLM server.",
137
- )
138
- parser.add_argument(
139
- "--use-chat-completion",
140
- default=False,
141
- action="store_true",
142
- help="Start a local responses compat server for this session.",
143
- )
144
- parser.add_argument(
145
- "--use-messages",
146
- default=False,
147
- action="store_true",
148
- help="Route through a downstream /v1/messages backend.",
149
- )
150
- return parser
151
-
152
-
153
- def parse_listen(target: str) -> "typing.Tuple[str, int]":
154
- target_text = str(target or "").strip() or "127.0.0.1:6007"
155
- if ":" not in target_text:
156
- raise ValueError("workspace listen target must look like host:port")
157
- host, port_text = target_text.rsplit(":", 1)
158
- host = host.strip() or "127.0.0.1"
159
- try:
160
- port = int(port_text)
161
- except ValueError as exc:
162
- raise ValueError("workspace port must be an integer") from exc
163
- return host, port
164
-
165
-
166
- SessionFactory = typing.Callable[[], object]
167
- ThreadedSessionFactory = typing.Callable[[], "WorkspaceInteractiveSession"]
168
- SPINNER_STATUS_PREVIEW_LIMIT = 180
169
- AUTH_COOKIE_NAME = "pycodex_ws_auth"
170
-
171
-
172
- class WebSessionView:
173
- def __init__(self) -> None:
174
- self._subscribers: "typing.Set[asyncio.Queue]" = set()
175
- self._events: "typing.List[typing.Dict[str, object]]" = []
176
- self._turns: "typing.List[typing.Dict[str, object]]" = []
177
- self._turns_by_submission_id: "typing.Dict[str, typing.Dict[str, object]]" = {}
178
- self._turns_by_turn_id: "typing.Dict[str, typing.Dict[str, object]]" = {}
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
185
- self._spinner_status = ""
186
- self._stream_buffer = ""
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
190
- self._server_loop: "typing.Union[asyncio.AbstractEventLoop, None]" = None
191
- self._lock = threading.RLock()
192
-
193
- def attach_server_loop(self, loop: "asyncio.AbstractEventLoop") -> None:
194
- self._server_loop = loop
195
-
196
- def handle_event(self, event: "Event") -> None:
197
- with self._lock:
198
- self._apply_runtime_event(event)
199
- payload = _event_data(event)
200
- payload.update({"type": "event", "snapshot": self.snapshot()})
201
- if isinstance(event, ToolCompletedEvent):
202
- payload["summary"] = event.visualize()
203
- self._publish_nowait(payload)
204
-
205
- def finish_stream(self) -> None:
206
- with self._lock:
207
- if not self._stream_buffer:
208
- return
209
- active_turn = self._last_active_turn()
210
- if active_turn is not None and not active_turn.get("response"):
211
- active_turn["response"] = self._stream_buffer
212
- active_turn["thinking"] = ""
213
- active_turn["_thinking_active"] = False
214
- self._stream_buffer = ""
215
- event = {"type": "snapshot", "snapshot": self.snapshot()}
216
- self._publish_nowait(event)
217
-
218
- def write_line(self, text: str) -> None:
219
- with self._lock:
220
- text = str(text or "")
221
- turn = self._new_control_turn(text)
222
- turn["response"] = text
223
- turn["status"] = "completed"
224
- event = {"type": "snapshot", "snapshot": self.snapshot()}
225
- self._publish_nowait(event)
226
-
227
- def show_error(self, text: str) -> None:
228
- self.finish_stream()
229
- with self._lock:
230
- turn = self._new_control_turn("")
231
- turn["error"] = str(text or "")
232
- turn["status"] = "error"
233
- event = {"type": "snapshot", "snapshot": self.snapshot()}
234
- self._publish_nowait(event)
235
-
236
- def set_session_title(self, title: str) -> None:
237
- with self._lock:
238
- self._set_title(title)
239
- event = {
240
- "type": "title_changed",
241
- "title": self._title,
242
- "snapshot": self.snapshot(),
243
- }
244
- self._publish_nowait(event)
245
-
246
- def load_session_history(
247
- self,
248
- title: "typing.Union[str, None]",
249
- history: "typing.Iterable[typing.Tuple[str, str]]",
250
- ) -> None:
251
- self.finish_stream()
252
- with self._lock:
253
- self._set_title(title)
254
- self._turns = []
255
- self._turns_by_submission_id = {}
256
- self._turns_by_turn_id = {}
257
- self._events = []
258
- for prompt, response in history:
259
- submission_id = uuid7_string()
260
- turn = self._ensure_turn(
261
- submission_id, submission_id, str(prompt or "")
262
- )
263
- turn["response"] = str(response or "")
264
- turn["status"] = "completed"
265
- turn["queue"] = "history"
266
- turn["sender"] = "resume"
267
- event = {"type": "snapshot", "snapshot": self.snapshot()}
268
- self._publish_nowait(event)
269
-
270
- def subscribe(self) -> "asyncio.Queue":
271
- queue: "asyncio.Queue" = asyncio.Queue()
272
- with self._lock:
273
- self._subscribers.add(queue)
274
- event = {
275
- "type": "hello",
276
- "events": list(self._events[-200:]),
277
- "snapshot": self.snapshot(),
278
- }
279
- queue.put_nowait(event)
280
- return queue
281
-
282
- def unsubscribe(self, queue: "asyncio.Queue") -> None:
283
- with self._lock:
284
- self._subscribers.discard(queue)
285
-
286
- def close(self) -> None:
287
- with self._lock:
288
- subscribers = tuple(self._subscribers)
289
- self._subscribers.clear()
290
- self._publish_to_queues(subscribers, None)
291
-
292
- def snapshot(self) -> "typing.Dict[str, object]":
293
- with self._lock:
294
- return {
295
- "running": bool(self._spinner_status),
296
- "status": self._spinner_status,
297
- "status_kind": "spinner" if self._spinner_status else "idle",
298
- "spinner": self._spinner_status,
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,
309
- "title": self._title,
310
- **self._context_usage(),
311
- "turns": [_public_turn(turn) for turn in self._turns[-80:]],
312
- }
313
-
314
- def summary(self) -> "typing.Dict[str, object]":
315
- with self._lock:
316
- return {
317
- "model": self._model,
318
- "running": bool(self._spinner_status),
319
- "spinner": self._spinner_status,
320
- "title": self._title,
321
- "turn_count": len(self._turns),
322
- "last_assistant": _last_assistant_text(self._turns),
323
- **self._context_usage(),
324
- }
325
-
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 = ""
379
- return
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
395
- turn = self._turns_by_submission_id.get(submission_id)
396
-
397
- if isinstance(event, TurnStartedEvent):
398
- self._set_spinner_status(event.kind)
399
- turn = self._ensure_turn(submission_id, turn_id, event.visualize())
400
- turn["status"] = "running"
401
- turn["thinking"] = ""
402
- turn["_thinking_active"] = False
403
- turn["error"] = ""
404
- return
405
-
406
- self._apply_spinner_event(event)
407
- if turn is None:
408
- return
409
-
410
- if isinstance(event, AssistantDeltaEvent):
411
- turn["status"] = "responding"
412
- delta = event.visualize()
413
- self._stream_buffer += delta
414
- if turn.get("_thinking_active"):
415
- turn["thinking"] = str(turn.get("thinking") or "") + delta
416
- else:
417
- turn["thinking"] = delta
418
- turn["_thinking_active"] = True
419
- return
420
-
421
- if isinstance(event, ToolStartedEvent):
422
- turn["status"] = "tool"
423
- turn["tool_name"] = event.call.name
424
- turn["_thinking_active"] = False
425
- return
426
-
427
- if isinstance(event, ToolCompletedEvent):
428
- turn["_thinking_active"] = False
429
- turn["status"] = "running"
430
- return
431
-
432
- if isinstance(event, TurnCompletedEvent):
433
- response = event.visualize()
434
- if response:
435
- turn["response"] = response
436
- elif turn.get("thinking"):
437
- turn["response"] = str(turn.get("thinking") or "")
438
- turn["thinking"] = ""
439
- turn["_thinking_active"] = False
440
- turn["status"] = "completed"
441
- self._stream_buffer = ""
442
- return
443
-
444
- if isinstance(event, TurnFailedEvent):
445
- turn["status"] = "error"
446
- turn["error"] = event.visualize()
447
- self._stream_buffer = ""
448
- return
449
-
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"):
454
- turn["response"] = str(turn.get("thinking") or "")
455
- turn["thinking"] = ""
456
- turn["_thinking_active"] = False
457
- turn["status"] = "interrupted"
458
- self._stream_buffer = ""
459
-
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
- }
481
-
482
- def _apply_spinner_event(self, event: "TurnEvent") -> None:
483
- if isinstance(event, AssistantDeltaEvent):
484
- self._set_spinner_status("talking")
485
- return
486
- if isinstance(event, StreamErrorEvent):
487
- self._set_spinner_status("reconnecting")
488
- return
489
- if isinstance(event, (AutoCompactStartedEvent, CompactStartedEvent)):
490
- self._set_spinner_status("compacting")
491
- return
492
- if isinstance(event, AutoCompactCompletedEvent):
493
- self._set_spinner_status("compacted")
494
- return
495
- if isinstance(event, ToolStartedEvent):
496
- self._set_spinner_status(
497
- shorten_title(
498
- event.visualize(),
499
- limit=SPINNER_STATUS_PREVIEW_LIMIT,
500
- )
501
- )
502
- return
503
- if isinstance(event, ToolCompletedEvent):
504
- self._set_spinner_status("called {0}".format(event.call.name))
505
- return
506
- if isinstance(event, TerminalEvent):
507
- self._set_idle_spinner_status(event)
508
-
509
- def _set_spinner_status(self, text: "typing.Union[str, None]") -> None:
510
- self._spinner_status = str(text or "").strip()
511
-
512
- def _set_idle_spinner_status(self, event: "TerminalEvent") -> None:
513
- if (event.background_work_count or 0) > 0:
514
- self._set_spinner_status(IDLE_SLEEPING_STATUS)
515
- else:
516
- self._set_spinner_status("")
517
-
518
- def _ensure_turn(
519
- self,
520
- submission_id: str,
521
- turn_id: str,
522
- prompt: str,
523
- ) -> "typing.Dict[str, object]":
524
- submission_id = str(submission_id or "").strip()
525
- turn_id = str(turn_id or "").strip()
526
- turn = self._turns_by_submission_id.get(submission_id)
527
- if turn is None and turn_id and not submission_id:
528
- turn = self._turns_by_turn_id.get(turn_id)
529
- if turn is None:
530
- turn = {
531
- "submission_id": submission_id,
532
- "turn_id": "",
533
- "prompt": prompt,
534
- "response": "",
535
- "thinking": "",
536
- "_thinking_active": False,
537
- "status": "queued",
538
- "error": "",
539
- "kind": "assistant",
540
- "queue": "steer",
541
- "sender": "web",
542
- }
543
- if submission_id:
544
- turn["submission_id"] = submission_id
545
- self._turns_by_submission_id[submission_id] = turn
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)
550
- turn["turn_id"] = turn_id
551
- self._turns_by_turn_id[turn_id] = turn
552
- if prompt:
553
- turn["prompt"] = prompt
554
- return turn
555
-
556
- def _new_control_turn(self, text: str) -> "typing.Dict[str, object]":
557
- submission_id = uuid7_string()
558
- turn = self._ensure_turn(submission_id, submission_id, "")
559
- turn["kind"] = "control"
560
- turn["queue"] = "control"
561
- turn["sender"] = "web"
562
- turn["status"] = "running"
563
- turn["error"] = ""
564
- turn["response"] = ""
565
- turn["thinking"] = ""
566
- turn["prompt"] = ""
567
- return turn
568
-
569
- def _set_title(self, title: "typing.Union[str, None]") -> None:
570
- self._title = str(title or "").strip()
571
-
572
- def _last_active_turn(self) -> "typing.Union[typing.Dict[str, object], None]":
573
- for turn in reversed(self._turns):
574
- if turn.get("kind") != "control" and turn.get("status") not in {
575
- "completed",
576
- "error",
577
- "interrupted",
578
- }:
579
- return turn
580
- return None
581
-
582
- def _publish_nowait(self, event: "typing.Dict[str, object]") -> None:
583
- with self._lock:
584
- self._events.append(event)
585
- if len(self._events) > 500:
586
- del self._events[:-500]
587
- subscribers = tuple(self._subscribers)
588
- self._publish_to_queues(subscribers, event)
589
-
590
- def _publish_to_queues(
591
- self,
592
- queues: "typing.Iterable[asyncio.Queue]",
593
- event: "typing.Union[typing.Dict[str, object], None]",
594
- ) -> None:
595
- loop = self._server_loop
596
- if loop is None:
597
- for queue in queues:
598
- queue.put_nowait(event)
599
- return
600
-
601
- def publish() -> None:
602
- for queue in queues:
603
- queue.put_nowait(event)
604
-
605
- loop.call_soon_threadsafe(publish)
606
-
607
-
608
- class WorkspaceInteractiveSession:
609
- def __init__(
610
- self,
611
- runtime,
612
- config_path: "typing.Union[str, None]" = None,
613
- ) -> None:
614
- self.runtime = runtime
615
- self.config_path = config_path
616
- self.view = WebSessionView()
617
- self._frontend_id = None
618
-
619
- async def start(self) -> "WorkspaceInteractiveSession":
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)
623
- return self
624
-
625
- async def close(self) -> None:
626
- try:
627
- await self.runtime.close()
628
- finally:
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
- }
650
-
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()}
657
-
658
- def subscribe(self) -> "asyncio.Queue":
659
- return self.view.subscribe()
660
-
661
- def unsubscribe(self, queue: "asyncio.Queue") -> None:
662
- self.view.unsubscribe(queue)
663
-
664
- def snapshot(self) -> "typing.Dict[str, object]":
665
- return self.view.snapshot()
666
-
667
- def summary(self) -> "typing.Dict[str, object]":
668
- return self.view.summary()
669
-
670
- def rollout_path(self) -> str:
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)
682
-
683
-
684
- class ThreadedWorkspaceInteractiveSession:
685
- def __init__(
686
- self,
687
- session_factory: "ThreadedSessionFactory",
688
- server_loop: "asyncio.AbstractEventLoop",
689
- ) -> None:
690
- self._session_factory = session_factory
691
- self._server_loop = server_loop
692
- self._view = WebSessionView()
693
- self._view.attach_server_loop(server_loop)
694
- self._thread: "typing.Union[threading.Thread, None]" = None
695
- self._worker_loop: "typing.Union[asyncio.AbstractEventLoop, None]" = None
696
- self._ready = threading.Event()
697
- self._startup_error: "typing.Union[BaseException, None]" = None
698
- self._session: "typing.Union[WorkspaceInteractiveSession, None]" = None
699
-
700
- async def start(self) -> "ThreadedWorkspaceInteractiveSession":
701
- if self._thread is not None:
702
- return self
703
- self._thread = threading.Thread(
704
- target=self._thread_main,
705
- name="pycodex-workspace-session",
706
- daemon=True,
707
- )
708
- self._thread.start()
709
- await asyncio.to_thread(self._ready.wait)
710
- if self._startup_error is not None:
711
- raise RuntimeError(
712
- "workspace session thread failed to start"
713
- ) from self._startup_error
714
- return self
715
-
716
- def _thread_main(self) -> None:
717
- loop = asyncio.new_event_loop()
718
- self._worker_loop = loop
719
- asyncio.set_event_loop(loop)
720
- try:
721
- session = self._session_factory()
722
- session.view = self._view
723
- self._session = session
724
- try:
725
- loop.run_until_complete(session.start())
726
- except BaseException:
727
- loop.run_until_complete(session.close())
728
- raise
729
- self._ready.set()
730
- loop.run_forever()
731
- except BaseException as exc:
732
- self._startup_error = exc
733
- self._ready.set()
734
- finally:
735
- pending = asyncio.all_tasks(loop)
736
- for task in pending:
737
- task.cancel()
738
- if pending:
739
- loop.run_until_complete(
740
- asyncio.gather(*pending, return_exceptions=True)
741
- )
742
- asyncio.set_event_loop(None)
743
- loop.close()
744
-
745
- async def close(self) -> None:
746
- session = self._session
747
- loop = self._worker_loop
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)
752
- )
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)
781
-
782
- def subscribe(self) -> "asyncio.Queue":
783
- return self._view.subscribe()
784
-
785
- def unsubscribe(self, queue: "asyncio.Queue") -> None:
786
- self._view.unsubscribe(queue)
787
-
788
- def snapshot(self) -> "typing.Dict[str, object]":
789
- return self._view.snapshot()
790
-
791
- def summary(self) -> "typing.Dict[str, object]":
792
- return self._view.summary()
793
-
794
- def rollout_path(self) -> str:
795
- if self._session is None:
796
- return ""
797
- return self._session.rollout_path()
798
-
799
- async def restore_from_rollout(
800
- self, rollout_path: str, title: str = "", fork: bool = False
801
- ) -> None:
802
- session = self._session
803
- loop = self._worker_loop
804
- if session is None or loop is None:
805
- return
806
-
807
- future = asyncio.run_coroutine_threadsafe(
808
- session.restore_from_rollout(rollout_path, title=title, fork=fork),
809
- loop,
810
- )
811
- await asyncio.wrap_future(future)
812
-
813
-
814
- def create_app(
815
- session_source: "typing.Union[WorkspaceSessionManager, SessionFactory]",
816
- board_path: "typing.Union[Path, None]",
817
- password: "typing.Union[str, None]" = None,
818
- ) -> FastAPI:
819
- manager = (
820
- session_source
821
- if isinstance(session_source, WorkspaceSessionManager)
822
- else WorkspaceSessionManager(session_source, board_path)
823
- )
824
- app = _create_lifespan_app(manager.start, manager.close)
825
- auth_token = _install_auth(app, password)
826
- _install_workspace_routes(app, manager, board_path)
827
- app.state.workspace_auth_token = auth_token
828
- return app
829
-
830
-
831
- def create_multi_workspace_app(
832
- registry: "WorkspaceRegistry",
833
- password: "typing.Union[str, None]" = None,
834
- ) -> FastAPI:
835
- app = _create_lifespan_app(registry.start, registry.close)
836
- auth_token = _install_auth(app, password)
837
-
838
- @app.get("/")
839
- async def index() -> Response:
840
- return _html_response(_render_workspaces_manager_shell())
841
-
842
- @app.get("/favicon.ico")
843
- async def favicon() -> Response:
844
- return Response(status_code=204)
845
-
846
- @app.get("/api/workspaces")
847
- async def workspaces() -> JSONResponse:
848
- return JSONResponse({"workspaces": registry.list_workspaces()})
849
-
850
- @app.post("/api/workspaces")
851
- async def add_workspace(payload: "typing.Dict[str, object]") -> JSONResponse:
852
- try:
853
- entry = await registry.add_workspace(
854
- str(payload.get("name") or ""),
855
- work_dir=str(payload.get("dir") or "./"),
856
- board=payload.get("board"),
857
- )
858
- except ValueError as exc:
859
- return JSONResponse({"ok": False, "error": str(exc)}, status_code=400)
860
- return JSONResponse(
861
- {
862
- "ok": True,
863
- "workspace": entry.to_dict(),
864
- "workspaces": registry.list_workspaces(),
865
- }
866
- )
867
-
868
- @app.delete("/api/workspaces/{workspace_id}")
869
- async def delete_workspace(workspace_id: str) -> JSONResponse:
870
- try:
871
- await registry.delete_workspace(workspace_id)
872
- except KeyError:
873
- raise HTTPException(status_code=404, detail="workspace not found")
874
- return JSONResponse({"ok": True, "workspaces": registry.list_workspaces()})
875
-
876
- @app.api_route("/w/{workspace_id}", methods=["GET", "HEAD"])
877
- async def workspace_index_redirect(workspace_id: str) -> RedirectResponse:
878
- _workspace_entry_or_404(registry, workspace_id)
879
- return RedirectResponse(url="/w/{0}/".format(workspace_id), status_code=307)
880
-
881
- @app.api_route("/w/{workspace_id}/", methods=["GET", "HEAD"])
882
- async def workspace_index(workspace_id: str) -> HTMLResponse:
883
- entry = _workspace_entry_or_404(registry, workspace_id)
884
- return _html_response(
885
- _render_workspace_shell(
886
- entry.definition.board_path,
887
- title=entry.definition.workspace_id,
888
- work_dir=entry.definition.work_dir,
889
- )
890
- )
891
-
892
- @app.api_route("/w/{workspace_id}/board", methods=["GET", "HEAD"])
893
- async def workspace_board(workspace_id: str) -> Response:
894
- entry = _workspace_entry_or_404(registry, workspace_id)
895
- return _board_response(entry.definition.board_path)
896
-
897
- @app.get("/w/{workspace_id}/api/board")
898
- async def workspace_board_status(workspace_id: str) -> JSONResponse:
899
- entry = _workspace_entry_or_404(registry, workspace_id)
900
- return _board_status_response(entry.definition.board_path)
901
-
902
- @app.get("/w/{workspace_id}/ws/session")
903
- async def workspace_websocket_backend_hint(workspace_id: str) -> JSONResponse:
904
- _workspace_entry_or_404(registry, workspace_id)
905
- return _websocket_backend_hint_response()
906
-
907
- @app.get("/w/{workspace_id}/api/sessions")
908
- async def workspace_sessions(workspace_id: str) -> JSONResponse:
909
- entry = _workspace_entry_or_404(registry, workspace_id)
910
- return _sessions_response(entry.manager)
911
-
912
- @app.post("/w/{workspace_id}/api/sessions")
913
- async def workspace_new_session(workspace_id: str) -> JSONResponse:
914
- entry = _workspace_entry_or_404(registry, workspace_id)
915
- return await _new_session_response(entry.manager)
916
-
917
- @app.delete("/w/{workspace_id}/api/sessions/{session_id}")
918
- async def workspace_delete_session(
919
- workspace_id: str, session_id: str
920
- ) -> JSONResponse:
921
- entry = _workspace_entry_or_404(registry, workspace_id)
922
- return await _delete_session_response(entry.manager, session_id)
923
-
924
- @app.get("/w/{workspace_id}/api/session")
925
- async def workspace_session(
926
- workspace_id: str,
927
- session_id: "typing.Union[str, None]" = None,
928
- ) -> JSONResponse:
929
- entry = _workspace_entry_or_404(registry, workspace_id)
930
- return _session_response(entry.manager, session_id)
931
-
932
- @app.post("/w/{workspace_id}/api/session/message")
933
- async def workspace_message(
934
- workspace_id: str,
935
- payload: "typing.Dict[str, object]",
936
- ) -> JSONResponse:
937
- entry = _workspace_entry_or_404(registry, workspace_id)
938
- return await _message_response(entry.manager, payload)
939
-
940
- @app.websocket("/w/{workspace_id}/ws/session")
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
- ):
947
- await websocket.close(code=1008)
948
- return
949
- try:
950
- entry = registry.get(workspace_id)
951
- except (KeyError, ValueError):
952
- await websocket.close(code=1008)
953
- return
954
- await _websocket_session_handler(entry.manager, websocket)
955
-
956
- @app.api_route(
957
- "/w/{workspace_id}/{asset_path:path}",
958
- methods=["GET", "HEAD"],
959
- )
960
- async def workspace_board_asset(workspace_id: str, asset_path: str) -> Response:
961
- entry = _workspace_entry_or_404(registry, workspace_id)
962
- return _board_asset_response(entry.definition.board_path, asset_path)
963
-
964
- return app
965
-
966
-
967
- def _install_auth(app: FastAPI, password: "typing.Union[str, None]") -> str:
968
- password_text = str(password or "")
969
- if not password_text:
970
- return ""
971
- token = secrets.token_urlsafe(32)
972
-
973
- @app.middleware("http")
974
- async def auth_middleware(request: Request, call_next):
975
- path = request.url.path
976
- if path in {"/favicon.ico", "/login"} or _auth_cookie_matches(
977
- token,
978
- request.cookies.get(AUTH_COOKIE_NAME),
979
- ):
980
- return await call_next(request)
981
- if path.startswith("/api/") or "/api/" in path:
982
- return JSONResponse(
983
- {"ok": False, "error": "authentication required"},
984
- status_code=401,
985
- )
986
- target = path + ("?" + request.url.query if request.url.query else "")
987
- return RedirectResponse(
988
- url="/login?" + urlencode({"next": target}), status_code=303
989
- )
990
-
991
- @app.get("/login")
992
- async def login_page() -> HTMLResponse:
993
- return _html_response(_render_login_shell())
994
-
995
- @app.post("/login")
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
- ):
1002
- return JSONResponse(
1003
- {"ok": False, "error": "invalid password"},
1004
- status_code=401,
1005
- )
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})
1015
- response.set_cookie(
1016
- AUTH_COOKIE_NAME,
1017
- token,
1018
- httponly=True,
1019
- samesite="lax",
1020
- )
1021
- return response
1022
-
1023
- return token
1024
-
1025
-
1026
- def _auth_cookie_matches(token: str, cookie: "typing.Union[str, None]") -> bool:
1027
- if not token:
1028
- return True
1029
- return bool(cookie) and secrets.compare_digest(str(cookie), token)
1030
-
1031
-
1032
- def _render_login_shell() -> str:
1033
- return """<!doctype html>
1034
- <html>
1035
- <head>
1036
- <meta charset="utf-8">
1037
- <meta name="viewport" content="width=device-width, initial-scale=1">
1038
- <title>pycodex login</title>
1039
- <style>
1040
- :root {
1041
- color-scheme: light dark;
1042
- font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
1043
- }
1044
- body {
1045
- min-height: 100vh;
1046
- margin: 0;
1047
- display: grid;
1048
- place-items: center;
1049
- background: Canvas;
1050
- color: CanvasText;
1051
- }
1052
- form {
1053
- width: min(360px, calc(100vw - 32px));
1054
- display: grid;
1055
- gap: 12px;
1056
- }
1057
- h1 {
1058
- margin: 0;
1059
- font-size: 22px;
1060
- }
1061
- input, button {
1062
- min-height: 38px;
1063
- border-radius: 7px;
1064
- border: 1px solid color-mix(in srgb, CanvasText 18%, Canvas 82%);
1065
- padding: 8px 10px;
1066
- font: inherit;
1067
- }
1068
- button {
1069
- cursor: pointer;
1070
- }
1071
- .status {
1072
- min-height: 20px;
1073
- color: #b42318;
1074
- font-size: 13px;
1075
- }
1076
- </style>
1077
- </head>
1078
- <body>
1079
- <form id="loginForm">
1080
- <h1>pycodex workspace</h1>
1081
- <input id="passwordInput" type="password" autocomplete="current-password" autofocus>
1082
- <button type="submit">Open</button>
1083
- <div id="status" class="status" role="status"></div>
1084
- </form>
1085
- <script>
1086
- const form = document.getElementById("loginForm");
1087
- const passwordInput = document.getElementById("passwordInput");
1088
- const statusEl = document.getElementById("status");
1089
- form.addEventListener("submit", async function(event) {
1090
- event.preventDefault();
1091
- statusEl.textContent = "";
1092
- const response = await fetch(window.location.pathname + window.location.search, {
1093
- method: "POST",
1094
- headers: {"Content-Type": "application/json"},
1095
- body: JSON.stringify({password: passwordInput.value}),
1096
- });
1097
- if (response.ok) {
1098
- const result = await response.json();
1099
- window.location.href = result.redirect;
1100
- return;
1101
- }
1102
- statusEl.textContent = "Invalid password";
1103
- });
1104
- </script>
1105
- </body>
1106
- </html>"""
1107
-
1108
-
1109
- def _create_lifespan_app(
1110
- start: "typing.Callable[[], typing.Awaitable[None]]",
1111
- close: "typing.Callable[[], typing.Awaitable[None]]",
1112
- ) -> FastAPI:
1113
- if asynccontextmanager is not None:
1114
-
1115
- @asynccontextmanager
1116
- async def lifespan(_app):
1117
- await start()
1118
- try:
1119
- yield
1120
- finally:
1121
- await close()
1122
-
1123
- return FastAPI(lifespan=lifespan)
1124
-
1125
- app = FastAPI()
1126
-
1127
- @app.on_event("startup")
1128
- async def startup() -> None:
1129
- await start()
1130
-
1131
- @app.on_event("shutdown")
1132
- async def shutdown() -> None:
1133
- await close()
1134
-
1135
- return app
1136
-
1137
-
1138
- def _install_workspace_routes(
1139
- app: FastAPI,
1140
- manager: WorkspaceSessionManager,
1141
- board_path: "typing.Union[Path, None]",
1142
- ) -> None:
1143
- @app.get("/")
1144
- async def index() -> HTMLResponse:
1145
- return _html_response(_render_workspace_shell(board_path))
1146
-
1147
- @app.get("/favicon.ico")
1148
- async def favicon() -> Response:
1149
- return Response(status_code=204)
1150
-
1151
- @app.api_route("/board", methods=["GET", "HEAD"])
1152
- async def board() -> Response:
1153
- return _board_response(board_path)
1154
-
1155
- @app.get("/api/board")
1156
- async def board_status() -> JSONResponse:
1157
- return _board_status_response(board_path)
1158
-
1159
- @app.get("/ws/session")
1160
- async def websocket_backend_hint() -> JSONResponse:
1161
- return _websocket_backend_hint_response()
1162
-
1163
- @app.get("/api/sessions")
1164
- async def sessions() -> JSONResponse:
1165
- return _sessions_response(manager)
1166
-
1167
- @app.post("/api/sessions")
1168
- async def new_session() -> JSONResponse:
1169
- return await _new_session_response(manager)
1170
-
1171
- @app.delete("/api/sessions/{session_id}")
1172
- async def delete_session(session_id: str) -> JSONResponse:
1173
- return await _delete_session_response(manager, session_id)
1174
-
1175
- @app.get("/api/session")
1176
- async def session(
1177
- session_id: "typing.Union[str, None]" = None,
1178
- ) -> JSONResponse:
1179
- return _session_response(manager, session_id)
1180
-
1181
- @app.post("/api/session/message")
1182
- async def message(
1183
- payload: "typing.Dict[str, object]",
1184
- ) -> JSONResponse:
1185
- return await _message_response(manager, payload)
1186
-
1187
- @app.websocket("/ws/session")
1188
- async def websocket_session(websocket: WebSocket) -> None:
1189
- auth_token = typing.cast(str, app.state.workspace_auth_token)
1190
- if not _auth_cookie_matches(
1191
- auth_token, websocket.cookies.get(AUTH_COOKIE_NAME)
1192
- ):
1193
- await websocket.close(code=1008)
1194
- return
1195
- await _websocket_session_handler(manager, websocket)
1196
-
1197
- @app.api_route("/{asset_path:path}", methods=["GET", "HEAD"])
1198
- async def board_asset(asset_path: str) -> Response:
1199
- return _board_asset_response(board_path, asset_path)
1200
-
1201
-
1202
- def _board_status_response(board_path: "typing.Union[Path, None]") -> JSONResponse:
1203
- if board_path is None or not board_path.is_file():
1204
- return JSONResponse({"exists": False})
1205
- stat = board_path.stat()
1206
- return JSONResponse(
1207
- {
1208
- "exists": True,
1209
- "path": str(board_path),
1210
- "mtime_ns": stat.st_mtime_ns,
1211
- "size": stat.st_size,
1212
- }
1213
- )
1214
-
1215
-
1216
- def _websocket_backend_hint_response() -> JSONResponse:
1217
- return JSONResponse(
1218
- {
1219
- "error": "websocket backend is unavailable; HTTP polling is active",
1220
- },
1221
- status_code=426,
1222
- )
1223
-
1224
-
1225
- def _sessions_response(manager: "WorkspaceSessionManager") -> JSONResponse:
1226
- return JSONResponse({"sessions": manager.list_sessions()})
1227
-
1228
-
1229
- async def _new_session_response(manager: "WorkspaceSessionManager") -> JSONResponse:
1230
- session_id = await manager.create_session()
1231
- return JSONResponse(
1232
- {
1233
- "ok": True,
1234
- "session_id": session_id,
1235
- "sessions": manager.list_sessions(),
1236
- "snapshot": session_snapshot(manager.get(session_id)),
1237
- }
1238
- )
1239
-
1240
-
1241
- async def _delete_session_response(
1242
- manager: "WorkspaceSessionManager",
1243
- session_id: str,
1244
- ) -> JSONResponse:
1245
- try:
1246
- await manager.close_session(session_id)
1247
- except KeyError:
1248
- raise HTTPException(status_code=404, detail="session not found")
1249
- except ValueError as exc:
1250
- return JSONResponse({"ok": False, "error": str(exc)}, status_code=400)
1251
- return JSONResponse({"ok": True, "sessions": manager.list_sessions()})
1252
-
1253
-
1254
- def _session_response(
1255
- manager: "WorkspaceSessionManager",
1256
- session_id: "typing.Union[str, None]" = None,
1257
- ) -> JSONResponse:
1258
- try:
1259
- resolved_id = manager.resolve_session_id(session_id)
1260
- link = manager.get(resolved_id)
1261
- except KeyError:
1262
- raise HTTPException(status_code=404, detail="session not found")
1263
- return JSONResponse(
1264
- {
1265
- "session_id": resolved_id,
1266
- "sessions": manager.list_sessions(),
1267
- "snapshot": session_snapshot(link),
1268
- }
1269
- )
1270
-
1271
-
1272
- async def _message_response(
1273
- manager: "WorkspaceSessionManager",
1274
- payload: "typing.Dict[str, object]",
1275
- ) -> JSONResponse:
1276
- session_id = str(payload.get("session_id") or "")
1277
- try:
1278
- link = manager.get(session_id or None)
1279
- except KeyError:
1280
- raise HTTPException(status_code=404, detail="session not found")
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
- )
1288
- if isinstance(result, dict):
1289
- result.setdefault("sessions", manager.list_sessions())
1290
- status = 200 if result.get("ok") else 400
1291
- return JSONResponse(result, status_code=status)
1292
-
1293
-
1294
- async def _websocket_session_handler(
1295
- manager: "WorkspaceSessionManager",
1296
- websocket: WebSocket,
1297
- ) -> None:
1298
- await websocket.accept()
1299
- session_id = str(websocket.query_params.get("session_id") or "")
1300
- try:
1301
- link = manager.get(session_id or None)
1302
- except KeyError:
1303
- await websocket.close(code=1008)
1304
- return
1305
- subscriber = link.subscribe()
1306
- sender = asyncio.create_task(_send_ws_events(websocket, subscriber))
1307
- try:
1308
- while True:
1309
- data = await websocket.receive_text()
1310
- try:
1311
- payload = json.loads(data)
1312
- except ValueError:
1313
- await websocket.send_json({"type": "error", "error": "invalid json"})
1314
- continue
1315
- action = str(payload.get("type") or payload.get("action") or "")
1316
- if action in {"send", "answer"}:
1317
- target_session_id = str(payload.get("session_id") or session_id or "")
1318
- try:
1319
- target_link = manager.get(target_session_id or None)
1320
- except KeyError:
1321
- await websocket.send_json(
1322
- {"type": "error", "error": "session not found"}
1323
- )
1324
- continue
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
- )
1335
- await websocket.send_json({"type": "send_result", "result": result})
1336
- elif action == "ping":
1337
- await websocket.send_json({"type": "pong"})
1338
- else:
1339
- await websocket.send_json({"type": "error", "error": "unknown action"})
1340
- except WebSocketDisconnect:
1341
- pass
1342
- finally:
1343
- link.unsubscribe(subscriber)
1344
- sender.cancel()
1345
- await asyncio.gather(sender, return_exceptions=True)
1346
-
1347
-
1348
- def _last_assistant_text(turns: "typing.Iterable[typing.Dict[str, object]]") -> str:
1349
- for turn in reversed(list(turns)):
1350
- if str(turn.get("kind") or "assistant") == "control":
1351
- continue
1352
- response = str(turn.get("response") or "").strip()
1353
- if response:
1354
- return response
1355
- return ""
1356
-
1357
-
1358
- def _public_turn(turn: "typing.Dict[str, object]") -> "typing.Dict[str, object]":
1359
- return typing.cast(
1360
- "typing.Dict[str, object]",
1361
- _json_safe(
1362
- {
1363
- "submission_id": turn.get("submission_id", ""),
1364
- "turn_id": turn.get("turn_id", ""),
1365
- "prompt": turn.get("prompt", ""),
1366
- "response": turn.get("response", ""),
1367
- "thinking": turn.get("thinking", ""),
1368
- "status": turn.get("status", ""),
1369
- "error": turn.get("error", ""),
1370
- "queue": turn.get("queue", ""),
1371
- "sender": turn.get("sender", ""),
1372
- "kind": turn.get("kind", "assistant"),
1373
- }
1374
- ),
1375
- )
1376
-
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
-
1430
- def _json_safe(value: object) -> "JSONValue":
1431
- if value is None or isinstance(value, (bool, int, float, str)):
1432
- return value
1433
- if isinstance(value, (list, tuple)):
1434
- return [_json_safe(item) for item in value]
1435
- if isinstance(value, dict):
1436
- return {str(key): _json_safe(item) for key, item in value.items()}
1437
- if isinstance(value, InputRequestedEvent):
1438
- return _event_data(value)["payload"]
1439
- if is_dataclass(value):
1440
- return _json_safe(asdict(value))
1441
- try:
1442
- json.dumps(value)
1443
- except TypeError:
1444
- return str(value)
1445
- return typing.cast(JSONValue, value)
1446
-
1447
-
1448
- def _html_response(content: str) -> HTMLResponse:
1449
- return HTMLResponse(
1450
- content,
1451
- headers={
1452
- "Cache-Control": "no-store",
1453
- "Pragma": "no-cache",
1454
- },
1455
- )
1456
-
1457
-
1458
- async def _send_ws_events(websocket: WebSocket, subscriber: "asyncio.Queue") -> None:
1459
- while True:
1460
- event = await subscriber.get()
1461
- if event is None:
1462
- return
1463
- await websocket.send_json(event)
1464
-
1465
-
1466
- def _board_response(board_path: "typing.Union[Path, None]") -> Response:
1467
- if board_path is None:
1468
- return _html_response(_render_empty_board())
1469
- if not board_path.is_file():
1470
- return _html_response(_render_missing_board(board_path))
1471
- return _html_response(board_path.read_text(encoding="utf-8", errors="replace"))
1472
-
1473
-
1474
- def _board_asset_response(
1475
- board_path: "typing.Union[Path, None]",
1476
- asset_path: str,
1477
- ) -> Response:
1478
- media_type, unused_encoding = mimetypes.guess_type(str(asset_path or ""))
1479
- del unused_encoding
1480
- if board_path is None or not media_type or not media_type.startswith("image/"):
1481
- raise HTTPException(status_code=404, detail="board image not found")
1482
-
1483
- resolved_asset = None
1484
- try:
1485
- board_directory = board_path.parent.resolve()
1486
- resolved_asset = (board_directory / asset_path).resolve()
1487
- within_board_directory = os.path.commonpath(
1488
- [str(board_directory), str(resolved_asset)]
1489
- ) == str(board_directory)
1490
- except (OSError, RuntimeError, ValueError):
1491
- within_board_directory = False
1492
-
1493
- if (
1494
- not within_board_directory
1495
- or resolved_asset is None
1496
- or not resolved_asset.is_file()
1497
- ):
1498
- raise HTTPException(status_code=404, detail="board image not found")
1499
-
1500
- return FileResponse(
1501
- str(resolved_asset),
1502
- media_type=media_type,
1503
- headers={
1504
- "Cache-Control": "no-cache",
1505
- "X-Content-Type-Options": "nosniff",
1506
- },
1507
- )
1508
-
1509
-
1510
- def _workspace_entry_or_404(
1511
- registry: "WorkspaceRegistry",
1512
- workspace_id: str,
1513
- ) -> "WorkspaceEntry":
1514
- try:
1515
- return registry.get(workspace_id)
1516
- except (KeyError, ValueError):
1517
- raise HTTPException(status_code=404, detail="workspace not found")
1518
-
1519
-
1520
- def run_serve_cli(args: "argparse.Namespace") -> int:
1521
- from .owned_process import run_owned_server
1522
-
1523
- host, port = parse_listen(args.listen)
1524
- configure_loguru()
1525
-
1526
- definitions = load_workspace_definitions(args.workspace_config)
1527
- entries = [_build_workspace_entry(definition, args) for definition in definitions]
1528
- registry = WorkspaceRegistry(
1529
- entries,
1530
- config_path=args.workspace_config,
1531
- entry_factory=lambda definition, persist_callback: _build_workspace_entry(
1532
- definition,
1533
- args,
1534
- persist_callback,
1535
- ),
1536
- )
1537
- app = create_multi_workspace_app(registry, password=args.password)
1538
-
1539
- print(
1540
- "pycodex workspace listening on http://{0}:{1}".format(host, port),
1541
- flush=True,
1542
- )
1543
- for definition in definitions:
1544
- print(
1545
- "workspace {0}: board={1} work_dir={2} url=http://{3}:{4}/w/{0}/".format(
1546
- definition.workspace_id,
1547
- definition.board_path or "",
1548
- definition.work_dir,
1549
- host,
1550
- port,
1551
- ),
1552
- flush=True,
1553
- )
1554
- run_owned_server(app, host, port, sys.stdin.fileno())
1555
- return 0
1556
-
1557
-
1558
- def _build_workspace_entry(
1559
- definition: "WorkspaceDefinition",
1560
- args: "argparse.Namespace",
1561
- persist_callback: "typing.Union[typing.Callable[[], None], None]" = None,
1562
- ) -> "WorkspaceEntry":
1563
- def build_session() -> "WorkspaceInteractiveSession":
1564
- model = build_model(
1565
- config_path=args.config,
1566
- profile=args.profile,
1567
- timeout_seconds=args.timeout_seconds,
1568
- vllm_endpoint=args.vllm_endpoint,
1569
- use_chat_completion=args.use_chat_completion or None,
1570
- use_messages=args.use_messages,
1571
- )
1572
- agent = build_agent(
1573
- model,
1574
- config_path=args.config,
1575
- profile=args.profile,
1576
- system_prompt=args.system_prompt,
1577
- extra_contextual_user_messages=(
1578
- [_board_context_text(definition.board_path, definition.work_dir)]
1579
- if definition.board_path is not None
1580
- else []
1581
- ),
1582
- cwd=definition.work_dir,
1583
- toolset=args.toolset,
1584
- )
1585
- return WorkspaceInteractiveSession(
1586
- build_runtime(agent),
1587
- config_path=args.config,
1588
- )
1589
-
1590
- def session_factory() -> "ThreadedWorkspaceInteractiveSession":
1591
- return ThreadedWorkspaceInteractiveSession(
1592
- build_session, asyncio.get_running_loop()
1593
- )
1594
-
1595
- return WorkspaceEntry(
1596
- definition=definition,
1597
- manager=WorkspaceSessionManager(
1598
- session_factory,
1599
- definition.board_path,
1600
- persist_callback=persist_callback,
1601
- ),
1602
- )
1603
-
1604
-
1605
- def _board_context_text(
1606
- board_path: Path,
1607
- work_dir: "typing.Union[Path, None]" = None,
1608
- ) -> str:
1609
- return (
1610
- "Current workspace board file: {0}. "
1611
- "Changes you make to this file are shown to the user in real time. "
1612
- "You can create or modify this file anytime."
1613
- ).format(_format_board_path_for_prompt(board_path, work_dir=work_dir))
1614
-
1615
-
1616
- def _format_board_path_for_prompt(
1617
- board_path: Path,
1618
- work_dir: "typing.Union[Path, None]" = None,
1619
- ) -> str:
1620
- resolved = board_path.resolve()
1621
- try:
1622
- relative = os.path.relpath(
1623
- str(resolved),
1624
- str(Path(work_dir or Path.cwd()).resolve()),
1625
- )
1626
- except ValueError:
1627
- return str(resolved)
1628
- if relative == ".":
1629
- return "."
1630
- if relative.startswith(".."):
1631
- return str(resolved)
1632
- return "./{0}".format(relative)
1633
-
1634
-
1635
- def _render_workspace_shell(
1636
- board_path: "typing.Union[Path, None]",
1637
- title: "typing.Union[str, None]" = None,
1638
- work_dir: "typing.Union[Path, None]" = None,
1639
- ) -> str:
1640
- board_label = str(board_path) if board_path is not None else "No board"
1641
- cwd_label = str(work_dir or Path.cwd())
1642
- page_title = str(title or "pycodex workspace")
1643
- template = (Path(__file__).with_name("workspace.html")).read_text(encoding="utf-8")
1644
- return (
1645
- template.replace("__WORKSPACE_TITLE__", html.escape(page_title))
1646
- .replace("__BOARD_LABEL__", html.escape(board_label))
1647
- .replace("__CWD_LABEL__", html.escape(cwd_label))
1648
- )
1649
-
1650
-
1651
- def _render_workspaces_manager_shell() -> str:
1652
- return (Path(__file__).with_name("workspaces.html")).read_text(encoding="utf-8")
1653
-
1654
-
1655
- def _render_empty_board() -> str:
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
- )
1661
-
1662
-
1663
- def _render_missing_board(board_path: Path) -> str:
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:
1676
- return """<!doctype html>
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
- )
1702
-
1703
-
1704
- def main(argv: "typing.Union[typing.Sequence[str], None]" = None) -> int:
1705
- parser = build_parser()
1706
- args = parser.parse_args(argv)
1707
- try:
1708
- return run_serve_cli(args)
1709
- except ValueError as exc:
1710
- parser.error(str(exc))
1711
- except KeyboardInterrupt:
1712
- return 130
1713
- return 0