plato-sdk-v2 2.3.3__py3-none-any.whl → 2.4.1__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/v1/cli/main.py CHANGED
@@ -9,6 +9,7 @@ import typer
9
9
  from dotenv import load_dotenv
10
10
 
11
11
  from plato.v1.cli.agent import agent_app
12
+ from plato.v1.cli.chronos import chronos_app
12
13
  from plato.v1.cli.pm import pm_app
13
14
  from plato.v1.cli.sandbox import sandbox_app
14
15
  from plato.v1.cli.utils import console
@@ -71,6 +72,7 @@ app.add_typer(sandbox_app, name="sandbox")
71
72
  app.add_typer(pm_app, name="pm")
72
73
  app.add_typer(agent_app, name="agent")
73
74
  app.add_typer(world_app, name="world")
75
+ app.add_typer(chronos_app, name="chronos")
74
76
 
75
77
 
76
78
  # =============================================================================
plato/v1/cli/pm.py CHANGED
@@ -753,16 +753,16 @@ def review_data(
753
753
  is_installed = "site-packages" in str(package_dir)
754
754
 
755
755
  if is_installed:
756
- extension_source_path = package_dir / "extensions" / "envgen-recorder"
756
+ extension_source_path = package_dir / "extensions" / "envgen-recorder-old"
757
757
  else:
758
758
  repo_root = package_dir.parent.parent.parent # plato-client/
759
- extension_source_path = repo_root / "extensions" / "envgen-recorder"
759
+ extension_source_path = repo_root / "extensions" / "envgen-recorder-old"
760
760
 
761
761
  # Fallback to env var
762
762
  if not extension_source_path.exists():
763
763
  plato_client_dir_env = os.getenv("PLATO_CLIENT_DIR")
764
764
  if plato_client_dir_env:
765
- env_path = Path(plato_client_dir_env) / "extensions" / "envgen-recorder"
765
+ env_path = Path(plato_client_dir_env) / "extensions" / "envgen-recorder-old"
766
766
  if env_path.exists():
767
767
  extension_source_path = env_path
768
768
 
plato/v1/cli/sandbox.py CHANGED
@@ -131,6 +131,9 @@ def sandbox_start(
131
131
  timeout: int = typer.Option(1800, "--timeout", help="VM lifetime in seconds (default: 30 minutes)"),
132
132
  no_reset: bool = typer.Option(False, "--no-reset", help="Skip initial reset after ready"),
133
133
  json_output: bool = typer.Option(False, "--json", "-j", help="Output as JSON"),
134
+ working_dir: Path = typer.Option(
135
+ None, "--working-dir", "-w", help="Working directory for .sandbox.yaml and .plato/"
136
+ ),
134
137
  ):
135
138
  """
136
139
  Start a sandbox environment.
@@ -377,7 +380,7 @@ def sandbox_start(
377
380
  console.print("[cyan] Generating SSH key pair...[/cyan]")
378
381
 
379
382
  base_url = os.getenv("PLATO_BASE_URL", "https://plato.so")
380
- ssh_info = setup_ssh_for_sandbox(base_url, job_id, username=ssh_username)
383
+ ssh_info = setup_ssh_for_sandbox(base_url, job_id, username=ssh_username, working_dir=working_dir)
381
384
  ssh_host = ssh_info["ssh_host"]
382
385
  ssh_config_path = ssh_info["config_path"]
383
386
  ssh_private_key_path = ssh_info["private_key_path"]
@@ -489,7 +492,7 @@ def sandbox_start(
489
492
  # Add heartbeat PID
490
493
  if heartbeat_pid:
491
494
  state["heartbeat_pid"] = heartbeat_pid
492
- save_sandbox_state(state)
495
+ save_sandbox_state(state, working_dir)
493
496
 
494
497
  # Close the plato client (heartbeat process keeps session alive)
495
498
  plato.close()
@@ -1641,6 +1644,8 @@ def sandbox_state_cmd(
1641
1644
  return True, False, None
1642
1645
  return False, False, None
1643
1646
 
1647
+ all_mutations = []
1648
+
1644
1649
  if session_id:
1645
1650
  if not json_output:
1646
1651
  console.print(f"[cyan]Getting state for session: {session_id}[/cyan]")
@@ -1648,6 +1653,7 @@ def sandbox_state_cmd(
1648
1653
  response = sessions_state.sync(
1649
1654
  client=client,
1650
1655
  session_id=session_id,
1656
+ merge_mutations=True,
1651
1657
  x_api_key=api_key,
1652
1658
  )
1653
1659
  if response and response.results:
@@ -1661,6 +1667,13 @@ def sandbox_state_cmd(
1661
1667
  has_error = has_error or e
1662
1668
  if msg:
1663
1669
  error_message = msg
1670
+ # Extract mutations from state
1671
+ if isinstance(result, dict) and "state" in result:
1672
+ state_data = result.get("state", {})
1673
+ if isinstance(state_data, dict):
1674
+ mutations = state_data.get("mutations", [])
1675
+ if mutations:
1676
+ all_mutations.extend(mutations)
1664
1677
  elif job_id:
1665
1678
  if not json_output:
1666
1679
  console.print(f"[cyan]Getting state for job: {job_id}[/cyan]")
@@ -1676,6 +1689,13 @@ def sandbox_state_cmd(
1676
1689
  has_mutations = m
1677
1690
  has_error = e
1678
1691
  error_message = msg
1692
+ # Extract mutations from state
1693
+ if isinstance(state_dict, dict) and "state" in state_dict:
1694
+ state_data = state_dict.get("state", {})
1695
+ if isinstance(state_data, dict):
1696
+ mutations = state_data.get("mutations", [])
1697
+ if mutations:
1698
+ all_mutations.extend(mutations)
1679
1699
  elif job_group_id:
1680
1700
  if not json_output:
1681
1701
  console.print(f"[cyan]Getting state for job_group: {job_group_id}[/cyan]")
@@ -1683,6 +1703,7 @@ def sandbox_state_cmd(
1683
1703
  response = sessions_state.sync(
1684
1704
  client=client,
1685
1705
  session_id=job_group_id,
1706
+ merge_mutations=True,
1686
1707
  x_api_key=api_key,
1687
1708
  )
1688
1709
  if response and response.results:
@@ -1696,6 +1717,13 @@ def sandbox_state_cmd(
1696
1717
  has_error = has_error or e
1697
1718
  if msg:
1698
1719
  error_message = msg
1720
+ # Extract mutations from state
1721
+ if isinstance(result, dict) and "state" in result:
1722
+ state_data = result.get("state", {})
1723
+ if isinstance(state_data, dict):
1724
+ mutations = state_data.get("mutations", [])
1725
+ if mutations:
1726
+ all_mutations.extend(mutations)
1699
1727
  else:
1700
1728
  console.print("[red]❌ .sandbox.yaml missing session_id, job_id, or job_group_id[/red]")
1701
1729
  raise typer.Exit(1)
@@ -1716,6 +1744,26 @@ def sandbox_state_cmd(
1716
1744
  elif state_dict:
1717
1745
  console.print("\n[bold]Environment State:[/bold]")
1718
1746
  console.print(json.dumps(state_dict, indent=2, default=str))
1747
+
1748
+ # Display mutations if any
1749
+ if all_mutations:
1750
+ console.print(f"\n[bold red]Mutations ({len(all_mutations)}):[/bold red]")
1751
+ # Group by table and action for summary
1752
+ from collections import defaultdict
1753
+
1754
+ table_ops: dict[str, dict[str, int]] = defaultdict(lambda: {"INSERT": 0, "UPDATE": 0, "DELETE": 0})
1755
+ for mutation in all_mutations:
1756
+ table = mutation.get("table_name", mutation.get("table", "unknown"))
1757
+ op = mutation.get("action", mutation.get("operation", "UNKNOWN")).upper()
1758
+ if op in table_ops[table]:
1759
+ table_ops[table][op] += 1
1760
+
1761
+ console.print("\n [dim]Table INSERT UPDATE DELETE[/dim]")
1762
+ console.print(" [dim]───────────────────────────────────────────────────────[/dim]")
1763
+ for table, ops in sorted(table_ops.items(), key=lambda x: sum(x[1].values()), reverse=True):
1764
+ console.print(f" {table:<30} {ops['INSERT']:>6} {ops['UPDATE']:>6} {ops['DELETE']:>6}")
1765
+ else:
1766
+ console.print("\n[green]No mutations recorded[/green]")
1719
1767
  else:
1720
1768
  console.print("[yellow]No state returned[/yellow]")
1721
1769
 
@@ -1816,8 +1864,6 @@ def sandbox_clear_audit(
1816
1864
 
1817
1865
  for name, db_config in db_listeners:
1818
1866
  db_type = db_config.get("db_type", "postgresql").lower()
1819
- db_host = db_config.get("db_host", "127.0.0.1")
1820
- db_port = db_config.get("db_port", 5432 if db_type == "postgresql" else 3306)
1821
1867
  db_user = db_config.get("db_user", "postgres" if db_type == "postgresql" else "root")
1822
1868
  db_password = db_config.get("db_password", "")
1823
1869
  db_database = db_config.get("db_database", "postgres")
@@ -1826,10 +1872,16 @@ def sandbox_clear_audit(
1826
1872
  console.print(f"[cyan]Clearing audit_log for listener '{name}' ({db_type})...[/cyan]")
1827
1873
 
1828
1874
  # Build SQL command based on db_type
1875
+ # Use docker exec since psql/mysql aren't installed on the VM directly
1829
1876
  if db_type == "postgresql":
1830
- sql_cmd = f"PGPASSWORD='{db_password}' psql -h {db_host} -p {db_port} -U {db_user} -d {db_database} -c 'TRUNCATE TABLE audit_log RESTART IDENTITY CASCADE'"
1877
+ # Find the postgres container and truncate all audit_log tables across all schemas
1878
+ # Use $body$ delimiter instead of $$ to avoid shell expansion
1879
+ truncate_sql = "DO \\$body\\$ DECLARE r RECORD; BEGIN FOR r IN SELECT schemaname FROM pg_tables WHERE tablename = 'audit_log' LOOP EXECUTE format('TRUNCATE TABLE %I.audit_log RESTART IDENTITY CASCADE', r.schemaname); END LOOP; END \\$body\\$;"
1880
+ sql_cmd = f"CONTAINER=$(docker ps --format '{{{{.Names}}}}\\t{{{{.Image}}}}' | grep -i postgres | head -1 | cut -f1) && docker exec $CONTAINER psql -U {db_user} -d {db_database} -c \"{truncate_sql}\""
1831
1881
  elif db_type in ("mysql", "mariadb"):
1832
- sql_cmd = f"mysql -h {db_host} -P {db_port} -u {db_user} -p'{db_password}' {db_database} -e 'SET FOREIGN_KEY_CHECKS=0; DELETE FROM audit_log; SET FOREIGN_KEY_CHECKS=1;'"
1882
+ # Find the mysql/mariadb container and exec into it
1883
+ # Use mariadb client (mysql is a symlink or may not exist in newer mariadb images)
1884
+ sql_cmd = f"CONTAINER=$(docker ps --format '{{{{.Names}}}}\\t{{{{.Image}}}}' | grep -iE 'mysql|mariadb' | head -1 | cut -f1) && docker exec $CONTAINER mariadb -u {db_user} -p'{db_password}' {db_database} -e 'SET FOREIGN_KEY_CHECKS=0; DELETE FROM audit_log; SET FOREIGN_KEY_CHECKS=1;'"
1833
1885
  else:
1834
1886
  if not json_output:
1835
1887
  console.print(f"[yellow]⚠ Unsupported db_type '{db_type}' for listener '{name}'[/yellow]")
plato/v1/cli/ssh.py CHANGED
@@ -9,21 +9,21 @@ from cryptography.hazmat.primitives import serialization
9
9
  from cryptography.hazmat.primitives.asymmetric import ed25519
10
10
 
11
11
 
12
- def get_plato_dir() -> Path:
12
+ def get_plato_dir(working_dir: Path | str | None = None) -> Path:
13
13
  """Get the directory for plato config/SSH files.
14
14
 
15
- Uses /workspace/.plato if /workspace exists (container environment),
16
- otherwise uses ~/.plato (local development).
15
+ Args:
16
+ working_dir: If provided, returns working_dir/.plato (for container/agent use).
17
+ If None, returns ~/.plato (local development).
17
18
  """
18
- workspace = Path("/workspace")
19
- if workspace.exists() and workspace.is_dir():
20
- return workspace / ".plato"
19
+ if working_dir is not None:
20
+ return Path(working_dir) / ".plato"
21
21
  return Path.home() / ".plato"
22
22
 
23
23
 
24
- def get_next_sandbox_number() -> int:
24
+ def get_next_sandbox_number(working_dir: Path | str | None = None) -> int:
25
25
  """Find next available sandbox number by checking existing config files."""
26
- plato_dir = get_plato_dir()
26
+ plato_dir = get_plato_dir(working_dir)
27
27
  if not plato_dir.exists():
28
28
  return 1
29
29
 
@@ -41,13 +41,13 @@ def get_next_sandbox_number() -> int:
41
41
  return max_num + 1
42
42
 
43
43
 
44
- def generate_ssh_key_pair(sandbox_num: int) -> tuple[str, str]:
44
+ def generate_ssh_key_pair(sandbox_num: int, working_dir: Path | str | None = None) -> tuple[str, str]:
45
45
  """
46
46
  Generate a new ed25519 SSH key pair for a specific sandbox.
47
47
 
48
48
  Returns (public_key_str, private_key_path).
49
49
  """
50
- plato_dir = get_plato_dir()
50
+ plato_dir = get_plato_dir(working_dir)
51
51
  plato_dir.mkdir(mode=0o700, exist_ok=True)
52
52
 
53
53
  private_key_path = plato_dir / f"ssh_{sandbox_num}_key"
@@ -136,6 +136,7 @@ def create_ssh_config(
136
136
  username: str,
137
137
  private_key_path: str,
138
138
  sandbox_num: int,
139
+ working_dir: Path | str | None = None,
139
140
  ) -> str:
140
141
  """
141
142
  Create a temporary SSH config file for a specific sandbox.
@@ -172,7 +173,7 @@ def create_ssh_config(
172
173
  TCPKeepAlive yes
173
174
  """
174
175
 
175
- plato_dir = get_plato_dir()
176
+ plato_dir = get_plato_dir(working_dir)
176
177
  plato_dir.mkdir(mode=0o700, exist_ok=True)
177
178
 
178
179
  config_path = plato_dir / f"ssh_{sandbox_num}.conf"
@@ -182,7 +183,12 @@ def create_ssh_config(
182
183
  return str(config_path)
183
184
 
184
185
 
185
- def setup_ssh_for_sandbox(base_url: str, job_public_id: str, username: str = "plato") -> dict:
186
+ def setup_ssh_for_sandbox(
187
+ base_url: str,
188
+ job_public_id: str,
189
+ username: str = "plato",
190
+ working_dir: Path | str | None = None,
191
+ ) -> dict:
186
192
  """
187
193
  Set up SSH access for a sandbox - generates keys and creates config.
188
194
 
@@ -190,14 +196,14 @@ def setup_ssh_for_sandbox(base_url: str, job_public_id: str, username: str = "pl
190
196
 
191
197
  Returns dict with: ssh_host, config_path, public_key, private_key_path
192
198
  """
193
- sandbox_num = get_next_sandbox_number()
199
+ sandbox_num = get_next_sandbox_number(working_dir)
194
200
  ssh_host = f"sandbox-{sandbox_num}"
195
201
 
196
202
  # Choose random port between 2200 and 2299
197
203
  local_port = random.randint(2200, 2299)
198
204
 
199
205
  # Generate SSH key pair
200
- public_key, private_key_path = generate_ssh_key_pair(sandbox_num)
206
+ public_key, private_key_path = generate_ssh_key_pair(sandbox_num, working_dir)
201
207
 
202
208
  # Create SSH config file
203
209
  config_path = create_ssh_config(
@@ -208,6 +214,7 @@ def setup_ssh_for_sandbox(base_url: str, job_public_id: str, username: str = "pl
208
214
  username=username,
209
215
  private_key_path=private_key_path,
210
216
  sandbox_num=sandbox_num,
217
+ working_dir=working_dir,
211
218
  )
212
219
 
213
220
  return {
@@ -0,0 +1,27 @@
1
+ # World runner image for plato chronos dev
2
+ # Includes git, docker CLI, and Python dependencies
3
+
4
+ FROM python:3.12-slim
5
+
6
+ # Install git and docker CLI
7
+ RUN apt-get update && apt-get install -y --no-install-recommends \
8
+ git \
9
+ curl \
10
+ ca-certificates \
11
+ && curl -fsSL https://get.docker.com -o get-docker.sh \
12
+ && sh get-docker.sh \
13
+ && rm get-docker.sh \
14
+ && apt-get clean \
15
+ && rm -rf /var/lib/apt/lists/*
16
+
17
+ # Install uv for fast package installation
18
+ RUN pip install --no-cache-dir uv
19
+
20
+ WORKDIR /world
21
+
22
+ # Entry point expects:
23
+ # - /world mounted with world source
24
+ # - /python-sdk mounted with plato SDK source (optional, for dev)
25
+ # - /config.json mounted with config
26
+ # - WORLD_NAME env var set
27
+ CMD ["bash", "-c", "if [ -d /python-sdk ]; then uv pip install --system /python-sdk; fi && uv pip install --system . 2>/dev/null || pip install -q . && plato-world-runner run --world $WORLD_NAME --config /config.json"]
plato/v1/cli/utils.py CHANGED
@@ -15,32 +15,52 @@ console = Console()
15
15
  SANDBOX_FILE = ".sandbox.yaml"
16
16
 
17
17
 
18
- def get_sandbox_state() -> dict | None:
19
- """Read sandbox state from .sandbox.yaml in current directory."""
20
- sandbox_file = Path.cwd() / SANDBOX_FILE
18
+ def get_sandbox_state(working_dir: Path | str | None = None) -> dict | None:
19
+ """Read sandbox state from .sandbox.yaml.
20
+
21
+ Args:
22
+ working_dir: Directory containing .sandbox.yaml. If None, uses cwd.
23
+ """
24
+ base_dir = Path(working_dir) if working_dir else Path.cwd()
25
+ sandbox_file = base_dir / SANDBOX_FILE
21
26
  if not sandbox_file.exists():
22
27
  return None
23
28
  with open(sandbox_file) as f:
24
29
  return yaml.safe_load(f)
25
30
 
26
31
 
27
- def save_sandbox_state(state: dict) -> None:
28
- """Save sandbox state to .sandbox.yaml in current directory."""
29
- sandbox_file = Path.cwd() / SANDBOX_FILE
32
+ def save_sandbox_state(state: dict, working_dir: Path | str | None = None) -> None:
33
+ """Save sandbox state to .sandbox.yaml.
34
+
35
+ Args:
36
+ state: State dict to save.
37
+ working_dir: Directory to save .sandbox.yaml in. If None, uses cwd.
38
+ """
39
+ base_dir = Path(working_dir) if working_dir else Path.cwd()
40
+ sandbox_file = base_dir / SANDBOX_FILE
30
41
  with open(sandbox_file, "w") as f:
31
42
  yaml.dump(state, f, default_flow_style=False)
32
43
 
33
44
 
34
- def remove_sandbox_state() -> None:
35
- """Remove .sandbox.yaml from current directory."""
36
- sandbox_file = Path.cwd() / SANDBOX_FILE
45
+ def remove_sandbox_state(working_dir: Path | str | None = None) -> None:
46
+ """Remove .sandbox.yaml.
47
+
48
+ Args:
49
+ working_dir: Directory containing .sandbox.yaml. If None, uses cwd.
50
+ """
51
+ base_dir = Path(working_dir) if working_dir else Path.cwd()
52
+ sandbox_file = base_dir / SANDBOX_FILE
37
53
  if sandbox_file.exists():
38
54
  sandbox_file.unlink()
39
55
 
40
56
 
41
- def require_sandbox_state() -> dict:
42
- """Get sandbox state or exit with error."""
43
- state = get_sandbox_state()
57
+ def require_sandbox_state(working_dir: Path | str | None = None) -> dict:
58
+ """Get sandbox state or exit with error.
59
+
60
+ Args:
61
+ working_dir: Directory containing .sandbox.yaml. If None, uses cwd.
62
+ """
63
+ state = get_sandbox_state(working_dir)
44
64
  if not state:
45
65
  console.print("[red]No sandbox found in current directory[/red]")
46
66
  console.print("\n[yellow]Start a sandbox with:[/yellow]")
plato/worlds/README.md CHANGED
@@ -28,7 +28,8 @@ Create a JSON config file:
28
28
  },
29
29
  "git_token": "ghp_...",
30
30
  "session_id": "local-test-001",
31
- "callback_url": ""
31
+ "otel_url": "",
32
+ "upload_url": ""
32
33
  }
33
34
  ```
34
35