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,1149 @@
1
+ """Workflow import functionality for RalphX.
2
+
3
+ Enables importing workflows from ZIP archives into projects,
4
+ with support for selective import, conflict resolution, and ID regeneration.
5
+ """
6
+
7
+ import hashlib
8
+ import io
9
+ import json
10
+ import re
11
+ import uuid
12
+ import zipfile
13
+ from dataclasses import dataclass, field
14
+ from datetime import datetime
15
+ from enum import Enum
16
+ from pathlib import Path
17
+ from typing import Any, Optional
18
+
19
+ from ralphx.core.project_db import PROJECT_SCHEMA_VERSION, ProjectDatabase
20
+ from ralphx.core.workflow_export import EXPORT_FORMAT_NAME, EXPORT_FORMAT_VERSION
21
+
22
+
23
+ # Security limits
24
+ MAX_IMPORT_SIZE_MB = 500
25
+ MAX_COMPRESSION_RATIO = 100 # Max uncompressed/compressed ratio
26
+ MAX_FILES_IN_ARCHIVE = 10000
27
+
28
+
29
+ def _compare_versions(v1: str, v2: str) -> int:
30
+ """Compare two semantic version strings.
31
+
32
+ Args:
33
+ v1: First version string (e.g., "1.0", "1.10", "2.0.1").
34
+ v2: Second version string.
35
+
36
+ Returns:
37
+ -1 if v1 < v2, 0 if v1 == v2, 1 if v1 > v2.
38
+ """
39
+ def parse_version(v: str) -> list[int]:
40
+ try:
41
+ return [int(x) for x in v.split('.')]
42
+ except ValueError:
43
+ return [0]
44
+
45
+ parts1 = parse_version(v1)
46
+ parts2 = parse_version(v2)
47
+
48
+ # Pad shorter version with zeros
49
+ max_len = max(len(parts1), len(parts2))
50
+ parts1.extend([0] * (max_len - len(parts1)))
51
+ parts2.extend([0] * (max_len - len(parts2)))
52
+
53
+ for p1, p2 in zip(parts1, parts2):
54
+ if p1 < p2:
55
+ return -1
56
+ if p1 > p2:
57
+ return 1
58
+ return 0
59
+
60
+
61
+ class ConflictResolution(str, Enum):
62
+ """How to resolve conflicts during import."""
63
+ SKIP = "skip" # Skip conflicting items
64
+ RENAME = "rename" # Auto-rename with suffix
65
+ OVERWRITE = "overwrite" # Overwrite existing
66
+
67
+
68
+ class ConflictType(str, Enum):
69
+ """Types of conflicts that can occur."""
70
+ ITEM_ID = "item_id"
71
+ RESOURCE_NAME = "resource_name"
72
+ STEP_NUMBER = "step_number"
73
+ MISSING_DEPENDENCY = "missing_dependency"
74
+
75
+
76
+ @dataclass
77
+ class Conflict:
78
+ """A detected conflict during import preview."""
79
+ conflict_type: ConflictType
80
+ source_id: str
81
+ source_name: str
82
+ target_id: Optional[str] = None
83
+ target_name: Optional[str] = None
84
+ details: Optional[str] = None
85
+
86
+
87
+ @dataclass
88
+ class StepPreviewInfo:
89
+ """Preview info for a single step."""
90
+ step_number: int
91
+ name: str
92
+ step_type: str
93
+ items_count: int
94
+
95
+
96
+ @dataclass
97
+ class ResourcePreviewInfo:
98
+ """Preview info for a single resource."""
99
+ id: int
100
+ name: str
101
+ resource_type: str
102
+
103
+
104
+ @dataclass
105
+ class ImportPreview:
106
+ """Preview of what will be imported."""
107
+ # Basic info
108
+ workflow_name: str
109
+ workflow_id: str
110
+ exported_at: str
111
+ ralphx_version: str
112
+ schema_version: int
113
+
114
+ # Counts
115
+ steps_count: int
116
+ items_count: int
117
+ resources_count: int
118
+ has_planning_session: bool
119
+ has_runs: bool
120
+
121
+ # Compatibility
122
+ is_compatible: bool
123
+
124
+ # Optional fields (must come after required fields)
125
+ has_step_artifacts: bool = False # Whether export includes step artifacts
126
+ compatibility_notes: list[str] = field(default_factory=list)
127
+
128
+ # Detailed breakdown for selective import
129
+ steps: list[StepPreviewInfo] = field(default_factory=list)
130
+ resources: list[ResourcePreviewInfo] = field(default_factory=list)
131
+
132
+ # Conflicts (only for import into existing)
133
+ conflicts: list[Conflict] = field(default_factory=list)
134
+
135
+ # Security
136
+ potential_secrets_detected: bool = False
137
+
138
+
139
+ @dataclass
140
+ class ImportOptions:
141
+ """Options for import operation."""
142
+ conflict_resolution: ConflictResolution = ConflictResolution.RENAME
143
+ import_items: bool = True
144
+ import_resources: bool = True
145
+ import_planning: bool = True
146
+ import_runs: bool = False
147
+ import_step_artifacts: bool = False # Import step artifacts if present (off by default)
148
+ selected_step_ids: Optional[list[int]] = None # None = all steps (for new workflow import)
149
+ selected_resource_ids: Optional[list[int]] = None # None = all resources
150
+
151
+ # Merge-specific options
152
+ selected_source_step_ids: Optional[list[int]] = None # Which source steps to import items from
153
+ target_step_id: Optional[int] = None # Which target step to import items into (None = first step)
154
+ import_steps_to_target: bool = False # Add source steps to target workflow during merge
155
+
156
+
157
+ @dataclass
158
+ class ImportResult:
159
+ """Result of import operation."""
160
+ success: bool
161
+ workflow_id: str
162
+ workflow_name: str
163
+ steps_created: int
164
+ items_imported: int
165
+ items_renamed: int
166
+ items_skipped: int
167
+ resources_created: int
168
+ resources_renamed: int
169
+ planning_sessions_imported: int
170
+ runs_imported: int
171
+ id_mapping: dict[str, str] # old_id -> new_id
172
+ warnings: list[str] = field(default_factory=list)
173
+
174
+
175
+ class WorkflowImporter:
176
+ """Imports workflows from ZIP archives.
177
+
178
+ Supports:
179
+ - Full import (create new workflow)
180
+ - Selective import (pick components)
181
+ - Import into existing workflow (merge)
182
+ """
183
+
184
+ def __init__(self, project_db: ProjectDatabase):
185
+ """Initialize importer.
186
+
187
+ Args:
188
+ project_db: ProjectDatabase instance for the project.
189
+ """
190
+ self.db = project_db
191
+
192
+ def get_preview(self, zip_data: bytes) -> ImportPreview:
193
+ """Get a preview of what will be imported from the ZIP.
194
+
195
+ Args:
196
+ zip_data: ZIP file content as bytes.
197
+
198
+ Returns:
199
+ ImportPreview with contents and compatibility info.
200
+
201
+ Raises:
202
+ ValueError: If archive is invalid or fails security checks.
203
+ """
204
+ # Validate archive
205
+ self._validate_archive(zip_data)
206
+
207
+ with zipfile.ZipFile(io.BytesIO(zip_data), 'r') as zf:
208
+ # Read and parse manifest
209
+ manifest = self._read_manifest(zf)
210
+
211
+ # Check compatibility
212
+ is_compatible, notes = self._check_compatibility(manifest)
213
+
214
+ # Read workflow info
215
+ workflow_data = self._read_workflow(zf)
216
+
217
+ # Detect if step artifacts are present
218
+ # Check manifest first (preferred), then check actual step data
219
+ has_step_artifacts = manifest.get('export_options', {}).get('include_step_artifacts', False)
220
+ if not has_step_artifacts:
221
+ # Also check actual workflow data for any non-null artifacts
222
+ for step in workflow_data.get('steps', []):
223
+ if step.get('artifacts'):
224
+ has_step_artifacts = True
225
+ break
226
+
227
+ # Build detailed step info with item counts per step
228
+ steps_preview: list[StepPreviewInfo] = []
229
+ items_data = self._read_items(zf)
230
+ step_item_counts: dict[int, int] = {}
231
+ for item in items_data:
232
+ step_id = item.get('source_step_id')
233
+ if step_id is not None:
234
+ step_item_counts[step_id] = step_item_counts.get(step_id, 0) + 1
235
+
236
+ for step in workflow_data.get('steps', []):
237
+ step_id = step.get('id')
238
+ steps_preview.append(StepPreviewInfo(
239
+ step_number=step.get('step_number', 0),
240
+ name=step.get('name', 'Unknown'),
241
+ step_type=step.get('step_type', 'interactive'),
242
+ items_count=step_item_counts.get(step_id, 0),
243
+ ))
244
+
245
+ # Build detailed resource info
246
+ resources_preview: list[ResourcePreviewInfo] = []
247
+ resources_data = self._read_resources(zf)
248
+ for i, resource in enumerate(resources_data):
249
+ resources_preview.append(ResourcePreviewInfo(
250
+ id=resource.get('id', i),
251
+ name=resource.get('name', 'Unknown'),
252
+ resource_type=resource.get('resource_type', 'custom'),
253
+ ))
254
+
255
+ return ImportPreview(
256
+ workflow_name=manifest['workflow']['name'],
257
+ workflow_id=manifest['workflow']['id'],
258
+ exported_at=manifest['exported_at'],
259
+ ralphx_version=manifest.get('ralphx_version', 'unknown'),
260
+ schema_version=manifest.get('schema_version', 0),
261
+ steps_count=manifest['contents']['steps'],
262
+ items_count=manifest['contents']['items_total'],
263
+ resources_count=manifest['contents']['resources'],
264
+ has_planning_session=manifest['contents']['has_planning_session'],
265
+ has_runs=manifest['contents'].get('has_runs', False),
266
+ has_step_artifacts=has_step_artifacts,
267
+ is_compatible=is_compatible,
268
+ compatibility_notes=notes,
269
+ steps=steps_preview,
270
+ resources=resources_preview,
271
+ potential_secrets_detected=manifest.get('security', {}).get('potential_secrets_detected', False),
272
+ )
273
+
274
+ def get_merge_preview(
275
+ self,
276
+ zip_data: bytes,
277
+ target_workflow_id: str,
278
+ ) -> ImportPreview:
279
+ """Get a preview for merging into an existing workflow.
280
+
281
+ Args:
282
+ zip_data: ZIP file content as bytes.
283
+ target_workflow_id: ID of workflow to merge into.
284
+
285
+ Returns:
286
+ ImportPreview with detected conflicts.
287
+
288
+ Raises:
289
+ ValueError: If archive is invalid or target workflow not found.
290
+ """
291
+ preview = self.get_preview(zip_data)
292
+
293
+ # Get target workflow
294
+ target_workflow = self.db.get_workflow(target_workflow_id)
295
+ if not target_workflow:
296
+ raise ValueError(f"Target workflow '{target_workflow_id}' not found")
297
+
298
+ # Detect conflicts
299
+ conflicts = self._detect_conflicts(zip_data, target_workflow_id)
300
+ preview.conflicts = conflicts
301
+
302
+ return preview
303
+
304
+ def import_workflow(
305
+ self,
306
+ zip_data: bytes,
307
+ options: Optional[ImportOptions] = None,
308
+ ) -> ImportResult:
309
+ """Import a workflow from ZIP as a new workflow.
310
+
311
+ IDs are always regenerated to prevent collisions.
312
+
313
+ Args:
314
+ zip_data: ZIP file content as bytes.
315
+ options: Import options.
316
+
317
+ Returns:
318
+ ImportResult with details of what was imported.
319
+
320
+ Raises:
321
+ ValueError: If import fails validation.
322
+ """
323
+ if options is None:
324
+ options = ImportOptions()
325
+
326
+ # Validate archive
327
+ self._validate_archive(zip_data)
328
+
329
+ with zipfile.ZipFile(io.BytesIO(zip_data), 'r') as zf:
330
+ # Read all data
331
+ manifest = self._read_manifest(zf)
332
+ workflow_data = self._read_workflow(zf)
333
+ items_data = self._read_items(zf)
334
+ resources_data = self._read_resources(zf)
335
+ step_resources_data = self._read_step_resources(zf)
336
+ planning_data = self._read_planning(zf) if options.import_planning else []
337
+ runs_data = self._read_runs(zf) if options.import_runs else []
338
+
339
+ # Check compatibility
340
+ is_compatible, notes = self._check_compatibility(manifest)
341
+ if not is_compatible:
342
+ raise ValueError(f"Import not compatible: {'; '.join(notes)}")
343
+
344
+ # Generate new IDs
345
+ id_mapping = self._generate_id_mapping(workflow_data, items_data, resources_data)
346
+
347
+ # Create workflow in a transaction
348
+ result = self._execute_import(
349
+ workflow_data,
350
+ items_data,
351
+ resources_data,
352
+ step_resources_data,
353
+ planning_data,
354
+ runs_data,
355
+ id_mapping,
356
+ options,
357
+ manifest,
358
+ )
359
+
360
+ return result
361
+
362
+ def merge_into_workflow(
363
+ self,
364
+ zip_data: bytes,
365
+ target_workflow_id: str,
366
+ options: Optional[ImportOptions] = None,
367
+ ) -> ImportResult:
368
+ """Merge imported data into an existing workflow.
369
+
370
+ Args:
371
+ zip_data: ZIP file content as bytes.
372
+ target_workflow_id: ID of workflow to merge into.
373
+ options: Import options with conflict resolution.
374
+
375
+ Returns:
376
+ ImportResult with details of what was merged.
377
+
378
+ Raises:
379
+ ValueError: If merge fails.
380
+ """
381
+ if options is None:
382
+ options = ImportOptions()
383
+
384
+ # Validate archive and target
385
+ self._validate_archive(zip_data)
386
+
387
+ target_workflow = self.db.get_workflow(target_workflow_id)
388
+ if not target_workflow:
389
+ raise ValueError(f"Target workflow '{target_workflow_id}' not found")
390
+
391
+ # Check for active runs
392
+ active_runs = self.db.list_runs(workflow_id=target_workflow_id, status='running')
393
+ if active_runs:
394
+ raise ValueError("Cannot merge into workflow with active runs")
395
+
396
+ with zipfile.ZipFile(io.BytesIO(zip_data), 'r') as zf:
397
+ manifest = self._read_manifest(zf)
398
+ workflow_data = self._read_workflow(zf) if options.import_steps_to_target else None
399
+ items_data = self._read_items(zf)
400
+ resources_data = self._read_resources(zf)
401
+ planning_data = self._read_planning(zf) if options.import_planning else []
402
+
403
+ # Execute merge
404
+ result = self._execute_merge(
405
+ target_workflow_id,
406
+ target_workflow,
407
+ workflow_data,
408
+ items_data,
409
+ resources_data,
410
+ planning_data,
411
+ options,
412
+ manifest,
413
+ )
414
+
415
+ return result
416
+
417
+ def _validate_archive(self, zip_data: bytes) -> None:
418
+ """Validate ZIP archive for security and format.
419
+
420
+ Raises:
421
+ ValueError: If validation fails.
422
+ """
423
+ # Size check
424
+ if len(zip_data) > MAX_IMPORT_SIZE_MB * 1024 * 1024:
425
+ raise ValueError(f"Archive exceeds maximum size of {MAX_IMPORT_SIZE_MB}MB")
426
+
427
+ # Check it's a valid ZIP
428
+ if not zipfile.is_zipfile(io.BytesIO(zip_data)):
429
+ raise ValueError("Invalid ZIP file")
430
+
431
+ with zipfile.ZipFile(io.BytesIO(zip_data), 'r') as zf:
432
+ # Check file count
433
+ if len(zf.namelist()) > MAX_FILES_IN_ARCHIVE:
434
+ raise ValueError(f"Archive contains too many files (max {MAX_FILES_IN_ARCHIVE})")
435
+
436
+ # Calculate total uncompressed size and check each entry
437
+ total_uncompressed = 0
438
+ for info in zf.infolist():
439
+ # Zip Slip prevention: reject paths that could escape extraction directory
440
+ filename = info.filename
441
+
442
+ # Reject absolute paths (Unix or Windows style)
443
+ if filename.startswith('/') or filename.startswith('\\'):
444
+ raise ValueError(f"Absolute path not allowed in archive: {filename}")
445
+
446
+ # Reject backslashes (Windows path separator could bypass Unix checks)
447
+ if '\\' in filename:
448
+ raise ValueError(f"Backslash not allowed in archive path: {filename}")
449
+
450
+ # Check each path component for ".." traversal
451
+ # This catches "foo/../bar" but NOT "foo/..bar" (valid filename)
452
+ parts = filename.split('/')
453
+ for part in parts:
454
+ if part == '..':
455
+ raise ValueError(f"Path traversal (..) not allowed in archive: {filename}")
456
+
457
+ # Reject symlinks (external_attr high nibble 0xA = symlink)
458
+ if info.external_attr >> 28 == 0xA:
459
+ raise ValueError(f"Symlinks not allowed: {filename}")
460
+
461
+ total_uncompressed += info.file_size
462
+
463
+ # Zip bomb protection
464
+ if len(zip_data) > 0:
465
+ ratio = total_uncompressed / len(zip_data)
466
+ if ratio > MAX_COMPRESSION_RATIO:
467
+ raise ValueError(f"Compression ratio too high ({ratio:.0f}:1), possible zip bomb")
468
+
469
+ if total_uncompressed > MAX_IMPORT_SIZE_MB * 1024 * 1024:
470
+ raise ValueError(f"Uncompressed size exceeds {MAX_IMPORT_SIZE_MB}MB")
471
+
472
+ # Verify required files exist
473
+ names = zf.namelist()
474
+ if 'manifest.json' not in names:
475
+ raise ValueError("Missing manifest.json")
476
+ if 'workflow.json' not in names:
477
+ raise ValueError("Missing workflow.json")
478
+
479
+ def _read_manifest(self, zf: zipfile.ZipFile) -> dict:
480
+ """Read and parse manifest.json."""
481
+ try:
482
+ content = zf.read('manifest.json').decode('utf-8')
483
+ manifest = json.loads(content)
484
+ except (KeyError, json.JSONDecodeError) as e:
485
+ raise ValueError(f"Invalid manifest.json: {e}")
486
+
487
+ # Validate format
488
+ if manifest.get('format') != EXPORT_FORMAT_NAME:
489
+ raise ValueError(f"Unknown export format: {manifest.get('format')}")
490
+
491
+ return manifest
492
+
493
+ def _read_workflow(self, zf: zipfile.ZipFile) -> dict:
494
+ """Read and parse workflow.json."""
495
+ try:
496
+ content = zf.read('workflow.json').decode('utf-8')
497
+ return json.loads(content)
498
+ except (KeyError, json.JSONDecodeError) as e:
499
+ raise ValueError(f"Invalid workflow.json: {e}")
500
+
501
+ def _read_items(self, zf: zipfile.ZipFile) -> list[dict]:
502
+ """Read and parse items.jsonl."""
503
+ if 'items.jsonl' not in zf.namelist():
504
+ return []
505
+
506
+ try:
507
+ content = zf.read('items.jsonl').decode('utf-8')
508
+ items = []
509
+ for line in content.strip().split('\n'):
510
+ if line.strip():
511
+ items.append(json.loads(line))
512
+ return items
513
+ except (KeyError, json.JSONDecodeError) as e:
514
+ raise ValueError(f"Invalid items.jsonl: {e}")
515
+
516
+ def _read_resources(self, zf: zipfile.ZipFile) -> list[dict]:
517
+ """Read and parse resources."""
518
+ if 'resources/resources.json' not in zf.namelist():
519
+ return []
520
+
521
+ try:
522
+ content = zf.read('resources/resources.json').decode('utf-8')
523
+ return json.loads(content)
524
+ except (KeyError, json.JSONDecodeError) as e:
525
+ raise ValueError(f"Invalid resources.json: {e}")
526
+
527
+ def _read_step_resources(self, zf: zipfile.ZipFile) -> dict:
528
+ """Read and parse step resources."""
529
+ if 'step-resources/step-resources.json' not in zf.namelist():
530
+ return {}
531
+
532
+ try:
533
+ content = zf.read('step-resources/step-resources.json').decode('utf-8')
534
+ return json.loads(content)
535
+ except (KeyError, json.JSONDecodeError) as e:
536
+ raise ValueError(f"Invalid step-resources.json: {e}")
537
+
538
+ def _read_planning(self, zf: zipfile.ZipFile) -> list[dict]:
539
+ """Read and parse planning sessions."""
540
+ if 'planning/session.json' not in zf.namelist():
541
+ return []
542
+
543
+ try:
544
+ content = zf.read('planning/session.json').decode('utf-8')
545
+ return json.loads(content)
546
+ except (KeyError, json.JSONDecodeError) as e:
547
+ raise ValueError(f"Invalid planning session.json: {e}")
548
+
549
+ def _read_runs(self, zf: zipfile.ZipFile) -> list[dict]:
550
+ """Read and parse runs."""
551
+ if 'runs/runs.json' not in zf.namelist():
552
+ return []
553
+
554
+ try:
555
+ content = zf.read('runs/runs.json').decode('utf-8')
556
+ return json.loads(content)
557
+ except (KeyError, json.JSONDecodeError) as e:
558
+ raise ValueError(f"Invalid runs.json: {e}")
559
+
560
+ def _check_compatibility(self, manifest: dict) -> tuple[bool, list[str]]:
561
+ """Check if the export is compatible with this version.
562
+
563
+ Returns:
564
+ Tuple of (is_compatible, list of notes/warnings).
565
+ """
566
+ notes = []
567
+ is_compatible = True
568
+
569
+ # Check export format version (using semantic version comparison)
570
+ export_version = manifest.get('version', '0.0')
571
+ if _compare_versions(export_version, EXPORT_FORMAT_VERSION) > 0:
572
+ notes.append(f"Export format {export_version} is newer than supported {EXPORT_FORMAT_VERSION}")
573
+ is_compatible = False
574
+
575
+ # Check schema version
576
+ schema_version = manifest.get('schema_version', 0)
577
+ if schema_version > PROJECT_SCHEMA_VERSION:
578
+ notes.append(f"Schema version {schema_version} is newer than current {PROJECT_SCHEMA_VERSION}")
579
+ is_compatible = False
580
+ elif schema_version < PROJECT_SCHEMA_VERSION - 5:
581
+ notes.append(f"Schema version {schema_version} is quite old, some data may not migrate perfectly")
582
+
583
+ # Check for secrets warning
584
+ if manifest.get('security', {}).get('potential_secrets_detected'):
585
+ notes.append("Export contains potential secrets - review content before sharing")
586
+
587
+ return is_compatible, notes
588
+
589
+ def _detect_conflicts(
590
+ self,
591
+ zip_data: bytes,
592
+ target_workflow_id: str,
593
+ ) -> list[Conflict]:
594
+ """Detect conflicts for merging into existing workflow."""
595
+ conflicts = []
596
+
597
+ with zipfile.ZipFile(io.BytesIO(zip_data), 'r') as zf:
598
+ items_data = self._read_items(zf)
599
+ resources_data = self._read_resources(zf)
600
+
601
+ # Get existing items and resources
602
+ existing_items, _ = self.db.list_work_items(workflow_id=target_workflow_id, limit=100000)
603
+ existing_resources = self.db.list_workflow_resources(target_workflow_id)
604
+
605
+ existing_item_ids = {i['id'] for i in existing_items}
606
+ existing_resource_names = {r['name'] for r in existing_resources}
607
+
608
+ # Check item ID conflicts
609
+ for item in items_data:
610
+ if item['id'] in existing_item_ids:
611
+ conflicts.append(Conflict(
612
+ conflict_type=ConflictType.ITEM_ID,
613
+ source_id=item['id'],
614
+ source_name=item.get('title', item['id']),
615
+ target_id=item['id'],
616
+ details="Item ID already exists in target workflow",
617
+ ))
618
+
619
+ # Check resource name conflicts
620
+ for resource in resources_data:
621
+ if resource['name'] in existing_resource_names:
622
+ conflicts.append(Conflict(
623
+ conflict_type=ConflictType.RESOURCE_NAME,
624
+ source_id=str(resource['id']),
625
+ source_name=resource['name'],
626
+ target_name=resource['name'],
627
+ details="Resource name already exists in target workflow",
628
+ ))
629
+
630
+ # Check for missing dependencies
631
+ import_item_ids = {i['id'] for i in items_data}
632
+ for item in items_data:
633
+ deps = item.get('dependencies', []) or []
634
+ for dep_id in deps:
635
+ if dep_id not in import_item_ids and dep_id not in existing_item_ids:
636
+ conflicts.append(Conflict(
637
+ conflict_type=ConflictType.MISSING_DEPENDENCY,
638
+ source_id=item['id'],
639
+ source_name=item.get('title', item['id']),
640
+ target_id=dep_id,
641
+ details=f"Dependency '{dep_id}' not found in import or target",
642
+ ))
643
+
644
+ return conflicts
645
+
646
+ def _generate_id_mapping(
647
+ self,
648
+ workflow_data: dict,
649
+ items_data: list[dict],
650
+ resources_data: list[dict],
651
+ ) -> dict[str, str]:
652
+ """Generate new IDs for all entities.
653
+
654
+ Returns:
655
+ Mapping of old_id -> new_id.
656
+ """
657
+ mapping = {}
658
+
659
+ # Workflow ID
660
+ old_wf_id = workflow_data['workflow']['id']
661
+ new_wf_id = f"wf-{uuid.uuid4().hex[:12]}"
662
+ mapping[old_wf_id] = new_wf_id
663
+
664
+ # Item IDs: preserve prefix, add unique suffix
665
+ for item in items_data:
666
+ old_id = item['id']
667
+ hash_suffix = hashlib.md5(
668
+ f"{old_id}-{uuid.uuid4().hex}".encode()
669
+ ).hexdigest()[:8]
670
+ new_id = f"{old_id}-{hash_suffix}"
671
+ mapping[old_id] = new_id
672
+
673
+ # Resource IDs (numeric, just generate new sequence)
674
+ for i, resource in enumerate(resources_data):
675
+ old_id = str(resource['id'])
676
+ mapping[old_id] = f"res-{i}" # Placeholder, actual ID from DB insert
677
+
678
+ return mapping
679
+
680
+ def _update_references(
681
+ self,
682
+ items_data: list[dict],
683
+ id_mapping: dict[str, str],
684
+ ) -> list[dict]:
685
+ """Update all internal references with new IDs."""
686
+ updated_items = []
687
+ for item in items_data:
688
+ new_item = item.copy()
689
+
690
+ # Update workflow_id
691
+ if 'workflow_id' in new_item and new_item['workflow_id'] in id_mapping:
692
+ new_item['workflow_id'] = id_mapping[new_item['workflow_id']]
693
+
694
+ # Update dependencies
695
+ deps = new_item.get('dependencies', []) or []
696
+ if deps:
697
+ new_deps = []
698
+ for dep_id in deps:
699
+ new_deps.append(id_mapping.get(dep_id, dep_id))
700
+ new_item['dependencies'] = new_deps
701
+
702
+ # Update duplicate_of
703
+ if new_item.get('duplicate_of') and new_item['duplicate_of'] in id_mapping:
704
+ new_item['duplicate_of'] = id_mapping[new_item['duplicate_of']]
705
+
706
+ # Update item ID
707
+ if new_item['id'] in id_mapping:
708
+ new_item['id'] = id_mapping[new_item['id']]
709
+
710
+ updated_items.append(new_item)
711
+
712
+ return updated_items
713
+
714
+ def _execute_import(
715
+ self,
716
+ workflow_data: dict,
717
+ items_data: list[dict],
718
+ resources_data: list[dict],
719
+ step_resources_data: dict,
720
+ planning_data: list[dict],
721
+ runs_data: list[dict],
722
+ id_mapping: dict[str, str],
723
+ options: ImportOptions,
724
+ manifest: dict,
725
+ ) -> ImportResult:
726
+ """Execute the import in a transaction."""
727
+ warnings = []
728
+ items_imported = 0
729
+ items_renamed = 0
730
+ resources_created = 0
731
+ resources_renamed = 0
732
+ planning_imported = 0
733
+ runs_imported = 0
734
+
735
+ # Get new workflow ID
736
+ old_wf_id = workflow_data['workflow']['id']
737
+ new_wf_id = id_mapping[old_wf_id]
738
+
739
+ # Update items with new IDs
740
+ updated_items = self._update_references(items_data, id_mapping)
741
+
742
+ # Create workflow
743
+ workflow = self.db.create_workflow(
744
+ id=new_wf_id,
745
+ name=workflow_data['workflow']['name'],
746
+ template_id=workflow_data['workflow'].get('template_id'),
747
+ status='draft',
748
+ )
749
+
750
+ # Create step ID mapping (old step ID -> new step ID)
751
+ step_id_mapping: dict[int, int] = {}
752
+
753
+ # Create steps
754
+ steps_created = 0
755
+ for step_def in workflow_data.get('steps', []):
756
+ old_step_id = step_def['id']
757
+
758
+ # Check if step should be imported
759
+ if options.selected_step_ids is not None:
760
+ if old_step_id not in options.selected_step_ids:
761
+ continue
762
+
763
+ step = self.db.create_workflow_step(
764
+ workflow_id=new_wf_id,
765
+ step_number=step_def['step_number'],
766
+ name=step_def['name'],
767
+ step_type=step_def['step_type'],
768
+ config=step_def.get('config'),
769
+ loop_name=step_def.get('loop_name'),
770
+ status='pending',
771
+ )
772
+ step_id_mapping[old_step_id] = step['id']
773
+ steps_created += 1
774
+
775
+ # Import artifacts if option is enabled and artifacts exist
776
+ if options.import_step_artifacts and step_def.get('artifacts'):
777
+ try:
778
+ self.db.update_workflow_step(step['id'], artifacts=step_def['artifacts'])
779
+ except Exception as e:
780
+ warnings.append(f"Failed to import artifacts for step {step_def['name']}: {e}")
781
+
782
+ # Import items
783
+ if options.import_items:
784
+ # Skip item import if no steps were created
785
+ if not step_id_mapping:
786
+ if updated_items:
787
+ warnings.append(f"Skipping {len(updated_items)} items: no steps were imported to associate them with")
788
+ else:
789
+ for item in updated_items:
790
+ # Check if item's step was imported
791
+ old_step_id = item.get('source_step_id')
792
+ if old_step_id and old_step_id not in step_id_mapping:
793
+ continue
794
+
795
+ # Use mapped step ID or fall back to first available step
796
+ new_step_id = step_id_mapping.get(old_step_id) if old_step_id else None
797
+ if new_step_id is None:
798
+ new_step_id = list(step_id_mapping.values())[0]
799
+
800
+ try:
801
+ self.db.create_work_item(
802
+ id=item['id'],
803
+ workflow_id=new_wf_id,
804
+ source_step_id=new_step_id,
805
+ content=item.get('content', ''),
806
+ title=item.get('title'),
807
+ priority=item.get('priority'),
808
+ status='pending',
809
+ category=item.get('category'),
810
+ metadata=item.get('metadata'),
811
+ item_type=item.get('item_type'),
812
+ dependencies=item.get('dependencies'),
813
+ phase=item.get('phase'),
814
+ duplicate_of=item.get('duplicate_of'),
815
+ )
816
+ items_imported += 1
817
+ # Update tags if present (not supported in create_work_item)
818
+ if item.get('tags'):
819
+ self.db.update_work_item(item['id'], tags=item['tags'])
820
+ except Exception as e:
821
+ warnings.append(f"Failed to import item {item['id']}: {e}")
822
+
823
+ # Import resources
824
+ if options.import_resources:
825
+ resource_id_mapping: dict[int, int] = {}
826
+ for resource in resources_data:
827
+ # Check if resource should be imported
828
+ if options.selected_resource_ids is not None:
829
+ if resource['id'] not in options.selected_resource_ids:
830
+ continue
831
+
832
+ try:
833
+ # NOTE: We intentionally ignore file_path from imports.
834
+ # Accepting arbitrary file paths from imported data could allow
835
+ # an attacker to plant paths that get read during later exports.
836
+ new_resource = self.db.create_workflow_resource(
837
+ workflow_id=new_wf_id,
838
+ resource_type=resource['resource_type'],
839
+ name=resource['name'],
840
+ content=resource.get('content'),
841
+ file_path=None, # Never import file paths from archives
842
+ source='imported',
843
+ enabled=resource.get('enabled', True),
844
+ )
845
+ resource_id_mapping[resource['id']] = new_resource['id']
846
+ resources_created += 1
847
+ except Exception as e:
848
+ warnings.append(f"Failed to import resource {resource['name']}: {e}")
849
+
850
+ # Import step resources
851
+ for old_step_id_str, step_res_list in step_resources_data.items():
852
+ old_step_id = int(old_step_id_str)
853
+ if old_step_id not in step_id_mapping:
854
+ continue
855
+
856
+ new_step_id = step_id_mapping[old_step_id]
857
+ for step_res in step_res_list:
858
+ try:
859
+ # Map workflow_resource_id if present
860
+ wf_res_id = step_res.get('workflow_resource_id')
861
+ new_wf_res_id = resource_id_mapping.get(wf_res_id) if wf_res_id else None
862
+
863
+ # NOTE: We intentionally ignore file_path from imports.
864
+ self.db.create_step_resource(
865
+ step_id=new_step_id,
866
+ mode=step_res.get('mode', 'add'),
867
+ workflow_resource_id=new_wf_res_id,
868
+ resource_type=step_res.get('resource_type'),
869
+ name=step_res.get('name'),
870
+ content=step_res.get('content'),
871
+ file_path=None, # Never import file paths from archives
872
+ enabled=step_res.get('enabled', True),
873
+ priority=step_res.get('priority', 0),
874
+ )
875
+ except Exception as e:
876
+ warnings.append(f"Failed to import step resource: {e}")
877
+
878
+ # Import planning sessions
879
+ if options.import_planning and planning_data:
880
+ for session in planning_data:
881
+ old_step_id = session.get('step_id')
882
+ if old_step_id not in step_id_mapping:
883
+ continue
884
+
885
+ try:
886
+ new_session_id = f"ps-{uuid.uuid4().hex[:12]}"
887
+ self.db.create_planning_session(
888
+ id=new_session_id,
889
+ workflow_id=new_wf_id,
890
+ step_id=step_id_mapping[old_step_id],
891
+ messages=session.get('messages', []),
892
+ artifacts=session.get('artifacts'),
893
+ status='completed',
894
+ )
895
+ planning_imported += 1
896
+ except Exception as e:
897
+ warnings.append(f"Failed to import planning session: {e}")
898
+
899
+ # Store original IDs in workflow metadata for traceability
900
+ original_metadata = {
901
+ 'imported_from': {
902
+ 'original_workflow_id': old_wf_id,
903
+ 'export_timestamp': manifest.get('exported_at'),
904
+ 'export_version': manifest.get('ralphx_version'),
905
+ },
906
+ 'id_mapping': {
907
+ 'items': {old: new for old, new in id_mapping.items() if not old.startswith('wf-')},
908
+ },
909
+ }
910
+
911
+ # TODO: Store metadata in workflow (need to add metadata column or use config)
912
+
913
+ return ImportResult(
914
+ success=True,
915
+ workflow_id=new_wf_id,
916
+ workflow_name=workflow['name'],
917
+ steps_created=steps_created,
918
+ items_imported=items_imported,
919
+ items_renamed=items_renamed,
920
+ items_skipped=len(items_data) - items_imported,
921
+ resources_created=resources_created,
922
+ resources_renamed=resources_renamed,
923
+ planning_sessions_imported=planning_imported,
924
+ runs_imported=runs_imported,
925
+ id_mapping=id_mapping,
926
+ warnings=warnings,
927
+ )
928
+
929
+ def _execute_merge(
930
+ self,
931
+ target_workflow_id: str,
932
+ target_workflow: dict,
933
+ workflow_data: Optional[dict],
934
+ items_data: list[dict],
935
+ resources_data: list[dict],
936
+ planning_data: list[dict],
937
+ options: ImportOptions,
938
+ manifest: dict,
939
+ ) -> ImportResult:
940
+ """Execute merge into existing workflow."""
941
+ warnings = []
942
+ items_imported = 0
943
+ items_renamed = 0
944
+ items_skipped = 0
945
+ resources_created = 0
946
+ resources_renamed = 0
947
+ steps_created = 0
948
+
949
+ # Get existing items and resources for conflict detection
950
+ existing_items, _ = self.db.list_work_items(workflow_id=target_workflow_id, limit=100000)
951
+ existing_resources = self.db.list_workflow_resources(target_workflow_id)
952
+ existing_item_ids = {i['id'] for i in existing_items}
953
+ existing_resource_names = {r['name'] for r in existing_resources}
954
+
955
+ # Get target steps for mapping source_step_id
956
+ target_steps = self.db.list_workflow_steps(target_workflow_id)
957
+ if not target_steps and not options.import_steps_to_target:
958
+ raise ValueError("Target workflow has no steps")
959
+
960
+ # Build step ID mapping for imported steps (source step ID -> target step ID)
961
+ step_id_mapping: dict[int, int] = {}
962
+
963
+ # Import steps to target if requested
964
+ if options.import_steps_to_target and workflow_data:
965
+ source_steps = workflow_data.get('steps', [])
966
+
967
+ # Filter by selected source step IDs if specified
968
+ if options.selected_source_step_ids is not None:
969
+ source_steps = [s for s in source_steps if s['id'] in options.selected_source_step_ids]
970
+
971
+ # Get max step number in target
972
+ max_step_number = max((s['step_number'] for s in target_steps), default=0) if target_steps else 0
973
+
974
+ for step_def in source_steps:
975
+ try:
976
+ new_step = self.db.create_workflow_step(
977
+ workflow_id=target_workflow_id,
978
+ step_number=max_step_number + 1,
979
+ name=step_def['name'],
980
+ step_type=step_def['step_type'],
981
+ config=step_def.get('config'),
982
+ loop_name=step_def.get('loop_name'),
983
+ status='pending',
984
+ )
985
+ step_id_mapping[step_def['id']] = new_step['id']
986
+ max_step_number += 1
987
+ steps_created += 1
988
+
989
+ # Import artifacts if option is enabled
990
+ if options.import_step_artifacts and step_def.get('artifacts'):
991
+ try:
992
+ self.db.update_workflow_step(new_step['id'], artifacts=step_def['artifacts'])
993
+ except Exception as e:
994
+ warnings.append(f"Failed to import artifacts for step {step_def['name']}: {e}")
995
+ except Exception as e:
996
+ warnings.append(f"Failed to import step {step_def['name']}: {e}")
997
+
998
+ # Refresh target steps after adding new ones
999
+ target_steps = self.db.list_workflow_steps(target_workflow_id)
1000
+
1001
+ # Determine which step to assign items to
1002
+ if options.target_step_id is not None:
1003
+ # Use specified target step
1004
+ default_step_id = options.target_step_id
1005
+ elif target_steps:
1006
+ # Use first step as default
1007
+ default_step_id = target_steps[0]['id']
1008
+ else:
1009
+ raise ValueError("No target step available for items")
1010
+
1011
+ # Import items with conflict resolution
1012
+ if options.import_items:
1013
+ for item in items_data:
1014
+ # Filter by selected source step IDs if specified
1015
+ # Note: Items with None source_step_id are included unless explicitly filtered
1016
+ source_step_id = item.get('source_step_id')
1017
+ if options.selected_source_step_ids is not None:
1018
+ if source_step_id is not None and source_step_id not in options.selected_source_step_ids:
1019
+ continue
1020
+
1021
+ item_id = item['id']
1022
+ has_conflict = item_id in existing_item_ids
1023
+
1024
+ if has_conflict:
1025
+ if options.conflict_resolution == ConflictResolution.SKIP:
1026
+ items_skipped += 1
1027
+ continue
1028
+ elif options.conflict_resolution == ConflictResolution.RENAME:
1029
+ hash_suffix = hashlib.md5(
1030
+ f"{item_id}-{uuid.uuid4().hex}".encode()
1031
+ ).hexdigest()[:8]
1032
+ item_id = f"{item['id']}-{hash_suffix}"
1033
+ items_renamed += 1
1034
+ elif options.conflict_resolution == ConflictResolution.OVERWRITE:
1035
+ # Delete existing item first - use raw SQL for composite key
1036
+ try:
1037
+ with self.db._writer() as conn:
1038
+ conn.execute(
1039
+ "DELETE FROM work_items WHERE id = ? AND workflow_id = ?",
1040
+ (item['id'], target_workflow_id),
1041
+ )
1042
+ except Exception:
1043
+ pass
1044
+
1045
+ # Determine target step: use mapped step if available, else default
1046
+ target_step_for_item = step_id_mapping.get(source_step_id, default_step_id)
1047
+
1048
+ try:
1049
+ self.db.create_work_item(
1050
+ id=item_id,
1051
+ workflow_id=target_workflow_id,
1052
+ source_step_id=target_step_for_item,
1053
+ content=item.get('content', ''),
1054
+ title=item.get('title'),
1055
+ priority=item.get('priority'),
1056
+ status='pending',
1057
+ category=item.get('category'),
1058
+ metadata=item.get('metadata'),
1059
+ item_type=item.get('item_type'),
1060
+ dependencies=item.get('dependencies'),
1061
+ phase=item.get('phase'),
1062
+ duplicate_of=item.get('duplicate_of'),
1063
+ )
1064
+ items_imported += 1
1065
+ # Update tags if present (not supported in create_work_item)
1066
+ if item.get('tags'):
1067
+ self.db.update_work_item(item_id, tags=item['tags'])
1068
+ except Exception as e:
1069
+ warnings.append(f"Failed to import item {item_id}: {e}")
1070
+ items_skipped += 1
1071
+
1072
+ # Import resources with conflict resolution
1073
+ if options.import_resources:
1074
+ for resource in resources_data:
1075
+ resource_name = resource['name']
1076
+ has_conflict = resource_name in existing_resource_names
1077
+
1078
+ if has_conflict:
1079
+ if options.conflict_resolution == ConflictResolution.SKIP:
1080
+ continue
1081
+ elif options.conflict_resolution == ConflictResolution.RENAME:
1082
+ hash_suffix = hashlib.md5(
1083
+ f"{resource_name}-{uuid.uuid4().hex}".encode()
1084
+ ).hexdigest()[:6]
1085
+ resource_name = f"{resource['name']}-{hash_suffix}"
1086
+ resources_renamed += 1
1087
+ elif options.conflict_resolution == ConflictResolution.OVERWRITE:
1088
+ # Find and delete existing
1089
+ for existing in existing_resources:
1090
+ if existing['name'] == resource_name:
1091
+ try:
1092
+ self.db.delete_workflow_resource(existing['id'])
1093
+ except Exception:
1094
+ pass
1095
+ break
1096
+
1097
+ try:
1098
+ # NOTE: We intentionally ignore file_path from imports.
1099
+ self.db.create_workflow_resource(
1100
+ workflow_id=target_workflow_id,
1101
+ resource_type=resource['resource_type'],
1102
+ name=resource_name,
1103
+ content=resource.get('content'),
1104
+ file_path=None, # Never import file paths from archives
1105
+ source='imported',
1106
+ enabled=resource.get('enabled', True),
1107
+ )
1108
+ resources_created += 1
1109
+ except Exception as e:
1110
+ warnings.append(f"Failed to import resource {resource_name}: {e}")
1111
+
1112
+ # Import planning sessions
1113
+ planning_imported = 0
1114
+ if options.import_planning and planning_data:
1115
+ # Get existing step IDs for the target workflow
1116
+ target_steps = self.db.list_workflow_steps(target_workflow_id)
1117
+ if target_steps:
1118
+ # Use first step as default for planning sessions
1119
+ default_step_id = target_steps[0]['id']
1120
+ for session in planning_data:
1121
+ try:
1122
+ new_session_id = f"ps-{uuid.uuid4().hex[:12]}"
1123
+ self.db.create_planning_session(
1124
+ id=new_session_id,
1125
+ workflow_id=target_workflow_id,
1126
+ step_id=default_step_id,
1127
+ messages=session.get('messages', []),
1128
+ artifacts=session.get('artifacts'),
1129
+ status='completed',
1130
+ )
1131
+ planning_imported += 1
1132
+ except Exception as e:
1133
+ warnings.append(f"Failed to import planning session: {e}")
1134
+
1135
+ return ImportResult(
1136
+ success=True,
1137
+ workflow_id=target_workflow_id,
1138
+ workflow_name=target_workflow['name'],
1139
+ steps_created=steps_created,
1140
+ items_imported=items_imported,
1141
+ items_renamed=items_renamed,
1142
+ items_skipped=items_skipped,
1143
+ resources_created=resources_created,
1144
+ resources_renamed=resources_renamed,
1145
+ planning_sessions_imported=planning_imported,
1146
+ runs_imported=0,
1147
+ id_mapping=step_id_mapping,
1148
+ warnings=warnings,
1149
+ )