ralphx 0.2.2__py3-none-any.whl → 0.3.5__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.
Files changed (45) hide show
  1. ralphx/__init__.py +1 -1
  2. ralphx/api/main.py +9 -1
  3. ralphx/api/routes/auth.py +730 -65
  4. ralphx/api/routes/config.py +3 -56
  5. ralphx/api/routes/export_import.py +795 -0
  6. ralphx/api/routes/loops.py +4 -4
  7. ralphx/api/routes/planning.py +19 -5
  8. ralphx/api/routes/projects.py +84 -2
  9. ralphx/api/routes/templates.py +115 -2
  10. ralphx/api/routes/workflows.py +22 -22
  11. ralphx/cli.py +21 -6
  12. ralphx/core/auth.py +346 -171
  13. ralphx/core/database.py +615 -167
  14. ralphx/core/executor.py +0 -3
  15. ralphx/core/loop.py +15 -2
  16. ralphx/core/loop_templates.py +69 -3
  17. ralphx/core/planning_service.py +109 -21
  18. ralphx/core/preview.py +9 -25
  19. ralphx/core/project_db.py +175 -75
  20. ralphx/core/project_export.py +469 -0
  21. ralphx/core/project_import.py +670 -0
  22. ralphx/core/sample_project.py +430 -0
  23. ralphx/core/templates.py +46 -9
  24. ralphx/core/workflow_executor.py +35 -5
  25. ralphx/core/workflow_export.py +606 -0
  26. ralphx/core/workflow_import.py +1149 -0
  27. ralphx/examples/sample_project/DESIGN.md +345 -0
  28. ralphx/examples/sample_project/README.md +37 -0
  29. ralphx/examples/sample_project/guardrails.md +57 -0
  30. ralphx/examples/sample_project/stories.jsonl +10 -0
  31. ralphx/mcp/__init__.py +6 -2
  32. ralphx/mcp/registry.py +3 -3
  33. ralphx/mcp/server.py +99 -29
  34. ralphx/mcp/tools/__init__.py +4 -0
  35. ralphx/mcp/tools/help.py +204 -0
  36. ralphx/mcp/tools/workflows.py +114 -32
  37. ralphx/mcp_server.py +6 -2
  38. ralphx/static/assets/index-0ovNnfOq.css +1 -0
  39. ralphx/static/assets/index-CY9s08ZB.js +251 -0
  40. ralphx/static/assets/index-CY9s08ZB.js.map +1 -0
  41. ralphx/static/index.html +14 -0
  42. {ralphx-0.2.2.dist-info → ralphx-0.3.5.dist-info}/METADATA +34 -12
  43. {ralphx-0.2.2.dist-info → ralphx-0.3.5.dist-info}/RECORD +45 -30
  44. {ralphx-0.2.2.dist-info → ralphx-0.3.5.dist-info}/WHEEL +0 -0
  45. {ralphx-0.2.2.dist-info → ralphx-0.3.5.dist-info}/entry_points.txt +0 -0
@@ -0,0 +1,795 @@
1
+ """Export/Import API routes for RalphX.
2
+
3
+ Provides endpoints for:
4
+ - Workflow export/import (single workflow)
5
+ - Project export/import (multiple workflows)
6
+ """
7
+
8
+ from typing import Optional
9
+
10
+ from fastapi import APIRouter, File, HTTPException, Query, UploadFile, status
11
+ from fastapi.responses import Response
12
+ from pydantic import BaseModel, Field
13
+
14
+ from ralphx.core.database import Database
15
+ from ralphx.core.project_db import ProjectDatabase
16
+ from ralphx.core.project_export import ProjectExporter, ProjectExportOptions
17
+ from ralphx.core.project_import import ProjectImporter, ProjectImportOptions
18
+ from ralphx.core.workflow_export import ExportOptions, WorkflowExporter
19
+ from ralphx.core.workflow_import import ConflictResolution, ImportOptions, WorkflowImporter, MAX_IMPORT_SIZE_MB
20
+
21
+
22
+ router = APIRouter()
23
+
24
+
25
+ # Maximum upload size in bytes (must match MAX_IMPORT_SIZE_MB from workflow_import)
26
+ MAX_UPLOAD_SIZE = MAX_IMPORT_SIZE_MB * 1024 * 1024
27
+
28
+
29
+ async def _read_upload_with_limit(file: UploadFile, max_size: int = MAX_UPLOAD_SIZE) -> bytes:
30
+ """Read uploaded file with size limit to prevent memory exhaustion.
31
+
32
+ Args:
33
+ file: The uploaded file.
34
+ max_size: Maximum allowed size in bytes.
35
+
36
+ Returns:
37
+ File content as bytes.
38
+
39
+ Raises:
40
+ HTTPException: If file exceeds size limit.
41
+ """
42
+ chunks = []
43
+ total_size = 0
44
+
45
+ # Read in chunks to avoid loading huge files all at once
46
+ chunk_size = 1024 * 1024 # 1MB chunks
47
+
48
+ while True:
49
+ chunk = await file.read(chunk_size)
50
+ if not chunk:
51
+ break
52
+
53
+ total_size += len(chunk)
54
+ if total_size > max_size:
55
+ raise HTTPException(
56
+ status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE,
57
+ detail=f"File size exceeds maximum allowed size of {MAX_IMPORT_SIZE_MB}MB",
58
+ )
59
+
60
+ chunks.append(chunk)
61
+
62
+ return b''.join(chunks)
63
+
64
+
65
+ # ============================================================================
66
+ # Request/Response Models
67
+ # ============================================================================
68
+
69
+
70
+ class ExportPreviewResponse(BaseModel):
71
+ """Response for export preview."""
72
+ workflow_name: str
73
+ workflow_id: str
74
+ steps_count: int
75
+ items_total: int
76
+ resources_count: int
77
+ has_planning_session: bool
78
+ runs_count: int
79
+ estimated_size_bytes: int
80
+ potential_secrets_detected: bool
81
+ warnings: list[str] = []
82
+
83
+
84
+ class WorkflowExportRequest(BaseModel):
85
+ """Request for workflow export."""
86
+ include_runs: bool = False
87
+ include_planning: bool = True
88
+ include_planning_messages: bool = False
89
+ strip_secrets: bool = False
90
+ as_template: bool = False
91
+
92
+
93
+ class StepPreviewResponse(BaseModel):
94
+ """Preview info for a single step."""
95
+ step_number: int
96
+ name: str
97
+ step_type: str
98
+ items_count: int
99
+
100
+
101
+ class ResourcePreviewResponse(BaseModel):
102
+ """Preview info for a single resource."""
103
+ id: int
104
+ name: str
105
+ resource_type: str
106
+
107
+
108
+ class ImportPreviewResponse(BaseModel):
109
+ """Response for import preview."""
110
+ workflow_name: str
111
+ workflow_id: str
112
+ exported_at: str
113
+ ralphx_version: str
114
+ schema_version: int
115
+ steps_count: int
116
+ items_count: int
117
+ resources_count: int
118
+ has_planning_session: bool
119
+ has_runs: bool
120
+ is_compatible: bool
121
+ compatibility_notes: list[str]
122
+ potential_secrets_detected: bool
123
+ # Detailed breakdown for selective import
124
+ steps: list[StepPreviewResponse] = []
125
+ resources: list[ResourcePreviewResponse] = []
126
+
127
+
128
+ class ConflictInfo(BaseModel):
129
+ """Information about a conflict."""
130
+ conflict_type: str
131
+ source_id: str
132
+ source_name: str
133
+ target_id: Optional[str] = None
134
+ target_name: Optional[str] = None
135
+ details: Optional[str] = None
136
+
137
+
138
+ class MergePreviewResponse(BaseModel):
139
+ """Response for merge preview."""
140
+ workflow_name: str
141
+ workflow_id: str
142
+ items_count: int
143
+ resources_count: int
144
+ conflicts: list[ConflictInfo]
145
+ is_compatible: bool
146
+ compatibility_notes: list[str]
147
+
148
+
149
+ class WorkflowImportRequest(BaseModel):
150
+ """Request for workflow import."""
151
+ conflict_resolution: str = Field(
152
+ default="rename",
153
+ pattern=r"^(skip|rename|overwrite)$",
154
+ )
155
+ import_items: bool = True
156
+ import_resources: bool = True
157
+ import_planning: bool = True
158
+ import_runs: bool = False
159
+ selected_step_ids: Optional[list[int]] = None
160
+ selected_resource_ids: Optional[list[int]] = None
161
+
162
+
163
+ class MergeRequest(BaseModel):
164
+ """Request for merging into existing workflow."""
165
+ conflict_resolution: str = Field(
166
+ default="rename",
167
+ pattern=r"^(skip|rename|overwrite)$",
168
+ )
169
+ import_items: bool = True
170
+ import_resources: bool = True
171
+ import_planning: bool = True
172
+
173
+
174
+ class ImportResultResponse(BaseModel):
175
+ """Response for import result."""
176
+ success: bool
177
+ workflow_id: str
178
+ workflow_name: str
179
+ steps_created: int
180
+ items_imported: int
181
+ items_renamed: int
182
+ items_skipped: int
183
+ resources_created: int
184
+ resources_renamed: int
185
+ planning_sessions_imported: int
186
+ runs_imported: int
187
+ warnings: list[str]
188
+
189
+
190
+ # Project export/import models
191
+
192
+
193
+ class WorkflowSummaryResponse(BaseModel):
194
+ """Summary of a workflow in project export."""
195
+ id: str
196
+ name: str
197
+ steps_count: int
198
+ items_count: int
199
+ resources_count: int
200
+
201
+
202
+ class ProjectExportPreviewResponse(BaseModel):
203
+ """Response for project export preview."""
204
+ project_name: str
205
+ project_slug: str
206
+ workflows: list[WorkflowSummaryResponse]
207
+ total_items: int
208
+ total_resources: int
209
+ estimated_size_bytes: int
210
+ potential_secrets_detected: bool
211
+
212
+
213
+ class ProjectExportRequest(BaseModel):
214
+ """Request for project export."""
215
+ workflow_ids: Optional[list[str]] = None
216
+ include_runs: bool = False
217
+ include_planning: bool = True
218
+ include_planning_messages: bool = False
219
+ strip_secrets: bool = False
220
+ include_project_resources: bool = True
221
+
222
+
223
+ class ProjectImportPreviewResponse(BaseModel):
224
+ """Response for project import preview."""
225
+ is_project_export: bool
226
+ project_name: Optional[str] = None
227
+ project_slug: Optional[str] = None
228
+ workflows: list[WorkflowSummaryResponse]
229
+ total_items: int
230
+ total_resources: int
231
+ shared_resources_count: int
232
+ is_compatible: bool
233
+ compatibility_notes: list[str]
234
+ exported_at: Optional[str] = None
235
+ ralphx_version: Optional[str] = None
236
+ schema_version: int
237
+
238
+
239
+ class ProjectImportRequest(BaseModel):
240
+ """Request for project import."""
241
+ selected_workflow_ids: Optional[list[str]] = None
242
+ import_shared_resources: bool = True
243
+ conflict_resolution: str = Field(
244
+ default="rename",
245
+ pattern=r"^(skip|rename|overwrite)$",
246
+ )
247
+
248
+
249
+ class WorkflowImportResultResponse(BaseModel):
250
+ """Result for a single workflow import."""
251
+ success: bool
252
+ workflow_id: str
253
+ workflow_name: str
254
+ steps_created: int
255
+ items_imported: int
256
+ items_renamed: int
257
+ items_skipped: int
258
+ resources_created: int
259
+ warnings: list[str]
260
+
261
+
262
+ class ProjectImportResultResponse(BaseModel):
263
+ """Response for project import result."""
264
+ success: bool
265
+ workflows_imported: int
266
+ workflow_results: list[WorkflowImportResultResponse]
267
+ shared_resources_imported: int
268
+ warnings: list[str]
269
+
270
+
271
+ # ============================================================================
272
+ # Helper Functions
273
+ # ============================================================================
274
+
275
+
276
+ def _get_project_db(slug: str) -> ProjectDatabase:
277
+ """Get project database for a project slug."""
278
+ db = Database()
279
+ project = db.get_project(slug)
280
+ if not project:
281
+ raise HTTPException(
282
+ status_code=status.HTTP_404_NOT_FOUND,
283
+ detail=f"Project '{slug}' not found",
284
+ )
285
+ return ProjectDatabase(project["path"])
286
+
287
+
288
+ def _get_project_and_db(slug: str) -> tuple[dict, ProjectDatabase]:
289
+ """Get project info and database."""
290
+ db = Database()
291
+ project = db.get_project(slug)
292
+ if not project:
293
+ raise HTTPException(
294
+ status_code=status.HTTP_404_NOT_FOUND,
295
+ detail=f"Project '{slug}' not found",
296
+ )
297
+ return project, ProjectDatabase(project["path"])
298
+
299
+
300
+ # ============================================================================
301
+ # Workflow Export Endpoints
302
+ # ============================================================================
303
+
304
+
305
+ @router.get("/workflows/{workflow_id}/export/preview", response_model=ExportPreviewResponse)
306
+ async def preview_workflow_export(slug: str, workflow_id: str):
307
+ """Preview what will be exported from a workflow.
308
+
309
+ Returns counts of steps, items, resources, and estimated size.
310
+ Also scans for potential secrets and warns if found.
311
+ """
312
+ pdb = _get_project_db(slug)
313
+
314
+ exporter = WorkflowExporter(pdb)
315
+ try:
316
+ preview = exporter.get_preview(workflow_id)
317
+ except ValueError as e:
318
+ raise HTTPException(
319
+ status_code=status.HTTP_404_NOT_FOUND,
320
+ detail=str(e),
321
+ )
322
+
323
+ return ExportPreviewResponse(
324
+ workflow_name=preview.workflow_name,
325
+ workflow_id=preview.workflow_id,
326
+ steps_count=preview.steps_count,
327
+ items_total=preview.items_total,
328
+ resources_count=preview.resources_count,
329
+ has_planning_session=preview.has_planning_session,
330
+ runs_count=preview.runs_count,
331
+ estimated_size_bytes=preview.estimated_size_bytes,
332
+ potential_secrets_detected=len(preview.potential_secrets) > 0,
333
+ warnings=preview.warnings,
334
+ )
335
+
336
+
337
+ @router.post("/workflows/{workflow_id}/export")
338
+ async def export_workflow(
339
+ slug: str,
340
+ workflow_id: str,
341
+ request: WorkflowExportRequest,
342
+ ):
343
+ """Export a workflow to a ZIP file.
344
+
345
+ Returns the ZIP file as a download.
346
+ """
347
+ pdb = _get_project_db(slug)
348
+
349
+ exporter = WorkflowExporter(pdb)
350
+
351
+ options = ExportOptions(
352
+ include_runs=request.include_runs,
353
+ include_planning=request.include_planning,
354
+ include_planning_messages=request.include_planning_messages,
355
+ strip_secrets=request.strip_secrets,
356
+ as_template=request.as_template,
357
+ )
358
+
359
+ try:
360
+ zip_bytes, filename = exporter.export_workflow(workflow_id, options)
361
+ except ValueError as e:
362
+ raise HTTPException(
363
+ status_code=status.HTTP_400_BAD_REQUEST,
364
+ detail=str(e),
365
+ )
366
+
367
+ return Response(
368
+ content=zip_bytes,
369
+ media_type="application/zip",
370
+ headers={
371
+ "Content-Disposition": f'attachment; filename="{filename}"',
372
+ },
373
+ )
374
+
375
+
376
+ # ============================================================================
377
+ # Workflow Import Endpoints
378
+ # ============================================================================
379
+
380
+
381
+ @router.post("/workflows/import/preview", response_model=ImportPreviewResponse)
382
+ async def preview_workflow_import(
383
+ slug: str,
384
+ file: UploadFile = File(...),
385
+ ):
386
+ """Preview what will be imported from a ZIP file.
387
+
388
+ Upload the ZIP file to see its contents and compatibility.
389
+ """
390
+ pdb = _get_project_db(slug)
391
+
392
+ # Read file content with size limit
393
+ zip_data = await _read_upload_with_limit(file)
394
+
395
+ importer = WorkflowImporter(pdb)
396
+ try:
397
+ preview = importer.get_preview(zip_data)
398
+ except ValueError as e:
399
+ raise HTTPException(
400
+ status_code=status.HTTP_400_BAD_REQUEST,
401
+ detail=str(e),
402
+ )
403
+
404
+ return ImportPreviewResponse(
405
+ workflow_name=preview.workflow_name,
406
+ workflow_id=preview.workflow_id,
407
+ exported_at=preview.exported_at,
408
+ ralphx_version=preview.ralphx_version,
409
+ schema_version=preview.schema_version,
410
+ steps_count=preview.steps_count,
411
+ items_count=preview.items_count,
412
+ resources_count=preview.resources_count,
413
+ has_planning_session=preview.has_planning_session,
414
+ has_runs=preview.has_runs,
415
+ is_compatible=preview.is_compatible,
416
+ compatibility_notes=preview.compatibility_notes,
417
+ potential_secrets_detected=preview.potential_secrets_detected,
418
+ steps=[
419
+ StepPreviewResponse(
420
+ step_number=s.step_number,
421
+ name=s.name,
422
+ step_type=s.step_type,
423
+ items_count=s.items_count,
424
+ )
425
+ for s in preview.steps
426
+ ],
427
+ resources=[
428
+ ResourcePreviewResponse(
429
+ id=r.id,
430
+ name=r.name,
431
+ resource_type=r.resource_type,
432
+ )
433
+ for r in preview.resources
434
+ ],
435
+ )
436
+
437
+
438
+ @router.post("/workflows/import", response_model=ImportResultResponse)
439
+ async def import_workflow(
440
+ slug: str,
441
+ file: UploadFile = File(...),
442
+ conflict_resolution: str = Query(default="rename", pattern=r"^(skip|rename|overwrite)$"),
443
+ import_items: bool = Query(default=True),
444
+ import_resources: bool = Query(default=True),
445
+ import_planning: bool = Query(default=True),
446
+ import_runs: bool = Query(default=False),
447
+ selected_steps: Optional[str] = Query(default=None, description="Comma-separated step numbers to import"),
448
+ selected_resource_ids: Optional[str] = Query(default=None, description="Comma-separated resource IDs to import"),
449
+ ):
450
+ """Import a workflow from a ZIP file.
451
+
452
+ Creates a new workflow with all IDs regenerated.
453
+ Optionally filter to specific steps and resources.
454
+ """
455
+ pdb = _get_project_db(slug)
456
+
457
+ zip_data = await _read_upload_with_limit(file)
458
+
459
+ importer = WorkflowImporter(pdb)
460
+
461
+ # Parse comma-separated values if provided
462
+ step_ids = None
463
+ if selected_steps:
464
+ try:
465
+ step_ids = [int(s.strip()) for s in selected_steps.split(',') if s.strip()]
466
+ except ValueError:
467
+ raise HTTPException(
468
+ status_code=status.HTTP_400_BAD_REQUEST,
469
+ detail="selected_steps must be comma-separated integers",
470
+ )
471
+
472
+ resource_ids = None
473
+ if selected_resource_ids:
474
+ try:
475
+ resource_ids = [int(r.strip()) for r in selected_resource_ids.split(',') if r.strip()]
476
+ except ValueError:
477
+ raise HTTPException(
478
+ status_code=status.HTTP_400_BAD_REQUEST,
479
+ detail="selected_resource_ids must be comma-separated integers",
480
+ )
481
+
482
+ options = ImportOptions(
483
+ conflict_resolution=ConflictResolution(conflict_resolution),
484
+ import_items=import_items,
485
+ import_resources=import_resources,
486
+ import_planning=import_planning,
487
+ import_runs=import_runs,
488
+ selected_step_ids=step_ids,
489
+ selected_resource_ids=resource_ids,
490
+ )
491
+
492
+ try:
493
+ result = importer.import_workflow(zip_data, options)
494
+ except ValueError as e:
495
+ raise HTTPException(
496
+ status_code=status.HTTP_400_BAD_REQUEST,
497
+ detail=str(e),
498
+ )
499
+
500
+ return ImportResultResponse(
501
+ success=result.success,
502
+ workflow_id=result.workflow_id,
503
+ workflow_name=result.workflow_name,
504
+ steps_created=result.steps_created,
505
+ items_imported=result.items_imported,
506
+ items_renamed=result.items_renamed,
507
+ items_skipped=result.items_skipped,
508
+ resources_created=result.resources_created,
509
+ resources_renamed=result.resources_renamed,
510
+ planning_sessions_imported=result.planning_sessions_imported,
511
+ runs_imported=result.runs_imported,
512
+ warnings=result.warnings,
513
+ )
514
+
515
+
516
+ @router.post("/workflows/{workflow_id}/merge/preview", response_model=MergePreviewResponse)
517
+ async def preview_workflow_merge(
518
+ slug: str,
519
+ workflow_id: str,
520
+ file: UploadFile = File(...),
521
+ ):
522
+ """Preview merging imported content into an existing workflow.
523
+
524
+ Shows detected conflicts and resolution options.
525
+ """
526
+ pdb = _get_project_db(slug)
527
+
528
+ zip_data = await _read_upload_with_limit(file)
529
+
530
+ importer = WorkflowImporter(pdb)
531
+ try:
532
+ preview = importer.get_merge_preview(zip_data, workflow_id)
533
+ except ValueError as e:
534
+ raise HTTPException(
535
+ status_code=status.HTTP_400_BAD_REQUEST,
536
+ detail=str(e),
537
+ )
538
+
539
+ conflicts = [
540
+ ConflictInfo(
541
+ conflict_type=c.conflict_type.value,
542
+ source_id=c.source_id,
543
+ source_name=c.source_name,
544
+ target_id=c.target_id,
545
+ target_name=c.target_name,
546
+ details=c.details,
547
+ )
548
+ for c in preview.conflicts
549
+ ]
550
+
551
+ return MergePreviewResponse(
552
+ workflow_name=preview.workflow_name,
553
+ workflow_id=preview.workflow_id,
554
+ items_count=preview.items_count,
555
+ resources_count=preview.resources_count,
556
+ conflicts=conflicts,
557
+ is_compatible=preview.is_compatible,
558
+ compatibility_notes=preview.compatibility_notes,
559
+ )
560
+
561
+
562
+ @router.post("/workflows/{workflow_id}/merge", response_model=ImportResultResponse)
563
+ async def merge_into_workflow(
564
+ slug: str,
565
+ workflow_id: str,
566
+ file: UploadFile = File(...),
567
+ conflict_resolution: str = Query(default="rename", pattern=r"^(skip|rename|overwrite)$"),
568
+ import_items: bool = Query(default=True),
569
+ import_resources: bool = Query(default=True),
570
+ import_planning: bool = Query(default=True),
571
+ ):
572
+ """Merge imported content into an existing workflow.
573
+
574
+ Uses specified conflict resolution strategy for conflicts.
575
+ """
576
+ pdb = _get_project_db(slug)
577
+
578
+ zip_data = await _read_upload_with_limit(file)
579
+
580
+ importer = WorkflowImporter(pdb)
581
+
582
+ options = ImportOptions(
583
+ conflict_resolution=ConflictResolution(conflict_resolution),
584
+ import_items=import_items,
585
+ import_resources=import_resources,
586
+ import_planning=import_planning,
587
+ )
588
+
589
+ try:
590
+ result = importer.merge_into_workflow(zip_data, workflow_id, options)
591
+ except ValueError as e:
592
+ raise HTTPException(
593
+ status_code=status.HTTP_400_BAD_REQUEST,
594
+ detail=str(e),
595
+ )
596
+
597
+ return ImportResultResponse(
598
+ success=result.success,
599
+ workflow_id=result.workflow_id,
600
+ workflow_name=result.workflow_name,
601
+ steps_created=result.steps_created,
602
+ items_imported=result.items_imported,
603
+ items_renamed=result.items_renamed,
604
+ items_skipped=result.items_skipped,
605
+ resources_created=result.resources_created,
606
+ resources_renamed=result.resources_renamed,
607
+ planning_sessions_imported=result.planning_sessions_imported,
608
+ runs_imported=result.runs_imported,
609
+ warnings=result.warnings,
610
+ )
611
+
612
+
613
+ # ============================================================================
614
+ # Project Export Endpoints
615
+ # ============================================================================
616
+
617
+
618
+ @router.get("/export/preview", response_model=ProjectExportPreviewResponse)
619
+ async def preview_project_export(
620
+ slug: str,
621
+ workflow_ids: Optional[list[str]] = Query(default=None),
622
+ ):
623
+ """Preview what will be exported from the project.
624
+
625
+ Returns list of workflows with their counts.
626
+ """
627
+ project, pdb = _get_project_and_db(slug)
628
+
629
+ options = ProjectExportOptions(workflow_ids=workflow_ids)
630
+ exporter = ProjectExporter(pdb, project)
631
+
632
+ preview = exporter.get_preview(options)
633
+
634
+ return ProjectExportPreviewResponse(
635
+ project_name=preview.project_name,
636
+ project_slug=preview.project_slug,
637
+ workflows=[
638
+ WorkflowSummaryResponse(
639
+ id=w.id,
640
+ name=w.name,
641
+ steps_count=w.steps_count,
642
+ items_count=w.items_count,
643
+ resources_count=w.resources_count,
644
+ )
645
+ for w in preview.workflows
646
+ ],
647
+ total_items=preview.total_items,
648
+ total_resources=preview.total_resources,
649
+ estimated_size_bytes=preview.estimated_size_bytes,
650
+ potential_secrets_detected=len(preview.potential_secrets) > 0,
651
+ )
652
+
653
+
654
+ @router.post("/export")
655
+ async def export_project(
656
+ slug: str,
657
+ request: ProjectExportRequest,
658
+ ):
659
+ """Export the project (or selected workflows) to a ZIP file.
660
+
661
+ Returns the ZIP file as a download.
662
+ """
663
+ project, pdb = _get_project_and_db(slug)
664
+
665
+ options = ProjectExportOptions(
666
+ workflow_ids=request.workflow_ids,
667
+ include_runs=request.include_runs,
668
+ include_planning=request.include_planning,
669
+ include_planning_messages=request.include_planning_messages,
670
+ strip_secrets=request.strip_secrets,
671
+ include_project_resources=request.include_project_resources,
672
+ )
673
+
674
+ exporter = ProjectExporter(pdb, project)
675
+
676
+ try:
677
+ zip_bytes, filename = exporter.export_project(options)
678
+ except ValueError as e:
679
+ raise HTTPException(
680
+ status_code=status.HTTP_400_BAD_REQUEST,
681
+ detail=str(e),
682
+ )
683
+
684
+ return Response(
685
+ content=zip_bytes,
686
+ media_type="application/zip",
687
+ headers={
688
+ "Content-Disposition": f'attachment; filename="{filename}"',
689
+ },
690
+ )
691
+
692
+
693
+ # ============================================================================
694
+ # Project Import Endpoints
695
+ # ============================================================================
696
+
697
+
698
+ @router.post("/import/preview", response_model=ProjectImportPreviewResponse)
699
+ async def preview_project_import(
700
+ slug: str,
701
+ file: UploadFile = File(...),
702
+ ):
703
+ """Preview what will be imported from a ZIP file.
704
+
705
+ Auto-detects whether this is a project export or workflow export.
706
+ """
707
+ pdb = _get_project_db(slug)
708
+
709
+ zip_data = await _read_upload_with_limit(file)
710
+
711
+ importer = ProjectImporter(pdb)
712
+ try:
713
+ preview = importer.get_preview(zip_data)
714
+ except ValueError as e:
715
+ raise HTTPException(
716
+ status_code=status.HTTP_400_BAD_REQUEST,
717
+ detail=str(e),
718
+ )
719
+
720
+ return ProjectImportPreviewResponse(
721
+ is_project_export=preview.is_project_export,
722
+ project_name=preview.project_name,
723
+ project_slug=preview.project_slug,
724
+ workflows=[
725
+ WorkflowSummaryResponse(
726
+ id=w.id,
727
+ name=w.name,
728
+ steps_count=w.steps_count,
729
+ items_count=w.items_count,
730
+ resources_count=w.resources_count,
731
+ )
732
+ for w in preview.workflows
733
+ ],
734
+ total_items=preview.total_items,
735
+ total_resources=preview.total_resources,
736
+ shared_resources_count=preview.shared_resources_count,
737
+ is_compatible=preview.is_compatible,
738
+ compatibility_notes=preview.compatibility_notes,
739
+ exported_at=preview.exported_at,
740
+ ralphx_version=preview.ralphx_version,
741
+ schema_version=preview.schema_version,
742
+ )
743
+
744
+
745
+ @router.post("/import", response_model=ProjectImportResultResponse)
746
+ async def import_project(
747
+ slug: str,
748
+ file: UploadFile = File(...),
749
+ selected_workflow_ids: Optional[list[str]] = Query(default=None),
750
+ import_shared_resources: bool = Query(default=True),
751
+ conflict_resolution: str = Query(default="rename", pattern=r"^(skip|rename|overwrite)$"),
752
+ ):
753
+ """Import workflows from a ZIP file.
754
+
755
+ Supports both project exports (multiple workflows) and single workflow exports.
756
+ """
757
+ pdb = _get_project_db(slug)
758
+
759
+ zip_data = await _read_upload_with_limit(file)
760
+
761
+ options = ProjectImportOptions(
762
+ selected_workflow_ids=selected_workflow_ids,
763
+ import_shared_resources=import_shared_resources,
764
+ conflict_resolution=ConflictResolution(conflict_resolution),
765
+ )
766
+
767
+ importer = ProjectImporter(pdb)
768
+ try:
769
+ result = importer.import_project(zip_data, options)
770
+ except ValueError as e:
771
+ raise HTTPException(
772
+ status_code=status.HTTP_400_BAD_REQUEST,
773
+ detail=str(e),
774
+ )
775
+
776
+ return ProjectImportResultResponse(
777
+ success=result.success,
778
+ workflows_imported=result.workflows_imported,
779
+ workflow_results=[
780
+ WorkflowImportResultResponse(
781
+ success=r.success,
782
+ workflow_id=r.workflow_id,
783
+ workflow_name=r.workflow_name,
784
+ steps_created=r.steps_created,
785
+ items_imported=r.items_imported,
786
+ items_renamed=r.items_renamed,
787
+ items_skipped=r.items_skipped,
788
+ resources_created=r.resources_created,
789
+ warnings=r.warnings,
790
+ )
791
+ for r in result.workflow_results
792
+ ],
793
+ shared_resources_imported=result.shared_resources_imported,
794
+ warnings=result.warnings,
795
+ )