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.
- ralphx/__init__.py +1 -1
- ralphx/api/main.py +9 -1
- ralphx/api/routes/auth.py +730 -65
- ralphx/api/routes/config.py +3 -56
- ralphx/api/routes/export_import.py +795 -0
- ralphx/api/routes/loops.py +4 -4
- ralphx/api/routes/planning.py +19 -5
- ralphx/api/routes/projects.py +84 -2
- ralphx/api/routes/templates.py +115 -2
- ralphx/api/routes/workflows.py +22 -22
- ralphx/cli.py +21 -6
- ralphx/core/auth.py +346 -171
- ralphx/core/database.py +615 -167
- ralphx/core/executor.py +0 -3
- ralphx/core/loop.py +15 -2
- ralphx/core/loop_templates.py +69 -3
- ralphx/core/planning_service.py +109 -21
- ralphx/core/preview.py +9 -25
- ralphx/core/project_db.py +175 -75
- ralphx/core/project_export.py +469 -0
- ralphx/core/project_import.py +670 -0
- ralphx/core/sample_project.py +430 -0
- ralphx/core/templates.py +46 -9
- ralphx/core/workflow_executor.py +35 -5
- ralphx/core/workflow_export.py +606 -0
- ralphx/core/workflow_import.py +1149 -0
- ralphx/examples/sample_project/DESIGN.md +345 -0
- ralphx/examples/sample_project/README.md +37 -0
- ralphx/examples/sample_project/guardrails.md +57 -0
- ralphx/examples/sample_project/stories.jsonl +10 -0
- ralphx/mcp/__init__.py +6 -2
- ralphx/mcp/registry.py +3 -3
- ralphx/mcp/server.py +99 -29
- ralphx/mcp/tools/__init__.py +4 -0
- ralphx/mcp/tools/help.py +204 -0
- ralphx/mcp/tools/workflows.py +114 -32
- ralphx/mcp_server.py +6 -2
- ralphx/static/assets/index-0ovNnfOq.css +1 -0
- ralphx/static/assets/index-CY9s08ZB.js +251 -0
- ralphx/static/assets/index-CY9s08ZB.js.map +1 -0
- ralphx/static/index.html +14 -0
- {ralphx-0.2.2.dist-info → ralphx-0.3.5.dist-info}/METADATA +34 -12
- {ralphx-0.2.2.dist-info → ralphx-0.3.5.dist-info}/RECORD +45 -30
- {ralphx-0.2.2.dist-info → ralphx-0.3.5.dist-info}/WHEEL +0 -0
- {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
|
+
)
|