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.
- agno/agent/__init__.py +4 -0
- agno/agent/agent.py +1368 -541
- agno/agent/remote.py +13 -0
- agno/db/base.py +339 -0
- agno/db/postgres/async_postgres.py +116 -12
- agno/db/postgres/postgres.py +1242 -25
- agno/db/postgres/schemas.py +48 -1
- agno/db/sqlite/async_sqlite.py +119 -4
- agno/db/sqlite/schemas.py +51 -0
- agno/db/sqlite/sqlite.py +1186 -13
- agno/db/utils.py +37 -1
- agno/integrations/discord/client.py +12 -1
- agno/knowledge/__init__.py +4 -0
- agno/knowledge/chunking/code.py +1 -1
- agno/knowledge/chunking/semantic.py +1 -1
- agno/knowledge/chunking/strategy.py +4 -0
- agno/knowledge/filesystem.py +412 -0
- agno/knowledge/knowledge.py +3722 -2182
- agno/knowledge/protocol.py +134 -0
- agno/knowledge/reader/arxiv_reader.py +2 -2
- agno/knowledge/reader/base.py +9 -7
- agno/knowledge/reader/csv_reader.py +236 -13
- agno/knowledge/reader/docx_reader.py +2 -2
- agno/knowledge/reader/field_labeled_csv_reader.py +169 -5
- agno/knowledge/reader/firecrawl_reader.py +2 -2
- agno/knowledge/reader/json_reader.py +2 -2
- agno/knowledge/reader/markdown_reader.py +2 -2
- agno/knowledge/reader/pdf_reader.py +5 -4
- agno/knowledge/reader/pptx_reader.py +2 -2
- agno/knowledge/reader/reader_factory.py +118 -1
- agno/knowledge/reader/s3_reader.py +2 -2
- agno/knowledge/reader/tavily_reader.py +2 -2
- agno/knowledge/reader/text_reader.py +2 -2
- agno/knowledge/reader/web_search_reader.py +2 -2
- agno/knowledge/reader/website_reader.py +5 -3
- agno/knowledge/reader/wikipedia_reader.py +2 -2
- agno/knowledge/reader/youtube_reader.py +2 -2
- agno/knowledge/remote_content/__init__.py +29 -0
- agno/knowledge/remote_content/config.py +204 -0
- agno/knowledge/remote_content/remote_content.py +74 -17
- agno/knowledge/utils.py +37 -29
- agno/learn/__init__.py +6 -0
- agno/learn/machine.py +35 -0
- agno/learn/schemas.py +82 -11
- agno/learn/stores/__init__.py +3 -0
- agno/learn/stores/decision_log.py +1156 -0
- agno/learn/stores/learned_knowledge.py +6 -6
- agno/models/anthropic/claude.py +24 -0
- agno/models/aws/bedrock.py +20 -0
- agno/models/base.py +60 -6
- agno/models/cerebras/cerebras.py +34 -2
- agno/models/cohere/chat.py +25 -0
- agno/models/google/gemini.py +50 -5
- agno/models/litellm/chat.py +38 -0
- agno/models/n1n/__init__.py +3 -0
- agno/models/n1n/n1n.py +57 -0
- agno/models/openai/chat.py +25 -1
- agno/models/openrouter/openrouter.py +46 -0
- agno/models/perplexity/perplexity.py +2 -0
- agno/models/response.py +16 -0
- agno/os/app.py +83 -44
- agno/os/interfaces/slack/router.py +10 -1
- agno/os/interfaces/whatsapp/router.py +6 -0
- agno/os/middleware/__init__.py +2 -0
- agno/os/middleware/trailing_slash.py +27 -0
- agno/os/router.py +1 -0
- agno/os/routers/agents/router.py +29 -16
- agno/os/routers/agents/schema.py +6 -4
- agno/os/routers/components/__init__.py +3 -0
- agno/os/routers/components/components.py +475 -0
- agno/os/routers/evals/schemas.py +4 -3
- agno/os/routers/health.py +3 -3
- agno/os/routers/knowledge/knowledge.py +128 -3
- agno/os/routers/knowledge/schemas.py +12 -0
- agno/os/routers/memory/schemas.py +4 -2
- agno/os/routers/metrics/metrics.py +9 -11
- agno/os/routers/metrics/schemas.py +10 -6
- agno/os/routers/registry/__init__.py +3 -0
- agno/os/routers/registry/registry.py +337 -0
- agno/os/routers/teams/router.py +20 -8
- agno/os/routers/teams/schema.py +6 -4
- agno/os/routers/traces/traces.py +5 -5
- agno/os/routers/workflows/router.py +38 -11
- agno/os/routers/workflows/schema.py +1 -1
- agno/os/schema.py +92 -26
- agno/os/utils.py +84 -19
- agno/reasoning/anthropic.py +2 -2
- agno/reasoning/azure_ai_foundry.py +2 -2
- agno/reasoning/deepseek.py +2 -2
- agno/reasoning/default.py +6 -7
- agno/reasoning/gemini.py +2 -2
- agno/reasoning/helpers.py +6 -7
- agno/reasoning/manager.py +4 -10
- agno/reasoning/ollama.py +2 -2
- agno/reasoning/openai.py +2 -2
- agno/reasoning/vertexai.py +2 -2
- agno/registry/__init__.py +3 -0
- agno/registry/registry.py +68 -0
- agno/run/agent.py +59 -0
- agno/run/base.py +7 -0
- agno/run/team.py +57 -0
- agno/skills/agent_skills.py +10 -3
- agno/team/__init__.py +3 -1
- agno/team/team.py +1165 -330
- agno/tools/duckduckgo.py +25 -71
- agno/tools/exa.py +0 -21
- agno/tools/function.py +35 -83
- agno/tools/knowledge.py +9 -4
- agno/tools/mem0.py +11 -10
- agno/tools/memory.py +47 -46
- agno/tools/parallel.py +0 -7
- agno/tools/reasoning.py +30 -23
- agno/tools/tavily.py +4 -1
- agno/tools/websearch.py +93 -0
- agno/tools/website.py +1 -1
- agno/tools/wikipedia.py +1 -1
- agno/tools/workflow.py +48 -47
- agno/utils/agent.py +42 -5
- agno/utils/events.py +160 -2
- agno/utils/print_response/agent.py +0 -31
- agno/utils/print_response/team.py +0 -2
- agno/utils/print_response/workflow.py +0 -2
- agno/utils/team.py +61 -11
- agno/vectordb/lancedb/lance_db.py +4 -1
- agno/vectordb/mongodb/mongodb.py +1 -1
- agno/vectordb/pgvector/pgvector.py +3 -3
- agno/vectordb/qdrant/qdrant.py +4 -4
- agno/workflow/__init__.py +3 -1
- agno/workflow/condition.py +0 -21
- agno/workflow/loop.py +0 -21
- agno/workflow/parallel.py +0 -21
- agno/workflow/router.py +0 -21
- agno/workflow/step.py +117 -24
- agno/workflow/steps.py +0 -21
- agno/workflow/workflow.py +427 -63
- {agno-2.3.26.dist-info → agno-2.4.1.dist-info}/METADATA +49 -76
- {agno-2.3.26.dist-info → agno-2.4.1.dist-info}/RECORD +140 -126
- {agno-2.3.26.dist-info → agno-2.4.1.dist-info}/WHEEL +1 -1
- {agno-2.3.26.dist-info → agno-2.4.1.dist-info}/licenses/LICENSE +0 -0
- {agno-2.3.26.dist-info → agno-2.4.1.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
|
-
|
|
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
|
-
#
|
|
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
|
|
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
|
-
#
|
|
264
|
+
# Single-column FK
|
|
204
265
|
if "foreign_key" in col_config:
|
|
205
|
-
|
|
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
|
-
#
|
|
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
|
-
#
|
|
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
|
-
|
|
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,1006 @@ 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 or latest draft.
|
|
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_row = (
|
|
3494
|
+
sess.execute(
|
|
3495
|
+
select(components_table.c.current_version, components_table.c.component_id).where(
|
|
3496
|
+
components_table.c.component_id == component_id,
|
|
3497
|
+
components_table.c.deleted_at.is_(None),
|
|
3498
|
+
)
|
|
3499
|
+
)
|
|
3500
|
+
.mappings()
|
|
3501
|
+
.one_or_none()
|
|
3502
|
+
)
|
|
3503
|
+
|
|
3504
|
+
if component_row is None:
|
|
3505
|
+
return None
|
|
3506
|
+
|
|
3507
|
+
current_version = component_row["current_version"]
|
|
3508
|
+
|
|
3509
|
+
if version is not None:
|
|
3510
|
+
stmt = select(configs_table).where(
|
|
3511
|
+
configs_table.c.component_id == component_id,
|
|
3512
|
+
configs_table.c.version == version,
|
|
3513
|
+
)
|
|
3514
|
+
elif label is not None:
|
|
3515
|
+
stmt = select(configs_table).where(
|
|
3516
|
+
configs_table.c.component_id == component_id,
|
|
3517
|
+
configs_table.c.label == label,
|
|
3518
|
+
)
|
|
3519
|
+
elif current_version is not None:
|
|
3520
|
+
# Use the current published version
|
|
3521
|
+
stmt = select(configs_table).where(
|
|
3522
|
+
configs_table.c.component_id == component_id,
|
|
3523
|
+
configs_table.c.version == current_version,
|
|
3524
|
+
)
|
|
3525
|
+
else:
|
|
3526
|
+
# No current_version set (draft only) - get the latest version
|
|
3527
|
+
stmt = (
|
|
3528
|
+
select(configs_table)
|
|
3529
|
+
.where(configs_table.c.component_id == component_id)
|
|
3530
|
+
.order_by(configs_table.c.version.desc())
|
|
3531
|
+
.limit(1)
|
|
3532
|
+
)
|
|
3533
|
+
|
|
3534
|
+
result = sess.execute(stmt).fetchone()
|
|
3535
|
+
return dict(result._mapping) if result else None
|
|
3536
|
+
|
|
3537
|
+
except Exception as e:
|
|
3538
|
+
log_error(f"Error getting config: {e}")
|
|
3539
|
+
raise
|
|
3540
|
+
|
|
3541
|
+
def upsert_config(
|
|
3542
|
+
self,
|
|
3543
|
+
component_id: str,
|
|
3544
|
+
config: Optional[Dict[str, Any]] = None,
|
|
3545
|
+
version: Optional[int] = None,
|
|
3546
|
+
label: Optional[str] = None,
|
|
3547
|
+
stage: Optional[str] = None,
|
|
3548
|
+
notes: Optional[str] = None,
|
|
3549
|
+
links: Optional[List[Dict[str, Any]]] = None,
|
|
3550
|
+
) -> Dict[str, Any]:
|
|
3551
|
+
"""Create or update a config version for a component.
|
|
3552
|
+
|
|
3553
|
+
Rules:
|
|
3554
|
+
- Draft configs can be edited freely
|
|
3555
|
+
- Published configs are immutable
|
|
3556
|
+
- Publishing a config automatically sets it as current_version
|
|
3557
|
+
|
|
3558
|
+
Args:
|
|
3559
|
+
component_id: The component ID.
|
|
3560
|
+
config: The config data. Required for create, optional for update.
|
|
3561
|
+
version: If None, creates new version. If provided, updates that version.
|
|
3562
|
+
label: Optional human-readable label.
|
|
3563
|
+
stage: "draft" or "published". Defaults to "draft" for new configs.
|
|
3564
|
+
notes: Optional notes.
|
|
3565
|
+
links: Optional list of links. Each link must have child_version set.
|
|
3566
|
+
|
|
3567
|
+
Returns:
|
|
3568
|
+
Created/updated config dictionary.
|
|
3569
|
+
|
|
3570
|
+
Raises:
|
|
3571
|
+
ValueError: If component doesn't exist, version not found, label conflict,
|
|
3572
|
+
or attempting to update a published config.
|
|
3573
|
+
"""
|
|
3574
|
+
if stage is not None and stage not in {"draft", "published"}:
|
|
3575
|
+
raise ValueError(f"Invalid stage: {stage}")
|
|
3576
|
+
|
|
3577
|
+
try:
|
|
3578
|
+
configs_table = self._get_table(table_type="component_configs", create_table_if_not_found=True)
|
|
3579
|
+
components_table = self._get_table(table_type="components")
|
|
3580
|
+
links_table = self._get_table(table_type="component_links", create_table_if_not_found=True)
|
|
3581
|
+
|
|
3582
|
+
if components_table is None:
|
|
3583
|
+
raise ValueError("Components table not found")
|
|
3584
|
+
if configs_table is None:
|
|
3585
|
+
raise ValueError("Component configs table not found")
|
|
3586
|
+
|
|
3587
|
+
with self.Session() as sess, sess.begin():
|
|
3588
|
+
# Verify component exists and is not deleted
|
|
3589
|
+
component = sess.execute(
|
|
3590
|
+
select(components_table.c.component_id).where(
|
|
3591
|
+
components_table.c.component_id == component_id,
|
|
3592
|
+
components_table.c.deleted_at.is_(None),
|
|
3593
|
+
)
|
|
3594
|
+
).fetchone()
|
|
3595
|
+
|
|
3596
|
+
if component is None:
|
|
3597
|
+
raise ValueError(f"Component {component_id} not found")
|
|
3598
|
+
|
|
3599
|
+
# Label uniqueness check
|
|
3600
|
+
if label is not None:
|
|
3601
|
+
label_query = select(configs_table.c.version).where(
|
|
3602
|
+
configs_table.c.component_id == component_id,
|
|
3603
|
+
configs_table.c.label == label,
|
|
3604
|
+
)
|
|
3605
|
+
if version is not None:
|
|
3606
|
+
label_query = label_query.where(configs_table.c.version != version)
|
|
3607
|
+
|
|
3608
|
+
if sess.execute(label_query).first():
|
|
3609
|
+
raise ValueError(f"Label '{label}' already exists for {component_id}")
|
|
3610
|
+
|
|
3611
|
+
# Validate links have child_version
|
|
3612
|
+
if links:
|
|
3613
|
+
for link in links:
|
|
3614
|
+
if link.get("child_version") is None:
|
|
3615
|
+
raise ValueError(f"child_version is required for link to {link['child_component_id']}")
|
|
3616
|
+
|
|
3617
|
+
if version is None:
|
|
3618
|
+
if config is None:
|
|
3619
|
+
raise ValueError("config is required when creating a new version")
|
|
3620
|
+
|
|
3621
|
+
# Default to draft for new configs
|
|
3622
|
+
if stage is None:
|
|
3623
|
+
stage = "draft"
|
|
3624
|
+
|
|
3625
|
+
max_version = sess.execute(
|
|
3626
|
+
select(configs_table.c.version)
|
|
3627
|
+
.where(configs_table.c.component_id == component_id)
|
|
3628
|
+
.order_by(configs_table.c.version.desc())
|
|
3629
|
+
.limit(1)
|
|
3630
|
+
).scalar()
|
|
3631
|
+
|
|
3632
|
+
final_version = (max_version or 0) + 1
|
|
3633
|
+
|
|
3634
|
+
sess.execute(
|
|
3635
|
+
configs_table.insert().values(
|
|
3636
|
+
component_id=component_id,
|
|
3637
|
+
version=final_version,
|
|
3638
|
+
label=label,
|
|
3639
|
+
stage=stage,
|
|
3640
|
+
config=config,
|
|
3641
|
+
notes=notes,
|
|
3642
|
+
created_at=int(time.time()),
|
|
3643
|
+
)
|
|
3644
|
+
)
|
|
3645
|
+
else:
|
|
3646
|
+
existing = sess.execute(
|
|
3647
|
+
select(configs_table.c.version, configs_table.c.stage).where(
|
|
3648
|
+
configs_table.c.component_id == component_id,
|
|
3649
|
+
configs_table.c.version == version,
|
|
3650
|
+
)
|
|
3651
|
+
).fetchone()
|
|
3652
|
+
|
|
3653
|
+
if existing is None:
|
|
3654
|
+
raise ValueError(f"Config {component_id} v{version} not found")
|
|
3655
|
+
|
|
3656
|
+
# Published configs are immutable
|
|
3657
|
+
if existing.stage == "published":
|
|
3658
|
+
raise ValueError(f"Cannot update published config {component_id} v{version}")
|
|
3659
|
+
|
|
3660
|
+
# Build update dict with only provided fields
|
|
3661
|
+
updates: Dict[str, Any] = {"updated_at": int(time.time())}
|
|
3662
|
+
if label is not None:
|
|
3663
|
+
updates["label"] = label
|
|
3664
|
+
if stage is not None:
|
|
3665
|
+
updates["stage"] = stage
|
|
3666
|
+
if config is not None:
|
|
3667
|
+
updates["config"] = config
|
|
3668
|
+
if notes is not None:
|
|
3669
|
+
updates["notes"] = notes
|
|
3670
|
+
|
|
3671
|
+
sess.execute(
|
|
3672
|
+
configs_table.update()
|
|
3673
|
+
.where(
|
|
3674
|
+
configs_table.c.component_id == component_id,
|
|
3675
|
+
configs_table.c.version == version,
|
|
3676
|
+
)
|
|
3677
|
+
.values(**updates)
|
|
3678
|
+
)
|
|
3679
|
+
final_version = version
|
|
3680
|
+
|
|
3681
|
+
if links is not None and links_table is not None:
|
|
3682
|
+
sess.execute(
|
|
3683
|
+
links_table.delete().where(
|
|
3684
|
+
links_table.c.parent_component_id == component_id,
|
|
3685
|
+
links_table.c.parent_version == final_version,
|
|
3686
|
+
)
|
|
3687
|
+
)
|
|
3688
|
+
for link in links:
|
|
3689
|
+
sess.execute(
|
|
3690
|
+
links_table.insert().values(
|
|
3691
|
+
parent_component_id=component_id,
|
|
3692
|
+
parent_version=final_version,
|
|
3693
|
+
link_kind=link["link_kind"],
|
|
3694
|
+
link_key=link["link_key"],
|
|
3695
|
+
child_component_id=link["child_component_id"],
|
|
3696
|
+
child_version=link["child_version"],
|
|
3697
|
+
position=link["position"],
|
|
3698
|
+
meta=link.get("meta"),
|
|
3699
|
+
created_at=int(time.time()),
|
|
3700
|
+
)
|
|
3701
|
+
)
|
|
3702
|
+
|
|
3703
|
+
# Determine final stage (could be from update or create)
|
|
3704
|
+
final_stage = stage if stage is not None else (existing.stage if version is not None else "draft")
|
|
3705
|
+
|
|
3706
|
+
if final_stage == "published":
|
|
3707
|
+
sess.execute(
|
|
3708
|
+
components_table.update()
|
|
3709
|
+
.where(components_table.c.component_id == component_id)
|
|
3710
|
+
.values(current_version=final_version, updated_at=int(time.time()))
|
|
3711
|
+
)
|
|
3712
|
+
|
|
3713
|
+
result = self.get_config(component_id, version=final_version)
|
|
3714
|
+
if result is None:
|
|
3715
|
+
raise ValueError(f"Failed to get config {component_id} v{final_version} after upsert")
|
|
3716
|
+
return result
|
|
3717
|
+
|
|
3718
|
+
except Exception as e:
|
|
3719
|
+
log_error(f"Error upserting config: {e}")
|
|
3720
|
+
raise
|
|
3721
|
+
|
|
3722
|
+
def delete_config(
|
|
3723
|
+
self,
|
|
3724
|
+
component_id: str,
|
|
3725
|
+
version: int,
|
|
3726
|
+
) -> bool:
|
|
3727
|
+
"""Delete a specific config version.
|
|
3728
|
+
|
|
3729
|
+
Only draft configs can be deleted. Published configs are immutable.
|
|
3730
|
+
Cannot delete the current version.
|
|
3731
|
+
|
|
3732
|
+
Args:
|
|
3733
|
+
component_id: The component ID.
|
|
3734
|
+
version: The version to delete.
|
|
3735
|
+
|
|
3736
|
+
Returns:
|
|
3737
|
+
True if deleted, False if not found.
|
|
3738
|
+
|
|
3739
|
+
Raises:
|
|
3740
|
+
ValueError: If attempting to delete a published or current config.
|
|
3741
|
+
"""
|
|
3742
|
+
try:
|
|
3743
|
+
configs_table = self._get_table(table_type="component_configs")
|
|
3744
|
+
links_table = self._get_table(table_type="component_links")
|
|
3745
|
+
components_table = self._get_table(table_type="components")
|
|
3746
|
+
|
|
3747
|
+
if configs_table is None or components_table is None:
|
|
3748
|
+
return False
|
|
3749
|
+
|
|
3750
|
+
with self.Session() as sess, sess.begin():
|
|
3751
|
+
# Get config stage and check if it's current
|
|
3752
|
+
config_row = sess.execute(
|
|
3753
|
+
select(configs_table.c.stage).where(
|
|
3754
|
+
configs_table.c.component_id == component_id,
|
|
3755
|
+
configs_table.c.version == version,
|
|
3756
|
+
)
|
|
3757
|
+
).fetchone()
|
|
3758
|
+
|
|
3759
|
+
if config_row is None:
|
|
3760
|
+
return False
|
|
3761
|
+
|
|
3762
|
+
# Cannot delete published configs
|
|
3763
|
+
if config_row.stage == "published":
|
|
3764
|
+
raise ValueError(f"Cannot delete published config {component_id} v{version}")
|
|
3765
|
+
|
|
3766
|
+
# Check if it's current version
|
|
3767
|
+
current = sess.execute(
|
|
3768
|
+
select(components_table.c.current_version).where(components_table.c.component_id == component_id)
|
|
3769
|
+
).fetchone()
|
|
3770
|
+
|
|
3771
|
+
if current and current.current_version == version:
|
|
3772
|
+
raise ValueError(f"Cannot delete current config {component_id} v{version}")
|
|
3773
|
+
|
|
3774
|
+
# Delete associated links
|
|
3775
|
+
if links_table is not None:
|
|
3776
|
+
sess.execute(
|
|
3777
|
+
links_table.delete().where(
|
|
3778
|
+
links_table.c.parent_component_id == component_id,
|
|
3779
|
+
links_table.c.parent_version == version,
|
|
3780
|
+
)
|
|
3781
|
+
)
|
|
3782
|
+
|
|
3783
|
+
# Delete the config
|
|
3784
|
+
sess.execute(
|
|
3785
|
+
configs_table.delete().where(
|
|
3786
|
+
configs_table.c.component_id == component_id,
|
|
3787
|
+
configs_table.c.version == version,
|
|
3788
|
+
)
|
|
3789
|
+
)
|
|
3790
|
+
|
|
3791
|
+
return True
|
|
3792
|
+
|
|
3793
|
+
except Exception as e:
|
|
3794
|
+
log_error(f"Error deleting config: {e}")
|
|
3795
|
+
raise
|
|
3796
|
+
|
|
3797
|
+
def list_configs(
|
|
3798
|
+
self,
|
|
3799
|
+
component_id: str,
|
|
3800
|
+
include_config: bool = False,
|
|
3801
|
+
) -> List[Dict[str, Any]]:
|
|
3802
|
+
"""List all config versions for a component.
|
|
3803
|
+
|
|
3804
|
+
Args:
|
|
3805
|
+
component_id: The component ID.
|
|
3806
|
+
include_config: If True, include full config blob. Otherwise just metadata.
|
|
3807
|
+
|
|
3808
|
+
Returns:
|
|
3809
|
+
List of config dictionaries, newest first.
|
|
3810
|
+
Returns empty list if component not found or deleted.
|
|
3811
|
+
"""
|
|
3812
|
+
try:
|
|
3813
|
+
configs_table = self._get_table(table_type="component_configs")
|
|
3814
|
+
components_table = self._get_table(table_type="components")
|
|
3815
|
+
|
|
3816
|
+
if configs_table is None or components_table is None:
|
|
3817
|
+
return []
|
|
3818
|
+
|
|
3819
|
+
with self.Session() as sess:
|
|
3820
|
+
# Verify component exists and is not deleted
|
|
3821
|
+
exists = sess.execute(
|
|
3822
|
+
select(components_table.c.component_id).where(
|
|
3823
|
+
components_table.c.component_id == component_id,
|
|
3824
|
+
components_table.c.deleted_at.is_(None),
|
|
3825
|
+
)
|
|
3826
|
+
).fetchone()
|
|
3827
|
+
|
|
3828
|
+
if exists is None:
|
|
3829
|
+
return []
|
|
3830
|
+
|
|
3831
|
+
# Select columns based on include_config flag
|
|
3832
|
+
if include_config:
|
|
3833
|
+
stmt = select(configs_table)
|
|
3834
|
+
else:
|
|
3835
|
+
stmt = select(
|
|
3836
|
+
configs_table.c.component_id,
|
|
3837
|
+
configs_table.c.version,
|
|
3838
|
+
configs_table.c.label,
|
|
3839
|
+
configs_table.c.stage,
|
|
3840
|
+
configs_table.c.notes,
|
|
3841
|
+
configs_table.c.created_at,
|
|
3842
|
+
configs_table.c.updated_at,
|
|
3843
|
+
)
|
|
3844
|
+
|
|
3845
|
+
stmt = stmt.where(configs_table.c.component_id == component_id).order_by(configs_table.c.version.desc())
|
|
3846
|
+
|
|
3847
|
+
results = sess.execute(stmt).fetchall()
|
|
3848
|
+
return [dict(row._mapping) for row in results]
|
|
3849
|
+
|
|
3850
|
+
except Exception as e:
|
|
3851
|
+
log_error(f"Error listing configs: {e}")
|
|
3852
|
+
raise
|
|
3853
|
+
|
|
3854
|
+
def set_current_version(
|
|
3855
|
+
self,
|
|
3856
|
+
component_id: str,
|
|
3857
|
+
version: int,
|
|
3858
|
+
) -> bool:
|
|
3859
|
+
"""Set a specific published version as current.
|
|
3860
|
+
|
|
3861
|
+
Only published configs can be set as current. This is used for
|
|
3862
|
+
rollback scenarios where you want to switch to a previous
|
|
3863
|
+
published version.
|
|
3864
|
+
|
|
3865
|
+
Args:
|
|
3866
|
+
component_id: The component ID.
|
|
3867
|
+
version: The version to set as current (must be published).
|
|
3868
|
+
|
|
3869
|
+
Returns:
|
|
3870
|
+
True if successful, False if component or version not found.
|
|
3871
|
+
|
|
3872
|
+
Raises:
|
|
3873
|
+
ValueError: If attempting to set a draft config as current.
|
|
3874
|
+
"""
|
|
3875
|
+
try:
|
|
3876
|
+
configs_table = self._get_table(table_type="component_configs")
|
|
3877
|
+
components_table = self._get_table(table_type="components")
|
|
3878
|
+
|
|
3879
|
+
if configs_table is None or components_table is None:
|
|
3880
|
+
return False
|
|
3881
|
+
|
|
3882
|
+
with self.Session() as sess, sess.begin():
|
|
3883
|
+
# Verify component exists and is not deleted
|
|
3884
|
+
component_exists = sess.execute(
|
|
3885
|
+
select(components_table.c.component_id).where(
|
|
3886
|
+
components_table.c.component_id == component_id,
|
|
3887
|
+
components_table.c.deleted_at.is_(None),
|
|
3888
|
+
)
|
|
3889
|
+
).fetchone()
|
|
3890
|
+
|
|
3891
|
+
if component_exists is None:
|
|
3892
|
+
return False
|
|
3893
|
+
|
|
3894
|
+
# Verify version exists and get stage
|
|
3895
|
+
stage = sess.execute(
|
|
3896
|
+
select(configs_table.c.stage).where(
|
|
3897
|
+
configs_table.c.component_id == component_id,
|
|
3898
|
+
configs_table.c.version == version,
|
|
3899
|
+
)
|
|
3900
|
+
).fetchone()
|
|
3901
|
+
|
|
3902
|
+
if stage is None:
|
|
3903
|
+
return False
|
|
3904
|
+
|
|
3905
|
+
# Only published configs can be set as current
|
|
3906
|
+
if stage.stage != "published":
|
|
3907
|
+
raise ValueError(
|
|
3908
|
+
f"Cannot set draft config {component_id} v{version} as current. "
|
|
3909
|
+
"Only published configs can be current."
|
|
3910
|
+
)
|
|
3911
|
+
|
|
3912
|
+
# Update pointer
|
|
3913
|
+
sess.execute(
|
|
3914
|
+
components_table.update()
|
|
3915
|
+
.where(components_table.c.component_id == component_id)
|
|
3916
|
+
.values(current_version=version, updated_at=int(time.time()))
|
|
3917
|
+
)
|
|
3918
|
+
|
|
3919
|
+
log_debug(f"Set {component_id} current version to {version}")
|
|
3920
|
+
return True
|
|
3921
|
+
|
|
3922
|
+
except Exception as e:
|
|
3923
|
+
log_error(f"Error setting current version: {e}")
|
|
3924
|
+
raise
|
|
3925
|
+
|
|
3926
|
+
# --- Component Links ---
|
|
3927
|
+
def get_links(
|
|
3928
|
+
self,
|
|
3929
|
+
component_id: str,
|
|
3930
|
+
version: int,
|
|
3931
|
+
link_kind: Optional[str] = None,
|
|
3932
|
+
) -> List[Dict[str, Any]]:
|
|
3933
|
+
"""Get links for a config version.
|
|
3934
|
+
|
|
3935
|
+
Args:
|
|
3936
|
+
component_id: The component ID.
|
|
3937
|
+
version: The config version.
|
|
3938
|
+
link_kind: Optional filter by link kind (member|step).
|
|
3939
|
+
|
|
3940
|
+
Returns:
|
|
3941
|
+
List of link dictionaries, ordered by position.
|
|
3942
|
+
"""
|
|
3943
|
+
try:
|
|
3944
|
+
table = self._get_table(table_type="component_links")
|
|
3945
|
+
if table is None:
|
|
3946
|
+
return []
|
|
3947
|
+
|
|
3948
|
+
with self.Session() as sess:
|
|
3949
|
+
stmt = (
|
|
3950
|
+
select(table)
|
|
3951
|
+
.where(
|
|
3952
|
+
table.c.parent_component_id == component_id,
|
|
3953
|
+
table.c.parent_version == version,
|
|
3954
|
+
)
|
|
3955
|
+
.order_by(table.c.position)
|
|
3956
|
+
)
|
|
3957
|
+
if link_kind is not None:
|
|
3958
|
+
stmt = stmt.where(table.c.link_kind == link_kind)
|
|
3959
|
+
|
|
3960
|
+
results = sess.execute(stmt).fetchall()
|
|
3961
|
+
return [dict(row._mapping) for row in results]
|
|
3962
|
+
|
|
3963
|
+
except Exception as e:
|
|
3964
|
+
log_error(f"Error getting links: {e}")
|
|
3965
|
+
raise
|
|
3966
|
+
|
|
3967
|
+
def get_dependents(
|
|
3968
|
+
self,
|
|
3969
|
+
component_id: str,
|
|
3970
|
+
version: Optional[int] = None,
|
|
3971
|
+
) -> List[Dict[str, Any]]:
|
|
3972
|
+
"""Find all components that reference this component.
|
|
3973
|
+
|
|
3974
|
+
Args:
|
|
3975
|
+
component_id: The component ID to find dependents of.
|
|
3976
|
+
version: Optional specific version. If None, finds links to any version.
|
|
3977
|
+
|
|
3978
|
+
Returns:
|
|
3979
|
+
List of link dictionaries showing what depends on this component.
|
|
3980
|
+
"""
|
|
3981
|
+
try:
|
|
3982
|
+
table = self._get_table(table_type="component_links")
|
|
3983
|
+
if table is None:
|
|
3984
|
+
return []
|
|
3985
|
+
|
|
3986
|
+
with self.Session() as sess:
|
|
3987
|
+
stmt = select(table).where(table.c.child_component_id == component_id)
|
|
3988
|
+
if version is not None:
|
|
3989
|
+
stmt = stmt.where(table.c.child_version == version)
|
|
3990
|
+
|
|
3991
|
+
results = sess.execute(stmt).fetchall()
|
|
3992
|
+
return [dict(row._mapping) for row in results]
|
|
3993
|
+
|
|
3994
|
+
except Exception as e:
|
|
3995
|
+
log_error(f"Error getting dependents: {e}")
|
|
3996
|
+
raise
|
|
3997
|
+
|
|
3998
|
+
def resolve_version(
|
|
3999
|
+
self,
|
|
4000
|
+
component_id: str,
|
|
4001
|
+
version: Optional[int],
|
|
4002
|
+
) -> Optional[int]:
|
|
4003
|
+
"""Resolve a version number, handling NULL (current) case.
|
|
4004
|
+
|
|
4005
|
+
Args:
|
|
4006
|
+
component_id: The component ID.
|
|
4007
|
+
version: Version number or None for current.
|
|
4008
|
+
|
|
4009
|
+
Returns:
|
|
4010
|
+
Resolved version number or None if component not found.
|
|
4011
|
+
"""
|
|
4012
|
+
if version is not None:
|
|
4013
|
+
return version
|
|
4014
|
+
|
|
4015
|
+
try:
|
|
4016
|
+
components_table = self._get_table(table_type="components")
|
|
4017
|
+
if components_table is None:
|
|
4018
|
+
return None
|
|
4019
|
+
|
|
4020
|
+
with self.Session() as sess:
|
|
4021
|
+
result = sess.execute(
|
|
4022
|
+
select(components_table.c.current_version).where(components_table.c.component_id == component_id)
|
|
4023
|
+
).scalar()
|
|
4024
|
+
return result
|
|
4025
|
+
|
|
4026
|
+
except Exception as e:
|
|
4027
|
+
log_error(f"Error resolving version: {e}")
|
|
4028
|
+
raise
|
|
4029
|
+
|
|
4030
|
+
def load_component_graph(
|
|
4031
|
+
self,
|
|
4032
|
+
component_id: str,
|
|
4033
|
+
version: Optional[int] = None,
|
|
4034
|
+
) -> Optional[Dict[str, Any]]:
|
|
4035
|
+
"""Load a component with its full resolved graph.
|
|
4036
|
+
|
|
4037
|
+
Args:
|
|
4038
|
+
component_id: The component ID.
|
|
4039
|
+
version: Specific version or None for current.
|
|
4040
|
+
|
|
4041
|
+
Returns:
|
|
4042
|
+
Dictionary with component, config, links, and resolved children.
|
|
4043
|
+
"""
|
|
4044
|
+
try:
|
|
4045
|
+
# Get component
|
|
4046
|
+
component = self.get_component(component_id)
|
|
4047
|
+
if component is None:
|
|
4048
|
+
return None
|
|
4049
|
+
|
|
4050
|
+
# Resolve version
|
|
4051
|
+
resolved_version = self.resolve_version(component_id, version)
|
|
4052
|
+
if resolved_version is None:
|
|
4053
|
+
return None
|
|
4054
|
+
|
|
4055
|
+
# Get config
|
|
4056
|
+
config = self.get_config(component_id, version=resolved_version)
|
|
4057
|
+
if config is None:
|
|
4058
|
+
return None
|
|
4059
|
+
|
|
4060
|
+
# Get links
|
|
4061
|
+
links = self.get_links(component_id, resolved_version)
|
|
4062
|
+
|
|
4063
|
+
# Resolve children recursively
|
|
4064
|
+
children = []
|
|
4065
|
+
resolved_versions: Dict[str, Optional[int]] = {component_id: resolved_version}
|
|
4066
|
+
|
|
4067
|
+
for link in links:
|
|
4068
|
+
child_version = self.resolve_version(
|
|
4069
|
+
link["child_component_id"],
|
|
4070
|
+
link["child_version"],
|
|
4071
|
+
)
|
|
4072
|
+
resolved_versions[link["child_component_id"]] = child_version
|
|
4073
|
+
|
|
4074
|
+
child_graph = self.load_component_graph(
|
|
4075
|
+
link["child_component_id"],
|
|
4076
|
+
version=child_version,
|
|
4077
|
+
)
|
|
4078
|
+
|
|
4079
|
+
if child_graph:
|
|
4080
|
+
# Merge nested resolved versions
|
|
4081
|
+
resolved_versions.update(child_graph.get("resolved_versions", {}))
|
|
4082
|
+
|
|
4083
|
+
children.append(
|
|
4084
|
+
{
|
|
4085
|
+
"link": link,
|
|
4086
|
+
"graph": child_graph,
|
|
4087
|
+
}
|
|
4088
|
+
)
|
|
4089
|
+
|
|
4090
|
+
return {
|
|
4091
|
+
"component": component,
|
|
4092
|
+
"config": config,
|
|
4093
|
+
"children": children,
|
|
4094
|
+
"resolved_versions": resolved_versions,
|
|
4095
|
+
}
|
|
4096
|
+
|
|
4097
|
+
except Exception as e:
|
|
4098
|
+
log_error(f"Error loading component graph: {e}")
|
|
4099
|
+
raise
|
|
4100
|
+
|
|
2928
4101
|
# -- Learning methods --
|
|
2929
4102
|
def get_learning(
|
|
2930
4103
|
self,
|