python-codex 0.2.4__py3-none-any.whl → 0.2.6__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.
@@ -22,6 +22,7 @@ Include:
22
22
  - Important context, constraints, or user preferences
23
23
  - What remains to be done (clear next steps)
24
24
  - Any critical data, examples, or references needed to continue
25
+ - Preserve concise verbatim excerpts of the latest user request and active constraints in their original language, and write the summary in the user's primary language.
25
26
 
26
27
  Be concise, structured, and focused on helping the next LLM seamlessly continue the work."""
27
28
 
@@ -31,15 +32,10 @@ SUMMARY_PREFIX = (
31
32
  "that were used by that language model. Use this to build on the work that "
32
33
  "has already been done and avoid duplicating work. Here is the summary "
33
34
  "produced by the other language model, use the information in this summary "
34
- "to assist with your own analysis:"
35
+ "to assist with your own analysis. Continue the current task directly; do "
36
+ "not merely repeat or acknowledge the handoff:"
35
37
  )
36
38
 
37
- COMPACT_USER_MESSAGE_MAX_TOKENS = 20_000
38
- _APPROX_CHARS_PER_TOKEN = 4
39
- _SUBAGENT_NOTIFICATION_PREFIX = "<subagent_notification>\n"
40
- _EXEC_COMMAND_COMPLETED_PREFIX = "<exec_command_completed>\n"
41
-
42
-
43
39
  @dataclass(frozen=True)
44
40
  class CompactResult:
45
41
  history: 'typing.Tuple[ConversationItem, ...]'
@@ -67,8 +63,7 @@ def compact(
67
63
  history: 'typing.Sequence[ConversationItem]',
68
64
  ) -> 'typing.Tuple[ConversationItem, ...]':
69
65
  summary_text = _build_summary_message(_last_assistant_message(history))
70
- user_messages = collect_user_messages(history)
71
- return build_compacted_history(user_messages, summary_text)
66
+ return build_compacted_history(summary_text)
72
67
 
73
68
 
74
69
  async def compact_agent(
@@ -159,53 +154,10 @@ def prune_oldest_tool_response(
159
154
  )
160
155
 
161
156
 
162
- def collect_user_messages(
163
- history: 'typing.Sequence[ConversationItem]',
164
- ) -> 'typing.Tuple[str, ...]':
165
- compact_prompt = _normalize_for_compare(DEFAULT_COMPACT_PROMPT)
166
- collected: 'typing.List[str]' = []
167
- for item in history:
168
- if not isinstance(item, UserMessage):
169
- continue
170
- if is_summary_message(item.text):
171
- continue
172
- if _normalize_for_compare(item.text) == compact_prompt:
173
- continue
174
- if _is_synthetic_user_message(item.text):
175
- continue
176
- collected.append(item.text)
177
- return tuple(collected)
178
-
179
-
180
- def is_summary_message(message: 'str') -> 'bool':
181
- return message.startswith(f"{SUMMARY_PREFIX}\n")
182
-
183
-
184
157
  def build_compacted_history(
185
- user_messages: 'typing.Sequence[str]',
186
158
  summary_text: 'str',
187
- max_tokens: 'int' = COMPACT_USER_MESSAGE_MAX_TOKENS,
188
159
  ) -> 'typing.Tuple[ConversationItem, ...]':
189
- selected_messages: 'typing.List[str]' = []
190
- if max_tokens > 0:
191
- remaining = max_tokens
192
- for message in reversed(tuple(user_messages)):
193
- if remaining <= 0:
194
- break
195
- tokens = _approx_token_count(message)
196
- if tokens <= remaining:
197
- selected_messages.append(message)
198
- remaining -= tokens
199
- continue
200
- selected_messages.append(_truncate_text_to_tokens(message, remaining))
201
- break
202
- selected_messages.reverse()
203
-
204
- compacted: 'typing.List[ConversationItem]' = [
205
- UserMessage(text=message) for message in selected_messages
206
- ]
207
- compacted.append(UserMessage(text=summary_text or _build_summary_message(None)))
208
- return tuple(compacted)
160
+ return (UserMessage(text=summary_text or _build_summary_message(None)),)
209
161
 
210
162
 
211
163
  def _last_assistant_message(
@@ -222,46 +174,12 @@ def _build_summary_message(summary_text: 'typing.Union[str, None]') -> 'str':
222
174
  return f"{SUMMARY_PREFIX}\n{normalized}"
223
175
 
224
176
 
225
- def _approx_token_count(text: 'str') -> 'int':
226
- if not text:
227
- return 0
228
- return max(1, (len(text) + _APPROX_CHARS_PER_TOKEN - 1) // _APPROX_CHARS_PER_TOKEN)
229
-
230
-
231
- def _truncate_text_to_tokens(text: 'str', max_tokens: 'int') -> 'str':
232
- if max_tokens <= 0:
233
- return ""
234
- max_chars = max(max_tokens, 1) * _APPROX_CHARS_PER_TOKEN
235
- if len(text) <= max_chars:
236
- return text
237
-
238
- removed_tokens = _approx_token_count(text[max_chars:])
239
- suffix = f"\n...[{removed_tokens} tokens truncated]..."
240
- available = max_chars - len(suffix)
241
- if available <= 0:
242
- return suffix.lstrip()
243
- return text[:available].rstrip() + suffix
244
-
245
-
246
- def _normalize_for_compare(text: 'str') -> 'str':
247
- return "\n".join(line.rstrip() for line in text.strip().splitlines()).strip()
248
-
249
-
250
177
  def _pluralize(noun: 'str', count: 'int') -> 'str':
251
178
  if count == 1:
252
179
  return noun
253
180
  return f"{noun}s"
254
181
 
255
182
 
256
- def _is_synthetic_user_message(text: 'str') -> 'bool':
257
- return text.startswith(
258
- (
259
- _SUBAGENT_NOTIFICATION_PREFIX,
260
- _EXEC_COMMAND_COMPLETED_PREFIX,
261
- )
262
- )
263
-
264
-
265
183
  def _is_context_length_error(message: 'str') -> 'bool':
266
184
  lower = message.lower()
267
185
  return (
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: python-codex
3
- Version: 0.2.4
3
+ Version: 0.2.6
4
4
  Summary: A minimal Python extraction of Codex's main agent loop
5
5
  License-File: LICENSE
6
6
  Requires-Python: >=3.6.2
@@ -23,8 +23,17 @@ Description-Content-Type: text/markdown
23
23
 
24
24
  English README. Chinese version: `README_ZH.md`
25
25
 
26
- PyPI distribution name: `python-codex`
27
- Import path and CLI command remain `pycodex`.
26
+ PyPI distributions:
27
+
28
+ - Primary package: `python-codex`
29
+ - Workspace install alias: `pycodex-ws`
30
+
31
+ The import path remains `pycodex`; the CLI commands are `pycodex` and
32
+ `pycodex-ws`.
33
+
34
+ `pycodex-ws` is a thin metapackage that depends on the exact matching
35
+ `python-codex` version. The implementation and console script remain owned by
36
+ `python-codex`, so the two distributions never install duplicate modules.
28
37
 
29
38
  This repository extracts the core Codex agent loop from upstream Codex
30
39
  (`https://github.com/openai/codex`) into a deliberately small Python version,
@@ -44,6 +53,13 @@ Relevant Rust reference points:
44
53
 
45
54
  ## Quick Start
46
55
 
56
+ Install the full package or the workspace-oriented alias:
57
+
58
+ ```bash
59
+ pip install python-codex
60
+ pip install pycodex-ws
61
+ ```
62
+
47
63
  Install dependencies first:
48
64
 
49
65
  ```bash
@@ -179,7 +195,8 @@ Current behavior:
179
195
  - interactive mode shows a compact event stream for user-visible phases such as
180
196
  tool execution and model follow-up after tool results
181
197
  - assistant text is printed from streaming deltas directly
182
- - interactive mode supports `/history`, `/title`, `/model`, `/resume`, and `/compact`
198
+ - interactive mode supports `/history`, `/title`, `/model`, `/resume`, `/compact`,
199
+ and `/fork`
183
200
  - `/model <name>` switches the model used by later turns in the current
184
201
  interactive session; `/model` shows the current model and available choices
185
202
  - `/resume` with no argument lists the currently resumable sessions by their
@@ -189,9 +206,15 @@ Current behavior:
189
206
  - `/compact` synthesizes a local handoff summary, replaces the in-memory
190
207
  conversation history with the compacted view, and appends a compacted-history
191
208
  entry to the rollout so later `/resume` sees the same state
209
+ - `/fork` generates a new model session id while preserving the current history,
210
+ rollout, and workspace tab
192
211
  - `model_auto_compact_token_limit = <tokens>` in `config.toml` enables the same
193
212
  compaction path automatically when the latest reported usage reaches that
194
213
  threshold before a follow-up sampling request or the next user turn
214
+ - `service_tier = "fast"` enables Fast mode for models whose vendored metadata
215
+ advertises the `priority` service tier; pycodex follows upstream Codex by
216
+ sending `service_tier = "priority"` on the Responses request, while
217
+ `service_tier = "default"` or unsupported tiers are omitted
195
218
  - if a model request fails with `context_length_exceeded`, pycodex now treats
196
219
  the provider-reported requested token count as a failed-request usage sample,
197
220
  triggers the same compact path immediately, and retries the request once; if
@@ -219,8 +242,13 @@ Current behavior:
219
242
  `delete(name)` controls; omitted names become `workspace-1`, `workspace-2`,
220
243
  etc. If `board` is omitted when adding a workspace, pycodex assigns a random
221
244
  writable `/tmp/pcws-*.html` board path. Add/delete actions and later
222
- session-state saves refresh the JSON file. `--password <value>` enables a
223
- password-only login page for workspace pages, APIs, and websocket connections.
245
+ session-state saves refresh the JSON file. Board HTML can reference local
246
+ images beside the board (including nested paths) with relative URLs; only
247
+ `image/*` files contained by the board directory are served.
248
+ Assistant Markdown supports KaTeX formulas with `$...$`, `$$...$$`,
249
+ `\(...\)`, and `\[...\]` delimiters.
250
+ `--password <value>` enables a password-only login page for workspace pages,
251
+ APIs, and websocket connections.
224
252
  - steer is enabled by default in interactive mode: normal input goes into the
225
253
  runtime steer path, the current request stops at the next safe boundary, and
226
254
  later steer text is appended to the next model request's `input` in order;
@@ -3,12 +3,13 @@ pycodex/agent.py,sha256=HKzWVFRag_Le8LVR1qqfdfVzk23bRsXkykZV8Zq2hLE,21659
3
3
  pycodex/cli.py,sha256=1wTlt3ccgQqczrWBV3IjUVE0_L2QUlfPZEf6GB-tGZY,20983
4
4
  pycodex/collaboration.py,sha256=yQ6pBD-R3ZWR4_FAYQFoS7KF0m4LLD42otXIbPqw2ys,641
5
5
  pycodex/compat.py,sha256=l35JE0vGAOCn9NWbWbqwGURGk83HXddXQ5wJIfcG41o,3254
6
- pycodex/context.py,sha256=uI8y7V7L3VsXuDBljXWhP5tQRJM6MYQtoeEu2oSIJb4,26689
6
+ pycodex/context.py,sha256=NRCJpYpEm76cAK1akid0BvQ-Z0mAvw1ZNboVxjPtm6U,26219
7
7
  pycodex/doctor.py,sha256=De3M4hRBJq8ZeqsUJgHz0vitqrH18YugrEnz7oHhTdQ,10572
8
8
  pycodex/feishu_card.py,sha256=De6pM--3MfhgGo-WcWfhm-fop5UtzjwKrZ4gI2Lls3w,26198
9
9
  pycodex/feishu_link.py,sha256=DDQCYXQXgo_2SpXPyWlK8KYcumNO1-sqFuQDsEMQpTg,16427
10
- pycodex/interactive_session.py,sha256=pcIC_GvQuqQSsjzcTTFB_crrS-XVufAGplJZxnHgy7I,15769
11
- pycodex/model.py,sha256=O5Gf35qCMAGrqvr_5d1ZQNk3vHmNRD5Uv-8k7-zbpXg,36679
10
+ pycodex/interactive_session.py,sha256=WzOD06d43ckF7SPer12JKzUPIK42Djw4kEjFRdM6_Lw,16420
11
+ pycodex/model.py,sha256=2euikRqEuemeCv0gyyITCyuTdEOTK9yT50HIzd75Vtg,41808
12
+ pycodex/model_metadata.py,sha256=7fHntKSUXhG5eASJi4yw4jU_IyzGbYBp-WE6af19ATA,877
12
13
  pycodex/portable.py,sha256=gxl2E2h5uZJbasMEPPs-nyALFPIvX79T2ZYsu6vXZrg,15656
13
14
  pycodex/portable_server.py,sha256=6I3pQkWj3e_SFlDXY2mGdCPns1w_3PSxByBV9wv5epI,7331
14
15
  pycodex/protocol.py,sha256=4qiEcBQc3d3RZqsIKBjwORsHsQa78cqwvNljtnIuNbM,10795
@@ -17,7 +18,7 @@ pycodex/runtime_services.py,sha256=6PQMI4MM8F9imijaVKBIz_0ADtoKDcdYs6vQ-c4frtQ,1
17
18
  pycodex/prompts/collaboration_default.md,sha256=MBTmPuMubeWfZgIeFVj49wwnwD4n_o3fVYAbgWKwu6Q,955
18
19
  pycodex/prompts/collaboration_plan.md,sha256=IzjQAA5oHJz-3FmJdOjsJ4LHq6LW1tlEYMoy09n0HKk,8777
19
20
  pycodex/prompts/default_base_instructions.md,sha256=D65mcj6bo4CDvVom-D9cbJRJVNquo0NghKt164_fRsg,20923
20
- pycodex/prompts/models.json,sha256=D9AdqWpvxkhN6y4k3G6tJsjQFj99ATb4mjwGRcggWa8,349617
21
+ pycodex/prompts/models.json,sha256=Q3xyyqb6_c8_b3RN-0r0x2rpa-RUq5nxiCBtMdE8cZU,458204
21
22
  pycodex/prompts/permissions/approval_policy/never.md,sha256=QceTG6wjkaJARjYr0HYV1aPnPcpGcrkRUW-smWRr6MQ,120
22
23
  pycodex/prompts/permissions/approval_policy/on_failure.md,sha256=dfJjpXkpO6_ANdCKxbVJ8o4vyLxevrJWfKsGHTqtbkc,289
23
24
  pycodex/prompts/permissions/approval_policy/on_request.md,sha256=hVQalzh0FAdkKzw5u-N4H7-LtC9ijVDlYsh3OKsZKzo,3661
@@ -46,7 +47,7 @@ pycodex/tools/send_input_tool.py,sha256=vRg-f7LI2Sr01j1LeCPKeXto_ROQZ7bcUC5GIjx9
46
47
  pycodex/tools/shell_command_tool.py,sha256=wUw4lw8VLGIQ-7BIgyEsI0oqjj-Yr-MZn-_VDrnimAw,4202
47
48
  pycodex/tools/shell_tool.py,sha256=1m-Tcbn3His4ggyK5ec8Mkg6ihopqTvBd9F6fJlm6m4,4054
48
49
  pycodex/tools/spawn_agent_tool.py,sha256=7Me4Frjz7_fTpjMMFCvNmDHPOK2G1qTfXUr2Ugs61bU,6194
49
- pycodex/tools/unified_exec_manager.py,sha256=cKW_YNjSweVlGt8xNdeK_7gVwsGXxumCqjr-OlW5Xe0,12868
50
+ pycodex/tools/unified_exec_manager.py,sha256=YM0mKGygkprV51iAgt-A2YXJCG8Fyg3wuGGHktXuuQM,13957
50
51
  pycodex/tools/update_plan_tool.py,sha256=UsChtCBqI1RnVnPQbByPmD2LMZXMoCwfe6NpGqP8qu0,3174
51
52
  pycodex/tools/view_image_tool.py,sha256=2Xu5Vx7djVpz7-IV-LKrDKJjvVJc9AND-MFXp3CWplg,3892
52
53
  pycodex/tools/wait_agent_tool.py,sha256=0Uj9-IrXe2dSvOtOMq-RAc0XzaidwFH5q7Mri3BXWyM,3135
@@ -55,7 +56,7 @@ pycodex/tools/web_search_tool.py,sha256=mhiK_G6VC6K6-01KctmIpsr-BURDF8L4ZJyY6IFG
55
56
  pycodex/tools/write_stdin_tool.py,sha256=K--XMp-AyEdp8yQ9EseYHsK3QrGrK4AgzOyvUk4swmw,3608
56
57
  pycodex/utils/__init__.py,sha256=p3jaERPxkimNDhmMIsm4glvKiazf3UMv1X-qoH5Zl3U,963
57
58
  pycodex/utils/async_bridge.py,sha256=d21Pjim-nsQbSG5pJddd0WaQ03CzA3w3TINWDmmjWbg,1815
58
- pycodex/utils/compactor.py,sha256=zny3xhbc68FzpIlUJ1o0eEw0Cr9_rjBO_CyWDuoL74E,9013
59
+ pycodex/utils/compactor.py,sha256=REmxBha4UH7EODDhd9oKl0GsVsqujHCJ_2LUKR_R1qY,6524
59
60
  pycodex/utils/debug.py,sha256=JeEB5JfzYfbdG0fXlrWFmXyR1ts86fKsI_97IqgF6R0,296
60
61
  pycodex/utils/dotenv.py,sha256=rGKmurHjm7GdP4giyjHBPpSPv2Oi45qBqDB6HG3CnfA,1866
61
62
  pycodex/utils/get_env.py,sha256=5fNhcNhujOakWV6AS66rGW3jEA68WGpuE4YVXJZFE6U,7427
@@ -79,12 +80,12 @@ responses_server/tools/custom_adapter.py,sha256=LxO7ldydvR-GWachDz8GKC0Q8KGGFoFP
79
80
  responses_server/tools/web_search.py,sha256=pm4ZUiHUfxc0bGY1kEvt-BCzDrZIyP24xzPUcga2ul0,8908
80
81
  workspace_server/__init__.py,sha256=PRM4ONODb6hxjBUHkBD4TprE_pkTCIlT9sfftK5ic6M,866
81
82
  workspace_server/__main__.py,sha256=9SRp-Yw7ShGxc6DhSIXcDLKgGEdAVm3oBZ59rBOPjT0,62
82
- workspace_server/app.py,sha256=m1xG4ASJJMnB7mIIKAor43-oze7XFXXdIA8XBbUaclA,55098
83
- workspace_server/workspace.html,sha256=96uDofM2ZNnNzAhunyy5xPYk-Ii_m73elbiMbZJOAdA,40443
83
+ workspace_server/app.py,sha256=20lTENFBlercCzFmtAR4fIeMvqIZr8Gq_J2VotmNPck,56858
84
+ workspace_server/workspace.html,sha256=UqUqOfQFra7KV8btI4BUTB38jkkhYZvzzdN5OWpQwdQ,48521
84
85
  workspace_server/workspaces.html,sha256=GuYlFiOm1RJINrCKncClHt24D5i7XNABiP9C0xGxBA0,15679
85
86
  workspace_server/workspaces.py,sha256=Z-zoj0xgkOOP2CwbQl3vAI6TwLIDUInJKyobs0Jt3ps,19962
86
- python_codex-0.2.4.dist-info/METADATA,sha256=UgU9tI4s9gFR__d2aFnm4FqR-RPfa0odly3VWFJaWXE,18163
87
- python_codex-0.2.4.dist-info/WHEEL,sha256=KGYbc1zXlYddvwxnNty23BeaKzh7YuoSIvIMO4jEhvw,87
88
- python_codex-0.2.4.dist-info/entry_points.txt,sha256=vkV2UWCtEKvQNMJuPNjt8HyBKiwp83JyqBatrBNGDp8,80
89
- python_codex-0.2.4.dist-info/licenses/LICENSE,sha256=0X8ifk312hYAORM4hlzg8wVSEXYKNmiPgWlB1YIy2Nw,10926
90
- python_codex-0.2.4.dist-info/RECORD,,
87
+ python_codex-0.2.6.dist-info/METADATA,sha256=AjjNd0oWIKS2og9c2chID0_K4Jz2ICk2z6JfE_npUoM,19288
88
+ python_codex-0.2.6.dist-info/WHEEL,sha256=KGYbc1zXlYddvwxnNty23BeaKzh7YuoSIvIMO4jEhvw,87
89
+ python_codex-0.2.6.dist-info/entry_points.txt,sha256=vkV2UWCtEKvQNMJuPNjt8HyBKiwp83JyqBatrBNGDp8,80
90
+ python_codex-0.2.6.dist-info/licenses/LICENSE,sha256=0X8ifk312hYAORM4hlzg8wVSEXYKNmiPgWlB1YIy2Nw,10926
91
+ python_codex-0.2.6.dist-info/RECORD,,
workspace_server/app.py CHANGED
@@ -2,6 +2,7 @@ import argparse
2
2
  import asyncio
3
3
  import html
4
4
  import json
5
+ import mimetypes
5
6
  import os
6
7
  import secrets
7
8
  import threading
@@ -13,7 +14,13 @@ except ImportError: # pragma: no cover - Python 3.6 compatibility
13
14
  from pathlib import Path
14
15
 
15
16
  from fastapi import FastAPI, HTTPException, Request, WebSocket, WebSocketDisconnect
16
- from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse, Response
17
+ from fastapi.responses import (
18
+ FileResponse,
19
+ HTMLResponse,
20
+ JSONResponse,
21
+ RedirectResponse,
22
+ Response,
23
+ )
17
24
 
18
25
  from pycodex.cli import build_agent, build_cli_queue, build_model, configure_loguru
19
26
  from pycodex.interactive_session import run_interactive_session
@@ -968,6 +975,14 @@ def create_multi_workspace_app(
968
975
  return
969
976
  await _websocket_session_handler(entry.manager, websocket)
970
977
 
978
+ @app.api_route(
979
+ "/w/{workspace_id}/{asset_path:path}",
980
+ methods=["GET", "HEAD"],
981
+ )
982
+ async def workspace_board_asset(workspace_id: str, asset_path: str) -> Response:
983
+ entry = _workspace_entry_or_404(registry, workspace_id)
984
+ return _board_asset_response(entry.definition.board_path, asset_path)
985
+
971
986
  return app
972
987
 
973
988
 
@@ -1181,6 +1196,10 @@ def _install_workspace_routes(
1181
1196
  return
1182
1197
  await _websocket_session_handler(manager, websocket)
1183
1198
 
1199
+ @app.api_route("/{asset_path:path}", methods=["GET", "HEAD"])
1200
+ async def board_asset(asset_path: str) -> Response:
1201
+ return _board_asset_response(board_path, asset_path)
1202
+
1184
1203
 
1185
1204
  def _board_status_response(board_path: "typing.Union[Path, None]") -> JSONResponse:
1186
1205
  if board_path is None or not board_path.is_file():
@@ -1388,6 +1407,43 @@ def _board_response(board_path: "typing.Union[Path, None]") -> Response:
1388
1407
  return _html_response(board_path.read_text(encoding="utf-8", errors="replace"))
1389
1408
 
1390
1409
 
1410
+ def _board_asset_response(
1411
+ board_path: "typing.Union[Path, None]",
1412
+ asset_path: str,
1413
+ ) -> Response:
1414
+ media_type, unused_encoding = mimetypes.guess_type(str(asset_path or ""))
1415
+ del unused_encoding
1416
+ if board_path is None or not media_type or not media_type.startswith("image/"):
1417
+ raise HTTPException(status_code=404, detail="board image not found")
1418
+
1419
+ resolved_asset = None
1420
+ try:
1421
+ board_directory = board_path.parent.resolve()
1422
+ resolved_asset = (board_directory / asset_path).resolve()
1423
+ within_board_directory = (
1424
+ os.path.commonpath([str(board_directory), str(resolved_asset)])
1425
+ == str(board_directory)
1426
+ )
1427
+ except (OSError, RuntimeError, ValueError):
1428
+ within_board_directory = False
1429
+
1430
+ if (
1431
+ not within_board_directory
1432
+ or resolved_asset is None
1433
+ or not resolved_asset.is_file()
1434
+ ):
1435
+ raise HTTPException(status_code=404, detail="board image not found")
1436
+
1437
+ return FileResponse(
1438
+ str(resolved_asset),
1439
+ media_type=media_type,
1440
+ headers={
1441
+ "Cache-Control": "no-cache",
1442
+ "X-Content-Type-Options": "nosniff",
1443
+ },
1444
+ )
1445
+
1446
+
1391
1447
  def _workspace_entry_or_404(
1392
1448
  registry: 'WorkspaceRegistry',
1393
1449
  workspace_id: str,