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/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