@weirdfingers/baseboards 0.2.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 (139) hide show
  1. package/README.md +191 -0
  2. package/dist/index.d.ts +1 -0
  3. package/dist/index.js +887 -0
  4. package/dist/index.js.map +1 -0
  5. package/package.json +64 -0
  6. package/templates/README.md +120 -0
  7. package/templates/api/.env.example +62 -0
  8. package/templates/api/Dockerfile +32 -0
  9. package/templates/api/README.md +132 -0
  10. package/templates/api/alembic/env.py +106 -0
  11. package/templates/api/alembic/script.py.mako +28 -0
  12. package/templates/api/alembic/versions/20250101_000000_initial_schema.py +448 -0
  13. package/templates/api/alembic/versions/20251022_174729_remove_provider_name_from_generations.py +71 -0
  14. package/templates/api/alembic/versions/20251023_165852_switch_to_declarative_base_and_mapping.py +411 -0
  15. package/templates/api/alembic/versions/2025925_62735_add_seed_data_for_default_tenant.py +85 -0
  16. package/templates/api/alembic.ini +36 -0
  17. package/templates/api/config/generators.yaml +25 -0
  18. package/templates/api/config/storage_config.yaml +26 -0
  19. package/templates/api/docs/ADDING_GENERATORS.md +409 -0
  20. package/templates/api/docs/GENERATORS_API.md +502 -0
  21. package/templates/api/docs/MIGRATIONS.md +472 -0
  22. package/templates/api/docs/storage_providers.md +337 -0
  23. package/templates/api/pyproject.toml +165 -0
  24. package/templates/api/src/boards/__init__.py +10 -0
  25. package/templates/api/src/boards/api/app.py +171 -0
  26. package/templates/api/src/boards/api/auth.py +75 -0
  27. package/templates/api/src/boards/api/endpoints/__init__.py +3 -0
  28. package/templates/api/src/boards/api/endpoints/jobs.py +76 -0
  29. package/templates/api/src/boards/api/endpoints/setup.py +505 -0
  30. package/templates/api/src/boards/api/endpoints/sse.py +129 -0
  31. package/templates/api/src/boards/api/endpoints/storage.py +74 -0
  32. package/templates/api/src/boards/api/endpoints/tenant_registration.py +296 -0
  33. package/templates/api/src/boards/api/endpoints/webhooks.py +13 -0
  34. package/templates/api/src/boards/auth/__init__.py +15 -0
  35. package/templates/api/src/boards/auth/adapters/__init__.py +20 -0
  36. package/templates/api/src/boards/auth/adapters/auth0.py +220 -0
  37. package/templates/api/src/boards/auth/adapters/base.py +73 -0
  38. package/templates/api/src/boards/auth/adapters/clerk.py +172 -0
  39. package/templates/api/src/boards/auth/adapters/jwt.py +122 -0
  40. package/templates/api/src/boards/auth/adapters/none.py +102 -0
  41. package/templates/api/src/boards/auth/adapters/oidc.py +284 -0
  42. package/templates/api/src/boards/auth/adapters/supabase.py +110 -0
  43. package/templates/api/src/boards/auth/context.py +35 -0
  44. package/templates/api/src/boards/auth/factory.py +115 -0
  45. package/templates/api/src/boards/auth/middleware.py +221 -0
  46. package/templates/api/src/boards/auth/provisioning.py +129 -0
  47. package/templates/api/src/boards/auth/tenant_extraction.py +278 -0
  48. package/templates/api/src/boards/cli.py +354 -0
  49. package/templates/api/src/boards/config.py +116 -0
  50. package/templates/api/src/boards/database/__init__.py +7 -0
  51. package/templates/api/src/boards/database/cli.py +110 -0
  52. package/templates/api/src/boards/database/connection.py +252 -0
  53. package/templates/api/src/boards/database/models.py +19 -0
  54. package/templates/api/src/boards/database/seed_data.py +182 -0
  55. package/templates/api/src/boards/dbmodels/__init__.py +455 -0
  56. package/templates/api/src/boards/generators/__init__.py +57 -0
  57. package/templates/api/src/boards/generators/artifacts.py +53 -0
  58. package/templates/api/src/boards/generators/base.py +140 -0
  59. package/templates/api/src/boards/generators/implementations/__init__.py +12 -0
  60. package/templates/api/src/boards/generators/implementations/audio/__init__.py +3 -0
  61. package/templates/api/src/boards/generators/implementations/audio/whisper.py +66 -0
  62. package/templates/api/src/boards/generators/implementations/image/__init__.py +3 -0
  63. package/templates/api/src/boards/generators/implementations/image/dalle3.py +93 -0
  64. package/templates/api/src/boards/generators/implementations/image/flux_pro.py +85 -0
  65. package/templates/api/src/boards/generators/implementations/video/__init__.py +3 -0
  66. package/templates/api/src/boards/generators/implementations/video/lipsync.py +70 -0
  67. package/templates/api/src/boards/generators/loader.py +253 -0
  68. package/templates/api/src/boards/generators/registry.py +114 -0
  69. package/templates/api/src/boards/generators/resolution.py +515 -0
  70. package/templates/api/src/boards/generators/testmods/class_gen.py +34 -0
  71. package/templates/api/src/boards/generators/testmods/import_side_effect.py +35 -0
  72. package/templates/api/src/boards/graphql/__init__.py +7 -0
  73. package/templates/api/src/boards/graphql/access_control.py +136 -0
  74. package/templates/api/src/boards/graphql/mutations/root.py +136 -0
  75. package/templates/api/src/boards/graphql/queries/root.py +116 -0
  76. package/templates/api/src/boards/graphql/resolvers/__init__.py +8 -0
  77. package/templates/api/src/boards/graphql/resolvers/auth.py +12 -0
  78. package/templates/api/src/boards/graphql/resolvers/board.py +1055 -0
  79. package/templates/api/src/boards/graphql/resolvers/generation.py +889 -0
  80. package/templates/api/src/boards/graphql/resolvers/generator.py +50 -0
  81. package/templates/api/src/boards/graphql/resolvers/user.py +25 -0
  82. package/templates/api/src/boards/graphql/schema.py +81 -0
  83. package/templates/api/src/boards/graphql/types/board.py +102 -0
  84. package/templates/api/src/boards/graphql/types/generation.py +130 -0
  85. package/templates/api/src/boards/graphql/types/generator.py +17 -0
  86. package/templates/api/src/boards/graphql/types/user.py +47 -0
  87. package/templates/api/src/boards/jobs/repository.py +104 -0
  88. package/templates/api/src/boards/logging.py +195 -0
  89. package/templates/api/src/boards/middleware.py +339 -0
  90. package/templates/api/src/boards/progress/__init__.py +4 -0
  91. package/templates/api/src/boards/progress/models.py +25 -0
  92. package/templates/api/src/boards/progress/publisher.py +64 -0
  93. package/templates/api/src/boards/py.typed +0 -0
  94. package/templates/api/src/boards/redis_pool.py +118 -0
  95. package/templates/api/src/boards/storage/__init__.py +52 -0
  96. package/templates/api/src/boards/storage/base.py +363 -0
  97. package/templates/api/src/boards/storage/config.py +187 -0
  98. package/templates/api/src/boards/storage/factory.py +278 -0
  99. package/templates/api/src/boards/storage/implementations/__init__.py +27 -0
  100. package/templates/api/src/boards/storage/implementations/gcs.py +340 -0
  101. package/templates/api/src/boards/storage/implementations/local.py +201 -0
  102. package/templates/api/src/boards/storage/implementations/s3.py +294 -0
  103. package/templates/api/src/boards/storage/implementations/supabase.py +218 -0
  104. package/templates/api/src/boards/tenant_isolation.py +446 -0
  105. package/templates/api/src/boards/validation.py +262 -0
  106. package/templates/api/src/boards/workers/__init__.py +1 -0
  107. package/templates/api/src/boards/workers/actors.py +201 -0
  108. package/templates/api/src/boards/workers/cli.py +125 -0
  109. package/templates/api/src/boards/workers/context.py +188 -0
  110. package/templates/api/src/boards/workers/middleware.py +58 -0
  111. package/templates/api/src/py.typed +0 -0
  112. package/templates/compose.dev.yaml +39 -0
  113. package/templates/compose.yaml +109 -0
  114. package/templates/docker/env.example +23 -0
  115. package/templates/web/.env.example +28 -0
  116. package/templates/web/Dockerfile +51 -0
  117. package/templates/web/components.json +22 -0
  118. package/templates/web/imageLoader.js +18 -0
  119. package/templates/web/next-env.d.ts +5 -0
  120. package/templates/web/next.config.js +36 -0
  121. package/templates/web/package.json +37 -0
  122. package/templates/web/postcss.config.mjs +7 -0
  123. package/templates/web/public/favicon.ico +0 -0
  124. package/templates/web/src/app/boards/[boardId]/page.tsx +232 -0
  125. package/templates/web/src/app/globals.css +120 -0
  126. package/templates/web/src/app/layout.tsx +21 -0
  127. package/templates/web/src/app/page.tsx +35 -0
  128. package/templates/web/src/app/providers.tsx +18 -0
  129. package/templates/web/src/components/boards/ArtifactInputSlots.tsx +142 -0
  130. package/templates/web/src/components/boards/ArtifactPreview.tsx +125 -0
  131. package/templates/web/src/components/boards/GenerationGrid.tsx +45 -0
  132. package/templates/web/src/components/boards/GenerationInput.tsx +251 -0
  133. package/templates/web/src/components/boards/GeneratorSelector.tsx +89 -0
  134. package/templates/web/src/components/header.tsx +30 -0
  135. package/templates/web/src/components/ui/button.tsx +58 -0
  136. package/templates/web/src/components/ui/card.tsx +92 -0
  137. package/templates/web/src/components/ui/navigation-menu.tsx +168 -0
  138. package/templates/web/src/lib/utils.ts +6 -0
  139. package/templates/web/tsconfig.json +47 -0
@@ -0,0 +1,889 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import TYPE_CHECKING
4
+ from uuid import UUID
5
+
6
+ import strawberry
7
+ from sqlalchemy import or_, select
8
+ from sqlalchemy.orm import selectinload
9
+
10
+ from ...database.connection import get_async_session
11
+ from ...dbmodels import BoardMembers, Boards, Generations, Users
12
+ from ...generators.registry import registry as generator_registry
13
+ from ...jobs import repository as jobs_repo
14
+ from ...logging import get_logger
15
+ from ...workers.actors import process_generation
16
+ from ..access_control import can_access_board, get_auth_context_from_info
17
+
18
+ if TYPE_CHECKING:
19
+ from ..mutations.root import CreateGenerationInput
20
+ from ..types.board import Board
21
+ from ..types.generation import ArtifactType, Generation, GenerationStatus
22
+ from ..types.user import User
23
+
24
+ logger = get_logger(__name__)
25
+
26
+
27
+ # Query resolvers
28
+ async def resolve_generation_by_id(info: strawberry.Info, id: UUID) -> Generation | None:
29
+ """
30
+ Resolve a generation by its ID.
31
+
32
+ Checks authorization: user must have access to the generation's board.
33
+ """
34
+ auth_context = await get_auth_context_from_info(info)
35
+ if auth_context is None:
36
+ return None
37
+
38
+ async with get_async_session() as session:
39
+ # Query generation
40
+ stmt = select(Generations).where(Generations.id == id)
41
+ result = await session.execute(stmt)
42
+ gen = result.scalar_one_or_none()
43
+
44
+ if not gen:
45
+ logger.info("Generation not found", generation_id=str(id))
46
+ return None
47
+
48
+ # Check board access
49
+ board_stmt = (
50
+ select(Boards)
51
+ .where(Boards.id == gen.board_id)
52
+ .options(selectinload(Boards.board_members))
53
+ )
54
+ board_result = await session.execute(board_stmt)
55
+ board = board_result.scalar_one_or_none()
56
+
57
+ if not board or not can_access_board(board, auth_context):
58
+ logger.info(
59
+ "Access denied to generation",
60
+ generation_id=str(id),
61
+ board_id=str(gen.board_id),
62
+ user_id=(
63
+ str(auth_context.user_id) if auth_context and auth_context.user_id else None
64
+ ),
65
+ )
66
+ return None
67
+
68
+ # Convert to GraphQL type
69
+ from ..types.generation import ArtifactType, GenerationStatus
70
+ from ..types.generation import Generation as GenerationType
71
+
72
+ return GenerationType(
73
+ id=gen.id,
74
+ tenant_id=gen.tenant_id,
75
+ board_id=gen.board_id,
76
+ user_id=gen.user_id,
77
+ generator_name=gen.generator_name,
78
+ artifact_type=ArtifactType(gen.artifact_type),
79
+ storage_url=gen.storage_url,
80
+ thumbnail_url=gen.thumbnail_url,
81
+ additional_files=gen.additional_files or [],
82
+ input_params=gen.input_params or {},
83
+ output_metadata=gen.output_metadata or {},
84
+ parent_generation_id=gen.parent_generation_id,
85
+ input_generation_ids=gen.input_generation_ids or [],
86
+ external_job_id=gen.external_job_id,
87
+ status=GenerationStatus(gen.status),
88
+ progress=float(gen.progress or 0.0),
89
+ error_message=gen.error_message,
90
+ started_at=gen.started_at,
91
+ completed_at=gen.completed_at,
92
+ created_at=gen.created_at,
93
+ updated_at=gen.updated_at,
94
+ )
95
+
96
+
97
+ async def resolve_recent_generations(
98
+ info: strawberry.Info,
99
+ board_id: UUID | None,
100
+ status: GenerationStatus | None,
101
+ artifact_type: ArtifactType | None,
102
+ limit: int,
103
+ offset: int,
104
+ ) -> list[Generation]:
105
+ """
106
+ Resolve recent generations with filtering.
107
+
108
+ If board_id is None, returns generations from all boards the user has access to.
109
+ """
110
+ auth_context = await get_auth_context_from_info(info)
111
+ if not auth_context or not auth_context.is_authenticated:
112
+ logger.info("Unauthenticated access to recent_generations")
113
+ return []
114
+
115
+ async with get_async_session() as session:
116
+ # Build base query
117
+ generations_query = select(Generations)
118
+
119
+ # Apply filters
120
+ if board_id is not None:
121
+ # Check access to specific board
122
+ board_stmt = (
123
+ select(Boards)
124
+ .where(Boards.id == board_id)
125
+ .options(selectinload(Boards.board_members))
126
+ )
127
+ board_result = await session.execute(board_stmt)
128
+ board = board_result.scalar_one_or_none()
129
+
130
+ if not board or not can_access_board(board, auth_context):
131
+ logger.info(
132
+ "Access denied to board for recent generations",
133
+ board_id=str(board_id),
134
+ user_id=str(auth_context.user_id),
135
+ )
136
+ return []
137
+
138
+ generations_query = generations_query.where(Generations.board_id == board_id)
139
+ else:
140
+ # Get all boards user has access to
141
+ member_board_ids = select(BoardMembers.board_id).where(
142
+ BoardMembers.user_id == auth_context.user_id
143
+ )
144
+ accessible_boards_condition = or_(
145
+ Boards.owner_id == auth_context.user_id,
146
+ Boards.id.in_(member_board_ids),
147
+ Boards.is_public,
148
+ )
149
+ accessible_boards_stmt = select(Boards.id).where(accessible_boards_condition)
150
+ accessible_boards_result = await session.execute(accessible_boards_stmt)
151
+ accessible_board_ids = [row[0] for row in accessible_boards_result.all()]
152
+
153
+ if not accessible_board_ids:
154
+ return []
155
+
156
+ generations_query = generations_query.where(
157
+ Generations.board_id.in_(accessible_board_ids)
158
+ )
159
+
160
+ # Apply status filter
161
+ if status is not None:
162
+ generations_query = generations_query.where(Generations.status == status.value)
163
+
164
+ # Apply artifact_type filter
165
+ if artifact_type is not None:
166
+ generations_query = generations_query.where(
167
+ Generations.artifact_type == artifact_type.value
168
+ )
169
+
170
+ # Order by created_at DESC and apply pagination
171
+ generations_query = (
172
+ generations_query.order_by(Generations.created_at.desc()).limit(limit).offset(offset)
173
+ )
174
+
175
+ result = await session.execute(generations_query)
176
+ generations = result.scalars().all()
177
+
178
+ # Convert to GraphQL types
179
+ from ..types.generation import ArtifactType as ArtifactTypeEnum
180
+ from ..types.generation import Generation as GenerationType
181
+ from ..types.generation import GenerationStatus as GenerationStatusEnum
182
+
183
+ return [
184
+ GenerationType(
185
+ id=gen.id,
186
+ tenant_id=gen.tenant_id,
187
+ board_id=gen.board_id,
188
+ user_id=gen.user_id,
189
+ generator_name=gen.generator_name,
190
+ artifact_type=ArtifactTypeEnum(gen.artifact_type),
191
+ storage_url=gen.storage_url,
192
+ thumbnail_url=gen.thumbnail_url,
193
+ additional_files=gen.additional_files or [],
194
+ input_params=gen.input_params or {},
195
+ output_metadata=gen.output_metadata or {},
196
+ parent_generation_id=gen.parent_generation_id,
197
+ input_generation_ids=gen.input_generation_ids or [],
198
+ external_job_id=gen.external_job_id,
199
+ status=GenerationStatusEnum(gen.status),
200
+ progress=float(gen.progress or 0.0),
201
+ error_message=gen.error_message,
202
+ started_at=gen.started_at,
203
+ completed_at=gen.completed_at,
204
+ created_at=gen.created_at,
205
+ updated_at=gen.updated_at,
206
+ )
207
+ for gen in generations
208
+ ]
209
+
210
+
211
+ # Field resolvers
212
+ async def resolve_generation_board(generation: Generation, info: strawberry.Info) -> Board:
213
+ """Resolve the board this generation belongs to."""
214
+ auth_context = await get_auth_context_from_info(info)
215
+
216
+ async with get_async_session() as session:
217
+ stmt = (
218
+ select(Boards)
219
+ .where(Boards.id == generation.board_id)
220
+ .options(
221
+ selectinload(Boards.owner),
222
+ selectinload(Boards.board_members).selectinload(BoardMembers.user),
223
+ )
224
+ )
225
+ result = await session.execute(stmt)
226
+ board = result.scalar_one_or_none()
227
+
228
+ if not board:
229
+ raise RuntimeError("Generation board not found")
230
+
231
+ if not can_access_board(board, auth_context):
232
+ raise RuntimeError("Access denied to generation board")
233
+
234
+ from ..types.board import Board as BoardType
235
+
236
+ return BoardType(
237
+ id=board.id,
238
+ tenant_id=board.tenant_id,
239
+ owner_id=board.owner_id,
240
+ title=board.title,
241
+ description=board.description,
242
+ is_public=board.is_public,
243
+ settings=board.settings or {},
244
+ metadata=board.metadata_ or {},
245
+ created_at=board.created_at,
246
+ updated_at=board.updated_at,
247
+ )
248
+
249
+
250
+ async def resolve_generation_user(generation: Generation, info: strawberry.Info) -> User:
251
+ """Resolve the user who created this generation."""
252
+ async with get_async_session() as session:
253
+ stmt = select(Users).where(Users.id == generation.user_id)
254
+ result = await session.execute(stmt)
255
+ user = result.scalar_one_or_none()
256
+
257
+ if not user:
258
+ raise RuntimeError("Generation user not found")
259
+
260
+ from ..types.user import User as UserType
261
+
262
+ return UserType(
263
+ id=user.id,
264
+ tenant_id=user.tenant_id,
265
+ auth_provider=user.auth_provider,
266
+ auth_subject=user.auth_subject,
267
+ email=user.email,
268
+ display_name=user.display_name,
269
+ avatar_url=user.avatar_url,
270
+ created_at=user.created_at,
271
+ updated_at=user.updated_at,
272
+ )
273
+
274
+
275
+ async def resolve_generation_parent(
276
+ generation: Generation, info: strawberry.Info
277
+ ) -> Generation | None:
278
+ """Resolve the parent generation if any."""
279
+ if not generation.parent_generation_id:
280
+ return None
281
+
282
+ auth_context = await get_auth_context_from_info(info)
283
+
284
+ async with get_async_session() as session:
285
+ # Query parent generation
286
+ stmt = select(Generations).where(Generations.id == generation.parent_generation_id)
287
+ result = await session.execute(stmt)
288
+ parent = result.scalar_one_or_none()
289
+
290
+ if not parent:
291
+ logger.warning(
292
+ "Parent generation not found",
293
+ parent_id=str(generation.parent_generation_id),
294
+ )
295
+ return None
296
+
297
+ # Check access to parent's board
298
+ board_stmt = (
299
+ select(Boards)
300
+ .where(Boards.id == parent.board_id)
301
+ .options(selectinload(Boards.board_members))
302
+ )
303
+ board_result = await session.execute(board_stmt)
304
+ board = board_result.scalar_one_or_none()
305
+
306
+ if not board or not can_access_board(board, auth_context):
307
+ logger.info(
308
+ "Access denied to parent generation",
309
+ parent_id=str(generation.parent_generation_id),
310
+ )
311
+ return None
312
+
313
+ # Convert to GraphQL type
314
+ from ..types.generation import ArtifactType, GenerationStatus
315
+ from ..types.generation import Generation as GenerationType
316
+
317
+ return GenerationType(
318
+ id=parent.id,
319
+ tenant_id=parent.tenant_id,
320
+ board_id=parent.board_id,
321
+ user_id=parent.user_id,
322
+ generator_name=parent.generator_name,
323
+ artifact_type=ArtifactType(parent.artifact_type),
324
+ storage_url=parent.storage_url,
325
+ thumbnail_url=parent.thumbnail_url,
326
+ additional_files=parent.additional_files or [],
327
+ input_params=parent.input_params or {},
328
+ output_metadata=parent.output_metadata or {},
329
+ parent_generation_id=parent.parent_generation_id,
330
+ input_generation_ids=parent.input_generation_ids or [],
331
+ external_job_id=parent.external_job_id,
332
+ status=GenerationStatus(parent.status),
333
+ progress=float(parent.progress or 0.0),
334
+ error_message=parent.error_message,
335
+ started_at=parent.started_at,
336
+ completed_at=parent.completed_at,
337
+ created_at=parent.created_at,
338
+ updated_at=parent.updated_at,
339
+ )
340
+
341
+
342
+ async def resolve_generation_inputs(
343
+ generation: Generation, info: strawberry.Info
344
+ ) -> list[Generation]: # noqa: E501
345
+ """Resolve input generations used for this generation."""
346
+ if not generation.input_generation_ids:
347
+ return []
348
+
349
+ auth_context = await get_auth_context_from_info(info)
350
+
351
+ async with get_async_session() as session:
352
+ # Query input generations
353
+ stmt = select(Generations).where(Generations.id.in_(generation.input_generation_ids))
354
+ result = await session.execute(stmt)
355
+ inputs = result.scalars().all()
356
+
357
+ # Filter by board access
358
+ accessible_inputs = []
359
+ for input_gen in inputs:
360
+ board_stmt = (
361
+ select(Boards)
362
+ .where(Boards.id == input_gen.board_id)
363
+ .options(selectinload(Boards.board_members))
364
+ )
365
+ board_result = await session.execute(board_stmt)
366
+ board = board_result.scalar_one_or_none()
367
+
368
+ if board and can_access_board(board, auth_context):
369
+ accessible_inputs.append(input_gen)
370
+
371
+ # Convert to GraphQL types
372
+ from ..types.generation import ArtifactType, GenerationStatus
373
+ from ..types.generation import Generation as GenerationType
374
+
375
+ return [
376
+ GenerationType(
377
+ id=gen.id,
378
+ tenant_id=gen.tenant_id,
379
+ board_id=gen.board_id,
380
+ user_id=gen.user_id,
381
+ generator_name=gen.generator_name,
382
+ artifact_type=ArtifactType(gen.artifact_type),
383
+ storage_url=gen.storage_url,
384
+ thumbnail_url=gen.thumbnail_url,
385
+ additional_files=gen.additional_files or [],
386
+ input_params=gen.input_params or {},
387
+ output_metadata=gen.output_metadata or {},
388
+ parent_generation_id=gen.parent_generation_id,
389
+ input_generation_ids=gen.input_generation_ids or [],
390
+ external_job_id=gen.external_job_id,
391
+ status=GenerationStatus(gen.status),
392
+ progress=float(gen.progress or 0.0),
393
+ error_message=gen.error_message,
394
+ started_at=gen.started_at,
395
+ completed_at=gen.completed_at,
396
+ created_at=gen.created_at,
397
+ updated_at=gen.updated_at,
398
+ )
399
+ for gen in accessible_inputs
400
+ ]
401
+
402
+
403
+ async def resolve_generation_children(
404
+ generation: Generation, info: strawberry.Info
405
+ ) -> list[Generation]: # noqa: E501
406
+ """Resolve child generations derived from this one."""
407
+ auth_context = await get_auth_context_from_info(info)
408
+
409
+ async with get_async_session() as session:
410
+ # Query child generations
411
+ stmt = select(Generations).where(Generations.parent_generation_id == generation.id)
412
+ result = await session.execute(stmt)
413
+ children = result.scalars().all()
414
+
415
+ # Filter by board access
416
+ accessible_children = []
417
+ for child_gen in children:
418
+ board_stmt = (
419
+ select(Boards)
420
+ .where(Boards.id == child_gen.board_id)
421
+ .options(selectinload(Boards.board_members))
422
+ )
423
+ board_result = await session.execute(board_stmt)
424
+ board = board_result.scalar_one_or_none()
425
+
426
+ if board and can_access_board(board, auth_context):
427
+ accessible_children.append(child_gen)
428
+
429
+ # Convert to GraphQL types
430
+ from ..types.generation import ArtifactType, GenerationStatus
431
+ from ..types.generation import Generation as GenerationType
432
+
433
+ return [
434
+ GenerationType(
435
+ id=gen.id,
436
+ tenant_id=gen.tenant_id,
437
+ board_id=gen.board_id,
438
+ user_id=gen.user_id,
439
+ generator_name=gen.generator_name,
440
+ artifact_type=ArtifactType(gen.artifact_type),
441
+ storage_url=gen.storage_url,
442
+ thumbnail_url=gen.thumbnail_url,
443
+ additional_files=gen.additional_files or [],
444
+ input_params=gen.input_params or {},
445
+ output_metadata=gen.output_metadata or {},
446
+ parent_generation_id=gen.parent_generation_id,
447
+ input_generation_ids=gen.input_generation_ids or [],
448
+ external_job_id=gen.external_job_id,
449
+ status=GenerationStatus(gen.status),
450
+ progress=float(gen.progress or 0.0),
451
+ error_message=gen.error_message,
452
+ started_at=gen.started_at,
453
+ completed_at=gen.completed_at,
454
+ created_at=gen.created_at,
455
+ updated_at=gen.updated_at,
456
+ )
457
+ for gen in accessible_children
458
+ ]
459
+
460
+
461
+ # Mutation resolvers
462
+ async def create_generation(info: strawberry.Info, input: CreateGenerationInput) -> Generation:
463
+ """
464
+ Create a new generation and enqueue it for processing.
465
+
466
+ Requires editor or owner role on the target board.
467
+ """
468
+ auth_context = await get_auth_context_from_info(info)
469
+ if not auth_context or not auth_context.is_authenticated or not auth_context.user_id:
470
+ raise RuntimeError("Authentication required to create a generation")
471
+
472
+ async with get_async_session() as session:
473
+ # Check board access - require editor or owner role
474
+ board_stmt = (
475
+ select(Boards)
476
+ .where(Boards.id == input.board_id)
477
+ .options(selectinload(Boards.board_members))
478
+ )
479
+ board_result = await session.execute(board_stmt)
480
+ board = board_result.scalar_one_or_none()
481
+
482
+ if not board:
483
+ raise RuntimeError("Board not found")
484
+
485
+ # Check if user is owner or editor
486
+ is_owner = board.owner_id == auth_context.user_id
487
+ is_editor = any(
488
+ member.user_id == auth_context.user_id and member.role in {"editor", "admin"}
489
+ for member in board.board_members
490
+ )
491
+
492
+ if not is_owner and not is_editor:
493
+ raise RuntimeError(
494
+ "Permission denied: only board owner or editor can create generations"
495
+ )
496
+
497
+ # Validate generator exists
498
+ generator = generator_registry.get(input.generator_name)
499
+ if generator is None:
500
+ raise RuntimeError(f"Unknown generator: {input.generator_name}")
501
+
502
+ # Verify access to parent generation if provided
503
+ if input.parent_generation_id:
504
+ parent_stmt = select(Generations).where(Generations.id == input.parent_generation_id)
505
+ parent_result = await session.execute(parent_stmt)
506
+ parent_gen = parent_result.scalar_one_or_none()
507
+
508
+ if not parent_gen:
509
+ raise RuntimeError("Parent generation not found")
510
+
511
+ # Check access to parent's board
512
+ parent_board_stmt = (
513
+ select(Boards)
514
+ .where(Boards.id == parent_gen.board_id)
515
+ .options(selectinload(Boards.board_members))
516
+ )
517
+ parent_board_result = await session.execute(parent_board_stmt)
518
+ parent_board = parent_board_result.scalar_one_or_none()
519
+
520
+ if not parent_board or not can_access_board(parent_board, auth_context):
521
+ raise RuntimeError("Access denied to parent generation")
522
+
523
+ # Verify access to input generations if provided
524
+ if input.input_generation_ids:
525
+ for input_gen_id in input.input_generation_ids:
526
+ input_stmt = select(Generations).where(Generations.id == input_gen_id)
527
+ input_result = await session.execute(input_stmt)
528
+ input_gen = input_result.scalar_one_or_none()
529
+
530
+ if not input_gen:
531
+ raise RuntimeError(f"Input generation {input_gen_id} not found")
532
+
533
+ # Check access to input's board
534
+ input_board_stmt = (
535
+ select(Boards)
536
+ .where(Boards.id == input_gen.board_id)
537
+ .options(selectinload(Boards.board_members))
538
+ )
539
+ input_board_result = await session.execute(input_board_stmt)
540
+ input_board = input_board_result.scalar_one_or_none()
541
+
542
+ if not input_board or not can_access_board(input_board, auth_context):
543
+ raise RuntimeError(f"Access denied to input generation {input_gen_id}")
544
+
545
+ # Create generation record
546
+ gen = await jobs_repo.create_generation(
547
+ session,
548
+ tenant_id=auth_context.tenant_id,
549
+ board_id=input.board_id,
550
+ user_id=auth_context.user_id,
551
+ generator_name=input.generator_name,
552
+ artifact_type=input.artifact_type.value,
553
+ input_params=input.input_params,
554
+ )
555
+
556
+ # Update parent and input relationships if provided
557
+ if input.parent_generation_id:
558
+ gen.parent_generation_id = input.parent_generation_id
559
+ if input.input_generation_ids:
560
+ gen.input_generation_ids = input.input_generation_ids
561
+
562
+ await session.commit()
563
+ await session.refresh(gen)
564
+
565
+ logger.info(
566
+ "Generation created",
567
+ generation_id=str(gen.id),
568
+ board_id=str(input.board_id),
569
+ user_id=str(auth_context.user_id),
570
+ generator_name=input.generator_name,
571
+ )
572
+
573
+ # Enqueue job for processing
574
+ message = process_generation.send(str(gen.id))
575
+ logger.info(
576
+ "Generation job enqueued",
577
+ generation_id=str(gen.id),
578
+ message_id=message.message_id,
579
+ queue_name=message.queue_name,
580
+ )
581
+
582
+ # Convert to GraphQL type
583
+ from ..types.generation import ArtifactType, GenerationStatus
584
+ from ..types.generation import Generation as GenerationType
585
+
586
+ return GenerationType(
587
+ id=gen.id,
588
+ tenant_id=gen.tenant_id,
589
+ board_id=gen.board_id,
590
+ user_id=gen.user_id,
591
+ generator_name=gen.generator_name,
592
+ artifact_type=ArtifactType(gen.artifact_type),
593
+ storage_url=gen.storage_url,
594
+ thumbnail_url=gen.thumbnail_url,
595
+ additional_files=gen.additional_files or [],
596
+ input_params=gen.input_params or {},
597
+ output_metadata=gen.output_metadata or {},
598
+ parent_generation_id=gen.parent_generation_id,
599
+ input_generation_ids=gen.input_generation_ids or [],
600
+ external_job_id=gen.external_job_id,
601
+ status=GenerationStatus(gen.status),
602
+ progress=float(gen.progress or 0.0),
603
+ error_message=gen.error_message,
604
+ started_at=gen.started_at,
605
+ completed_at=gen.completed_at,
606
+ created_at=gen.created_at,
607
+ updated_at=gen.updated_at,
608
+ )
609
+
610
+
611
+ async def cancel_generation(info: strawberry.Info, id: UUID) -> Generation:
612
+ """
613
+ Cancel a pending or processing generation.
614
+
615
+ Requires ownership or editor role on the board.
616
+ """
617
+ auth_context = await get_auth_context_from_info(info)
618
+ if not auth_context or not auth_context.is_authenticated:
619
+ raise RuntimeError("Authentication required to cancel a generation")
620
+
621
+ async with get_async_session() as session:
622
+ # Query generation
623
+ stmt = select(Generations).where(Generations.id == id)
624
+ result = await session.execute(stmt)
625
+ gen = result.scalar_one_or_none()
626
+
627
+ if not gen:
628
+ raise RuntimeError("Generation not found")
629
+
630
+ # Check board access and permissions
631
+ board_stmt = (
632
+ select(Boards)
633
+ .where(Boards.id == gen.board_id)
634
+ .options(selectinload(Boards.board_members))
635
+ )
636
+ board_result = await session.execute(board_stmt)
637
+ board = board_result.scalar_one_or_none()
638
+
639
+ if not board:
640
+ raise RuntimeError("Board not found")
641
+
642
+ # Check authorization
643
+ is_owner = board.owner_id == auth_context.user_id
644
+ is_editor = any(
645
+ member.user_id == auth_context.user_id and member.role in {"editor", "admin"}
646
+ for member in board.board_members
647
+ )
648
+ is_creator = gen.user_id == auth_context.user_id
649
+
650
+ # Owner can cancel any generation, editor can only cancel their own
651
+ if not is_owner and not (is_editor and is_creator):
652
+ raise RuntimeError(
653
+ "Permission denied: only board owner or generation creator can cancel"
654
+ )
655
+
656
+ # Validate status
657
+ if gen.status not in {"pending", "processing"}:
658
+ raise RuntimeError(
659
+ f"Cannot cancel generation with status '{gen.status}'. "
660
+ "Only pending or processing generations can be cancelled."
661
+ )
662
+
663
+ # Update status to cancelled
664
+ await jobs_repo.update_progress(
665
+ session,
666
+ id,
667
+ status="cancelled",
668
+ progress=float(gen.progress or 0.0),
669
+ error_message="Cancelled by user",
670
+ )
671
+ await session.commit()
672
+
673
+ # Refresh to get updated data
674
+ await session.refresh(gen)
675
+
676
+ logger.info(
677
+ "Generation cancelled",
678
+ generation_id=str(id),
679
+ user_id=str(auth_context.user_id),
680
+ )
681
+
682
+ # Convert to GraphQL type
683
+ from ..types.generation import ArtifactType, GenerationStatus
684
+ from ..types.generation import Generation as GenerationType
685
+
686
+ return GenerationType(
687
+ id=gen.id,
688
+ tenant_id=gen.tenant_id,
689
+ board_id=gen.board_id,
690
+ user_id=gen.user_id,
691
+ generator_name=gen.generator_name,
692
+ artifact_type=ArtifactType(gen.artifact_type),
693
+ storage_url=gen.storage_url,
694
+ thumbnail_url=gen.thumbnail_url,
695
+ additional_files=gen.additional_files or [],
696
+ input_params=gen.input_params or {},
697
+ output_metadata=gen.output_metadata or {},
698
+ parent_generation_id=gen.parent_generation_id,
699
+ input_generation_ids=gen.input_generation_ids or [],
700
+ external_job_id=gen.external_job_id,
701
+ status=GenerationStatus(gen.status),
702
+ progress=float(gen.progress or 0.0),
703
+ error_message=gen.error_message,
704
+ started_at=gen.started_at,
705
+ completed_at=gen.completed_at,
706
+ created_at=gen.created_at,
707
+ updated_at=gen.updated_at,
708
+ )
709
+
710
+
711
+ async def delete_generation(info: strawberry.Info, id: UUID) -> bool:
712
+ """
713
+ Delete a generation and its associated storage artifacts.
714
+
715
+ Requires ownership or editor role on the board.
716
+ """
717
+ auth_context = await get_auth_context_from_info(info)
718
+ if not auth_context or not auth_context.is_authenticated:
719
+ raise RuntimeError("Authentication required to delete a generation")
720
+
721
+ async with get_async_session() as session:
722
+ # Query generation
723
+ stmt = select(Generations).where(Generations.id == id)
724
+ result = await session.execute(stmt)
725
+ gen = result.scalar_one_or_none()
726
+
727
+ if not gen:
728
+ raise RuntimeError("Generation not found")
729
+
730
+ # Check board access and permissions
731
+ board_stmt = (
732
+ select(Boards)
733
+ .where(Boards.id == gen.board_id)
734
+ .options(selectinload(Boards.board_members))
735
+ )
736
+ board_result = await session.execute(board_stmt)
737
+ board = board_result.scalar_one_or_none()
738
+
739
+ if not board:
740
+ raise RuntimeError("Board not found")
741
+
742
+ # Check authorization
743
+ is_owner = board.owner_id == auth_context.user_id
744
+ is_editor = any(
745
+ member.user_id == auth_context.user_id and member.role in {"editor", "admin"}
746
+ for member in board.board_members
747
+ )
748
+ is_creator = gen.user_id == auth_context.user_id
749
+
750
+ # Owner can delete any generation, editor can only delete their own
751
+ if not is_owner and not (is_editor and is_creator):
752
+ raise RuntimeError(
753
+ "Permission denied: only board owner or generation creator can delete"
754
+ )
755
+
756
+ # Delete storage artifacts
757
+ # TODO: Full storage deletion requires storage_key and storage_provider fields
758
+ # to be added to the Generations table. For now, we log the deletion intent.
759
+ # Once those fields are added, use: await storage_manager.delete_artifact(key, provider)
760
+
761
+ if gen.storage_url or gen.thumbnail_url or gen.additional_files:
762
+ logger.info(
763
+ "Storage artifact deletion",
764
+ generation_id=str(id),
765
+ has_storage_url=bool(gen.storage_url),
766
+ has_thumbnail=bool(gen.thumbnail_url),
767
+ additional_files_count=(len(gen.additional_files) if gen.additional_files else 0),
768
+ )
769
+ logger.warning(
770
+ "Storage artifact deletion not yet implemented - requires storage_key and "
771
+ "storage_provider fields in Generations table",
772
+ generation_id=str(id),
773
+ )
774
+
775
+ # Delete generation from database
776
+ await session.delete(gen)
777
+ await session.commit()
778
+
779
+ logger.info(
780
+ "Generation deleted",
781
+ generation_id=str(id),
782
+ user_id=str(auth_context.user_id),
783
+ )
784
+
785
+ return True
786
+
787
+
788
+ async def regenerate(info: strawberry.Info, id: UUID) -> Generation:
789
+ """
790
+ Regenerate from an existing generation.
791
+
792
+ Creates a new generation with the same inputs and sets the original as parent.
793
+ """
794
+ auth_context = await get_auth_context_from_info(info)
795
+ if not auth_context or not auth_context.is_authenticated or not auth_context.user_id:
796
+ raise RuntimeError("Authentication required to regenerate")
797
+
798
+ async with get_async_session() as session:
799
+ # Query original generation
800
+ stmt = select(Generations).where(Generations.id == id)
801
+ result = await session.execute(stmt)
802
+ original = result.scalar_one_or_none()
803
+
804
+ if not original:
805
+ raise RuntimeError("Original generation not found")
806
+
807
+ # Check board access - require editor or owner role
808
+ board_stmt = (
809
+ select(Boards)
810
+ .where(Boards.id == original.board_id)
811
+ .options(selectinload(Boards.board_members))
812
+ )
813
+ board_result = await session.execute(board_stmt)
814
+ board = board_result.scalar_one_or_none()
815
+
816
+ if not board:
817
+ raise RuntimeError("Board not found")
818
+
819
+ # Check if user is owner or editor
820
+ is_owner = board.owner_id == auth_context.user_id
821
+ is_editor = any(
822
+ member.user_id == auth_context.user_id and member.role in {"editor", "admin"}
823
+ for member in board.board_members
824
+ )
825
+
826
+ if not is_owner and not is_editor:
827
+ raise RuntimeError("Permission denied: only board owner or editor can regenerate")
828
+
829
+ # Validate generator still exists
830
+ generator = generator_registry.get(original.generator_name)
831
+ if generator is None:
832
+ raise RuntimeError(f"Generator '{original.generator_name}' is no longer available")
833
+
834
+ # Create new generation with copied inputs
835
+ new_gen = await jobs_repo.create_generation(
836
+ session,
837
+ tenant_id=original.tenant_id,
838
+ board_id=original.board_id,
839
+ user_id=auth_context.user_id,
840
+ generator_name=original.generator_name,
841
+ artifact_type=original.artifact_type,
842
+ input_params=original.input_params or {},
843
+ )
844
+
845
+ # Set parent and copy input relationships
846
+ new_gen.parent_generation_id = original.id
847
+ new_gen.input_generation_ids = original.input_generation_ids or []
848
+
849
+ await session.commit()
850
+ await session.refresh(new_gen)
851
+
852
+ logger.info(
853
+ "Generation regenerated",
854
+ new_generation_id=str(new_gen.id),
855
+ original_generation_id=str(id),
856
+ user_id=str(auth_context.user_id),
857
+ )
858
+
859
+ # Enqueue job for processing
860
+ process_generation.send(str(new_gen.id))
861
+ logger.info("Regeneration job enqueued", generation_id=str(new_gen.id))
862
+
863
+ # Convert to GraphQL type
864
+ from ..types.generation import ArtifactType, GenerationStatus
865
+ from ..types.generation import Generation as GenerationType
866
+
867
+ return GenerationType(
868
+ id=new_gen.id,
869
+ tenant_id=new_gen.tenant_id,
870
+ board_id=new_gen.board_id,
871
+ user_id=new_gen.user_id,
872
+ generator_name=new_gen.generator_name,
873
+ artifact_type=ArtifactType(new_gen.artifact_type),
874
+ storage_url=new_gen.storage_url,
875
+ thumbnail_url=new_gen.thumbnail_url,
876
+ additional_files=new_gen.additional_files or [],
877
+ input_params=new_gen.input_params or {},
878
+ output_metadata=new_gen.output_metadata or {},
879
+ parent_generation_id=new_gen.parent_generation_id,
880
+ input_generation_ids=new_gen.input_generation_ids or [],
881
+ external_job_id=new_gen.external_job_id,
882
+ status=GenerationStatus(new_gen.status),
883
+ progress=float(new_gen.progress or 0.0),
884
+ error_message=new_gen.error_message,
885
+ started_at=new_gen.started_at,
886
+ completed_at=new_gen.completed_at,
887
+ created_at=new_gen.created_at,
888
+ updated_at=new_gen.updated_at,
889
+ )