@weirdfingers/baseboards 0.5.2 → 0.6.0

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 (76) hide show
  1. package/README.md +4 -1
  2. package/dist/index.js +131 -11
  3. package/dist/index.js.map +1 -1
  4. package/package.json +1 -1
  5. package/templates/api/alembic/env.py +9 -1
  6. package/templates/api/alembic/versions/20250101_000000_initial_schema.py +107 -49
  7. package/templates/api/alembic/versions/20251022_174729_remove_provider_name_from_generations.py +7 -3
  8. package/templates/api/alembic/versions/20251023_165852_switch_to_declarative_base_and_mapping.py +57 -1
  9. package/templates/api/alembic/versions/20251202_000000_add_artifact_lineage.py +134 -0
  10. package/templates/api/alembic/versions/2025925_62735_add_seed_data_for_default_tenant.py +8 -5
  11. package/templates/api/config/generators.yaml +111 -0
  12. package/templates/api/src/boards/__init__.py +1 -1
  13. package/templates/api/src/boards/api/app.py +2 -1
  14. package/templates/api/src/boards/api/endpoints/tenant_registration.py +1 -1
  15. package/templates/api/src/boards/api/endpoints/uploads.py +150 -0
  16. package/templates/api/src/boards/auth/factory.py +1 -1
  17. package/templates/api/src/boards/dbmodels/__init__.py +8 -22
  18. package/templates/api/src/boards/generators/artifact_resolution.py +45 -12
  19. package/templates/api/src/boards/generators/implementations/fal/audio/__init__.py +16 -1
  20. package/templates/api/src/boards/generators/implementations/fal/audio/beatoven_music_generation.py +171 -0
  21. package/templates/api/src/boards/generators/implementations/fal/audio/beatoven_sound_effect_generation.py +167 -0
  22. package/templates/api/src/boards/generators/implementations/fal/audio/elevenlabs_sound_effects_v2.py +194 -0
  23. package/templates/api/src/boards/generators/implementations/fal/audio/elevenlabs_tts_eleven_v3.py +209 -0
  24. package/templates/api/src/boards/generators/implementations/fal/audio/fal_elevenlabs_tts_turbo_v2_5.py +206 -0
  25. package/templates/api/src/boards/generators/implementations/fal/audio/fal_minimax_speech_26_hd.py +237 -0
  26. package/templates/api/src/boards/generators/implementations/fal/audio/minimax_speech_2_6_turbo.py +1 -1
  27. package/templates/api/src/boards/generators/implementations/fal/image/__init__.py +30 -0
  28. package/templates/api/src/boards/generators/implementations/fal/image/clarity_upscaler.py +220 -0
  29. package/templates/api/src/boards/generators/implementations/fal/image/crystal_upscaler.py +173 -0
  30. package/templates/api/src/boards/generators/implementations/fal/image/fal_ideogram_character.py +227 -0
  31. package/templates/api/src/boards/generators/implementations/fal/image/flux_2.py +203 -0
  32. package/templates/api/src/boards/generators/implementations/fal/image/flux_2_edit.py +230 -0
  33. package/templates/api/src/boards/generators/implementations/fal/image/flux_2_pro.py +204 -0
  34. package/templates/api/src/boards/generators/implementations/fal/image/flux_2_pro_edit.py +221 -0
  35. package/templates/api/src/boards/generators/implementations/fal/image/gemini_25_flash_image.py +177 -0
  36. package/templates/api/src/boards/generators/implementations/fal/image/gpt_image_1_edit_image.py +182 -0
  37. package/templates/api/src/boards/generators/implementations/fal/image/gpt_image_1_mini.py +167 -0
  38. package/templates/api/src/boards/generators/implementations/fal/image/ideogram_character_edit.py +299 -0
  39. package/templates/api/src/boards/generators/implementations/fal/image/ideogram_v2.py +190 -0
  40. package/templates/api/src/boards/generators/implementations/fal/image/nano_banana_pro_edit.py +226 -0
  41. package/templates/api/src/boards/generators/implementations/fal/image/qwen_image.py +249 -0
  42. package/templates/api/src/boards/generators/implementations/fal/image/qwen_image_edit.py +244 -0
  43. package/templates/api/src/boards/generators/implementations/fal/video/__init__.py +42 -0
  44. package/templates/api/src/boards/generators/implementations/fal/video/bytedance_seedance_v1_pro_text_to_video.py +209 -0
  45. package/templates/api/src/boards/generators/implementations/fal/video/creatify_lipsync.py +161 -0
  46. package/templates/api/src/boards/generators/implementations/fal/video/fal_bytedance_seedance_v1_pro_image_to_video.py +222 -0
  47. package/templates/api/src/boards/generators/implementations/fal/video/fal_minimax_hailuo_02_standard_text_to_video.py +152 -0
  48. package/templates/api/src/boards/generators/implementations/fal/video/fal_pixverse_lipsync.py +197 -0
  49. package/templates/api/src/boards/generators/implementations/fal/video/fal_sora_2_text_to_video.py +173 -0
  50. package/templates/api/src/boards/generators/implementations/fal/video/infinitalk.py +221 -0
  51. package/templates/api/src/boards/generators/implementations/fal/video/kling_video_v2_5_turbo_pro_image_to_video.py +175 -0
  52. package/templates/api/src/boards/generators/implementations/fal/video/minimax_hailuo_2_3_pro_image_to_video.py +153 -0
  53. package/templates/api/src/boards/generators/implementations/fal/video/sora2_image_to_video.py +172 -0
  54. package/templates/api/src/boards/generators/implementations/fal/video/sora_2_image_to_video_pro.py +175 -0
  55. package/templates/api/src/boards/generators/implementations/fal/video/sora_2_text_to_video_pro.py +163 -0
  56. package/templates/api/src/boards/generators/implementations/fal/video/sync_lipsync_v2_pro.py +155 -0
  57. package/templates/api/src/boards/generators/implementations/fal/video/veed_lipsync.py +174 -0
  58. package/templates/api/src/boards/generators/implementations/fal/video/veo3.py +194 -0
  59. package/templates/api/src/boards/generators/implementations/fal/video/veo31_first_last_frame_to_video.py +1 -1
  60. package/templates/api/src/boards/generators/implementations/fal/video/wan_pro_image_to_video.py +158 -0
  61. package/templates/api/src/boards/graphql/access_control.py +1 -1
  62. package/templates/api/src/boards/graphql/mutations/root.py +16 -4
  63. package/templates/api/src/boards/graphql/resolvers/board.py +0 -2
  64. package/templates/api/src/boards/graphql/resolvers/generation.py +10 -233
  65. package/templates/api/src/boards/graphql/resolvers/lineage.py +381 -0
  66. package/templates/api/src/boards/graphql/resolvers/upload.py +463 -0
  67. package/templates/api/src/boards/graphql/types/generation.py +62 -26
  68. package/templates/api/src/boards/middleware.py +1 -1
  69. package/templates/api/src/boards/storage/factory.py +2 -2
  70. package/templates/api/src/boards/tenant_isolation.py +9 -9
  71. package/templates/api/src/boards/workers/actors.py +10 -1
  72. package/templates/web/package.json +1 -1
  73. package/templates/web/src/app/boards/[boardId]/page.tsx +14 -5
  74. package/templates/web/src/app/lineage/[generationId]/page.tsx +233 -0
  75. package/templates/web/src/components/boards/ArtifactPreview.tsx +20 -1
  76. package/templates/web/src/components/boards/UploadArtifact.tsx +253 -0
@@ -0,0 +1,463 @@
1
+ """Resolvers for artifact upload operations."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import ipaddress
6
+ from datetime import UTC, datetime
7
+ from decimal import Decimal
8
+ from typing import TYPE_CHECKING
9
+ from urllib.parse import urlparse
10
+ from uuid import UUID
11
+
12
+ import aiohttp
13
+ import strawberry
14
+ from sqlalchemy import select
15
+ from sqlalchemy.orm import selectinload
16
+
17
+ from ...auth.context import AuthContext
18
+ from ...database.connection import get_async_session
19
+ from ...dbmodels import Boards, Generations
20
+ from ...logging import get_logger
21
+ from ...storage.factory import create_storage_manager
22
+ from ..access_control import get_auth_context_from_info
23
+ from ..types.generation import ArtifactType
24
+
25
+ if TYPE_CHECKING:
26
+ from ..types.generation import Generation as GenerationType
27
+ from ..types.generation import UploadArtifactInput
28
+
29
+ logger = get_logger(__name__)
30
+
31
+
32
+ def _validate_mime_type(
33
+ content_type: str, artifact_type: ArtifactType, filename: str | None
34
+ ) -> tuple[bool, str | None]:
35
+ """
36
+ Validate that MIME type matches the expected artifact type.
37
+
38
+ Args:
39
+ content_type: The MIME type to validate (e.g., "image/jpeg")
40
+ artifact_type: The expected artifact type enum
41
+ filename: Optional filename for additional context
42
+
43
+ Returns:
44
+ Tuple of (is_valid, error_message)
45
+ """
46
+ # Define allowed MIME types for each artifact type
47
+ allowed_mime_types = {
48
+ ArtifactType.IMAGE: [
49
+ "image/jpeg",
50
+ "image/jpg",
51
+ "image/png",
52
+ "image/gif",
53
+ "image/webp",
54
+ "image/bmp",
55
+ "image/svg+xml",
56
+ ],
57
+ ArtifactType.VIDEO: [
58
+ "video/mp4",
59
+ "video/quicktime",
60
+ "video/x-msvideo",
61
+ "video/webm",
62
+ "video/mpeg",
63
+ "video/x-matroska",
64
+ ],
65
+ ArtifactType.AUDIO: [
66
+ "audio/mpeg",
67
+ "audio/mp3",
68
+ "audio/wav",
69
+ "audio/ogg",
70
+ "audio/webm",
71
+ "audio/x-m4a",
72
+ "audio/mp4",
73
+ ],
74
+ ArtifactType.TEXT: [
75
+ "text/plain",
76
+ "text/markdown",
77
+ "application/json",
78
+ "text/html",
79
+ "text/csv",
80
+ ],
81
+ }
82
+
83
+ # Normalize MIME type (remove charset, etc.)
84
+ mime_type = content_type.split(";")[0].strip().lower()
85
+
86
+ # Check if artifact type is supported
87
+ if artifact_type not in allowed_mime_types:
88
+ return False, f"Unsupported artifact type: {artifact_type.value}"
89
+
90
+ # Check if MIME type is allowed for this artifact type
91
+ if mime_type not in allowed_mime_types[artifact_type]:
92
+ # Also check for generic types
93
+ mime_category = mime_type.split("/")[0]
94
+ if mime_category != artifact_type.value:
95
+ return (
96
+ False,
97
+ f"MIME type '{mime_type}' does not match artifact type '{artifact_type.value}'",
98
+ )
99
+
100
+ return True, None
101
+
102
+
103
+ def _is_safe_url(url: str) -> tuple[bool, str | None]:
104
+ """
105
+ Validate URL to prevent SSRF attacks.
106
+
107
+ Returns:
108
+ Tuple of (is_safe, error_message)
109
+ """
110
+ try:
111
+ parsed = urlparse(url)
112
+
113
+ # Only allow http and https
114
+ if parsed.scheme not in ("http", "https"):
115
+ return (
116
+ False,
117
+ f"URL scheme '{parsed.scheme}' not allowed. " "Only http and https are supported.",
118
+ )
119
+
120
+ hostname = parsed.hostname
121
+ if not hostname:
122
+ return False, "Invalid URL: no hostname found"
123
+
124
+ # Block localhost
125
+ if hostname.lower() in ("localhost", "127.0.0.1", "::1"):
126
+ return False, "Access to localhost is not allowed"
127
+
128
+ # Try to resolve hostname to IP
129
+ try:
130
+ # Check if it's already an IP address
131
+ ip = ipaddress.ip_address(hostname)
132
+
133
+ # Block private IP ranges
134
+ if ip.is_private:
135
+ return False, f"Access to private IP address {ip} is not allowed"
136
+
137
+ # Block link-local addresses (including AWS metadata endpoint)
138
+ if ip.is_link_local:
139
+ return False, f"Access to link-local address {ip} is not allowed"
140
+
141
+ # Block loopback
142
+ if ip.is_loopback:
143
+ return False, f"Access to loopback address {ip} is not allowed"
144
+
145
+ except ValueError:
146
+ # Not an IP address, it's a hostname - this is OK
147
+ # In production, you might want to resolve the hostname and check the IP
148
+ # but that adds complexity and potential DNS rebinding issues
149
+ pass
150
+
151
+ return True, None
152
+
153
+ except Exception as e:
154
+ return False, f"Invalid URL: {e}"
155
+
156
+
157
+ async def upload_artifact_from_url(
158
+ info: strawberry.Info,
159
+ input: UploadArtifactInput,
160
+ ) -> GenerationType:
161
+ """Upload artifact from URL (synchronous)."""
162
+ from ...config import settings
163
+
164
+ auth_context = await get_auth_context_from_info(info)
165
+ if not auth_context or not auth_context.is_authenticated:
166
+ raise RuntimeError("Authentication required")
167
+
168
+ if not input.file_url:
169
+ raise RuntimeError("file_url is required")
170
+
171
+ # Validate URL to prevent SSRF attacks
172
+ is_safe, error_msg = _is_safe_url(input.file_url)
173
+ if not is_safe:
174
+ logger.warning("Unsafe URL blocked", url=input.file_url, reason=error_msg)
175
+ raise RuntimeError(f"URL not allowed: {error_msg}")
176
+
177
+ # Download file from URL
178
+ async with aiohttp.ClientSession() as http_session:
179
+ try:
180
+ async with http_session.get(
181
+ input.file_url, timeout=aiohttp.ClientTimeout(total=60)
182
+ ) as resp:
183
+ if resp.status != 200:
184
+ raise RuntimeError(f"Failed to download from URL: HTTP {resp.status}")
185
+
186
+ # Check Content-Length before downloading to prevent memory exhaustion
187
+ content_length = resp.headers.get("Content-Length")
188
+ if content_length:
189
+ file_size = int(content_length)
190
+ if file_size > settings.max_upload_size:
191
+ raise RuntimeError(
192
+ f"File size ({file_size} bytes) exceeds maximum allowed "
193
+ f"size ({settings.max_upload_size} bytes)"
194
+ )
195
+
196
+ content = await resp.read()
197
+ content_type = resp.headers.get("Content-Type", "application/octet-stream")
198
+
199
+ # Extract filename from URL if not provided
200
+ filename = input.original_filename
201
+ if not filename:
202
+ path = urlparse(input.file_url).path
203
+ filename = path.split("/")[-1] if path else "uploaded_file"
204
+
205
+ except aiohttp.ClientError as e:
206
+ logger.error("URL download failed", url=input.file_url, error=str(e))
207
+ raise RuntimeError("Failed to download file from URL") from e
208
+
209
+ # Process upload
210
+ return await _process_upload(
211
+ auth_context=auth_context,
212
+ board_id=input.board_id,
213
+ artifact_type=input.artifact_type,
214
+ file_content=content,
215
+ filename=filename,
216
+ content_type=content_type,
217
+ user_description=input.user_description,
218
+ parent_generation_id=input.parent_generation_id,
219
+ upload_source="url",
220
+ source_url=input.file_url,
221
+ )
222
+
223
+
224
+ async def upload_artifact_from_file(
225
+ auth_context: AuthContext,
226
+ board_id: UUID,
227
+ artifact_type: str,
228
+ file_content: bytes,
229
+ filename: str | None,
230
+ content_type: str | None,
231
+ user_description: str | None,
232
+ parent_generation_id: UUID | None,
233
+ ) -> GenerationType:
234
+ """Upload artifact from file (synchronous)."""
235
+ return await _process_upload(
236
+ auth_context=auth_context,
237
+ board_id=board_id,
238
+ artifact_type=ArtifactType(artifact_type),
239
+ file_content=file_content,
240
+ filename=filename or "uploaded_file",
241
+ content_type=content_type or "application/octet-stream",
242
+ user_description=user_description,
243
+ parent_generation_id=parent_generation_id,
244
+ upload_source="file",
245
+ source_url=None,
246
+ )
247
+
248
+
249
+ def _sanitize_filename(filename: str) -> str:
250
+ """
251
+ Sanitize filename to prevent path traversal and other security issues.
252
+
253
+ Returns:
254
+ Sanitized filename (basename only, no path components)
255
+ """
256
+ import os
257
+ import re
258
+
259
+ # Get basename only (remove any path components)
260
+ filename = os.path.basename(filename)
261
+
262
+ # Remove any null bytes
263
+ filename = filename.replace("\x00", "")
264
+
265
+ # Replace potentially dangerous characters (including backslash for Windows paths)
266
+ filename = re.sub(r'[<>:"|?*\\]', "_", filename)
267
+
268
+ # Remove leading/trailing whitespace and dots
269
+ filename = filename.strip(". ")
270
+
271
+ # If filename is empty after sanitization, use a default
272
+ if not filename:
273
+ filename = "uploaded_file"
274
+
275
+ return filename
276
+
277
+
278
+ async def _process_upload(
279
+ auth_context: AuthContext,
280
+ board_id: UUID,
281
+ artifact_type: ArtifactType,
282
+ file_content: bytes,
283
+ filename: str,
284
+ content_type: str,
285
+ user_description: str | None,
286
+ parent_generation_id: UUID | None,
287
+ upload_source: str,
288
+ source_url: str | None,
289
+ ) -> GenerationType:
290
+ """Common upload processing logic.
291
+
292
+ Args:
293
+ auth_context: Authentication context for the request
294
+ board_id: UUID of the board to upload to
295
+ artifact_type: Type of artifact being uploaded (enum)
296
+ file_content: Binary content of the file
297
+ filename: Original filename
298
+ content_type: MIME type of the file
299
+ user_description: Optional user-provided description
300
+ parent_generation_id: Optional parent generation UUID
301
+ upload_source: Source of upload ("file" or "url")
302
+ source_url: URL if uploaded from URL, None otherwise
303
+
304
+ Returns:
305
+ GenerationType object representing the uploaded artifact
306
+ """
307
+ from ...config import settings
308
+ from ..types.generation import Generation as GenerationType
309
+ from ..types.generation import GenerationStatus
310
+
311
+ # Sanitize filename to prevent path traversal
312
+ filename = _sanitize_filename(filename)
313
+
314
+ # Validate MIME type matches artifact type
315
+ is_valid, error_msg = _validate_mime_type(content_type, artifact_type, filename)
316
+ if not is_valid:
317
+ logger.warning(
318
+ "Invalid MIME type for artifact",
319
+ mime_type=content_type,
320
+ artifact_type=artifact_type.value,
321
+ reason=error_msg,
322
+ )
323
+ raise RuntimeError(f"Invalid file type: {error_msg}")
324
+
325
+ # Validate file size (double-check even after Content-Length check)
326
+ if len(file_content) > settings.max_upload_size:
327
+ raise RuntimeError(
328
+ f"File size ({len(file_content)} bytes) exceeds maximum allowed "
329
+ f"size ({settings.max_upload_size} bytes)"
330
+ )
331
+
332
+ async with get_async_session() as session:
333
+ # Validate board access
334
+ board_stmt = (
335
+ select(Boards).where(Boards.id == board_id).options(selectinload(Boards.board_members))
336
+ )
337
+ board = (await session.execute(board_stmt)).scalar_one_or_none()
338
+
339
+ if not board:
340
+ raise RuntimeError("Board not found")
341
+
342
+ # Check permissions (same as create_generation)
343
+ if not auth_context.user_id:
344
+ raise RuntimeError("User ID is required")
345
+
346
+ is_owner = board.owner_id == auth_context.user_id
347
+ is_editor = any(
348
+ m.user_id == auth_context.user_id and m.role in {"editor", "admin"}
349
+ for m in board.board_members
350
+ )
351
+
352
+ if not is_owner and not is_editor:
353
+ raise RuntimeError(
354
+ "Permission denied: You don't have permission to upload to this board"
355
+ )
356
+
357
+ # Create generation record (status=pending temporarily)
358
+ gen = Generations()
359
+ gen.tenant_id = auth_context.tenant_id
360
+ gen.board_id = board_id
361
+ gen.user_id = auth_context.user_id
362
+ gen.generator_name = f"user-upload-{artifact_type.value}"
363
+ gen.artifact_type = artifact_type.value
364
+ gen.status = "pending"
365
+ gen.progress = Decimal(0.0)
366
+ gen.input_params = {
367
+ "upload_source": upload_source,
368
+ "original_filename": filename,
369
+ "source_url": source_url,
370
+ "user_description": user_description,
371
+ }
372
+ gen.output_metadata = {
373
+ "file_size": len(file_content),
374
+ "mime_type": content_type,
375
+ "upload_timestamp": datetime.now(UTC).isoformat(),
376
+ }
377
+ # If parent_generation_id is provided, add it to input_artifacts
378
+ if parent_generation_id:
379
+ gen.input_artifacts = [
380
+ {
381
+ "generation_id": str(parent_generation_id),
382
+ "role": "parent",
383
+ "artifact_type": artifact_type.value,
384
+ }
385
+ ]
386
+ else:
387
+ gen.input_artifacts = []
388
+ gen.started_at = datetime.now(UTC)
389
+
390
+ session.add(gen)
391
+ await session.flush() # Get ID
392
+
393
+ try:
394
+ # Upload to storage
395
+ storage_manager = create_storage_manager()
396
+ artifact_ref = await storage_manager.store_artifact(
397
+ artifact_id=str(gen.id),
398
+ content=file_content,
399
+ artifact_type=artifact_type.value,
400
+ content_type=content_type,
401
+ tenant_id=str(auth_context.tenant_id),
402
+ board_id=str(board_id),
403
+ )
404
+
405
+ # Update generation with storage info
406
+ gen.storage_url = artifact_ref.storage_url
407
+ gen.status = "completed"
408
+ gen.progress = Decimal(100.0)
409
+ gen.completed_at = datetime.now(UTC)
410
+
411
+ # Update metadata with storage details
412
+ if gen.output_metadata is None:
413
+ gen.output_metadata = {}
414
+ gen.output_metadata["storage_key"] = artifact_ref.storage_key
415
+ gen.output_metadata["storage_provider"] = artifact_ref.storage_provider
416
+
417
+ await session.commit()
418
+ await session.refresh(gen)
419
+
420
+ logger.info(
421
+ "Artifact uploaded",
422
+ generation_id=str(gen.id),
423
+ artifact_type=artifact_type,
424
+ file_size=len(file_content),
425
+ upload_source=upload_source,
426
+ )
427
+
428
+ # Convert to GraphQL type
429
+ return GenerationType(
430
+ id=gen.id,
431
+ tenant_id=gen.tenant_id,
432
+ board_id=gen.board_id,
433
+ user_id=gen.user_id,
434
+ generator_name=gen.generator_name,
435
+ artifact_type=ArtifactType(gen.artifact_type),
436
+ storage_url=gen.storage_url,
437
+ thumbnail_url=gen.thumbnail_url,
438
+ additional_files=gen.additional_files or [],
439
+ input_params=gen.input_params or {},
440
+ output_metadata=gen.output_metadata or {},
441
+ external_job_id=gen.external_job_id,
442
+ status=GenerationStatus(gen.status),
443
+ progress=float(gen.progress),
444
+ error_message=gen.error_message,
445
+ started_at=gen.started_at,
446
+ completed_at=gen.completed_at,
447
+ created_at=gen.created_at,
448
+ updated_at=gen.updated_at,
449
+ )
450
+
451
+ except Exception as e:
452
+ # Mark as failed
453
+ gen.status = "failed"
454
+ gen.error_message = str(e)
455
+ gen.completed_at = datetime.now(UTC)
456
+ await session.commit()
457
+
458
+ logger.error(
459
+ "Upload failed",
460
+ generation_id=str(gen.id),
461
+ error=str(e),
462
+ )
463
+ raise RuntimeError(f"Upload failed: {e}") from e
@@ -46,6 +46,56 @@ class AdditionalFile:
46
46
  metadata: strawberry.scalars.JSON # type: ignore[reportInvalidTypeForm]
47
47
 
48
48
 
49
+ @strawberry.input
50
+ class UploadArtifactInput:
51
+ """Input for uploading an artifact from URL."""
52
+
53
+ board_id: UUID
54
+ artifact_type: ArtifactType
55
+ file_url: str | None = None
56
+ original_filename: str | None = None
57
+ user_description: str | None = None
58
+ parent_generation_id: UUID | None = None
59
+
60
+
61
+ @strawberry.type
62
+ class ArtifactLineage:
63
+ """Represents a single input artifact relationship with role metadata."""
64
+
65
+ generation_id: UUID
66
+ role: str
67
+ artifact_type: ArtifactType
68
+
69
+ @strawberry.field
70
+ async def generation(
71
+ self, info: strawberry.Info
72
+ ) -> Annotated["Generation", strawberry.lazy(".generation")] | None:
73
+ """Resolve the full generation object for this input."""
74
+ from ..resolvers.lineage import resolve_generation_by_id
75
+
76
+ return await resolve_generation_by_id(info, self.generation_id)
77
+
78
+
79
+ @strawberry.type
80
+ class AncestryNode:
81
+ """Represents a node in the ancestry tree."""
82
+
83
+ generation: Annotated["Generation", strawberry.lazy(".generation")]
84
+ depth: int
85
+ role: str | None
86
+ parents: list["AncestryNode"]
87
+
88
+
89
+ @strawberry.type
90
+ class DescendantNode:
91
+ """Represents a node in the descendants tree."""
92
+
93
+ generation: Annotated["Generation", strawberry.lazy(".generation")]
94
+ depth: int
95
+ role: str | None
96
+ children: list["DescendantNode"]
97
+
98
+
49
99
  @strawberry.type
50
100
  class Generation:
51
101
  """Generation type for GraphQL API."""
@@ -68,10 +118,6 @@ class Generation:
68
118
  input_params: strawberry.scalars.JSON # type: ignore[reportInvalidTypeForm]
69
119
  output_metadata: strawberry.scalars.JSON # type: ignore[reportInvalidTypeForm]
70
120
 
71
- # Lineage
72
- parent_generation_id: UUID | None
73
- input_generation_ids: list[UUID]
74
-
75
121
  # Job tracking
76
122
  external_job_id: str | None
77
123
  status: GenerationStatus
@@ -99,32 +145,22 @@ class Generation:
99
145
  return await resolve_generation_user(self, info)
100
146
 
101
147
  @strawberry.field
102
- async def parent(
103
- self, info: strawberry.Info
104
- ) -> Annotated["Generation", strawberry.lazy(".generation")] | None: # noqa: E501
105
- """Get the parent generation if any."""
106
- if not self.parent_generation_id:
107
- return None
108
- from ..resolvers.generation import resolve_generation_parent
148
+ async def input_artifacts(self, info: strawberry.Info) -> list[ArtifactLineage]:
149
+ """Get input artifacts with role metadata."""
150
+ from ..resolvers.lineage import resolve_input_artifacts
109
151
 
110
- return await resolve_generation_parent(self, info)
152
+ return await resolve_input_artifacts(self, info)
111
153
 
112
154
  @strawberry.field
113
- async def inputs(
114
- self, info: strawberry.Info
115
- ) -> list[Annotated["Generation", strawberry.lazy(".generation")]]: # noqa: E501
116
- """Get input generations used for this generation."""
117
- if not self.input_generation_ids:
118
- return []
119
- from ..resolvers.generation import resolve_generation_inputs
155
+ async def ancestry(self, info: strawberry.Info, max_depth: int = 25) -> AncestryNode:
156
+ """Get complete ancestry tree up to max_depth levels."""
157
+ from ..resolvers.lineage import resolve_ancestry
120
158
 
121
- return await resolve_generation_inputs(self, info)
159
+ return await resolve_ancestry(self, info, max_depth)
122
160
 
123
161
  @strawberry.field
124
- async def children(
125
- self, info: strawberry.Info
126
- ) -> list[Annotated["Generation", strawberry.lazy(".generation")]]: # noqa: E501
127
- """Get child generations derived from this one."""
128
- from ..resolvers.generation import resolve_generation_children
162
+ async def descendants(self, info: strawberry.Info, max_depth: int = 25) -> DescendantNode:
163
+ """Get complete descendants tree up to max_depth levels."""
164
+ from ..resolvers.lineage import resolve_descendants
129
165
 
130
- return await resolve_generation_children(self, info)
166
+ return await resolve_descendants(self, info, max_depth)
@@ -260,7 +260,7 @@ class TenantRoutingMiddleware(BaseHTTPMiddleware):
260
260
  content={
261
261
  "error": "Missing X-Tenant header",
262
262
  "detail": (
263
- "X-Tenant header is required in multi-tenant mode " "for this endpoint"
263
+ "X-Tenant header is required in multi-tenant mode for this endpoint"
264
264
  ),
265
265
  "multi_tenant_mode": True,
266
266
  },
@@ -60,7 +60,7 @@ except ImportError:
60
60
  S3StorageProvider = None
61
61
  _s3_available = False
62
62
  logger.warning(
63
- "S3 storage not available. " "Install with: pip install weirdfingers-boards[storage-s3]"
63
+ "S3 storage not available. Install with: pip install weirdfingers-boards[storage-s3]"
64
64
  )
65
65
 
66
66
  try:
@@ -71,7 +71,7 @@ except ImportError:
71
71
  GCSStorageProvider = None
72
72
  _gcs_available = False
73
73
  logger.warning(
74
- "GCS storage not available. " "Install with: pip install weirdfingers-boards[storage-gcs]"
74
+ "GCS storage not available. Install with: pip install weirdfingers-boards[storage-gcs]"
75
75
  )
76
76
 
77
77
 
@@ -214,8 +214,8 @@ class TenantIsolationValidator:
214
214
  stmt = text(
215
215
  """
216
216
  SELECT u.id as user_id, b.id as board_id, b.tenant_id as board_tenant_id
217
- FROM users u
218
- JOIN boards b ON u.id = b.owner_id
217
+ FROM boards.users u
218
+ JOIN boards.boards b ON u.id = b.owner_id
219
219
  WHERE u.tenant_id = :tenant_id AND b.tenant_id != :tenant_id
220
220
  """
221
221
  )
@@ -240,8 +240,8 @@ class TenantIsolationValidator:
240
240
  g.tenant_id,
241
241
  g.board_id,
242
242
  b.tenant_id as board_tenant_id
243
- FROM generations g
244
- JOIN boards b ON g.board_id = b.id
243
+ FROM boards.generations g
244
+ JOIN boards.boards b ON g.board_id = b.id
245
245
  WHERE g.tenant_id = :tenant_id AND b.tenant_id != :tenant_id
246
246
  """
247
247
  )
@@ -275,9 +275,9 @@ class TenantIsolationValidator:
275
275
  bm.user_id,
276
276
  b.tenant_id as board_tenant_id,
277
277
  u.tenant_id as user_tenant_id
278
- FROM board_members bm
279
- JOIN boards b ON bm.board_id = b.id
280
- JOIN users u ON bm.user_id = u.id
278
+ FROM boards.board_members bm
279
+ JOIN boards.boards b ON bm.board_id = b.id
280
+ JOIN boards.users u ON bm.user_id = u.id
281
281
  WHERE b.tenant_id = :tenant_id AND u.tenant_id != :tenant_id
282
282
  """
283
283
  )
@@ -325,8 +325,8 @@ class TenantIsolationValidator:
325
325
  stmt = text(
326
326
  """
327
327
  SELECT COUNT(*) as count
328
- FROM board_members bm
329
- JOIN boards b ON bm.board_id = b.id
328
+ FROM boards.board_members bm
329
+ JOIN boards.boards b ON bm.board_id = b.id
330
330
  WHERE b.tenant_id = :tenant_id
331
331
  """
332
332
  )
@@ -3,6 +3,7 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  import traceback
6
+ from typing import Any
6
7
 
7
8
  import dramatiq
8
9
  from dramatiq import actor
@@ -92,6 +93,7 @@ async def process_generation(generation_id: str) -> None:
92
93
  # Build and validate typed inputs
93
94
  # First resolve any artifact fields (generation IDs -> artifact objects)
94
95
  # This happens automatically via type introspection
96
+ lineage_metadata: list[dict[str, Any]] = []
95
97
  try:
96
98
  input_schema = generator.get_input_schema()
97
99
 
@@ -99,12 +101,19 @@ async def process_generation(generation_id: str) -> None:
99
101
  from ..generators.artifact_resolution import resolve_input_artifacts
100
102
 
101
103
  async with get_async_session() as session:
102
- resolved_params = await resolve_input_artifacts(
104
+ resolved_params, lineage_metadata = await resolve_input_artifacts(
103
105
  input_params,
104
106
  input_schema, # Schema is introspected to find artifact fields
105
107
  session,
106
108
  tenant_id,
107
109
  )
110
+
111
+ # Store lineage metadata in the generation
112
+ if lineage_metadata:
113
+ generation = await jobs_repo.get_generation(session, generation_id)
114
+ generation.input_artifacts = lineage_metadata
115
+ await session.commit()
116
+
108
117
  typed_inputs = input_schema.model_validate(resolved_params)
109
118
  except Exception as e:
110
119
  error_msg = "Invalid input parameters"