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,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
|
+
)
|