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/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())