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.
- pycodex/__init__.py +3 -27
- pycodex/bootstrap.py +5 -45
- pycodex/cli.py +1 -14
- pycodex/context.py +1 -1
- pycodex/events.py +1 -1
- pycodex/model.py +2 -2
- pycodex/portable/__init__.py +45 -0
- pycodex/{portable.py → portable/client.py} +4 -7
- pycodex/{portable_server.py → portable/server.py} +3 -3
- pycodex/protocol.py +1 -1
- pycodex/runtime.py +2 -2
- pycodex/tools/base_tool.py +1 -1
- pycodex/tools/close_agent_tool.py +1 -1
- pycodex/tools/code_mode_manager.py +1 -1
- pycodex/tools/request_permissions_tool.py +1 -1
- pycodex/tools/resume_agent_tool.py +1 -1
- pycodex/{runtime_services.py → tools/runtime_services.py} +5 -5
- pycodex/tools/send_input_tool.py +1 -1
- pycodex/tools/spawn_agent_tool.py +1 -1
- pycodex/tools/unified_exec_manager.py +1 -1
- pycodex/tools/update_plan_tool.py +1 -1
- pycodex/tools/view_image_tool.py +5 -0
- pycodex/tools/wait_agent_tool.py +1 -1
- pycodex/utils/__init__.py +0 -4
- pycodex/utils/_aes_gcm/LICENSE +56 -0
- pycodex/utils/_aes_gcm/README.md +61 -0
- pycodex/utils/_aes_gcm/__init__.py +1 -0
- pycodex/utils/_aes_gcm/aes.py +175 -0
- pycodex/utils/_aes_gcm/gcm.py +73 -0
- pycodex/utils/_aes_gcm/source-sha256.json +4 -0
- pycodex/utils/async_bridge.py +3 -4
- pycodex/{compat.py → utils/compat.py} +2 -0
- pycodex/utils/crypto.py +35 -0
- pycodex/utils/debug.py +24 -0
- pycodex/{doctor.py → utils/doctor.py} +3 -3
- pycodex/utils/image_utils.py +3 -2
- pycodex/{model_metadata.py → utils/model_metadata.py} +2 -2
- pycodex/utils/session_persist.py +28 -0
- {python_codex-0.3.3.dist-info → python_codex-0.4.1.dist-info}/METADATA +124 -42
- python_codex-0.4.1.dist-info/RECORD +90 -0
- {python_codex-0.3.3.dist-info → python_codex-0.4.1.dist-info}/entry_points.txt +0 -1
- responses_server/app.py +28 -15
- pycodex/feishu_card.py +0 -793
- pycodex/feishu_link.py +0 -440
- pycodex/tools/ipython_tool.py +0 -142
- python_codex-0.3.3.dist-info/RECORD +0 -98
- workspace_server/__init__.py +0 -39
- workspace_server/__main__.py +0 -4
- workspace_server/app.py +0 -1906
- workspace_server/content_views.LICENSE.txt +0 -470
- workspace_server/content_views.css +0 -1
- workspace_server/content_views.mjs +0 -25
- workspace_server/editor.css +0 -1
- workspace_server/editor.mjs +0 -1260
- workspace_server/owned_process.py +0 -27
- workspace_server/workspace.html +0 -2267
- workspace_server/workspace_views.py +0 -533
- workspace_server/workspaces.html +0 -550
- workspace_server/workspaces.py +0 -632
- {python_codex-0.3.3.dist-info → python_codex-0.4.1.dist-info}/WHEEL +0 -0
- {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
|