plato-sdk-v2 2.7.6__py3-none-any.whl → 2.7.7__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- plato/cli/__init__.py +5 -0
- plato/cli/agent.py +1209 -0
- plato/cli/audit_ui.py +316 -0
- plato/cli/chronos.py +817 -0
- plato/cli/main.py +193 -0
- plato/cli/pm.py +1206 -0
- plato/cli/proxy.py +222 -0
- plato/cli/sandbox.py +808 -0
- plato/cli/utils.py +200 -0
- plato/cli/verify.py +690 -0
- plato/cli/world.py +250 -0
- plato/v1/cli/pm.py +4 -1
- plato/v2/__init__.py +2 -0
- plato/v2/models.py +42 -0
- plato/v2/sync/__init__.py +6 -0
- plato/v2/sync/client.py +6 -3
- plato/v2/sync/sandbox.py +1461 -0
- {plato_sdk_v2-2.7.6.dist-info → plato_sdk_v2-2.7.7.dist-info}/METADATA +1 -1
- {plato_sdk_v2-2.7.6.dist-info → plato_sdk_v2-2.7.7.dist-info}/RECORD +21 -9
- {plato_sdk_v2-2.7.6.dist-info → plato_sdk_v2-2.7.7.dist-info}/entry_points.txt +1 -1
- {plato_sdk_v2-2.7.6.dist-info → plato_sdk_v2-2.7.7.dist-info}/WHEEL +0 -0
plato/cli/pm.py
ADDED
|
@@ -0,0 +1,1206 @@
|
|
|
1
|
+
"""Project Management CLI commands for Plato simulator workflow."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
import re
|
|
7
|
+
import shutil
|
|
8
|
+
import tempfile
|
|
9
|
+
from datetime import datetime, timedelta
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
import httpx
|
|
13
|
+
import typer
|
|
14
|
+
import yaml
|
|
15
|
+
from rich.table import Table
|
|
16
|
+
|
|
17
|
+
from plato._generated.api.v1.env import get_simulator_by_name, get_simulators
|
|
18
|
+
from plato._generated.api.v1.organization import get_organization_members
|
|
19
|
+
from plato._generated.api.v1.simulator import (
|
|
20
|
+
add_simulator_review,
|
|
21
|
+
update_simulator,
|
|
22
|
+
update_simulator_status,
|
|
23
|
+
update_tag,
|
|
24
|
+
)
|
|
25
|
+
from plato._generated.api.v2.sessions import state as sessions_state
|
|
26
|
+
from plato._generated.models import (
|
|
27
|
+
AddReviewRequest,
|
|
28
|
+
AppApiV1SimulatorRoutesUpdateSimulatorRequest,
|
|
29
|
+
Authentication,
|
|
30
|
+
Flow,
|
|
31
|
+
Outcome,
|
|
32
|
+
ReviewType,
|
|
33
|
+
UpdateStatusRequest,
|
|
34
|
+
UpdateTagRequest,
|
|
35
|
+
)
|
|
36
|
+
from plato.cli.utils import (
|
|
37
|
+
console,
|
|
38
|
+
handle_async,
|
|
39
|
+
read_plato_config,
|
|
40
|
+
require_api_key,
|
|
41
|
+
require_plato_config_field,
|
|
42
|
+
require_sandbox_field,
|
|
43
|
+
require_sandbox_state,
|
|
44
|
+
)
|
|
45
|
+
from plato.cli.verify import pm_verify_app
|
|
46
|
+
from plato.v2.async_.client import AsyncPlato
|
|
47
|
+
from plato.v2.async_.flow_executor import FlowExecutor
|
|
48
|
+
from plato.v2.types import Env
|
|
49
|
+
|
|
50
|
+
# =============================================================================
|
|
51
|
+
# CONSTANTS
|
|
52
|
+
# =============================================================================
|
|
53
|
+
|
|
54
|
+
# UUID pattern for detecting artifact IDs in sim:artifact notation
|
|
55
|
+
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)
|
|
56
|
+
|
|
57
|
+
# =============================================================================
|
|
58
|
+
# APP STRUCTURE
|
|
59
|
+
# =============================================================================
|
|
60
|
+
|
|
61
|
+
pm_app = typer.Typer(help="Project management for simulator workflow")
|
|
62
|
+
list_app = typer.Typer(help="List simulators pending review")
|
|
63
|
+
review_app = typer.Typer(help="Review simulator artifacts")
|
|
64
|
+
submit_app = typer.Typer(help="Submit simulator artifacts for review")
|
|
65
|
+
|
|
66
|
+
pm_app.add_typer(list_app, name="list")
|
|
67
|
+
pm_app.add_typer(review_app, name="review")
|
|
68
|
+
pm_app.add_typer(submit_app, name="submit")
|
|
69
|
+
pm_app.add_typer(pm_verify_app, name="verify")
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
# =============================================================================
|
|
73
|
+
# SHARED HELPERS
|
|
74
|
+
# =============================================================================
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def parse_simulator_artifact(
|
|
78
|
+
simulator: str | None,
|
|
79
|
+
artifact: str | None,
|
|
80
|
+
require_artifact: bool = False,
|
|
81
|
+
command_name: str = "command",
|
|
82
|
+
) -> tuple[str | None, str | None]:
|
|
83
|
+
"""
|
|
84
|
+
Parse simulator and artifact from CLI args, supporting colon notation.
|
|
85
|
+
|
|
86
|
+
Supports:
|
|
87
|
+
-s simulator # Simulator only
|
|
88
|
+
-s simulator -a <artifact-uuid> # Explicit artifact
|
|
89
|
+
-s simulator:<artifact-uuid> # Colon notation
|
|
90
|
+
|
|
91
|
+
Args:
|
|
92
|
+
simulator: The -s/--simulator arg value
|
|
93
|
+
artifact: The -a/--artifact arg value
|
|
94
|
+
require_artifact: If True, artifact is required
|
|
95
|
+
command_name: Name of command for error messages
|
|
96
|
+
|
|
97
|
+
Returns:
|
|
98
|
+
(simulator_name, artifact_id) tuple
|
|
99
|
+
"""
|
|
100
|
+
simulator_name = None
|
|
101
|
+
artifact_id = artifact or ""
|
|
102
|
+
|
|
103
|
+
if simulator:
|
|
104
|
+
# Check for colon notation: sim:artifact
|
|
105
|
+
if ":" in simulator:
|
|
106
|
+
sim_part, colon_part = simulator.split(":", 1)
|
|
107
|
+
simulator_name = sim_part
|
|
108
|
+
if UUID_PATTERN.match(colon_part):
|
|
109
|
+
artifact_id = colon_part
|
|
110
|
+
else:
|
|
111
|
+
console.print(f"[red]❌ Invalid artifact UUID after colon: '{colon_part}'[/red]")
|
|
112
|
+
console.print()
|
|
113
|
+
console.print("[yellow]Usage:[/yellow]")
|
|
114
|
+
console.print(f" plato pm {command_name} -s <simulator> # Simulator only")
|
|
115
|
+
console.print(f" plato pm {command_name} -s <simulator> -a <artifact-uuid> # With artifact")
|
|
116
|
+
console.print(f" plato pm {command_name} -s <simulator>:<artifact-uuid> # Colon notation")
|
|
117
|
+
raise typer.Exit(1)
|
|
118
|
+
else:
|
|
119
|
+
simulator_name = simulator
|
|
120
|
+
|
|
121
|
+
if not simulator_name:
|
|
122
|
+
console.print("[red]❌ Simulator name is required[/red]")
|
|
123
|
+
console.print()
|
|
124
|
+
console.print("[yellow]Usage:[/yellow]")
|
|
125
|
+
console.print(f" plato pm {command_name} -s <simulator> # Simulator only")
|
|
126
|
+
console.print(f" plato pm {command_name} -s <simulator> -a <artifact-uuid> # With artifact")
|
|
127
|
+
console.print(f" plato pm {command_name} -s <simulator>:<artifact-uuid> # Colon notation")
|
|
128
|
+
raise typer.Exit(1)
|
|
129
|
+
|
|
130
|
+
if require_artifact and not artifact_id:
|
|
131
|
+
console.print("[red]❌ Artifact ID is required[/red]")
|
|
132
|
+
console.print()
|
|
133
|
+
console.print("[yellow]Usage:[/yellow]")
|
|
134
|
+
console.print(f" plato pm {command_name} -s <simulator> -a <artifact-uuid> # With artifact flag")
|
|
135
|
+
console.print(f" plato pm {command_name} -s <simulator>:<artifact-uuid> # Colon notation")
|
|
136
|
+
raise typer.Exit(1)
|
|
137
|
+
|
|
138
|
+
return simulator_name, artifact_id or None
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _get_base_url() -> str:
|
|
142
|
+
"""Get base URL with /api suffix stripped."""
|
|
143
|
+
base_url = os.getenv("PLATO_BASE_URL", "https://plato.so")
|
|
144
|
+
if base_url.endswith("/api"):
|
|
145
|
+
base_url = base_url[:-4]
|
|
146
|
+
return base_url.rstrip("/")
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def validate_status_transition(current_status: str, expected_status: str, command_name: str):
|
|
150
|
+
"""Validate that current status matches expected status for the command."""
|
|
151
|
+
if current_status != expected_status:
|
|
152
|
+
console.print(f"[red]❌ Invalid status for {command_name}[/red]")
|
|
153
|
+
console.print(f"\n[yellow]Current status:[/yellow] {current_status}")
|
|
154
|
+
console.print(f"[yellow]Expected status:[/yellow] {expected_status}")
|
|
155
|
+
console.print(f"\n[yellow]Cannot run {command_name} from status '{current_status}'[/yellow]")
|
|
156
|
+
raise typer.Exit(1)
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
# =============================================================================
|
|
160
|
+
# LIST COMMANDS
|
|
161
|
+
# =============================================================================
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def _list_pending_reviews(review_type: str):
|
|
165
|
+
"""Shared logic for listing pending reviews."""
|
|
166
|
+
api_key = require_api_key()
|
|
167
|
+
|
|
168
|
+
target_status = "env_review_requested" if review_type == "base" else "data_review_requested"
|
|
169
|
+
|
|
170
|
+
async def _list_reviews():
|
|
171
|
+
base_url = _get_base_url()
|
|
172
|
+
|
|
173
|
+
async with httpx.AsyncClient(base_url=base_url, timeout=60.0) as client:
|
|
174
|
+
simulators = await get_simulators.asyncio(
|
|
175
|
+
client=client,
|
|
176
|
+
x_api_key=api_key,
|
|
177
|
+
)
|
|
178
|
+
|
|
179
|
+
# Fetch organization members to map user IDs to usernames
|
|
180
|
+
user_id_to_name: dict[int, str] = {}
|
|
181
|
+
try:
|
|
182
|
+
members = await get_organization_members.asyncio(
|
|
183
|
+
client=client,
|
|
184
|
+
x_api_key=api_key,
|
|
185
|
+
)
|
|
186
|
+
for member in members:
|
|
187
|
+
user_id = member.get("id")
|
|
188
|
+
username = member.get("username") or member.get("email", "")
|
|
189
|
+
if user_id is not None:
|
|
190
|
+
user_id_to_name[user_id] = username
|
|
191
|
+
except Exception:
|
|
192
|
+
pass # Continue without usernames if fetch fails
|
|
193
|
+
|
|
194
|
+
# Filter by target status
|
|
195
|
+
pending_review = []
|
|
196
|
+
for sim in simulators:
|
|
197
|
+
config = sim.get("config", {}) if isinstance(sim, dict) else getattr(sim, "config", {})
|
|
198
|
+
status = config.get("status", "not_started") if isinstance(config, dict) else "not_started"
|
|
199
|
+
if status == target_status:
|
|
200
|
+
pending_review.append(sim)
|
|
201
|
+
|
|
202
|
+
if not pending_review:
|
|
203
|
+
console.print(f"[yellow]No simulators pending {review_type} review (status: {target_status})[/yellow]")
|
|
204
|
+
return
|
|
205
|
+
|
|
206
|
+
# Build table
|
|
207
|
+
table = Table(title=f"Simulators Pending {review_type.title()} Review")
|
|
208
|
+
table.add_column("Name", style="cyan", no_wrap=True)
|
|
209
|
+
table.add_column("Assignees", style="magenta", no_wrap=True)
|
|
210
|
+
table.add_column("Notes", style="white", max_width=40)
|
|
211
|
+
artifact_col_name = "base_artifact_id" if review_type == "base" else "data_artifact_id"
|
|
212
|
+
table.add_column(artifact_col_name, style="green", no_wrap=True)
|
|
213
|
+
|
|
214
|
+
for sim in pending_review:
|
|
215
|
+
if isinstance(sim, dict):
|
|
216
|
+
name = sim.get("name", "N/A")
|
|
217
|
+
config = sim.get("config", {})
|
|
218
|
+
else:
|
|
219
|
+
name = getattr(sim, "name", "N/A")
|
|
220
|
+
config = getattr(sim, "config", {})
|
|
221
|
+
|
|
222
|
+
notes = config.get("notes", "") if isinstance(config, dict) else ""
|
|
223
|
+
notes = notes or "-"
|
|
224
|
+
artifact_key = "base_artifact_id" if review_type == "base" else "data_artifact_id"
|
|
225
|
+
artifact_id = config.get(artifact_key, "") if isinstance(config, dict) else ""
|
|
226
|
+
artifact_id = artifact_id or "-"
|
|
227
|
+
|
|
228
|
+
# Get assignees based on review type (env_assignees for base, data_assignees for data)
|
|
229
|
+
assignee_key = "env_assignees" if review_type == "base" else "data_assignees"
|
|
230
|
+
assignee_ids = config.get(assignee_key, []) if isinstance(config, dict) else []
|
|
231
|
+
assignee_names = []
|
|
232
|
+
if assignee_ids:
|
|
233
|
+
for uid in assignee_ids:
|
|
234
|
+
assignee_names.append(user_id_to_name.get(uid, str(uid)))
|
|
235
|
+
assignees_str = ", ".join(assignee_names) if assignee_names else "-"
|
|
236
|
+
|
|
237
|
+
table.add_row(name, assignees_str, notes, artifact_id)
|
|
238
|
+
|
|
239
|
+
console.print(table)
|
|
240
|
+
console.print(f"\n[cyan]Total: {len(pending_review)} simulator(s) pending {review_type} review[/cyan]")
|
|
241
|
+
|
|
242
|
+
handle_async(_list_reviews())
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
@list_app.command(name="base")
|
|
246
|
+
def list_base():
|
|
247
|
+
"""List simulators pending base/environment review.
|
|
248
|
+
|
|
249
|
+
Shows simulators with status 'env_review_requested' in a table format.
|
|
250
|
+
Displays name, assignees, notes, and base_artifact_id for each simulator.
|
|
251
|
+
"""
|
|
252
|
+
_list_pending_reviews("base")
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
@list_app.command(name="data")
|
|
256
|
+
def list_data():
|
|
257
|
+
"""List simulators pending data review.
|
|
258
|
+
|
|
259
|
+
Shows simulators with status 'data_review_requested' in a table format.
|
|
260
|
+
Displays name, assignees, notes, and data_artifact_id for each simulator.
|
|
261
|
+
"""
|
|
262
|
+
_list_pending_reviews("data")
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
# =============================================================================
|
|
266
|
+
# REVIEW COMMANDS
|
|
267
|
+
# =============================================================================
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
@review_app.command(name="base")
|
|
271
|
+
def review_base(
|
|
272
|
+
simulator: str = typer.Option(
|
|
273
|
+
None,
|
|
274
|
+
"--simulator",
|
|
275
|
+
"-s",
|
|
276
|
+
help="Simulator name. Supports colon notation: -s sim:<artifact-uuid>",
|
|
277
|
+
),
|
|
278
|
+
artifact: str = typer.Option(
|
|
279
|
+
None,
|
|
280
|
+
"--artifact",
|
|
281
|
+
"-a",
|
|
282
|
+
help="Artifact UUID to review. If not provided, uses server's base_artifact_id.",
|
|
283
|
+
),
|
|
284
|
+
skip_review: bool = typer.Option(
|
|
285
|
+
False,
|
|
286
|
+
"--skip-review",
|
|
287
|
+
help="Run login flow and check state, but skip interactive review. For automated verification.",
|
|
288
|
+
),
|
|
289
|
+
local: str = typer.Option(
|
|
290
|
+
None,
|
|
291
|
+
"--local",
|
|
292
|
+
"-l",
|
|
293
|
+
help="Path to a local flow YAML file to run instead of the default login flow.",
|
|
294
|
+
),
|
|
295
|
+
clock: str = typer.Option(
|
|
296
|
+
None,
|
|
297
|
+
"--clock",
|
|
298
|
+
help="Set fake browser time (ISO format or offset like '-30d' for 30 days ago).",
|
|
299
|
+
),
|
|
300
|
+
):
|
|
301
|
+
"""Review base/environment artifact for a simulator.
|
|
302
|
+
|
|
303
|
+
Creates an environment from the artifact, launches a browser for testing,
|
|
304
|
+
runs the login flow, and checks for database mutations. After testing,
|
|
305
|
+
choose pass (→ env_approved) or reject (→ env_in_progress).
|
|
306
|
+
|
|
307
|
+
Requires simulator status: env_review_requested
|
|
308
|
+
|
|
309
|
+
Options:
|
|
310
|
+
-s, --simulator: Simulator name. Supports colon notation for artifact:
|
|
311
|
+
'-s sim' (uses server's base_artifact_id) or '-s sim:<uuid>'
|
|
312
|
+
-a, --artifact: Explicit artifact UUID to review. Overrides server's value.
|
|
313
|
+
--skip-review: Run automated checks without interactive review session.
|
|
314
|
+
"""
|
|
315
|
+
api_key = require_api_key()
|
|
316
|
+
|
|
317
|
+
# Parse simulator and artifact from args (artifact not required - falls back to server config)
|
|
318
|
+
simulator_name, artifact_id_input = parse_simulator_artifact(
|
|
319
|
+
simulator, artifact, require_artifact=False, command_name="review base"
|
|
320
|
+
)
|
|
321
|
+
|
|
322
|
+
async def _review_base():
|
|
323
|
+
base_url = _get_base_url()
|
|
324
|
+
plato = AsyncPlato(api_key=api_key, base_url=base_url)
|
|
325
|
+
session = None
|
|
326
|
+
playwright = None
|
|
327
|
+
browser = None
|
|
328
|
+
login_result = None
|
|
329
|
+
|
|
330
|
+
try:
|
|
331
|
+
http_client = plato._http
|
|
332
|
+
|
|
333
|
+
# simulator_name is guaranteed set by parse_simulator_artifact (or we exit)
|
|
334
|
+
assert simulator_name is not None, "simulator_name must be set"
|
|
335
|
+
|
|
336
|
+
# Get simulator by name
|
|
337
|
+
sim = await get_simulator_by_name.asyncio(
|
|
338
|
+
client=http_client,
|
|
339
|
+
name=simulator_name,
|
|
340
|
+
x_api_key=api_key,
|
|
341
|
+
)
|
|
342
|
+
simulator_id = sim.id
|
|
343
|
+
current_config = sim.config or {}
|
|
344
|
+
current_status = current_config.get("status", "not_started")
|
|
345
|
+
|
|
346
|
+
console.print(f"[cyan]Current status:[/cyan] {current_status}")
|
|
347
|
+
|
|
348
|
+
# Use provided artifact ID or fall back to base_artifact_id from server config
|
|
349
|
+
artifact_id: str | None = artifact_id_input if artifact_id_input else current_config.get("base_artifact_id")
|
|
350
|
+
if not artifact_id:
|
|
351
|
+
console.print("[red]❌ No artifact ID provided.[/red]")
|
|
352
|
+
console.print(
|
|
353
|
+
"[yellow]This simulator hasn't been submitted yet, so there's no base artifact on record.[/yellow]"
|
|
354
|
+
)
|
|
355
|
+
console.print(
|
|
356
|
+
"[yellow]Specify the artifact ID from your snapshot using: plato pm review base --artifact <artifact_id>[/yellow]"
|
|
357
|
+
)
|
|
358
|
+
raise typer.Exit(1)
|
|
359
|
+
|
|
360
|
+
console.print(f"[cyan]Using artifact:[/cyan] {artifact_id}")
|
|
361
|
+
|
|
362
|
+
# Try to create session with environment from artifact
|
|
363
|
+
try:
|
|
364
|
+
console.print(f"[cyan]Creating {simulator_name} environment with artifact {artifact_id}...[/cyan]")
|
|
365
|
+
session = await plato.sessions.create(
|
|
366
|
+
envs=[Env.artifact(artifact_id)],
|
|
367
|
+
timeout=300,
|
|
368
|
+
)
|
|
369
|
+
console.print(f"[green]✅ Session created: {session.session_id}[/green]")
|
|
370
|
+
|
|
371
|
+
# Reset
|
|
372
|
+
console.print("[cyan]Resetting environment...[/cyan]")
|
|
373
|
+
await session.reset()
|
|
374
|
+
console.print("[green]✅ Environment reset complete![/green]")
|
|
375
|
+
|
|
376
|
+
# Get public URL
|
|
377
|
+
public_urls = await session.get_public_url()
|
|
378
|
+
first_alias = session.envs[0].alias if session.envs else None
|
|
379
|
+
public_url = public_urls.get(first_alias) if first_alias else None
|
|
380
|
+
if not public_url and public_urls:
|
|
381
|
+
public_url = list(public_urls.values())[0]
|
|
382
|
+
console.print(f"[cyan]Public URL:[/cyan] {public_url}")
|
|
383
|
+
|
|
384
|
+
# Launch Playwright browser and login
|
|
385
|
+
console.print("[cyan]Launching browser and logging in...[/cyan]")
|
|
386
|
+
from playwright.async_api import async_playwright
|
|
387
|
+
|
|
388
|
+
playwright = await async_playwright().start()
|
|
389
|
+
browser = await playwright.chromium.launch(headless=False)
|
|
390
|
+
|
|
391
|
+
# Install fake clock if requested
|
|
392
|
+
fake_time: datetime | None = None
|
|
393
|
+
if clock:
|
|
394
|
+
# Parse clock option: ISO format or offset like '-30d'
|
|
395
|
+
if clock.startswith("-") and clock[-1] in "dhms":
|
|
396
|
+
# Offset format: -30d, -1h, -30m, -60s
|
|
397
|
+
unit = clock[-1]
|
|
398
|
+
amount = int(clock[1:-1])
|
|
399
|
+
if unit == "d":
|
|
400
|
+
fake_time = datetime.now() - timedelta(days=amount)
|
|
401
|
+
elif unit == "h":
|
|
402
|
+
fake_time = datetime.now() - timedelta(hours=amount)
|
|
403
|
+
elif unit == "m":
|
|
404
|
+
fake_time = datetime.now() - timedelta(minutes=amount)
|
|
405
|
+
elif unit == "s":
|
|
406
|
+
fake_time = datetime.now() - timedelta(seconds=amount)
|
|
407
|
+
else:
|
|
408
|
+
raise ValueError(f"Invalid clock offset unit: {unit}")
|
|
409
|
+
else:
|
|
410
|
+
# ISO format
|
|
411
|
+
fake_time = datetime.fromisoformat(clock)
|
|
412
|
+
|
|
413
|
+
assert fake_time is not None, f"Failed to parse clock value: {clock}"
|
|
414
|
+
console.print(f"[cyan]Setting fake browser time to:[/cyan] {fake_time.isoformat()}")
|
|
415
|
+
|
|
416
|
+
if local:
|
|
417
|
+
# Use local flow file instead of default login
|
|
418
|
+
local_path = Path(local)
|
|
419
|
+
if not local_path.exists():
|
|
420
|
+
console.print(f"[red]❌ Local flow file not found: {local}[/red]")
|
|
421
|
+
raise typer.Exit(1)
|
|
422
|
+
|
|
423
|
+
console.print(f"[cyan]Loading local flow from: {local}[/cyan]")
|
|
424
|
+
with open(local_path) as f:
|
|
425
|
+
flow_dict = yaml.safe_load(f)
|
|
426
|
+
|
|
427
|
+
# Find login flow (or first flow if only one)
|
|
428
|
+
flows = flow_dict.get("flows", [])
|
|
429
|
+
if not flows:
|
|
430
|
+
console.print("[red]❌ No flows found in flow file[/red]")
|
|
431
|
+
raise typer.Exit(1)
|
|
432
|
+
|
|
433
|
+
# Try to find 'login' flow, otherwise use first flow
|
|
434
|
+
flow_data = next((f for f in flows if f.get("name") == "login"), flows[0])
|
|
435
|
+
flow = Flow.model_validate(flow_data)
|
|
436
|
+
console.print(f"[cyan]Running flow: {flow.name}[/cyan]")
|
|
437
|
+
|
|
438
|
+
# Create page and navigate to public URL
|
|
439
|
+
page = await browser.new_page()
|
|
440
|
+
|
|
441
|
+
# Install fake clock if requested
|
|
442
|
+
if fake_time:
|
|
443
|
+
await page.clock.install(time=fake_time)
|
|
444
|
+
console.print(f"[green]✅ Fake clock installed: {fake_time.isoformat()}[/green]")
|
|
445
|
+
|
|
446
|
+
if public_url:
|
|
447
|
+
await page.goto(public_url)
|
|
448
|
+
|
|
449
|
+
# Execute the flow
|
|
450
|
+
try:
|
|
451
|
+
executor = FlowExecutor(page, flow)
|
|
452
|
+
await executor.execute()
|
|
453
|
+
console.print("[green]✅ Local flow executed successfully[/green]")
|
|
454
|
+
except Exception as e:
|
|
455
|
+
console.print(f"[yellow]⚠️ Flow execution error: {e}[/yellow]")
|
|
456
|
+
else:
|
|
457
|
+
# Use default login via session.login()
|
|
458
|
+
if fake_time:
|
|
459
|
+
console.print("[yellow]⚠️ --clock with default login may not work correctly.[/yellow]")
|
|
460
|
+
console.print("[yellow] Use --local with a flow file for reliable clock testing.[/yellow]")
|
|
461
|
+
try:
|
|
462
|
+
login_result = await session.login(browser, dataset="base")
|
|
463
|
+
page = list(login_result.pages.values())[0] if login_result.pages else None
|
|
464
|
+
console.print("[green]✅ Logged into environment[/green]")
|
|
465
|
+
except Exception as e:
|
|
466
|
+
console.print(f"[yellow]⚠️ Login error: {e}[/yellow]")
|
|
467
|
+
page = await browser.new_page()
|
|
468
|
+
# Install fake clock on fallback page
|
|
469
|
+
if fake_time:
|
|
470
|
+
await page.clock.install(time=fake_time)
|
|
471
|
+
console.print(f"[green]✅ Fake clock installed: {fake_time.isoformat()}[/green]")
|
|
472
|
+
if public_url:
|
|
473
|
+
await page.goto(public_url)
|
|
474
|
+
|
|
475
|
+
# ALWAYS check state after login to verify no mutations
|
|
476
|
+
console.print("\n[cyan]Checking environment state after login...[/cyan]")
|
|
477
|
+
has_mutations = False
|
|
478
|
+
has_errors = False
|
|
479
|
+
try:
|
|
480
|
+
state_response = await sessions_state.asyncio(
|
|
481
|
+
client=http_client,
|
|
482
|
+
session_id=session.session_id,
|
|
483
|
+
merge_mutations=True,
|
|
484
|
+
x_api_key=api_key,
|
|
485
|
+
)
|
|
486
|
+
if state_response and state_response.results:
|
|
487
|
+
for jid, result in state_response.results.items():
|
|
488
|
+
state_data = result.state if hasattr(result, "state") else result
|
|
489
|
+
console.print(f"\n[bold cyan]Job {jid}:[/bold cyan]")
|
|
490
|
+
|
|
491
|
+
if isinstance(state_data, dict):
|
|
492
|
+
# Check for error in state response
|
|
493
|
+
if "error" in state_data:
|
|
494
|
+
has_errors = True
|
|
495
|
+
console.print("\n[bold red]❌ State API Error:[/bold red]")
|
|
496
|
+
console.print(f"[red]{state_data['error']}[/red]")
|
|
497
|
+
continue
|
|
498
|
+
|
|
499
|
+
mutations = state_data.pop("mutations", [])
|
|
500
|
+
console.print("\n[bold]State:[/bold]")
|
|
501
|
+
console.print(json.dumps(state_data, indent=2, default=str))
|
|
502
|
+
if mutations:
|
|
503
|
+
has_mutations = True
|
|
504
|
+
console.print(f"\n[bold red]Mutations ({len(mutations)}):[/bold red]")
|
|
505
|
+
console.print(json.dumps(mutations, indent=2, default=str))
|
|
506
|
+
else:
|
|
507
|
+
console.print("\n[green]No mutations recorded[/green]")
|
|
508
|
+
else:
|
|
509
|
+
console.print(f"[yellow]Unexpected state format: {type(state_data)}[/yellow]")
|
|
510
|
+
|
|
511
|
+
if has_errors:
|
|
512
|
+
console.print("\n[bold red]❌ State check failed due to errors![/bold red]")
|
|
513
|
+
console.print("[yellow]The worker may not be properly connected.[/yellow]")
|
|
514
|
+
elif has_mutations:
|
|
515
|
+
console.print("\n[bold red]⚠️ WARNING: Login flow created mutations![/bold red]")
|
|
516
|
+
console.print("[yellow]The login flow should NOT modify database state.[/yellow]")
|
|
517
|
+
else:
|
|
518
|
+
console.print("\n[bold green]✅ Login flow verified - no mutations created[/bold green]")
|
|
519
|
+
else:
|
|
520
|
+
console.print("[yellow]No state data available[/yellow]")
|
|
521
|
+
except Exception as e:
|
|
522
|
+
console.print(f"[red]❌ Error getting state: {e}[/red]")
|
|
523
|
+
|
|
524
|
+
# If skip_review, exit without interactive loop
|
|
525
|
+
if skip_review:
|
|
526
|
+
console.print("\n[cyan]Skipping interactive review (--skip-review)[/cyan]")
|
|
527
|
+
return
|
|
528
|
+
|
|
529
|
+
console.print("\n" + "=" * 60)
|
|
530
|
+
console.print("[bold green]Environment Review Session Active[/bold green]")
|
|
531
|
+
console.print("=" * 60)
|
|
532
|
+
console.print("[bold]Commands:[/bold]")
|
|
533
|
+
console.print(" - 'state' or 's': Show environment state and mutations")
|
|
534
|
+
console.print(" - 'finish' or 'f': Exit loop and submit review outcome")
|
|
535
|
+
console.print("=" * 60)
|
|
536
|
+
|
|
537
|
+
# Show recent env review if available
|
|
538
|
+
reviews = current_config.get("reviews") or []
|
|
539
|
+
env_reviews = [r for r in reviews if r.get("review_type") == "env"]
|
|
540
|
+
if env_reviews:
|
|
541
|
+
env_reviews.sort(key=lambda r: r.get("timestamp_iso", ""), reverse=True)
|
|
542
|
+
recent_review = env_reviews[0]
|
|
543
|
+
outcome = recent_review.get("outcome", "unknown")
|
|
544
|
+
timestamp = recent_review.get("timestamp_iso", "")[:10]
|
|
545
|
+
console.print()
|
|
546
|
+
if outcome == "reject":
|
|
547
|
+
console.print(f"[bold red]📋 Most Recent Base Review: REJECTED[/bold red] ({timestamp})")
|
|
548
|
+
else:
|
|
549
|
+
console.print(f"[bold green]📋 Most Recent Base Review: PASSED[/bold green] ({timestamp})")
|
|
550
|
+
comments = recent_review.get("comments")
|
|
551
|
+
if comments:
|
|
552
|
+
console.print(f"[yellow]Reviewer Comments:[/yellow] {comments}")
|
|
553
|
+
|
|
554
|
+
console.print()
|
|
555
|
+
|
|
556
|
+
# Interactive loop
|
|
557
|
+
while True:
|
|
558
|
+
try:
|
|
559
|
+
command = input("Enter command: ").strip().lower()
|
|
560
|
+
|
|
561
|
+
if command in ["finish", "f"]:
|
|
562
|
+
console.print("\n[yellow]Finishing review...[/yellow]")
|
|
563
|
+
break
|
|
564
|
+
elif command in ["state", "s"]:
|
|
565
|
+
console.print("\n[cyan]Getting environment state with mutations...[/cyan]")
|
|
566
|
+
try:
|
|
567
|
+
# Call API directly with merge_mutations=True to include mutations
|
|
568
|
+
state_response = await sessions_state.asyncio(
|
|
569
|
+
client=http_client,
|
|
570
|
+
session_id=session.session_id,
|
|
571
|
+
merge_mutations=True,
|
|
572
|
+
x_api_key=api_key,
|
|
573
|
+
)
|
|
574
|
+
if state_response and state_response.results:
|
|
575
|
+
for jid, result in state_response.results.items():
|
|
576
|
+
state_data = result.state if hasattr(result, "state") else result
|
|
577
|
+
console.print(f"\n[bold cyan]Job {jid}:[/bold cyan]")
|
|
578
|
+
|
|
579
|
+
if isinstance(state_data, dict):
|
|
580
|
+
# Check for error in state response
|
|
581
|
+
if "error" in state_data:
|
|
582
|
+
console.print("\n[bold red]❌ State API Error:[/bold red]")
|
|
583
|
+
console.print(f"[red]{state_data['error']}[/red]")
|
|
584
|
+
continue
|
|
585
|
+
|
|
586
|
+
mutations = state_data.pop("mutations", [])
|
|
587
|
+
console.print("\n[bold]State:[/bold]")
|
|
588
|
+
console.print(json.dumps(state_data, indent=2, default=str))
|
|
589
|
+
if mutations:
|
|
590
|
+
console.print(f"\n[bold]Mutations ({len(mutations)}):[/bold]")
|
|
591
|
+
console.print(json.dumps(mutations, indent=2, default=str))
|
|
592
|
+
else:
|
|
593
|
+
console.print("\n[yellow]No mutations recorded[/yellow]")
|
|
594
|
+
else:
|
|
595
|
+
console.print(json.dumps(state_data, indent=2, default=str))
|
|
596
|
+
else:
|
|
597
|
+
console.print("[yellow]No state data available[/yellow]")
|
|
598
|
+
console.print()
|
|
599
|
+
except Exception as e:
|
|
600
|
+
console.print(f"[red]❌ Error getting state: {e}[/red]")
|
|
601
|
+
else:
|
|
602
|
+
console.print("[yellow]Unknown command. Use 'state' or 'finish'[/yellow]")
|
|
603
|
+
|
|
604
|
+
except KeyboardInterrupt:
|
|
605
|
+
console.print("\n[yellow]Interrupted! Finishing review...[/yellow]")
|
|
606
|
+
break
|
|
607
|
+
|
|
608
|
+
except Exception as env_error:
|
|
609
|
+
console.print(f"[yellow]⚠️ Environment creation failed: {env_error}[/yellow]")
|
|
610
|
+
console.print("[yellow]You can still submit a review without testing the environment.[/yellow]")
|
|
611
|
+
|
|
612
|
+
# Prompt for outcome
|
|
613
|
+
console.print("\n[bold]Choose outcome:[/bold]")
|
|
614
|
+
console.print(" 1. pass")
|
|
615
|
+
console.print(" 2. reject")
|
|
616
|
+
console.print(" 3. skip (no status update)")
|
|
617
|
+
outcome_choice = typer.prompt("Choice [1/2/3]").strip()
|
|
618
|
+
|
|
619
|
+
if outcome_choice == "1":
|
|
620
|
+
outcome = "pass"
|
|
621
|
+
elif outcome_choice == "2":
|
|
622
|
+
outcome = "reject"
|
|
623
|
+
elif outcome_choice == "3":
|
|
624
|
+
console.print("[yellow]Review session ended without status update[/yellow]")
|
|
625
|
+
return
|
|
626
|
+
else:
|
|
627
|
+
console.print("[red]❌ Invalid choice. Aborting.[/red]")
|
|
628
|
+
raise typer.Exit(1)
|
|
629
|
+
|
|
630
|
+
# Validate status BEFORE submitting outcome
|
|
631
|
+
if outcome == "pass":
|
|
632
|
+
validate_status_transition(current_status, "env_review_requested", "review base pass")
|
|
633
|
+
new_status = "env_approved"
|
|
634
|
+
else:
|
|
635
|
+
validate_status_transition(current_status, "env_review_requested", "review base reject")
|
|
636
|
+
new_status = "env_in_progress"
|
|
637
|
+
|
|
638
|
+
# Update status
|
|
639
|
+
await update_simulator_status.asyncio(
|
|
640
|
+
client=http_client,
|
|
641
|
+
simulator_id=simulator_id,
|
|
642
|
+
body=UpdateStatusRequest(status=new_status),
|
|
643
|
+
x_api_key=api_key,
|
|
644
|
+
)
|
|
645
|
+
|
|
646
|
+
# Add review if rejecting
|
|
647
|
+
if outcome == "reject":
|
|
648
|
+
comments = ""
|
|
649
|
+
while not comments:
|
|
650
|
+
comments = typer.prompt("Comments (required for reject)").strip()
|
|
651
|
+
if not comments:
|
|
652
|
+
console.print("[yellow]Comments are required when rejecting. Please provide feedback.[/yellow]")
|
|
653
|
+
|
|
654
|
+
await add_simulator_review.asyncio(
|
|
655
|
+
client=http_client,
|
|
656
|
+
simulator_id=simulator_id,
|
|
657
|
+
body=AddReviewRequest(
|
|
658
|
+
review_type=ReviewType.env,
|
|
659
|
+
outcome=Outcome.reject,
|
|
660
|
+
artifact_id=artifact_id,
|
|
661
|
+
comments=comments,
|
|
662
|
+
),
|
|
663
|
+
x_api_key=api_key,
|
|
664
|
+
)
|
|
665
|
+
|
|
666
|
+
console.print(f"[green]✅ Review submitted: {outcome}[/green]")
|
|
667
|
+
console.print(f"[cyan]Status:[/cyan] {current_status} → {new_status}")
|
|
668
|
+
|
|
669
|
+
# If passed, automatically tag artifact as prod-latest
|
|
670
|
+
if outcome == "pass" and artifact_id:
|
|
671
|
+
console.print("\n[cyan]Tagging artifact as prod-latest...[/cyan]")
|
|
672
|
+
try:
|
|
673
|
+
# simulator_name and artifact_id are guaranteed to be set at this point
|
|
674
|
+
assert simulator_name is not None
|
|
675
|
+
await update_tag.asyncio(
|
|
676
|
+
client=http_client,
|
|
677
|
+
body=UpdateTagRequest(
|
|
678
|
+
simulator_name=simulator_name,
|
|
679
|
+
artifact_id=artifact_id,
|
|
680
|
+
tag_name="prod-latest",
|
|
681
|
+
dataset="base",
|
|
682
|
+
),
|
|
683
|
+
x_api_key=api_key,
|
|
684
|
+
)
|
|
685
|
+
console.print(f"[green]✅ Tagged {artifact_id[:8]}... as prod-latest[/green]")
|
|
686
|
+
except Exception as e:
|
|
687
|
+
console.print(f"[yellow]⚠️ Could not tag as prod-latest: {e}[/yellow]")
|
|
688
|
+
|
|
689
|
+
except typer.Exit:
|
|
690
|
+
raise
|
|
691
|
+
except Exception as e:
|
|
692
|
+
console.print(f"[red]❌ Error during review session: {e}[/red]")
|
|
693
|
+
raise
|
|
694
|
+
|
|
695
|
+
finally:
|
|
696
|
+
# Cleanup
|
|
697
|
+
try:
|
|
698
|
+
if login_result and login_result.context:
|
|
699
|
+
await login_result.context.close()
|
|
700
|
+
if browser:
|
|
701
|
+
await browser.close()
|
|
702
|
+
if playwright:
|
|
703
|
+
await playwright.stop()
|
|
704
|
+
except Exception as e:
|
|
705
|
+
console.print(f"[yellow]⚠️ Browser cleanup error: {e}[/yellow]")
|
|
706
|
+
|
|
707
|
+
if session:
|
|
708
|
+
try:
|
|
709
|
+
console.print("[cyan]Shutting down session...[/cyan]")
|
|
710
|
+
await session.close()
|
|
711
|
+
console.print("[green]✅ Session shut down[/green]")
|
|
712
|
+
except Exception as e:
|
|
713
|
+
console.print(f"[yellow]⚠️ Session cleanup error: {e}[/yellow]")
|
|
714
|
+
|
|
715
|
+
try:
|
|
716
|
+
await plato.close()
|
|
717
|
+
except Exception as e:
|
|
718
|
+
console.print(f"[yellow]⚠️ Client cleanup error: {e}[/yellow]")
|
|
719
|
+
|
|
720
|
+
handle_async(_review_base())
|
|
721
|
+
|
|
722
|
+
|
|
723
|
+
@review_app.command(name="data")
|
|
724
|
+
def review_data(
|
|
725
|
+
simulator: str = typer.Option(
|
|
726
|
+
None,
|
|
727
|
+
"--simulator",
|
|
728
|
+
"-s",
|
|
729
|
+
help="Simulator name. Supports colon notation: -s sim:<artifact-uuid>",
|
|
730
|
+
),
|
|
731
|
+
artifact: str = typer.Option(
|
|
732
|
+
None,
|
|
733
|
+
"--artifact",
|
|
734
|
+
"-a",
|
|
735
|
+
help="Artifact UUID to review. If not provided, uses server's data_artifact_id.",
|
|
736
|
+
),
|
|
737
|
+
):
|
|
738
|
+
"""Launch browser with EnvGen Recorder extension for data review.
|
|
739
|
+
|
|
740
|
+
Opens Chrome with the EnvGen Recorder extension installed for reviewing
|
|
741
|
+
data artifacts. The extension sidebar can be used to record and submit reviews.
|
|
742
|
+
Press Control-C to exit when done.
|
|
743
|
+
|
|
744
|
+
Requires simulator status: data_review_requested
|
|
745
|
+
|
|
746
|
+
Options:
|
|
747
|
+
-s, --simulator: Simulator name. Supports colon notation for artifact:
|
|
748
|
+
'-s sim' (uses server's data_artifact_id) or '-s sim:<uuid>'
|
|
749
|
+
-a, --artifact: Explicit artifact UUID to review. Overrides server's value.
|
|
750
|
+
"""
|
|
751
|
+
api_key = require_api_key()
|
|
752
|
+
|
|
753
|
+
# Parse simulator and artifact from args (artifact not required - falls back to server config)
|
|
754
|
+
simulator_name, artifact_id = parse_simulator_artifact(
|
|
755
|
+
simulator, artifact, require_artifact=False, command_name="review data"
|
|
756
|
+
)
|
|
757
|
+
|
|
758
|
+
# Determine target URL based on simulator
|
|
759
|
+
target_url = f"https://{simulator_name}.web.plato.so"
|
|
760
|
+
console.print(f"[cyan]Simulator:[/cyan] {simulator_name}")
|
|
761
|
+
|
|
762
|
+
# Fetch simulator config and get artifact ID if not provided
|
|
763
|
+
recent_review = None
|
|
764
|
+
|
|
765
|
+
async def _fetch_artifact_info():
|
|
766
|
+
nonlocal artifact_id
|
|
767
|
+
# simulator_name is guaranteed set by parse_simulator_artifact (or we exit)
|
|
768
|
+
assert simulator_name is not None, "simulator_name must be set"
|
|
769
|
+
|
|
770
|
+
base_url = _get_base_url()
|
|
771
|
+
async with httpx.AsyncClient(base_url=base_url, timeout=60.0) as client:
|
|
772
|
+
try:
|
|
773
|
+
sim = await get_simulator_by_name.asyncio(
|
|
774
|
+
client=client,
|
|
775
|
+
name=simulator_name,
|
|
776
|
+
x_api_key=api_key,
|
|
777
|
+
)
|
|
778
|
+
config = sim.config or {}
|
|
779
|
+
|
|
780
|
+
# If no artifact provided, try to get data_artifact_id from server
|
|
781
|
+
if not artifact_id:
|
|
782
|
+
artifact_id = config.get("data_artifact_id")
|
|
783
|
+
if artifact_id:
|
|
784
|
+
console.print(f"[cyan]Using data_artifact_id from server:[/cyan] {artifact_id}")
|
|
785
|
+
else:
|
|
786
|
+
console.print("[yellow]No artifact specified and no data_artifact_id on server[/yellow]")
|
|
787
|
+
|
|
788
|
+
# Find most recent data review
|
|
789
|
+
reviews = config.get("reviews") or []
|
|
790
|
+
data_reviews = [r for r in reviews if r.get("review_type") == "data"]
|
|
791
|
+
if data_reviews:
|
|
792
|
+
data_reviews.sort(key=lambda r: r.get("timestamp_iso", ""), reverse=True)
|
|
793
|
+
return data_reviews[0]
|
|
794
|
+
except Exception as e:
|
|
795
|
+
console.print(f"[yellow]⚠️ Could not fetch simulator info: {e}[/yellow]")
|
|
796
|
+
return None
|
|
797
|
+
|
|
798
|
+
recent_review = handle_async(_fetch_artifact_info())
|
|
799
|
+
|
|
800
|
+
if artifact_id:
|
|
801
|
+
console.print(f"[cyan]Artifact:[/cyan] {artifact_id}")
|
|
802
|
+
|
|
803
|
+
# Find Chrome extension source
|
|
804
|
+
package_dir = Path(__file__).resolve().parent.parent # v1/
|
|
805
|
+
is_installed = "site-packages" in str(package_dir)
|
|
806
|
+
|
|
807
|
+
if is_installed:
|
|
808
|
+
extension_source_path = package_dir / "extensions" / "envgen-recorder-old"
|
|
809
|
+
else:
|
|
810
|
+
repo_root = package_dir.parent.parent.parent # plato-client/
|
|
811
|
+
extension_source_path = repo_root / "extensions" / "envgen-recorder-old"
|
|
812
|
+
|
|
813
|
+
# Fallback to env var
|
|
814
|
+
if not extension_source_path.exists():
|
|
815
|
+
plato_client_dir_env = os.getenv("PLATO_CLIENT_DIR")
|
|
816
|
+
if plato_client_dir_env:
|
|
817
|
+
env_path = Path(plato_client_dir_env) / "extensions" / "envgen-recorder-old"
|
|
818
|
+
if env_path.exists():
|
|
819
|
+
extension_source_path = env_path
|
|
820
|
+
|
|
821
|
+
if not extension_source_path.exists():
|
|
822
|
+
console.print("[red]❌ EnvGen Recorder extension not found[/red]")
|
|
823
|
+
console.print(f"\n[yellow]Expected location:[/yellow] {extension_source_path}")
|
|
824
|
+
raise typer.Exit(1)
|
|
825
|
+
|
|
826
|
+
# Copy extension to temp directory
|
|
827
|
+
temp_ext_dir = Path(tempfile.mkdtemp(prefix="plato-extension-"))
|
|
828
|
+
extension_path = temp_ext_dir / "envgen-recorder"
|
|
829
|
+
|
|
830
|
+
console.print("[cyan]Copying extension to temp directory...[/cyan]")
|
|
831
|
+
shutil.copytree(extension_source_path, extension_path, dirs_exist_ok=False)
|
|
832
|
+
console.print(f"[green]✅ Extension copied to: {extension_path}[/green]")
|
|
833
|
+
|
|
834
|
+
async def _review_data():
|
|
835
|
+
playwright = None
|
|
836
|
+
browser = None
|
|
837
|
+
|
|
838
|
+
try:
|
|
839
|
+
user_data_dir = Path.home() / ".plato" / "chrome-data"
|
|
840
|
+
user_data_dir.mkdir(parents=True, exist_ok=True)
|
|
841
|
+
|
|
842
|
+
console.print("[cyan]Launching Chrome with EnvGen Recorder extension...[/cyan]")
|
|
843
|
+
|
|
844
|
+
from playwright.async_api import async_playwright
|
|
845
|
+
|
|
846
|
+
playwright = await async_playwright().start()
|
|
847
|
+
|
|
848
|
+
browser = await playwright.chromium.launch_persistent_context(
|
|
849
|
+
str(user_data_dir),
|
|
850
|
+
headless=False,
|
|
851
|
+
args=[
|
|
852
|
+
f"--disable-extensions-except={extension_path}",
|
|
853
|
+
f"--load-extension={extension_path}",
|
|
854
|
+
],
|
|
855
|
+
)
|
|
856
|
+
|
|
857
|
+
# Wait for extension to load
|
|
858
|
+
await asyncio.sleep(2)
|
|
859
|
+
|
|
860
|
+
# Find extension ID via CDP
|
|
861
|
+
extension_id = None
|
|
862
|
+
temp_page = await browser.new_page()
|
|
863
|
+
try:
|
|
864
|
+
cdp = await temp_page.context.new_cdp_session(temp_page)
|
|
865
|
+
targets_result = await cdp.send("Target.getTargets")
|
|
866
|
+
for target_info in targets_result.get("targetInfos", []):
|
|
867
|
+
ext_url = target_info.get("url", "")
|
|
868
|
+
if "chrome-extension://" in ext_url:
|
|
869
|
+
parts = ext_url.replace("chrome-extension://", "").split("/")
|
|
870
|
+
if parts:
|
|
871
|
+
extension_id = parts[0]
|
|
872
|
+
break
|
|
873
|
+
except Exception as e:
|
|
874
|
+
console.print(f"[yellow]⚠️ CDP query failed: {e}[/yellow]")
|
|
875
|
+
finally:
|
|
876
|
+
await temp_page.close()
|
|
877
|
+
|
|
878
|
+
if extension_id:
|
|
879
|
+
console.print("[green]✅ Extension loaded[/green]")
|
|
880
|
+
else:
|
|
881
|
+
console.print("[yellow]⚠️ Could not find extension ID. Please set API key manually.[/yellow]")
|
|
882
|
+
|
|
883
|
+
# Step 1: Navigate to target URL first
|
|
884
|
+
console.print(f"[cyan]Navigating to {target_url}...[/cyan]")
|
|
885
|
+
main_page = await browser.new_page()
|
|
886
|
+
await main_page.goto(target_url, wait_until="domcontentloaded")
|
|
887
|
+
console.print(f"[green]✅ Loaded: {target_url}[/green]")
|
|
888
|
+
|
|
889
|
+
# Step 2: Use options page to set API key
|
|
890
|
+
if extension_id:
|
|
891
|
+
options_page = await browser.new_page()
|
|
892
|
+
try:
|
|
893
|
+
await options_page.goto(
|
|
894
|
+
f"chrome-extension://{extension_id}/options.html",
|
|
895
|
+
wait_until="domcontentloaded",
|
|
896
|
+
timeout=5000,
|
|
897
|
+
)
|
|
898
|
+
|
|
899
|
+
# Set API key
|
|
900
|
+
await options_page.fill("#platoApiKey", api_key)
|
|
901
|
+
save_button = options_page.locator('button:has-text("Save")')
|
|
902
|
+
if await save_button.count() > 0:
|
|
903
|
+
await save_button.click()
|
|
904
|
+
await asyncio.sleep(0.3)
|
|
905
|
+
console.print("[green]✅ API key saved[/green]")
|
|
906
|
+
|
|
907
|
+
except Exception as e:
|
|
908
|
+
console.print(f"[yellow]⚠️ Could not set up extension: {e}[/yellow]")
|
|
909
|
+
finally:
|
|
910
|
+
await options_page.close()
|
|
911
|
+
|
|
912
|
+
# Bring main page to front
|
|
913
|
+
await main_page.bring_to_front()
|
|
914
|
+
|
|
915
|
+
console.print()
|
|
916
|
+
console.print("[bold]Instructions:[/bold]")
|
|
917
|
+
console.print(" 1. Click the EnvGen Recorder extension icon to open the sidebar")
|
|
918
|
+
if simulator:
|
|
919
|
+
console.print(f" 2. Click 'Configure Session' and enter '{simulator}' as the simulator name")
|
|
920
|
+
else:
|
|
921
|
+
console.print(" 2. Click 'Configure Session' and enter a simulator name")
|
|
922
|
+
console.print(" 3. Use the extension to record and submit reviews")
|
|
923
|
+
console.print(" 4. When done, press Control-C to exit")
|
|
924
|
+
|
|
925
|
+
# Show recent review if available
|
|
926
|
+
if recent_review:
|
|
927
|
+
console.print()
|
|
928
|
+
console.print("=" * 60)
|
|
929
|
+
outcome = recent_review.get("outcome", "unknown")
|
|
930
|
+
timestamp = recent_review.get("timestamp_iso", "")[:10] # Just the date
|
|
931
|
+
if outcome == "reject":
|
|
932
|
+
console.print(f"[bold red]📋 Most Recent Data Review: REJECTED[/bold red] ({timestamp})")
|
|
933
|
+
else:
|
|
934
|
+
console.print(f"[bold green]📋 Most Recent Data Review: PASSED[/bold green] ({timestamp})")
|
|
935
|
+
|
|
936
|
+
comments = recent_review.get("comments")
|
|
937
|
+
if comments:
|
|
938
|
+
console.print("\n[yellow]Reviewer Comments:[/yellow]")
|
|
939
|
+
console.print(f" {comments}")
|
|
940
|
+
console.print("=" * 60)
|
|
941
|
+
|
|
942
|
+
console.print()
|
|
943
|
+
console.print("[bold]Press Control-C when done[/bold]")
|
|
944
|
+
|
|
945
|
+
try:
|
|
946
|
+
await asyncio.Event().wait()
|
|
947
|
+
except KeyboardInterrupt:
|
|
948
|
+
console.print("\n[yellow]Interrupted by user[/yellow]")
|
|
949
|
+
except Exception:
|
|
950
|
+
pass
|
|
951
|
+
|
|
952
|
+
console.print("\n[green]✅ Browser closed. Review session ended.[/green]")
|
|
953
|
+
|
|
954
|
+
except Exception as e:
|
|
955
|
+
console.print(f"[red]❌ Error during review session: {e}[/red]")
|
|
956
|
+
raise
|
|
957
|
+
|
|
958
|
+
finally:
|
|
959
|
+
try:
|
|
960
|
+
if browser:
|
|
961
|
+
await browser.close()
|
|
962
|
+
if playwright:
|
|
963
|
+
await playwright.stop()
|
|
964
|
+
if temp_ext_dir.exists():
|
|
965
|
+
shutil.rmtree(temp_ext_dir, ignore_errors=True)
|
|
966
|
+
except Exception as e:
|
|
967
|
+
console.print(f"[yellow]⚠️ Browser cleanup error: {e}[/yellow]")
|
|
968
|
+
|
|
969
|
+
handle_async(_review_data())
|
|
970
|
+
|
|
971
|
+
|
|
972
|
+
# =============================================================================
|
|
973
|
+
# SUBMIT COMMANDS
|
|
974
|
+
# =============================================================================
|
|
975
|
+
|
|
976
|
+
|
|
977
|
+
@submit_app.command(name="base")
|
|
978
|
+
def submit_base():
|
|
979
|
+
"""Submit base/environment artifact for review after snapshot.
|
|
980
|
+
|
|
981
|
+
Reads simulator name and artifact_id from .sandbox.yaml, syncs metadata from
|
|
982
|
+
plato-config.yml to the server, and transitions status to env_review_requested.
|
|
983
|
+
Run from the simulator directory after creating a snapshot.
|
|
984
|
+
|
|
985
|
+
Requires simulator status: env_in_progress
|
|
986
|
+
No arguments needed - reads everything from .sandbox.yaml and plato-config.yml.
|
|
987
|
+
"""
|
|
988
|
+
api_key = require_api_key()
|
|
989
|
+
|
|
990
|
+
# Get sandbox state
|
|
991
|
+
sandbox_data = require_sandbox_state()
|
|
992
|
+
artifact_id = require_sandbox_field(
|
|
993
|
+
sandbox_data, "artifact_id", "The sandbox must have an artifact_id to request review"
|
|
994
|
+
)
|
|
995
|
+
plato_config_path = require_sandbox_field(sandbox_data, "plato_config_path")
|
|
996
|
+
|
|
997
|
+
# Read plato-config.yml to get simulator name and metadata
|
|
998
|
+
plato_config = read_plato_config(plato_config_path)
|
|
999
|
+
simulator_name = require_plato_config_field(plato_config, "service")
|
|
1000
|
+
|
|
1001
|
+
# Extract metadata from plato-config.yml
|
|
1002
|
+
datasets = plato_config.get("datasets", {})
|
|
1003
|
+
base_dataset = datasets.get("base", {})
|
|
1004
|
+
metadata = base_dataset.get("metadata", {})
|
|
1005
|
+
|
|
1006
|
+
# Get metadata fields
|
|
1007
|
+
config_description = metadata.get("description")
|
|
1008
|
+
config_license = metadata.get("license")
|
|
1009
|
+
config_source_code_url = metadata.get("source_code_url")
|
|
1010
|
+
config_start_url = metadata.get("start_url")
|
|
1011
|
+
config_favicon_url = metadata.get("favicon_url") # Explicit favicon URL
|
|
1012
|
+
|
|
1013
|
+
# Get authentication from variables
|
|
1014
|
+
variables = metadata.get("variables", [])
|
|
1015
|
+
username = None
|
|
1016
|
+
password = None
|
|
1017
|
+
for var in variables:
|
|
1018
|
+
if isinstance(var, dict):
|
|
1019
|
+
var_name = var.get("name", "").lower()
|
|
1020
|
+
var_value = var.get("value")
|
|
1021
|
+
if var_name in ("username", "user", "email", "admin_email", "adminmail"):
|
|
1022
|
+
username = var_value
|
|
1023
|
+
elif var_name in ("password", "pass", "admin_password", "adminpass"):
|
|
1024
|
+
password = var_value
|
|
1025
|
+
|
|
1026
|
+
# Use explicit favicon_url from config, or warn if missing
|
|
1027
|
+
favicon_url = config_favicon_url
|
|
1028
|
+
if not favicon_url:
|
|
1029
|
+
console.print("[yellow]⚠️ No favicon_url in plato-config.yml metadata - favicon will not be set[/yellow]")
|
|
1030
|
+
console.print(
|
|
1031
|
+
"[yellow] Add 'favicon_url: https://www.google.com/s2/favicons?domain=APPNAME.com&sz=32' to metadata[/yellow]"
|
|
1032
|
+
)
|
|
1033
|
+
|
|
1034
|
+
async def _submit_base():
|
|
1035
|
+
base_url = _get_base_url()
|
|
1036
|
+
|
|
1037
|
+
async with httpx.AsyncClient(base_url=base_url, timeout=60.0) as client:
|
|
1038
|
+
# Get simulator by name
|
|
1039
|
+
sim = await get_simulator_by_name.asyncio(
|
|
1040
|
+
client=client,
|
|
1041
|
+
name=simulator_name,
|
|
1042
|
+
x_api_key=api_key,
|
|
1043
|
+
)
|
|
1044
|
+
simulator_id = sim.id
|
|
1045
|
+
current_config = sim.config or {}
|
|
1046
|
+
current_status = current_config.get("status", "not_started")
|
|
1047
|
+
|
|
1048
|
+
# Validate status transition
|
|
1049
|
+
validate_status_transition(current_status, "env_in_progress", "submit base")
|
|
1050
|
+
|
|
1051
|
+
# Show info and submit
|
|
1052
|
+
console.print(f"[cyan]Simulator:[/cyan] {simulator_name}")
|
|
1053
|
+
console.print(f"[cyan]Artifact ID:[/cyan] {artifact_id}")
|
|
1054
|
+
console.print(f"[cyan]Current Status:[/cyan] {current_status}")
|
|
1055
|
+
console.print()
|
|
1056
|
+
|
|
1057
|
+
# Sync metadata from plato-config.yml to server
|
|
1058
|
+
console.print("[cyan]Syncing metadata to server...[/cyan]")
|
|
1059
|
+
|
|
1060
|
+
# Build update request with metadata from plato-config.yml
|
|
1061
|
+
update_fields: dict = {}
|
|
1062
|
+
|
|
1063
|
+
if config_description:
|
|
1064
|
+
update_fields["description"] = config_description
|
|
1065
|
+
console.print(f" [dim]description:[/dim] {config_description[:50]}...")
|
|
1066
|
+
|
|
1067
|
+
if favicon_url:
|
|
1068
|
+
update_fields["img_url"] = favicon_url
|
|
1069
|
+
console.print(f" [dim]img_url:[/dim] {favicon_url}")
|
|
1070
|
+
|
|
1071
|
+
if config_license:
|
|
1072
|
+
update_fields["license"] = config_license
|
|
1073
|
+
console.print(f" [dim]license:[/dim] {config_license}")
|
|
1074
|
+
|
|
1075
|
+
if config_source_code_url:
|
|
1076
|
+
update_fields["source_code_url"] = config_source_code_url
|
|
1077
|
+
console.print(f" [dim]source_code_url:[/dim] {config_source_code_url}")
|
|
1078
|
+
|
|
1079
|
+
if config_start_url:
|
|
1080
|
+
update_fields["start_url"] = config_start_url
|
|
1081
|
+
console.print(f" [dim]start_url:[/dim] {config_start_url}")
|
|
1082
|
+
|
|
1083
|
+
if username and password:
|
|
1084
|
+
update_fields["authentication"] = Authentication(user=username, password=password)
|
|
1085
|
+
console.print(f" [dim]authentication:[/dim] {username} / {'*' * len(password)}")
|
|
1086
|
+
|
|
1087
|
+
# Always include base_artifact_id
|
|
1088
|
+
update_fields["base_artifact_id"] = artifact_id
|
|
1089
|
+
|
|
1090
|
+
try:
|
|
1091
|
+
await update_simulator.asyncio(
|
|
1092
|
+
client=client,
|
|
1093
|
+
simulator_id=simulator_id,
|
|
1094
|
+
body=AppApiV1SimulatorRoutesUpdateSimulatorRequest(**update_fields),
|
|
1095
|
+
x_api_key=api_key,
|
|
1096
|
+
)
|
|
1097
|
+
console.print("[green]✅ Metadata synced to server[/green]")
|
|
1098
|
+
except Exception as e:
|
|
1099
|
+
console.print(f"[yellow]⚠️ Could not sync metadata: {e}[/yellow]")
|
|
1100
|
+
|
|
1101
|
+
console.print()
|
|
1102
|
+
|
|
1103
|
+
# Update simulator status
|
|
1104
|
+
await update_simulator_status.asyncio(
|
|
1105
|
+
client=client,
|
|
1106
|
+
simulator_id=simulator_id,
|
|
1107
|
+
body=UpdateStatusRequest(status="env_review_requested"),
|
|
1108
|
+
x_api_key=api_key,
|
|
1109
|
+
)
|
|
1110
|
+
|
|
1111
|
+
console.print("[green]✅ Environment review requested successfully![/green]")
|
|
1112
|
+
console.print(f"[cyan]Status:[/cyan] {current_status} → env_review_requested")
|
|
1113
|
+
console.print(f"[cyan]Base Artifact:[/cyan] {artifact_id}")
|
|
1114
|
+
|
|
1115
|
+
handle_async(_submit_base())
|
|
1116
|
+
|
|
1117
|
+
|
|
1118
|
+
@submit_app.command(name="data")
|
|
1119
|
+
def submit_data(
|
|
1120
|
+
simulator: str = typer.Option(
|
|
1121
|
+
None,
|
|
1122
|
+
"--simulator",
|
|
1123
|
+
"-s",
|
|
1124
|
+
help="Simulator name. Supports colon notation: -s sim:<artifact-uuid>",
|
|
1125
|
+
),
|
|
1126
|
+
artifact: str = typer.Option(
|
|
1127
|
+
None,
|
|
1128
|
+
"--artifact",
|
|
1129
|
+
"-a",
|
|
1130
|
+
help="Artifact UUID to submit for data review (required).",
|
|
1131
|
+
),
|
|
1132
|
+
):
|
|
1133
|
+
"""Submit data artifact for review after data generation.
|
|
1134
|
+
|
|
1135
|
+
Transitions simulator from data_in_progress → data_review_requested and
|
|
1136
|
+
tags the artifact as 'data-pending-review'.
|
|
1137
|
+
|
|
1138
|
+
Requires simulator status: data_in_progress
|
|
1139
|
+
|
|
1140
|
+
Options:
|
|
1141
|
+
-s, --simulator: Simulator name. Supports colon notation:
|
|
1142
|
+
'-s sim:<uuid>' or use separate -a flag
|
|
1143
|
+
-a, --artifact: Artifact UUID to submit (required)
|
|
1144
|
+
"""
|
|
1145
|
+
api_key = require_api_key()
|
|
1146
|
+
|
|
1147
|
+
# Parse simulator and artifact from args (artifact IS required for data submit)
|
|
1148
|
+
simulator_name, artifact_id = parse_simulator_artifact(
|
|
1149
|
+
simulator, artifact, require_artifact=True, command_name="submit data"
|
|
1150
|
+
)
|
|
1151
|
+
|
|
1152
|
+
async def _submit_data():
|
|
1153
|
+
# simulator_name and artifact_id are guaranteed set by parse_simulator_artifact with require_artifact=True
|
|
1154
|
+
assert simulator_name is not None, "simulator_name must be set"
|
|
1155
|
+
assert artifact_id is not None, "artifact_id must be set"
|
|
1156
|
+
|
|
1157
|
+
base_url = _get_base_url()
|
|
1158
|
+
|
|
1159
|
+
async with httpx.AsyncClient(base_url=base_url, timeout=60.0) as client:
|
|
1160
|
+
# Get simulator by name
|
|
1161
|
+
sim = await get_simulator_by_name.asyncio(
|
|
1162
|
+
client=client,
|
|
1163
|
+
name=simulator_name,
|
|
1164
|
+
x_api_key=api_key,
|
|
1165
|
+
)
|
|
1166
|
+
simulator_id = sim.id
|
|
1167
|
+
current_config = sim.config or {}
|
|
1168
|
+
current_status = current_config.get("status", "not_started")
|
|
1169
|
+
|
|
1170
|
+
# Validate status transition
|
|
1171
|
+
validate_status_transition(current_status, "data_in_progress", "submit data")
|
|
1172
|
+
|
|
1173
|
+
# Show info and submit
|
|
1174
|
+
console.print(f"[cyan]Simulator:[/cyan] {simulator_name}")
|
|
1175
|
+
console.print(f"[cyan]Artifact ID:[/cyan] {artifact_id}")
|
|
1176
|
+
console.print(f"[cyan]Current Status:[/cyan] {current_status}")
|
|
1177
|
+
console.print()
|
|
1178
|
+
|
|
1179
|
+
# Update simulator status
|
|
1180
|
+
await update_simulator_status.asyncio(
|
|
1181
|
+
client=client,
|
|
1182
|
+
simulator_id=simulator_id,
|
|
1183
|
+
body=UpdateStatusRequest(status="data_review_requested"),
|
|
1184
|
+
x_api_key=api_key,
|
|
1185
|
+
)
|
|
1186
|
+
|
|
1187
|
+
# Set data_artifact_id via tag update (simulator_name and artifact_id already asserted above)
|
|
1188
|
+
try:
|
|
1189
|
+
await update_tag.asyncio(
|
|
1190
|
+
client=client,
|
|
1191
|
+
body=UpdateTagRequest(
|
|
1192
|
+
simulator_name=simulator_name,
|
|
1193
|
+
artifact_id=artifact_id,
|
|
1194
|
+
tag_name="data-pending-review",
|
|
1195
|
+
dataset="base",
|
|
1196
|
+
),
|
|
1197
|
+
x_api_key=api_key,
|
|
1198
|
+
)
|
|
1199
|
+
except Exception as e:
|
|
1200
|
+
console.print(f"[yellow]⚠️ Could not set artifact tag: {e}[/yellow]")
|
|
1201
|
+
|
|
1202
|
+
console.print("[green]✅ Data review requested successfully![/green]")
|
|
1203
|
+
console.print(f"[cyan]Status:[/cyan] {current_status} → data_review_requested")
|
|
1204
|
+
console.print(f"[cyan]Data Artifact:[/cyan] {artifact_id}")
|
|
1205
|
+
|
|
1206
|
+
handle_async(_submit_data())
|