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