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.
- plato/__init__.py +7 -6
- plato/_generated/__init__.py +1 -1
- plato/_generated/api/v1/env/evaluate_session.py +3 -3
- plato/_generated/api/v1/env/log_state_mutation.py +4 -4
- plato/_generated/api/v1/sandbox/checkpoint_vm.py +3 -3
- plato/_generated/api/v1/sandbox/save_vm_snapshot.py +3 -3
- plato/_generated/api/v1/sandbox/setup_sandbox.py +8 -8
- plato/_generated/api/v1/session/__init__.py +2 -0
- plato/_generated/api/v1/session/get_sessions_for_archival.py +100 -0
- plato/_generated/api/v1/testcases/__init__.py +6 -2
- plato/_generated/api/v1/testcases/get_mutation_groups_for_testcase.py +98 -0
- plato/_generated/api/v1/testcases/{get_next_output_testcase_for_scoring.py → get_next_testcase_for_scoring.py} +23 -10
- plato/_generated/api/v1/testcases/get_testcase_metadata_for_scoring.py +74 -0
- plato/_generated/api/v2/__init__.py +2 -1
- plato/_generated/api/v2/jobs/__init__.py +4 -0
- plato/_generated/api/v2/jobs/checkpoint.py +3 -3
- plato/_generated/api/v2/jobs/disk_snapshot.py +3 -3
- plato/_generated/api/v2/jobs/log_for_job.py +4 -39
- plato/_generated/api/v2/jobs/make.py +4 -4
- plato/_generated/api/v2/jobs/setup_sandbox.py +97 -0
- plato/_generated/api/v2/jobs/snapshot.py +3 -3
- plato/_generated/api/v2/jobs/snapshot_store.py +91 -0
- plato/_generated/api/v2/sessions/__init__.py +4 -0
- plato/_generated/api/v2/sessions/checkpoint.py +3 -3
- plato/_generated/api/v2/sessions/disk_snapshot.py +3 -3
- plato/_generated/api/v2/sessions/evaluate.py +3 -3
- plato/_generated/api/v2/sessions/log_job_mutation.py +4 -39
- plato/_generated/api/v2/sessions/make.py +4 -4
- plato/_generated/api/v2/sessions/setup_sandbox.py +98 -0
- plato/_generated/api/v2/sessions/snapshot.py +3 -3
- plato/_generated/api/v2/sessions/snapshot_store.py +94 -0
- plato/_generated/api/v2/user/__init__.py +7 -0
- plato/_generated/api/v2/user/get_current_user.py +76 -0
- plato/_generated/models/__init__.py +174 -23
- plato/_sims_generator/__init__.py +19 -4
- plato/_sims_generator/instruction.py +203 -0
- plato/_sims_generator/templates/instruction/helpers.py.jinja +161 -0
- plato/_sims_generator/templates/instruction/init.py.jinja +43 -0
- plato/agents/__init__.py +107 -517
- plato/agents/base.py +145 -0
- plato/agents/build.py +61 -0
- plato/agents/config.py +160 -0
- plato/agents/logging.py +401 -0
- plato/agents/runner.py +161 -0
- plato/agents/trajectory.py +266 -0
- plato/chronos/__init__.py +37 -0
- plato/chronos/api/__init__.py +3 -0
- plato/chronos/api/agents/__init__.py +13 -0
- plato/chronos/api/agents/create_agent.py +63 -0
- plato/chronos/api/agents/delete_agent.py +61 -0
- plato/chronos/api/agents/get_agent.py +62 -0
- plato/chronos/api/agents/get_agent_schema.py +72 -0
- plato/chronos/api/agents/get_agent_versions.py +62 -0
- plato/chronos/api/agents/list_agents.py +57 -0
- plato/chronos/api/agents/lookup_agent.py +74 -0
- plato/chronos/api/auth/__init__.py +9 -0
- plato/chronos/api/auth/debug_auth_api_auth_debug_get.py +43 -0
- plato/chronos/api/auth/get_auth_status_api_auth_status_get.py +61 -0
- plato/chronos/api/auth/get_current_user_route_api_auth_me_get.py +60 -0
- plato/chronos/api/callback/__init__.py +11 -0
- plato/chronos/api/callback/push_agent_logs.py +61 -0
- plato/chronos/api/callback/update_agent_status.py +57 -0
- plato/chronos/api/callback/upload_artifacts.py +59 -0
- plato/chronos/api/callback/upload_logs_zip.py +57 -0
- plato/chronos/api/callback/upload_trajectory.py +57 -0
- plato/chronos/api/default/__init__.py +7 -0
- plato/chronos/api/default/health.py +43 -0
- plato/chronos/api/jobs/__init__.py +7 -0
- plato/chronos/api/jobs/launch_job.py +63 -0
- plato/chronos/api/registry/__init__.py +19 -0
- plato/chronos/api/registry/get_agent_schema_api_registry_agents__agent_name__schema_get.py +62 -0
- plato/chronos/api/registry/get_agent_versions_api_registry_agents__agent_name__versions_get.py +52 -0
- plato/chronos/api/registry/get_world_schema_api_registry_worlds__package_name__schema_get.py +68 -0
- plato/chronos/api/registry/get_world_versions_api_registry_worlds__package_name__versions_get.py +52 -0
- plato/chronos/api/registry/list_registry_agents_api_registry_agents_get.py +44 -0
- plato/chronos/api/registry/list_registry_worlds_api_registry_worlds_get.py +44 -0
- plato/chronos/api/runtimes/__init__.py +11 -0
- plato/chronos/api/runtimes/create_runtime.py +63 -0
- plato/chronos/api/runtimes/delete_runtime.py +61 -0
- plato/chronos/api/runtimes/get_runtime.py +62 -0
- plato/chronos/api/runtimes/list_runtimes.py +57 -0
- plato/chronos/api/runtimes/test_runtime.py +67 -0
- plato/chronos/api/secrets/__init__.py +11 -0
- plato/chronos/api/secrets/create_secret.py +63 -0
- plato/chronos/api/secrets/delete_secret.py +61 -0
- plato/chronos/api/secrets/get_secret.py +62 -0
- plato/chronos/api/secrets/list_secrets.py +57 -0
- plato/chronos/api/secrets/update_secret.py +68 -0
- plato/chronos/api/sessions/__init__.py +10 -0
- plato/chronos/api/sessions/get_session.py +62 -0
- plato/chronos/api/sessions/get_session_logs.py +72 -0
- plato/chronos/api/sessions/get_session_logs_download.py +62 -0
- plato/chronos/api/sessions/list_sessions.py +57 -0
- plato/chronos/api/status/__init__.py +8 -0
- plato/chronos/api/status/get_status_api_status_get.py +44 -0
- plato/chronos/api/status/get_version_info_api_version_get.py +44 -0
- plato/chronos/api/templates/__init__.py +11 -0
- plato/chronos/api/templates/create_template.py +63 -0
- plato/chronos/api/templates/delete_template.py +61 -0
- plato/chronos/api/templates/get_template.py +62 -0
- plato/chronos/api/templates/list_templates.py +57 -0
- plato/chronos/api/templates/update_template.py +68 -0
- plato/chronos/api/trajectories/__init__.py +8 -0
- plato/chronos/api/trajectories/get_trajectory.py +62 -0
- plato/chronos/api/trajectories/list_trajectories.py +62 -0
- plato/chronos/api/worlds/__init__.py +10 -0
- plato/chronos/api/worlds/create_world.py +63 -0
- plato/chronos/api/worlds/delete_world.py +61 -0
- plato/chronos/api/worlds/get_world.py +62 -0
- plato/chronos/api/worlds/list_worlds.py +57 -0
- plato/chronos/client.py +171 -0
- plato/chronos/errors.py +141 -0
- plato/chronos/models/__init__.py +647 -0
- plato/chronos/py.typed +0 -0
- plato/sims/cli.py +299 -123
- plato/sims/registry.py +77 -4
- plato/v1/cli/agent.py +88 -84
- plato/v1/cli/main.py +2 -0
- plato/v1/cli/pm.py +441 -119
- plato/v1/cli/sandbox.py +747 -191
- plato/v1/cli/sim.py +11 -0
- plato/v1/cli/verify.py +1269 -0
- plato/v1/cli/world.py +3 -0
- plato/v1/flow_executor.py +21 -17
- plato/v1/models/env.py +11 -11
- plato/v1/sdk.py +2 -2
- plato/v1/sync_env.py +11 -11
- plato/v1/sync_flow_executor.py +21 -17
- plato/v1/sync_sdk.py +4 -2
- plato/v2/__init__.py +2 -0
- plato/v2/async_/environment.py +20 -1
- plato/v2/async_/session.py +54 -3
- plato/v2/sync/environment.py +2 -1
- plato/v2/sync/session.py +52 -2
- plato/worlds/README.md +218 -0
- plato/worlds/__init__.py +54 -18
- plato/worlds/base.py +304 -93
- plato/worlds/config.py +239 -73
- plato/worlds/runner.py +391 -80
- {plato_sdk_v2-2.0.50.dist-info → plato_sdk_v2-2.2.4.dist-info}/METADATA +1 -3
- {plato_sdk_v2-2.0.50.dist-info → plato_sdk_v2-2.2.4.dist-info}/RECORD +143 -68
- {plato_sdk_v2-2.0.50.dist-info → plato_sdk_v2-2.2.4.dist-info}/entry_points.txt +1 -0
- plato/_generated/api/v2/interfaces/__init__.py +0 -27
- plato/_generated/api/v2/interfaces/v2_interface_browser_create.py +0 -68
- plato/_generated/api/v2/interfaces/v2_interface_cdp_url.py +0 -65
- plato/_generated/api/v2/interfaces/v2_interface_click.py +0 -64
- plato/_generated/api/v2/interfaces/v2_interface_close.py +0 -59
- plato/_generated/api/v2/interfaces/v2_interface_computer_create.py +0 -68
- plato/_generated/api/v2/interfaces/v2_interface_cursor.py +0 -64
- plato/_generated/api/v2/interfaces/v2_interface_key.py +0 -68
- plato/_generated/api/v2/interfaces/v2_interface_screenshot.py +0 -65
- plato/_generated/api/v2/interfaces/v2_interface_scroll.py +0 -70
- plato/_generated/api/v2/interfaces/v2_interface_type.py +0 -64
- plato/world/__init__.py +0 -44
- plato/world/base.py +0 -267
- plato/world/config.py +0 -139
- plato/world/types.py +0 -47
- {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
|
-
|
|
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
|
|
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
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
131
|
-
|
|
132
|
-
|
|
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
|
-
|
|
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
|
-
|
|
140
|
-
|
|
141
|
-
|
|
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]
|
|
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]
|
|
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
|
-
|
|
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
|
-
|
|
224
|
-
# Extract sim_name
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
366
|
+
|
|
367
|
+
if job_id:
|
|
294
368
|
if not json_output:
|
|
295
|
-
console.print("[cyan]Setting up
|
|
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=
|
|
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:
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
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
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
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
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
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
|
-
|
|
354
|
-
|
|
355
|
-
|
|
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
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
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]
|
|
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:
|
|
372
|
-
console.print("[yellow]You may
|
|
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":
|
|
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":
|
|
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
|
-
|
|
447
|
-
|
|
448
|
-
|
|
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
|
-
|
|
553
|
-
|
|
554
|
-
plato sandbox
|
|
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 (
|
|
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
|
|
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
|
|
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
|
-
|
|
678
|
-
|
|
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
|
-
|
|
942
|
+
USAGE:
|
|
681
943
|
|
|
682
|
-
|
|
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
|
-
|
|
685
|
-
|
|
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
|
|
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
|
-
|
|
802
|
-
execute API, and extracts it on the remote sandbox.
|
|
1149
|
+
NOTE:
|
|
803
1150
|
|
|
804
|
-
|
|
805
|
-
|
|
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
|
|
1369
|
+
Execute a test flow against the running sandbox.
|
|
1023
1370
|
|
|
1024
|
-
|
|
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
|
-
|
|
1027
|
-
1. Local plato-config.yml (development workflow)
|
|
1028
|
-
2. Flows from API based on artifact (artifact/simulator mode)
|
|
1374
|
+
REQUIRES:
|
|
1029
1375
|
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
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
|
-
#
|
|
1047
|
-
|
|
1048
|
-
if not
|
|
1049
|
-
console.print("[red]❌ No
|
|
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
|
-
#
|
|
1053
|
-
url = format_public_url_with_router_target(
|
|
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
|
|
1067
|
-
|
|
1068
|
-
|
|
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(
|
|
1164
|
-
executor = FlowExecutor(page,
|
|
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
|
-
|
|
1180
|
-
|
|
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 =
|
|
1185
|
-
job_id =
|
|
1186
|
-
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
|
-
|
|
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
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
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
|
-
|
|
1208
|
-
|
|
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
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
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
|
-
|
|
1223
|
-
|
|
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
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
1357
|
-
|
|
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
|
-
|
|
1361
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1409
|
-
|
|
1410
|
-
|
|
1411
|
-
|
|
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
|
-
|
|
1414
|
-
|
|
1415
|
-
plato sandbox start-
|
|
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:
|