plato-sdk-v2 2.3.3__py3-none-any.whl → 2.4.2__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.
@@ -0,0 +1,767 @@
1
+ """Plato Chronos CLI - Launch and manage Chronos jobs."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import json
7
+ import logging
8
+ import os
9
+ import subprocess
10
+ import tempfile
11
+ from pathlib import Path
12
+ from typing import Annotated
13
+
14
+ import typer
15
+
16
+ from plato.v1.cli.utils import console
17
+
18
+ chronos_app = typer.Typer(help="Chronos job management commands.")
19
+ logger = logging.getLogger(__name__)
20
+
21
+
22
+ @chronos_app.command()
23
+ def launch(
24
+ config: Path = typer.Argument(
25
+ ...,
26
+ help="Path to job config JSON file",
27
+ exists=True,
28
+ readable=True,
29
+ ),
30
+ chronos_url: str = typer.Option(
31
+ None,
32
+ "--url",
33
+ "-u",
34
+ envvar="CHRONOS_URL",
35
+ help="Chronos API URL (default: https://chronos.plato.so)",
36
+ ),
37
+ api_key: str = typer.Option(
38
+ None,
39
+ "--api-key",
40
+ "-k",
41
+ envvar="PLATO_API_KEY",
42
+ help="Plato API key for authentication",
43
+ ),
44
+ wait: bool = typer.Option(
45
+ False,
46
+ "--wait",
47
+ "-w",
48
+ help="Wait for job completion and stream logs",
49
+ ),
50
+ ):
51
+ """
52
+ Launch a Chronos job from a config file.
53
+
54
+ \b
55
+ Config format:
56
+ {
57
+ "world": {
58
+ "package": "plato-world-structured-execution:0.1.17",
59
+ "config": {
60
+ "skill_runner": {
61
+ "image": "computer-use:1.1.0",
62
+ "config": { ... }
63
+ },
64
+ "secrets": { ... }
65
+ }
66
+ },
67
+ "runtime": {
68
+ "artifact_id": "..." // optional
69
+ }
70
+ }
71
+
72
+ Examples:
73
+ plato chronos launch config.json
74
+ plato chronos launch config.json --wait
75
+ """
76
+ import httpx
77
+
78
+ # Set defaults
79
+ if not chronos_url:
80
+ chronos_url = "https://chronos.plato.so"
81
+
82
+ if not api_key:
83
+ console.print("[red]❌ No API key provided[/red]")
84
+ console.print("Set PLATO_API_KEY environment variable or use --api-key")
85
+ raise typer.Exit(1)
86
+
87
+ # Load config
88
+ try:
89
+ with open(config) as f:
90
+ job_config = json.load(f)
91
+ except json.JSONDecodeError as e:
92
+ console.print(f"[red]❌ Invalid JSON in config file: {e}[/red]")
93
+ raise typer.Exit(1)
94
+
95
+ # Validate required fields
96
+ if "world" not in job_config or "package" not in job_config.get("world", {}):
97
+ console.print("[red]❌ Missing required field: world.package[/red]")
98
+ raise typer.Exit(1)
99
+
100
+ # Build request
101
+ request_body = {
102
+ "world": job_config["world"],
103
+ "runtime": job_config.get("runtime", {}),
104
+ }
105
+
106
+ world_package = job_config["world"]["package"]
107
+ console.print("[blue]🚀 Launching job...[/blue]")
108
+ console.print(f" World: {world_package}")
109
+
110
+ try:
111
+ with httpx.Client(timeout=60) as client:
112
+ response = client.post(
113
+ f"{chronos_url.rstrip('/')}/api/jobs/launch",
114
+ json=request_body,
115
+ headers={"X-API-Key": api_key},
116
+ )
117
+ response.raise_for_status()
118
+ result = response.json()
119
+
120
+ console.print("\n[green]✅ Job launched successfully![/green]")
121
+ console.print(f" Session ID: {result['session_id']}")
122
+ console.print(f" Plato Session: {result.get('plato_session_id', 'N/A')}")
123
+ console.print(f" Status: {result['status']}")
124
+ console.print(f"\n[dim]View at: {chronos_url}/sessions/{result['session_id']}[/dim]")
125
+
126
+ if wait:
127
+ console.print("\n[yellow]--wait not yet implemented[/yellow]")
128
+
129
+ except Exception as e:
130
+ console.print(f"[red]❌ Failed to launch job: {e}[/red]")
131
+ raise typer.Exit(1)
132
+
133
+
134
+ @chronos_app.command()
135
+ def example(
136
+ world: str = typer.Argument(
137
+ "structured-execution",
138
+ help="World to generate example config for",
139
+ ),
140
+ output: Path = typer.Option(
141
+ None,
142
+ "--output",
143
+ "-o",
144
+ help="Output file path (prints to stdout if not specified)",
145
+ ),
146
+ ):
147
+ """
148
+ Generate an example job config file.
149
+
150
+ Examples:
151
+ plato chronos example
152
+ plato chronos example structured-execution -o config.json
153
+ """
154
+ examples = {
155
+ "structured-execution": {
156
+ "world_package": "plato-world-structured-execution",
157
+ "world_version": "latest",
158
+ "world_config": {
159
+ "sim_name": "my-sim",
160
+ "github_url": "https://github.com/example/repo",
161
+ "max_attempts": 3,
162
+ "use_backtrack": True,
163
+ "skill_runner": {
164
+ "image": "claude-code:2.1.5",
165
+ "config": {"model_name": "anthropic/claude-sonnet-4-20250514", "max_turns": 100},
166
+ },
167
+ "plato_api_key": "pk_xxx",
168
+ "anthropic_api_key": "sk-ant-xxx",
169
+ },
170
+ "_comment": "Agents and secrets are embedded directly in world_config",
171
+ },
172
+ "code-world": {
173
+ "world_package": "plato-world-code",
174
+ "world_config": {
175
+ "task": "Fix the bug in src/main.py",
176
+ "repo_url": "https://github.com/example/repo",
177
+ "coder": {
178
+ "image": "claude-code:latest",
179
+ "config": {"model_name": "anthropic/claude-sonnet-4-20250514"},
180
+ },
181
+ },
182
+ "_comment": "world_version is optional - uses latest if not specified",
183
+ },
184
+ }
185
+
186
+ if world not in examples:
187
+ console.print(f"[red]❌ Unknown world: {world}[/red]")
188
+ console.print(f"Available examples: {list(examples.keys())}")
189
+ raise typer.Exit(1)
190
+
191
+ example_config = examples[world]
192
+ json_output = json.dumps(example_config, indent=2)
193
+
194
+ if output:
195
+ with open(output, "w") as f:
196
+ f.write(json_output)
197
+ console.print(f"[green]✅ Example config written to {output}[/green]")
198
+ else:
199
+ console.print(json_output)
200
+
201
+
202
+ def _get_world_runner_dockerfile() -> Path:
203
+ """Get the path to the world runner Dockerfile template."""
204
+ return Path(__file__).parent / "templates" / "world-runner.Dockerfile"
205
+
206
+
207
+ def _build_world_runner_image(platform_override: str | None = None) -> str:
208
+ """Build the world runner Docker image if needed."""
209
+ image_tag = "plato-world-runner:latest"
210
+ dockerfile_path = _get_world_runner_dockerfile()
211
+
212
+ if not dockerfile_path.exists():
213
+ raise FileNotFoundError(f"World runner Dockerfile not found: {dockerfile_path}")
214
+
215
+ docker_platform = _get_docker_platform(platform_override)
216
+
217
+ # Check if image exists
218
+ result = subprocess.run(
219
+ ["docker", "images", "-q", image_tag],
220
+ capture_output=True,
221
+ text=True,
222
+ )
223
+
224
+ if result.stdout.strip():
225
+ # Image exists
226
+ return image_tag
227
+
228
+ console.print("[blue]Building world runner image...[/blue]")
229
+
230
+ cmd = [
231
+ "docker",
232
+ "build",
233
+ "--platform",
234
+ docker_platform,
235
+ "-t",
236
+ image_tag,
237
+ "-f",
238
+ str(dockerfile_path),
239
+ str(dockerfile_path.parent),
240
+ ]
241
+
242
+ result = subprocess.run(cmd)
243
+ if result.returncode != 0:
244
+ raise RuntimeError("Failed to build world runner image")
245
+
246
+ console.print(f"[green]✅ Built {image_tag}[/green]")
247
+ return image_tag
248
+
249
+
250
+ def _get_docker_platform(override: str | None = None) -> str:
251
+ """Get the appropriate Docker platform for the current system."""
252
+ if override:
253
+ return override
254
+
255
+ import platform as plat
256
+
257
+ system = plat.system()
258
+ machine = plat.machine().lower()
259
+
260
+ if system == "Darwin" and machine in ("arm64", "aarch64"):
261
+ return "linux/arm64"
262
+ elif system == "Linux" and machine in ("arm64", "aarch64"):
263
+ return "linux/arm64"
264
+ else:
265
+ return "linux/amd64"
266
+
267
+
268
+ def _get_docker_host_ip() -> str:
269
+ """Get the Docker host IP address accessible from containers."""
270
+ try:
271
+ result = subprocess.run(
272
+ ["docker", "network", "inspect", "bridge", "--format", "{{range .IPAM.Config}}{{.Gateway}}{{end}}"],
273
+ capture_output=True,
274
+ text=True,
275
+ )
276
+ if result.returncode == 0 and result.stdout.strip():
277
+ return result.stdout.strip()
278
+ except Exception:
279
+ pass
280
+ # Fallback to common Docker gateway IP
281
+ return "172.17.0.1"
282
+
283
+
284
+ def _build_agent_image(
285
+ agent_name: str,
286
+ agents_dir: Path,
287
+ platform_override: str | None = None,
288
+ ) -> bool:
289
+ """Build a local agent Docker image."""
290
+ agents_dir = agents_dir.expanduser().resolve()
291
+ agent_path = agents_dir / agent_name
292
+ dockerfile_path = agent_path / "Dockerfile"
293
+
294
+ if not dockerfile_path.exists():
295
+ logger.warning(f"No Dockerfile found for agent '{agent_name}' at {dockerfile_path}")
296
+ return False
297
+
298
+ image_tag = f"{agent_name}:latest"
299
+ docker_platform = _get_docker_platform(platform_override)
300
+
301
+ # Determine build context - check if we're in plato-client structure
302
+ plato_client_root = agents_dir.parent if agents_dir.name == "agents" else None
303
+
304
+ if plato_client_root and (plato_client_root / "python-sdk").exists():
305
+ build_context = str(plato_client_root)
306
+ target = "dev"
307
+ console.print(f"[blue]Building {image_tag} (dev mode from {build_context})...[/blue]")
308
+ else:
309
+ build_context = str(agent_path)
310
+ target = "prod"
311
+ console.print(f"[blue]Building {image_tag} (prod mode from {build_context})...[/blue]")
312
+
313
+ console.print(f"[dim]Platform: {docker_platform}[/dim]")
314
+
315
+ cmd = [
316
+ "docker",
317
+ "build",
318
+ "--platform",
319
+ docker_platform,
320
+ "--build-arg",
321
+ f"PLATFORM={docker_platform}",
322
+ "--target",
323
+ target,
324
+ "-t",
325
+ image_tag,
326
+ "-f",
327
+ str(dockerfile_path),
328
+ build_context,
329
+ ]
330
+
331
+ result = subprocess.run(cmd)
332
+ if result.returncode != 0:
333
+ console.print(f"[red]❌ Failed to build {image_tag}[/red]")
334
+ return False
335
+
336
+ console.print(f"[green]✅ Built {image_tag}[/green]")
337
+ return True
338
+
339
+
340
+ def _extract_agent_images_from_config(config_data: dict) -> list[str]:
341
+ """Extract local agent image names from config data."""
342
+ images = []
343
+
344
+ # Check agents section
345
+ agents = config_data.get("agents", {})
346
+ for agent_config in agents.values():
347
+ if isinstance(agent_config, dict):
348
+ image = agent_config.get("image", "")
349
+ # Only include local images (no registry prefix)
350
+ if image and "/" not in image.split(":")[0]:
351
+ name = image.split(":")[0]
352
+ if name not in images:
353
+ images.append(name)
354
+
355
+ # Also check direct coder/verifier fields
356
+ for field in ["coder", "verifier", "skill_runner"]:
357
+ agent_config = config_data.get(field, {})
358
+ if isinstance(agent_config, dict):
359
+ image = agent_config.get("image", "")
360
+ if image and "/" not in image.split(":")[0]:
361
+ name = image.split(":")[0]
362
+ if name not in images:
363
+ images.append(name)
364
+
365
+ return images
366
+
367
+
368
+ async def _create_chronos_session(
369
+ chronos_url: str,
370
+ api_key: str,
371
+ world_name: str,
372
+ world_config: dict,
373
+ plato_session_id: str | None = None,
374
+ ) -> dict:
375
+ """Create a session in Chronos."""
376
+ import httpx
377
+
378
+ url = f"{chronos_url.rstrip('/')}/api/sessions"
379
+
380
+ async with httpx.AsyncClient(timeout=30.0) as client:
381
+ response = await client.post(
382
+ url,
383
+ json={
384
+ "world_name": world_name,
385
+ "world_config": world_config,
386
+ "plato_session_id": plato_session_id,
387
+ },
388
+ headers={"x-api-key": api_key},
389
+ )
390
+ response.raise_for_status()
391
+ return response.json()
392
+
393
+
394
+ async def _close_chronos_session(
395
+ chronos_url: str,
396
+ api_key: str,
397
+ session_id: str,
398
+ ) -> None:
399
+ """Close a Chronos session."""
400
+ import httpx
401
+
402
+ url = f"{chronos_url.rstrip('/')}/api/sessions/{session_id}/close"
403
+
404
+ try:
405
+ async with httpx.AsyncClient(timeout=30.0) as client:
406
+ response = await client.post(url, headers={"x-api-key": api_key})
407
+ response.raise_for_status()
408
+ logger.info(f"Closed Chronos session: {session_id}")
409
+ except Exception as e:
410
+ logger.warning(f"Failed to close Chronos session: {e}")
411
+
412
+
413
+ async def _complete_chronos_session(
414
+ chronos_url: str,
415
+ api_key: str,
416
+ session_id: str,
417
+ status: str,
418
+ exit_code: int | None = None,
419
+ error_message: str | None = None,
420
+ ) -> None:
421
+ """Complete a Chronos session with final status."""
422
+ import httpx
423
+
424
+ url = f"{chronos_url.rstrip('/')}/api/sessions/{session_id}/complete"
425
+
426
+ payload = {"status": status}
427
+ if exit_code is not None:
428
+ payload["exit_code"] = exit_code
429
+ if error_message:
430
+ payload["error_message"] = error_message
431
+
432
+ try:
433
+ async with httpx.AsyncClient(timeout=30.0) as client:
434
+ response = await client.post(url, headers={"x-api-key": api_key}, json=payload)
435
+ response.raise_for_status()
436
+ logger.info(f"Completed Chronos session: {session_id} with status: {status}")
437
+ except Exception as e:
438
+ logger.warning(f"Failed to complete Chronos session: {e}")
439
+
440
+
441
+ async def _run_dev_impl(
442
+ world_dir: Path,
443
+ config_path: Path,
444
+ agents_dir: Path | None = None,
445
+ platform_override: str | None = None,
446
+ env_timeout: int = 7200,
447
+ ) -> None:
448
+ """Run a world locally in a Docker container.
449
+
450
+ This:
451
+ 1. Builds local agent images if --agents-dir is provided
452
+ 2. Creates Plato environments
453
+ 3. Creates Chronos session for OTel traces
454
+ 4. Runs the world in a Docker container with docker.sock mounted
455
+ """
456
+ from plato._generated.models import Envs
457
+ from plato.v2 import AsyncPlato
458
+ from plato.worlds.config import EnvConfig
459
+
460
+ # Get required env vars
461
+ chronos_url = os.environ.get("CHRONOS_URL", "https://chronos.plato.so")
462
+ api_key = os.environ.get("PLATO_API_KEY")
463
+
464
+ if not api_key:
465
+ raise ValueError("PLATO_API_KEY environment variable is required")
466
+
467
+ # Resolve paths
468
+ world_dir = world_dir.expanduser().resolve()
469
+ config_path = config_path.expanduser().resolve()
470
+
471
+ # Load config
472
+ with open(config_path) as f:
473
+ raw_config = json.load(f)
474
+
475
+ # Validate config format: { world: { package, config }, runtime: { artifact_id } }
476
+ if "world" not in raw_config or "package" not in raw_config.get("world", {}):
477
+ raise ValueError("Invalid config: missing world.package")
478
+
479
+ world_package = raw_config["world"]["package"]
480
+ config_data = raw_config["world"].get("config", {}).copy()
481
+ runtime_artifact_id = raw_config.get("runtime", {}).get("artifact_id")
482
+ if runtime_artifact_id:
483
+ config_data["runtime_artifact_id"] = runtime_artifact_id
484
+
485
+ # Parse world name from package (e.g., "plato-world-structured-execution:0.1.17")
486
+ world_package_name = world_package.split(":")[0] if ":" in world_package else world_package
487
+ if world_package_name.startswith("plato-world-"):
488
+ world_name = world_package_name[len("plato-world-") :]
489
+ else:
490
+ world_name = world_package_name or "unknown"
491
+
492
+ # Build local agent images if agents_dir is provided
493
+ if agents_dir:
494
+ agents_dir = agents_dir.expanduser().resolve()
495
+ agent_images = _extract_agent_images_from_config(config_data)
496
+ if agent_images:
497
+ console.print(f"[blue]Building agent images: {agent_images}[/blue]")
498
+ for agent_name in agent_images:
499
+ success = _build_agent_image(agent_name, agents_dir, platform_override)
500
+ if not success:
501
+ raise RuntimeError(f"Failed to build agent image: {agent_name}")
502
+
503
+ # Import world module to get config class for environment detection
504
+ # We need to dynamically load the world from world_dir
505
+ import sys
506
+
507
+ sys.path.insert(0, str(world_dir / "src"))
508
+
509
+ try:
510
+ # Try to import the world module
511
+
512
+ world_module_path = list((world_dir / "src").glob("*_world/*.py"))
513
+ if not world_module_path:
514
+ world_module_path = list((world_dir / "src").glob("*/__init__.py"))
515
+
516
+ env_configs: list[EnvConfig] = []
517
+
518
+ # Try to extract env configs from world config
519
+ if "envs" in config_data:
520
+ for env_cfg in config_data["envs"]:
521
+ env_configs.append(Envs.model_validate(env_cfg).root)
522
+ finally:
523
+ if str(world_dir / "src") in sys.path:
524
+ sys.path.remove(str(world_dir / "src"))
525
+
526
+ # Create Plato client and session
527
+ plato = AsyncPlato()
528
+ session = None
529
+ plato_session_id: str | None = None
530
+ chronos_session_id: str | None = None
531
+
532
+ try:
533
+ if env_configs:
534
+ console.print(f"[blue]Creating {len(env_configs)} Plato environments...[/blue]")
535
+ session = await plato.sessions.create(envs=env_configs, timeout=env_timeout)
536
+ plato_session_id = session.session_id
537
+ console.print(f"[green]✅ Created Plato session: {plato_session_id}[/green]")
538
+
539
+ # Add session to config
540
+ config_data["plato_session"] = session.dump()
541
+
542
+ # Create Chronos session
543
+ console.print("[blue]Creating Chronos session...[/blue]")
544
+ chronos_session = await _create_chronos_session(
545
+ chronos_url=chronos_url,
546
+ api_key=api_key,
547
+ world_name=world_name,
548
+ world_config=config_data,
549
+ plato_session_id=plato_session_id,
550
+ )
551
+ chronos_session_id = chronos_session["public_id"]
552
+ console.print(f"[green]✅ Created Chronos session: {chronos_session_id}[/green]")
553
+ console.print(f"[dim]View at: {chronos_url}/sessions/{chronos_session_id}[/dim]")
554
+
555
+ # Add session info to config
556
+ config_data["session_id"] = chronos_session_id
557
+ # Use otel_url from backend response (uses tunnel if available), or construct it
558
+ otel_url = chronos_session.get("otel_url") or f"{chronos_url.rstrip('/')}/api/otel"
559
+ # For Docker containers, replace localhost with Docker gateway IP
560
+ if "localhost" in otel_url or "127.0.0.1" in otel_url:
561
+ docker_host_ip = _get_docker_host_ip()
562
+ otel_url = otel_url.replace("localhost", docker_host_ip).replace("127.0.0.1", docker_host_ip)
563
+ config_data["otel_url"] = otel_url
564
+ config_data["upload_url"] = chronos_session.get("upload_url", "")
565
+
566
+ # Write updated config to temp file
567
+ with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as f:
568
+ # Write in direct format (not Chronos format) for the world runner
569
+ json.dump(config_data, f)
570
+ container_config_path = f.name
571
+
572
+ # Create shared workspace volume for DIND compatibility
573
+ import uuid as uuid_mod
574
+
575
+ workspace_volume = f"plato-workspace-{uuid_mod.uuid4().hex[:8]}"
576
+ subprocess.run(
577
+ ["docker", "volume", "create", workspace_volume],
578
+ capture_output=True,
579
+ )
580
+ console.print(f"[blue]Created workspace volume: {workspace_volume}[/blue]")
581
+
582
+ try:
583
+ # Run world in Docker container
584
+ console.print("[blue]Starting world in Docker container...[/blue]")
585
+
586
+ docker_platform = _get_docker_platform(platform_override)
587
+
588
+ # Build world runner image if needed
589
+ world_runner_image = _build_world_runner_image(platform_override)
590
+
591
+ # Find python-sdk relative to world_dir (assumes plato-client structure)
592
+ # world_dir: plato-client/worlds/structured-execution
593
+ # python_sdk: plato-client/python-sdk
594
+ python_sdk_dir = world_dir.parent.parent / "python-sdk"
595
+
596
+ # For Docker containers, replace localhost with Docker gateway IP
597
+ docker_chronos_url = chronos_url
598
+ if "localhost" in docker_chronos_url or "127.0.0.1" in docker_chronos_url:
599
+ docker_host_ip = _get_docker_host_ip()
600
+ docker_chronos_url = docker_chronos_url.replace("localhost", docker_host_ip).replace(
601
+ "127.0.0.1", docker_host_ip
602
+ )
603
+
604
+ docker_cmd = [
605
+ "docker",
606
+ "run",
607
+ "--rm",
608
+ "--platform",
609
+ docker_platform,
610
+ "--privileged",
611
+ "-v",
612
+ "/var/run/docker.sock:/var/run/docker.sock",
613
+ "-v",
614
+ f"{world_dir}:/world:ro",
615
+ "-v",
616
+ f"{python_sdk_dir}:/python-sdk:ro", # Mount local SDK for dev
617
+ "-v",
618
+ f"{container_config_path}:/config.json:ro",
619
+ "-v",
620
+ f"{workspace_volume}:/tmp/workspace", # Shared workspace volume
621
+ "-e",
622
+ f"WORLD_NAME={world_name}",
623
+ "-e",
624
+ f"WORKSPACE_VOLUME={workspace_volume}", # Pass volume name for run_agent
625
+ "-e",
626
+ f"CHRONOS_URL={docker_chronos_url}",
627
+ "-e",
628
+ f"PLATO_API_KEY={api_key}",
629
+ "-e",
630
+ f"SESSION_ID={chronos_session_id}",
631
+ "-e",
632
+ f"OTEL_EXPORTER_OTLP_ENDPOINT={otel_url}",
633
+ "-e",
634
+ f"UPLOAD_URL={chronos_session.get('upload_url', '')}",
635
+ ]
636
+
637
+ # Add secrets as env vars
638
+ for key, value in config_data.get("secrets", {}).items():
639
+ docker_cmd.extend(["-e", f"{key.upper()}={value}"])
640
+
641
+ # Use world runner image
642
+ docker_cmd.append(world_runner_image)
643
+
644
+ console.print(f"[dim]Running: docker run ... {world_runner_image}[/dim]")
645
+
646
+ # Run and stream output
647
+ process = subprocess.Popen(
648
+ docker_cmd,
649
+ stdout=subprocess.PIPE,
650
+ stderr=subprocess.STDOUT,
651
+ text=True,
652
+ )
653
+
654
+ if process.stdout:
655
+ for line in process.stdout:
656
+ print(line, end="")
657
+
658
+ process.wait()
659
+ world_exit_code = process.returncode
660
+
661
+ if world_exit_code != 0:
662
+ raise RuntimeError(f"World execution failed with exit code {world_exit_code}")
663
+
664
+ finally:
665
+ os.unlink(container_config_path)
666
+ # Clean up workspace volume
667
+ subprocess.run(
668
+ ["docker", "volume", "rm", "-f", workspace_volume],
669
+ capture_output=True,
670
+ )
671
+ console.print(f"[dim]Cleaned up workspace volume: {workspace_volume}[/dim]")
672
+
673
+ except Exception as e:
674
+ # Complete session as failed
675
+ if chronos_session_id:
676
+ await _complete_chronos_session(
677
+ chronos_url,
678
+ api_key,
679
+ chronos_session_id,
680
+ status="failed",
681
+ exit_code=getattr(e, "exit_code", 1),
682
+ error_message=str(e)[:500],
683
+ )
684
+ raise
685
+ else:
686
+ # Complete session as successful
687
+ if chronos_session_id:
688
+ await _complete_chronos_session(
689
+ chronos_url,
690
+ api_key,
691
+ chronos_session_id,
692
+ status="completed",
693
+ exit_code=0,
694
+ )
695
+ finally:
696
+ if session:
697
+ console.print("[blue]Closing Plato session...[/blue]")
698
+ await session.close()
699
+ await plato.close()
700
+
701
+
702
+ @chronos_app.command()
703
+ def dev(
704
+ config: Annotated[
705
+ Path,
706
+ typer.Argument(help="Path to config JSON file", exists=True, readable=True),
707
+ ],
708
+ world_dir: Annotated[
709
+ Path,
710
+ typer.Option("--world-dir", "-w", help="Directory containing world source code"),
711
+ ],
712
+ agents_dir: Annotated[
713
+ Path | None,
714
+ typer.Option("--agents-dir", "-a", help="Directory containing agent source code"),
715
+ ] = None,
716
+ platform: Annotated[
717
+ str | None,
718
+ typer.Option("--platform", "-p", help="Docker platform (e.g., linux/amd64)"),
719
+ ] = None,
720
+ env_timeout: Annotated[
721
+ int,
722
+ typer.Option("--env-timeout", help="Timeout for environment creation (seconds)"),
723
+ ] = 7200,
724
+ ):
725
+ """
726
+ Run a world locally for development/debugging.
727
+
728
+ This runs the world in a Docker container with docker.sock mounted,
729
+ allowing the world to spawn agent containers.
730
+
731
+ \b
732
+ Config format (same as Chronos launch):
733
+ {
734
+ "world": {
735
+ "package": "plato-world-structured-execution:0.1.17",
736
+ "config": {
737
+ "skill_runner": {
738
+ "image": "computer-use:1.1.0",
739
+ "config": { ... }
740
+ },
741
+ "secrets": { ... }
742
+ }
743
+ },
744
+ "runtime": {
745
+ "artifact_id": "..."
746
+ }
747
+ }
748
+
749
+ Example:
750
+ plato chronos dev config.json -w ~/worlds/my-world -a ~/agents --platform linux/amd64
751
+ """
752
+ logging.basicConfig(
753
+ level=logging.INFO,
754
+ format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
755
+ datefmt="%H:%M:%S",
756
+ )
757
+
758
+ if not os.environ.get("PLATO_API_KEY"):
759
+ console.print("[red]❌ PLATO_API_KEY environment variable required[/red]")
760
+ raise typer.Exit(1)
761
+
762
+ try:
763
+ asyncio.run(_run_dev_impl(world_dir, config, agents_dir, platform, env_timeout))
764
+ except Exception as e:
765
+ console.print(f"[red]❌ Failed: {e}[/red]")
766
+ logger.exception("World execution failed")
767
+ raise typer.Exit(1)