python-codex 0.2.1__py3-none-any.whl → 0.2.3__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/cli.py +21 -13
- pycodex/context.py +4 -1
- pycodex/feishu_link.py +32 -14
- pycodex/interactive_session.py +5 -1
- {python_codex-0.2.1.dist-info → python_codex-0.2.3.dist-info}/METADATA +21 -4
- {python_codex-0.2.1.dist-info → python_codex-0.2.3.dist-info}/RECORD +14 -12
- workspace_server/__init__.py +19 -3
- workspace_server/app.py +591 -369
- workspace_server/workspace.html +377 -32
- workspace_server/workspaces.html +551 -0
- workspace_server/workspaces.py +561 -0
- {python_codex-0.2.1.dist-info → python_codex-0.2.3.dist-info}/WHEEL +0 -0
- {python_codex-0.2.1.dist-info → python_codex-0.2.3.dist-info}/entry_points.txt +0 -0
- {python_codex-0.2.1.dist-info → python_codex-0.2.3.dist-info}/licenses/LICENSE +0 -0
|
@@ -0,0 +1,561 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import json
|
|
3
|
+
import os
|
|
4
|
+
import tempfile
|
|
5
|
+
import typing
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from uuid import uuid4
|
|
9
|
+
|
|
10
|
+
from pycodex.utils import uuid7_string
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
SessionFactory = typing.Callable[[], object]
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def default_board_path() -> Path:
|
|
17
|
+
return Path(tempfile.gettempdir()) / "pcws-{0}.html".format(uuid4().hex[:8])
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class WorkspaceStateStore:
|
|
21
|
+
def __init__(self, board_path: "typing.Union[Path, None]") -> None:
|
|
22
|
+
self.path = (
|
|
23
|
+
None if board_path is None else board_path.with_suffix(".pycodex-ws.json")
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
def load_tabs(self) -> "typing.List[typing.Dict[str, str]]":
|
|
27
|
+
if self.path is None or not self.path.is_file():
|
|
28
|
+
return []
|
|
29
|
+
|
|
30
|
+
try:
|
|
31
|
+
payload = json.loads(
|
|
32
|
+
self.path.read_text(encoding="utf-8", errors="replace") or "{}"
|
|
33
|
+
)
|
|
34
|
+
except (OSError, ValueError):
|
|
35
|
+
return []
|
|
36
|
+
|
|
37
|
+
tabs = payload.get("tabs") if isinstance(payload, dict) else None
|
|
38
|
+
if not isinstance(tabs, list):
|
|
39
|
+
return []
|
|
40
|
+
|
|
41
|
+
result = []
|
|
42
|
+
for tab in tabs:
|
|
43
|
+
if not isinstance(tab, dict):
|
|
44
|
+
continue
|
|
45
|
+
title = str(tab.get("title") or "").strip()
|
|
46
|
+
rollout_path = str(tab.get("rollout_path") or "").strip()
|
|
47
|
+
if title or rollout_path:
|
|
48
|
+
result.append({"title": title, "rollout_path": rollout_path})
|
|
49
|
+
return result
|
|
50
|
+
|
|
51
|
+
def save_tabs(self, tabs: "typing.Iterable[typing.Dict[str, str]]") -> None:
|
|
52
|
+
if self.path is None:
|
|
53
|
+
return
|
|
54
|
+
|
|
55
|
+
state_tabs = [
|
|
56
|
+
{
|
|
57
|
+
"title": str(tab.get("title") or ""),
|
|
58
|
+
"rollout_path": str(tab.get("rollout_path") or ""),
|
|
59
|
+
}
|
|
60
|
+
for tab in tabs
|
|
61
|
+
]
|
|
62
|
+
payload = json.dumps(
|
|
63
|
+
{"version": 1, "tabs": state_tabs},
|
|
64
|
+
ensure_ascii=False,
|
|
65
|
+
indent=2,
|
|
66
|
+
) + "\n"
|
|
67
|
+
self.path.parent.mkdir(parents=True, exist_ok=True)
|
|
68
|
+
self.path.write_text(payload, encoding="utf-8")
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
@dataclass(frozen=True)
|
|
72
|
+
class WorkspaceDefinition:
|
|
73
|
+
workspace_id: 'str'
|
|
74
|
+
board_path: 'typing.Union[Path, None]'
|
|
75
|
+
work_dir: 'Path'
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def load_workspace_definitions(
|
|
79
|
+
config_path: 'typing.Union[str, Path]',
|
|
80
|
+
) -> 'typing.List[WorkspaceDefinition]':
|
|
81
|
+
path = Path(config_path).expanduser().resolve()
|
|
82
|
+
if not path.exists():
|
|
83
|
+
return []
|
|
84
|
+
try:
|
|
85
|
+
payload = json.loads(path.read_text(encoding="utf-8", errors="replace"))
|
|
86
|
+
except OSError as exc:
|
|
87
|
+
raise ValueError("unable to read workspace config: {0}".format(exc)) from exc
|
|
88
|
+
except ValueError as exc:
|
|
89
|
+
raise ValueError("workspace config must be valid JSON") from exc
|
|
90
|
+
|
|
91
|
+
if isinstance(payload, dict):
|
|
92
|
+
workspaces = payload.get("workspaces")
|
|
93
|
+
else:
|
|
94
|
+
workspaces = payload
|
|
95
|
+
if not isinstance(workspaces, list):
|
|
96
|
+
raise ValueError("workspace config must contain a workspace list")
|
|
97
|
+
|
|
98
|
+
result = []
|
|
99
|
+
seen_ids = set()
|
|
100
|
+
seen_boards = set()
|
|
101
|
+
for index, item in enumerate(workspaces, start=1):
|
|
102
|
+
if not isinstance(item, dict):
|
|
103
|
+
raise ValueError("workspace entry {0} must be an object".format(index))
|
|
104
|
+
workspace_id = normalize_workspace_id(str(item.get("id") or "").strip())
|
|
105
|
+
if not workspace_id:
|
|
106
|
+
workspace_id = _next_workspace_id(seen_ids)
|
|
107
|
+
if workspace_id in seen_ids:
|
|
108
|
+
raise ValueError("duplicate workspace name: {0}".format(workspace_id))
|
|
109
|
+
seen_ids.add(workspace_id)
|
|
110
|
+
|
|
111
|
+
work_dir_value = item.get("work_dir")
|
|
112
|
+
if work_dir_value is None:
|
|
113
|
+
work_dir_value = item.get("cwd")
|
|
114
|
+
if not str(work_dir_value or "").strip():
|
|
115
|
+
raise ValueError("workspace `{0}` is missing `work_dir`".format(workspace_id))
|
|
116
|
+
work_dir = _resolve_workspace_path(str(work_dir_value), path.parent)
|
|
117
|
+
if not work_dir.is_dir():
|
|
118
|
+
raise ValueError(
|
|
119
|
+
"workspace `{0}` work_dir does not exist: {1}".format(
|
|
120
|
+
workspace_id,
|
|
121
|
+
work_dir,
|
|
122
|
+
)
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
board_value = item.get("board")
|
|
126
|
+
if not str(board_value or "").strip():
|
|
127
|
+
raise ValueError("workspace `{0}` is missing `board`".format(workspace_id))
|
|
128
|
+
board_path = _resolve_workspace_path(str(board_value), path.parent)
|
|
129
|
+
if board_path in seen_boards:
|
|
130
|
+
raise ValueError("duplicate workspace board: {0}".format(board_path))
|
|
131
|
+
seen_boards.add(board_path)
|
|
132
|
+
if not board_path.parent.is_dir():
|
|
133
|
+
raise ValueError(
|
|
134
|
+
"workspace `{0}` board parent directory does not exist: {1}".format(
|
|
135
|
+
workspace_id,
|
|
136
|
+
board_path.parent,
|
|
137
|
+
)
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
result.append(
|
|
141
|
+
WorkspaceDefinition(
|
|
142
|
+
workspace_id=workspace_id,
|
|
143
|
+
board_path=board_path,
|
|
144
|
+
work_dir=work_dir,
|
|
145
|
+
)
|
|
146
|
+
)
|
|
147
|
+
return result
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def save_workspace_definitions(
|
|
151
|
+
config_path: 'typing.Union[str, Path]',
|
|
152
|
+
definitions: 'typing.Iterable[WorkspaceDefinition]',
|
|
153
|
+
) -> None:
|
|
154
|
+
path = Path(config_path).expanduser().resolve()
|
|
155
|
+
base_dir = path.parent
|
|
156
|
+
payload = {
|
|
157
|
+
"workspaces": [
|
|
158
|
+
_workspace_definition_to_json(definition, base_dir)
|
|
159
|
+
for definition in definitions
|
|
160
|
+
]
|
|
161
|
+
}
|
|
162
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
163
|
+
path.write_text(
|
|
164
|
+
json.dumps(payload, ensure_ascii=False, indent=2) + "\n",
|
|
165
|
+
encoding="utf-8",
|
|
166
|
+
)
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def _workspace_definition_to_json(
|
|
170
|
+
definition: 'WorkspaceDefinition',
|
|
171
|
+
base_dir: 'Path',
|
|
172
|
+
) -> 'typing.Dict[str, str]':
|
|
173
|
+
result = {
|
|
174
|
+
"id": definition.workspace_id,
|
|
175
|
+
"work_dir": _format_path_for_workspace_config(definition.work_dir, base_dir),
|
|
176
|
+
}
|
|
177
|
+
if definition.board_path is not None:
|
|
178
|
+
result["board"] = _format_path_for_workspace_config(
|
|
179
|
+
definition.board_path,
|
|
180
|
+
base_dir,
|
|
181
|
+
)
|
|
182
|
+
return result
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def _format_path_for_workspace_config(path: 'Path', base_dir: 'Path') -> 'str':
|
|
186
|
+
resolved = path.resolve()
|
|
187
|
+
try:
|
|
188
|
+
relative = os.path.relpath(str(resolved), str(base_dir.resolve()))
|
|
189
|
+
except ValueError:
|
|
190
|
+
return str(resolved)
|
|
191
|
+
if relative == ".":
|
|
192
|
+
return "."
|
|
193
|
+
if relative.startswith("..") or os.path.isabs(relative):
|
|
194
|
+
return str(resolved)
|
|
195
|
+
return relative
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def _resolve_workspace_path(value: str, base_dir: 'Path') -> 'Path':
|
|
199
|
+
path = Path(str(value)).expanduser()
|
|
200
|
+
if not path.is_absolute():
|
|
201
|
+
path = base_dir / path
|
|
202
|
+
return path.resolve()
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def _next_workspace_id(existing: 'typing.Container[str]') -> str:
|
|
206
|
+
index = 1
|
|
207
|
+
while True:
|
|
208
|
+
candidate = "workspace-{0}".format(index)
|
|
209
|
+
if candidate not in existing:
|
|
210
|
+
return candidate
|
|
211
|
+
index += 1
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def normalize_workspace_id(workspace_id: str) -> str:
|
|
215
|
+
text = str(workspace_id or "").strip()
|
|
216
|
+
if not text:
|
|
217
|
+
return ""
|
|
218
|
+
allowed = set("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_-")
|
|
219
|
+
if any(char not in allowed for char in text):
|
|
220
|
+
raise ValueError(
|
|
221
|
+
"workspace id may only contain letters, numbers, underscore, and dash: {0}".format(
|
|
222
|
+
text
|
|
223
|
+
)
|
|
224
|
+
)
|
|
225
|
+
return text
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
class WorkspaceSessionManager:
|
|
229
|
+
def __init__(
|
|
230
|
+
self,
|
|
231
|
+
session_factory: "SessionFactory",
|
|
232
|
+
board_path: "typing.Union[Path, None]" = None,
|
|
233
|
+
persist_callback: "typing.Union[typing.Callable[[], None], None]" = None,
|
|
234
|
+
) -> None:
|
|
235
|
+
self._session_factory = session_factory
|
|
236
|
+
self._sessions: "typing.Dict[str, object]" = {}
|
|
237
|
+
self._session_order: "typing.List[str]" = []
|
|
238
|
+
self._state_watchers: "typing.Dict[str, asyncio.Task]" = {}
|
|
239
|
+
self._persisted_titles: "typing.Dict[str, str]" = {}
|
|
240
|
+
self._lock = asyncio.Lock()
|
|
241
|
+
self._state_store = WorkspaceStateStore(board_path)
|
|
242
|
+
self._persist_callback = persist_callback
|
|
243
|
+
|
|
244
|
+
def set_persist_callback(
|
|
245
|
+
self,
|
|
246
|
+
callback: "typing.Union[typing.Callable[[], None], None]",
|
|
247
|
+
) -> None:
|
|
248
|
+
self._persist_callback = callback
|
|
249
|
+
|
|
250
|
+
async def start(self) -> None:
|
|
251
|
+
state_tabs = self._state_store.load_tabs()
|
|
252
|
+
if not state_tabs:
|
|
253
|
+
await self.create_session()
|
|
254
|
+
return
|
|
255
|
+
for tab in state_tabs:
|
|
256
|
+
await self.create_session(
|
|
257
|
+
title=str(tab.get("title") or ""),
|
|
258
|
+
rollout_path=str(tab.get("rollout_path") or ""),
|
|
259
|
+
)
|
|
260
|
+
|
|
261
|
+
async def close(self) -> None:
|
|
262
|
+
sessions = list(self._sessions.values())
|
|
263
|
+
watchers = list(self._state_watchers.values())
|
|
264
|
+
self._sessions.clear()
|
|
265
|
+
self._session_order = []
|
|
266
|
+
self._state_watchers.clear()
|
|
267
|
+
self._persisted_titles.clear()
|
|
268
|
+
for watcher in watchers:
|
|
269
|
+
watcher.cancel()
|
|
270
|
+
if watchers:
|
|
271
|
+
await asyncio.gather(*watchers, return_exceptions=True)
|
|
272
|
+
for session in sessions:
|
|
273
|
+
await session.close()
|
|
274
|
+
|
|
275
|
+
async def create_session(
|
|
276
|
+
self,
|
|
277
|
+
title: str = "",
|
|
278
|
+
rollout_path: str = "",
|
|
279
|
+
) -> str:
|
|
280
|
+
async with self._lock:
|
|
281
|
+
session_id = uuid7_string()
|
|
282
|
+
session = self._session_factory()
|
|
283
|
+
await session.start()
|
|
284
|
+
|
|
285
|
+
if rollout_path:
|
|
286
|
+
await session.restore_from_rollout(rollout_path, title=title)
|
|
287
|
+
|
|
288
|
+
self._sessions[session_id] = session
|
|
289
|
+
self._session_order.append(session_id)
|
|
290
|
+
self._persisted_titles[session_id] = str(
|
|
291
|
+
session_summary(session).get("title") or ""
|
|
292
|
+
)
|
|
293
|
+
self._state_watchers[session_id] = asyncio.create_task(
|
|
294
|
+
self._watch_session_title(session_id, session)
|
|
295
|
+
)
|
|
296
|
+
return session_id
|
|
297
|
+
|
|
298
|
+
async def close_session(self, session_id: str) -> None:
|
|
299
|
+
async with self._lock:
|
|
300
|
+
if len(self._session_order) <= 1:
|
|
301
|
+
raise ValueError("cannot close the last session")
|
|
302
|
+
session = self._sessions.pop(session_id, None)
|
|
303
|
+
if session is None:
|
|
304
|
+
raise KeyError(session_id)
|
|
305
|
+
watcher = self._state_watchers.pop(session_id, None)
|
|
306
|
+
self._persisted_titles.pop(session_id, None)
|
|
307
|
+
self._session_order = [
|
|
308
|
+
item for item in self._session_order if item != session_id
|
|
309
|
+
]
|
|
310
|
+
if watcher is not None:
|
|
311
|
+
watcher.cancel()
|
|
312
|
+
await asyncio.gather(watcher, return_exceptions=True)
|
|
313
|
+
await session.close()
|
|
314
|
+
self.persist_workspace_state()
|
|
315
|
+
|
|
316
|
+
async def _watch_session_title(self, session_id: str, session) -> None:
|
|
317
|
+
subscriber = session.subscribe()
|
|
318
|
+
try:
|
|
319
|
+
while True:
|
|
320
|
+
event = await subscriber.get()
|
|
321
|
+
if event is None:
|
|
322
|
+
return
|
|
323
|
+
if not isinstance(event, dict) or event.get("type") != "title_changed":
|
|
324
|
+
continue
|
|
325
|
+
title = str(event.get("title") or "")
|
|
326
|
+
if title == self._persisted_titles.get(session_id, ""):
|
|
327
|
+
continue
|
|
328
|
+
self._persisted_titles[session_id] = title
|
|
329
|
+
self.persist_workspace_state()
|
|
330
|
+
finally:
|
|
331
|
+
session.unsubscribe(subscriber)
|
|
332
|
+
|
|
333
|
+
def persist_workspace_state(self) -> None:
|
|
334
|
+
tabs = []
|
|
335
|
+
for session_id in self._session_order:
|
|
336
|
+
session = self._sessions.get(session_id)
|
|
337
|
+
if session is None:
|
|
338
|
+
continue
|
|
339
|
+
summary = session_summary(session)
|
|
340
|
+
title = str(summary.get("title") or "").strip()
|
|
341
|
+
rollout_path = str(session.rollout_path() or "")
|
|
342
|
+
if not title and not rollout_path:
|
|
343
|
+
continue
|
|
344
|
+
tabs.append({"title": title, "rollout_path": rollout_path})
|
|
345
|
+
self._state_store.save_tabs(tabs)
|
|
346
|
+
if self._persist_callback is not None:
|
|
347
|
+
self._persist_callback()
|
|
348
|
+
|
|
349
|
+
def get(self, session_id: "typing.Union[str, None]" = None) -> object:
|
|
350
|
+
resolved_id = self.resolve_session_id(session_id)
|
|
351
|
+
try:
|
|
352
|
+
return self._sessions[resolved_id]
|
|
353
|
+
except KeyError:
|
|
354
|
+
raise KeyError(resolved_id)
|
|
355
|
+
|
|
356
|
+
def resolve_session_id(self, session_id: "typing.Union[str, None]" = None) -> str:
|
|
357
|
+
if session_id:
|
|
358
|
+
return str(session_id)
|
|
359
|
+
if not self._session_order:
|
|
360
|
+
raise KeyError("no sessions")
|
|
361
|
+
return self._session_order[0]
|
|
362
|
+
|
|
363
|
+
def list_sessions(self) -> "typing.List[typing.Dict[str, object]]":
|
|
364
|
+
result = []
|
|
365
|
+
for session_id in self._session_order:
|
|
366
|
+
session = self._sessions[session_id]
|
|
367
|
+
summary = session_summary(session)
|
|
368
|
+
result.append(
|
|
369
|
+
{
|
|
370
|
+
"id": session_id,
|
|
371
|
+
"title": summary.get("title") or "pycodex",
|
|
372
|
+
"running": bool(summary.get("running")),
|
|
373
|
+
"spinner": summary.get("spinner") or "",
|
|
374
|
+
"turn_count": summary.get("turn_count") or 0,
|
|
375
|
+
"last_assistant": summary.get("last_assistant") or "",
|
|
376
|
+
"context_remaining_percent": summary.get(
|
|
377
|
+
"context_remaining_percent"
|
|
378
|
+
),
|
|
379
|
+
}
|
|
380
|
+
)
|
|
381
|
+
return result
|
|
382
|
+
|
|
383
|
+
|
|
384
|
+
@dataclass(frozen=True)
|
|
385
|
+
class WorkspaceEntry:
|
|
386
|
+
definition: 'WorkspaceDefinition'
|
|
387
|
+
manager: 'WorkspaceSessionManager'
|
|
388
|
+
|
|
389
|
+
def to_dict(self) -> 'typing.Dict[str, object]':
|
|
390
|
+
return {
|
|
391
|
+
"id": self.definition.workspace_id,
|
|
392
|
+
"board_path": (
|
|
393
|
+
"" if self.definition.board_path is None
|
|
394
|
+
else str(self.definition.board_path)
|
|
395
|
+
),
|
|
396
|
+
"work_dir": str(self.definition.work_dir),
|
|
397
|
+
"sessions": self.manager.list_sessions(),
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
|
|
401
|
+
WorkspaceEntryFactory = typing.Callable[
|
|
402
|
+
[WorkspaceDefinition, "typing.Union[typing.Callable[[], None], None]"],
|
|
403
|
+
WorkspaceEntry,
|
|
404
|
+
]
|
|
405
|
+
|
|
406
|
+
|
|
407
|
+
class WorkspaceRegistry:
|
|
408
|
+
def __init__(
|
|
409
|
+
self,
|
|
410
|
+
entries: 'typing.Iterable[WorkspaceEntry]',
|
|
411
|
+
config_path: 'typing.Union[str, Path, None]' = None,
|
|
412
|
+
entry_factory: "typing.Union[WorkspaceEntryFactory, None]" = None,
|
|
413
|
+
) -> None:
|
|
414
|
+
self._entries: 'typing.Dict[str, WorkspaceEntry]' = {}
|
|
415
|
+
self._name_to_key: 'typing.Dict[str, str]' = {}
|
|
416
|
+
self._order: 'typing.List[str]' = []
|
|
417
|
+
self._started = False
|
|
418
|
+
self._config_path = (
|
|
419
|
+
None if config_path is None else Path(config_path).expanduser().resolve()
|
|
420
|
+
)
|
|
421
|
+
self._entry_factory = entry_factory
|
|
422
|
+
for entry in entries:
|
|
423
|
+
self._register(entry)
|
|
424
|
+
if not self._entries and self._entry_factory is None:
|
|
425
|
+
raise ValueError("at least one workspace is required")
|
|
426
|
+
|
|
427
|
+
def _register(self, entry: 'WorkspaceEntry') -> None:
|
|
428
|
+
workspace_id = entry.definition.workspace_id
|
|
429
|
+
key = self._key_for_definition(entry.definition)
|
|
430
|
+
if key in self._entries:
|
|
431
|
+
raise ValueError(
|
|
432
|
+
"duplicate workspace board: {0}".format(entry.definition.board_path)
|
|
433
|
+
)
|
|
434
|
+
if workspace_id in self._name_to_key:
|
|
435
|
+
raise ValueError("duplicate workspace name: {0}".format(workspace_id))
|
|
436
|
+
self._entries[key] = entry
|
|
437
|
+
self._name_to_key[workspace_id] = key
|
|
438
|
+
self._order.append(key)
|
|
439
|
+
entry.manager.set_persist_callback(self.persist_definitions)
|
|
440
|
+
|
|
441
|
+
def _key_for_definition(self, definition: 'WorkspaceDefinition') -> str:
|
|
442
|
+
if definition.board_path is None:
|
|
443
|
+
raise ValueError("workspace `{0}` is missing board".format(definition.workspace_id))
|
|
444
|
+
return str(definition.board_path.resolve())
|
|
445
|
+
|
|
446
|
+
async def start(self) -> None:
|
|
447
|
+
self._started = True
|
|
448
|
+
for key in self._order:
|
|
449
|
+
await self._entries[key].manager.start()
|
|
450
|
+
|
|
451
|
+
async def close(self) -> None:
|
|
452
|
+
for key in reversed(self._order):
|
|
453
|
+
await self._entries[key].manager.close()
|
|
454
|
+
self._started = False
|
|
455
|
+
|
|
456
|
+
def get(self, workspace_id: str) -> 'WorkspaceEntry':
|
|
457
|
+
workspace_id = normalize_workspace_id(workspace_id)
|
|
458
|
+
if not workspace_id:
|
|
459
|
+
raise KeyError(workspace_id)
|
|
460
|
+
try:
|
|
461
|
+
return self._entries[self._name_to_key[workspace_id]]
|
|
462
|
+
except KeyError:
|
|
463
|
+
raise KeyError(workspace_id)
|
|
464
|
+
|
|
465
|
+
async def add_workspace(
|
|
466
|
+
self,
|
|
467
|
+
name: str,
|
|
468
|
+
work_dir: str = "./",
|
|
469
|
+
board: "typing.Union[str, None]" = None,
|
|
470
|
+
) -> 'WorkspaceEntry':
|
|
471
|
+
if self._entry_factory is None:
|
|
472
|
+
raise ValueError("workspace creation is unavailable")
|
|
473
|
+
|
|
474
|
+
base_dir = self._config_path.parent if self._config_path is not None else Path.cwd()
|
|
475
|
+
resolved_work_dir = _resolve_workspace_path(str(work_dir or "./"), base_dir)
|
|
476
|
+
resolved_work_dir.mkdir(parents=True, exist_ok=True)
|
|
477
|
+
|
|
478
|
+
board_path = (
|
|
479
|
+
_resolve_workspace_path(str(board), base_dir)
|
|
480
|
+
if str(board or "").strip()
|
|
481
|
+
else default_board_path()
|
|
482
|
+
)
|
|
483
|
+
board_path.parent.mkdir(parents=True, exist_ok=True)
|
|
484
|
+
board_key = str(board_path.resolve())
|
|
485
|
+
if board_key in self._entries:
|
|
486
|
+
raise ValueError("workspace board already exists: {0}".format(board_path))
|
|
487
|
+
|
|
488
|
+
workspace_id = normalize_workspace_id(name)
|
|
489
|
+
if not workspace_id:
|
|
490
|
+
workspace_id = _next_workspace_id(self._name_to_key)
|
|
491
|
+
if workspace_id in self._name_to_key:
|
|
492
|
+
raise ValueError("workspace name already exists: {0}".format(workspace_id))
|
|
493
|
+
|
|
494
|
+
definition = WorkspaceDefinition(
|
|
495
|
+
workspace_id=workspace_id,
|
|
496
|
+
board_path=board_path,
|
|
497
|
+
work_dir=resolved_work_dir,
|
|
498
|
+
)
|
|
499
|
+
entry = self._entry_factory(definition, self.persist_definitions)
|
|
500
|
+
self._register(entry)
|
|
501
|
+
if self._started:
|
|
502
|
+
await entry.manager.start()
|
|
503
|
+
self.persist_definitions()
|
|
504
|
+
return entry
|
|
505
|
+
|
|
506
|
+
async def delete_workspace(self, name: str) -> 'WorkspaceDefinition':
|
|
507
|
+
workspace_id = normalize_workspace_id(name)
|
|
508
|
+
if not workspace_id:
|
|
509
|
+
raise KeyError(workspace_id)
|
|
510
|
+
try:
|
|
511
|
+
key = self._name_to_key.pop(workspace_id)
|
|
512
|
+
entry = self._entries.pop(key)
|
|
513
|
+
except KeyError:
|
|
514
|
+
raise KeyError(workspace_id)
|
|
515
|
+
self._order = [item for item in self._order if item != key]
|
|
516
|
+
await entry.manager.close()
|
|
517
|
+
self.persist_definitions()
|
|
518
|
+
return entry.definition
|
|
519
|
+
|
|
520
|
+
def persist_definitions(self) -> None:
|
|
521
|
+
if self._config_path is None:
|
|
522
|
+
return
|
|
523
|
+
save_workspace_definitions(
|
|
524
|
+
self._config_path,
|
|
525
|
+
[self._entries[key].definition for key in self._order],
|
|
526
|
+
)
|
|
527
|
+
|
|
528
|
+
def list_workspaces(self) -> 'typing.List[typing.Dict[str, object]]':
|
|
529
|
+
return [
|
|
530
|
+
self._entries[key].to_dict()
|
|
531
|
+
for key in self._order
|
|
532
|
+
]
|
|
533
|
+
|
|
534
|
+
|
|
535
|
+
def session_snapshot(session) -> "typing.Dict[str, object]":
|
|
536
|
+
return typing.cast("typing.Dict[str, object]", session.snapshot())
|
|
537
|
+
|
|
538
|
+
|
|
539
|
+
def session_summary(session) -> "typing.Dict[str, object]":
|
|
540
|
+
summary = getattr(session, "summary", None)
|
|
541
|
+
if callable(summary):
|
|
542
|
+
return typing.cast("typing.Dict[str, object]", summary())
|
|
543
|
+
snapshot = session_snapshot(session)
|
|
544
|
+
return {
|
|
545
|
+
"title": snapshot.get("title") or "",
|
|
546
|
+
"running": bool(snapshot.get("running")),
|
|
547
|
+
"spinner": snapshot.get("spinner") or "",
|
|
548
|
+
"turn_count": len(snapshot.get("turns") or []),
|
|
549
|
+
"last_assistant": _last_assistant_text(snapshot.get("turns") or []),
|
|
550
|
+
"context_remaining_percent": snapshot.get("context_remaining_percent"),
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
|
|
554
|
+
def _last_assistant_text(turns: "typing.Iterable[typing.Dict[str, object]]") -> str:
|
|
555
|
+
for turn in reversed(list(turns)):
|
|
556
|
+
if str(turn.get("kind") or "assistant") == "control":
|
|
557
|
+
continue
|
|
558
|
+
response = str(turn.get("response") or "").strip()
|
|
559
|
+
if response:
|
|
560
|
+
return response
|
|
561
|
+
return ""
|
|
File without changes
|
|
File without changes
|
|
File without changes
|