python-codex 0.3.3__py3-none-any.whl → 0.4.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.
Files changed (61) 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 +2 -2
  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/utils/async_bridge.py +3 -4
  32. pycodex/{compat.py → utils/compat.py} +2 -0
  33. pycodex/utils/crypto.py +35 -0
  34. pycodex/utils/debug.py +24 -0
  35. pycodex/{doctor.py → utils/doctor.py} +3 -3
  36. pycodex/utils/image_utils.py +3 -2
  37. pycodex/{model_metadata.py → utils/model_metadata.py} +2 -2
  38. pycodex/utils/session_persist.py +28 -0
  39. {python_codex-0.3.3.dist-info → python_codex-0.4.1.dist-info}/METADATA +124 -42
  40. python_codex-0.4.1.dist-info/RECORD +90 -0
  41. {python_codex-0.3.3.dist-info → python_codex-0.4.1.dist-info}/entry_points.txt +0 -1
  42. responses_server/app.py +28 -15
  43. pycodex/feishu_card.py +0 -793
  44. pycodex/feishu_link.py +0 -440
  45. pycodex/tools/ipython_tool.py +0 -142
  46. python_codex-0.3.3.dist-info/RECORD +0 -98
  47. workspace_server/__init__.py +0 -39
  48. workspace_server/__main__.py +0 -4
  49. workspace_server/app.py +0 -1906
  50. workspace_server/content_views.LICENSE.txt +0 -470
  51. workspace_server/content_views.css +0 -1
  52. workspace_server/content_views.mjs +0 -25
  53. workspace_server/editor.css +0 -1
  54. workspace_server/editor.mjs +0 -1260
  55. workspace_server/owned_process.py +0 -27
  56. workspace_server/workspace.html +0 -2267
  57. workspace_server/workspace_views.py +0 -533
  58. workspace_server/workspaces.html +0 -550
  59. workspace_server/workspaces.py +0 -632
  60. {python_codex-0.3.3.dist-info → python_codex-0.4.1.dist-info}/WHEEL +0 -0
  61. {python_codex-0.3.3.dist-info → python_codex-0.4.1.dist-info}/licenses/LICENSE +0 -0
workspace_server/app.py DELETED
@@ -1,1906 +0,0 @@
1
- import argparse
2
- import asyncio
3
- import gzip
4
- import hashlib
5
- import html
6
- import json
7
- import mimetypes
8
- import os
9
- import secrets
10
- import subprocess
11
- import sys
12
- import threading
13
- from dataclasses import asdict, fields, is_dataclass
14
-
15
- try:
16
- from contextlib import asynccontextmanager
17
- except ImportError: # pragma: no cover - Python 3.6 compatibility
18
- asynccontextmanager = None
19
- import typing
20
- from pathlib import Path
21
- from urllib.parse import urlencode
22
-
23
- from fastapi import FastAPI, HTTPException, Request, WebSocket, WebSocketDisconnect
24
- from fastapi.responses import (
25
- FileResponse,
26
- HTMLResponse,
27
- JSONResponse,
28
- RedirectResponse,
29
- Response,
30
- )
31
-
32
- from pycodex.bootstrap import build_agent, build_model, build_runtime, configure_loguru
33
- from pycodex.events import (
34
- IDLE_SLEEPING_STATUS,
35
- AssistantDeltaEvent,
36
- AutoCompactCompletedEvent,
37
- AutoCompactFailedEvent,
38
- AutoCompactStartedEvent,
39
- CommandCompletedEvent,
40
- CommandFailedEvent,
41
- CompactCompletedEvent,
42
- CompactFailedEvent,
43
- CompactStartedEvent,
44
- Event,
45
- InputQueuedEvent,
46
- InputRequestedEvent,
47
- InputResolvedEvent,
48
- SessionClosedEvent,
49
- SessionStateEvent,
50
- StreamErrorEvent,
51
- TerminalEvent,
52
- TokenCountEvent,
53
- ToolCalledEvent,
54
- ToolCompletedEvent,
55
- ToolStartedEvent,
56
- TurnCompletedEvent,
57
- TurnEvent,
58
- TurnFailedEvent,
59
- TurnInterruptedEvent,
60
- TurnStartedEvent,
61
- )
62
- from pycodex.model import DEFAULT_CODEX_CONFIG_PATH
63
- from pycodex.utils import uuid7_string
64
- from pycodex.utils.event_helpers import (
65
- completed_history,
66
- shorten_title,
67
- )
68
-
69
- from .workspace_views import WorkspaceViewOpener, WorkspaceViewStore
70
- from .workspaces import (
71
- WorkspaceDefinition,
72
- WorkspaceEntry,
73
- WorkspaceRegistry,
74
- WorkspaceSessionManager,
75
- load_workspace_definitions,
76
- session_snapshot,
77
- )
78
-
79
- JSONValue = typing.Union[
80
- None,
81
- bool,
82
- int,
83
- float,
84
- str,
85
- typing.List["JSONValue"],
86
- typing.Dict[str, "JSONValue"],
87
- ]
88
-
89
-
90
- def build_parser() -> "argparse.ArgumentParser":
91
- parser = argparse.ArgumentParser(
92
- prog="pycodex-ws",
93
- description="Run a local pycodex workspace with a board and chat session.",
94
- epilog="Stdin EOF gracefully shuts down the server after accepted work finishes.",
95
- )
96
- parser.add_argument(
97
- "--listen",
98
- default="127.0.0.1:6007",
99
- help="Bind address as host:port, for example 0.0.0.0:6007.",
100
- )
101
- parser.add_argument(
102
- "--workspace-config",
103
- default="./workspaces.json",
104
- help=(
105
- "Optional JSON file listing workspaces. Each entry needs `id`, "
106
- "`board`, and `work_dir`; routes are served under /w/<id>/."
107
- ),
108
- )
109
- parser.add_argument(
110
- "--password",
111
- default=None,
112
- help="Optional password required to open the workspace server.",
113
- )
114
- parser.add_argument(
115
- "--config",
116
- default=str(DEFAULT_CODEX_CONFIG_PATH),
117
- help="Path to Codex config.toml.",
118
- )
119
- parser.add_argument("--profile", default=None, help="Optional profile name.")
120
- parser.add_argument(
121
- "--system-prompt",
122
- default=None,
123
- help="Optional base instructions override passed to the model.",
124
- )
125
- parser.add_argument(
126
- "--toolset",
127
- nargs="*",
128
- default=None,
129
- help="Builtin tool names for all sessions. An empty list disables all tools.",
130
- )
131
- parser.add_argument(
132
- "--timeout-seconds",
133
- type=float,
134
- default=120.0,
135
- help="HTTP timeout for one model call.",
136
- )
137
- parser.add_argument(
138
- "--vllm-endpoint",
139
- default=None,
140
- help="Optional base URL for a chat-completions-backed vLLM server.",
141
- )
142
- parser.add_argument(
143
- "--use-chat-completion",
144
- default=False,
145
- action="store_true",
146
- help="Start a local responses compat server for this session.",
147
- )
148
- parser.add_argument(
149
- "--use-messages",
150
- default=False,
151
- action="store_true",
152
- help="Route through a downstream /v1/messages backend.",
153
- )
154
- return parser
155
-
156
-
157
- def parse_listen(target: str) -> "typing.Tuple[str, int]":
158
- target_text = str(target or "").strip() or "127.0.0.1:6007"
159
- if ":" not in target_text:
160
- raise ValueError("workspace listen target must look like host:port")
161
- host, port_text = target_text.rsplit(":", 1)
162
- host = host.strip() or "127.0.0.1"
163
- try:
164
- port = int(port_text)
165
- except ValueError as exc:
166
- raise ValueError("workspace port must be an integer") from exc
167
- return host, port
168
-
169
-
170
- SessionFactory = typing.Callable[[], object]
171
- ThreadedSessionFactory = typing.Callable[[], "WorkspaceInteractiveSession"]
172
- SPINNER_STATUS_PREVIEW_LIMIT = 180
173
- AUTH_COOKIE_NAME = "pycodex_ws_auth"
174
-
175
-
176
- class WebSessionView:
177
- def __init__(self) -> None:
178
- self._subscribers: "typing.Set[asyncio.Queue]" = set()
179
- self._events: "typing.List[typing.Dict[str, object]]" = []
180
- self._turns: "typing.List[typing.Dict[str, object]]" = []
181
- self._turns_by_submission_id: "typing.Dict[str, typing.Dict[str, object]]" = {}
182
- self._turns_by_turn_id: "typing.Dict[str, typing.Dict[str, object]]" = {}
183
- self._title = ""
184
- self._model = "pycodex"
185
- self._rollout_path = ""
186
- self._recorded_rollout_path = ""
187
- self._input_request = None
188
- self._accepts_input = True
189
- self._spinner_status = ""
190
- self._stream_buffer = ""
191
- self._max_context_window: "typing.Union[int, None]" = None
192
- self._auto_compact_token_limit: "typing.Union[int, None]" = None
193
- self._usage_tokens: "typing.Union[int, None]" = None
194
- self._server_loop: "typing.Union[asyncio.AbstractEventLoop, None]" = None
195
- self._lock = threading.RLock()
196
-
197
- def attach_server_loop(self, loop: "asyncio.AbstractEventLoop") -> None:
198
- self._server_loop = loop
199
-
200
- def handle_event(self, event: "Event") -> None:
201
- with self._lock:
202
- self._apply_runtime_event(event)
203
- payload = _event_data(event)
204
- payload.update({"type": "event", "snapshot": self.snapshot()})
205
- if isinstance(event, ToolCompletedEvent):
206
- payload["summary"] = event.visualize()
207
- self._publish_nowait(payload)
208
-
209
- def finish_stream(self) -> None:
210
- with self._lock:
211
- if not self._stream_buffer:
212
- return
213
- active_turn = self._last_active_turn()
214
- if active_turn is not None and not active_turn.get("response"):
215
- active_turn["response"] = self._stream_buffer
216
- active_turn["thinking"] = ""
217
- active_turn["_thinking_active"] = False
218
- self._stream_buffer = ""
219
- event = {"type": "snapshot", "snapshot": self.snapshot()}
220
- self._publish_nowait(event)
221
-
222
- def write_line(self, text: str) -> None:
223
- with self._lock:
224
- text = str(text or "")
225
- turn = self._new_control_turn(text)
226
- turn["response"] = text
227
- turn["status"] = "completed"
228
- event = {"type": "snapshot", "snapshot": self.snapshot()}
229
- self._publish_nowait(event)
230
-
231
- def show_error(self, text: str) -> None:
232
- self.finish_stream()
233
- with self._lock:
234
- turn = self._new_control_turn("")
235
- turn["error"] = str(text or "")
236
- turn["status"] = "error"
237
- event = {"type": "snapshot", "snapshot": self.snapshot()}
238
- self._publish_nowait(event)
239
-
240
- def set_session_title(self, title: str) -> None:
241
- with self._lock:
242
- self._set_title(title)
243
- event = {
244
- "type": "title_changed",
245
- "title": self._title,
246
- "snapshot": self.snapshot(),
247
- }
248
- self._publish_nowait(event)
249
-
250
- def load_session_history(
251
- self,
252
- title: "typing.Union[str, None]",
253
- history: "typing.Iterable[typing.Tuple[str, str]]",
254
- ) -> None:
255
- self.finish_stream()
256
- with self._lock:
257
- self._set_title(title)
258
- self._turns = []
259
- self._turns_by_submission_id = {}
260
- self._turns_by_turn_id = {}
261
- self._events = []
262
- for prompt, response in history:
263
- submission_id = uuid7_string()
264
- turn = self._ensure_turn(
265
- submission_id, submission_id, str(prompt or "")
266
- )
267
- turn["response"] = str(response or "")
268
- turn["status"] = "completed"
269
- turn["queue"] = "history"
270
- turn["sender"] = "resume"
271
- event = {"type": "snapshot", "snapshot": self.snapshot()}
272
- self._publish_nowait(event)
273
-
274
- def subscribe(self) -> "asyncio.Queue":
275
- queue: "asyncio.Queue" = asyncio.Queue()
276
- with self._lock:
277
- self._subscribers.add(queue)
278
- event = {
279
- "type": "hello",
280
- "events": list(self._events[-200:]),
281
- "snapshot": self.snapshot(),
282
- }
283
- queue.put_nowait(event)
284
- return queue
285
-
286
- def unsubscribe(self, queue: "asyncio.Queue") -> None:
287
- with self._lock:
288
- self._subscribers.discard(queue)
289
-
290
- def close(self) -> None:
291
- with self._lock:
292
- subscribers = tuple(self._subscribers)
293
- self._subscribers.clear()
294
- self._publish_to_queues(subscribers, None)
295
-
296
- def snapshot(self) -> "typing.Dict[str, object]":
297
- with self._lock:
298
- return {
299
- "running": bool(self._spinner_status),
300
- "status": self._spinner_status,
301
- "status_kind": "spinner" if self._spinner_status else "idle",
302
- "spinner": self._spinner_status,
303
- "model": self._model,
304
- "rollout_path": self._rollout_path,
305
- "recorded_rollout_path": self._recorded_rollout_path,
306
- "input_request": _json_safe(self._input_request),
307
- "queued_inputs": [
308
- {"queue": turn["queue"], "prompt": turn["prompt"]}
309
- for turn in self._turns_by_submission_id.values()
310
- if not turn["turn_id"]
311
- ],
312
- "accepts_input": self._accepts_input,
313
- "title": self._title,
314
- **self._context_usage(),
315
- "turns": [_public_turn(turn) for turn in self._turns[-80:]],
316
- }
317
-
318
- def summary(self) -> "typing.Dict[str, object]":
319
- with self._lock:
320
- return {
321
- "model": self._model,
322
- "running": bool(self._spinner_status),
323
- "spinner": self._spinner_status,
324
- "title": self._title,
325
- "turn_count": len(self._turns),
326
- "last_assistant": _last_assistant_text(self._turns),
327
- **self._context_usage(),
328
- }
329
-
330
- def _apply_runtime_event(self, event: "Event") -> None:
331
- if isinstance(event, SessionStateEvent):
332
- state = event.state
333
- self._model = state["model"]
334
- self._rollout_path = state["rollout_path"]
335
- self._recorded_rollout_path = state["recorded_rollout_path"]
336
- self._input_request = state["input_request"]
337
- self._accepts_input = state["accepts_input"]
338
- self._max_context_window = state["max_context_window"]
339
- self._auto_compact_token_limit = state["auto_compact_token_limit"]
340
- self._usage_tokens = state["usage_tokens"]
341
- if event.reason in {"attach", "history"}:
342
- self.load_session_history(state["title"], completed_history(state))
343
- active = state["active_turn"]
344
- if active is not None:
345
- self._apply_runtime_event(
346
- TurnStartedEvent(
347
- active["turn_id"],
348
- tuple(active["user_texts"]),
349
- active["submission_id"],
350
- )
351
- )
352
- self._apply_runtime_event(
353
- AssistantDeltaEvent(
354
- active["assistant_text"],
355
- active["turn_id"],
356
- active["submission_id"],
357
- )
358
- )
359
- elif event.reason == "title":
360
- self.set_session_title(state["title"])
361
- else:
362
- self._set_title(state["title"])
363
- return
364
- if isinstance(event, CommandCompletedEvent):
365
- if event.result["kind"] in {"title_changed", "resumed"}:
366
- return
367
- message = event.visualize()
368
- if message:
369
- self.write_line(message)
370
- return
371
- if isinstance(event, CommandFailedEvent):
372
- self.show_error(event.visualize())
373
- return
374
- if isinstance(event, InputRequestedEvent):
375
- self._input_request = event
376
- return
377
- if isinstance(event, InputResolvedEvent):
378
- self._input_request = None
379
- return
380
- if isinstance(event, SessionClosedEvent):
381
- self._accepts_input = False
382
- self._spinner_status = ""
383
- return
384
- if isinstance(event, InputQueuedEvent):
385
- turn = self._ensure_turn(event.submission_id, "", "")
386
- turn["prompt"] += ("\n" if turn["prompt"] else "") + event.prompt
387
- turn["queue"] = event.queue
388
- turn["sender"] = event.sender
389
- return
390
- if isinstance(event, TokenCountEvent):
391
- self._usage_tokens = int(event.usage["total_tokens"])
392
- return
393
- if isinstance(event, (AutoCompactCompletedEvent, CompactCompletedEvent)):
394
- self._usage_tokens = None
395
- if not isinstance(event, TurnEvent):
396
- return
397
- turn_id = event.turn_id
398
- submission_id = event.submission_id or turn_id
399
- turn = self._turns_by_submission_id.get(submission_id)
400
-
401
- if isinstance(event, TurnStartedEvent):
402
- self._set_spinner_status(event.kind)
403
- turn = self._ensure_turn(submission_id, turn_id, event.visualize())
404
- turn["status"] = "running"
405
- turn["thinking"] = ""
406
- turn["_thinking_active"] = False
407
- turn["error"] = ""
408
- return
409
-
410
- self._apply_spinner_event(event)
411
- if turn is None:
412
- return
413
-
414
- if isinstance(event, AssistantDeltaEvent):
415
- turn["status"] = "responding"
416
- delta = event.visualize()
417
- self._stream_buffer += delta
418
- if turn.get("_thinking_active"):
419
- turn["thinking"] = str(turn.get("thinking") or "") + delta
420
- else:
421
- turn["thinking"] = delta
422
- turn["_thinking_active"] = True
423
- return
424
-
425
- if isinstance(event, ToolStartedEvent):
426
- turn["status"] = "tool"
427
- turn["tool_name"] = event.call.name
428
- turn["_thinking_active"] = False
429
- return
430
-
431
- if isinstance(event, ToolCompletedEvent):
432
- turn["_thinking_active"] = False
433
- turn["status"] = "running"
434
- return
435
-
436
- if isinstance(event, TurnCompletedEvent):
437
- response = event.visualize()
438
- if response:
439
- turn["response"] = response
440
- elif turn.get("thinking"):
441
- turn["response"] = str(turn.get("thinking") or "")
442
- turn["thinking"] = ""
443
- turn["_thinking_active"] = False
444
- turn["status"] = "completed"
445
- self._stream_buffer = ""
446
- return
447
-
448
- if isinstance(event, TurnFailedEvent):
449
- turn["status"] = "error"
450
- turn["error"] = event.visualize()
451
- self._stream_buffer = ""
452
- return
453
-
454
- if isinstance(event, TurnInterruptedEvent):
455
- if event.output_text:
456
- turn["response"] = event.output_text
457
- elif turn.get("thinking") and not turn.get("response"):
458
- turn["response"] = str(turn.get("thinking") or "")
459
- turn["thinking"] = ""
460
- turn["_thinking_active"] = False
461
- turn["status"] = "interrupted"
462
- self._stream_buffer = ""
463
-
464
- def _context_usage(self) -> "typing.Dict[str, object]":
465
- limit = self._auto_compact_token_limit
466
- if limit is None:
467
- limit = self._max_context_window
468
- if limit is None:
469
- remaining_percent = None
470
- elif self._usage_tokens is None:
471
- remaining_percent = 100
472
- elif self._usage_tokens >= limit:
473
- remaining_percent = 0
474
- else:
475
- # Round up so zero means the actual threshold has been reached.
476
- remaining_percent = min(
477
- 100, ((limit - self._usage_tokens) * 100 + limit - 1) // limit
478
- )
479
- return {
480
- "usage_tokens": self._usage_tokens,
481
- "auto_compact_token_limit": self._auto_compact_token_limit,
482
- "max_context_window": self._max_context_window,
483
- "context_remaining_percent": remaining_percent,
484
- }
485
-
486
- def _apply_spinner_event(self, event: "TurnEvent") -> None:
487
- if isinstance(event, AssistantDeltaEvent):
488
- self._set_spinner_status("talking")
489
- return
490
- if isinstance(event, StreamErrorEvent):
491
- self._set_spinner_status("reconnecting")
492
- return
493
- if isinstance(event, (AutoCompactStartedEvent, CompactStartedEvent)):
494
- self._set_spinner_status("compacting")
495
- return
496
- if isinstance(event, AutoCompactCompletedEvent):
497
- self._set_spinner_status("compacted")
498
- return
499
- if isinstance(event, ToolStartedEvent):
500
- self._set_spinner_status(
501
- shorten_title(
502
- event.visualize(),
503
- limit=SPINNER_STATUS_PREVIEW_LIMIT,
504
- )
505
- )
506
- return
507
- if isinstance(event, ToolCompletedEvent):
508
- self._set_spinner_status("called {0}".format(event.call.name))
509
- return
510
- if isinstance(event, TerminalEvent):
511
- self._set_idle_spinner_status(event)
512
-
513
- def _set_spinner_status(self, text: "typing.Union[str, None]") -> None:
514
- self._spinner_status = str(text or "").strip()
515
-
516
- def _set_idle_spinner_status(self, event: "TerminalEvent") -> None:
517
- if (event.background_work_count or 0) > 0:
518
- self._set_spinner_status(IDLE_SLEEPING_STATUS)
519
- else:
520
- self._set_spinner_status("")
521
-
522
- def _ensure_turn(
523
- self,
524
- submission_id: str,
525
- turn_id: str,
526
- prompt: str,
527
- ) -> "typing.Dict[str, object]":
528
- submission_id = str(submission_id or "").strip()
529
- turn_id = str(turn_id or "").strip()
530
- turn = self._turns_by_submission_id.get(submission_id)
531
- if turn is None and turn_id and not submission_id:
532
- turn = self._turns_by_turn_id.get(turn_id)
533
- if turn is None:
534
- turn = {
535
- "submission_id": submission_id,
536
- "turn_id": "",
537
- "prompt": prompt,
538
- "response": "",
539
- "thinking": "",
540
- "_thinking_active": False,
541
- "status": "queued",
542
- "error": "",
543
- "kind": "assistant",
544
- "queue": "steer",
545
- "sender": "web",
546
- }
547
- if submission_id:
548
- turn["submission_id"] = submission_id
549
- self._turns_by_submission_id[submission_id] = turn
550
- if turn_id:
551
- if not turn["turn_id"]:
552
- # Queue admission stays hidden until the turn actually starts.
553
- self._turns.append(turn)
554
- turn["turn_id"] = turn_id
555
- self._turns_by_turn_id[turn_id] = turn
556
- if prompt:
557
- turn["prompt"] = prompt
558
- return turn
559
-
560
- def _new_control_turn(self, text: str) -> "typing.Dict[str, object]":
561
- submission_id = uuid7_string()
562
- turn = self._ensure_turn(submission_id, submission_id, "")
563
- turn["kind"] = "control"
564
- turn["queue"] = "control"
565
- turn["sender"] = "web"
566
- turn["status"] = "running"
567
- turn["error"] = ""
568
- turn["response"] = ""
569
- turn["thinking"] = ""
570
- turn["prompt"] = ""
571
- return turn
572
-
573
- def _set_title(self, title: "typing.Union[str, None]") -> None:
574
- self._title = str(title or "").strip()
575
-
576
- def _last_active_turn(self) -> "typing.Union[typing.Dict[str, object], None]":
577
- for turn in reversed(self._turns):
578
- if turn.get("kind") != "control" and turn.get("status") not in {
579
- "completed",
580
- "error",
581
- "interrupted",
582
- }:
583
- return turn
584
- return None
585
-
586
- def _publish_nowait(self, event: "typing.Dict[str, object]") -> None:
587
- with self._lock:
588
- self._events.append(event)
589
- if len(self._events) > 500:
590
- del self._events[:-500]
591
- subscribers = tuple(self._subscribers)
592
- self._publish_to_queues(subscribers, event)
593
-
594
- def _publish_to_queues(
595
- self,
596
- queues: "typing.Iterable[asyncio.Queue]",
597
- event: "typing.Union[typing.Dict[str, object], None]",
598
- ) -> None:
599
- loop = self._server_loop
600
- if loop is None:
601
- for queue in queues:
602
- queue.put_nowait(event)
603
- return
604
-
605
- def publish() -> None:
606
- for queue in queues:
607
- queue.put_nowait(event)
608
-
609
- loop.call_soon_threadsafe(publish)
610
-
611
-
612
- class WorkspaceInteractiveSession:
613
- def __init__(
614
- self,
615
- runtime,
616
- config_path: "typing.Union[str, None]" = None,
617
- ) -> None:
618
- self.runtime = runtime
619
- self.config_path = config_path
620
- self.view = WebSessionView()
621
- self._frontend_id = None
622
-
623
- async def start(self) -> "WorkspaceInteractiveSession":
624
- await self.runtime.start(self.config_path)
625
- if self._frontend_id is None:
626
- self._frontend_id = self.runtime.attach(self.view.handle_event)
627
- return self
628
-
629
- async def close(self) -> None:
630
- try:
631
- await self.runtime.close()
632
- finally:
633
- self.detach()
634
-
635
- def detach(self):
636
- if self._frontend_id is not None:
637
- self.runtime.detach(self._frontend_id)
638
- self._frontend_id = None
639
- self.view.close()
640
-
641
- async def submit(
642
- self, prompt: str, sender: str = "web"
643
- ) -> "typing.Dict[str, object]":
644
- try:
645
- receipt = await self.runtime.submit_input(prompt, sender)
646
- except (ValueError, RuntimeError) as exc:
647
- return {"ok": False, "error": str(exc), "snapshot": self.snapshot()}
648
- return {
649
- "ok": True,
650
- "type": "submitted",
651
- "submission_id": receipt.submission_id,
652
- "snapshot": self.snapshot(),
653
- }
654
-
655
- async def answer_input(self, request_id, answer):
656
- try:
657
- self.runtime.answer_input(request_id, answer)
658
- except ValueError as exc:
659
- return {"ok": False, "error": str(exc), "snapshot": self.snapshot()}
660
- return {"ok": True, "type": "answered", "snapshot": self.snapshot()}
661
-
662
- def subscribe(self) -> "asyncio.Queue":
663
- return self.view.subscribe()
664
-
665
- def unsubscribe(self, queue: "asyncio.Queue") -> None:
666
- self.view.unsubscribe(queue)
667
-
668
- def snapshot(self) -> "typing.Dict[str, object]":
669
- return self.view.snapshot()
670
-
671
- def summary(self) -> "typing.Dict[str, object]":
672
- return self.view.summary()
673
-
674
- def rollout_path(self) -> str:
675
- return self.view.snapshot()["rollout_path"]
676
-
677
- async def restore_from_rollout(
678
- self, rollout_path: str, title: str = "", fork: bool = False
679
- ) -> None:
680
- if rollout_path:
681
- self.runtime.resume(rollout_path, title)
682
- if fork:
683
- self.runtime.fork()
684
- else:
685
- self.runtime.set_title(title)
686
-
687
-
688
- class ThreadedWorkspaceInteractiveSession:
689
- def __init__(
690
- self,
691
- session_factory: "ThreadedSessionFactory",
692
- server_loop: "asyncio.AbstractEventLoop",
693
- ) -> None:
694
- self._session_factory = session_factory
695
- self._server_loop = server_loop
696
- self._view = WebSessionView()
697
- self._view.attach_server_loop(server_loop)
698
- self._thread: "typing.Union[threading.Thread, None]" = None
699
- self._worker_loop: "typing.Union[asyncio.AbstractEventLoop, None]" = None
700
- self._ready = threading.Event()
701
- self._startup_error: "typing.Union[BaseException, None]" = None
702
- self._session: "typing.Union[WorkspaceInteractiveSession, None]" = None
703
-
704
- async def start(self) -> "ThreadedWorkspaceInteractiveSession":
705
- if self._thread is not None:
706
- return self
707
- self._thread = threading.Thread(
708
- target=self._thread_main,
709
- name="pycodex-workspace-session",
710
- daemon=True,
711
- )
712
- self._thread.start()
713
- await asyncio.to_thread(self._ready.wait)
714
- if self._startup_error is not None:
715
- raise RuntimeError(
716
- "workspace session thread failed to start"
717
- ) from self._startup_error
718
- return self
719
-
720
- def _thread_main(self) -> None:
721
- loop = asyncio.new_event_loop()
722
- self._worker_loop = loop
723
- asyncio.set_event_loop(loop)
724
- try:
725
- session = self._session_factory()
726
- session.view = self._view
727
- self._session = session
728
- try:
729
- loop.run_until_complete(session.start())
730
- except BaseException:
731
- loop.run_until_complete(session.close())
732
- raise
733
- self._ready.set()
734
- loop.run_forever()
735
- except BaseException as exc:
736
- self._startup_error = exc
737
- self._ready.set()
738
- finally:
739
- pending = asyncio.all_tasks(loop)
740
- for task in pending:
741
- task.cancel()
742
- if pending:
743
- loop.run_until_complete(
744
- asyncio.gather(*pending, return_exceptions=True)
745
- )
746
- asyncio.set_event_loop(None)
747
- loop.close()
748
-
749
- async def close(self) -> None:
750
- session = self._session
751
- loop = self._worker_loop
752
- try:
753
- if session is not None and loop is not None and loop.is_running():
754
- future = asyncio.wrap_future(
755
- asyncio.run_coroutine_threadsafe(session.close(), loop)
756
- )
757
- try:
758
- await asyncio.shield(future)
759
- except asyncio.CancelledError:
760
- await future
761
- raise
762
- finally:
763
- if loop is not None and loop.is_running():
764
- loop.call_soon_threadsafe(loop.stop)
765
- thread = self._thread
766
- if thread is not None:
767
- await asyncio.to_thread(thread.join)
768
- self._thread = None
769
-
770
- async def submit(
771
- self, prompt: str, sender: str = "web"
772
- ) -> "typing.Dict[str, object]":
773
- future = asyncio.run_coroutine_threadsafe(
774
- self._session.submit(prompt, sender),
775
- self._worker_loop,
776
- )
777
- return await asyncio.wrap_future(future)
778
-
779
- async def answer_input(self, request_id, answer):
780
- future = asyncio.run_coroutine_threadsafe(
781
- self._session.answer_input(request_id, answer),
782
- self._worker_loop,
783
- )
784
- return await asyncio.wrap_future(future)
785
-
786
- def subscribe(self) -> "asyncio.Queue":
787
- return self._view.subscribe()
788
-
789
- def unsubscribe(self, queue: "asyncio.Queue") -> None:
790
- self._view.unsubscribe(queue)
791
-
792
- def snapshot(self) -> "typing.Dict[str, object]":
793
- return self._view.snapshot()
794
-
795
- def summary(self) -> "typing.Dict[str, object]":
796
- return self._view.summary()
797
-
798
- def rollout_path(self) -> str:
799
- if self._session is None:
800
- return ""
801
- return self._session.rollout_path()
802
-
803
- async def restore_from_rollout(
804
- self, rollout_path: str, title: str = "", fork: bool = False
805
- ) -> None:
806
- session = self._session
807
- loop = self._worker_loop
808
- if session is None or loop is None:
809
- return
810
-
811
- future = asyncio.run_coroutine_threadsafe(
812
- session.restore_from_rollout(rollout_path, title=title, fork=fork),
813
- loop,
814
- )
815
- await asyncio.wrap_future(future)
816
-
817
-
818
- def create_app(
819
- session_source: "typing.Union[WorkspaceSessionManager, SessionFactory]",
820
- board_path: "typing.Union[Path, None]",
821
- password: "typing.Union[str, None]" = None,
822
- work_dir: "typing.Union[Path, None]" = None,
823
- ) -> FastAPI:
824
- manager = (
825
- session_source
826
- if isinstance(session_source, WorkspaceSessionManager)
827
- else WorkspaceSessionManager(session_source, board_path)
828
- )
829
- app = _create_lifespan_app(manager.start, manager.close)
830
- auth_token = _install_auth(app, password)
831
- _install_workspace_routes(app, manager, board_path, Path(work_dir or Path.cwd()).resolve())
832
- app.state.workspace_auth_token = auth_token
833
- return app
834
-
835
-
836
- def create_multi_workspace_app(
837
- registry: "WorkspaceRegistry",
838
- password: "typing.Union[str, None]" = None,
839
- ) -> FastAPI:
840
- app = _create_lifespan_app(registry.start, registry.close)
841
- auth_token = _install_auth(app, password)
842
- view_asset_cache = {}
843
-
844
- @app.get("/")
845
- async def index() -> Response:
846
- return _html_response(_render_workspaces_manager_shell())
847
-
848
- @app.get("/favicon.ico")
849
- async def favicon() -> Response:
850
- return Response(status_code=204)
851
-
852
- @app.get("/api/workspaces")
853
- async def workspaces() -> JSONResponse:
854
- return JSONResponse({"workspaces": registry.list_workspaces()})
855
-
856
- @app.post("/api/workspaces")
857
- async def add_workspace(payload: "typing.Dict[str, object]") -> JSONResponse:
858
- try:
859
- entry = await registry.add_workspace(
860
- str(payload.get("name") or ""),
861
- work_dir=str(payload.get("dir") or "./"),
862
- board=payload.get("board"),
863
- )
864
- except ValueError as exc:
865
- return JSONResponse({"ok": False, "error": str(exc)}, status_code=400)
866
- return JSONResponse(
867
- {
868
- "ok": True,
869
- "workspace": entry.to_dict(),
870
- "workspaces": registry.list_workspaces(),
871
- }
872
- )
873
-
874
- @app.delete("/api/workspaces/{workspace_id}")
875
- async def delete_workspace(workspace_id: str) -> JSONResponse:
876
- try:
877
- await registry.delete_workspace(workspace_id)
878
- except KeyError:
879
- raise HTTPException(status_code=404, detail="workspace not found")
880
- return JSONResponse({"ok": True, "workspaces": registry.list_workspaces()})
881
-
882
- @app.api_route("/w/{workspace_id}", methods=["GET", "HEAD"])
883
- async def workspace_index_redirect(workspace_id: str) -> RedirectResponse:
884
- _workspace_entry_or_404(registry, workspace_id)
885
- return RedirectResponse(url="/w/{0}/".format(workspace_id), status_code=307)
886
-
887
- @app.api_route("/w/{workspace_id}/", methods=["GET", "HEAD"])
888
- async def workspace_index(workspace_id: str) -> HTMLResponse:
889
- entry = _workspace_entry_or_404(registry, workspace_id)
890
- return _html_response(
891
- _render_workspace_shell(
892
- entry.definition.board_path,
893
- title=entry.definition.workspace_id,
894
- work_dir=entry.definition.work_dir,
895
- )
896
- )
897
-
898
- @app.api_route("/w/{workspace_id}/board", methods=["GET", "HEAD"])
899
- async def workspace_board(workspace_id: str) -> Response:
900
- entry = _workspace_entry_or_404(registry, workspace_id)
901
- return _board_response(entry.definition.board_path)
902
-
903
- @app.get("/w/{workspace_id}/api/board")
904
- async def workspace_board_status(workspace_id: str) -> JSONResponse:
905
- entry = _workspace_entry_or_404(registry, workspace_id)
906
- return _board_status_response(entry.definition.board_path)
907
-
908
- @app.get("/w/{workspace_id}/api/views")
909
- def workspace_views(workspace_id: str) -> JSONResponse:
910
- entry = _workspace_entry_or_404(registry, workspace_id)
911
- return _views_response(entry.manager)
912
-
913
- @app.post("/w/{workspace_id}/api/views/open")
914
- def workspace_open_view(
915
- workspace_id: str, payload: "typing.Dict[str, object]"
916
- ) -> JSONResponse:
917
- entry = _workspace_entry_or_404(registry, workspace_id)
918
- return _open_view_response(entry.manager, entry.definition.work_dir, payload)
919
-
920
- @app.get("/w/{workspace_id}/api/views/{view_id}")
921
- def workspace_view(workspace_id: str, view_id: str) -> JSONResponse:
922
- entry = _workspace_entry_or_404(registry, workspace_id)
923
- return _view_response(entry.manager, view_id)
924
-
925
- @app.delete("/w/{workspace_id}/api/views/{view_id}")
926
- def workspace_close_view(workspace_id: str, view_id: str) -> JSONResponse:
927
- entry = _workspace_entry_or_404(registry, workspace_id)
928
- return _close_view_response(entry.manager, view_id)
929
-
930
- @app.api_route(
931
- "/w/{workspace_id}/assets/content_views.mjs", methods=["GET", "HEAD"]
932
- )
933
- @app.api_route(
934
- "/w/{workspace_id}/assets/content_views.css", methods=["GET", "HEAD"]
935
- )
936
- @app.api_route(
937
- "/w/{workspace_id}/assets/content_views.LICENSE.txt", methods=["GET", "HEAD"]
938
- )
939
- @app.api_route("/w/{workspace_id}/assets/editor.mjs", methods=["GET", "HEAD"])
940
- @app.api_route("/w/{workspace_id}/assets/editor.css", methods=["GET", "HEAD"])
941
- def workspace_view_asset(workspace_id: str, request: Request) -> Response:
942
- _workspace_entry_or_404(registry, workspace_id)
943
- return _view_asset_response(
944
- request.url.path.rsplit("/", 1)[-1], request, view_asset_cache
945
- )
946
-
947
- @app.get("/w/{workspace_id}/ws/session")
948
- async def workspace_websocket_backend_hint(workspace_id: str) -> JSONResponse:
949
- _workspace_entry_or_404(registry, workspace_id)
950
- return _websocket_backend_hint_response()
951
-
952
- @app.get("/w/{workspace_id}/api/sessions")
953
- async def workspace_sessions(workspace_id: str) -> JSONResponse:
954
- entry = _workspace_entry_or_404(registry, workspace_id)
955
- return _sessions_response(entry.manager)
956
-
957
- @app.post("/w/{workspace_id}/api/sessions")
958
- async def workspace_new_session(workspace_id: str) -> JSONResponse:
959
- entry = _workspace_entry_or_404(registry, workspace_id)
960
- return await _new_session_response(entry.manager)
961
-
962
- @app.delete("/w/{workspace_id}/api/sessions/{session_id}")
963
- async def workspace_delete_session(
964
- workspace_id: str, session_id: str
965
- ) -> JSONResponse:
966
- entry = _workspace_entry_or_404(registry, workspace_id)
967
- return await _delete_session_response(entry.manager, session_id)
968
-
969
- @app.get("/w/{workspace_id}/api/session")
970
- async def workspace_session(
971
- workspace_id: str,
972
- session_id: "typing.Union[str, None]" = None,
973
- ) -> JSONResponse:
974
- entry = _workspace_entry_or_404(registry, workspace_id)
975
- return _session_response(entry.manager, session_id)
976
-
977
- @app.post("/w/{workspace_id}/api/session/message")
978
- async def workspace_message(
979
- workspace_id: str,
980
- payload: "typing.Dict[str, object]",
981
- ) -> JSONResponse:
982
- entry = _workspace_entry_or_404(registry, workspace_id)
983
- return await _message_response(entry.manager, payload)
984
-
985
- @app.websocket("/w/{workspace_id}/ws/session")
986
- async def workspace_websocket_session(
987
- workspace_id: str, websocket: WebSocket
988
- ) -> None:
989
- if not _auth_cookie_matches(
990
- auth_token, websocket.cookies.get(AUTH_COOKIE_NAME)
991
- ):
992
- await websocket.close(code=1008)
993
- return
994
- try:
995
- entry = registry.get(workspace_id)
996
- except (KeyError, ValueError):
997
- await websocket.close(code=1008)
998
- return
999
- await _websocket_session_handler(entry.manager, websocket)
1000
-
1001
- @app.api_route(
1002
- "/w/{workspace_id}/{asset_path:path}",
1003
- methods=["GET", "HEAD"],
1004
- )
1005
- async def workspace_board_asset(workspace_id: str, asset_path: str) -> Response:
1006
- entry = _workspace_entry_or_404(registry, workspace_id)
1007
- return _board_asset_response(entry.definition.board_path, asset_path)
1008
-
1009
- return app
1010
-
1011
-
1012
- def _install_auth(app: FastAPI, password: "typing.Union[str, None]") -> str:
1013
- password_text = str(password or "")
1014
- if not password_text:
1015
- return ""
1016
- token = secrets.token_urlsafe(32)
1017
-
1018
- @app.middleware("http")
1019
- async def auth_middleware(request: Request, call_next):
1020
- path = request.url.path
1021
- if path in {"/favicon.ico", "/login"} or _auth_cookie_matches(
1022
- token,
1023
- request.cookies.get(AUTH_COOKIE_NAME),
1024
- ):
1025
- return await call_next(request)
1026
- if path.startswith("/api/") or "/api/" in path:
1027
- return JSONResponse(
1028
- {"ok": False, "error": "authentication required"},
1029
- status_code=401,
1030
- )
1031
- target = path + ("?" + request.url.query if request.url.query else "")
1032
- return RedirectResponse(
1033
- url="/login?" + urlencode({"next": target}), status_code=303
1034
- )
1035
-
1036
- @app.get("/login")
1037
- async def login_page() -> HTMLResponse:
1038
- return _html_response(_render_login_shell())
1039
-
1040
- @app.post("/login")
1041
- async def login(
1042
- request: Request, payload: "typing.Dict[str, object]"
1043
- ) -> JSONResponse:
1044
- if not secrets.compare_digest(
1045
- str(payload.get("password") or ""), password_text
1046
- ):
1047
- return JSONResponse(
1048
- {"ok": False, "error": "invalid password"},
1049
- status_code=401,
1050
- )
1051
- target = request.query_params.get("next", "/")
1052
- if (
1053
- not target.startswith("/")
1054
- or target.startswith("//")
1055
- or "\\" in target
1056
- or any(ord(char) < 32 for char in target)
1057
- ):
1058
- target = "/"
1059
- response = JSONResponse({"ok": True, "redirect": target})
1060
- response.set_cookie(
1061
- AUTH_COOKIE_NAME,
1062
- token,
1063
- httponly=True,
1064
- samesite="lax",
1065
- )
1066
- return response
1067
-
1068
- return token
1069
-
1070
-
1071
- def _auth_cookie_matches(token: str, cookie: "typing.Union[str, None]") -> bool:
1072
- if not token:
1073
- return True
1074
- return bool(cookie) and secrets.compare_digest(str(cookie), token)
1075
-
1076
-
1077
- def _render_login_shell() -> str:
1078
- return """<!doctype html>
1079
- <html>
1080
- <head>
1081
- <meta charset="utf-8">
1082
- <meta name="viewport" content="width=device-width, initial-scale=1">
1083
- <title>pycodex login</title>
1084
- <style>
1085
- :root {
1086
- color-scheme: light dark;
1087
- font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
1088
- }
1089
- body {
1090
- min-height: 100vh;
1091
- margin: 0;
1092
- display: grid;
1093
- place-items: center;
1094
- background: Canvas;
1095
- color: CanvasText;
1096
- }
1097
- form {
1098
- width: min(360px, calc(100vw - 32px));
1099
- display: grid;
1100
- gap: 12px;
1101
- }
1102
- h1 {
1103
- margin: 0;
1104
- font-size: 22px;
1105
- }
1106
- input, button {
1107
- min-height: 38px;
1108
- border-radius: 7px;
1109
- border: 1px solid color-mix(in srgb, CanvasText 18%, Canvas 82%);
1110
- padding: 8px 10px;
1111
- font: inherit;
1112
- }
1113
- button {
1114
- cursor: pointer;
1115
- }
1116
- .status {
1117
- min-height: 20px;
1118
- color: #b42318;
1119
- font-size: 13px;
1120
- }
1121
- </style>
1122
- </head>
1123
- <body>
1124
- <form id="loginForm">
1125
- <h1>pycodex workspace</h1>
1126
- <input id="passwordInput" type="password" autocomplete="current-password" autofocus>
1127
- <button type="submit">Open</button>
1128
- <div id="status" class="status" role="status"></div>
1129
- </form>
1130
- <script>
1131
- const form = document.getElementById("loginForm");
1132
- const passwordInput = document.getElementById("passwordInput");
1133
- const statusEl = document.getElementById("status");
1134
- form.addEventListener("submit", async function(event) {
1135
- event.preventDefault();
1136
- statusEl.textContent = "";
1137
- const response = await fetch(window.location.pathname + window.location.search, {
1138
- method: "POST",
1139
- headers: {"Content-Type": "application/json"},
1140
- body: JSON.stringify({password: passwordInput.value}),
1141
- });
1142
- if (response.ok) {
1143
- const result = await response.json();
1144
- window.location.href = result.redirect;
1145
- return;
1146
- }
1147
- statusEl.textContent = "Invalid password";
1148
- });
1149
- </script>
1150
- </body>
1151
- </html>"""
1152
-
1153
-
1154
- def _create_lifespan_app(
1155
- start: "typing.Callable[[], typing.Awaitable[None]]",
1156
- close: "typing.Callable[[], typing.Awaitable[None]]",
1157
- ) -> FastAPI:
1158
- if asynccontextmanager is not None:
1159
-
1160
- @asynccontextmanager
1161
- async def lifespan(_app):
1162
- await start()
1163
- try:
1164
- yield
1165
- finally:
1166
- await close()
1167
-
1168
- return FastAPI(lifespan=lifespan)
1169
-
1170
- app = FastAPI()
1171
-
1172
- @app.on_event("startup")
1173
- async def startup() -> None:
1174
- await start()
1175
-
1176
- @app.on_event("shutdown")
1177
- async def shutdown() -> None:
1178
- await close()
1179
-
1180
- return app
1181
-
1182
-
1183
- def _install_workspace_routes(
1184
- app: FastAPI,
1185
- manager: WorkspaceSessionManager,
1186
- board_path: "typing.Union[Path, None]",
1187
- work_dir: Path,
1188
- ) -> None:
1189
- view_asset_cache = {}
1190
-
1191
- @app.get("/")
1192
- async def index() -> HTMLResponse:
1193
- return _html_response(_render_workspace_shell(board_path, work_dir=work_dir))
1194
-
1195
- @app.get("/favicon.ico")
1196
- async def favicon() -> Response:
1197
- return Response(status_code=204)
1198
-
1199
- @app.api_route("/board", methods=["GET", "HEAD"])
1200
- async def board() -> Response:
1201
- return _board_response(board_path)
1202
-
1203
- @app.get("/api/board")
1204
- async def board_status() -> JSONResponse:
1205
- return _board_status_response(board_path)
1206
-
1207
- @app.get("/api/views")
1208
- def views() -> JSONResponse:
1209
- return _views_response(manager)
1210
-
1211
- @app.post("/api/views/open")
1212
- def open_view(payload: "typing.Dict[str, object]") -> JSONResponse:
1213
- return _open_view_response(manager, work_dir, payload)
1214
-
1215
- @app.get("/api/views/{view_id}")
1216
- def view(view_id: str) -> JSONResponse:
1217
- return _view_response(manager, view_id)
1218
-
1219
- @app.delete("/api/views/{view_id}")
1220
- def close_view(view_id: str) -> JSONResponse:
1221
- return _close_view_response(manager, view_id)
1222
-
1223
- @app.api_route("/assets/content_views.mjs", methods=["GET", "HEAD"])
1224
- @app.api_route("/assets/content_views.css", methods=["GET", "HEAD"])
1225
- @app.api_route("/assets/content_views.LICENSE.txt", methods=["GET", "HEAD"])
1226
- @app.api_route("/assets/editor.mjs", methods=["GET", "HEAD"])
1227
- @app.api_route("/assets/editor.css", methods=["GET", "HEAD"])
1228
- def view_asset(request: Request) -> Response:
1229
- return _view_asset_response(
1230
- request.url.path.rsplit("/", 1)[-1], request, view_asset_cache
1231
- )
1232
-
1233
- @app.get("/ws/session")
1234
- async def websocket_backend_hint() -> JSONResponse:
1235
- return _websocket_backend_hint_response()
1236
-
1237
- @app.get("/api/sessions")
1238
- async def sessions() -> JSONResponse:
1239
- return _sessions_response(manager)
1240
-
1241
- @app.post("/api/sessions")
1242
- async def new_session() -> JSONResponse:
1243
- return await _new_session_response(manager)
1244
-
1245
- @app.delete("/api/sessions/{session_id}")
1246
- async def delete_session(session_id: str) -> JSONResponse:
1247
- return await _delete_session_response(manager, session_id)
1248
-
1249
- @app.get("/api/session")
1250
- async def session(
1251
- session_id: "typing.Union[str, None]" = None,
1252
- ) -> JSONResponse:
1253
- return _session_response(manager, session_id)
1254
-
1255
- @app.post("/api/session/message")
1256
- async def message(
1257
- payload: "typing.Dict[str, object]",
1258
- ) -> JSONResponse:
1259
- return await _message_response(manager, payload)
1260
-
1261
- @app.websocket("/ws/session")
1262
- async def websocket_session(websocket: WebSocket) -> None:
1263
- auth_token = typing.cast(str, app.state.workspace_auth_token)
1264
- if not _auth_cookie_matches(
1265
- auth_token, websocket.cookies.get(AUTH_COOKIE_NAME)
1266
- ):
1267
- await websocket.close(code=1008)
1268
- return
1269
- await _websocket_session_handler(manager, websocket)
1270
-
1271
- @app.api_route("/{asset_path:path}", methods=["GET", "HEAD"])
1272
- async def board_asset(asset_path: str) -> Response:
1273
- return _board_asset_response(board_path, asset_path)
1274
-
1275
-
1276
- def _views_response(manager: WorkspaceSessionManager) -> JSONResponse:
1277
- try:
1278
- views = manager.views.list_views()
1279
- except (OSError, ValueError) as error:
1280
- raise HTTPException(status_code=400, detail=str(error))
1281
- return JSONResponse({"views": views}, headers={"Cache-Control": "no-store"})
1282
-
1283
-
1284
- def _open_view_response(
1285
- manager: WorkspaceSessionManager, work_dir: Path, payload: "typing.Dict[str, object]"
1286
- ) -> JSONResponse:
1287
- try:
1288
- view = WorkspaceViewOpener(manager.views, work_dir).open(payload.get("href"))
1289
- views = manager.views.list_views()
1290
- except (OSError, ValueError, subprocess.TimeoutExpired) as error:
1291
- raise HTTPException(status_code=400, detail=str(error))
1292
- return JSONResponse(
1293
- {"view": view, "views": views}, headers={"Cache-Control": "no-store"}
1294
- )
1295
-
1296
-
1297
- def _view_response(manager: WorkspaceSessionManager, view_id: str) -> JSONResponse:
1298
- try:
1299
- view = manager.views.get(view_id)
1300
- except KeyError:
1301
- raise HTTPException(status_code=404, detail="view not found")
1302
- except (OSError, ValueError) as error:
1303
- raise HTTPException(status_code=400, detail=str(error))
1304
- return JSONResponse(view, headers={"Cache-Control": "no-store"})
1305
-
1306
-
1307
- def _close_view_response(
1308
- manager: WorkspaceSessionManager, view_id: str
1309
- ) -> JSONResponse:
1310
- try:
1311
- manager.views.close(view_id)
1312
- except KeyError:
1313
- raise HTTPException(status_code=404, detail="view not found")
1314
- return JSONResponse({"ok": True})
1315
-
1316
-
1317
- def _view_asset_encoding(accept_encoding: str) -> str:
1318
- qualities = {}
1319
- for entry in accept_encoding.lower().split(","):
1320
- name, *parameters = entry.strip().split(";")
1321
- quality = 1.0
1322
- for parameter in parameters:
1323
- key, _, value = parameter.strip().partition("=")
1324
- if key != "q":
1325
- continue
1326
- try:
1327
- quality = float(value)
1328
- except ValueError as exc:
1329
- raise HTTPException(
1330
- status_code=400, detail="invalid encoding quality"
1331
- ) from exc
1332
- if not 0 <= quality <= 1:
1333
- raise HTTPException(status_code=400, detail="invalid encoding quality")
1334
- qualities[name.strip()] = quality
1335
- gzip_quality = qualities.get("gzip", qualities.get("*", 0))
1336
- if gzip_quality > 0 and gzip_quality >= qualities.get("identity", 0):
1337
- return "gzip"
1338
- if qualities.get("identity", 0 if qualities.get("*") == 0 else 1) > 0:
1339
- return "identity"
1340
- raise HTTPException(status_code=406, detail="no acceptable asset encoding")
1341
-
1342
-
1343
- def _view_asset_response(asset_name: str, request: Request, cache: dict) -> Response:
1344
- media_types = {
1345
- "content_views.mjs": "text/javascript",
1346
- "content_views.css": "text/css",
1347
- "content_views.LICENSE.txt": "text/plain",
1348
- "editor.mjs": "text/javascript",
1349
- "editor.css": "text/css",
1350
- }
1351
- if asset_name not in media_types:
1352
- raise HTTPException(status_code=404, detail="asset not found")
1353
- encoding = _view_asset_encoding(request.headers.get("accept-encoding", ""))
1354
- path = Path(__file__).with_name(asset_name)
1355
- stat = path.stat()
1356
- stamp = (stat.st_mtime_ns, stat.st_size)
1357
- key = (asset_name, encoding)
1358
- cached = cache.get(key)
1359
- if cached is None or cached[0] != stamp:
1360
- body = path.read_bytes()
1361
- if encoding == "gzip":
1362
- body = gzip.compress(body, compresslevel=6)
1363
- etag = '"' + hashlib.sha256(body).hexdigest() + '"'
1364
- cached = cache[key] = (stamp, body, etag)
1365
- _, body, etag = cached
1366
- headers = {
1367
- "Cache-Control": "no-cache",
1368
- "X-Content-Type-Options": "nosniff",
1369
- "Vary": "Accept-Encoding",
1370
- "ETag": etag,
1371
- }
1372
- if encoding == "gzip":
1373
- headers["Content-Encoding"] = "gzip"
1374
- for candidate in request.headers.get("if-none-match", "").split(","):
1375
- candidate = candidate.strip()
1376
- if candidate.startswith("W/"):
1377
- candidate = candidate[2:]
1378
- if candidate in ("*", etag):
1379
- return Response(status_code=304, headers=headers)
1380
- headers["Content-Length"] = str(len(body))
1381
- return Response(
1382
- b"" if request.method == "HEAD" else body,
1383
- media_type=media_types[asset_name], headers=headers,
1384
- )
1385
-
1386
-
1387
- def _board_status_response(board_path: "typing.Union[Path, None]") -> JSONResponse:
1388
- if board_path is None or not board_path.is_file():
1389
- return JSONResponse({"exists": False})
1390
- stat = board_path.stat()
1391
- return JSONResponse(
1392
- {
1393
- "exists": True,
1394
- "path": str(board_path),
1395
- "mtime_ns": stat.st_mtime_ns,
1396
- "size": stat.st_size,
1397
- }
1398
- )
1399
-
1400
-
1401
- def _websocket_backend_hint_response() -> JSONResponse:
1402
- return JSONResponse(
1403
- {
1404
- "error": "websocket backend is unavailable; HTTP polling is active",
1405
- },
1406
- status_code=426,
1407
- )
1408
-
1409
-
1410
- def _sessions_response(manager: "WorkspaceSessionManager") -> JSONResponse:
1411
- return JSONResponse({"sessions": manager.list_sessions()})
1412
-
1413
-
1414
- async def _new_session_response(manager: "WorkspaceSessionManager") -> JSONResponse:
1415
- session_id = await manager.create_session()
1416
- return JSONResponse(
1417
- {
1418
- "ok": True,
1419
- "session_id": session_id,
1420
- "sessions": manager.list_sessions(),
1421
- "snapshot": session_snapshot(manager.get(session_id)),
1422
- }
1423
- )
1424
-
1425
-
1426
- async def _delete_session_response(
1427
- manager: "WorkspaceSessionManager",
1428
- session_id: str,
1429
- ) -> JSONResponse:
1430
- try:
1431
- await manager.close_session(session_id)
1432
- except KeyError:
1433
- raise HTTPException(status_code=404, detail="session not found")
1434
- except ValueError as exc:
1435
- return JSONResponse({"ok": False, "error": str(exc)}, status_code=400)
1436
- return JSONResponse({"ok": True, "sessions": manager.list_sessions()})
1437
-
1438
-
1439
- def _session_response(
1440
- manager: "WorkspaceSessionManager",
1441
- session_id: "typing.Union[str, None]" = None,
1442
- ) -> JSONResponse:
1443
- try:
1444
- resolved_id = manager.resolve_session_id(session_id)
1445
- link = manager.get(resolved_id)
1446
- except KeyError:
1447
- raise HTTPException(status_code=404, detail="session not found")
1448
- return JSONResponse(
1449
- {
1450
- "session_id": resolved_id,
1451
- "sessions": manager.list_sessions(),
1452
- "snapshot": session_snapshot(link),
1453
- }
1454
- )
1455
-
1456
-
1457
- async def _message_response(
1458
- manager: "WorkspaceSessionManager",
1459
- payload: "typing.Dict[str, object]",
1460
- ) -> JSONResponse:
1461
- session_id = str(payload.get("session_id") or "")
1462
- try:
1463
- link = manager.get(session_id or None)
1464
- except KeyError:
1465
- raise HTTPException(status_code=404, detail="session not found")
1466
- if "request_id" in payload:
1467
- result = await link.answer_input(payload["request_id"], payload.get("answer"))
1468
- else:
1469
- result = await link.submit(
1470
- str(payload.get("prompt") or ""),
1471
- sender=str(payload.get("sender") or "web"),
1472
- )
1473
- if isinstance(result, dict):
1474
- result.setdefault("sessions", manager.list_sessions())
1475
- status = 200 if result.get("ok") else 400
1476
- return JSONResponse(result, status_code=status)
1477
-
1478
-
1479
- async def _websocket_session_handler(
1480
- manager: "WorkspaceSessionManager",
1481
- websocket: WebSocket,
1482
- ) -> None:
1483
- await websocket.accept()
1484
- session_id = str(websocket.query_params.get("session_id") or "")
1485
- try:
1486
- link = manager.get(session_id or None)
1487
- except KeyError:
1488
- await websocket.close(code=1008)
1489
- return
1490
- subscriber = link.subscribe()
1491
- sender = asyncio.create_task(_send_ws_events(websocket, subscriber))
1492
- try:
1493
- while True:
1494
- data = await websocket.receive_text()
1495
- try:
1496
- payload = json.loads(data)
1497
- except ValueError:
1498
- await websocket.send_json({"type": "error", "error": "invalid json"})
1499
- continue
1500
- action = str(payload.get("type") or payload.get("action") or "")
1501
- if action in {"send", "answer"}:
1502
- target_session_id = str(payload.get("session_id") or session_id or "")
1503
- try:
1504
- target_link = manager.get(target_session_id or None)
1505
- except KeyError:
1506
- await websocket.send_json(
1507
- {"type": "error", "error": "session not found"}
1508
- )
1509
- continue
1510
- if action == "answer":
1511
- result = await target_link.answer_input(
1512
- payload.get("request_id"),
1513
- payload.get("answer"),
1514
- )
1515
- else:
1516
- result = await target_link.submit(
1517
- str(payload.get("prompt") or ""),
1518
- sender=str(payload.get("sender") or "web"),
1519
- )
1520
- await websocket.send_json({"type": "send_result", "result": result})
1521
- elif action == "ping":
1522
- await websocket.send_json({"type": "pong"})
1523
- else:
1524
- await websocket.send_json({"type": "error", "error": "unknown action"})
1525
- except WebSocketDisconnect:
1526
- pass
1527
- finally:
1528
- link.unsubscribe(subscriber)
1529
- sender.cancel()
1530
- await asyncio.gather(sender, return_exceptions=True)
1531
-
1532
-
1533
- def _last_assistant_text(turns: "typing.Iterable[typing.Dict[str, object]]") -> str:
1534
- for turn in reversed(list(turns)):
1535
- if str(turn.get("kind") or "assistant") == "control":
1536
- continue
1537
- response = str(turn.get("response") or "").strip()
1538
- if response:
1539
- return response
1540
- return ""
1541
-
1542
-
1543
- def _public_turn(turn: "typing.Dict[str, object]") -> "typing.Dict[str, object]":
1544
- return typing.cast(
1545
- "typing.Dict[str, object]",
1546
- _json_safe(
1547
- {
1548
- "submission_id": turn.get("submission_id", ""),
1549
- "turn_id": turn.get("turn_id", ""),
1550
- "prompt": turn.get("prompt", ""),
1551
- "response": turn.get("response", ""),
1552
- "thinking": turn.get("thinking", ""),
1553
- "status": turn.get("status", ""),
1554
- "error": turn.get("error", ""),
1555
- "queue": turn.get("queue", ""),
1556
- "sender": turn.get("sender", ""),
1557
- "kind": turn.get("kind", "assistant"),
1558
- }
1559
- ),
1560
- )
1561
-
1562
-
1563
- def _event_data(event: "Event") -> "typing.Dict[str, object]":
1564
- payload = {
1565
- item.name: getattr(event, item.name)
1566
- for item in fields(event)
1567
- if item.name not in {"turn_id", "submission_id"}
1568
- }
1569
- turn_id = ""
1570
- if isinstance(event, TurnEvent):
1571
- turn_id = event.turn_id
1572
- if event.submission_id is not None:
1573
- payload.update(submission_id=event.submission_id, turn_id=event.turn_id)
1574
- elif isinstance(
1575
- event, (CommandCompletedEvent, CommandFailedEvent, InputQueuedEvent)
1576
- ):
1577
- turn_id = event.submission_id
1578
- if isinstance(event, InputQueuedEvent):
1579
- payload["submission_id"] = event.submission_id
1580
- if isinstance(event, TurnStartedEvent):
1581
- payload["user_text"] = "\n".join(event.user_texts)
1582
- if isinstance(event, (ToolStartedEvent, ToolCompletedEvent)):
1583
- payload.update(tool_name=event.call.name, call_id=event.call.call_id)
1584
- if isinstance(event, ToolCompletedEvent):
1585
- payload["is_error"] = event.result.is_error
1586
- if isinstance(event, ToolCalledEvent):
1587
- payload = {name: value for name, value in payload.items() if value is not None}
1588
- if isinstance(
1589
- event,
1590
- (
1591
- CompactStartedEvent,
1592
- CompactCompletedEvent,
1593
- CompactFailedEvent,
1594
- AutoCompactStartedEvent,
1595
- AutoCompactCompletedEvent,
1596
- AutoCompactFailedEvent,
1597
- ),
1598
- ):
1599
- for name in ("total_tokens", "token_limit"):
1600
- if payload[name] is None:
1601
- del payload[name]
1602
- if payload.get("pruned_tool_results") == 0:
1603
- del payload["pruned_tool_results"]
1604
- if isinstance(event, (CompactCompletedEvent, AutoCompactCompletedEvent)):
1605
- payload["summary"] = event.summary
1606
- if isinstance(event, TerminalEvent) and event.background_work_count is None:
1607
- del payload["background_work_count"]
1608
- if isinstance(event, InputRequestedEvent):
1609
- payload["kind"] = payload.pop("request_kind")
1610
- payload["text"] = event.visualize()
1611
- payload = {name: value for name, value in payload.items() if value is not None}
1612
- return {"kind": event.kind, "turn_id": turn_id, "payload": _json_safe(payload)}
1613
-
1614
-
1615
- def _json_safe(value: object) -> "JSONValue":
1616
- if value is None or isinstance(value, (bool, int, float, str)):
1617
- return value
1618
- if isinstance(value, (list, tuple)):
1619
- return [_json_safe(item) for item in value]
1620
- if isinstance(value, dict):
1621
- return {str(key): _json_safe(item) for key, item in value.items()}
1622
- if isinstance(value, InputRequestedEvent):
1623
- return _event_data(value)["payload"]
1624
- if is_dataclass(value):
1625
- return _json_safe(asdict(value))
1626
- try:
1627
- json.dumps(value)
1628
- except TypeError:
1629
- return str(value)
1630
- return typing.cast(JSONValue, value)
1631
-
1632
-
1633
- def _html_response(content: str) -> HTMLResponse:
1634
- return HTMLResponse(
1635
- content,
1636
- headers={
1637
- "Cache-Control": "no-store",
1638
- "Pragma": "no-cache",
1639
- },
1640
- )
1641
-
1642
-
1643
- async def _send_ws_events(websocket: WebSocket, subscriber: "asyncio.Queue") -> None:
1644
- while True:
1645
- event = await subscriber.get()
1646
- if event is None:
1647
- return
1648
- await websocket.send_json(event)
1649
-
1650
-
1651
- def _board_response(board_path: "typing.Union[Path, None]") -> Response:
1652
- if board_path is None:
1653
- return _html_response(_render_empty_board())
1654
- if not board_path.is_file():
1655
- return _html_response(_render_missing_board(board_path))
1656
- return _html_response(board_path.read_text(encoding="utf-8", errors="replace"))
1657
-
1658
-
1659
- def _board_asset_response(
1660
- board_path: "typing.Union[Path, None]",
1661
- asset_path: str,
1662
- ) -> Response:
1663
- media_type, unused_encoding = mimetypes.guess_type(str(asset_path or ""))
1664
- del unused_encoding
1665
- if board_path is None or not media_type or not media_type.startswith("image/"):
1666
- raise HTTPException(status_code=404, detail="board image not found")
1667
-
1668
- resolved_asset = None
1669
- try:
1670
- board_directory = board_path.parent.resolve()
1671
- resolved_asset = (board_directory / asset_path).resolve()
1672
- within_board_directory = os.path.commonpath(
1673
- [str(board_directory), str(resolved_asset)]
1674
- ) == str(board_directory)
1675
- except (OSError, RuntimeError, ValueError):
1676
- within_board_directory = False
1677
-
1678
- if (
1679
- not within_board_directory
1680
- or resolved_asset is None
1681
- or not resolved_asset.is_file()
1682
- ):
1683
- raise HTTPException(status_code=404, detail="board image not found")
1684
-
1685
- return FileResponse(
1686
- str(resolved_asset),
1687
- media_type=media_type,
1688
- headers={
1689
- "Cache-Control": "no-cache",
1690
- "X-Content-Type-Options": "nosniff",
1691
- },
1692
- )
1693
-
1694
-
1695
- def _workspace_entry_or_404(
1696
- registry: "WorkspaceRegistry",
1697
- workspace_id: str,
1698
- ) -> "WorkspaceEntry":
1699
- try:
1700
- return registry.get(workspace_id)
1701
- except (KeyError, ValueError):
1702
- raise HTTPException(status_code=404, detail="workspace not found")
1703
-
1704
-
1705
- def run_serve_cli(args: "argparse.Namespace") -> int:
1706
- from .owned_process import run_owned_server
1707
-
1708
- host, port = parse_listen(args.listen)
1709
- configure_loguru()
1710
-
1711
- definitions = load_workspace_definitions(args.workspace_config)
1712
- entries = [_build_workspace_entry(definition, args) for definition in definitions]
1713
- registry = WorkspaceRegistry(
1714
- entries,
1715
- config_path=args.workspace_config,
1716
- entry_factory=lambda definition, persist_callback: _build_workspace_entry(
1717
- definition,
1718
- args,
1719
- persist_callback,
1720
- ),
1721
- )
1722
- app = create_multi_workspace_app(registry, password=args.password)
1723
-
1724
- print(
1725
- "pycodex workspace listening on http://{0}:{1}".format(host, port),
1726
- flush=True,
1727
- )
1728
- for definition in definitions:
1729
- print(
1730
- "workspace {0}: board={1} work_dir={2} url=http://{3}:{4}/w/{0}/".format(
1731
- definition.workspace_id,
1732
- definition.board_path or "",
1733
- definition.work_dir,
1734
- host,
1735
- port,
1736
- ),
1737
- flush=True,
1738
- )
1739
- run_owned_server(app, host, port, sys.stdin.fileno())
1740
- return 0
1741
-
1742
-
1743
- def _build_workspace_entry(
1744
- definition: "WorkspaceDefinition",
1745
- args: "argparse.Namespace",
1746
- persist_callback: "typing.Union[typing.Callable[[], None], None]" = None,
1747
- ) -> "WorkspaceEntry":
1748
- views = WorkspaceViewStore(definition.board_path)
1749
-
1750
- def build_session() -> "WorkspaceInteractiveSession":
1751
- model = build_model(
1752
- config_path=args.config,
1753
- profile=args.profile,
1754
- timeout_seconds=args.timeout_seconds,
1755
- vllm_endpoint=args.vllm_endpoint,
1756
- use_chat_completion=args.use_chat_completion or None,
1757
- use_messages=args.use_messages,
1758
- )
1759
- agent = build_agent(
1760
- model,
1761
- config_path=args.config,
1762
- profile=args.profile,
1763
- system_prompt=args.system_prompt,
1764
- extra_contextual_user_messages=(
1765
- [_workspace_context_text(definition.board_path, definition.work_dir)]
1766
- if definition.board_path is not None
1767
- else []
1768
- ),
1769
- cwd=definition.work_dir,
1770
- toolset=args.toolset,
1771
- )
1772
- return WorkspaceInteractiveSession(
1773
- build_runtime(agent),
1774
- config_path=args.config,
1775
- )
1776
-
1777
- def session_factory() -> "ThreadedWorkspaceInteractiveSession":
1778
- return ThreadedWorkspaceInteractiveSession(
1779
- build_session, asyncio.get_running_loop()
1780
- )
1781
-
1782
- return WorkspaceEntry(
1783
- definition=definition,
1784
- manager=WorkspaceSessionManager(
1785
- session_factory,
1786
- definition.board_path,
1787
- persist_callback=persist_callback,
1788
- views=views,
1789
- ),
1790
- )
1791
-
1792
-
1793
- def _workspace_context_text(
1794
- board_path: Path,
1795
- work_dir: "typing.Union[Path, None]",
1796
- ) -> str:
1797
- return (
1798
- "Current workspace Canvas file: {0}. "
1799
- "Changes you make to this HTML file are shown to the user in real time. "
1800
- "You can create or modify this file anytime.\n\n"
1801
- "Replies and Canvas HTML can use extended links to open files and diffs "
1802
- "in the board: git:REV:path, diff:REV:path, diff:BASE..HEAD:path, "
1803
- "and diff:left..right. Append #L42 to select a file or left-side line, "
1804
- "or #R42 for the right side of a diff. "
1805
- "Use workspace://canvas to return to the live Canvas."
1806
- ).format(_format_board_path_for_prompt(board_path, work_dir=work_dir))
1807
-
1808
-
1809
- def _format_board_path_for_prompt(
1810
- board_path: Path,
1811
- work_dir: "typing.Union[Path, None]" = None,
1812
- ) -> str:
1813
- resolved = board_path.resolve()
1814
- try:
1815
- relative = os.path.relpath(
1816
- str(resolved),
1817
- str(Path(work_dir or Path.cwd()).resolve()),
1818
- )
1819
- except ValueError:
1820
- return str(resolved)
1821
- if relative == ".":
1822
- return "."
1823
- if relative.startswith(".."):
1824
- return str(resolved)
1825
- return "./{0}".format(relative)
1826
-
1827
-
1828
- def _render_workspace_shell(
1829
- board_path: "typing.Union[Path, None]",
1830
- title: "typing.Union[str, None]" = None,
1831
- work_dir: "typing.Union[Path, None]" = None,
1832
- ) -> str:
1833
- board_label = str(board_path) if board_path is not None else "No board"
1834
- cwd_label = str(work_dir or Path.cwd())
1835
- page_title = str(title or "pycodex workspace")
1836
- template = (Path(__file__).with_name("workspace.html")).read_text(encoding="utf-8")
1837
- return (
1838
- template.replace("__WORKSPACE_TITLE__", html.escape(page_title))
1839
- .replace("__BOARD_LABEL__", html.escape(board_label))
1840
- .replace("__CWD_LABEL__", html.escape(cwd_label))
1841
- )
1842
-
1843
-
1844
- def _render_workspaces_manager_shell() -> str:
1845
- return (Path(__file__).with_name("workspaces.html")).read_text(encoding="utf-8")
1846
-
1847
-
1848
- def _render_empty_board() -> str:
1849
- return _render_board_placeholder(
1850
- "No board",
1851
- "No board connected",
1852
- "Add a board from the workspaces page to see your work here.",
1853
- )
1854
-
1855
-
1856
- def _render_missing_board(board_path: Path) -> str:
1857
- return _render_board_placeholder(
1858
- "Canvas pending",
1859
- "Your work will appear here",
1860
- "Ask pycodex to create a page, a report, or a visual. "
1861
- "This canvas updates as you work.",
1862
- str(board_path),
1863
- )
1864
-
1865
-
1866
- def _render_board_placeholder(
1867
- title: str, heading: str, description: str, path_label: str = ""
1868
- ) -> str:
1869
- return """<!doctype html>
1870
- <html lang="en"><head><meta charset="utf-8">
1871
- <meta name="viewport" content="width=device-width, initial-scale=1">
1872
- <title>{0}</title><style>
1873
- * {{ box-sizing: border-box; }}
1874
- body {{ margin: 0; min-height: 100dvh; display: grid; place-items: center;
1875
- padding: 32px; background: #f8fbfd; color: #172630;
1876
- font: 14px/1.7 -apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif; }}
1877
- main {{ max-width: 360px; text-align: center; }}
1878
- svg {{ width: 40px; height: 40px; color: #aebfc9; margin-bottom: 20px; }}
1879
- h1 {{ margin: 0 0 12px; font-size: 23px; font-weight: 500; line-height: 1.35;
1880
- letter-spacing: -0.5px; }}
1881
- p {{ margin: 0; color: #60727d; }}
1882
- code {{ display: block; margin-top: 28px; font-size: 11px; color: #60727d;
1883
- overflow-wrap: anywhere; }}
1884
- </style></head><body><main>
1885
- <svg viewBox="0 0 32 32" fill="none" stroke="currentColor" stroke-width="1.25"
1886
- aria-hidden="true"><rect x="3" y="3" width="26" height="26" rx="5"/>
1887
- <path d="M3 11h26M11 11v18"/></svg>
1888
- <h1>{1}</h1><p>{2}</p><code>{3}</code>
1889
- </main></body></html>""".format(
1890
- html.escape(title),
1891
- html.escape(heading),
1892
- html.escape(description),
1893
- html.escape(path_label),
1894
- )
1895
-
1896
-
1897
- def main(argv: "typing.Union[typing.Sequence[str], None]" = None) -> int:
1898
- parser = build_parser()
1899
- args = parser.parse_args(argv)
1900
- try:
1901
- return run_serve_cli(args)
1902
- except ValueError as exc:
1903
- parser.error(str(exc))
1904
- except KeyboardInterrupt:
1905
- return 130
1906
- return 0