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