plato-sdk-v2 2.8.0__py3-none-any.whl → 2.8.2__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/v1/cli/pm.py CHANGED
@@ -1,1256 +1,14 @@
1
- """Project Management CLI commands for Plato simulator workflow."""
1
+ """Project Management CLI commands for Plato simulator workflow.
2
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
3
+ This module re-exports from plato.cli.pm for backwards compatibility.
4
+ """
11
5
 
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,
6
+ # Re-export from main CLI for backwards compatibility
7
+ from plato.cli.pm import (
8
+ list_app,
9
+ pm_app,
10
+ review_app,
11
+ submit_app,
35
12
  )
36
- from plato.v1.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.v1.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
- # Handle both old 'comments' field and new 'sim_comments' structure
551
- sim_comments = recent_review.get("sim_comments")
552
- if sim_comments:
553
- console.print("[yellow]Reviewer Comments:[/yellow]")
554
- for i, item in enumerate(sim_comments, 1):
555
- comment_text = item.get("comment", "")
556
- if comment_text:
557
- console.print(f" {i}. {comment_text}")
558
- else:
559
- # Fallback to old comments field
560
- comments = recent_review.get("comments")
561
- if comments:
562
- console.print(f"[yellow]Reviewer Comments:[/yellow] {comments}")
563
-
564
- console.print()
565
-
566
- # Interactive loop
567
- while True:
568
- try:
569
- command = input("Enter command: ").strip().lower()
570
-
571
- if command in ["finish", "f"]:
572
- console.print("\n[yellow]Finishing review...[/yellow]")
573
- break
574
- elif command in ["state", "s"]:
575
- console.print("\n[cyan]Getting environment state with mutations...[/cyan]")
576
- try:
577
- # Call API directly with merge_mutations=True to include mutations
578
- state_response = await sessions_state.asyncio(
579
- client=http_client,
580
- session_id=session.session_id,
581
- merge_mutations=True,
582
- x_api_key=api_key,
583
- )
584
- if state_response and state_response.results:
585
- for jid, result in state_response.results.items():
586
- state_data = result.state if hasattr(result, "state") else result
587
- console.print(f"\n[bold cyan]Job {jid}:[/bold cyan]")
588
-
589
- if isinstance(state_data, dict):
590
- # Check for error in state response
591
- if "error" in state_data:
592
- console.print("\n[bold red]❌ State API Error:[/bold red]")
593
- console.print(f"[red]{state_data['error']}[/red]")
594
- continue
595
-
596
- mutations = state_data.pop("mutations", [])
597
- console.print("\n[bold]State:[/bold]")
598
- console.print(json.dumps(state_data, indent=2, default=str))
599
- if mutations:
600
- console.print(f"\n[bold]Mutations ({len(mutations)}):[/bold]")
601
- console.print(json.dumps(mutations, indent=2, default=str))
602
- else:
603
- console.print("\n[yellow]No mutations recorded[/yellow]")
604
- else:
605
- console.print(json.dumps(state_data, indent=2, default=str))
606
- else:
607
- console.print("[yellow]No state data available[/yellow]")
608
- console.print()
609
- except Exception as e:
610
- console.print(f"[red]❌ Error getting state: {e}[/red]")
611
- else:
612
- console.print("[yellow]Unknown command. Use 'state' or 'finish'[/yellow]")
613
-
614
- except KeyboardInterrupt:
615
- console.print("\n[yellow]Interrupted! Finishing review...[/yellow]")
616
- break
617
-
618
- except Exception as env_error:
619
- console.print(f"[yellow]⚠️ Environment creation failed: {env_error}[/yellow]")
620
- console.print("[yellow]You can still submit a review without testing the environment.[/yellow]")
621
-
622
- # Prompt for outcome
623
- console.print("\n[bold]Choose outcome:[/bold]")
624
- console.print(" 1. pass")
625
- console.print(" 2. reject")
626
- console.print(" 3. skip (no status update)")
627
- outcome_choice = typer.prompt("Choice [1/2/3]").strip()
628
-
629
- if outcome_choice == "1":
630
- outcome = "pass"
631
- elif outcome_choice == "2":
632
- outcome = "reject"
633
- elif outcome_choice == "3":
634
- console.print("[yellow]Review session ended without status update[/yellow]")
635
- return
636
- else:
637
- console.print("[red]❌ Invalid choice. Aborting.[/red]")
638
- raise typer.Exit(1)
639
-
640
- # Validate status BEFORE submitting outcome
641
- if outcome == "pass":
642
- validate_status_transition(current_status, "env_review_requested", "review base pass")
643
- new_status = "env_approved"
644
- else:
645
- validate_status_transition(current_status, "env_review_requested", "review base reject")
646
- new_status = "env_in_progress"
647
-
648
- # Update status
649
- await update_simulator_status.asyncio(
650
- client=http_client,
651
- simulator_id=simulator_id,
652
- body=UpdateStatusRequest(status=new_status),
653
- x_api_key=api_key,
654
- )
655
-
656
- # Add review if rejecting
657
- if outcome == "reject":
658
- comments = ""
659
- while not comments:
660
- comments = typer.prompt("Comments (required for reject)").strip()
661
- if not comments:
662
- console.print("[yellow]Comments are required when rejecting. Please provide feedback.[/yellow]")
663
-
664
- await add_simulator_review.asyncio(
665
- client=http_client,
666
- simulator_id=simulator_id,
667
- body=AddReviewRequest(
668
- review_type=ReviewType.env,
669
- outcome=Outcome.reject,
670
- artifact_id=artifact_id,
671
- comments=comments,
672
- ),
673
- x_api_key=api_key,
674
- )
675
-
676
- console.print(f"[green]✅ Review submitted: {outcome}[/green]")
677
- console.print(f"[cyan]Status:[/cyan] {current_status} → {new_status}")
678
-
679
- # If passed, automatically tag artifact as prod-latest
680
- if outcome == "pass" and artifact_id:
681
- console.print("\n[cyan]Tagging artifact as prod-latest...[/cyan]")
682
- try:
683
- # simulator_name and artifact_id are guaranteed to be set at this point
684
- assert simulator_name is not None
685
- await update_tag.asyncio(
686
- client=http_client,
687
- body=UpdateTagRequest(
688
- simulator_name=simulator_name,
689
- artifact_id=artifact_id,
690
- tag_name="prod-latest",
691
- dataset="base",
692
- ),
693
- x_api_key=api_key,
694
- )
695
- console.print(f"[green]✅ Tagged {artifact_id[:8]}... as prod-latest[/green]")
696
- except Exception as e:
697
- console.print(f"[yellow]⚠️ Could not tag as prod-latest: {e}[/yellow]")
698
-
699
- except typer.Exit:
700
- raise
701
- except Exception as e:
702
- console.print(f"[red]❌ Error during review session: {e}[/red]")
703
- raise
704
-
705
- finally:
706
- # Cleanup
707
- try:
708
- if login_result and login_result.context:
709
- await login_result.context.close()
710
- if browser:
711
- await browser.close()
712
- if playwright:
713
- await playwright.stop()
714
- except Exception as e:
715
- console.print(f"[yellow]⚠️ Browser cleanup error: {e}[/yellow]")
716
-
717
- if session:
718
- try:
719
- console.print("[cyan]Shutting down session...[/cyan]")
720
- await session.close()
721
- console.print("[green]✅ Session shut down[/green]")
722
- except Exception as e:
723
- console.print(f"[yellow]⚠️ Session cleanup error: {e}[/yellow]")
724
-
725
- try:
726
- await plato.close()
727
- except Exception as e:
728
- console.print(f"[yellow]⚠️ Client cleanup error: {e}[/yellow]")
729
-
730
- handle_async(_review_base())
731
-
732
-
733
- @review_app.command(name="data")
734
- def review_data(
735
- simulator: str = typer.Option(
736
- None,
737
- "--simulator",
738
- "-s",
739
- help="Simulator name. Supports colon notation: -s sim:<artifact-uuid>",
740
- ),
741
- artifact: str = typer.Option(
742
- None,
743
- "--artifact",
744
- "-a",
745
- help="Artifact UUID to review. If not provided, uses server's data_artifact_id.",
746
- ),
747
- ):
748
- """Launch browser with EnvGen Recorder extension for data review.
749
-
750
- Opens Chrome with the EnvGen Recorder extension installed for reviewing
751
- data artifacts. The extension sidebar can be used to record and submit reviews.
752
- Press Control-C to exit when done.
753
-
754
- Requires simulator status: data_review_requested
755
-
756
- Options:
757
- -s, --simulator: Simulator name. Supports colon notation for artifact:
758
- '-s sim' (uses server's data_artifact_id) or '-s sim:<uuid>'
759
- -a, --artifact: Explicit artifact UUID to review. Overrides server's value.
760
- """
761
- api_key = require_api_key()
762
-
763
- # Parse simulator and artifact from args (artifact not required - falls back to server config)
764
- simulator_name, artifact_id = parse_simulator_artifact(
765
- simulator, artifact, require_artifact=False, command_name="review data"
766
- )
767
-
768
- console.print(f"[cyan]Simulator:[/cyan] {simulator_name}")
769
-
770
- # Fetch simulator config and get artifact ID if not provided
771
- recent_review = None
772
-
773
- async def _fetch_artifact_info():
774
- nonlocal artifact_id
775
- # simulator_name is guaranteed set by parse_simulator_artifact (or we exit)
776
- assert simulator_name is not None, "simulator_name must be set"
777
-
778
- base_url = _get_base_url()
779
- async with httpx.AsyncClient(base_url=base_url, timeout=60.0) as client:
780
- try:
781
- sim = await get_simulator_by_name.asyncio(
782
- client=client,
783
- name=simulator_name,
784
- x_api_key=api_key,
785
- )
786
- config = sim.config or {}
787
-
788
- # If no artifact provided, try to get data_artifact_id from server
789
- if not artifact_id:
790
- artifact_id = config.get("data_artifact_id")
791
- if artifact_id:
792
- console.print(f"[cyan]Using data_artifact_id from server:[/cyan] {artifact_id}")
793
- else:
794
- console.print("[yellow]No artifact specified and no data_artifact_id on server[/yellow]")
795
-
796
- # Find most recent data review
797
- reviews = config.get("reviews") or []
798
- data_reviews = [r for r in reviews if r.get("review_type") == "data"]
799
- if data_reviews:
800
- data_reviews.sort(key=lambda r: r.get("timestamp_iso", ""), reverse=True)
801
- return data_reviews[0]
802
- except Exception as e:
803
- console.print(f"[yellow]⚠️ Could not fetch simulator info: {e}[/yellow]")
804
- return None
805
-
806
- recent_review = handle_async(_fetch_artifact_info())
807
-
808
- if artifact_id:
809
- console.print(f"[cyan]Artifact:[/cyan] {artifact_id}")
810
-
811
- # Find Chrome extension source
812
- package_dir = Path(__file__).resolve().parent.parent # v1/
813
- is_installed = "site-packages" in str(package_dir)
814
-
815
- if is_installed:
816
- extension_source_path = package_dir / "extensions" / "data-review"
817
- else:
818
- repo_root = package_dir.parent.parent.parent # plato-client/
819
- extension_source_path = repo_root / "extensions" / "data-review"
820
-
821
- # Fallback to env var
822
- if not extension_source_path.exists():
823
- plato_client_dir_env = os.getenv("PLATO_CLIENT_DIR")
824
- if plato_client_dir_env:
825
- env_path = Path(plato_client_dir_env) / "extensions" / "data-review"
826
- if env_path.exists():
827
- extension_source_path = env_path
828
-
829
- if not extension_source_path.exists():
830
- console.print("[red]❌ Data Review extension not found[/red]")
831
- console.print(f"\n[yellow]Expected location:[/yellow] {extension_source_path}")
832
- raise typer.Exit(1)
833
-
834
- # Copy extension to temp directory
835
- temp_ext_dir = Path(tempfile.mkdtemp(prefix="plato-extension-"))
836
- extension_path = temp_ext_dir / "envgen-recorder"
837
-
838
- console.print("[cyan]Copying extension to temp directory...[/cyan]")
839
- shutil.copytree(extension_source_path, extension_path, dirs_exist_ok=False)
840
- console.print(f"[green]✅ Extension copied to: {extension_path}[/green]")
841
-
842
- async def _review_data():
843
- base_url = _get_base_url()
844
- plato = AsyncPlato(api_key=api_key, base_url=base_url)
845
- session = None
846
- playwright = None
847
- browser = None
848
-
849
- try:
850
- # Check if we have an artifact ID to create a session
851
- if not artifact_id:
852
- console.print("[red]❌ No artifact ID available. Cannot create session.[/red]")
853
- console.print("[yellow]Specify artifact with: plato pm review data -s simulator:artifact_id[/yellow]")
854
- raise typer.Exit(1)
855
-
856
- # Create session with artifact
857
- console.print(f"[cyan]Creating {simulator_name} environment with artifact {artifact_id}...[/cyan]")
858
- session = await plato.sessions.create(
859
- envs=[Env.artifact(artifact_id)],
860
- timeout=300,
861
- )
862
- console.print(f"[green]✅ Session created: {session.session_id}[/green]")
863
-
864
- # Reset environment
865
- console.print("[cyan]Resetting environment...[/cyan]")
866
- await session.reset()
867
- console.print("[green]✅ Environment reset complete![/green]")
868
-
869
- # Get public URL
870
- public_urls = await session.get_public_url()
871
- first_alias = session.envs[0].alias if session.envs else None
872
- public_url = public_urls.get(first_alias) if first_alias else None
873
- if not public_url and public_urls:
874
- public_url = list(public_urls.values())[0]
875
- console.print(f"[cyan]Public URL:[/cyan] {public_url}")
876
-
877
- user_data_dir = Path.home() / ".plato" / "chrome-data"
878
- user_data_dir.mkdir(parents=True, exist_ok=True)
879
-
880
- console.print("[cyan]Launching Chrome with Data Review extension...[/cyan]")
881
-
882
- from playwright.async_api import async_playwright
883
-
884
- playwright = await async_playwright().start()
885
-
886
- browser = await playwright.chromium.launch_persistent_context(
887
- str(user_data_dir),
888
- headless=False,
889
- args=[
890
- f"--disable-extensions-except={extension_path}",
891
- f"--load-extension={extension_path}",
892
- ],
893
- )
894
-
895
- # Wait for extension to load
896
- await asyncio.sleep(2)
897
-
898
- # Find extension ID via CDP
899
- extension_id = None
900
- temp_page = await browser.new_page()
901
- try:
902
- cdp = await temp_page.context.new_cdp_session(temp_page)
903
- targets_result = await cdp.send("Target.getTargets")
904
- for target_info in targets_result.get("targetInfos", []):
905
- ext_url = target_info.get("url", "")
906
- if "chrome-extension://" in ext_url:
907
- parts = ext_url.replace("chrome-extension://", "").split("/")
908
- if parts:
909
- extension_id = parts[0]
910
- break
911
- except Exception as e:
912
- console.print(f"[yellow]⚠️ CDP query failed: {e}[/yellow]")
913
- finally:
914
- await temp_page.close()
915
-
916
- if extension_id:
917
- console.print("[green]✅ Extension loaded[/green]")
918
- else:
919
- console.print("[yellow]⚠️ Could not find extension ID. Please set API key manually.[/yellow]")
920
-
921
- # Navigate to public URL (user logs in manually with displayed credentials)
922
- console.print("[cyan]Opening environment...[/cyan]")
923
- main_page = await browser.new_page()
924
- if public_url:
925
- await main_page.goto(public_url)
926
- console.print(f"[green]✅ Loaded: {public_url}[/green]")
927
-
928
- # Use options page to set API key
929
- if extension_id:
930
- options_page = await browser.new_page()
931
- try:
932
- await options_page.goto(
933
- f"chrome-extension://{extension_id}/options.html",
934
- wait_until="domcontentloaded",
935
- timeout=5000,
936
- )
937
-
938
- # Set API key
939
- await options_page.fill("#platoApiKey", api_key)
940
- save_button = options_page.locator('button:has-text("Save")')
941
- if await save_button.count() > 0:
942
- await save_button.click()
943
- await asyncio.sleep(0.3)
944
- console.print("[green]✅ API key saved[/green]")
945
-
946
- except Exception as e:
947
- console.print(f"[yellow]⚠️ Could not set up extension: {e}[/yellow]")
948
- finally:
949
- await options_page.close()
950
-
951
- # Bring main page to front
952
- if main_page:
953
- await main_page.bring_to_front()
954
-
955
- console.print()
956
- console.print("[bold]Instructions:[/bold]")
957
- console.print(" 1. Click the Data Review extension icon to open the sidebar")
958
- console.print(f" 2. Enter '{simulator_name}' as the simulator name and click Start Review")
959
- console.print(" 3. Take screenshots and add comments for any issues")
960
- console.print(" 4. Select Pass or Reject and submit the review")
961
- console.print(" 5. When done, press Control-C to exit")
962
-
963
- # Show recent review if available
964
- if recent_review:
965
- console.print()
966
- console.print("=" * 60)
967
- outcome = recent_review.get("outcome", "unknown")
968
- timestamp = recent_review.get("timestamp_iso", "")[:10] # Just the date
969
- if outcome == "reject":
970
- console.print(f"[bold red]📋 Most Recent Data Review: REJECTED[/bold red] ({timestamp})")
971
- else:
972
- console.print(f"[bold green]📋 Most Recent Data Review: PASSED[/bold green] ({timestamp})")
973
-
974
- # Handle both old 'comments' field and new 'sim_comments' structure
975
- sim_comments = recent_review.get("sim_comments")
976
- if sim_comments:
977
- console.print("\n[yellow]Reviewer Comments:[/yellow]")
978
- for i, item in enumerate(sim_comments, 1):
979
- comment_text = item.get("comment", "")
980
- if comment_text:
981
- console.print(f" {i}. {comment_text}")
982
- else:
983
- # Fallback to old comments field
984
- comments = recent_review.get("comments")
985
- if comments:
986
- console.print("\n[yellow]Reviewer Comments:[/yellow]")
987
- console.print(f" {comments}")
988
- console.print("=" * 60)
989
-
990
- console.print()
991
- console.print("[bold]Press Control-C when done[/bold]")
992
-
993
- try:
994
- await asyncio.Event().wait()
995
- except KeyboardInterrupt:
996
- console.print("\n[yellow]Interrupted by user[/yellow]")
997
- except Exception:
998
- pass
999
-
1000
- console.print("\n[green]✅ Browser closed. Review session ended.[/green]")
1001
-
1002
- except Exception as e:
1003
- console.print(f"[red]❌ Error during review session: {e}[/red]")
1004
- raise
1005
-
1006
- finally:
1007
- try:
1008
- if session:
1009
- await session.close()
1010
- if browser:
1011
- await browser.close()
1012
- if playwright:
1013
- await playwright.stop()
1014
- if temp_ext_dir.exists():
1015
- shutil.rmtree(temp_ext_dir, ignore_errors=True)
1016
- except Exception as e:
1017
- console.print(f"[yellow]⚠️ Cleanup error: {e}[/yellow]")
1018
-
1019
- handle_async(_review_data())
1020
-
1021
-
1022
- # =============================================================================
1023
- # SUBMIT COMMANDS
1024
- # =============================================================================
1025
-
1026
-
1027
- @submit_app.command(name="base")
1028
- def submit_base():
1029
- """Submit base/environment artifact for review after snapshot.
1030
-
1031
- Reads simulator name and artifact_id from .sandbox.yaml, syncs metadata from
1032
- plato-config.yml to the server, and transitions status to env_review_requested.
1033
- Run from the simulator directory after creating a snapshot.
1034
-
1035
- Requires simulator status: env_in_progress
1036
- No arguments needed - reads everything from .sandbox.yaml and plato-config.yml.
1037
- """
1038
- api_key = require_api_key()
1039
-
1040
- # Get sandbox state
1041
- sandbox_data = require_sandbox_state()
1042
- artifact_id = require_sandbox_field(
1043
- sandbox_data, "artifact_id", "The sandbox must have an artifact_id to request review"
1044
- )
1045
- plato_config_path = require_sandbox_field(sandbox_data, "plato_config_path")
1046
-
1047
- # Read plato-config.yml to get simulator name and metadata
1048
- plato_config = read_plato_config(plato_config_path)
1049
- simulator_name = require_plato_config_field(plato_config, "service")
1050
-
1051
- # Extract metadata from plato-config.yml
1052
- datasets = plato_config.get("datasets", {})
1053
- base_dataset = datasets.get("base", {})
1054
- metadata = base_dataset.get("metadata", {})
1055
-
1056
- # Get metadata fields
1057
- config_description = metadata.get("description")
1058
- config_license = metadata.get("license")
1059
- config_source_code_url = metadata.get("source_code_url")
1060
- config_start_url = metadata.get("start_url")
1061
- config_favicon_url = metadata.get("favicon_url") # Explicit favicon URL
1062
-
1063
- # Get authentication from variables
1064
- variables = metadata.get("variables", [])
1065
- username = None
1066
- password = None
1067
- for var in variables:
1068
- if isinstance(var, dict):
1069
- var_name = var.get("name", "").lower()
1070
- var_value = var.get("value")
1071
- if var_name in ("username", "user", "email", "admin_email", "adminmail"):
1072
- username = var_value
1073
- elif var_name in ("password", "pass", "admin_password", "adminpass"):
1074
- password = var_value
1075
-
1076
- # Use explicit favicon_url from config, or warn if missing
1077
- favicon_url = config_favicon_url
1078
- if not favicon_url:
1079
- console.print("[yellow]⚠️ No favicon_url in plato-config.yml metadata - favicon will not be set[/yellow]")
1080
- console.print(
1081
- "[yellow] Add 'favicon_url: https://www.google.com/s2/favicons?domain=APPNAME.com&sz=32' to metadata[/yellow]"
1082
- )
1083
-
1084
- async def _submit_base():
1085
- base_url = _get_base_url()
1086
-
1087
- async with httpx.AsyncClient(base_url=base_url, timeout=60.0) as client:
1088
- # Get simulator by name
1089
- sim = await get_simulator_by_name.asyncio(
1090
- client=client,
1091
- name=simulator_name,
1092
- x_api_key=api_key,
1093
- )
1094
- simulator_id = sim.id
1095
- current_config = sim.config or {}
1096
- current_status = current_config.get("status", "not_started")
1097
-
1098
- # Validate status transition
1099
- validate_status_transition(current_status, "env_in_progress", "submit base")
1100
-
1101
- # Show info and submit
1102
- console.print(f"[cyan]Simulator:[/cyan] {simulator_name}")
1103
- console.print(f"[cyan]Artifact ID:[/cyan] {artifact_id}")
1104
- console.print(f"[cyan]Current Status:[/cyan] {current_status}")
1105
- console.print()
1106
-
1107
- # Sync metadata from plato-config.yml to server
1108
- console.print("[cyan]Syncing metadata to server...[/cyan]")
1109
-
1110
- # Build update request with metadata from plato-config.yml
1111
- update_fields: dict = {}
1112
-
1113
- if config_description:
1114
- update_fields["description"] = config_description
1115
- console.print(f" [dim]description:[/dim] {config_description[:50]}...")
1116
-
1117
- if favicon_url:
1118
- update_fields["img_url"] = favicon_url
1119
- console.print(f" [dim]img_url:[/dim] {favicon_url}")
1120
-
1121
- if config_license:
1122
- update_fields["license"] = config_license
1123
- console.print(f" [dim]license:[/dim] {config_license}")
1124
-
1125
- if config_source_code_url:
1126
- update_fields["source_code_url"] = config_source_code_url
1127
- console.print(f" [dim]source_code_url:[/dim] {config_source_code_url}")
1128
-
1129
- if config_start_url:
1130
- update_fields["start_url"] = config_start_url
1131
- console.print(f" [dim]start_url:[/dim] {config_start_url}")
1132
-
1133
- if username and password:
1134
- update_fields["authentication"] = Authentication(user=username, password=password)
1135
- console.print(f" [dim]authentication:[/dim] {username} / {'*' * len(password)}")
1136
-
1137
- # Always include base_artifact_id
1138
- update_fields["base_artifact_id"] = artifact_id
1139
-
1140
- try:
1141
- await update_simulator.asyncio(
1142
- client=client,
1143
- simulator_id=simulator_id,
1144
- body=AppApiV1SimulatorRoutesUpdateSimulatorRequest(**update_fields),
1145
- x_api_key=api_key,
1146
- )
1147
- console.print("[green]✅ Metadata synced to server[/green]")
1148
- except Exception as e:
1149
- console.print(f"[yellow]⚠️ Could not sync metadata: {e}[/yellow]")
1150
-
1151
- console.print()
1152
-
1153
- # Update simulator status
1154
- await update_simulator_status.asyncio(
1155
- client=client,
1156
- simulator_id=simulator_id,
1157
- body=UpdateStatusRequest(status="env_review_requested"),
1158
- x_api_key=api_key,
1159
- )
1160
-
1161
- console.print("[green]✅ Environment review requested successfully![/green]")
1162
- console.print(f"[cyan]Status:[/cyan] {current_status} → env_review_requested")
1163
- console.print(f"[cyan]Base Artifact:[/cyan] {artifact_id}")
1164
-
1165
- handle_async(_submit_base())
1166
-
1167
-
1168
- @submit_app.command(name="data")
1169
- def submit_data(
1170
- simulator: str = typer.Option(
1171
- None,
1172
- "--simulator",
1173
- "-s",
1174
- help="Simulator name. Supports colon notation: -s sim:<artifact-uuid>",
1175
- ),
1176
- artifact: str = typer.Option(
1177
- None,
1178
- "--artifact",
1179
- "-a",
1180
- help="Artifact UUID to submit for data review (required).",
1181
- ),
1182
- ):
1183
- """Submit data artifact for review after data generation.
1184
-
1185
- Transitions simulator from data_in_progress → data_review_requested and
1186
- tags the artifact as 'data-pending-review'.
1187
-
1188
- Requires simulator status: data_in_progress
1189
-
1190
- Options:
1191
- -s, --simulator: Simulator name. Supports colon notation:
1192
- '-s sim:<uuid>' or use separate -a flag
1193
- -a, --artifact: Artifact UUID to submit (required)
1194
- """
1195
- api_key = require_api_key()
1196
-
1197
- # Parse simulator and artifact from args (artifact IS required for data submit)
1198
- simulator_name, artifact_id = parse_simulator_artifact(
1199
- simulator, artifact, require_artifact=True, command_name="submit data"
1200
- )
1201
-
1202
- async def _submit_data():
1203
- # simulator_name and artifact_id are guaranteed set by parse_simulator_artifact with require_artifact=True
1204
- assert simulator_name is not None, "simulator_name must be set"
1205
- assert artifact_id is not None, "artifact_id must be set"
1206
-
1207
- base_url = _get_base_url()
1208
-
1209
- async with httpx.AsyncClient(base_url=base_url, timeout=60.0) as client:
1210
- # Get simulator by name
1211
- sim = await get_simulator_by_name.asyncio(
1212
- client=client,
1213
- name=simulator_name,
1214
- x_api_key=api_key,
1215
- )
1216
- simulator_id = sim.id
1217
- current_config = sim.config or {}
1218
- current_status = current_config.get("status", "not_started")
1219
-
1220
- # Validate status transition
1221
- validate_status_transition(current_status, "data_in_progress", "submit data")
1222
-
1223
- # Show info and submit
1224
- console.print(f"[cyan]Simulator:[/cyan] {simulator_name}")
1225
- console.print(f"[cyan]Artifact ID:[/cyan] {artifact_id}")
1226
- console.print(f"[cyan]Current Status:[/cyan] {current_status}")
1227
- console.print()
1228
-
1229
- # Update simulator status
1230
- await update_simulator_status.asyncio(
1231
- client=client,
1232
- simulator_id=simulator_id,
1233
- body=UpdateStatusRequest(status="data_review_requested"),
1234
- x_api_key=api_key,
1235
- )
1236
-
1237
- # Set data_artifact_id via tag update (simulator_name and artifact_id already asserted above)
1238
- try:
1239
- await update_tag.asyncio(
1240
- client=client,
1241
- body=UpdateTagRequest(
1242
- simulator_name=simulator_name,
1243
- artifact_id=artifact_id,
1244
- tag_name="data-pending-review",
1245
- dataset="base",
1246
- ),
1247
- x_api_key=api_key,
1248
- )
1249
- except Exception as e:
1250
- console.print(f"[yellow]⚠️ Could not set artifact tag: {e}[/yellow]")
1251
-
1252
- console.print("[green]✅ Data review requested successfully![/green]")
1253
- console.print(f"[cyan]Status:[/cyan] {current_status} → data_review_requested")
1254
- console.print(f"[cyan]Data Artifact:[/cyan] {artifact_id}")
1255
13
 
1256
- handle_async(_submit_data())
14
+ __all__ = ["pm_app", "list_app", "review_app", "submit_app"]