plato-sdk-v2 2.0.50__py3-none-any.whl → 2.2.4__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.
Files changed (158) hide show
  1. plato/__init__.py +7 -6
  2. plato/_generated/__init__.py +1 -1
  3. plato/_generated/api/v1/env/evaluate_session.py +3 -3
  4. plato/_generated/api/v1/env/log_state_mutation.py +4 -4
  5. plato/_generated/api/v1/sandbox/checkpoint_vm.py +3 -3
  6. plato/_generated/api/v1/sandbox/save_vm_snapshot.py +3 -3
  7. plato/_generated/api/v1/sandbox/setup_sandbox.py +8 -8
  8. plato/_generated/api/v1/session/__init__.py +2 -0
  9. plato/_generated/api/v1/session/get_sessions_for_archival.py +100 -0
  10. plato/_generated/api/v1/testcases/__init__.py +6 -2
  11. plato/_generated/api/v1/testcases/get_mutation_groups_for_testcase.py +98 -0
  12. plato/_generated/api/v1/testcases/{get_next_output_testcase_for_scoring.py → get_next_testcase_for_scoring.py} +23 -10
  13. plato/_generated/api/v1/testcases/get_testcase_metadata_for_scoring.py +74 -0
  14. plato/_generated/api/v2/__init__.py +2 -1
  15. plato/_generated/api/v2/jobs/__init__.py +4 -0
  16. plato/_generated/api/v2/jobs/checkpoint.py +3 -3
  17. plato/_generated/api/v2/jobs/disk_snapshot.py +3 -3
  18. plato/_generated/api/v2/jobs/log_for_job.py +4 -39
  19. plato/_generated/api/v2/jobs/make.py +4 -4
  20. plato/_generated/api/v2/jobs/setup_sandbox.py +97 -0
  21. plato/_generated/api/v2/jobs/snapshot.py +3 -3
  22. plato/_generated/api/v2/jobs/snapshot_store.py +91 -0
  23. plato/_generated/api/v2/sessions/__init__.py +4 -0
  24. plato/_generated/api/v2/sessions/checkpoint.py +3 -3
  25. plato/_generated/api/v2/sessions/disk_snapshot.py +3 -3
  26. plato/_generated/api/v2/sessions/evaluate.py +3 -3
  27. plato/_generated/api/v2/sessions/log_job_mutation.py +4 -39
  28. plato/_generated/api/v2/sessions/make.py +4 -4
  29. plato/_generated/api/v2/sessions/setup_sandbox.py +98 -0
  30. plato/_generated/api/v2/sessions/snapshot.py +3 -3
  31. plato/_generated/api/v2/sessions/snapshot_store.py +94 -0
  32. plato/_generated/api/v2/user/__init__.py +7 -0
  33. plato/_generated/api/v2/user/get_current_user.py +76 -0
  34. plato/_generated/models/__init__.py +174 -23
  35. plato/_sims_generator/__init__.py +19 -4
  36. plato/_sims_generator/instruction.py +203 -0
  37. plato/_sims_generator/templates/instruction/helpers.py.jinja +161 -0
  38. plato/_sims_generator/templates/instruction/init.py.jinja +43 -0
  39. plato/agents/__init__.py +107 -517
  40. plato/agents/base.py +145 -0
  41. plato/agents/build.py +61 -0
  42. plato/agents/config.py +160 -0
  43. plato/agents/logging.py +401 -0
  44. plato/agents/runner.py +161 -0
  45. plato/agents/trajectory.py +266 -0
  46. plato/chronos/__init__.py +37 -0
  47. plato/chronos/api/__init__.py +3 -0
  48. plato/chronos/api/agents/__init__.py +13 -0
  49. plato/chronos/api/agents/create_agent.py +63 -0
  50. plato/chronos/api/agents/delete_agent.py +61 -0
  51. plato/chronos/api/agents/get_agent.py +62 -0
  52. plato/chronos/api/agents/get_agent_schema.py +72 -0
  53. plato/chronos/api/agents/get_agent_versions.py +62 -0
  54. plato/chronos/api/agents/list_agents.py +57 -0
  55. plato/chronos/api/agents/lookup_agent.py +74 -0
  56. plato/chronos/api/auth/__init__.py +9 -0
  57. plato/chronos/api/auth/debug_auth_api_auth_debug_get.py +43 -0
  58. plato/chronos/api/auth/get_auth_status_api_auth_status_get.py +61 -0
  59. plato/chronos/api/auth/get_current_user_route_api_auth_me_get.py +60 -0
  60. plato/chronos/api/callback/__init__.py +11 -0
  61. plato/chronos/api/callback/push_agent_logs.py +61 -0
  62. plato/chronos/api/callback/update_agent_status.py +57 -0
  63. plato/chronos/api/callback/upload_artifacts.py +59 -0
  64. plato/chronos/api/callback/upload_logs_zip.py +57 -0
  65. plato/chronos/api/callback/upload_trajectory.py +57 -0
  66. plato/chronos/api/default/__init__.py +7 -0
  67. plato/chronos/api/default/health.py +43 -0
  68. plato/chronos/api/jobs/__init__.py +7 -0
  69. plato/chronos/api/jobs/launch_job.py +63 -0
  70. plato/chronos/api/registry/__init__.py +19 -0
  71. plato/chronos/api/registry/get_agent_schema_api_registry_agents__agent_name__schema_get.py +62 -0
  72. plato/chronos/api/registry/get_agent_versions_api_registry_agents__agent_name__versions_get.py +52 -0
  73. plato/chronos/api/registry/get_world_schema_api_registry_worlds__package_name__schema_get.py +68 -0
  74. plato/chronos/api/registry/get_world_versions_api_registry_worlds__package_name__versions_get.py +52 -0
  75. plato/chronos/api/registry/list_registry_agents_api_registry_agents_get.py +44 -0
  76. plato/chronos/api/registry/list_registry_worlds_api_registry_worlds_get.py +44 -0
  77. plato/chronos/api/runtimes/__init__.py +11 -0
  78. plato/chronos/api/runtimes/create_runtime.py +63 -0
  79. plato/chronos/api/runtimes/delete_runtime.py +61 -0
  80. plato/chronos/api/runtimes/get_runtime.py +62 -0
  81. plato/chronos/api/runtimes/list_runtimes.py +57 -0
  82. plato/chronos/api/runtimes/test_runtime.py +67 -0
  83. plato/chronos/api/secrets/__init__.py +11 -0
  84. plato/chronos/api/secrets/create_secret.py +63 -0
  85. plato/chronos/api/secrets/delete_secret.py +61 -0
  86. plato/chronos/api/secrets/get_secret.py +62 -0
  87. plato/chronos/api/secrets/list_secrets.py +57 -0
  88. plato/chronos/api/secrets/update_secret.py +68 -0
  89. plato/chronos/api/sessions/__init__.py +10 -0
  90. plato/chronos/api/sessions/get_session.py +62 -0
  91. plato/chronos/api/sessions/get_session_logs.py +72 -0
  92. plato/chronos/api/sessions/get_session_logs_download.py +62 -0
  93. plato/chronos/api/sessions/list_sessions.py +57 -0
  94. plato/chronos/api/status/__init__.py +8 -0
  95. plato/chronos/api/status/get_status_api_status_get.py +44 -0
  96. plato/chronos/api/status/get_version_info_api_version_get.py +44 -0
  97. plato/chronos/api/templates/__init__.py +11 -0
  98. plato/chronos/api/templates/create_template.py +63 -0
  99. plato/chronos/api/templates/delete_template.py +61 -0
  100. plato/chronos/api/templates/get_template.py +62 -0
  101. plato/chronos/api/templates/list_templates.py +57 -0
  102. plato/chronos/api/templates/update_template.py +68 -0
  103. plato/chronos/api/trajectories/__init__.py +8 -0
  104. plato/chronos/api/trajectories/get_trajectory.py +62 -0
  105. plato/chronos/api/trajectories/list_trajectories.py +62 -0
  106. plato/chronos/api/worlds/__init__.py +10 -0
  107. plato/chronos/api/worlds/create_world.py +63 -0
  108. plato/chronos/api/worlds/delete_world.py +61 -0
  109. plato/chronos/api/worlds/get_world.py +62 -0
  110. plato/chronos/api/worlds/list_worlds.py +57 -0
  111. plato/chronos/client.py +171 -0
  112. plato/chronos/errors.py +141 -0
  113. plato/chronos/models/__init__.py +647 -0
  114. plato/chronos/py.typed +0 -0
  115. plato/sims/cli.py +299 -123
  116. plato/sims/registry.py +77 -4
  117. plato/v1/cli/agent.py +88 -84
  118. plato/v1/cli/main.py +2 -0
  119. plato/v1/cli/pm.py +441 -119
  120. plato/v1/cli/sandbox.py +747 -191
  121. plato/v1/cli/sim.py +11 -0
  122. plato/v1/cli/verify.py +1269 -0
  123. plato/v1/cli/world.py +3 -0
  124. plato/v1/flow_executor.py +21 -17
  125. plato/v1/models/env.py +11 -11
  126. plato/v1/sdk.py +2 -2
  127. plato/v1/sync_env.py +11 -11
  128. plato/v1/sync_flow_executor.py +21 -17
  129. plato/v1/sync_sdk.py +4 -2
  130. plato/v2/__init__.py +2 -0
  131. plato/v2/async_/environment.py +20 -1
  132. plato/v2/async_/session.py +54 -3
  133. plato/v2/sync/environment.py +2 -1
  134. plato/v2/sync/session.py +52 -2
  135. plato/worlds/README.md +218 -0
  136. plato/worlds/__init__.py +54 -18
  137. plato/worlds/base.py +304 -93
  138. plato/worlds/config.py +239 -73
  139. plato/worlds/runner.py +391 -80
  140. {plato_sdk_v2-2.0.50.dist-info → plato_sdk_v2-2.2.4.dist-info}/METADATA +1 -3
  141. {plato_sdk_v2-2.0.50.dist-info → plato_sdk_v2-2.2.4.dist-info}/RECORD +143 -68
  142. {plato_sdk_v2-2.0.50.dist-info → plato_sdk_v2-2.2.4.dist-info}/entry_points.txt +1 -0
  143. plato/_generated/api/v2/interfaces/__init__.py +0 -27
  144. plato/_generated/api/v2/interfaces/v2_interface_browser_create.py +0 -68
  145. plato/_generated/api/v2/interfaces/v2_interface_cdp_url.py +0 -65
  146. plato/_generated/api/v2/interfaces/v2_interface_click.py +0 -64
  147. plato/_generated/api/v2/interfaces/v2_interface_close.py +0 -59
  148. plato/_generated/api/v2/interfaces/v2_interface_computer_create.py +0 -68
  149. plato/_generated/api/v2/interfaces/v2_interface_cursor.py +0 -64
  150. plato/_generated/api/v2/interfaces/v2_interface_key.py +0 -68
  151. plato/_generated/api/v2/interfaces/v2_interface_screenshot.py +0 -65
  152. plato/_generated/api/v2/interfaces/v2_interface_scroll.py +0 -70
  153. plato/_generated/api/v2/interfaces/v2_interface_type.py +0 -64
  154. plato/world/__init__.py +0 -44
  155. plato/world/base.py +0 -267
  156. plato/world/config.py +0 -139
  157. plato/world/types.py +0 -47
  158. {plato_sdk_v2-2.0.50.dist-info → plato_sdk_v2-2.2.4.dist-info}/WHEEL +0 -0
plato/v1/cli/sandbox.py CHANGED
@@ -5,6 +5,7 @@ import io
5
5
  import json
6
6
  import logging
7
7
  import os
8
+ import re
8
9
  import shutil
9
10
  import subprocess
10
11
  import tarfile
@@ -16,7 +17,6 @@ from urllib.parse import quote
16
17
 
17
18
  import typer
18
19
  import yaml
19
- from playwright.async_api import async_playwright
20
20
  from rich.logging import RichHandler
21
21
 
22
22
  from plato._generated.api.v1.gitea import (
@@ -25,7 +25,7 @@ from plato._generated.api.v1.gitea import (
25
25
  get_gitea_credentials,
26
26
  get_simulator_repository,
27
27
  )
28
- from plato._generated.api.v1.sandbox import setup_sandbox, start_worker
28
+ from plato._generated.api.v1.sandbox import setup_root_access, setup_sandbox, start_worker
29
29
  from plato._generated.api.v2.jobs import get_flows as jobs_get_flows
30
30
  from plato._generated.api.v2.jobs import state as jobs_state
31
31
  from plato._generated.api.v2.sessions import (
@@ -47,13 +47,14 @@ from plato._generated.api.v2.sessions import (
47
47
  state as sessions_state,
48
48
  )
49
49
  from plato._generated.models import (
50
+ AppSchemasBuildModelsSetupSandboxRequest,
50
51
  AppSchemasBuildModelsSimConfigCompute,
51
52
  AppSchemasBuildModelsSimConfigDataset,
52
53
  AppSchemasBuildModelsSimConfigMetadata,
53
54
  CreateCheckpointRequest,
54
55
  ExecuteCommandRequest,
55
56
  Flow,
56
- SetupSandboxRequest,
57
+ SetupRootPasswordRequest,
57
58
  VMManagementRequest,
58
59
  )
59
60
  from plato.v1.cli.ssh import setup_ssh_for_sandbox
@@ -69,11 +70,16 @@ from plato.v1.cli.utils import (
69
70
  require_sandbox_state,
70
71
  save_sandbox_state,
71
72
  )
73
+ from plato.v1.cli.verify import sandbox_verify_app
72
74
  from plato.v2.async_.flow_executor import FlowExecutor
73
75
  from plato.v2.sync.client import Plato as PlatoV2
74
76
  from plato.v2.types import Env, SimConfigCompute
75
77
 
78
+ # UUID pattern for detecting artifact IDs in colon notation
79
+ UUID_PATTERN = re.compile(r"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$", re.IGNORECASE)
80
+
76
81
  sandbox_app = typer.Typer(help="Manage sandboxes for simulator development")
82
+ sandbox_app.add_typer(sandbox_verify_app, name="verify")
77
83
 
78
84
 
79
85
  def format_public_url_with_router_target(public_url: str | None, service_name: str | None) -> str | None:
@@ -89,6 +95,10 @@ def format_public_url_with_router_target(public_url: str | None, service_name: s
89
95
  if not public_url or not service_name:
90
96
  return public_url
91
97
 
98
+ # Check if router target already exists (idempotent)
99
+ if "_plato_router_target=" in public_url:
100
+ return public_url
101
+
92
102
  target_param = f"_plato_router_target={service_name}.web.plato.so"
93
103
  if "?" in public_url:
94
104
  return f"{public_url}&{target_param}"
@@ -104,56 +114,85 @@ def sandbox_start(
104
114
  None,
105
115
  "--simulator",
106
116
  "-s",
107
- help="Simulator name (e.g., espocrm, espocrm:staging)",
117
+ help="Simulator name. Supports: -s sim, -s sim:tag, -s sim:<artifact-uuid>",
108
118
  ),
109
- artifact_id: str = typer.Option(None, "--artifact-id", "-a", help="Specific artifact ID"),
119
+ artifact_id: str = typer.Option(None, "--artifact-id", "-a", help="Specific artifact UUID"),
110
120
  blank: bool = typer.Option(False, "--blank", "-b", help="Create blank VM"),
111
121
  # Config mode options
112
122
  dataset: str = typer.Option(None, "--dataset", "-d", help="Dataset from config or simulator"),
113
123
  # Artifact mode options
114
- tag: str = typer.Option("latest", "--tag", "-t", help="Artifact tag"),
124
+ tag: str = typer.Option("latest", "--tag", "-t", help="Artifact tag (used with -s)"),
115
125
  # Blank VM options
116
126
  service: str = typer.Option(None, "--service", help="Service name (required for blank VM)"),
117
127
  cpus: int = typer.Option(2, "--cpus", help="Number of CPUs (blank VM)"),
118
128
  memory: int = typer.Option(1024, "--memory", help="Memory in MB (blank VM)"),
119
129
  disk: int = typer.Option(10240, "--disk", help="Disk in MB (blank VM)"),
120
130
  # Common options
121
- timeout: int = typer.Option(300, "--timeout", help="Timeout for VM ready (seconds)"),
131
+ timeout: int = typer.Option(1800, "--timeout", help="VM lifetime in seconds (default: 30 minutes)"),
122
132
  no_reset: bool = typer.Option(False, "--no-reset", help="Skip initial reset after ready"),
123
133
  json_output: bool = typer.Option(False, "--json", "-j", help="Output as JSON"),
124
134
  ):
125
135
  """
126
136
  Start a sandbox environment.
127
137
 
128
- Three modes available:
138
+ THREE MODES (pick one):
139
+
140
+ 1. FROM CONFIG (-c): Use plato-config.yml in current directory
141
+
142
+ plato sandbox start -c
143
+ plato sandbox start -c -d base
144
+
145
+ 2. FROM SIMULATOR/ARTIFACT (-s or -a): Start from existing artifact
129
146
 
130
- 1. FROM CONFIG: Use plato-config.yml in current directory
131
- plato sandbox start --from-config
132
- plato sandbox start --from-config --dataset base
147
+ -s <simulator> Latest tag
148
+ -s <simulator>:<tag> Specific tag
149
+ -s <simulator>:<artifact-uuid> Specific artifact (UUID detected)
150
+ -s <simulator> -a <artifact-uuid> Explicit artifact
151
+ -a <artifact-uuid> Artifact only (no simulator name)
133
152
 
134
- 2. FROM ARTIFACT: Use simulator name or artifact ID
135
- plato sandbox start --simulator espocrm
136
- plato sandbox start --simulator espocrm:staging
137
- plato sandbox start --artifact-id art_abc123
153
+ 3. BLANK VM (-b): Create fresh VM with custom specs
138
154
 
139
- 3. BLANK VM: Create fresh VM with custom specs
140
- plato sandbox start --blank --service myapp
141
- plato sandbox start --blank --service myapp --cpus 4 --memory 2048
155
+ plato sandbox start -b --service myapp
156
+ plato sandbox start -b --service myapp --cpus 4 --memory 2048
157
+
158
+ EXAMPLES:
159
+
160
+ plato sandbox start -c # From config
161
+ plato sandbox start -s espocrm # Latest artifact
162
+ plato sandbox start -s espocrm:staging # Staging tag
163
+ plato sandbox start -s espocrm:e9c25ca5-1234-5678-... # Specific artifact
164
+ plato sandbox start -a e9c25ca5-1234-5678-9abc-... # Artifact only
142
165
  """
143
166
  api_key = require_api_key()
144
167
 
145
168
  # Validate mode selection - exactly one must be specified
146
169
  modes_selected = sum([from_config, simulator is not None, artifact_id is not None, blank])
147
170
  if modes_selected == 0:
148
- console.print("[red]Must specify one mode: --from-config, --simulator, --artifact-id, or --blank[/red]")
171
+ console.print("[red] No mode specified[/red]")
172
+ console.print()
173
+ console.print("[yellow]Usage (pick one):[/yellow]")
174
+ console.print(" plato sandbox start -c # From plato-config.yml")
175
+ console.print(" plato sandbox start -s <simulator> # From simulator (latest)")
176
+ console.print(" plato sandbox start -s <simulator>:<artifact> # From specific artifact")
177
+ console.print(" plato sandbox start -a <artifact-uuid> # From artifact directly")
178
+ console.print(" plato sandbox start -b --service <name> # Blank VM")
149
179
  raise typer.Exit(1)
150
180
  if modes_selected > 1:
151
- console.print("[red]Only one mode can be specified[/red]")
181
+ console.print("[red] Multiple modes specified - pick only one[/red]")
182
+ console.print()
183
+ console.print("[yellow]Use ONE of:[/yellow]")
184
+ console.print(" -c (--from-config) # From plato-config.yml")
185
+ console.print(" -s (--simulator) # From simulator name")
186
+ console.print(" -a (--artifact-id) # From artifact UUID")
187
+ console.print(" -b (--blank) # Blank VM")
152
188
  raise typer.Exit(1)
153
189
 
154
190
  # Validate mode-specific options
155
191
  if blank and not service:
156
- console.print("[red]--service is required for blank VM mode[/red]")
192
+ console.print("[red]--service is required for blank VM mode[/red]")
193
+ console.print()
194
+ console.print("[yellow]Usage:[/yellow]")
195
+ console.print(" plato sandbox start -b --service <name>")
157
196
  raise typer.Exit(1)
158
197
 
159
198
  # Build environment configuration
@@ -198,9 +237,13 @@ def sandbox_start(
198
237
  config_cpus = compute_config.get("cpus", 2)
199
238
  config_memory = compute_config.get("memory", 2048)
200
239
  config_disk = compute_config.get("disk", 10240)
240
+ config_app_port = compute_config.get("app_port", 80)
201
241
 
202
242
  # Create blank VM with specs from config
203
- sim_config = SimConfigCompute(cpus=config_cpus, memory=config_memory, disk=config_disk)
243
+ config_messaging_port = compute_config.get("plato_messaging_port", 7000)
244
+ sim_config = SimConfigCompute(
245
+ cpus=config_cpus, memory=config_memory, disk=config_disk, app_port=config_app_port
246
+ )
204
247
  env_config = Env.resource(sim_name, sim_config)
205
248
  state_extras = {
206
249
  "plato_config_path": str(config_path),
@@ -209,6 +252,8 @@ def sandbox_start(
209
252
  "cpus": config_cpus,
210
253
  "memory": config_memory,
211
254
  "disk": config_disk,
255
+ "app_port": config_app_port,
256
+ "messaging_port": config_messaging_port,
212
257
  }
213
258
  if not json_output:
214
259
  console.print(f"[cyan]Using plato-config.yml: {config_path}[/cyan]")
@@ -216,16 +261,42 @@ def sandbox_start(
216
261
  console.print(f"[cyan]Specs: {config_cpus} CPUs, {config_memory}MB RAM, {config_disk}MB disk[/cyan]")
217
262
 
218
263
  elif simulator:
219
- # MODE 2a: From simulator name
264
+ # MODE 2a: From simulator name (or simulator:artifact_id notation)
220
265
  mode = "artifact"
221
266
  # Default dataset to "base" if not provided
222
267
  effective_dataset = dataset or "base"
223
- env_config = Env.simulator(simulator, tag=tag, dataset=effective_dataset)
224
- # Extract sim_name from simulator (e.g., "docuseal:prod-latest" -> "docuseal")
225
- sim_name = simulator.split(":")[0] if ":" in simulator else simulator
226
- state_extras = {"simulator": simulator, "tag": tag, "dataset": effective_dataset, "service": sim_name}
227
- if not json_output:
228
- console.print(f"[cyan]Starting from simulator: {simulator}:{tag}[/cyan]")
268
+
269
+ # Extract sim_name and check if colon part is a UUID (artifact ID)
270
+ if ":" in simulator:
271
+ sim_name, colon_part = simulator.split(":", 1)
272
+ if UUID_PATTERN.match(colon_part):
273
+ # Colon part is a UUID -> treat as artifact ID
274
+ env_config = Env.artifact(colon_part)
275
+ state_extras = {
276
+ "simulator": sim_name,
277
+ "artifact_id": colon_part,
278
+ "dataset": effective_dataset,
279
+ "service": sim_name,
280
+ }
281
+ if not json_output:
282
+ console.print(f"[cyan]Starting from artifact: {sim_name}:{colon_part}[/cyan]")
283
+ else:
284
+ # Colon part is a tag name
285
+ env_config = Env.simulator(simulator, tag=tag, dataset=effective_dataset)
286
+ state_extras = {
287
+ "simulator": simulator,
288
+ "tag": colon_part,
289
+ "dataset": effective_dataset,
290
+ "service": sim_name,
291
+ }
292
+ if not json_output:
293
+ console.print(f"[cyan]Starting from simulator: {simulator}[/cyan]")
294
+ else:
295
+ sim_name = simulator
296
+ env_config = Env.simulator(simulator, tag=tag, dataset=effective_dataset)
297
+ state_extras = {"simulator": simulator, "tag": tag, "dataset": effective_dataset, "service": sim_name}
298
+ if not json_output:
299
+ console.print(f"[cyan]Starting from simulator: {simulator}:{tag}[/cyan]")
229
300
 
230
301
  elif artifact_id:
231
302
  # MODE 2b: From artifact ID
@@ -257,7 +328,9 @@ def sandbox_start(
257
328
 
258
329
  try:
259
330
  plato = PlatoV2(api_key=api_key)
260
- session = plato.sessions.create(envs=[env_config] if env_config else None, timeout=timeout)
331
+ if not env_config:
332
+ raise ValueError("No environment configuration provided")
333
+ session = plato.sessions.create(envs=[env_config], timeout=timeout)
261
334
 
262
335
  # Get session info
263
336
  session_id = session.session_id
@@ -286,20 +359,25 @@ def sandbox_start(
286
359
  console.print("[cyan]Resetting environment...[/cyan]")
287
360
  session.reset()
288
361
 
289
- # Setup sandbox for --from-config mode (clone git repo, configure worker)
362
+ # Setup SSH for ALL modes (so you can SSH into any sandbox)
290
363
  ssh_host = None
291
364
  ssh_config_path = None
292
365
  ssh_private_key_path = None
293
- if mode == "config" and full_dataset_config_dict and job_id:
366
+
367
+ if job_id:
294
368
  if not json_output:
295
- console.print("[cyan]Setting up sandbox (SSH keys, git clone, worker config)...[/cyan]")
369
+ console.print("[cyan]Setting up SSH access...[/cyan]")
296
370
  try:
297
371
  # Step 1: Generate SSH key pair and create SSH config (like Go hub does)
372
+ # For config mode: use "plato" user (setup_sandbox configures this)
373
+ # For artifact/simulator modes: use "root" user (setup_root_access configures this)
374
+ ssh_username = "plato" if (mode == "config" and full_dataset_config_dict) else "root"
375
+
298
376
  if not json_output:
299
377
  console.print("[cyan] Generating SSH key pair...[/cyan]")
300
378
 
301
379
  base_url = os.getenv("PLATO_BASE_URL", "https://plato.so")
302
- ssh_info = setup_ssh_for_sandbox(base_url, job_id, username="plato")
380
+ ssh_info = setup_ssh_for_sandbox(base_url, job_id, username=ssh_username)
303
381
  ssh_host = ssh_info["ssh_host"]
304
382
  ssh_config_path = ssh_info["config_path"]
305
383
  ssh_private_key_path = ssh_info["private_key_path"]
@@ -308,68 +386,81 @@ def sandbox_start(
308
386
  if not json_output:
309
387
  console.print(f"[cyan] SSH config: {ssh_config_path}[/cyan]")
310
388
 
311
- # Step 2: Build the full dataset config from plato-config.yml
312
- compute_dict = full_dataset_config_dict.get("compute", {})
313
- metadata_dict = full_dataset_config_dict.get("metadata", {})
314
- services_dict = full_dataset_config_dict.get("services")
315
- listeners_dict = full_dataset_config_dict.get("listeners")
316
-
317
- # Build compute config
318
- compute_obj = AppSchemasBuildModelsSimConfigCompute(
319
- cpus=compute_dict.get("cpus", 2),
320
- memory=compute_dict.get("memory", 2048),
321
- disk=compute_dict.get("disk", 10240),
322
- app_port=compute_dict.get("app_port", 80),
323
- plato_messaging_port=compute_dict.get("plato_messaging_port", 7000),
324
- )
325
-
326
- # Build metadata config
327
- metadata_obj = AppSchemasBuildModelsSimConfigMetadata(
328
- name=metadata_dict.get("name", sim_name),
329
- description=metadata_dict.get("description", ""),
330
- source_code_url=metadata_dict.get("source_code_url"),
331
- start_url=metadata_dict.get("start_url", "blank"),
332
- license=metadata_dict.get("license"),
333
- variables=metadata_dict.get("variables"),
334
- flows_path=metadata_dict.get("flows_path"),
335
- )
389
+ # Step 2: Upload SSH key to sandbox
390
+ # For --from-config mode: use setup_sandbox with full config
391
+ # For --simulator/--artifact-id modes: use setup_root_access (just SSH key, no config changes)
392
+ if not json_output:
393
+ console.print("[cyan] Uploading SSH key to sandbox...[/cyan]")
394
+
395
+ if mode == "config" and full_dataset_config_dict:
396
+ # Full config from plato-config.yml - use setup_sandbox API
397
+ compute_dict = full_dataset_config_dict.get("compute", {})
398
+ metadata_dict = full_dataset_config_dict.get("metadata", {})
399
+ services_dict = full_dataset_config_dict.get("services")
400
+ listeners_dict = full_dataset_config_dict.get("listeners")
401
+
402
+ compute_obj = AppSchemasBuildModelsSimConfigCompute(
403
+ cpus=compute_dict.get("cpus", 2),
404
+ memory=compute_dict.get("memory", 2048),
405
+ disk=compute_dict.get("disk", 10240),
406
+ app_port=compute_dict.get("app_port", 80),
407
+ plato_messaging_port=compute_dict.get("plato_messaging_port", 7000),
408
+ )
336
409
 
337
- # Build the full dataset config object
338
- dataset_config_obj = AppSchemasBuildModelsSimConfigDataset(
339
- compute=compute_obj,
340
- metadata=metadata_obj,
341
- services=services_dict,
342
- listeners=listeners_dict,
343
- )
410
+ metadata_obj = AppSchemasBuildModelsSimConfigMetadata(
411
+ name=metadata_dict.get("name", sim_name or "sandbox"),
412
+ description=metadata_dict.get("description", ""),
413
+ source_code_url=metadata_dict.get("source_code_url"),
414
+ start_url=metadata_dict.get("start_url", "blank"),
415
+ license=metadata_dict.get("license"),
416
+ variables=metadata_dict.get("variables"),
417
+ flows_path=metadata_dict.get("flows_path"),
418
+ )
344
419
 
345
- # Step 3: Build setup request WITH SSH public key
346
- setup_request = SetupSandboxRequest(
347
- service=sim_name,
348
- dataset=dataset_name or "",
349
- plato_dataset_config=dataset_config_obj,
350
- ssh_public_key=ssh_public_key, # Pass the generated public key
351
- )
420
+ dataset_config_obj = AppSchemasBuildModelsSimConfigDataset(
421
+ compute=compute_obj,
422
+ metadata=metadata_obj,
423
+ services=services_dict,
424
+ listeners=listeners_dict,
425
+ )
352
426
 
353
- # Step 4: Call setup_sandbox API
354
- if not json_output:
355
- console.print("[cyan] Calling setup-sandbox API...[/cyan]")
427
+ dataset_value = dataset_name or state_extras.get("dataset", "base")
428
+ setup_request = AppSchemasBuildModelsSetupSandboxRequest(
429
+ service=sim_name or "",
430
+ dataset=str(dataset_value) if dataset_value else "",
431
+ plato_dataset_config=dataset_config_obj,
432
+ ssh_public_key=ssh_public_key,
433
+ )
356
434
 
357
- with get_http_client() as client:
358
- _setup_response = setup_sandbox.sync(
359
- client=client,
360
- public_id=job_id,
361
- body=setup_request,
362
- x_api_key=api_key,
435
+ with get_http_client() as client:
436
+ _setup_response = setup_sandbox.sync(
437
+ client=client,
438
+ public_id=job_id,
439
+ body=setup_request,
440
+ x_api_key=api_key,
441
+ )
442
+ else:
443
+ # Artifact/simulator modes - use setup_root_access API (just SSH key, preserves existing config)
444
+ setup_root_request = SetupRootPasswordRequest(
445
+ ssh_public_key=ssh_public_key,
363
446
  )
364
447
 
448
+ with get_http_client() as client:
449
+ _setup_response = setup_root_access.sync(
450
+ client=client,
451
+ public_id=job_id,
452
+ body=setup_root_request,
453
+ x_api_key=api_key,
454
+ )
455
+
365
456
  if not json_output:
366
- console.print("[green]Sandbox setup complete![/green]")
457
+ console.print("[green]SSH setup complete![/green]")
367
458
  console.print(f" [cyan]SSH:[/cyan] ssh -F {ssh_config_path} {ssh_host}")
368
459
 
369
460
  except Exception as e:
370
461
  if not json_output:
371
- console.print(f"[yellow]Warning: Setup sandbox failed: {e}[/yellow]")
372
- console.print("[yellow]You may need to run 'plato sandbox start-worker' manually[/yellow]")
462
+ console.print(f"[yellow]Warning: SSH setup failed: {e}[/yellow]")
463
+ console.print("[yellow]You may not be able to SSH into this sandbox[/yellow]")
373
464
 
374
465
  # Start background heartbeat process to keep session alive
375
466
  heartbeat_pid = _start_heartbeat_process(session_id, api_key)
@@ -383,8 +474,7 @@ def sandbox_start(
383
474
  state = {
384
475
  "session_id": session_id,
385
476
  "job_id": job_id,
386
- "public_url": public_url,
387
- "url": display_url, # Full URL with _plato_router_target for flow execution
477
+ "public_url": display_url, # Full URL with _plato_router_target
388
478
  "mode": mode,
389
479
  "created_at": datetime.now(timezone.utc).isoformat(),
390
480
  **state_extras,
@@ -409,7 +499,7 @@ def sandbox_start(
409
499
  output = {
410
500
  "session_id": session_id,
411
501
  "job_id": job_id,
412
- "public_url": public_url,
502
+ "public_url": display_url, # Full URL with _plato_router_target
413
503
  }
414
504
  if ssh_host:
415
505
  output["ssh_host"] = ssh_host
@@ -426,6 +516,7 @@ def sandbox_start(
426
516
  console.print(f" [cyan]Public URL:[/cyan] {display_url}")
427
517
  if ssh_host and ssh_config_path:
428
518
  console.print(f" [cyan]SSH:[/cyan] ssh -F {ssh_config_path} {ssh_host}")
519
+ console.print(" [cyan]Docker:[/cyan] export DOCKER_HOST=unix:///var/run/docker-user.sock")
429
520
  console.print(f"\n[dim]State saved to {SANDBOX_FILE}[/dim]")
430
521
 
431
522
  except Exception as e:
@@ -439,27 +530,102 @@ def sandbox_start(
439
530
  @sandbox_app.command(name="snapshot")
440
531
  def sandbox_snapshot(
441
532
  json_output: bool = typer.Option(False, "--json", "-j", help="Output as JSON"),
533
+ include_config: bool = typer.Option(
534
+ None,
535
+ "--include-config",
536
+ "-c",
537
+ help="Include plato-config.yml and flows.yml in snapshot. Auto-enabled for sandboxes started from config.",
538
+ ),
539
+ app_port: int = typer.Option(None, "--app-port", help="Override internal app port"),
540
+ messaging_port: int = typer.Option(None, "--messaging-port", help="Override messaging port"),
541
+ target: str = typer.Option(None, "--target", help="Override target domain (e.g., myapp.web.plato.so)"),
442
542
  ):
443
543
  """
444
544
  Create a snapshot of the current sandbox state.
445
545
 
446
- Examples:
447
- plato sandbox snapshot
448
- plato sandbox snapshot --json
546
+ Saves the artifact ID to .sandbox.yaml so it can be used by
547
+ 'plato pm submit base' without needing to specify it manually.
548
+
549
+ CONFIG BEHAVIOR:
550
+
551
+ - Sandboxes started from config (-c): Automatically includes plato-config.yml,
552
+ flows.yml, app_port, and messaging_port in the snapshot.
553
+ - Sandboxes started from artifact: Inherits config from parent artifact.
554
+ Use --include-config to override with local config files.
555
+
556
+ USAGE:
557
+
558
+ plato sandbox snapshot # Creates snapshot, saves artifact_id
559
+ plato sandbox snapshot --json # JSON output
560
+ plato sandbox snapshot -c # Force include local config files
561
+ plato sandbox snapshot --app-port 8080 # Override app port
562
+
563
+ NEXT STEPS:
564
+
565
+ After snapshot, you can submit for review:
566
+ plato pm submit base # Reads artifact_id from .sandbox.yaml
449
567
  """
450
568
  api_key = require_api_key()
451
569
  state = require_sandbox_state()
452
570
  session_id = require_sandbox_field(state, "session_id")
571
+ mode = state.get("mode", "artifact")
572
+ service_name = state.get("service")
573
+
574
+ # Determine whether to include config
575
+ # Auto-enable for "config" mode sandboxes, unless explicitly disabled
576
+ should_include_config = include_config if include_config is not None else (mode == "config")
453
577
 
454
578
  if not json_output:
455
579
  console.print("[cyan]Creating snapshot...[/cyan]")
456
580
 
581
+ # Build the request with optional config fields
582
+ request_kwargs = {}
583
+
584
+ # Get port info from state or CLI overrides
585
+ if app_port is not None:
586
+ request_kwargs["internal_app_port"] = app_port
587
+ elif should_include_config and state.get("app_port"):
588
+ request_kwargs["internal_app_port"] = state.get("app_port")
589
+
590
+ if messaging_port is not None:
591
+ request_kwargs["messaging_port"] = messaging_port
592
+ elif should_include_config and state.get("messaging_port"):
593
+ request_kwargs["messaging_port"] = state.get("messaging_port")
594
+
595
+ # Set target domain
596
+ if target is not None:
597
+ request_kwargs["target"] = target
598
+ elif should_include_config and service_name:
599
+ request_kwargs["target"] = f"{service_name}.web.plato.so"
600
+
601
+ # Include config files if requested
602
+ if should_include_config:
603
+ plato_config_path = state.get("plato_config_path")
604
+ if plato_config_path:
605
+ config_file = Path(plato_config_path)
606
+ if config_file.exists():
607
+ request_kwargs["plato_config"] = config_file.read_text()
608
+ if not json_output:
609
+ console.print(f"[dim]Including plato-config.yml from {plato_config_path}[/dim]")
610
+
611
+ # Also check for flows.yml in same directory
612
+ flows_file = config_file.parent / "flows.yml"
613
+ if not flows_file.exists():
614
+ flows_file = config_file.parent / "base" / "flows.yml"
615
+ if flows_file.exists():
616
+ request_kwargs["flows"] = flows_file.read_text()
617
+ if not json_output:
618
+ console.print(f"[dim]Including flows.yml from {flows_file}[/dim]")
619
+
620
+ if not json_output and request_kwargs:
621
+ console.print(f"[dim]Snapshot config: {list(request_kwargs.keys())}[/dim]")
622
+
457
623
  try:
458
624
  with get_http_client() as client:
459
625
  response = sessions_snapshot.sync(
460
626
  client=client,
461
627
  session_id=session_id,
462
- body=CreateCheckpointRequest(),
628
+ body=CreateCheckpointRequest(**request_kwargs),
463
629
  x_api_key=api_key,
464
630
  )
465
631
 
@@ -470,11 +636,17 @@ def sandbox_snapshot(
470
636
  artifact_id = result.artifact_id if hasattr(result, "artifact_id") else None
471
637
  break
472
638
 
639
+ # Save artifact_id to sandbox state for pm submit base
640
+ if artifact_id:
641
+ state["artifact_id"] = artifact_id
642
+ save_sandbox_state(state)
643
+
473
644
  if json_output:
474
645
  console.print(json.dumps({"artifact_id": artifact_id}))
475
646
  else:
476
647
  console.print("\n[green]Snapshot created successfully![/green]")
477
648
  console.print(f" [cyan]Artifact ID:[/cyan] {artifact_id}")
649
+ console.print(" [dim]Saved to .sandbox.yaml for 'plato pm submit base'[/dim]")
478
650
 
479
651
  except Exception as e:
480
652
  if json_output:
@@ -487,10 +659,25 @@ def sandbox_snapshot(
487
659
  @sandbox_app.command(name="stop")
488
660
  def sandbox_stop():
489
661
  """
490
- Stop and destroy the sandbox.
662
+ Stop and destroy the current sandbox.
663
+
664
+ Closes the session, cleans up SSH keys, and removes .sandbox.yaml.
665
+ Run this when you're done with the sandbox or want to start fresh.
666
+
667
+ REQUIRES:
668
+
669
+ .sandbox.yaml in current directory (created by 'plato sandbox start')
670
+
671
+ USAGE:
491
672
 
492
- Examples:
493
673
  plato sandbox stop
674
+
675
+ WHAT IT DOES:
676
+
677
+ 1. Stops the heartbeat process
678
+ 2. Closes the remote session
679
+ 3. Removes SSH config and keys
680
+ 4. Deletes .sandbox.yaml
494
681
  """
495
682
  api_key = require_api_key()
496
683
  state = require_sandbox_state()
@@ -547,11 +734,27 @@ def sandbox_status(
547
734
  json_output: bool = typer.Option(False, "--json", "-j", help="Output as JSON"),
548
735
  ):
549
736
  """
550
- Show current sandbox status and info.
737
+ Show current sandbox status and connection info.
738
+
739
+ Displays the public URL, SSH config, VM status, and other details
740
+ from .sandbox.yaml plus live status from the API.
551
741
 
552
- Examples:
553
- plato sandbox status
554
- plato sandbox status --json
742
+ REQUIRES:
743
+
744
+ .sandbox.yaml in current directory (created by 'plato sandbox start')
745
+
746
+ USAGE:
747
+
748
+ plato sandbox status # Human-readable output
749
+ plato sandbox status --json # JSON output for scripts
750
+
751
+ OUTPUT INCLUDES:
752
+
753
+ - Public URL (for browser access)
754
+ - SSH config path (for 'ssh -F <config> sandbox-<id>')
755
+ - VM status (running/stopped/etc.)
756
+ - Session ID, Job ID
757
+ - Service name, dataset
555
758
  """
556
759
  state = require_sandbox_state()
557
760
 
@@ -607,6 +810,34 @@ def sandbox_status(
607
810
 
608
811
  if json_output:
609
812
  output = {**state, "vm_status": vm_status, "vm_status_reason": vm_status_reason, "vm_running": vm_running}
813
+
814
+ # Add heartbeat status to JSON output
815
+ if session_id:
816
+ try:
817
+ with get_http_client() as client:
818
+ response = client.post(
819
+ f"/api/v2/sessions/{session_id}/heartbeat",
820
+ headers={"X-API-Key": api_key},
821
+ )
822
+ heartbeat_data = response.json()
823
+ output["heartbeat"] = {
824
+ "success": heartbeat_data.get("success", False),
825
+ "timestamp": heartbeat_data.get("timestamp"),
826
+ "jobs": {},
827
+ }
828
+ for job_id, job_result in heartbeat_data.get("results", {}).items():
829
+ errors = job_result.get("errors", [])
830
+ vm_ok = not any("registry_heartbeat_update" in e for e in errors)
831
+ worker_ok = not any("vm_heartbeat_message" in e for e in errors)
832
+ output["heartbeat"]["jobs"][job_id] = {
833
+ "success": job_result.get("success", False),
834
+ "vm_heartbeat_ok": vm_ok,
835
+ "worker_heartbeat_ok": worker_ok,
836
+ "errors": errors,
837
+ }
838
+ except Exception as e:
839
+ output["heartbeat"] = {"error": str(e)}
840
+
610
841
  console.print(json.dumps(output))
611
842
  else:
612
843
  console.print("\n[bold]Sandbox Status[/bold]")
@@ -648,7 +879,7 @@ def sandbox_status(
648
879
  if ssh_host and ssh_config_path:
649
880
  console.print(f" [cyan]SSH:[/cyan] ssh -F {ssh_config_path} {ssh_host}")
650
881
 
651
- # Display heartbeat status
882
+ # Display heartbeat process status
652
883
  heartbeat_pid = state.get("heartbeat_pid")
653
884
  if heartbeat_pid:
654
885
  # Check if process is still running
@@ -658,6 +889,30 @@ def sandbox_status(
658
889
  except ProcessLookupError:
659
890
  console.print(f" [cyan]Heartbeat:[/cyan] [red]stopped[/red] (PID: {heartbeat_pid} not found)")
660
891
 
892
+ # Call heartbeat endpoint and show detailed status
893
+ if session_id:
894
+ console.print("\n[bold]Heartbeat[/bold]")
895
+ try:
896
+ with get_http_client() as client:
897
+ response = client.post(
898
+ f"/api/v2/sessions/{session_id}/heartbeat",
899
+ headers={"X-API-Key": api_key},
900
+ )
901
+ hb = response.json()
902
+ console.print(f" success: {hb.get('success')}")
903
+ for job_id, result in hb.get("results", {}).items():
904
+ console.print(f" job {job_id[:8]}: success={result.get('success')}")
905
+ for err in result.get("errors") or []:
906
+ # Simplify error - just show type
907
+ if "502" in err:
908
+ console.print(" worker: 502 (not running)")
909
+ elif "registry" in err:
910
+ console.print(" vm: registry update failed")
911
+ else:
912
+ console.print(f" {err[:50]}")
913
+ except Exception as e:
914
+ console.print(f" error: {e}")
915
+
661
916
 
662
917
  @sandbox_app.command(name="start-worker")
663
918
  def sandbox_start_worker(
@@ -665,24 +920,46 @@ def sandbox_start_worker(
665
920
  None,
666
921
  "--service",
667
922
  "-s",
668
- help="Service name (uses sandbox service if not specified)",
923
+ help="Service name (defaults to value in .sandbox.yaml)",
669
924
  ),
670
925
  dataset: str = typer.Option("base", "--dataset", "-d", help="Dataset name"),
671
- config_path: Path | None = typer.Option(None, "--config-path", help="Path to plato-config.yml (required)"),
926
+ config_path: Path | None = typer.Option(None, "--config-path", help="Path to plato-config.yml"),
927
+ wait: bool = typer.Option(False, "--wait", "-w", help="Wait for worker to be ready (polls state API)"),
928
+ wait_timeout: int = typer.Option(240, "--wait-timeout", help="Timeout in seconds for --wait (default: 240)"),
672
929
  json_output: bool = typer.Option(False, "--json", "-j", help="Output as JSON"),
673
930
  ):
674
931
  """
675
- Start/configure the Plato worker in the sandbox (for blank VMs).
932
+ Start the Plato worker in the sandbox.
933
+
934
+ The worker handles flow execution and state tracking. Only start it
935
+ AFTER verifying login works (via 'plato sandbox flow' or browser testing).
936
+
937
+ REQUIRES:
676
938
 
677
- This is needed after creating a blank VM to configure the worker
678
- with the correct service and dataset settings.
939
+ .sandbox.yaml in current directory (created by 'plato sandbox start')
940
+ plato-config.yml in current directory (or specify with --config-path)
679
941
 
680
- Requires a plato-config.yml file with dataset configuration.
942
+ USAGE:
681
943
 
682
- Examples:
944
+ plato sandbox start-worker # Uses plato-config.yml in cwd
945
+ plato sandbox start-worker --wait # Wait for worker to be ready
946
+ plato sandbox start-worker -d base # Specify dataset
683
947
  plato sandbox start-worker --config-path ./plato-config.yml
684
- plato sandbox start-worker --config-path ./plato-config.yml --dataset base
685
- plato sandbox start-worker -s myapp -d base --config-path ./plato-config.yml
948
+
949
+ WORKFLOW POSITION:
950
+
951
+ 1. plato sandbox start -c
952
+ 2. plato sandbox start-services
953
+ 3. plato sandbox flow ← verify login works first!
954
+ 4. plato sandbox start-worker --wait ← you are here (wait ~2-3 min)
955
+ 5. plato sandbox flow ← run login again to verify with worker
956
+ 6. plato sandbox state --verify-no-mutations ← verify no mutations
957
+ 7. plato sandbox snapshot
958
+
959
+ WARNING:
960
+
961
+ Starting the worker with broken login causes infinite error loops.
962
+ Always verify login works before starting the worker.
686
963
  """
687
964
  api_key = require_api_key()
688
965
  state = require_sandbox_state()
@@ -763,18 +1040,66 @@ def sandbox_start_worker(
763
1040
  x_api_key=api_key,
764
1041
  )
765
1042
 
766
- if json_output:
767
- console.print(
768
- json.dumps(
769
- {
770
- "status": response.status if hasattr(response, "status") else "ok",
771
- "correlation_id": response.correlation_id if hasattr(response, "correlation_id") else None,
772
- }
773
- )
774
- )
775
- else:
1043
+ if not json_output:
776
1044
  console.print("[green]Worker started successfully![/green]")
777
1045
 
1046
+ # Wait for worker to be ready if --wait flag is set
1047
+ if wait:
1048
+ if not json_output:
1049
+ console.print(f"[cyan]Waiting for worker to be ready (timeout: {wait_timeout}s)...[/cyan]")
1050
+
1051
+ session_id = state.get("session_id")
1052
+ if not session_id:
1053
+ console.print("[red]Session ID not found in .sandbox.yaml[/red]")
1054
+ raise typer.Exit(1)
1055
+ start_time = time.time()
1056
+ poll_interval = 10 # seconds between polls
1057
+ worker_ready = False
1058
+
1059
+ while time.time() - start_time < wait_timeout:
1060
+ try:
1061
+ # Try to get state - if it works without error, worker is ready
1062
+ state_response = sessions_state.sync(
1063
+ client=client,
1064
+ session_id=session_id,
1065
+ x_api_key=api_key,
1066
+ )
1067
+ # Check if response has an error (like 502)
1068
+ if state_response and state_response.results:
1069
+ has_error = False
1070
+ for jid, result in state_response.results.items():
1071
+ result_dict = result.model_dump() if hasattr(result, "model_dump") else result
1072
+ if isinstance(result_dict, dict) and "error" in result_dict:
1073
+ has_error = True
1074
+ break
1075
+ if not has_error:
1076
+ worker_ready = True
1077
+ break
1078
+ except Exception:
1079
+ pass # Worker not ready yet
1080
+
1081
+ elapsed = int(time.time() - start_time)
1082
+ if not json_output:
1083
+ console.print(f" [dim]Worker not ready yet... ({elapsed}s elapsed)[/dim]")
1084
+ time.sleep(poll_interval)
1085
+
1086
+ if worker_ready:
1087
+ if not json_output:
1088
+ elapsed = int(time.time() - start_time)
1089
+ console.print(f"[green]Worker ready after {elapsed}s![/green]")
1090
+ else:
1091
+ if not json_output:
1092
+ console.print(f"[yellow]Warning: Worker not ready after {wait_timeout}s timeout[/yellow]")
1093
+
1094
+ if json_output:
1095
+ result = {
1096
+ "status": response.status if hasattr(response, "status") else "ok",
1097
+ "correlation_id": response.correlation_id if hasattr(response, "correlation_id") else None,
1098
+ }
1099
+ if wait:
1100
+ result["worker_ready"] = worker_ready
1101
+ console.print(json.dumps(result))
1102
+
778
1103
  except Exception as e:
779
1104
  if json_output:
780
1105
  console.print(json.dumps({"error": str(e)}))
@@ -796,15 +1121,35 @@ def sandbox_sync(
796
1121
  json_output: bool = typer.Option(False, "--json", "-j", help="Output as JSON"),
797
1122
  ):
798
1123
  """
799
- Sync local code to the sandbox (pure Python, no rsync needed).
1124
+ Sync local files to the sandbox VM.
1125
+
1126
+ Uploads local files to the remote sandbox. Useful for updating
1127
+ docker-compose.yml, flows.yml, or other config without restarting.
1128
+
1129
+ REQUIRES:
1130
+
1131
+ .sandbox.yaml in current directory (created by 'plato sandbox start')
1132
+
1133
+ USAGE:
1134
+
1135
+ plato sandbox sync # Sync current directory
1136
+ plato sandbox sync ./base # Sync specific directory
1137
+ plato sandbox sync -r /custom/path # Custom remote path
1138
+
1139
+ DEFAULT REMOTE PATH:
1140
+
1141
+ /home/plato/worktree/<service>/
1142
+
1143
+ WHAT IT SYNCS:
1144
+
1145
+ - Respects .gitignore patterns
1146
+ - Excludes .git, __pycache__, node_modules, etc.
1147
+ - Creates tar archive and extracts on remote
800
1148
 
801
- Creates a tar archive of the local directory, uploads it via the
802
- execute API, and extracts it on the remote sandbox.
1149
+ NOTE:
803
1150
 
804
- Examples:
805
- plato sandbox sync
806
- plato sandbox sync ./src
807
- plato sandbox sync --remote-path /home/plato/custom/path
1151
+ For most workflows, use 'plato sandbox start-services' instead,
1152
+ which syncs files AND restarts containers.
808
1153
  """
809
1154
  api_key = require_api_key()
810
1155
  state = require_sandbox_state()
@@ -1017,20 +1362,50 @@ _flow_logger = logging.getLogger("plato.flow")
1017
1362
  @sandbox_app.command(name="flow")
1018
1363
  def sandbox_flow(
1019
1364
  flow_name: str = typer.Option("login", "--flow-name", "-f", help="Name of the flow to execute"),
1365
+ local: bool = typer.Option(False, "--local", "-l", help="Force using local flows.yml only"),
1366
+ api: bool = typer.Option(False, "--api", "-a", help="Force fetching flows from API only"),
1020
1367
  ):
1021
1368
  """
1022
- Execute a test flow against a simulator environment.
1369
+ Execute a test flow against the running sandbox.
1023
1370
 
1024
- Reads .sandbox.yaml to get the URL. Uses "login" as default flow name.
1371
+ Runs a flow (like login) to verify it works before starting the worker.
1372
+ Opens a browser and executes the flow steps automatically.
1025
1373
 
1026
- Flow source priority:
1027
- 1. Local plato-config.yml (development workflow)
1028
- 2. Flows from API based on artifact (artifact/simulator mode)
1374
+ REQUIRES:
1029
1375
 
1030
- Example:
1031
- plato sandbox flow
1032
- plato sandbox flow --flow-name login
1376
+ .sandbox.yaml in current directory (created by 'plato sandbox start')
1377
+ Either:
1378
+ - Local plato-config.yml with flows_path pointing to flows.yml
1379
+ - Or sandbox started from artifact (flows fetched from API)
1380
+
1381
+ USAGE:
1382
+
1383
+ plato sandbox flow # Run "login" flow (default)
1384
+ plato sandbox flow -f login # Explicit flow name
1385
+ plato sandbox flow -f incorrect_login # Test failed login flow
1386
+ plato sandbox flow --local # Force local flows.yml
1387
+ plato sandbox flow --api # Force API flows (from artifact)
1388
+
1389
+ WORKFLOW POSITION:
1390
+
1391
+ 1. plato sandbox start -c
1392
+ 2. plato sandbox start-services
1393
+ 3. plato sandbox flow ← you are here (verify login)
1394
+ 4. plato sandbox start-worker
1395
+ 5. plato sandbox snapshot
1396
+
1397
+ FLOW SOURCE (default priority):
1398
+
1399
+ 1. Local flows.yml (from plato-config.yml metadata.flows_path)
1400
+ 2. API (fetched from artifact if started from simulator)
1401
+
1402
+ Use --local or --api to override this behavior.
1033
1403
  """
1404
+ # Validate mutually exclusive flags
1405
+ if local and api:
1406
+ console.print("[red]❌ Cannot use both --local and --api[/red]")
1407
+ raise typer.Exit(1)
1408
+
1034
1409
  api_key = require_api_key()
1035
1410
  sandbox_data = require_sandbox_state()
1036
1411
  job_id = sandbox_data.get("job_id")
@@ -1043,16 +1418,16 @@ def sandbox_flow(
1043
1418
  if simulator:
1044
1419
  service_name = simulator.split(":")[0] if ":" in simulator else simulator
1045
1420
 
1046
- # Build proper URL with router target
1047
- public_url = sandbox_data.get("public_url") or sandbox_data.get("url")
1048
- if not public_url:
1049
- console.print("[red]❌ No URL found in .sandbox.yaml[/red]")
1421
+ # Get URL from sandbox state
1422
+ url = sandbox_data.get("public_url")
1423
+ if not url:
1424
+ console.print("[red]❌ No public_url found in .sandbox.yaml[/red]")
1050
1425
  raise typer.Exit(1)
1051
1426
 
1052
- # Always construct URL with router target for proper routing
1053
- url = format_public_url_with_router_target(public_url, service_name)
1427
+ # Ensure URL has router target (in case of older .sandbox.yaml files)
1428
+ url = format_public_url_with_router_target(url, service_name)
1054
1429
 
1055
- # Try to get flows from local plato-config.yml first
1430
+ # Try to get flows from local plato-config.yml first (unless --api is set)
1056
1431
  flow_obj = None
1057
1432
  flow_file = None
1058
1433
  screenshots_dir = None
@@ -1061,11 +1436,12 @@ def sandbox_flow(
1061
1436
  plato_config_path = sandbox_data.get("plato_config_path")
1062
1437
  dataset = sandbox_data.get("dataset", "base")
1063
1438
 
1064
- # Try local config first (either from state or current directory)
1439
+ # Try local config first (either from state or current directory) unless --api forces API
1065
1440
  local_config_paths = []
1066
- if plato_config_path:
1067
- local_config_paths.append(Path(plato_config_path))
1068
- local_config_paths.extend([Path.cwd() / "plato-config.yml", Path.cwd() / "plato-config.yaml"])
1441
+ if not api: # Skip local if --api flag is set
1442
+ if plato_config_path:
1443
+ local_config_paths.append(Path(plato_config_path))
1444
+ local_config_paths.extend([Path.cwd() / "plato-config.yml", Path.cwd() / "plato-config.yaml"])
1069
1445
 
1070
1446
  for config_path in local_config_paths:
1071
1447
  if config_path.exists():
@@ -1098,11 +1474,19 @@ def sandbox_flow(
1098
1474
  screenshots_dir = Path(flow_file).parent / "screenshots"
1099
1475
  console.print(f"[cyan]Flow source: local ({flow_file})[/cyan]")
1100
1476
  break
1101
- except Exception:
1477
+ except Exception as e:
1478
+ console.print(f"[yellow]Warning: Failed to load flow from {config_path}: {e}[/yellow]")
1102
1479
  pass # Try next config or fall back to API
1103
1480
 
1104
1481
  # If no local flow found, fetch from API
1482
+ # If no local flow found (or --api forced), fetch from API
1105
1483
  if not flow_obj:
1484
+ # If --local was specified and we didn't find a local flow, error out
1485
+ if local:
1486
+ console.print("[red]❌ No local flow found and --local flag was specified[/red]")
1487
+ console.print("[yellow]Ensure plato-config.yml exists with flows_path pointing to flows.yml[/yellow]")
1488
+ raise typer.Exit(1)
1489
+
1106
1490
  if not job_id:
1107
1491
  console.print("[red]❌ No local plato-config.yml found and no job_id in .sandbox.yaml[/red]")
1108
1492
  console.print("[yellow]Either create a local plato-config.yml or start sandbox from an artifact[/yellow]")
@@ -1151,17 +1535,31 @@ def sandbox_flow(
1151
1535
  console.print(f"[red]❌ Failed to fetch flows from API: {e}[/red]")
1152
1536
  raise typer.Exit(1) from e
1153
1537
 
1538
+ # At this point, url and flow_obj must be set (validated above)
1539
+ if not url:
1540
+ console.print("[red]❌ URL is not set[/red]")
1541
+ raise typer.Exit(1)
1542
+ if not flow_obj:
1543
+ console.print("[red]❌ Flow object could not be loaded[/red]")
1544
+ raise typer.Exit(1)
1545
+
1154
1546
  console.print(f"[cyan]URL: {url}[/cyan]")
1155
1547
  console.print(f"[cyan]Flow name: {flow_name}[/cyan]")
1156
1548
 
1549
+ # Capture for closure (narrowed types)
1550
+ _url: str = url
1551
+ _flow_obj: Flow = flow_obj
1552
+
1157
1553
  async def _run():
1554
+ from playwright.async_api import async_playwright
1555
+
1158
1556
  browser = None
1159
1557
  try:
1160
1558
  async with async_playwright() as p:
1161
1559
  browser = await p.chromium.launch(headless=False)
1162
1560
  page = await browser.new_page()
1163
- await page.goto(url)
1164
- executor = FlowExecutor(page, flow_obj, screenshots_dir, log=_flow_logger)
1561
+ await page.goto(_url)
1562
+ executor = FlowExecutor(page, _flow_obj, screenshots_dir, log=_flow_logger)
1165
1563
  await executor.execute()
1166
1564
  console.print("[green]✅ Flow executed successfully[/green]")
1167
1565
  except Exception as e:
@@ -1175,37 +1573,98 @@ def sandbox_flow(
1175
1573
 
1176
1574
 
1177
1575
  @sandbox_app.command(name="state")
1178
- def sandbox_state_cmd():
1179
- """Get the current state of the simulator environment."""
1180
- state = require_sandbox_state()
1576
+ def sandbox_state_cmd(
1577
+ verify_no_mutations: bool = typer.Option(
1578
+ False, "--verify-no-mutations", "-v", help="Exit with code 1 if mutations are detected (for CI/automation)"
1579
+ ),
1580
+ json_output: bool = typer.Option(False, "--json", "-j", help="Output as JSON"),
1581
+ ):
1582
+ """
1583
+ Get the database state/mutations from the simulator.
1584
+
1585
+ Shows what database changes have been detected since the last reset.
1586
+ Useful during review to verify:
1587
+ - No mutations after login (state should be empty)
1588
+ - Mutations appear after making changes (audit is working)
1589
+
1590
+ REQUIRES:
1591
+
1592
+ .sandbox.yaml in current directory (created by 'plato sandbox start')
1593
+
1594
+ USAGE:
1595
+
1596
+ plato sandbox state # Show current state
1597
+ plato sandbox state --verify-no-mutations # Exit 1 if mutations found
1598
+ plato sandbox state -v # Short form
1599
+
1600
+ USED DURING REVIEW:
1601
+
1602
+ 1. Run login flow
1603
+ 2. plato sandbox state -v ← should pass (no mutations)
1604
+ 3. Make a change in the app
1605
+ 4. plato sandbox state ← should show mutations
1606
+ """
1607
+ sandbox_state = require_sandbox_state()
1181
1608
  api_key = require_api_key()
1182
1609
 
1183
1610
  # Try session_id first (v2 SDK), then job_id, then job_group_id (legacy)
1184
- session_id = state.get("session_id")
1185
- job_id = state.get("job_id")
1186
- job_group_id = state.get("job_group_id")
1611
+ session_id = sandbox_state.get("session_id")
1612
+ job_id = sandbox_state.get("job_id")
1613
+ job_group_id = sandbox_state.get("job_group_id")
1614
+
1615
+ state_dict = None
1616
+ has_mutations = False
1617
+ has_error = False
1618
+ error_message = None
1619
+
1620
+ def check_mutations(result_dict: dict) -> tuple[bool, bool, str | None]:
1621
+ """Check if result has mutations or errors. Returns (has_mutations, has_error, error_msg)."""
1622
+ if isinstance(result_dict, dict):
1623
+ # Check for state
1624
+ state = result_dict.get("state", {})
1625
+ if isinstance(state, dict):
1626
+ # Check for error wrapped in state (from API layer transformation)
1627
+ if "error" in state:
1628
+ return False, True, state["error"]
1629
+ # Check for db state
1630
+ db_state = state.get("db", {})
1631
+ if isinstance(db_state, dict):
1632
+ mutations = db_state.get("mutations", [])
1633
+ if mutations:
1634
+ return True, False, None
1635
+ # Also check audit_log_count
1636
+ audit_count = db_state.get("audit_log_count", 0)
1637
+ if audit_count > 0:
1638
+ return True, False, None
1639
+ # Check top-level mutations as fallback
1640
+ mutations = result_dict.get("mutations", [])
1641
+ if mutations:
1642
+ return True, False, None
1643
+ return False, False, None
1187
1644
 
1188
1645
  if session_id:
1189
- console.print(f"[cyan]Getting state for session: {session_id}[/cyan]")
1646
+ if not json_output:
1647
+ console.print(f"[cyan]Getting state for session: {session_id}[/cyan]")
1190
1648
  with get_http_client() as client:
1191
1649
  response = sessions_state.sync(
1192
1650
  client=client,
1193
1651
  session_id=session_id,
1194
1652
  x_api_key=api_key,
1195
1653
  )
1196
- # Convert response to dict for display
1197
1654
  if response and response.results:
1198
1655
  state_dict = {
1199
1656
  jid: result.model_dump() if hasattr(result, "model_dump") else result
1200
1657
  for jid, result in response.results.items()
1201
1658
  }
1202
- console.print("\n[bold]Environment State:[/bold]")
1203
- console.print(json.dumps(state_dict, indent=2, default=str))
1204
- else:
1205
- console.print("[yellow]No state returned[/yellow]")
1659
+ for jid, result in state_dict.items():
1660
+ m, e, msg = check_mutations(result)
1661
+ has_mutations = has_mutations or m
1662
+ has_error = has_error or e
1663
+ if msg:
1664
+ error_message = msg
1206
1665
  elif job_id:
1207
- # Use job-level state API
1208
- console.print(f"[cyan]Getting state for job: {job_id}[/cyan]")
1666
+ if not json_output:
1667
+ console.print(f"[cyan]Getting state for job: {job_id}[/cyan]")
1209
1668
  with get_http_client() as client:
1210
1669
  response = jobs_state.sync(
1211
1670
  client=client,
@@ -1214,13 +1673,13 @@ def sandbox_state_cmd():
1214
1673
  )
1215
1674
  if response:
1216
1675
  state_dict = response.model_dump() if hasattr(response, "model_dump") else response
1217
- console.print("\n[bold]Environment State:[/bold]")
1218
- console.print(json.dumps(state_dict, indent=2, default=str))
1219
- else:
1220
- console.print("[yellow]No state returned[/yellow]")
1676
+ m, e, msg = check_mutations(state_dict)
1677
+ has_mutations = m
1678
+ has_error = e
1679
+ error_message = msg
1221
1680
  elif job_group_id:
1222
- # Legacy: Use job_group_id with session state API (it accepts job_group_id as session_id)
1223
- console.print(f"[cyan]Getting state for job_group: {job_group_id}[/cyan]")
1681
+ if not json_output:
1682
+ console.print(f"[cyan]Getting state for job_group: {job_group_id}[/cyan]")
1224
1683
  with get_http_client() as client:
1225
1684
  response = sessions_state.sync(
1226
1685
  client=client,
@@ -1232,25 +1691,74 @@ def sandbox_state_cmd():
1232
1691
  jid: result.model_dump() if hasattr(result, "model_dump") else result
1233
1692
  for jid, result in response.results.items()
1234
1693
  }
1235
- console.print("\n[bold]Environment State:[/bold]")
1236
- console.print(json.dumps(state_dict, indent=2, default=str))
1237
- else:
1238
- console.print("[yellow]No state returned[/yellow]")
1694
+ for jid, result in state_dict.items():
1695
+ m, e, msg = check_mutations(result)
1696
+ has_mutations = has_mutations or m
1697
+ has_error = has_error or e
1698
+ if msg:
1699
+ error_message = msg
1239
1700
  else:
1240
1701
  console.print("[red]❌ .sandbox.yaml missing session_id, job_id, or job_group_id[/red]")
1241
1702
  raise typer.Exit(1)
1242
1703
 
1704
+ # Output results
1705
+ if json_output:
1706
+ result = {
1707
+ "state": state_dict,
1708
+ "has_mutations": has_mutations,
1709
+ "has_error": has_error,
1710
+ }
1711
+ if error_message:
1712
+ result["error"] = error_message
1713
+ console.print(json.dumps(result, indent=2, default=str))
1714
+ else:
1715
+ if has_error:
1716
+ console.print(f"\n[red]Error getting state:[/red] {error_message}")
1717
+ elif state_dict:
1718
+ console.print("\n[bold]Environment State:[/bold]")
1719
+ console.print(json.dumps(state_dict, indent=2, default=str))
1720
+ else:
1721
+ console.print("[yellow]No state returned[/yellow]")
1722
+
1723
+ # Summary for verify mode
1724
+ if verify_no_mutations:
1725
+ if has_error:
1726
+ console.print("\n[red]❌ Error checking state - worker may not be ready[/red]")
1727
+ elif has_mutations:
1728
+ console.print("\n[red]❌ FAIL: Mutations detected after login![/red]")
1729
+ console.print(
1730
+ "[yellow]Fix: Add affected tables/columns to audit_ignore_tables in plato-config.yml[/yellow]"
1731
+ )
1732
+ else:
1733
+ console.print("\n[green]✅ PASS: No mutations detected[/green]")
1734
+
1735
+ # Exit with error code if verify mode and mutations/errors found
1736
+ if verify_no_mutations and (has_mutations or has_error):
1737
+ raise typer.Exit(1)
1738
+
1243
1739
 
1244
1740
  @sandbox_app.command(name="audit-ui")
1245
1741
  def sandbox_audit_ui():
1246
1742
  """
1247
- Launch Streamlit UI for auditing database ignore rules.
1743
+ Launch Streamlit UI for configuring database audit rules.
1744
+
1745
+ Opens a visual interface to help configure audit_ignore_tables
1746
+ in plato-config.yml. Useful when you see unwanted mutations
1747
+ during review (like session tables, timestamps, etc.).
1748
+
1749
+ REQUIRES:
1750
+
1751
+ streamlit installed: pip install streamlit psycopg2-binary pymysql
1248
1752
 
1249
- Note: Requires streamlit to be installed:
1250
- pip install streamlit psycopg2-binary pymysql
1753
+ USAGE:
1251
1754
 
1252
- Examples:
1253
1755
  plato sandbox audit-ui
1756
+
1757
+ WHEN TO USE:
1758
+
1759
+ - Review shows mutations after login (sessions, timestamps)
1760
+ - Need to figure out which tables/columns to ignore
1761
+ - Want visual help building audit_ignore_tables config
1254
1762
  """
1255
1763
  # Check if streamlit is installed
1256
1764
  if not shutil.which("streamlit"):
@@ -1340,25 +1848,51 @@ def _start_heartbeat_process(session_id: str, api_key: str) -> int | None:
1340
1848
 
1341
1849
  Returns the PID of the background process, or None if failed.
1342
1850
  """
1851
+ # Log file for heartbeat debugging
1852
+ log_file = f"/tmp/plato_heartbeat_{session_id}.log"
1853
+
1343
1854
  # Python script to run in background
1344
1855
  heartbeat_script = f'''
1345
1856
  import time
1346
1857
  import os
1347
1858
  import httpx
1859
+ from datetime import datetime
1348
1860
 
1349
1861
  session_id = "{session_id}"
1350
1862
  api_key = "{api_key}"
1351
1863
  base_url = os.getenv("PLATO_BASE_URL", "https://plato.so")
1864
+ log_file = "{log_file}"
1352
1865
 
1866
+ def log(msg):
1867
+ timestamp = datetime.now().isoformat()
1868
+ with open(log_file, "a") as f:
1869
+ f.write(f"[{{timestamp}}] {{msg}}\\n")
1870
+ f.flush()
1871
+
1872
+ log(f"Heartbeat process started for session {{session_id}}")
1873
+ log(f"Base URL: {{base_url}}")
1874
+
1875
+ heartbeat_count = 0
1353
1876
  while True:
1877
+ heartbeat_count += 1
1354
1878
  try:
1355
1879
  with httpx.Client(base_url=base_url, timeout=30) as client:
1356
- client.post(
1357
- f"/api/v2/sessions/{{session_id}}/heartbeat",
1880
+ # base_url already includes /api, so just use /v2/...
1881
+ response = client.post(
1882
+ f"/v2/sessions/{{session_id}}/heartbeat",
1358
1883
  headers={{"X-API-Key": api_key}},
1359
1884
  )
1360
- except Exception:
1361
- pass
1885
+ result = response.json()
1886
+ success = result.get("success", False)
1887
+ log(f"Heartbeat #{{heartbeat_count}}: status={{response.status_code}}, success={{success}}")
1888
+ if not success:
1889
+ results = result.get("results", {{}})
1890
+ for job_id, job_result in results.items():
1891
+ errors = job_result.get("errors", [])
1892
+ if errors:
1893
+ log(f" Job {{job_id}} errors: {{errors}}")
1894
+ except Exception as e:
1895
+ log(f"Heartbeat #{{heartbeat_count}} EXCEPTION: {{type(e).__name__}}: {{e}}")
1362
1896
  time.sleep(30)
1363
1897
  '''
1364
1898
 
@@ -1398,21 +1932,39 @@ def sandbox_start_services(
1398
1932
  json_output: bool = typer.Option(False, "--json", "-j", help="Output as JSON"),
1399
1933
  ):
1400
1934
  """
1401
- Push code to hub, clone on VM, and start docker compose services.
1935
+ Deploy and start docker compose services on the sandbox.
1936
+
1937
+ Syncs your local code to the VM and starts the containers defined
1938
+ in plato-config.yml. This is the main command for deploying your app.
1939
+
1940
+ REQUIRES:
1941
+
1942
+ .sandbox.yaml in current directory (created by 'plato sandbox start -c')
1943
+ plato-config.yml with services defined
1944
+
1945
+ USAGE:
1946
+
1947
+ plato sandbox start-services # Deploy and start containers
1948
+ plato sandbox start-services --json # JSON output
1402
1949
 
1403
- This command:
1404
- 1. Pushes your local code to the Plato Hub (Gitea)
1405
- 2. Clones the code on the VM via SSH
1406
- 3. Starts docker compose services defined in plato-config.yml
1950
+ WHAT IT DOES:
1407
1951
 
1408
- Requires:
1409
- - A running sandbox (from 'plato sandbox start --from-config')
1410
- - plato-config.yml with services defined
1411
- - SSH access configured
1952
+ 1. Pushes local code to Plato Hub (Gitea)
1953
+ 2. Clones code on VM via SSH
1954
+ 3. Runs 'docker compose up -d' on VM
1955
+ 4. Waits for containers to be healthy
1412
1956
 
1413
- Examples:
1414
- plato sandbox start-services
1415
- plato sandbox start-services --json
1957
+ WORKFLOW POSITION:
1958
+
1959
+ 1. plato sandbox start -c ← creates VM
1960
+ 2. plato sandbox start-services ← you are here (deploy app)
1961
+ 3. plato sandbox flow ← verify login
1962
+ 4. plato sandbox start-worker
1963
+ 5. plato sandbox snapshot
1964
+
1965
+ AFTER MAKING CHANGES:
1966
+
1967
+ Run this command again to re-sync and restart containers.
1416
1968
  """
1417
1969
  api_key = require_api_key()
1418
1970
  state = require_sandbox_state()
@@ -1486,6 +2038,10 @@ def sandbox_start_services(
1486
2038
  if not json_output:
1487
2039
  console.print("[cyan]Step 3: Getting/creating repository...[/cyan]")
1488
2040
 
2041
+ if sim_id is None:
2042
+ console.print("[red]❌ Simulator ID not available[/red]")
2043
+ raise typer.Exit(1)
2044
+
1489
2045
  if has_repo:
1490
2046
  repo = get_simulator_repository.sync(client=client, simulator_id=sim_id, x_api_key=api_key)
1491
2047
  else: