agno 2.3.25__py3-none-any.whl → 2.4.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (128) hide show
  1. agno/agent/__init__.py +4 -0
  2. agno/agent/agent.py +1428 -558
  3. agno/agent/remote.py +13 -0
  4. agno/db/base.py +339 -0
  5. agno/db/postgres/async_postgres.py +116 -12
  6. agno/db/postgres/postgres.py +1229 -25
  7. agno/db/postgres/schemas.py +48 -1
  8. agno/db/sqlite/async_sqlite.py +119 -4
  9. agno/db/sqlite/schemas.py +51 -0
  10. agno/db/sqlite/sqlite.py +1173 -13
  11. agno/db/utils.py +37 -1
  12. agno/knowledge/__init__.py +4 -0
  13. agno/knowledge/chunking/code.py +1 -1
  14. agno/knowledge/chunking/semantic.py +1 -1
  15. agno/knowledge/chunking/strategy.py +4 -0
  16. agno/knowledge/filesystem.py +412 -0
  17. agno/knowledge/knowledge.py +2767 -2254
  18. agno/knowledge/protocol.py +134 -0
  19. agno/knowledge/reader/arxiv_reader.py +2 -2
  20. agno/knowledge/reader/base.py +9 -7
  21. agno/knowledge/reader/csv_reader.py +5 -5
  22. agno/knowledge/reader/docx_reader.py +2 -2
  23. agno/knowledge/reader/field_labeled_csv_reader.py +2 -2
  24. agno/knowledge/reader/firecrawl_reader.py +2 -2
  25. agno/knowledge/reader/json_reader.py +2 -2
  26. agno/knowledge/reader/markdown_reader.py +2 -2
  27. agno/knowledge/reader/pdf_reader.py +5 -4
  28. agno/knowledge/reader/pptx_reader.py +2 -2
  29. agno/knowledge/reader/reader_factory.py +110 -0
  30. agno/knowledge/reader/s3_reader.py +2 -2
  31. agno/knowledge/reader/tavily_reader.py +2 -2
  32. agno/knowledge/reader/text_reader.py +2 -2
  33. agno/knowledge/reader/web_search_reader.py +2 -2
  34. agno/knowledge/reader/website_reader.py +5 -3
  35. agno/knowledge/reader/wikipedia_reader.py +2 -2
  36. agno/knowledge/reader/youtube_reader.py +2 -2
  37. agno/knowledge/utils.py +37 -29
  38. agno/learn/__init__.py +6 -0
  39. agno/learn/machine.py +35 -0
  40. agno/learn/schemas.py +82 -11
  41. agno/learn/stores/__init__.py +3 -0
  42. agno/learn/stores/decision_log.py +1156 -0
  43. agno/learn/stores/learned_knowledge.py +6 -6
  44. agno/models/anthropic/claude.py +24 -0
  45. agno/models/aws/bedrock.py +20 -0
  46. agno/models/base.py +48 -4
  47. agno/models/cohere/chat.py +25 -0
  48. agno/models/google/gemini.py +50 -5
  49. agno/models/litellm/chat.py +38 -0
  50. agno/models/openai/chat.py +7 -0
  51. agno/models/openrouter/openrouter.py +46 -0
  52. agno/models/response.py +16 -0
  53. agno/os/app.py +83 -44
  54. agno/os/middleware/__init__.py +2 -0
  55. agno/os/middleware/trailing_slash.py +27 -0
  56. agno/os/router.py +1 -0
  57. agno/os/routers/agents/router.py +29 -16
  58. agno/os/routers/agents/schema.py +6 -4
  59. agno/os/routers/components/__init__.py +3 -0
  60. agno/os/routers/components/components.py +466 -0
  61. agno/os/routers/evals/schemas.py +4 -3
  62. agno/os/routers/health.py +3 -3
  63. agno/os/routers/knowledge/knowledge.py +3 -3
  64. agno/os/routers/memory/schemas.py +4 -2
  65. agno/os/routers/metrics/metrics.py +9 -11
  66. agno/os/routers/metrics/schemas.py +10 -6
  67. agno/os/routers/registry/__init__.py +3 -0
  68. agno/os/routers/registry/registry.py +337 -0
  69. agno/os/routers/teams/router.py +20 -8
  70. agno/os/routers/teams/schema.py +6 -4
  71. agno/os/routers/traces/traces.py +5 -5
  72. agno/os/routers/workflows/router.py +38 -11
  73. agno/os/routers/workflows/schema.py +1 -1
  74. agno/os/schema.py +92 -26
  75. agno/os/utils.py +133 -16
  76. agno/reasoning/anthropic.py +2 -2
  77. agno/reasoning/azure_ai_foundry.py +2 -2
  78. agno/reasoning/deepseek.py +2 -2
  79. agno/reasoning/default.py +6 -7
  80. agno/reasoning/gemini.py +2 -2
  81. agno/reasoning/helpers.py +6 -7
  82. agno/reasoning/manager.py +4 -10
  83. agno/reasoning/ollama.py +2 -2
  84. agno/reasoning/openai.py +2 -2
  85. agno/reasoning/vertexai.py +2 -2
  86. agno/registry/__init__.py +3 -0
  87. agno/registry/registry.py +68 -0
  88. agno/run/agent.py +57 -0
  89. agno/run/base.py +7 -0
  90. agno/run/team.py +57 -0
  91. agno/skills/agent_skills.py +10 -3
  92. agno/team/__init__.py +3 -1
  93. agno/team/team.py +1276 -326
  94. agno/tools/duckduckgo.py +25 -71
  95. agno/tools/exa.py +0 -21
  96. agno/tools/function.py +35 -83
  97. agno/tools/knowledge.py +9 -4
  98. agno/tools/mem0.py +11 -10
  99. agno/tools/memory.py +47 -46
  100. agno/tools/parallel.py +0 -7
  101. agno/tools/reasoning.py +30 -23
  102. agno/tools/tavily.py +4 -1
  103. agno/tools/websearch.py +93 -0
  104. agno/tools/website.py +1 -1
  105. agno/tools/wikipedia.py +1 -1
  106. agno/tools/workflow.py +48 -47
  107. agno/utils/agent.py +42 -5
  108. agno/utils/events.py +160 -2
  109. agno/utils/print_response/agent.py +0 -31
  110. agno/utils/print_response/team.py +0 -2
  111. agno/utils/print_response/workflow.py +0 -2
  112. agno/utils/team.py +61 -11
  113. agno/vectordb/lancedb/lance_db.py +4 -1
  114. agno/vectordb/mongodb/mongodb.py +1 -1
  115. agno/vectordb/qdrant/qdrant.py +4 -4
  116. agno/workflow/__init__.py +3 -1
  117. agno/workflow/condition.py +0 -21
  118. agno/workflow/loop.py +0 -21
  119. agno/workflow/parallel.py +0 -21
  120. agno/workflow/router.py +0 -21
  121. agno/workflow/step.py +117 -24
  122. agno/workflow/steps.py +0 -21
  123. agno/workflow/workflow.py +625 -63
  124. {agno-2.3.25.dist-info → agno-2.4.0.dist-info}/METADATA +46 -76
  125. {agno-2.3.25.dist-info → agno-2.4.0.dist-info}/RECORD +128 -117
  126. {agno-2.3.25.dist-info → agno-2.4.0.dist-info}/WHEEL +0 -0
  127. {agno-2.3.25.dist-info → agno-2.4.0.dist-info}/licenses/LICENSE +0 -0
  128. {agno-2.3.25.dist-info → agno-2.4.0.dist-info}/top_level.txt +0 -0
agno/db/sqlite/sqlite.py CHANGED
@@ -7,7 +7,7 @@ from uuid import uuid4
7
7
  if TYPE_CHECKING:
8
8
  from agno.tracing.schemas import Span, Trace
9
9
 
10
- from agno.db.base import BaseDb, SessionType
10
+ from agno.db.base import BaseDb, ComponentType, SessionType
11
11
  from agno.db.migrations.manager import MigrationManager
12
12
  from agno.db.schemas.culture import CulturalKnowledge
13
13
  from agno.db.schemas.evals import EvalFilterType, EvalRunRecord, EvalType
@@ -55,6 +55,9 @@ class SqliteDb(BaseDb):
55
55
  traces_table: Optional[str] = None,
56
56
  spans_table: Optional[str] = None,
57
57
  versions_table: Optional[str] = None,
58
+ components_table: Optional[str] = None,
59
+ component_configs_table: Optional[str] = None,
60
+ component_links_table: Optional[str] = None,
58
61
  learnings_table: Optional[str] = None,
59
62
  id: Optional[str] = None,
60
63
  ):
@@ -80,6 +83,9 @@ class SqliteDb(BaseDb):
80
83
  traces_table (Optional[str]): Name of the table to store run traces.
81
84
  spans_table (Optional[str]): Name of the table to store span events.
82
85
  versions_table (Optional[str]): Name of the table to store schema versions.
86
+ components_table (Optional[str]): Name of the table to store components.
87
+ component_configs_table (Optional[str]): Name of the table to store component configurations.
88
+ component_links_table (Optional[str]): Name of the table to store component links.
83
89
  learnings_table (Optional[str]): Name of the table to store learning records.
84
90
  id (Optional[str]): ID of the database.
85
91
 
@@ -101,6 +107,9 @@ class SqliteDb(BaseDb):
101
107
  traces_table=traces_table,
102
108
  spans_table=spans_table,
103
109
  versions_table=versions_table,
110
+ components_table=components_table,
111
+ component_configs_table=component_configs_table,
112
+ component_links_table=component_links_table,
104
113
  learnings_table=learnings_table,
105
114
  )
106
115
 
@@ -128,6 +137,38 @@ class SqliteDb(BaseDb):
128
137
  # Initialize database session
129
138
  self.Session: scoped_session = scoped_session(sessionmaker(bind=self.db_engine))
130
139
 
140
+ # -- Serialization methods --
141
+ def to_dict(self) -> Dict[str, Any]:
142
+ base = super().to_dict()
143
+ base.update(
144
+ {
145
+ "db_file": self.db_file,
146
+ "db_url": self.db_url,
147
+ "type": "sqlite",
148
+ }
149
+ )
150
+ return base
151
+
152
+ @classmethod
153
+ def from_dict(cls, data: Dict[str, Any]) -> "SqliteDb":
154
+ return cls(
155
+ db_file=data.get("db_file"),
156
+ db_url=data.get("db_url"),
157
+ session_table=data.get("session_table"),
158
+ culture_table=data.get("culture_table"),
159
+ memory_table=data.get("memory_table"),
160
+ metrics_table=data.get("metrics_table"),
161
+ eval_table=data.get("eval_table"),
162
+ knowledge_table=data.get("knowledge_table"),
163
+ traces_table=data.get("traces_table"),
164
+ spans_table=data.get("spans_table"),
165
+ versions_table=data.get("versions_table"),
166
+ components_table=data.get("components_table"),
167
+ component_configs_table=data.get("component_configs_table"),
168
+ component_links_table=data.get("component_links_table"),
169
+ id=data.get("id"),
170
+ )
171
+
131
172
  def close(self) -> None:
132
173
  """Close database connections and dispose of the connection pool.
133
174
 
@@ -159,6 +200,9 @@ class SqliteDb(BaseDb):
159
200
  (self.eval_table_name, "evals"),
160
201
  (self.knowledge_table_name, "knowledge"),
161
202
  (self.versions_table_name, "versions"),
203
+ (self.components_table_name, "components"),
204
+ (self.component_configs_table_name, "component_configs"),
205
+ (self.component_links_table_name, "component_links"),
162
206
  (self.learnings_table_name, "learnings"),
163
207
  ]
164
208
 
@@ -169,6 +213,12 @@ class SqliteDb(BaseDb):
169
213
  """
170
214
  Create a table with the appropriate schema based on the table type.
171
215
 
216
+ Supports:
217
+ - _unique_constraints: [{"name": "...", "columns": [...]}]
218
+ - __primary_key__: ["col1", "col2", ...]
219
+ - __foreign_keys__: [{"columns":[...], "ref_table":"...", "ref_columns":[...]}]
220
+ - column-level foreign_key: "logical_table.column" (resolved via _resolve_* helpers)
221
+
172
222
  Args:
173
223
  table_name (str): Name of the table to create
174
224
  table_type (str): Type of table (used to get schema definition)
@@ -177,48 +227,104 @@ class SqliteDb(BaseDb):
177
227
  Table: SQLAlchemy Table object
178
228
  """
179
229
  try:
230
+ from sqlalchemy.schema import ForeignKeyConstraint, PrimaryKeyConstraint
231
+
180
232
  # Pass traces_table_name for spans table foreign key resolution
181
233
  table_schema = get_table_schema_definition(table_type, traces_table_name=self.trace_table_name).copy()
182
234
 
183
235
  columns: List[Column] = []
184
236
  indexes: List[str] = []
185
- unique_constraints: List[str] = []
237
+
238
+ # Extract special schema keys before iterating columns
186
239
  schema_unique_constraints = table_schema.pop("_unique_constraints", [])
240
+ schema_primary_key = table_schema.pop("__primary_key__", None)
241
+ schema_foreign_keys = table_schema.pop("__foreign_keys__", [])
187
242
 
188
- # Get the columns, indexes, and unique constraints from the table schema
243
+ # Build columns
189
244
  for col_name, col_config in table_schema.items():
190
245
  column_args = [col_name, col_config["type"]()]
191
- column_kwargs = {}
246
+ column_kwargs: Dict[str, Any] = {}
192
247
 
193
- if col_config.get("primary_key", False):
248
+ # Column-level PK only if no composite PK is defined
249
+ if col_config.get("primary_key", False) and schema_primary_key is None:
194
250
  column_kwargs["primary_key"] = True
251
+
195
252
  if "nullable" in col_config:
196
253
  column_kwargs["nullable"] = col_config["nullable"]
254
+
255
+ if "default" in col_config:
256
+ column_kwargs["default"] = col_config["default"]
257
+
197
258
  if col_config.get("index", False):
198
259
  indexes.append(col_name)
260
+
199
261
  if col_config.get("unique", False):
200
262
  column_kwargs["unique"] = True
201
- unique_constraints.append(col_name)
202
263
 
203
- # Handle foreign key constraint
264
+ # Single-column FK
204
265
  if "foreign_key" in col_config:
205
- column_args.append(ForeignKey(col_config["foreign_key"]))
266
+ fk_ref = self._resolve_fk_reference(col_config["foreign_key"])
267
+ column_args.append(ForeignKey(fk_ref))
206
268
 
207
269
  columns.append(Column(*column_args, **column_kwargs)) # type: ignore
208
270
 
209
271
  # Create the table object
210
272
  table = Table(table_name, self.metadata, *columns)
211
273
 
212
- # Add multi-column unique constraints with table-specific names
274
+ # Composite PK
275
+ if schema_primary_key is not None:
276
+ missing = [c for c in schema_primary_key if c not in table.c]
277
+ if missing:
278
+ raise ValueError(f"Composite PK references missing columns in {table_name}: {missing}")
279
+
280
+ pk_constraint_name = f"{table_name}_pkey"
281
+ table.append_constraint(PrimaryKeyConstraint(*schema_primary_key, name=pk_constraint_name))
282
+
283
+ # Composite FKs
284
+ for fk_config in schema_foreign_keys:
285
+ fk_columns = fk_config["columns"]
286
+ ref_table_logical = fk_config["ref_table"]
287
+ ref_columns = fk_config["ref_columns"]
288
+
289
+ if len(fk_columns) != len(ref_columns):
290
+ raise ValueError(
291
+ f"Composite FK in {table_name} has mismatched columns/ref_columns: {fk_columns} vs {ref_columns}"
292
+ )
293
+
294
+ missing = [c for c in fk_columns if c not in table.c]
295
+ if missing:
296
+ raise ValueError(f"Composite FK references missing columns in {table_name}: {missing}")
297
+
298
+ resolved_ref_table = self._resolve_table_name(ref_table_logical)
299
+ fk_constraint_name = f"{table_name}_{'_'.join(fk_columns)}_fkey"
300
+
301
+ ref_column_strings = [f"{resolved_ref_table}.{col}" for col in ref_columns]
302
+
303
+ table.append_constraint(
304
+ ForeignKeyConstraint(
305
+ fk_columns,
306
+ ref_column_strings,
307
+ name=fk_constraint_name,
308
+ )
309
+ )
310
+
311
+ # Multi-column unique constraints
213
312
  for constraint in schema_unique_constraints:
214
313
  constraint_name = f"{table_name}_{constraint['name']}"
215
314
  constraint_columns = constraint["columns"]
315
+
316
+ missing = [c for c in constraint_columns if c not in table.c]
317
+ if missing:
318
+ raise ValueError(f"Unique constraint references missing columns in {table_name}: {missing}")
319
+
216
320
  table.append_constraint(UniqueConstraint(*constraint_columns, name=constraint_name))
217
321
 
218
- # Add indexes to the table definition
322
+ # Indexes
219
323
  for idx_col in indexes:
324
+ if idx_col not in table.c:
325
+ raise ValueError(f"Index references missing column in {table_name}: {idx_col}")
220
326
  idx_name = f"idx_{table_name}_{idx_col}"
221
- table.append_constraint(Index(idx_name, idx_col))
327
+ Index(idx_name, table.c[idx_col]) # Correct way; do NOT append as constraint
222
328
 
223
329
  # Create table
224
330
  table_created = False
@@ -229,7 +335,7 @@ class SqliteDb(BaseDb):
229
335
  else:
230
336
  log_debug(f"Table '{table_name}' already exists, skipping creation")
231
337
 
232
- # Create indexes
338
+ # Create indexes (SQLite)
233
339
  for idx in table.indexes:
234
340
  try:
235
341
  # Check if index already exists
@@ -241,8 +347,8 @@ class SqliteDb(BaseDb):
241
347
  continue
242
348
 
243
349
  idx.create(self.db_engine)
244
-
245
350
  log_debug(f"Created index: {idx.name} for table {table_name}")
351
+
246
352
  except Exception as e:
247
353
  log_warning(f"Error creating index {idx.name}: {e}")
248
354
 
@@ -260,6 +366,41 @@ class SqliteDb(BaseDb):
260
366
  log_error(f"Could not create table '{table_name}': {e}")
261
367
  raise e
262
368
 
369
+ def _resolve_fk_reference(self, fk_ref: str) -> str:
370
+ """
371
+ Resolve a simple foreign key reference to the actual table name.
372
+
373
+ Accepts:
374
+ - "logical_table.column" -> "{resolved_table}.{column}"
375
+ - already-qualified refs -> returned as-is
376
+ """
377
+ parts = fk_ref.split(".")
378
+ if len(parts) == 2:
379
+ table, column = parts
380
+ resolved_table = self._resolve_table_name(table)
381
+ return f"{resolved_table}.{column}"
382
+ return fk_ref
383
+
384
+ def _resolve_table_name(self, logical_name: str) -> str:
385
+ """
386
+ Resolve logical table name to configured table name.
387
+ """
388
+ table_map = {
389
+ "components": self.components_table_name,
390
+ "component_configs": self.component_configs_table_name,
391
+ "component_links": self.component_links_table_name,
392
+ "traces": self.trace_table_name,
393
+ "spans": self.span_table_name,
394
+ "sessions": self.session_table_name,
395
+ "memories": self.memory_table_name,
396
+ "metrics": self.metrics_table_name,
397
+ "evals": self.eval_table_name,
398
+ "knowledge": self.knowledge_table_name,
399
+ "culture": self.culture_table_name,
400
+ "versions": self.versions_table_name,
401
+ }
402
+ return table_map.get(logical_name, logical_name)
403
+
263
404
  def _get_table(self, table_type: str, create_table_if_not_found: Optional[bool] = False) -> Optional[Table]:
264
405
  if table_type == "sessions":
265
406
  self.session_table = self._get_or_create_table(
@@ -338,6 +479,38 @@ class SqliteDb(BaseDb):
338
479
  )
339
480
  return self.versions_table
340
481
 
482
+ elif table_type == "components":
483
+ self.components_table = self._get_or_create_table(
484
+ table_name=self.components_table_name,
485
+ table_type="components",
486
+ create_table_if_not_found=create_table_if_not_found,
487
+ )
488
+ return self.components_table
489
+
490
+ elif table_type == "component_configs":
491
+ # Ensure components table exists first (configs references components)
492
+ if create_table_if_not_found:
493
+ self._get_table(table_type="components", create_table_if_not_found=True)
494
+
495
+ self.component_configs_table = self._get_or_create_table(
496
+ table_name=self.component_configs_table_name,
497
+ table_type="component_configs",
498
+ create_table_if_not_found=create_table_if_not_found,
499
+ )
500
+ return self.component_configs_table
501
+
502
+ elif table_type == "component_links":
503
+ # Ensure components and component_configs tables exist first
504
+ if create_table_if_not_found:
505
+ self._get_table(table_type="components", create_table_if_not_found=True)
506
+ self._get_table(table_type="component_configs", create_table_if_not_found=True)
507
+
508
+ self.component_links_table = self._get_or_create_table(
509
+ table_name=self.component_links_table_name,
510
+ table_type="component_links",
511
+ create_table_if_not_found=create_table_if_not_found,
512
+ )
513
+ return self.component_links_table
341
514
  elif table_type == "learnings":
342
515
  self.learnings_table = self._get_or_create_table(
343
516
  table_name=self.learnings_table_name,
@@ -2925,6 +3098,993 @@ class SqliteDb(BaseDb):
2925
3098
  log_error(f"Error upserting cultural knowledge: {e}")
2926
3099
  raise e
2927
3100
 
3101
+ # --- Components ---
3102
+ def get_component(
3103
+ self,
3104
+ component_id: str,
3105
+ component_type: Optional[ComponentType] = None,
3106
+ ) -> Optional[Dict[str, Any]]:
3107
+ """Get a component by ID.
3108
+
3109
+ Args:
3110
+ component_id: The component ID.
3111
+ component_type: Optional type filter (agent|team|workflow).
3112
+
3113
+ Returns:
3114
+ Component dictionary or None if not found.
3115
+ """
3116
+ try:
3117
+ table = self._get_table(table_type="components")
3118
+ if table is None:
3119
+ return None
3120
+
3121
+ with self.Session() as sess:
3122
+ stmt = select(table).where(
3123
+ table.c.component_id == component_id,
3124
+ table.c.deleted_at.is_(None),
3125
+ )
3126
+ if component_type is not None:
3127
+ stmt = stmt.where(table.c.component_type == component_type.value)
3128
+
3129
+ result = sess.execute(stmt).fetchone()
3130
+ return dict(result._mapping) if result else None
3131
+
3132
+ except Exception as e:
3133
+ log_error(f"Error getting component: {e}")
3134
+ raise
3135
+
3136
+ def upsert_component(
3137
+ self,
3138
+ component_id: str,
3139
+ component_type: Optional[ComponentType] = None,
3140
+ name: Optional[str] = None,
3141
+ description: Optional[str] = None,
3142
+ metadata: Optional[Dict[str, Any]] = None,
3143
+ ) -> Dict[str, Any]:
3144
+ """Create or update a component.
3145
+
3146
+ Args:
3147
+ component_id: Unique identifier.
3148
+ component_type: Type (agent|team|workflow). Required for create, optional for update.
3149
+ name: Display name.
3150
+ description: Optional description.
3151
+ metadata: Optional metadata dict.
3152
+
3153
+ Returns:
3154
+ Created/updated component dictionary.
3155
+
3156
+ Raises:
3157
+ ValueError: If creating and component_type is not provided.
3158
+ """
3159
+ try:
3160
+ table = self._get_table(table_type="components", create_table_if_not_found=True)
3161
+ if table is None:
3162
+ raise ValueError("Components table not found")
3163
+
3164
+ with self.Session() as sess, sess.begin():
3165
+ existing = sess.execute(select(table).where(table.c.component_id == component_id)).fetchone()
3166
+
3167
+ if existing is None:
3168
+ # Create new component
3169
+ if component_type is None:
3170
+ raise ValueError("component_type is required when creating a new component")
3171
+
3172
+ sess.execute(
3173
+ table.insert().values(
3174
+ component_id=component_id,
3175
+ component_type=component_type.value if hasattr(component_type, "value") else component_type,
3176
+ name=name or component_id,
3177
+ description=description,
3178
+ current_version=None,
3179
+ metadata=metadata,
3180
+ created_at=int(time.time()),
3181
+ )
3182
+ )
3183
+ log_debug(f"Created component {component_id}")
3184
+
3185
+ elif existing.deleted_at is not None:
3186
+ # Reactivate soft-deleted
3187
+ if component_type is None:
3188
+ raise ValueError("component_type is required when reactivating a deleted component")
3189
+
3190
+ sess.execute(
3191
+ table.update()
3192
+ .where(table.c.component_id == component_id)
3193
+ .values(
3194
+ component_type=component_type.value if hasattr(component_type, "value") else component_type,
3195
+ name=name or component_id,
3196
+ description=description,
3197
+ current_version=None,
3198
+ metadata=metadata,
3199
+ updated_at=int(time.time()),
3200
+ deleted_at=None,
3201
+ )
3202
+ )
3203
+ log_debug(f"Reactivated component {component_id}")
3204
+
3205
+ else:
3206
+ # Update existing
3207
+ updates: Dict[str, Any] = {"updated_at": int(time.time())}
3208
+ if component_type is not None:
3209
+ updates["component_type"] = (
3210
+ component_type.value if hasattr(component_type, "value") else component_type
3211
+ )
3212
+ if name is not None:
3213
+ updates["name"] = name
3214
+ if description is not None:
3215
+ updates["description"] = description
3216
+ if metadata is not None:
3217
+ updates["metadata"] = metadata
3218
+
3219
+ sess.execute(table.update().where(table.c.component_id == component_id).values(**updates))
3220
+ log_debug(f"Updated component {component_id}")
3221
+
3222
+ result = self.get_component(component_id)
3223
+ if result is None:
3224
+ raise ValueError(f"Failed to get component {component_id} after upsert")
3225
+ return result
3226
+
3227
+ except Exception as e:
3228
+ log_error(f"Error upserting component: {e}")
3229
+ raise
3230
+
3231
+ def delete_component(
3232
+ self,
3233
+ component_id: str,
3234
+ hard_delete: bool = False,
3235
+ ) -> bool:
3236
+ """Delete a component and all its configs/links.
3237
+
3238
+ Args:
3239
+ component_id: The component ID.
3240
+ hard_delete: If True, permanently delete. Otherwise soft-delete.
3241
+
3242
+ Returns:
3243
+ True if deleted, False if not found.
3244
+ """
3245
+ try:
3246
+ components_table = self._get_table(table_type="components")
3247
+ configs_table = self._get_table(table_type="component_configs")
3248
+ links_table = self._get_table(table_type="component_links")
3249
+
3250
+ if components_table is None:
3251
+ return False
3252
+
3253
+ with self.Session() as sess, sess.begin():
3254
+ if hard_delete:
3255
+ # Delete links where this component is parent or child
3256
+ if links_table is not None:
3257
+ sess.execute(links_table.delete().where(links_table.c.parent_component_id == component_id))
3258
+ sess.execute(links_table.delete().where(links_table.c.child_component_id == component_id))
3259
+ # Delete configs
3260
+ if configs_table is not None:
3261
+ sess.execute(configs_table.delete().where(configs_table.c.component_id == component_id))
3262
+ # Delete component
3263
+ result = sess.execute(
3264
+ components_table.delete().where(components_table.c.component_id == component_id)
3265
+ )
3266
+ else:
3267
+ # Soft delete
3268
+ now = int(time.time())
3269
+ result = sess.execute(
3270
+ components_table.update()
3271
+ .where(components_table.c.component_id == component_id)
3272
+ .values(deleted_at=now)
3273
+ )
3274
+
3275
+ return result.rowcount > 0
3276
+
3277
+ except Exception as e:
3278
+ log_error(f"Error deleting component: {e}")
3279
+ raise
3280
+
3281
+ def list_components(
3282
+ self,
3283
+ component_type: Optional[ComponentType] = None,
3284
+ include_deleted: bool = False,
3285
+ limit: int = 20,
3286
+ offset: int = 0,
3287
+ ) -> Tuple[List[Dict[str, Any]], int]:
3288
+ """List components with pagination.
3289
+
3290
+ Args:
3291
+ component_type: Filter by type (agent|team|workflow).
3292
+ include_deleted: Include soft-deleted components.
3293
+ limit: Maximum number of items to return.
3294
+ offset: Number of items to skip.
3295
+
3296
+ Returns:
3297
+ Tuple of (list of component dicts, total count).
3298
+ """
3299
+ try:
3300
+ table = self._get_table(table_type="components")
3301
+ if table is None:
3302
+ return [], 0
3303
+
3304
+ with self.Session() as sess:
3305
+ # Build base where clause
3306
+ where_clauses = []
3307
+ if component_type is not None:
3308
+ where_clauses.append(table.c.component_type == component_type.value)
3309
+ if not include_deleted:
3310
+ where_clauses.append(table.c.deleted_at.is_(None))
3311
+
3312
+ # Get total count
3313
+ count_stmt = select(func.count()).select_from(table)
3314
+ for clause in where_clauses:
3315
+ count_stmt = count_stmt.where(clause)
3316
+ total_count = sess.execute(count_stmt).scalar() or 0
3317
+
3318
+ # Get paginated results
3319
+ stmt = select(table).order_by(
3320
+ table.c.created_at.desc(),
3321
+ table.c.component_id,
3322
+ )
3323
+ for clause in where_clauses:
3324
+ stmt = stmt.where(clause)
3325
+ stmt = stmt.limit(limit).offset(offset)
3326
+
3327
+ results = sess.execute(stmt).fetchall()
3328
+ return [dict(row._mapping) for row in results], total_count
3329
+
3330
+ except Exception as e:
3331
+ log_error(f"Error listing components: {e}")
3332
+ raise
3333
+
3334
+ def create_component_with_config(
3335
+ self,
3336
+ component_id: str,
3337
+ component_type: ComponentType,
3338
+ name: Optional[str],
3339
+ config: Dict[str, Any],
3340
+ description: Optional[str] = None,
3341
+ metadata: Optional[Dict[str, Any]] = None,
3342
+ label: Optional[str] = None,
3343
+ stage: str = "draft",
3344
+ notes: Optional[str] = None,
3345
+ links: Optional[List[Dict[str, Any]]] = None,
3346
+ ) -> Tuple[Dict[str, Any], Dict[str, Any]]:
3347
+ """Create a component with its initial config atomically.
3348
+
3349
+ Args:
3350
+ component_id: Unique identifier.
3351
+ component_type: Type (agent|team|workflow).
3352
+ name: Display name.
3353
+ config: The config data.
3354
+ description: Optional description.
3355
+ metadata: Optional metadata dict.
3356
+ label: Optional config label.
3357
+ stage: "draft" or "published".
3358
+ notes: Optional notes.
3359
+ links: Optional list of links. Each must have child_version set.
3360
+
3361
+ Returns:
3362
+ Tuple of (component dict, config dict).
3363
+
3364
+ Raises:
3365
+ ValueError: If component already exists, invalid stage, or link missing child_version.
3366
+ """
3367
+ if stage not in {"draft", "published"}:
3368
+ raise ValueError(f"Invalid stage: {stage}")
3369
+
3370
+ # Validate links have child_version
3371
+ if links:
3372
+ for link in links:
3373
+ if link.get("child_version") is None:
3374
+ raise ValueError(f"child_version is required for link to {link['child_component_id']}")
3375
+
3376
+ try:
3377
+ components_table = self._get_table(table_type="components", create_table_if_not_found=True)
3378
+ configs_table = self._get_table(table_type="component_configs", create_table_if_not_found=True)
3379
+ links_table = self._get_table(table_type="component_links", create_table_if_not_found=True)
3380
+
3381
+ if components_table is None:
3382
+ raise ValueError("Components table not found")
3383
+ if configs_table is None:
3384
+ raise ValueError("Component configs table not found")
3385
+
3386
+ with self.Session() as sess, sess.begin():
3387
+ # Check if component already exists
3388
+ existing = sess.execute(
3389
+ select(components_table.c.component_id).where(components_table.c.component_id == component_id)
3390
+ ).scalar_one_or_none()
3391
+
3392
+ if existing is not None:
3393
+ raise ValueError(f"Component {component_id} already exists")
3394
+
3395
+ # Check label uniqueness
3396
+ if label is not None:
3397
+ existing_label = sess.execute(
3398
+ select(configs_table.c.version).where(
3399
+ configs_table.c.component_id == component_id,
3400
+ configs_table.c.label == label,
3401
+ )
3402
+ ).first()
3403
+ if existing_label:
3404
+ raise ValueError(f"Label '{label}' already exists for {component_id}")
3405
+
3406
+ now = int(time.time())
3407
+ version = 1
3408
+
3409
+ # Create component
3410
+ sess.execute(
3411
+ components_table.insert().values(
3412
+ component_id=component_id,
3413
+ component_type=component_type.value,
3414
+ name=name,
3415
+ description=description,
3416
+ metadata=metadata,
3417
+ current_version=version if stage == "published" else None,
3418
+ created_at=now,
3419
+ )
3420
+ )
3421
+
3422
+ # Create initial config
3423
+ sess.execute(
3424
+ configs_table.insert().values(
3425
+ component_id=component_id,
3426
+ version=version,
3427
+ label=label,
3428
+ stage=stage,
3429
+ config=config,
3430
+ notes=notes,
3431
+ created_at=now,
3432
+ )
3433
+ )
3434
+
3435
+ # Create links if provided
3436
+ if links and links_table is not None:
3437
+ for link in links:
3438
+ sess.execute(
3439
+ links_table.insert().values(
3440
+ parent_component_id=component_id,
3441
+ parent_version=version,
3442
+ link_kind=link["link_kind"],
3443
+ link_key=link["link_key"],
3444
+ child_component_id=link["child_component_id"],
3445
+ child_version=link["child_version"],
3446
+ position=link["position"],
3447
+ meta=link.get("meta"),
3448
+ created_at=now,
3449
+ )
3450
+ )
3451
+
3452
+ # Fetch and return both
3453
+ component = self.get_component(component_id)
3454
+ config_result = self.get_config(component_id, version=version)
3455
+
3456
+ if component is None:
3457
+ raise ValueError(f"Failed to get component {component_id} after creation")
3458
+ if config_result is None:
3459
+ raise ValueError(f"Failed to get config for {component_id} after creation")
3460
+
3461
+ return component, config_result
3462
+
3463
+ except Exception as e:
3464
+ log_error(f"Error creating component with config: {e}")
3465
+ raise
3466
+
3467
+ # --- Config ---
3468
+ def get_config(
3469
+ self,
3470
+ component_id: str,
3471
+ version: Optional[int] = None,
3472
+ label: Optional[str] = None,
3473
+ ) -> Optional[Dict[str, Any]]:
3474
+ """Get a config by component ID and version or label.
3475
+
3476
+ Args:
3477
+ component_id: The component ID.
3478
+ version: Specific version number. If None, uses current.
3479
+ label: Config label to lookup. Ignored if version is provided.
3480
+
3481
+ Returns:
3482
+ Config dictionary or None if not found.
3483
+ """
3484
+ try:
3485
+ configs_table = self._get_table(table_type="component_configs")
3486
+ components_table = self._get_table(table_type="components")
3487
+
3488
+ if configs_table is None or components_table is None:
3489
+ return None
3490
+
3491
+ with self.Session() as sess:
3492
+ # Always verify component exists and is not deleted
3493
+ component = sess.execute(
3494
+ select(components_table.c.current_version).where(
3495
+ components_table.c.component_id == component_id,
3496
+ components_table.c.deleted_at.is_(None),
3497
+ )
3498
+ ).fetchone()
3499
+
3500
+ if component is None:
3501
+ return None
3502
+
3503
+ if version is not None:
3504
+ stmt = select(configs_table).where(
3505
+ configs_table.c.component_id == component_id,
3506
+ configs_table.c.version == version,
3507
+ )
3508
+ elif label is not None:
3509
+ stmt = select(configs_table).where(
3510
+ configs_table.c.component_id == component_id,
3511
+ configs_table.c.label == label,
3512
+ )
3513
+ else:
3514
+ if component.current_version is None:
3515
+ return None
3516
+ stmt = select(configs_table).where(
3517
+ configs_table.c.component_id == component_id,
3518
+ configs_table.c.version == component.current_version,
3519
+ )
3520
+
3521
+ result = sess.execute(stmt).fetchone()
3522
+ return dict(result._mapping) if result else None
3523
+
3524
+ except Exception as e:
3525
+ log_error(f"Error getting config: {e}")
3526
+ raise
3527
+
3528
+ def upsert_config(
3529
+ self,
3530
+ component_id: str,
3531
+ config: Optional[Dict[str, Any]] = None,
3532
+ version: Optional[int] = None,
3533
+ label: Optional[str] = None,
3534
+ stage: Optional[str] = None,
3535
+ notes: Optional[str] = None,
3536
+ links: Optional[List[Dict[str, Any]]] = None,
3537
+ ) -> Dict[str, Any]:
3538
+ """Create or update a config version for a component.
3539
+
3540
+ Rules:
3541
+ - Draft configs can be edited freely
3542
+ - Published configs are immutable
3543
+ - Publishing a config automatically sets it as current_version
3544
+
3545
+ Args:
3546
+ component_id: The component ID.
3547
+ config: The config data. Required for create, optional for update.
3548
+ version: If None, creates new version. If provided, updates that version.
3549
+ label: Optional human-readable label.
3550
+ stage: "draft" or "published". Defaults to "draft" for new configs.
3551
+ notes: Optional notes.
3552
+ links: Optional list of links. Each link must have child_version set.
3553
+
3554
+ Returns:
3555
+ Created/updated config dictionary.
3556
+
3557
+ Raises:
3558
+ ValueError: If component doesn't exist, version not found, label conflict,
3559
+ or attempting to update a published config.
3560
+ """
3561
+ if stage is not None and stage not in {"draft", "published"}:
3562
+ raise ValueError(f"Invalid stage: {stage}")
3563
+
3564
+ try:
3565
+ configs_table = self._get_table(table_type="component_configs", create_table_if_not_found=True)
3566
+ components_table = self._get_table(table_type="components")
3567
+ links_table = self._get_table(table_type="component_links", create_table_if_not_found=True)
3568
+
3569
+ if components_table is None:
3570
+ raise ValueError("Components table not found")
3571
+ if configs_table is None:
3572
+ raise ValueError("Component configs table not found")
3573
+
3574
+ with self.Session() as sess, sess.begin():
3575
+ # Verify component exists and is not deleted
3576
+ component = sess.execute(
3577
+ select(components_table.c.component_id).where(
3578
+ components_table.c.component_id == component_id,
3579
+ components_table.c.deleted_at.is_(None),
3580
+ )
3581
+ ).fetchone()
3582
+
3583
+ if component is None:
3584
+ raise ValueError(f"Component {component_id} not found")
3585
+
3586
+ # Label uniqueness check
3587
+ if label is not None:
3588
+ label_query = select(configs_table.c.version).where(
3589
+ configs_table.c.component_id == component_id,
3590
+ configs_table.c.label == label,
3591
+ )
3592
+ if version is not None:
3593
+ label_query = label_query.where(configs_table.c.version != version)
3594
+
3595
+ if sess.execute(label_query).first():
3596
+ raise ValueError(f"Label '{label}' already exists for {component_id}")
3597
+
3598
+ # Validate links have child_version
3599
+ if links:
3600
+ for link in links:
3601
+ if link.get("child_version") is None:
3602
+ raise ValueError(f"child_version is required for link to {link['child_component_id']}")
3603
+
3604
+ if version is None:
3605
+ if config is None:
3606
+ raise ValueError("config is required when creating a new version")
3607
+
3608
+ # Default to draft for new configs
3609
+ if stage is None:
3610
+ stage = "draft"
3611
+
3612
+ max_version = sess.execute(
3613
+ select(configs_table.c.version)
3614
+ .where(configs_table.c.component_id == component_id)
3615
+ .order_by(configs_table.c.version.desc())
3616
+ .limit(1)
3617
+ ).scalar()
3618
+
3619
+ final_version = (max_version or 0) + 1
3620
+
3621
+ sess.execute(
3622
+ configs_table.insert().values(
3623
+ component_id=component_id,
3624
+ version=final_version,
3625
+ label=label,
3626
+ stage=stage,
3627
+ config=config,
3628
+ notes=notes,
3629
+ created_at=int(time.time()),
3630
+ )
3631
+ )
3632
+ else:
3633
+ existing = sess.execute(
3634
+ select(configs_table.c.version, configs_table.c.stage).where(
3635
+ configs_table.c.component_id == component_id,
3636
+ configs_table.c.version == version,
3637
+ )
3638
+ ).fetchone()
3639
+
3640
+ if existing is None:
3641
+ raise ValueError(f"Config {component_id} v{version} not found")
3642
+
3643
+ # Published configs are immutable
3644
+ if existing.stage == "published":
3645
+ raise ValueError(f"Cannot update published config {component_id} v{version}")
3646
+
3647
+ # Build update dict with only provided fields
3648
+ updates: Dict[str, Any] = {"updated_at": int(time.time())}
3649
+ if label is not None:
3650
+ updates["label"] = label
3651
+ if stage is not None:
3652
+ updates["stage"] = stage
3653
+ if config is not None:
3654
+ updates["config"] = config
3655
+ if notes is not None:
3656
+ updates["notes"] = notes
3657
+
3658
+ sess.execute(
3659
+ configs_table.update()
3660
+ .where(
3661
+ configs_table.c.component_id == component_id,
3662
+ configs_table.c.version == version,
3663
+ )
3664
+ .values(**updates)
3665
+ )
3666
+ final_version = version
3667
+
3668
+ if links is not None and links_table is not None:
3669
+ sess.execute(
3670
+ links_table.delete().where(
3671
+ links_table.c.parent_component_id == component_id,
3672
+ links_table.c.parent_version == final_version,
3673
+ )
3674
+ )
3675
+ for link in links:
3676
+ sess.execute(
3677
+ links_table.insert().values(
3678
+ parent_component_id=component_id,
3679
+ parent_version=final_version,
3680
+ link_kind=link["link_kind"],
3681
+ link_key=link["link_key"],
3682
+ child_component_id=link["child_component_id"],
3683
+ child_version=link["child_version"],
3684
+ position=link["position"],
3685
+ meta=link.get("meta"),
3686
+ created_at=int(time.time()),
3687
+ )
3688
+ )
3689
+
3690
+ # Determine final stage (could be from update or create)
3691
+ final_stage = stage if stage is not None else (existing.stage if version is not None else "draft")
3692
+
3693
+ if final_stage == "published":
3694
+ sess.execute(
3695
+ components_table.update()
3696
+ .where(components_table.c.component_id == component_id)
3697
+ .values(current_version=final_version, updated_at=int(time.time()))
3698
+ )
3699
+
3700
+ result = self.get_config(component_id, version=final_version)
3701
+ if result is None:
3702
+ raise ValueError(f"Failed to get config {component_id} v{final_version} after upsert")
3703
+ return result
3704
+
3705
+ except Exception as e:
3706
+ log_error(f"Error upserting config: {e}")
3707
+ raise
3708
+
3709
+ def delete_config(
3710
+ self,
3711
+ component_id: str,
3712
+ version: int,
3713
+ ) -> bool:
3714
+ """Delete a specific config version.
3715
+
3716
+ Only draft configs can be deleted. Published configs are immutable.
3717
+ Cannot delete the current version.
3718
+
3719
+ Args:
3720
+ component_id: The component ID.
3721
+ version: The version to delete.
3722
+
3723
+ Returns:
3724
+ True if deleted, False if not found.
3725
+
3726
+ Raises:
3727
+ ValueError: If attempting to delete a published or current config.
3728
+ """
3729
+ try:
3730
+ configs_table = self._get_table(table_type="component_configs")
3731
+ links_table = self._get_table(table_type="component_links")
3732
+ components_table = self._get_table(table_type="components")
3733
+
3734
+ if configs_table is None or components_table is None:
3735
+ return False
3736
+
3737
+ with self.Session() as sess, sess.begin():
3738
+ # Get config stage and check if it's current
3739
+ config_row = sess.execute(
3740
+ select(configs_table.c.stage).where(
3741
+ configs_table.c.component_id == component_id,
3742
+ configs_table.c.version == version,
3743
+ )
3744
+ ).fetchone()
3745
+
3746
+ if config_row is None:
3747
+ return False
3748
+
3749
+ # Cannot delete published configs
3750
+ if config_row.stage == "published":
3751
+ raise ValueError(f"Cannot delete published config {component_id} v{version}")
3752
+
3753
+ # Check if it's current version
3754
+ current = sess.execute(
3755
+ select(components_table.c.current_version).where(components_table.c.component_id == component_id)
3756
+ ).fetchone()
3757
+
3758
+ if current and current.current_version == version:
3759
+ raise ValueError(f"Cannot delete current config {component_id} v{version}")
3760
+
3761
+ # Delete associated links
3762
+ if links_table is not None:
3763
+ sess.execute(
3764
+ links_table.delete().where(
3765
+ links_table.c.parent_component_id == component_id,
3766
+ links_table.c.parent_version == version,
3767
+ )
3768
+ )
3769
+
3770
+ # Delete the config
3771
+ sess.execute(
3772
+ configs_table.delete().where(
3773
+ configs_table.c.component_id == component_id,
3774
+ configs_table.c.version == version,
3775
+ )
3776
+ )
3777
+
3778
+ return True
3779
+
3780
+ except Exception as e:
3781
+ log_error(f"Error deleting config: {e}")
3782
+ raise
3783
+
3784
+ def list_configs(
3785
+ self,
3786
+ component_id: str,
3787
+ include_config: bool = False,
3788
+ ) -> List[Dict[str, Any]]:
3789
+ """List all config versions for a component.
3790
+
3791
+ Args:
3792
+ component_id: The component ID.
3793
+ include_config: If True, include full config blob. Otherwise just metadata.
3794
+
3795
+ Returns:
3796
+ List of config dictionaries, newest first.
3797
+ Returns empty list if component not found or deleted.
3798
+ """
3799
+ try:
3800
+ configs_table = self._get_table(table_type="component_configs")
3801
+ components_table = self._get_table(table_type="components")
3802
+
3803
+ if configs_table is None or components_table is None:
3804
+ return []
3805
+
3806
+ with self.Session() as sess:
3807
+ # Verify component exists and is not deleted
3808
+ exists = sess.execute(
3809
+ select(components_table.c.component_id).where(
3810
+ components_table.c.component_id == component_id,
3811
+ components_table.c.deleted_at.is_(None),
3812
+ )
3813
+ ).fetchone()
3814
+
3815
+ if exists is None:
3816
+ return []
3817
+
3818
+ # Select columns based on include_config flag
3819
+ if include_config:
3820
+ stmt = select(configs_table)
3821
+ else:
3822
+ stmt = select(
3823
+ configs_table.c.component_id,
3824
+ configs_table.c.version,
3825
+ configs_table.c.label,
3826
+ configs_table.c.stage,
3827
+ configs_table.c.notes,
3828
+ configs_table.c.created_at,
3829
+ configs_table.c.updated_at,
3830
+ )
3831
+
3832
+ stmt = stmt.where(configs_table.c.component_id == component_id).order_by(configs_table.c.version.desc())
3833
+
3834
+ results = sess.execute(stmt).fetchall()
3835
+ return [dict(row._mapping) for row in results]
3836
+
3837
+ except Exception as e:
3838
+ log_error(f"Error listing configs: {e}")
3839
+ raise
3840
+
3841
+ def set_current_version(
3842
+ self,
3843
+ component_id: str,
3844
+ version: int,
3845
+ ) -> bool:
3846
+ """Set a specific published version as current.
3847
+
3848
+ Only published configs can be set as current. This is used for
3849
+ rollback scenarios where you want to switch to a previous
3850
+ published version.
3851
+
3852
+ Args:
3853
+ component_id: The component ID.
3854
+ version: The version to set as current (must be published).
3855
+
3856
+ Returns:
3857
+ True if successful, False if component or version not found.
3858
+
3859
+ Raises:
3860
+ ValueError: If attempting to set a draft config as current.
3861
+ """
3862
+ try:
3863
+ configs_table = self._get_table(table_type="component_configs")
3864
+ components_table = self._get_table(table_type="components")
3865
+
3866
+ if configs_table is None or components_table is None:
3867
+ return False
3868
+
3869
+ with self.Session() as sess, sess.begin():
3870
+ # Verify component exists and is not deleted
3871
+ component_exists = sess.execute(
3872
+ select(components_table.c.component_id).where(
3873
+ components_table.c.component_id == component_id,
3874
+ components_table.c.deleted_at.is_(None),
3875
+ )
3876
+ ).fetchone()
3877
+
3878
+ if component_exists is None:
3879
+ return False
3880
+
3881
+ # Verify version exists and get stage
3882
+ stage = sess.execute(
3883
+ select(configs_table.c.stage).where(
3884
+ configs_table.c.component_id == component_id,
3885
+ configs_table.c.version == version,
3886
+ )
3887
+ ).fetchone()
3888
+
3889
+ if stage is None:
3890
+ return False
3891
+
3892
+ # Only published configs can be set as current
3893
+ if stage.stage != "published":
3894
+ raise ValueError(
3895
+ f"Cannot set draft config {component_id} v{version} as current. "
3896
+ "Only published configs can be current."
3897
+ )
3898
+
3899
+ # Update pointer
3900
+ sess.execute(
3901
+ components_table.update()
3902
+ .where(components_table.c.component_id == component_id)
3903
+ .values(current_version=version, updated_at=int(time.time()))
3904
+ )
3905
+
3906
+ log_debug(f"Set {component_id} current version to {version}")
3907
+ return True
3908
+
3909
+ except Exception as e:
3910
+ log_error(f"Error setting current version: {e}")
3911
+ raise
3912
+
3913
+ # --- Component Links ---
3914
+ def get_links(
3915
+ self,
3916
+ component_id: str,
3917
+ version: int,
3918
+ link_kind: Optional[str] = None,
3919
+ ) -> List[Dict[str, Any]]:
3920
+ """Get links for a config version.
3921
+
3922
+ Args:
3923
+ component_id: The component ID.
3924
+ version: The config version.
3925
+ link_kind: Optional filter by link kind (member|step).
3926
+
3927
+ Returns:
3928
+ List of link dictionaries, ordered by position.
3929
+ """
3930
+ try:
3931
+ table = self._get_table(table_type="component_links")
3932
+ if table is None:
3933
+ return []
3934
+
3935
+ with self.Session() as sess:
3936
+ stmt = (
3937
+ select(table)
3938
+ .where(
3939
+ table.c.parent_component_id == component_id,
3940
+ table.c.parent_version == version,
3941
+ )
3942
+ .order_by(table.c.position)
3943
+ )
3944
+ if link_kind is not None:
3945
+ stmt = stmt.where(table.c.link_kind == link_kind)
3946
+
3947
+ results = sess.execute(stmt).fetchall()
3948
+ return [dict(row._mapping) for row in results]
3949
+
3950
+ except Exception as e:
3951
+ log_error(f"Error getting links: {e}")
3952
+ raise
3953
+
3954
+ def get_dependents(
3955
+ self,
3956
+ component_id: str,
3957
+ version: Optional[int] = None,
3958
+ ) -> List[Dict[str, Any]]:
3959
+ """Find all components that reference this component.
3960
+
3961
+ Args:
3962
+ component_id: The component ID to find dependents of.
3963
+ version: Optional specific version. If None, finds links to any version.
3964
+
3965
+ Returns:
3966
+ List of link dictionaries showing what depends on this component.
3967
+ """
3968
+ try:
3969
+ table = self._get_table(table_type="component_links")
3970
+ if table is None:
3971
+ return []
3972
+
3973
+ with self.Session() as sess:
3974
+ stmt = select(table).where(table.c.child_component_id == component_id)
3975
+ if version is not None:
3976
+ stmt = stmt.where(table.c.child_version == version)
3977
+
3978
+ results = sess.execute(stmt).fetchall()
3979
+ return [dict(row._mapping) for row in results]
3980
+
3981
+ except Exception as e:
3982
+ log_error(f"Error getting dependents: {e}")
3983
+ raise
3984
+
3985
+ def resolve_version(
3986
+ self,
3987
+ component_id: str,
3988
+ version: Optional[int],
3989
+ ) -> Optional[int]:
3990
+ """Resolve a version number, handling NULL (current) case.
3991
+
3992
+ Args:
3993
+ component_id: The component ID.
3994
+ version: Version number or None for current.
3995
+
3996
+ Returns:
3997
+ Resolved version number or None if component not found.
3998
+ """
3999
+ if version is not None:
4000
+ return version
4001
+
4002
+ try:
4003
+ components_table = self._get_table(table_type="components")
4004
+ if components_table is None:
4005
+ return None
4006
+
4007
+ with self.Session() as sess:
4008
+ result = sess.execute(
4009
+ select(components_table.c.current_version).where(components_table.c.component_id == component_id)
4010
+ ).scalar()
4011
+ return result
4012
+
4013
+ except Exception as e:
4014
+ log_error(f"Error resolving version: {e}")
4015
+ raise
4016
+
4017
+ def load_component_graph(
4018
+ self,
4019
+ component_id: str,
4020
+ version: Optional[int] = None,
4021
+ ) -> Optional[Dict[str, Any]]:
4022
+ """Load a component with its full resolved graph.
4023
+
4024
+ Args:
4025
+ component_id: The component ID.
4026
+ version: Specific version or None for current.
4027
+
4028
+ Returns:
4029
+ Dictionary with component, config, links, and resolved children.
4030
+ """
4031
+ try:
4032
+ # Get component
4033
+ component = self.get_component(component_id)
4034
+ if component is None:
4035
+ return None
4036
+
4037
+ # Resolve version
4038
+ resolved_version = self.resolve_version(component_id, version)
4039
+ if resolved_version is None:
4040
+ return None
4041
+
4042
+ # Get config
4043
+ config = self.get_config(component_id, version=resolved_version)
4044
+ if config is None:
4045
+ return None
4046
+
4047
+ # Get links
4048
+ links = self.get_links(component_id, resolved_version)
4049
+
4050
+ # Resolve children recursively
4051
+ children = []
4052
+ resolved_versions: Dict[str, Optional[int]] = {component_id: resolved_version}
4053
+
4054
+ for link in links:
4055
+ child_version = self.resolve_version(
4056
+ link["child_component_id"],
4057
+ link["child_version"],
4058
+ )
4059
+ resolved_versions[link["child_component_id"]] = child_version
4060
+
4061
+ child_graph = self.load_component_graph(
4062
+ link["child_component_id"],
4063
+ version=child_version,
4064
+ )
4065
+
4066
+ if child_graph:
4067
+ # Merge nested resolved versions
4068
+ resolved_versions.update(child_graph.get("resolved_versions", {}))
4069
+
4070
+ children.append(
4071
+ {
4072
+ "link": link,
4073
+ "graph": child_graph,
4074
+ }
4075
+ )
4076
+
4077
+ return {
4078
+ "component": component,
4079
+ "config": config,
4080
+ "children": children,
4081
+ "resolved_versions": resolved_versions,
4082
+ }
4083
+
4084
+ except Exception as e:
4085
+ log_error(f"Error loading component graph: {e}")
4086
+ raise
4087
+
2928
4088
  # -- Learning methods --
2929
4089
  def get_learning(
2930
4090
  self,