agno 2.3.26__py3-none-any.whl → 2.4.1__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 (140) hide show
  1. agno/agent/__init__.py +4 -0
  2. agno/agent/agent.py +1368 -541
  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 +1242 -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 +1186 -13
  11. agno/db/utils.py +37 -1
  12. agno/integrations/discord/client.py +12 -1
  13. agno/knowledge/__init__.py +4 -0
  14. agno/knowledge/chunking/code.py +1 -1
  15. agno/knowledge/chunking/semantic.py +1 -1
  16. agno/knowledge/chunking/strategy.py +4 -0
  17. agno/knowledge/filesystem.py +412 -0
  18. agno/knowledge/knowledge.py +3722 -2182
  19. agno/knowledge/protocol.py +134 -0
  20. agno/knowledge/reader/arxiv_reader.py +2 -2
  21. agno/knowledge/reader/base.py +9 -7
  22. agno/knowledge/reader/csv_reader.py +236 -13
  23. agno/knowledge/reader/docx_reader.py +2 -2
  24. agno/knowledge/reader/field_labeled_csv_reader.py +169 -5
  25. agno/knowledge/reader/firecrawl_reader.py +2 -2
  26. agno/knowledge/reader/json_reader.py +2 -2
  27. agno/knowledge/reader/markdown_reader.py +2 -2
  28. agno/knowledge/reader/pdf_reader.py +5 -4
  29. agno/knowledge/reader/pptx_reader.py +2 -2
  30. agno/knowledge/reader/reader_factory.py +118 -1
  31. agno/knowledge/reader/s3_reader.py +2 -2
  32. agno/knowledge/reader/tavily_reader.py +2 -2
  33. agno/knowledge/reader/text_reader.py +2 -2
  34. agno/knowledge/reader/web_search_reader.py +2 -2
  35. agno/knowledge/reader/website_reader.py +5 -3
  36. agno/knowledge/reader/wikipedia_reader.py +2 -2
  37. agno/knowledge/reader/youtube_reader.py +2 -2
  38. agno/knowledge/remote_content/__init__.py +29 -0
  39. agno/knowledge/remote_content/config.py +204 -0
  40. agno/knowledge/remote_content/remote_content.py +74 -17
  41. agno/knowledge/utils.py +37 -29
  42. agno/learn/__init__.py +6 -0
  43. agno/learn/machine.py +35 -0
  44. agno/learn/schemas.py +82 -11
  45. agno/learn/stores/__init__.py +3 -0
  46. agno/learn/stores/decision_log.py +1156 -0
  47. agno/learn/stores/learned_knowledge.py +6 -6
  48. agno/models/anthropic/claude.py +24 -0
  49. agno/models/aws/bedrock.py +20 -0
  50. agno/models/base.py +60 -6
  51. agno/models/cerebras/cerebras.py +34 -2
  52. agno/models/cohere/chat.py +25 -0
  53. agno/models/google/gemini.py +50 -5
  54. agno/models/litellm/chat.py +38 -0
  55. agno/models/n1n/__init__.py +3 -0
  56. agno/models/n1n/n1n.py +57 -0
  57. agno/models/openai/chat.py +25 -1
  58. agno/models/openrouter/openrouter.py +46 -0
  59. agno/models/perplexity/perplexity.py +2 -0
  60. agno/models/response.py +16 -0
  61. agno/os/app.py +83 -44
  62. agno/os/interfaces/slack/router.py +10 -1
  63. agno/os/interfaces/whatsapp/router.py +6 -0
  64. agno/os/middleware/__init__.py +2 -0
  65. agno/os/middleware/trailing_slash.py +27 -0
  66. agno/os/router.py +1 -0
  67. agno/os/routers/agents/router.py +29 -16
  68. agno/os/routers/agents/schema.py +6 -4
  69. agno/os/routers/components/__init__.py +3 -0
  70. agno/os/routers/components/components.py +475 -0
  71. agno/os/routers/evals/schemas.py +4 -3
  72. agno/os/routers/health.py +3 -3
  73. agno/os/routers/knowledge/knowledge.py +128 -3
  74. agno/os/routers/knowledge/schemas.py +12 -0
  75. agno/os/routers/memory/schemas.py +4 -2
  76. agno/os/routers/metrics/metrics.py +9 -11
  77. agno/os/routers/metrics/schemas.py +10 -6
  78. agno/os/routers/registry/__init__.py +3 -0
  79. agno/os/routers/registry/registry.py +337 -0
  80. agno/os/routers/teams/router.py +20 -8
  81. agno/os/routers/teams/schema.py +6 -4
  82. agno/os/routers/traces/traces.py +5 -5
  83. agno/os/routers/workflows/router.py +38 -11
  84. agno/os/routers/workflows/schema.py +1 -1
  85. agno/os/schema.py +92 -26
  86. agno/os/utils.py +84 -19
  87. agno/reasoning/anthropic.py +2 -2
  88. agno/reasoning/azure_ai_foundry.py +2 -2
  89. agno/reasoning/deepseek.py +2 -2
  90. agno/reasoning/default.py +6 -7
  91. agno/reasoning/gemini.py +2 -2
  92. agno/reasoning/helpers.py +6 -7
  93. agno/reasoning/manager.py +4 -10
  94. agno/reasoning/ollama.py +2 -2
  95. agno/reasoning/openai.py +2 -2
  96. agno/reasoning/vertexai.py +2 -2
  97. agno/registry/__init__.py +3 -0
  98. agno/registry/registry.py +68 -0
  99. agno/run/agent.py +59 -0
  100. agno/run/base.py +7 -0
  101. agno/run/team.py +57 -0
  102. agno/skills/agent_skills.py +10 -3
  103. agno/team/__init__.py +3 -1
  104. agno/team/team.py +1165 -330
  105. agno/tools/duckduckgo.py +25 -71
  106. agno/tools/exa.py +0 -21
  107. agno/tools/function.py +35 -83
  108. agno/tools/knowledge.py +9 -4
  109. agno/tools/mem0.py +11 -10
  110. agno/tools/memory.py +47 -46
  111. agno/tools/parallel.py +0 -7
  112. agno/tools/reasoning.py +30 -23
  113. agno/tools/tavily.py +4 -1
  114. agno/tools/websearch.py +93 -0
  115. agno/tools/website.py +1 -1
  116. agno/tools/wikipedia.py +1 -1
  117. agno/tools/workflow.py +48 -47
  118. agno/utils/agent.py +42 -5
  119. agno/utils/events.py +160 -2
  120. agno/utils/print_response/agent.py +0 -31
  121. agno/utils/print_response/team.py +0 -2
  122. agno/utils/print_response/workflow.py +0 -2
  123. agno/utils/team.py +61 -11
  124. agno/vectordb/lancedb/lance_db.py +4 -1
  125. agno/vectordb/mongodb/mongodb.py +1 -1
  126. agno/vectordb/pgvector/pgvector.py +3 -3
  127. agno/vectordb/qdrant/qdrant.py +4 -4
  128. agno/workflow/__init__.py +3 -1
  129. agno/workflow/condition.py +0 -21
  130. agno/workflow/loop.py +0 -21
  131. agno/workflow/parallel.py +0 -21
  132. agno/workflow/router.py +0 -21
  133. agno/workflow/step.py +117 -24
  134. agno/workflow/steps.py +0 -21
  135. agno/workflow/workflow.py +427 -63
  136. {agno-2.3.26.dist-info → agno-2.4.1.dist-info}/METADATA +49 -76
  137. {agno-2.3.26.dist-info → agno-2.4.1.dist-info}/RECORD +140 -126
  138. {agno-2.3.26.dist-info → agno-2.4.1.dist-info}/WHEEL +1 -1
  139. {agno-2.3.26.dist-info → agno-2.4.1.dist-info}/licenses/LICENSE +0 -0
  140. {agno-2.3.26.dist-info → agno-2.4.1.dist-info}/top_level.txt +0 -0
@@ -1,12 +1,12 @@
1
1
  import time
2
2
  from datetime import date, datetime, timedelta, timezone
3
- from typing import TYPE_CHECKING, Any, Dict, List, Optional, Sequence, Tuple, Union, cast
3
+ from typing import TYPE_CHECKING, Any, Dict, List, Optional, Sequence, Set, Tuple, Union, cast
4
4
  from uuid import uuid4
5
5
 
6
6
  if TYPE_CHECKING:
7
7
  from agno.tracing.schemas import Span, Trace
8
8
 
9
- from agno.db.base import BaseDb, SessionType
9
+ from agno.db.base import BaseDb, ComponentType, SessionType
10
10
  from agno.db.migrations.manager import MigrationManager
11
11
  from agno.db.postgres.schemas import get_table_schema_definition
12
12
  from agno.db.postgres.utils import (
@@ -30,7 +30,20 @@ from agno.utils.log import log_debug, log_error, log_info, log_warning
30
30
  from agno.utils.string import generate_id, sanitize_postgres_string, sanitize_postgres_strings
31
31
 
32
32
  try:
33
- from sqlalchemy import ForeignKey, Index, String, UniqueConstraint, and_, case, func, or_, select, update
33
+ from sqlalchemy import (
34
+ ForeignKey,
35
+ ForeignKeyConstraint,
36
+ Index,
37
+ PrimaryKeyConstraint,
38
+ String,
39
+ UniqueConstraint,
40
+ and_,
41
+ case,
42
+ func,
43
+ or_,
44
+ select,
45
+ update,
46
+ )
34
47
  from sqlalchemy.dialects import postgresql
35
48
  from sqlalchemy.dialects.postgresql import TIMESTAMP
36
49
  from sqlalchemy.engine import Engine, create_engine
@@ -57,6 +70,9 @@ class PostgresDb(BaseDb):
57
70
  traces_table: Optional[str] = None,
58
71
  spans_table: Optional[str] = None,
59
72
  versions_table: Optional[str] = None,
73
+ components_table: Optional[str] = None,
74
+ component_configs_table: Optional[str] = None,
75
+ component_links_table: Optional[str] = None,
60
76
  learnings_table: Optional[str] = None,
61
77
  id: Optional[str] = None,
62
78
  create_schema: bool = True,
@@ -82,6 +98,9 @@ class PostgresDb(BaseDb):
82
98
  traces_table (Optional[str]): Name of the table to store run traces.
83
99
  spans_table (Optional[str]): Name of the table to store span events.
84
100
  versions_table (Optional[str]): Name of the table to store schema versions.
101
+ components_table (Optional[str]): Name of the table to store components.
102
+ component_configs_table (Optional[str]): Name of the table to store component configurations.
103
+ component_links_table (Optional[str]): Name of the table to store component references.
85
104
  learnings_table (Optional[str]): Name of the table to store learnings.
86
105
  id (Optional[str]): ID of the database.
87
106
  create_schema (bool): Whether to automatically create the database schema if it doesn't exist.
@@ -121,6 +140,9 @@ class PostgresDb(BaseDb):
121
140
  traces_table=traces_table,
122
141
  spans_table=spans_table,
123
142
  versions_table=versions_table,
143
+ components_table=components_table,
144
+ component_configs_table=component_configs_table,
145
+ component_links_table=component_links_table,
124
146
  learnings_table=learnings_table,
125
147
  )
126
148
 
@@ -131,6 +153,38 @@ class PostgresDb(BaseDb):
131
153
  # Initialize database session
132
154
  self.Session: scoped_session = scoped_session(sessionmaker(bind=self.db_engine, expire_on_commit=False))
133
155
 
156
+ # -- Serialization methods --
157
+ def to_dict(self):
158
+ base = super().to_dict()
159
+ base.update(
160
+ {
161
+ "db_url": self.db_url,
162
+ "db_schema": self.db_schema,
163
+ "type": "postgres",
164
+ }
165
+ )
166
+ return base
167
+
168
+ @classmethod
169
+ def from_dict(cls, data):
170
+ return cls(
171
+ db_url=data.get("db_url"),
172
+ db_schema=data.get("db_schema"),
173
+ session_table=data.get("session_table"),
174
+ culture_table=data.get("culture_table"),
175
+ memory_table=data.get("memory_table"),
176
+ metrics_table=data.get("metrics_table"),
177
+ eval_table=data.get("eval_table"),
178
+ knowledge_table=data.get("knowledge_table"),
179
+ traces_table=data.get("traces_table"),
180
+ spans_table=data.get("spans_table"),
181
+ versions_table=data.get("versions_table"),
182
+ components_table=data.get("components_table"),
183
+ component_configs_table=data.get("component_configs_table"),
184
+ component_links_table=data.get("component_links_table"),
185
+ id=data.get("id"),
186
+ )
187
+
134
188
  def close(self) -> None:
135
189
  """Close database connections and dispose of the connection pool.
136
190
 
@@ -162,6 +216,9 @@ class PostgresDb(BaseDb):
162
216
  (self.eval_table_name, "evals"),
163
217
  (self.knowledge_table_name, "knowledge"),
164
218
  (self.versions_table_name, "versions"),
219
+ (self.components_table_name, "components"),
220
+ (self.component_configs_table_name, "component_configs"),
221
+ (self.component_links_table_name, "component_links"),
165
222
  (self.learnings_table_name, "learnings"),
166
223
  ]
167
224
 
@@ -172,12 +229,11 @@ class PostgresDb(BaseDb):
172
229
  """
173
230
  Create a table with the appropriate schema based on the table type.
174
231
 
175
- Args:
176
- table_name (str): Name of the table to create
177
- table_type (str): Type of table (used to get schema definition)
178
-
179
- Returns:
180
- Table: SQLAlchemy Table object
232
+ Supports:
233
+ - _unique_constraints: [{"name": "...", "columns": [...]}]
234
+ - __primary_key__: ["col1", "col2", ...]
235
+ - __foreign_keys__: [{"columns":[...], "ref_table":"...", "ref_columns":[...]}]
236
+ - column-level foreign_key: "logical_table.column" (resolved via _resolve_* helpers)
181
237
  """
182
238
  try:
183
239
  # Pass traces_table_name and db_schema for spans table foreign key resolution
@@ -187,43 +243,100 @@ class PostgresDb(BaseDb):
187
243
 
188
244
  columns: List[Column] = []
189
245
  indexes: List[str] = []
190
- unique_constraints: List[str] = []
246
+
247
+ # Extract special schema keys before iterating columns
191
248
  schema_unique_constraints = table_schema.pop("_unique_constraints", [])
249
+ schema_primary_key = table_schema.pop("__primary_key__", None)
250
+ schema_foreign_keys = table_schema.pop("__foreign_keys__", [])
192
251
 
193
- # Get the columns, indexes, and unique constraints from the table schema
252
+ # Build columns
194
253
  for col_name, col_config in table_schema.items():
195
254
  column_args = [col_name, col_config["type"]()]
196
- column_kwargs = {}
197
- if col_config.get("primary_key", False):
255
+ column_kwargs: Dict[str, Any] = {}
256
+
257
+ # Column-level PK only if no composite PK is defined
258
+ if col_config.get("primary_key", False) and schema_primary_key is None:
198
259
  column_kwargs["primary_key"] = True
260
+
199
261
  if "nullable" in col_config:
200
262
  column_kwargs["nullable"] = col_config["nullable"]
263
+
264
+ if "default" in col_config:
265
+ column_kwargs["default"] = col_config["default"]
266
+
201
267
  if col_config.get("index", False):
202
268
  indexes.append(col_name)
269
+
203
270
  if col_config.get("unique", False):
204
271
  column_kwargs["unique"] = True
205
- unique_constraints.append(col_name)
206
272
 
207
- # Handle foreign key constraint
273
+ # Single-column FK
208
274
  if "foreign_key" in col_config:
209
- column_args.append(ForeignKey(col_config["foreign_key"]))
275
+ fk_ref = self._resolve_fk_reference(col_config["foreign_key"])
276
+ column_args.append(ForeignKey(fk_ref))
210
277
 
211
- columns.append(Column(*column_args, **column_kwargs)) # type: ignore
278
+ columns.append(Column(*column_args, **column_kwargs))
212
279
 
213
280
  # Create the table object
214
281
  table = Table(table_name, self.metadata, *columns, schema=self.db_schema)
215
282
 
216
- # Add multi-column unique constraints with table-specific names
283
+ # Composite PK
284
+ if schema_primary_key is not None:
285
+ missing = [c for c in schema_primary_key if c not in table.c]
286
+ if missing:
287
+ raise ValueError(f"Composite PK references missing columns in {table_name}: {missing}")
288
+
289
+ pk_constraint_name = f"{table_name}_pkey"
290
+ table.append_constraint(PrimaryKeyConstraint(*schema_primary_key, name=pk_constraint_name))
291
+
292
+ # Composite FKs
293
+ for fk_config in schema_foreign_keys:
294
+ fk_columns = fk_config["columns"]
295
+ ref_table_logical = fk_config["ref_table"]
296
+ ref_columns = fk_config["ref_columns"]
297
+
298
+ if len(fk_columns) != len(ref_columns):
299
+ raise ValueError(
300
+ f"Composite FK in {table_name} has mismatched columns/ref_columns: {fk_columns} vs {ref_columns}"
301
+ )
302
+
303
+ missing = [c for c in fk_columns if c not in table.c]
304
+ if missing:
305
+ raise ValueError(f"Composite FK references missing columns in {table_name}: {missing}")
306
+
307
+ resolved_ref_table = self._resolve_table_name(ref_table_logical)
308
+ fk_constraint_name = f"{table_name}_{'_'.join(fk_columns)}_fkey"
309
+
310
+ # IMPORTANT: since Table(schema=self.db_schema) is used, do NOT schema-qualify these targets.
311
+ ref_column_strings = [f"{resolved_ref_table}.{col}" for col in ref_columns]
312
+
313
+ table.append_constraint(
314
+ ForeignKeyConstraint(
315
+ fk_columns,
316
+ ref_column_strings,
317
+ name=fk_constraint_name,
318
+ )
319
+ )
320
+
321
+ # Multi-column unique constraints
217
322
  for constraint in schema_unique_constraints:
218
323
  constraint_name = f"{table_name}_{constraint['name']}"
219
324
  constraint_columns = constraint["columns"]
325
+
326
+ missing = [c for c in constraint_columns if c not in table.c]
327
+ if missing:
328
+ raise ValueError(f"Unique constraint references missing columns in {table_name}: {missing}")
329
+
220
330
  table.append_constraint(UniqueConstraint(*constraint_columns, name=constraint_name))
221
331
 
222
- # Add indexes to the table definition
332
+ # Indexes
223
333
  for idx_col in indexes:
334
+ if idx_col not in table.c:
335
+ raise ValueError(f"Index references missing column in {table_name}: {idx_col}")
224
336
  idx_name = f"idx_{table_name}_{idx_col}"
225
- table.append_constraint(Index(idx_name, idx_col))
337
+ Index(idx_name, table.c[idx_col]) # Correct way; do NOT append as constraint
226
338
 
339
+ # Create schema if requested
227
340
  if self.create_schema:
228
341
  with self.Session() as sess, sess.begin():
229
342
  create_schema(session=sess, db_schema=self.db_schema)
@@ -232,15 +345,14 @@ class PostgresDb(BaseDb):
232
345
  table_created = False
233
346
  if not self.table_exists(table_name):
234
347
  table.create(self.db_engine, checkfirst=True)
235
- log_debug(f"Successfully created table '{table_name}'")
348
+ log_debug(f"Successfully created table '{self.db_schema}.{table_name}'")
236
349
  table_created = True
237
350
  else:
238
351
  log_debug(f"Table {self.db_schema}.{table_name} already exists, skipping creation")
239
352
 
240
- # Create indexes
353
+ # Create indexes (Postgres)
241
354
  for idx in table.indexes:
242
355
  try:
243
- # Check if index already exists
244
356
  with self.Session() as sess:
245
357
  exists_query = text(
246
358
  "SELECT 1 FROM pg_indexes WHERE schemaname = :schema AND indexname = :index_name"
@@ -265,12 +377,47 @@ class PostgresDb(BaseDb):
265
377
  if table_name != self.versions_table_name and table_created:
266
378
  latest_schema_version = MigrationManager(self).latest_schema_version
267
379
  self.upsert_schema_version(table_name=table_name, version=latest_schema_version.public)
380
+
268
381
  return table
269
382
 
270
383
  except Exception as e:
271
384
  log_error(f"Could not create table {self.db_schema}.{table_name}: {e}")
272
385
  raise
273
386
 
387
+ def _resolve_fk_reference(self, fk_ref: str) -> str:
388
+ """
389
+ Resolve a simple foreign key reference to fully qualified name.
390
+
391
+ Accepts:
392
+ - "logical_table.column" -> "{schema}.{resolved_table}.{column}"
393
+ - already-qualified refs -> returned as-is
394
+ """
395
+ parts = fk_ref.split(".")
396
+ if len(parts) == 2:
397
+ table, column = parts
398
+ resolved_table = self._resolve_table_name(table)
399
+ return f"{self.db_schema}.{resolved_table}.{column}"
400
+ return fk_ref
401
+
402
+ def _resolve_table_name(self, logical_name: str) -> str:
403
+ """
404
+ Resolve logical table name to configured table name.
405
+ """
406
+ table_map = {
407
+ "traces": self.trace_table_name,
408
+ "spans": self.span_table_name,
409
+ "sessions": self.session_table_name,
410
+ "memories": self.memory_table_name,
411
+ "metrics": self.metrics_table_name,
412
+ "evals": self.eval_table_name,
413
+ "knowledge": self.knowledge_table_name,
414
+ "versions": self.versions_table_name,
415
+ "components": self.components_table_name,
416
+ "component_configs": self.component_configs_table_name,
417
+ "component_links": self.component_links_table_name,
418
+ }
419
+ return table_map.get(logical_name, logical_name)
420
+
274
421
  def _get_table(self, table_type: str, create_table_if_not_found: Optional[bool] = False) -> Optional[Table]:
275
422
  if table_type == "sessions":
276
423
  self.session_table = self._get_or_create_table(
@@ -348,6 +495,29 @@ class PostgresDb(BaseDb):
348
495
  )
349
496
  return self.spans_table
350
497
 
498
+ if table_type == "components":
499
+ self.component_table = self._get_or_create_table(
500
+ table_name=self.components_table_name,
501
+ table_type="components",
502
+ create_table_if_not_found=create_table_if_not_found,
503
+ )
504
+ return self.component_table
505
+
506
+ if table_type == "component_configs":
507
+ self.component_configs_table = self._get_or_create_table(
508
+ table_name=self.component_configs_table_name,
509
+ table_type="component_configs",
510
+ create_table_if_not_found=create_table_if_not_found,
511
+ )
512
+ return self.component_configs_table
513
+
514
+ if table_type == "component_links":
515
+ self.component_links_table = self._get_or_create_table(
516
+ table_name=self.component_links_table_name,
517
+ table_type="component_links",
518
+ create_table_if_not_found=create_table_if_not_found,
519
+ )
520
+ return self.component_links_table
351
521
  if table_type == "learnings":
352
522
  self.learnings_table = self._get_or_create_table(
353
523
  table_name=self.learnings_table_name,
@@ -568,12 +738,12 @@ class PostgresDb(BaseDb):
568
738
  deserialize: Optional[bool] = True,
569
739
  ) -> Union[List[Session], Tuple[List[Dict[str, Any]], int]]:
570
740
  """
571
- Get all sessions in the given table. Can filter by user_id and entity_id.
741
+ Get all sessions in the given table. Can filter by user_id and component_id.
572
742
 
573
743
  Args:
574
744
  session_type (Optional[SessionType]): The type of session to get.
575
745
  user_id (Optional[str]): The ID of the user to filter by.
576
- entity_id (Optional[str]): The ID of the agent / workflow to filter by.
746
+ component_id (Optional[str]): The ID of the agent / workflow to filter by.
577
747
  start_timestamp (Optional[int]): The start timestamp to filter by.
578
748
  end_timestamp (Optional[int]): The end timestamp to filter by.
579
749
  session_name (Optional[str]): The name of the session to filter by.
@@ -3038,6 +3208,1053 @@ class PostgresDb(BaseDb):
3038
3208
  log_error(f"Error getting spans: {e}")
3039
3209
  return []
3040
3210
 
3211
+ # --- Components ---
3212
+ def get_component(
3213
+ self,
3214
+ component_id: str,
3215
+ component_type: Optional[ComponentType] = None,
3216
+ ) -> Optional[Dict[str, Any]]:
3217
+ try:
3218
+ table = self._get_table(table_type="components")
3219
+ if table is None:
3220
+ return None
3221
+
3222
+ with self.Session() as sess:
3223
+ stmt = select(table).where(
3224
+ table.c.component_id == component_id,
3225
+ table.c.deleted_at.is_(None),
3226
+ )
3227
+
3228
+ if component_type is not None:
3229
+ stmt = stmt.where(table.c.component_type == component_type.value)
3230
+
3231
+ row = sess.execute(stmt).mappings().one_or_none()
3232
+ return dict(row) if row else None
3233
+
3234
+ except Exception as e:
3235
+ log_error(f"Error getting component: {e}")
3236
+ raise
3237
+
3238
+ def upsert_component(
3239
+ self,
3240
+ component_id: str,
3241
+ component_type: Optional[ComponentType] = None,
3242
+ name: Optional[str] = None,
3243
+ description: Optional[str] = None,
3244
+ metadata: Optional[Dict[str, Any]] = None,
3245
+ ) -> Dict[str, Any]:
3246
+ """Create or update a component.
3247
+
3248
+ Args:
3249
+ component_id: Unique identifier.
3250
+ component_type: Type (agent|team|workflow). Required for create, optional for update.
3251
+ name: Display name.
3252
+ description: Optional description.
3253
+ metadata: Optional metadata dict.
3254
+
3255
+ Returns:
3256
+ Created/updated component dictionary.
3257
+
3258
+ Raises:
3259
+ ValueError: If creating and component_type is not provided.
3260
+ """
3261
+ try:
3262
+ table = self._get_table(table_type="components", create_table_if_not_found=True)
3263
+ if table is None:
3264
+ raise ValueError("Components table not found")
3265
+
3266
+ with self.Session() as sess, sess.begin():
3267
+ existing = sess.execute(
3268
+ select(table).where(
3269
+ table.c.component_id == component_id,
3270
+ table.c.deleted_at.is_(None),
3271
+ )
3272
+ ).fetchone()
3273
+ if existing is None:
3274
+ # Create new component
3275
+ if component_type is None:
3276
+ raise ValueError("component_type is required when creating a new component")
3277
+
3278
+ sess.execute(
3279
+ table.insert().values(
3280
+ component_id=component_id,
3281
+ component_type=component_type.value,
3282
+ name=name,
3283
+ description=description,
3284
+ current_version=None,
3285
+ metadata=metadata,
3286
+ created_at=int(time.time()),
3287
+ )
3288
+ )
3289
+ log_debug(f"Created component {component_id}")
3290
+
3291
+ elif existing.deleted_at is not None:
3292
+ # Reactivate soft-deleted
3293
+ if component_type is None:
3294
+ raise ValueError("component_type is required when reactivating a deleted component")
3295
+
3296
+ sess.execute(
3297
+ table.update()
3298
+ .where(table.c.component_id == component_id)
3299
+ .values(
3300
+ component_type=component_type.value,
3301
+ name=name or component_id,
3302
+ description=description,
3303
+ current_version=None,
3304
+ metadata=metadata,
3305
+ updated_at=int(time.time()),
3306
+ deleted_at=None,
3307
+ )
3308
+ )
3309
+ log_debug(f"Reactivated component {component_id}")
3310
+
3311
+ else:
3312
+ # Update existing
3313
+ updates: Dict[str, Any] = {"updated_at": int(time.time())}
3314
+ if component_type is not None:
3315
+ updates["component_type"] = component_type.value
3316
+ if name is not None:
3317
+ updates["name"] = name
3318
+ if description is not None:
3319
+ updates["description"] = description
3320
+ if metadata is not None:
3321
+ updates["metadata"] = metadata
3322
+
3323
+ sess.execute(table.update().where(table.c.component_id == component_id).values(**updates))
3324
+ log_debug(f"Updated component {component_id}")
3325
+
3326
+ result = self.get_component(component_id)
3327
+ if result is None:
3328
+ raise ValueError(f"Failed to get component {component_id} after upsert")
3329
+ return result
3330
+
3331
+ except Exception as e:
3332
+ log_error(f"Error upserting component: {e}")
3333
+ raise
3334
+
3335
+ def delete_component(
3336
+ self,
3337
+ component_id: str,
3338
+ hard_delete: bool = False,
3339
+ ) -> bool:
3340
+ """Delete a component and all its configs/links.
3341
+
3342
+ Args:
3343
+ component_id: The component ID.
3344
+ hard_delete: If True, permanently delete. Otherwise soft-delete.
3345
+
3346
+ Returns:
3347
+ True if deleted, False if not found or already deleted.
3348
+ """
3349
+ try:
3350
+ components_table = self._get_table(table_type="components")
3351
+ configs_table = self._get_table(table_type="component_configs")
3352
+ links_table = self._get_table(table_type="component_links")
3353
+
3354
+ if components_table is None:
3355
+ return False
3356
+
3357
+ with self.Session() as sess, sess.begin():
3358
+ # Verify component exists (and not already soft-deleted for soft-delete)
3359
+ if hard_delete:
3360
+ exists = sess.execute(
3361
+ select(components_table.c.component_id).where(components_table.c.component_id == component_id)
3362
+ ).scalar_one_or_none()
3363
+ else:
3364
+ exists = sess.execute(
3365
+ select(components_table.c.component_id).where(
3366
+ components_table.c.component_id == component_id,
3367
+ components_table.c.deleted_at.is_(None),
3368
+ )
3369
+ ).scalar_one_or_none()
3370
+
3371
+ if exists is None:
3372
+ log_error(f"Component {component_id} not found")
3373
+ return False
3374
+
3375
+ if hard_delete:
3376
+ # Delete links where this component is parent or child
3377
+ if links_table is not None:
3378
+ sess.execute(links_table.delete().where(links_table.c.parent_component_id == component_id))
3379
+ sess.execute(links_table.delete().where(links_table.c.child_component_id == component_id))
3380
+ # Delete configs
3381
+ if configs_table is not None:
3382
+ sess.execute(configs_table.delete().where(configs_table.c.component_id == component_id))
3383
+ # Delete component
3384
+ sess.execute(components_table.delete().where(components_table.c.component_id == component_id))
3385
+ else:
3386
+ # Soft delete (preserve current_version for potential reactivation)
3387
+ sess.execute(
3388
+ components_table.update()
3389
+ .where(components_table.c.component_id == component_id)
3390
+ .values(deleted_at=int(time.time()))
3391
+ )
3392
+
3393
+ return True
3394
+
3395
+ except Exception as e:
3396
+ log_error(f"Error deleting component: {e}")
3397
+ raise
3398
+
3399
+ def list_components(
3400
+ self,
3401
+ component_type: Optional[ComponentType] = None,
3402
+ include_deleted: bool = False,
3403
+ limit: int = 20,
3404
+ offset: int = 0,
3405
+ ) -> Tuple[List[Dict[str, Any]], int]:
3406
+ """List components with pagination.
3407
+
3408
+ Args:
3409
+ component_type: Filter by type (agent|team|workflow).
3410
+ include_deleted: Include soft-deleted components.
3411
+ limit: Maximum number of items to return.
3412
+ offset: Number of items to skip.
3413
+
3414
+ Returns:
3415
+ Tuple of (list of component dicts, total count).
3416
+ """
3417
+ try:
3418
+ table = self._get_table(table_type="components")
3419
+ if table is None:
3420
+ return [], 0
3421
+
3422
+ with self.Session() as sess:
3423
+ # Build base where clause
3424
+ where_clauses = []
3425
+ if component_type is not None:
3426
+ where_clauses.append(table.c.component_type == component_type.value)
3427
+ if not include_deleted:
3428
+ where_clauses.append(table.c.deleted_at.is_(None))
3429
+
3430
+ # Get total count
3431
+ count_stmt = select(func.count()).select_from(table)
3432
+ for clause in where_clauses:
3433
+ count_stmt = count_stmt.where(clause)
3434
+ total_count = sess.execute(count_stmt).scalar() or 0
3435
+
3436
+ # Get paginated results
3437
+ stmt = select(table).order_by(
3438
+ table.c.created_at.desc(),
3439
+ table.c.component_id,
3440
+ )
3441
+ for clause in where_clauses:
3442
+ stmt = stmt.where(clause)
3443
+ stmt = stmt.limit(limit).offset(offset)
3444
+
3445
+ rows = sess.execute(stmt).mappings().all()
3446
+ return [dict(r) for r in rows], total_count
3447
+
3448
+ except Exception as e:
3449
+ log_error(f"Error listing components: {e}")
3450
+ raise
3451
+
3452
+ def create_component_with_config(
3453
+ self,
3454
+ component_id: str,
3455
+ component_type: ComponentType,
3456
+ name: Optional[str],
3457
+ config: Dict[str, Any],
3458
+ description: Optional[str] = None,
3459
+ metadata: Optional[Dict[str, Any]] = None,
3460
+ label: Optional[str] = None,
3461
+ stage: str = "draft",
3462
+ notes: Optional[str] = None,
3463
+ links: Optional[List[Dict[str, Any]]] = None,
3464
+ ) -> Tuple[Dict[str, Any], Dict[str, Any]]:
3465
+ """Create a component with its initial config atomically.
3466
+
3467
+ Args:
3468
+ component_id: Unique identifier.
3469
+ component_type: Type (agent|team|workflow).
3470
+ name: Display name.
3471
+ config: The config data.
3472
+ description: Optional description.
3473
+ metadata: Optional metadata dict.
3474
+ label: Optional config label.
3475
+ stage: "draft" or "published".
3476
+ notes: Optional notes.
3477
+ links: Optional list of links. Each must have child_version set.
3478
+
3479
+ Returns:
3480
+ Tuple of (component dict, config dict).
3481
+
3482
+ Raises:
3483
+ ValueError: If component already exists, invalid stage, or link missing child_version.
3484
+ """
3485
+ if stage not in {"draft", "published"}:
3486
+ raise ValueError(f"Invalid stage: {stage}")
3487
+
3488
+ # Validate links have child_version
3489
+ if links:
3490
+ for link in links:
3491
+ if link.get("child_version") is None:
3492
+ raise ValueError(f"child_version is required for link to {link['child_component_id']}")
3493
+
3494
+ try:
3495
+ components_table = self._get_table(table_type="components", create_table_if_not_found=True)
3496
+ configs_table = self._get_table(table_type="component_configs", create_table_if_not_found=True)
3497
+ links_table = self._get_table(table_type="component_links", create_table_if_not_found=True)
3498
+
3499
+ if components_table is None:
3500
+ raise ValueError("Components table not found")
3501
+ if configs_table is None:
3502
+ raise ValueError("Component configs table not found")
3503
+
3504
+ with self.Session() as sess, sess.begin():
3505
+ # Check if component already exists
3506
+ existing = sess.execute(
3507
+ select(components_table.c.component_id).where(components_table.c.component_id == component_id)
3508
+ ).scalar_one_or_none()
3509
+
3510
+ if existing is not None:
3511
+ raise ValueError(f"Component {component_id} already exists")
3512
+
3513
+ # Check label uniqueness
3514
+ if label is not None:
3515
+ existing_label = sess.execute(
3516
+ select(configs_table.c.version).where(
3517
+ configs_table.c.component_id == component_id,
3518
+ configs_table.c.label == label,
3519
+ )
3520
+ ).first()
3521
+ if existing_label:
3522
+ raise ValueError(f"Label '{label}' already exists for {component_id}")
3523
+
3524
+ now = int(time.time())
3525
+ version = 1
3526
+
3527
+ # Create component
3528
+ sess.execute(
3529
+ components_table.insert().values(
3530
+ component_id=component_id,
3531
+ component_type=component_type.value,
3532
+ name=name,
3533
+ description=description,
3534
+ metadata=metadata,
3535
+ current_version=version if stage == "published" else None,
3536
+ created_at=now,
3537
+ )
3538
+ )
3539
+
3540
+ # Create initial config
3541
+ sess.execute(
3542
+ configs_table.insert().values(
3543
+ component_id=component_id,
3544
+ version=version,
3545
+ label=label,
3546
+ stage=stage,
3547
+ config=config,
3548
+ notes=notes,
3549
+ created_at=now,
3550
+ )
3551
+ )
3552
+
3553
+ # Create links if provided
3554
+ if links and links_table is not None:
3555
+ for link in links:
3556
+ sess.execute(
3557
+ links_table.insert().values(
3558
+ parent_component_id=component_id,
3559
+ parent_version=version,
3560
+ link_kind=link["link_kind"],
3561
+ link_key=link["link_key"],
3562
+ child_component_id=link["child_component_id"],
3563
+ child_version=link["child_version"],
3564
+ position=link["position"],
3565
+ meta=link.get("meta"),
3566
+ created_at=now,
3567
+ )
3568
+ )
3569
+
3570
+ # Fetch and return both
3571
+ component = self.get_component(component_id)
3572
+ config_result = self.get_config(component_id, version=version)
3573
+
3574
+ if component is None:
3575
+ raise ValueError(f"Failed to get component {component_id} after creation")
3576
+ if config_result is None:
3577
+ raise ValueError(f"Failed to get config for {component_id} after creation")
3578
+
3579
+ return component, config_result
3580
+
3581
+ except Exception as e:
3582
+ log_error(f"Error creating component with config: {e}")
3583
+ raise
3584
+
3585
+ # --- Component Configs ---
3586
+ def get_config(
3587
+ self,
3588
+ component_id: str,
3589
+ version: Optional[int] = None,
3590
+ label: Optional[str] = None,
3591
+ ) -> Optional[Dict[str, Any]]:
3592
+ """Get a config by component ID and version or label.
3593
+
3594
+ Args:
3595
+ component_id: The component ID.
3596
+ version: Specific version number. If None, uses current or latest draft.
3597
+ label: Config label to lookup. Ignored if version is provided.
3598
+
3599
+ Returns:
3600
+ Config dictionary or None if not found.
3601
+ """
3602
+ try:
3603
+ configs_table = self._get_table(table_type="component_configs")
3604
+ components_table = self._get_table(table_type="components")
3605
+
3606
+ if configs_table is None or components_table is None:
3607
+ return None
3608
+
3609
+ with self.Session() as sess:
3610
+ # Verify component exists and get current_version
3611
+ component_row = (
3612
+ sess.execute(
3613
+ select(components_table.c.component_id, components_table.c.current_version).where(
3614
+ components_table.c.component_id == component_id,
3615
+ components_table.c.deleted_at.is_(None),
3616
+ )
3617
+ )
3618
+ .mappings()
3619
+ .one_or_none()
3620
+ )
3621
+
3622
+ if component_row is None:
3623
+ return None
3624
+
3625
+ current_version = component_row["current_version"]
3626
+
3627
+ if version is not None:
3628
+ stmt = select(configs_table).where(
3629
+ configs_table.c.component_id == component_id,
3630
+ configs_table.c.version == version,
3631
+ )
3632
+ elif label is not None:
3633
+ stmt = select(configs_table).where(
3634
+ configs_table.c.component_id == component_id,
3635
+ configs_table.c.label == label,
3636
+ )
3637
+ elif current_version is not None:
3638
+ # Use the current published version
3639
+ stmt = select(configs_table).where(
3640
+ configs_table.c.component_id == component_id,
3641
+ configs_table.c.version == current_version,
3642
+ )
3643
+ else:
3644
+ # No current_version set (draft only) - get the latest version
3645
+ stmt = (
3646
+ select(configs_table)
3647
+ .where(configs_table.c.component_id == component_id)
3648
+ .order_by(configs_table.c.version.desc())
3649
+ .limit(1)
3650
+ )
3651
+
3652
+ row = sess.execute(stmt).mappings().one_or_none()
3653
+ return dict(row) if row else None
3654
+
3655
+ except Exception as e:
3656
+ log_error(f"Error getting config: {e}")
3657
+ raise
3658
+
3659
+ def upsert_config(
3660
+ self,
3661
+ component_id: str,
3662
+ config: Optional[Dict[str, Any]] = None,
3663
+ version: Optional[int] = None,
3664
+ label: Optional[str] = None,
3665
+ stage: Optional[str] = None,
3666
+ notes: Optional[str] = None,
3667
+ links: Optional[List[Dict[str, Any]]] = None,
3668
+ ) -> Dict[str, Any]:
3669
+ """Create or update a config version for a component.
3670
+
3671
+ Rules:
3672
+ - Draft configs can be edited freely
3673
+ - Published configs are immutable
3674
+ - Publishing a config automatically sets it as current_version
3675
+
3676
+ Args:
3677
+ component_id: The component ID.
3678
+ config: The config data. Required for create, optional for update.
3679
+ version: If None, creates new version. If provided, updates that version.
3680
+ label: Optional human-readable label.
3681
+ stage: "draft" or "published". Defaults to "draft" for new configs.
3682
+ notes: Optional notes.
3683
+ links: Optional list of links. Each link must have child_version set.
3684
+
3685
+ Returns:
3686
+ Created/updated config dictionary.
3687
+
3688
+ Raises:
3689
+ ValueError: If component doesn't exist, version not found, label conflict,
3690
+ or attempting to update a published config.
3691
+ """
3692
+ if stage is not None and stage not in {"draft", "published"}:
3693
+ raise ValueError(f"Invalid stage: {stage}")
3694
+
3695
+ try:
3696
+ configs_table = self._get_table(table_type="component_configs", create_table_if_not_found=True)
3697
+ components_table = self._get_table(table_type="components")
3698
+ links_table = self._get_table(table_type="component_links", create_table_if_not_found=True)
3699
+
3700
+ if components_table is None:
3701
+ raise ValueError("Components table not found")
3702
+ if configs_table is None:
3703
+ raise ValueError("Component configs table not found")
3704
+
3705
+ with self.Session() as sess, sess.begin():
3706
+ # Verify component exists and is not deleted
3707
+ component = sess.execute(
3708
+ select(components_table.c.component_id).where(
3709
+ components_table.c.component_id == component_id,
3710
+ components_table.c.deleted_at.is_(None),
3711
+ )
3712
+ ).scalar_one_or_none()
3713
+
3714
+ if component is None:
3715
+ raise ValueError(f"Component {component_id} not found")
3716
+
3717
+ # Label uniqueness check
3718
+ if label is not None:
3719
+ label_query = select(configs_table.c.version).where(
3720
+ configs_table.c.component_id == component_id,
3721
+ configs_table.c.label == label,
3722
+ )
3723
+ if version is not None:
3724
+ label_query = label_query.where(configs_table.c.version != version)
3725
+
3726
+ if sess.execute(label_query).first():
3727
+ raise ValueError(f"Label '{label}' already exists for {component_id}")
3728
+
3729
+ # Validate links have child_version
3730
+ if links:
3731
+ for link in links:
3732
+ if link.get("child_version") is None:
3733
+ raise ValueError(f"child_version is required for link to {link['child_component_id']}")
3734
+
3735
+ if version is None:
3736
+ if config is None:
3737
+ raise ValueError("config is required when creating a new version")
3738
+
3739
+ # Default to draft for new configs
3740
+ if stage is None:
3741
+ stage = "draft"
3742
+
3743
+ max_version = sess.execute(
3744
+ select(configs_table.c.version)
3745
+ .where(configs_table.c.component_id == component_id)
3746
+ .order_by(configs_table.c.version.desc())
3747
+ .limit(1)
3748
+ ).scalar()
3749
+
3750
+ final_version = (max_version or 0) + 1
3751
+
3752
+ sess.execute(
3753
+ configs_table.insert().values(
3754
+ component_id=component_id,
3755
+ version=final_version,
3756
+ label=label,
3757
+ stage=stage,
3758
+ config=config,
3759
+ notes=notes,
3760
+ created_at=int(time.time()),
3761
+ )
3762
+ )
3763
+ else:
3764
+ existing = (
3765
+ sess.execute(
3766
+ select(configs_table.c.version, configs_table.c.stage).where(
3767
+ configs_table.c.component_id == component_id,
3768
+ configs_table.c.version == version,
3769
+ )
3770
+ )
3771
+ .mappings()
3772
+ .one_or_none()
3773
+ )
3774
+
3775
+ if existing is None:
3776
+ raise ValueError(f"Config {component_id} v{version} not found")
3777
+
3778
+ # Published configs are immutable
3779
+ if existing["stage"] == "published":
3780
+ raise ValueError(f"Cannot update published config {component_id} v{version}")
3781
+
3782
+ # Build update dict with only provided fields
3783
+ updates: Dict[str, Any] = {"updated_at": int(time.time())}
3784
+ if label is not None:
3785
+ updates["label"] = label
3786
+ if stage is not None:
3787
+ updates["stage"] = stage
3788
+ if config is not None:
3789
+ updates["config"] = config
3790
+ if notes is not None:
3791
+ updates["notes"] = notes
3792
+
3793
+ sess.execute(
3794
+ configs_table.update()
3795
+ .where(
3796
+ configs_table.c.component_id == component_id,
3797
+ configs_table.c.version == version,
3798
+ )
3799
+ .values(**updates)
3800
+ )
3801
+ final_version = version
3802
+
3803
+ if links is not None and links_table is not None:
3804
+ sess.execute(
3805
+ links_table.delete().where(
3806
+ links_table.c.parent_component_id == component_id,
3807
+ links_table.c.parent_version == final_version,
3808
+ )
3809
+ )
3810
+ for link in links:
3811
+ sess.execute(
3812
+ links_table.insert().values(
3813
+ parent_component_id=component_id,
3814
+ parent_version=final_version,
3815
+ link_kind=link["link_kind"],
3816
+ link_key=link["link_key"],
3817
+ child_component_id=link["child_component_id"],
3818
+ child_version=link["child_version"],
3819
+ position=link["position"],
3820
+ meta=link.get("meta"),
3821
+ created_at=int(time.time()),
3822
+ )
3823
+ )
3824
+
3825
+ # Determine final stage (could be from update or create)
3826
+ final_stage = stage if stage is not None else (existing["stage"] if version is not None else "draft")
3827
+
3828
+ if final_stage == "published":
3829
+ sess.execute(
3830
+ components_table.update()
3831
+ .where(components_table.c.component_id == component_id)
3832
+ .values(current_version=final_version, updated_at=int(time.time()))
3833
+ )
3834
+
3835
+ result = self.get_config(component_id, version=final_version)
3836
+ if result is None:
3837
+ raise ValueError(f"Failed to get config {component_id} v{final_version} after upsert")
3838
+ return result
3839
+
3840
+ except Exception as e:
3841
+ log_error(f"Error upserting config: {e}")
3842
+ raise
3843
+
3844
+ def delete_config(
3845
+ self,
3846
+ component_id: str,
3847
+ version: int,
3848
+ ) -> bool:
3849
+ """Delete a specific config version.
3850
+
3851
+ Only draft configs can be deleted. Published configs are immutable.
3852
+ Cannot delete the current version.
3853
+
3854
+ Args:
3855
+ component_id: The component ID.
3856
+ version: The version to delete.
3857
+
3858
+ Returns:
3859
+ True if deleted, False if not found.
3860
+
3861
+ Raises:
3862
+ ValueError: If attempting to delete a published or current config.
3863
+ """
3864
+ try:
3865
+ configs_table = self._get_table(table_type="component_configs")
3866
+ links_table = self._get_table(table_type="component_links")
3867
+ components_table = self._get_table(table_type="components")
3868
+
3869
+ if configs_table is None or components_table is None:
3870
+ return False
3871
+
3872
+ with self.Session() as sess, sess.begin():
3873
+ # Get config stage and check if it's current
3874
+ config_row = sess.execute(
3875
+ select(configs_table.c.stage).where(
3876
+ configs_table.c.component_id == component_id,
3877
+ configs_table.c.version == version,
3878
+ )
3879
+ ).scalar_one_or_none()
3880
+
3881
+ if config_row is None:
3882
+ return False
3883
+
3884
+ # Cannot delete published configs
3885
+ if config_row == "published":
3886
+ raise ValueError(f"Cannot delete published config {component_id} v{version}")
3887
+
3888
+ # Check if it's current version
3889
+ current = sess.execute(
3890
+ select(components_table.c.current_version).where(components_table.c.component_id == component_id)
3891
+ ).scalar_one_or_none()
3892
+
3893
+ if current == version:
3894
+ raise ValueError(f"Cannot delete current config {component_id} v{version}")
3895
+
3896
+ # Delete associated links
3897
+ if links_table is not None:
3898
+ sess.execute(
3899
+ links_table.delete().where(
3900
+ links_table.c.parent_component_id == component_id,
3901
+ links_table.c.parent_version == version,
3902
+ )
3903
+ )
3904
+
3905
+ # Delete the config
3906
+ sess.execute(
3907
+ configs_table.delete().where(
3908
+ configs_table.c.component_id == component_id,
3909
+ configs_table.c.version == version,
3910
+ )
3911
+ )
3912
+
3913
+ return True
3914
+
3915
+ except Exception as e:
3916
+ log_error(f"Error deleting config: {e}")
3917
+ raise
3918
+
3919
+ def list_configs(
3920
+ self,
3921
+ component_id: str,
3922
+ include_config: bool = False,
3923
+ ) -> List[Dict[str, Any]]:
3924
+ """List all config versions for a component.
3925
+
3926
+ Args:
3927
+ component_id: The component ID.
3928
+ include_config: If True, include full config blob. Otherwise just metadata.
3929
+
3930
+ Returns:
3931
+ List of config dictionaries, newest first.
3932
+ Returns empty list if component not found or deleted.
3933
+ """
3934
+ try:
3935
+ configs_table = self._get_table(table_type="component_configs")
3936
+ components_table = self._get_table(table_type="components")
3937
+
3938
+ if configs_table is None or components_table is None:
3939
+ return []
3940
+
3941
+ with self.Session() as sess:
3942
+ # Verify component exists and is not deleted
3943
+ exists = sess.execute(
3944
+ select(components_table.c.component_id).where(
3945
+ components_table.c.component_id == component_id,
3946
+ components_table.c.deleted_at.is_(None),
3947
+ )
3948
+ ).scalar_one_or_none()
3949
+
3950
+ if exists is None:
3951
+ return []
3952
+
3953
+ # Select columns based on include_config flag
3954
+ if include_config:
3955
+ stmt = select(configs_table)
3956
+ else:
3957
+ stmt = select(
3958
+ configs_table.c.component_id,
3959
+ configs_table.c.version,
3960
+ configs_table.c.label,
3961
+ configs_table.c.stage,
3962
+ configs_table.c.notes,
3963
+ configs_table.c.created_at,
3964
+ configs_table.c.updated_at,
3965
+ )
3966
+
3967
+ stmt = stmt.where(configs_table.c.component_id == component_id).order_by(configs_table.c.version.desc())
3968
+
3969
+ results = sess.execute(stmt).mappings().all()
3970
+ return [dict(row) for row in results]
3971
+
3972
+ except Exception as e:
3973
+ log_error(f"Error listing configs: {e}")
3974
+ raise
3975
+
3976
+ def set_current_version(
3977
+ self,
3978
+ component_id: str,
3979
+ version: int,
3980
+ ) -> bool:
3981
+ """Set a specific published version as current.
3982
+
3983
+ Only published configs can be set as current. This is used for
3984
+ rollback scenarios where you want to switch to a previous
3985
+ published version.
3986
+
3987
+ Args:
3988
+ component_id: The component ID.
3989
+ version: The version to set as current (must be published).
3990
+
3991
+ Returns:
3992
+ True if successful, False if component or version not found.
3993
+
3994
+ Raises:
3995
+ ValueError: If attempting to set a draft config as current.
3996
+ """
3997
+ try:
3998
+ configs_table = self._get_table(table_type="component_configs")
3999
+ components_table = self._get_table(table_type="components")
4000
+
4001
+ if configs_table is None or components_table is None:
4002
+ return False
4003
+
4004
+ with self.Session() as sess, sess.begin():
4005
+ # Verify component exists and is not deleted
4006
+ component_exists = sess.execute(
4007
+ select(components_table.c.component_id).where(
4008
+ components_table.c.component_id == component_id,
4009
+ components_table.c.deleted_at.is_(None),
4010
+ )
4011
+ ).scalar_one_or_none()
4012
+
4013
+ if component_exists is None:
4014
+ return False
4015
+
4016
+ # Verify version exists and get stage
4017
+ stage = sess.execute(
4018
+ select(configs_table.c.stage).where(
4019
+ configs_table.c.component_id == component_id,
4020
+ configs_table.c.version == version,
4021
+ )
4022
+ ).scalar_one_or_none()
4023
+
4024
+ if stage is None:
4025
+ return False
4026
+
4027
+ # Only published configs can be set as current
4028
+ if stage != "published":
4029
+ raise ValueError(
4030
+ f"Cannot set draft config {component_id} v{version} as current. "
4031
+ "Only published configs can be current."
4032
+ )
4033
+
4034
+ # Update pointer
4035
+ result = sess.execute(
4036
+ components_table.update()
4037
+ .where(components_table.c.component_id == component_id)
4038
+ .values(current_version=version, updated_at=int(time.time()))
4039
+ )
4040
+
4041
+ if result.rowcount == 0:
4042
+ return False
4043
+
4044
+ log_debug(f"Set {component_id} current version to {version}")
4045
+ return True
4046
+
4047
+ except Exception as e:
4048
+ log_error(f"Error setting current version: {e}")
4049
+ raise
4050
+
4051
+ # --- Component Links ---
4052
+ def get_links(
4053
+ self,
4054
+ component_id: str,
4055
+ version: int,
4056
+ link_kind: Optional[str] = None,
4057
+ ) -> List[Dict[str, Any]]:
4058
+ """Get links for a config version.
4059
+
4060
+ Args:
4061
+ component_id: The component ID.
4062
+ version: The config version.
4063
+ link_kind: Optional filter by link kind (member|step).
4064
+
4065
+ Returns:
4066
+ List of link dictionaries, ordered by position.
4067
+ """
4068
+ try:
4069
+ table = self._get_table(table_type="component_links")
4070
+ if table is None:
4071
+ return []
4072
+
4073
+ with self.Session() as sess:
4074
+ stmt = (
4075
+ select(table)
4076
+ .where(
4077
+ table.c.parent_component_id == component_id,
4078
+ table.c.parent_version == version,
4079
+ )
4080
+ .order_by(table.c.position)
4081
+ )
4082
+ if link_kind is not None:
4083
+ stmt = stmt.where(table.c.link_kind == link_kind)
4084
+
4085
+ rows = sess.execute(stmt).mappings().all()
4086
+ return [dict(r) for r in rows]
4087
+
4088
+ except Exception as e:
4089
+ log_error(f"Error getting links: {e}")
4090
+ raise
4091
+
4092
+ def get_dependents(
4093
+ self,
4094
+ component_id: str,
4095
+ version: Optional[int] = None,
4096
+ ) -> List[Dict[str, Any]]:
4097
+ """Find all components that reference this component.
4098
+
4099
+ Args:
4100
+ component_id: The component ID to find dependents of.
4101
+ version: Optional specific version. If None, finds links to any version.
4102
+
4103
+ Returns:
4104
+ List of link dictionaries showing what depends on this component.
4105
+ """
4106
+ try:
4107
+ table = self._get_table(table_type="component_links")
4108
+ if table is None:
4109
+ return []
4110
+
4111
+ with self.Session() as sess:
4112
+ stmt = select(table).where(table.c.child_component_id == component_id)
4113
+ if version is not None:
4114
+ stmt = stmt.where(table.c.child_version == version)
4115
+
4116
+ rows = sess.execute(stmt).mappings().all()
4117
+ return [dict(r) for r in rows]
4118
+
4119
+ except Exception as e:
4120
+ log_error(f"Error getting dependents: {e}")
4121
+ raise
4122
+
4123
+ def _resolve_version(
4124
+ self,
4125
+ component_id: str,
4126
+ version: Optional[int],
4127
+ ) -> Optional[int]:
4128
+ """Resolve a version number, handling None as 'current'.
4129
+
4130
+ Args:
4131
+ component_id: The component ID.
4132
+ version: Version number or None for current.
4133
+
4134
+ Returns:
4135
+ Resolved version number, or None if component missing/deleted or no current.
4136
+ """
4137
+ if version is not None:
4138
+ return version
4139
+
4140
+ try:
4141
+ components_table = self._get_table(table_type="components")
4142
+ if components_table is None:
4143
+ return None
4144
+
4145
+ with self.Session() as sess:
4146
+ return sess.execute(
4147
+ select(components_table.c.current_version).where(
4148
+ components_table.c.component_id == component_id,
4149
+ components_table.c.deleted_at.is_(None),
4150
+ )
4151
+ ).scalar_one_or_none()
4152
+
4153
+ except Exception as e:
4154
+ log_error(f"Error resolving version: {e}")
4155
+ raise
4156
+
4157
+ def load_component_graph(
4158
+ self,
4159
+ component_id: str,
4160
+ version: Optional[int] = None,
4161
+ label: Optional[str] = None,
4162
+ *,
4163
+ _visited: Optional[Set[Tuple[str, int]]] = None,
4164
+ _max_depth: int = 50,
4165
+ ) -> Optional[Dict[str, Any]]:
4166
+ """Load a component with its full resolved graph.
4167
+
4168
+ Handles cycles by returning a stub with cycle_detected=True.
4169
+ Has a max depth guard to prevent stack overflow.
4170
+
4171
+ Args:
4172
+ component_id: The component ID.
4173
+ version: Specific version or None for current.
4174
+ label: Optional label of the component.
4175
+ _visited: Internal cycle tracking (do not pass).
4176
+ _max_depth: Internal depth limit (do not pass).
4177
+
4178
+ Returns:
4179
+ Dictionary with component, config, children, and resolved_versions.
4180
+ Returns None if component not found or depth exceeded.
4181
+ """
4182
+ try:
4183
+ if _max_depth <= 0:
4184
+ return None
4185
+
4186
+ component = self.get_component(component_id)
4187
+ if component is None:
4188
+ return None
4189
+
4190
+ resolved_version = self._resolve_version(component_id, version)
4191
+ if resolved_version is None:
4192
+ return None
4193
+
4194
+ # Cycle detection
4195
+ if _visited is None:
4196
+ _visited = set()
4197
+
4198
+ node_key = (component_id, resolved_version)
4199
+ if node_key in _visited:
4200
+ return {
4201
+ "component": component,
4202
+ "config": self.get_config(component_id, version=resolved_version),
4203
+ "children": [],
4204
+ "resolved_versions": {component_id: resolved_version},
4205
+ "cycle_detected": True,
4206
+ }
4207
+ _visited.add(node_key)
4208
+
4209
+ config = self.get_config(component_id, version=resolved_version)
4210
+ if config is None:
4211
+ return None
4212
+
4213
+ links = self.get_links(component_id, resolved_version)
4214
+
4215
+ children: List[Dict[str, Any]] = []
4216
+ resolved_versions: Dict[str, Optional[int]] = {component_id: resolved_version}
4217
+
4218
+ for link in links:
4219
+ child_id = link["child_component_id"]
4220
+ child_ver = link.get("child_version")
4221
+
4222
+ resolved_child_ver = self._resolve_version(child_id, child_ver)
4223
+ resolved_versions[child_id] = resolved_child_ver
4224
+
4225
+ if resolved_child_ver is None:
4226
+ children.append(
4227
+ {
4228
+ "link": link,
4229
+ "graph": None,
4230
+ "error": "child_version_unresolvable",
4231
+ }
4232
+ )
4233
+ continue
4234
+
4235
+ child_graph = self.load_component_graph(
4236
+ child_id,
4237
+ version=resolved_child_ver,
4238
+ _visited=_visited,
4239
+ _max_depth=_max_depth - 1,
4240
+ )
4241
+
4242
+ if child_graph:
4243
+ resolved_versions.update(child_graph.get("resolved_versions", {}))
4244
+
4245
+ children.append({"link": link, "graph": child_graph})
4246
+
4247
+ return {
4248
+ "component": component,
4249
+ "config": config,
4250
+ "children": children,
4251
+ "resolved_versions": resolved_versions,
4252
+ }
4253
+
4254
+ except Exception as e:
4255
+ log_error(f"Error loading component graph: {e}")
4256
+ raise
4257
+
3041
4258
  # -- Learning methods --
3042
4259
  def get_learning(
3043
4260
  self,