dlab-cli 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- dlab/__init__.py +6 -0
- dlab/cli.py +1075 -0
- dlab/config.py +190 -0
- dlab/create_dpack.py +1096 -0
- dlab/create_dpack_wizard.py +1471 -0
- dlab/create_parallel_agent_wizard.py +582 -0
- dlab/data/__init__.py +0 -0
- dlab/data/models.json +1793 -0
- dlab/docker.py +591 -0
- dlab/local.py +269 -0
- dlab/model_fallback.py +360 -0
- dlab/parallel_tool.py +18 -0
- dlab/session.py +389 -0
- dlab/timeline.py +684 -0
- dlab/tui/__init__.py +9 -0
- dlab/tui/app.py +664 -0
- dlab/tui/log_watcher.py +208 -0
- dlab/tui/models.py +438 -0
- dlab/tui/widgets/__init__.py +18 -0
- dlab/tui/widgets/agent_list.py +170 -0
- dlab/tui/widgets/artifacts_pane.py +618 -0
- dlab/tui/widgets/log_view.py +505 -0
- dlab/tui/widgets/search_popup.py +151 -0
- dlab/tui/widgets/status_bar.py +106 -0
- dlab_cli-0.1.0.dist-info/METADATA +237 -0
- dlab_cli-0.1.0.dist-info/RECORD +30 -0
- dlab_cli-0.1.0.dist-info/WHEEL +5 -0
- dlab_cli-0.1.0.dist-info/entry_points.txt +2 -0
- dlab_cli-0.1.0.dist-info/licenses/LICENSE +201 -0
- dlab_cli-0.1.0.dist-info/top_level.txt +1 -0
dlab/session.py
ADDED
|
@@ -0,0 +1,389 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Session management for dlab work directories.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import re
|
|
7
|
+
import shutil
|
|
8
|
+
import subprocess
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
from dlab.model_fallback import process_opencode_dir
|
|
13
|
+
from dlab.parallel_tool import PARALLEL_AGENTS_SOURCE
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
STATE_FILE: str = ".state.json"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _session_dir_prefix(dpack_name: str) -> str:
|
|
20
|
+
"""Build session directory prefix from decision-pack name."""
|
|
21
|
+
return f"dlab-{dpack_name}-workdir-"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def get_next_sequence_number(base_dir: str, dpack_name: str = "analysis") -> int:
|
|
25
|
+
"""
|
|
26
|
+
Find the next available sequence number for session directories.
|
|
27
|
+
|
|
28
|
+
Parameters
|
|
29
|
+
----------
|
|
30
|
+
base_dir : str
|
|
31
|
+
Directory to search for existing session directories.
|
|
32
|
+
dpack_name : str
|
|
33
|
+
decision-pack name used as directory prefix.
|
|
34
|
+
|
|
35
|
+
Returns
|
|
36
|
+
-------
|
|
37
|
+
int
|
|
38
|
+
Next available sequence number (starts at 1).
|
|
39
|
+
"""
|
|
40
|
+
base_path: Path = Path(base_dir)
|
|
41
|
+
if not base_path.exists():
|
|
42
|
+
return 1
|
|
43
|
+
|
|
44
|
+
prefix: str = _session_dir_prefix(dpack_name)
|
|
45
|
+
pattern: re.Pattern[str] = re.compile(rf"^{re.escape(prefix)}(\d+)$")
|
|
46
|
+
max_seq: int = 0
|
|
47
|
+
|
|
48
|
+
for item in base_path.iterdir():
|
|
49
|
+
if item.is_dir():
|
|
50
|
+
match: re.Match[str] | None = pattern.match(item.name)
|
|
51
|
+
if match:
|
|
52
|
+
seq: int = int(match.group(1))
|
|
53
|
+
max_seq = max(max_seq, seq)
|
|
54
|
+
|
|
55
|
+
return max_seq + 1
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def copy_data_to_workdir(data_dir: str, work_dir: str) -> None:
|
|
59
|
+
"""
|
|
60
|
+
Copy source data directory to the work directory.
|
|
61
|
+
|
|
62
|
+
Parameters
|
|
63
|
+
----------
|
|
64
|
+
data_dir : str
|
|
65
|
+
Path to the source data directory.
|
|
66
|
+
work_dir : str
|
|
67
|
+
Path to the work directory.
|
|
68
|
+
|
|
69
|
+
Raises
|
|
70
|
+
------
|
|
71
|
+
ValueError
|
|
72
|
+
If data_dir does not exist or is not a directory.
|
|
73
|
+
"""
|
|
74
|
+
data_path: Path = Path(data_dir)
|
|
75
|
+
if not data_path.exists():
|
|
76
|
+
raise ValueError(f"Data directory does not exist: {data_dir}")
|
|
77
|
+
if not data_path.is_dir():
|
|
78
|
+
raise ValueError(f"Data path is not a directory: {data_dir}")
|
|
79
|
+
|
|
80
|
+
dest_path: Path = Path(work_dir) / "data"
|
|
81
|
+
shutil.copytree(data_path, dest_path)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def copy_data_paths_to_workdir(paths: list[str], work_dir: str) -> None:
|
|
85
|
+
"""
|
|
86
|
+
Copy data files and/or directories into the work directory.
|
|
87
|
+
|
|
88
|
+
Parameters
|
|
89
|
+
----------
|
|
90
|
+
paths : list[str]
|
|
91
|
+
Paths to files or directories.
|
|
92
|
+
work_dir : str
|
|
93
|
+
Path to the work directory.
|
|
94
|
+
|
|
95
|
+
Raises
|
|
96
|
+
------
|
|
97
|
+
ValueError
|
|
98
|
+
If any path does not exist.
|
|
99
|
+
"""
|
|
100
|
+
dest_path: Path = Path(work_dir) / "data"
|
|
101
|
+
dest_path.mkdir(parents=True, exist_ok=True)
|
|
102
|
+
|
|
103
|
+
for p in paths:
|
|
104
|
+
src: Path = Path(p)
|
|
105
|
+
if not src.exists():
|
|
106
|
+
raise ValueError(f"Data path does not exist: {p}")
|
|
107
|
+
if src.is_dir():
|
|
108
|
+
shutil.copytree(src, dest_path / src.name)
|
|
109
|
+
else:
|
|
110
|
+
shutil.copy2(src, dest_path / src.name)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def copy_opencode_config(config_dir: str, work_dir: str) -> None:
|
|
114
|
+
"""
|
|
115
|
+
Copy opencode configuration from decision-pack to work directory.
|
|
116
|
+
|
|
117
|
+
Parameters
|
|
118
|
+
----------
|
|
119
|
+
config_dir : str
|
|
120
|
+
Path to the decision-pack config directory.
|
|
121
|
+
work_dir : str
|
|
122
|
+
Path to the work directory.
|
|
123
|
+
|
|
124
|
+
Raises
|
|
125
|
+
------
|
|
126
|
+
ValueError
|
|
127
|
+
If opencode directory does not exist in config_dir.
|
|
128
|
+
"""
|
|
129
|
+
opencode_src: Path = Path(config_dir) / "opencode"
|
|
130
|
+
if not opencode_src.exists():
|
|
131
|
+
raise ValueError(f"opencode directory not found in: {config_dir}")
|
|
132
|
+
|
|
133
|
+
opencode_dest: Path = Path(work_dir) / ".opencode"
|
|
134
|
+
shutil.copytree(opencode_src, opencode_dest)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def copy_hook_scripts(config: dict[str, Any], work_dir: str) -> None:
|
|
138
|
+
"""
|
|
139
|
+
Copy hook scripts from decision-pack to work directory.
|
|
140
|
+
|
|
141
|
+
Copies pre-run and post-run scripts referenced in config hooks
|
|
142
|
+
to a _hooks/ directory in the work dir, preserving execute permissions.
|
|
143
|
+
|
|
144
|
+
Parameters
|
|
145
|
+
----------
|
|
146
|
+
config : dict[str, Any]
|
|
147
|
+
decision-pack configuration (from load_dpack_config).
|
|
148
|
+
work_dir : str
|
|
149
|
+
Path to the work directory.
|
|
150
|
+
"""
|
|
151
|
+
hooks: dict[str, Any] = config.get("hooks", {})
|
|
152
|
+
config_dir: Path = Path(config["config_dir"])
|
|
153
|
+
work_path: Path = Path(work_dir)
|
|
154
|
+
|
|
155
|
+
all_scripts: list[str] = hooks.get("pre-run", []) + hooks.get("post-run", [])
|
|
156
|
+
if not all_scripts:
|
|
157
|
+
return
|
|
158
|
+
|
|
159
|
+
hooks_dest: Path = work_path / "_hooks"
|
|
160
|
+
hooks_dest.mkdir(exist_ok=True)
|
|
161
|
+
|
|
162
|
+
for script_name in all_scripts:
|
|
163
|
+
src: Path = config_dir / script_name
|
|
164
|
+
if not src.exists():
|
|
165
|
+
raise ValueError(f"Hook script not found: {script_name}")
|
|
166
|
+
shutil.copy2(src, hooks_dest / src.name)
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def setup_opencode_config(
|
|
170
|
+
config_dir: str,
|
|
171
|
+
work_dir: str,
|
|
172
|
+
orchestrator_model: str | None = None,
|
|
173
|
+
env_file: str | None = None,
|
|
174
|
+
no_sandboxing: bool = False,
|
|
175
|
+
) -> list[str]:
|
|
176
|
+
"""
|
|
177
|
+
Set up opencode configuration in work directory.
|
|
178
|
+
|
|
179
|
+
Copies the opencode config from decision-pack, validates model names,
|
|
180
|
+
applies provider fallback, generates parallel-agents.ts if needed,
|
|
181
|
+
and sets up package.json dependencies.
|
|
182
|
+
|
|
183
|
+
Parameters
|
|
184
|
+
----------
|
|
185
|
+
config_dir : str
|
|
186
|
+
Path to the decision-pack config directory.
|
|
187
|
+
work_dir : str
|
|
188
|
+
Path to the work directory.
|
|
189
|
+
orchestrator_model : str | None
|
|
190
|
+
The orchestrator's model. If provided, agent configs referencing
|
|
191
|
+
providers without API keys in env_file will be replaced with this.
|
|
192
|
+
env_file : str | None
|
|
193
|
+
Path to .env file for checking available provider keys.
|
|
194
|
+
no_sandboxing : bool
|
|
195
|
+
If True, also check os.environ for API keys.
|
|
196
|
+
|
|
197
|
+
Returns
|
|
198
|
+
-------
|
|
199
|
+
list[str]
|
|
200
|
+
Messages from model validation and fallback (warnings, replacements).
|
|
201
|
+
"""
|
|
202
|
+
copy_opencode_config(config_dir, work_dir)
|
|
203
|
+
|
|
204
|
+
# Validate model names and apply provider fallback
|
|
205
|
+
messages: list[str] = []
|
|
206
|
+
opencode_dest: Path = Path(work_dir) / ".opencode"
|
|
207
|
+
if orchestrator_model:
|
|
208
|
+
messages = process_opencode_dir(
|
|
209
|
+
str(opencode_dest), orchestrator_model, env_file, no_sandboxing,
|
|
210
|
+
)
|
|
211
|
+
|
|
212
|
+
# Generate parallel-agents.ts if decision-pack has parallel_agents/ configs
|
|
213
|
+
parallel_configs_dir: Path = opencode_dest / "parallel_agents"
|
|
214
|
+
if parallel_configs_dir.exists() and any(parallel_configs_dir.glob("*.yaml")):
|
|
215
|
+
tools_dir: Path = opencode_dest / "tools"
|
|
216
|
+
tools_dir.mkdir(exist_ok=True)
|
|
217
|
+
(tools_dir / "parallel-agents.ts").write_text(PARALLEL_AGENTS_SOURCE)
|
|
218
|
+
|
|
219
|
+
# Ensure yaml dependency exists in package.json (needed by parallel-agents.ts)
|
|
220
|
+
package_json_path: Path = opencode_dest / "package.json"
|
|
221
|
+
if package_json_path.exists():
|
|
222
|
+
pkg: dict[str, Any] = json.loads(package_json_path.read_text())
|
|
223
|
+
if "dependencies" not in pkg:
|
|
224
|
+
pkg["dependencies"] = {}
|
|
225
|
+
if "yaml" not in pkg["dependencies"]:
|
|
226
|
+
pkg["dependencies"]["yaml"] = "^2.0.0"
|
|
227
|
+
package_json_path.write_text(json.dumps(pkg, indent=2))
|
|
228
|
+
else:
|
|
229
|
+
pkg = {"dependencies": {"yaml": "^2.0.0"}}
|
|
230
|
+
package_json_path.write_text(json.dumps(pkg, indent=2))
|
|
231
|
+
|
|
232
|
+
return messages
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def save_state(work_dir: str, state: dict[str, Any]) -> None:
|
|
236
|
+
"""
|
|
237
|
+
Save session state to .state.json in work directory.
|
|
238
|
+
|
|
239
|
+
Parameters
|
|
240
|
+
----------
|
|
241
|
+
work_dir : str
|
|
242
|
+
Path to the work directory.
|
|
243
|
+
state : dict[str, Any]
|
|
244
|
+
Session state to persist.
|
|
245
|
+
"""
|
|
246
|
+
state_path: Path = Path(work_dir) / STATE_FILE
|
|
247
|
+
with open(state_path, "w") as f:
|
|
248
|
+
json.dump(state, f, indent=2)
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def load_state(work_dir: str) -> dict[str, Any]:
|
|
252
|
+
"""
|
|
253
|
+
Load session state from work directory.
|
|
254
|
+
|
|
255
|
+
Parameters
|
|
256
|
+
----------
|
|
257
|
+
work_dir : str
|
|
258
|
+
Path to the work directory.
|
|
259
|
+
|
|
260
|
+
Returns
|
|
261
|
+
-------
|
|
262
|
+
dict[str, Any]
|
|
263
|
+
Session state.
|
|
264
|
+
|
|
265
|
+
Raises
|
|
266
|
+
------
|
|
267
|
+
ValueError
|
|
268
|
+
If .state.json does not exist or is invalid.
|
|
269
|
+
"""
|
|
270
|
+
state_path: Path = Path(work_dir) / STATE_FILE
|
|
271
|
+
if not state_path.exists():
|
|
272
|
+
raise ValueError(f"No .state.json found in: {work_dir}")
|
|
273
|
+
|
|
274
|
+
try:
|
|
275
|
+
with open(state_path, "r") as f:
|
|
276
|
+
state: dict[str, Any] = json.load(f)
|
|
277
|
+
except json.JSONDecodeError as e:
|
|
278
|
+
raise ValueError(f"Invalid JSON in .state.json: {e}")
|
|
279
|
+
|
|
280
|
+
return state
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
def create_session(
|
|
284
|
+
config: dict[str, Any],
|
|
285
|
+
data_dir: str | list[str] | None,
|
|
286
|
+
work_dir: str | None = None,
|
|
287
|
+
base_dir: str | None = None,
|
|
288
|
+
orchestrator_model: str | None = None,
|
|
289
|
+
env_file: str | None = None,
|
|
290
|
+
no_sandboxing: bool = False,
|
|
291
|
+
) -> dict[str, Any]:
|
|
292
|
+
"""
|
|
293
|
+
Create a new session with work directory.
|
|
294
|
+
|
|
295
|
+
Parameters
|
|
296
|
+
----------
|
|
297
|
+
config : dict[str, Any]
|
|
298
|
+
decision-pack configuration (from load_dpack_config).
|
|
299
|
+
data_dir : str | list[str] | None
|
|
300
|
+
Path to data directory, list of file/dir paths, or None.
|
|
301
|
+
work_dir : str | None
|
|
302
|
+
Explicit work directory path. If None, auto-generates one.
|
|
303
|
+
base_dir : str | None
|
|
304
|
+
Base directory for auto-generated work dirs. Defaults to current directory.
|
|
305
|
+
orchestrator_model : str | None
|
|
306
|
+
The orchestrator's model for provider fallback.
|
|
307
|
+
env_file : str | None
|
|
308
|
+
Path to .env file for checking available provider keys.
|
|
309
|
+
no_sandboxing : bool
|
|
310
|
+
If True, also check os.environ for API keys.
|
|
311
|
+
|
|
312
|
+
Returns
|
|
313
|
+
-------
|
|
314
|
+
dict[str, Any]
|
|
315
|
+
Session state including work_dir, config_dir, dpack_name, data_dir,
|
|
316
|
+
status, and model_fallback_messages.
|
|
317
|
+
|
|
318
|
+
Raises
|
|
319
|
+
------
|
|
320
|
+
ValueError
|
|
321
|
+
If data_dir is invalid or work_dir already exists.
|
|
322
|
+
"""
|
|
323
|
+
if base_dir is None:
|
|
324
|
+
base_dir = "."
|
|
325
|
+
|
|
326
|
+
if work_dir is None:
|
|
327
|
+
dpack_name: str = config.get("name", "analysis")
|
|
328
|
+
prefix: str = _session_dir_prefix(dpack_name)
|
|
329
|
+
seq: int = get_next_sequence_number(base_dir, dpack_name)
|
|
330
|
+
work_dir = str(Path(base_dir) / f"{prefix}{seq:03d}")
|
|
331
|
+
|
|
332
|
+
work_path: Path = Path(work_dir).resolve()
|
|
333
|
+
if work_path.exists():
|
|
334
|
+
raise ValueError(f"Work directory already exists: {work_dir}")
|
|
335
|
+
|
|
336
|
+
work_path.mkdir(parents=True)
|
|
337
|
+
try:
|
|
338
|
+
(work_path / "_opencode_logs").mkdir()
|
|
339
|
+
|
|
340
|
+
# Initialize git repo so OpenCode treats this as a project root.
|
|
341
|
+
# This prevents config traversal to parent directories.
|
|
342
|
+
if not (work_path / ".git").exists():
|
|
343
|
+
subprocess.run(
|
|
344
|
+
["git", "init"],
|
|
345
|
+
cwd=work_path,
|
|
346
|
+
stdout=subprocess.DEVNULL,
|
|
347
|
+
stderr=subprocess.DEVNULL,
|
|
348
|
+
)
|
|
349
|
+
|
|
350
|
+
if data_dir is not None:
|
|
351
|
+
if isinstance(data_dir, list):
|
|
352
|
+
# Multiple paths or single file — check if it's a single directory
|
|
353
|
+
if len(data_dir) == 1 and Path(data_dir[0]).is_dir():
|
|
354
|
+
copy_data_to_workdir(data_dir[0], str(work_path))
|
|
355
|
+
else:
|
|
356
|
+
copy_data_paths_to_workdir(data_dir, str(work_path))
|
|
357
|
+
else:
|
|
358
|
+
copy_data_to_workdir(data_dir, str(work_path))
|
|
359
|
+
|
|
360
|
+
fallback_messages: list[str] = setup_opencode_config(
|
|
361
|
+
config["config_dir"], str(work_path), orchestrator_model, env_file,
|
|
362
|
+
no_sandboxing,
|
|
363
|
+
)
|
|
364
|
+
copy_hook_scripts(config, str(work_path))
|
|
365
|
+
except Exception:
|
|
366
|
+
# Clean up the work dir we just created so the user can retry
|
|
367
|
+
# without hitting "already exists"
|
|
368
|
+
shutil.rmtree(work_path, ignore_errors=True)
|
|
369
|
+
raise
|
|
370
|
+
|
|
371
|
+
data_dir_str: str = ""
|
|
372
|
+
if data_dir is not None:
|
|
373
|
+
if isinstance(data_dir, list):
|
|
374
|
+
data_dir_str = ", ".join(str(Path(p).resolve()) for p in data_dir)
|
|
375
|
+
else:
|
|
376
|
+
data_dir_str = str(Path(data_dir).resolve())
|
|
377
|
+
|
|
378
|
+
state: dict[str, Any] = {
|
|
379
|
+
"work_dir": str(work_path),
|
|
380
|
+
"config_dir": config["config_dir"],
|
|
381
|
+
"dpack_name": config["name"],
|
|
382
|
+
"data_dir": data_dir_str,
|
|
383
|
+
"status": "created",
|
|
384
|
+
"model_fallback_messages": fallback_messages,
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
save_state(str(work_path), state)
|
|
388
|
+
|
|
389
|
+
return state
|