agno 2.3.25__py3-none-any.whl → 2.4.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- agno/agent/__init__.py +4 -0
- agno/agent/agent.py +1428 -558
- 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 +1229 -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 +1173 -13
- agno/db/utils.py +37 -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 +2767 -2254
- 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 +5 -5
- agno/knowledge/reader/docx_reader.py +2 -2
- agno/knowledge/reader/field_labeled_csv_reader.py +2 -2
- 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 +110 -0
- 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/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 +48 -4
- agno/models/cohere/chat.py +25 -0
- agno/models/google/gemini.py +50 -5
- agno/models/litellm/chat.py +38 -0
- agno/models/openai/chat.py +7 -0
- agno/models/openrouter/openrouter.py +46 -0
- agno/models/response.py +16 -0
- agno/os/app.py +83 -44
- 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 +466 -0
- agno/os/routers/evals/schemas.py +4 -3
- agno/os/routers/health.py +3 -3
- agno/os/routers/knowledge/knowledge.py +3 -3
- 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 +133 -16
- 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 +57 -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 +1276 -326
- 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/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 +625 -63
- {agno-2.3.25.dist-info → agno-2.4.0.dist-info}/METADATA +46 -76
- {agno-2.3.25.dist-info → agno-2.4.0.dist-info}/RECORD +128 -117
- {agno-2.3.25.dist-info → agno-2.4.0.dist-info}/WHEEL +0 -0
- {agno-2.3.25.dist-info → agno-2.4.0.dist-info}/licenses/LICENSE +0 -0
- {agno-2.3.25.dist-info → agno-2.4.0.dist-info}/top_level.txt +0 -0
agno/db/postgres/postgres.py
CHANGED
|
@@ -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
|
|
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
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
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
|
-
|
|
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
|
-
#
|
|
273
|
+
# Single-column FK
|
|
208
274
|
if "foreign_key" in col_config:
|
|
209
|
-
|
|
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))
|
|
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
|
-
#
|
|
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
|
-
#
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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,1040 @@ 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.
|
|
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
|
+
# Always verify component exists and is not deleted
|
|
3611
|
+
component = sess.execute(
|
|
3612
|
+
select(components_table.c.current_version).where(
|
|
3613
|
+
components_table.c.component_id == component_id,
|
|
3614
|
+
components_table.c.deleted_at.is_(None),
|
|
3615
|
+
)
|
|
3616
|
+
).scalar_one_or_none()
|
|
3617
|
+
|
|
3618
|
+
if component is None:
|
|
3619
|
+
return None
|
|
3620
|
+
|
|
3621
|
+
if version is not None:
|
|
3622
|
+
stmt = select(configs_table).where(
|
|
3623
|
+
configs_table.c.component_id == component_id,
|
|
3624
|
+
configs_table.c.version == version,
|
|
3625
|
+
)
|
|
3626
|
+
elif label is not None:
|
|
3627
|
+
stmt = select(configs_table).where(
|
|
3628
|
+
configs_table.c.component_id == component_id,
|
|
3629
|
+
configs_table.c.label == label,
|
|
3630
|
+
)
|
|
3631
|
+
else:
|
|
3632
|
+
if component is None: # current_version is NULL
|
|
3633
|
+
return None
|
|
3634
|
+
stmt = select(configs_table).where(
|
|
3635
|
+
configs_table.c.component_id == component_id,
|
|
3636
|
+
configs_table.c.version == component,
|
|
3637
|
+
)
|
|
3638
|
+
|
|
3639
|
+
row = sess.execute(stmt).mappings().one_or_none()
|
|
3640
|
+
return dict(row) if row else None
|
|
3641
|
+
|
|
3642
|
+
except Exception as e:
|
|
3643
|
+
log_error(f"Error getting config: {e}")
|
|
3644
|
+
raise
|
|
3645
|
+
|
|
3646
|
+
def upsert_config(
|
|
3647
|
+
self,
|
|
3648
|
+
component_id: str,
|
|
3649
|
+
config: Optional[Dict[str, Any]] = None,
|
|
3650
|
+
version: Optional[int] = None,
|
|
3651
|
+
label: Optional[str] = None,
|
|
3652
|
+
stage: Optional[str] = None,
|
|
3653
|
+
notes: Optional[str] = None,
|
|
3654
|
+
links: Optional[List[Dict[str, Any]]] = None,
|
|
3655
|
+
) -> Dict[str, Any]:
|
|
3656
|
+
"""Create or update a config version for a component.
|
|
3657
|
+
|
|
3658
|
+
Rules:
|
|
3659
|
+
- Draft configs can be edited freely
|
|
3660
|
+
- Published configs are immutable
|
|
3661
|
+
- Publishing a config automatically sets it as current_version
|
|
3662
|
+
|
|
3663
|
+
Args:
|
|
3664
|
+
component_id: The component ID.
|
|
3665
|
+
config: The config data. Required for create, optional for update.
|
|
3666
|
+
version: If None, creates new version. If provided, updates that version.
|
|
3667
|
+
label: Optional human-readable label.
|
|
3668
|
+
stage: "draft" or "published". Defaults to "draft" for new configs.
|
|
3669
|
+
notes: Optional notes.
|
|
3670
|
+
links: Optional list of links. Each link must have child_version set.
|
|
3671
|
+
|
|
3672
|
+
Returns:
|
|
3673
|
+
Created/updated config dictionary.
|
|
3674
|
+
|
|
3675
|
+
Raises:
|
|
3676
|
+
ValueError: If component doesn't exist, version not found, label conflict,
|
|
3677
|
+
or attempting to update a published config.
|
|
3678
|
+
"""
|
|
3679
|
+
if stage is not None and stage not in {"draft", "published"}:
|
|
3680
|
+
raise ValueError(f"Invalid stage: {stage}")
|
|
3681
|
+
|
|
3682
|
+
try:
|
|
3683
|
+
configs_table = self._get_table(table_type="component_configs", create_table_if_not_found=True)
|
|
3684
|
+
components_table = self._get_table(table_type="components")
|
|
3685
|
+
links_table = self._get_table(table_type="component_links", create_table_if_not_found=True)
|
|
3686
|
+
|
|
3687
|
+
if components_table is None:
|
|
3688
|
+
raise ValueError("Components table not found")
|
|
3689
|
+
if configs_table is None:
|
|
3690
|
+
raise ValueError("Component configs table not found")
|
|
3691
|
+
|
|
3692
|
+
with self.Session() as sess, sess.begin():
|
|
3693
|
+
# Verify component exists and is not deleted
|
|
3694
|
+
component = sess.execute(
|
|
3695
|
+
select(components_table.c.component_id).where(
|
|
3696
|
+
components_table.c.component_id == component_id,
|
|
3697
|
+
components_table.c.deleted_at.is_(None),
|
|
3698
|
+
)
|
|
3699
|
+
).scalar_one_or_none()
|
|
3700
|
+
|
|
3701
|
+
if component is None:
|
|
3702
|
+
raise ValueError(f"Component {component_id} not found")
|
|
3703
|
+
|
|
3704
|
+
# Label uniqueness check
|
|
3705
|
+
if label is not None:
|
|
3706
|
+
label_query = select(configs_table.c.version).where(
|
|
3707
|
+
configs_table.c.component_id == component_id,
|
|
3708
|
+
configs_table.c.label == label,
|
|
3709
|
+
)
|
|
3710
|
+
if version is not None:
|
|
3711
|
+
label_query = label_query.where(configs_table.c.version != version)
|
|
3712
|
+
|
|
3713
|
+
if sess.execute(label_query).first():
|
|
3714
|
+
raise ValueError(f"Label '{label}' already exists for {component_id}")
|
|
3715
|
+
|
|
3716
|
+
# Validate links have child_version
|
|
3717
|
+
if links:
|
|
3718
|
+
for link in links:
|
|
3719
|
+
if link.get("child_version") is None:
|
|
3720
|
+
raise ValueError(f"child_version is required for link to {link['child_component_id']}")
|
|
3721
|
+
|
|
3722
|
+
if version is None:
|
|
3723
|
+
if config is None:
|
|
3724
|
+
raise ValueError("config is required when creating a new version")
|
|
3725
|
+
|
|
3726
|
+
# Default to draft for new configs
|
|
3727
|
+
if stage is None:
|
|
3728
|
+
stage = "draft"
|
|
3729
|
+
|
|
3730
|
+
max_version = sess.execute(
|
|
3731
|
+
select(configs_table.c.version)
|
|
3732
|
+
.where(configs_table.c.component_id == component_id)
|
|
3733
|
+
.order_by(configs_table.c.version.desc())
|
|
3734
|
+
.limit(1)
|
|
3735
|
+
).scalar()
|
|
3736
|
+
|
|
3737
|
+
final_version = (max_version or 0) + 1
|
|
3738
|
+
|
|
3739
|
+
sess.execute(
|
|
3740
|
+
configs_table.insert().values(
|
|
3741
|
+
component_id=component_id,
|
|
3742
|
+
version=final_version,
|
|
3743
|
+
label=label,
|
|
3744
|
+
stage=stage,
|
|
3745
|
+
config=config,
|
|
3746
|
+
notes=notes,
|
|
3747
|
+
created_at=int(time.time()),
|
|
3748
|
+
)
|
|
3749
|
+
)
|
|
3750
|
+
else:
|
|
3751
|
+
existing = (
|
|
3752
|
+
sess.execute(
|
|
3753
|
+
select(configs_table.c.version, configs_table.c.stage).where(
|
|
3754
|
+
configs_table.c.component_id == component_id,
|
|
3755
|
+
configs_table.c.version == version,
|
|
3756
|
+
)
|
|
3757
|
+
)
|
|
3758
|
+
.mappings()
|
|
3759
|
+
.one_or_none()
|
|
3760
|
+
)
|
|
3761
|
+
|
|
3762
|
+
if existing is None:
|
|
3763
|
+
raise ValueError(f"Config {component_id} v{version} not found")
|
|
3764
|
+
|
|
3765
|
+
# Published configs are immutable
|
|
3766
|
+
if existing["stage"] == "published":
|
|
3767
|
+
raise ValueError(f"Cannot update published config {component_id} v{version}")
|
|
3768
|
+
|
|
3769
|
+
# Build update dict with only provided fields
|
|
3770
|
+
updates: Dict[str, Any] = {"updated_at": int(time.time())}
|
|
3771
|
+
if label is not None:
|
|
3772
|
+
updates["label"] = label
|
|
3773
|
+
if stage is not None:
|
|
3774
|
+
updates["stage"] = stage
|
|
3775
|
+
if config is not None:
|
|
3776
|
+
updates["config"] = config
|
|
3777
|
+
if notes is not None:
|
|
3778
|
+
updates["notes"] = notes
|
|
3779
|
+
|
|
3780
|
+
sess.execute(
|
|
3781
|
+
configs_table.update()
|
|
3782
|
+
.where(
|
|
3783
|
+
configs_table.c.component_id == component_id,
|
|
3784
|
+
configs_table.c.version == version,
|
|
3785
|
+
)
|
|
3786
|
+
.values(**updates)
|
|
3787
|
+
)
|
|
3788
|
+
final_version = version
|
|
3789
|
+
|
|
3790
|
+
if links is not None and links_table is not None:
|
|
3791
|
+
sess.execute(
|
|
3792
|
+
links_table.delete().where(
|
|
3793
|
+
links_table.c.parent_component_id == component_id,
|
|
3794
|
+
links_table.c.parent_version == final_version,
|
|
3795
|
+
)
|
|
3796
|
+
)
|
|
3797
|
+
for link in links:
|
|
3798
|
+
sess.execute(
|
|
3799
|
+
links_table.insert().values(
|
|
3800
|
+
parent_component_id=component_id,
|
|
3801
|
+
parent_version=final_version,
|
|
3802
|
+
link_kind=link["link_kind"],
|
|
3803
|
+
link_key=link["link_key"],
|
|
3804
|
+
child_component_id=link["child_component_id"],
|
|
3805
|
+
child_version=link["child_version"],
|
|
3806
|
+
position=link["position"],
|
|
3807
|
+
meta=link.get("meta"),
|
|
3808
|
+
created_at=int(time.time()),
|
|
3809
|
+
)
|
|
3810
|
+
)
|
|
3811
|
+
|
|
3812
|
+
# Determine final stage (could be from update or create)
|
|
3813
|
+
final_stage = stage if stage is not None else (existing["stage"] if version is not None else "draft")
|
|
3814
|
+
|
|
3815
|
+
if final_stage == "published":
|
|
3816
|
+
sess.execute(
|
|
3817
|
+
components_table.update()
|
|
3818
|
+
.where(components_table.c.component_id == component_id)
|
|
3819
|
+
.values(current_version=final_version, updated_at=int(time.time()))
|
|
3820
|
+
)
|
|
3821
|
+
|
|
3822
|
+
result = self.get_config(component_id, version=final_version)
|
|
3823
|
+
if result is None:
|
|
3824
|
+
raise ValueError(f"Failed to get config {component_id} v{final_version} after upsert")
|
|
3825
|
+
return result
|
|
3826
|
+
|
|
3827
|
+
except Exception as e:
|
|
3828
|
+
log_error(f"Error upserting config: {e}")
|
|
3829
|
+
raise
|
|
3830
|
+
|
|
3831
|
+
def delete_config(
|
|
3832
|
+
self,
|
|
3833
|
+
component_id: str,
|
|
3834
|
+
version: int,
|
|
3835
|
+
) -> bool:
|
|
3836
|
+
"""Delete a specific config version.
|
|
3837
|
+
|
|
3838
|
+
Only draft configs can be deleted. Published configs are immutable.
|
|
3839
|
+
Cannot delete the current version.
|
|
3840
|
+
|
|
3841
|
+
Args:
|
|
3842
|
+
component_id: The component ID.
|
|
3843
|
+
version: The version to delete.
|
|
3844
|
+
|
|
3845
|
+
Returns:
|
|
3846
|
+
True if deleted, False if not found.
|
|
3847
|
+
|
|
3848
|
+
Raises:
|
|
3849
|
+
ValueError: If attempting to delete a published or current config.
|
|
3850
|
+
"""
|
|
3851
|
+
try:
|
|
3852
|
+
configs_table = self._get_table(table_type="component_configs")
|
|
3853
|
+
links_table = self._get_table(table_type="component_links")
|
|
3854
|
+
components_table = self._get_table(table_type="components")
|
|
3855
|
+
|
|
3856
|
+
if configs_table is None or components_table is None:
|
|
3857
|
+
return False
|
|
3858
|
+
|
|
3859
|
+
with self.Session() as sess, sess.begin():
|
|
3860
|
+
# Get config stage and check if it's current
|
|
3861
|
+
config_row = sess.execute(
|
|
3862
|
+
select(configs_table.c.stage).where(
|
|
3863
|
+
configs_table.c.component_id == component_id,
|
|
3864
|
+
configs_table.c.version == version,
|
|
3865
|
+
)
|
|
3866
|
+
).scalar_one_or_none()
|
|
3867
|
+
|
|
3868
|
+
if config_row is None:
|
|
3869
|
+
return False
|
|
3870
|
+
|
|
3871
|
+
# Cannot delete published configs
|
|
3872
|
+
if config_row == "published":
|
|
3873
|
+
raise ValueError(f"Cannot delete published config {component_id} v{version}")
|
|
3874
|
+
|
|
3875
|
+
# Check if it's current version
|
|
3876
|
+
current = sess.execute(
|
|
3877
|
+
select(components_table.c.current_version).where(components_table.c.component_id == component_id)
|
|
3878
|
+
).scalar_one_or_none()
|
|
3879
|
+
|
|
3880
|
+
if current == version:
|
|
3881
|
+
raise ValueError(f"Cannot delete current config {component_id} v{version}")
|
|
3882
|
+
|
|
3883
|
+
# Delete associated links
|
|
3884
|
+
if links_table is not None:
|
|
3885
|
+
sess.execute(
|
|
3886
|
+
links_table.delete().where(
|
|
3887
|
+
links_table.c.parent_component_id == component_id,
|
|
3888
|
+
links_table.c.parent_version == version,
|
|
3889
|
+
)
|
|
3890
|
+
)
|
|
3891
|
+
|
|
3892
|
+
# Delete the config
|
|
3893
|
+
sess.execute(
|
|
3894
|
+
configs_table.delete().where(
|
|
3895
|
+
configs_table.c.component_id == component_id,
|
|
3896
|
+
configs_table.c.version == version,
|
|
3897
|
+
)
|
|
3898
|
+
)
|
|
3899
|
+
|
|
3900
|
+
return True
|
|
3901
|
+
|
|
3902
|
+
except Exception as e:
|
|
3903
|
+
log_error(f"Error deleting config: {e}")
|
|
3904
|
+
raise
|
|
3905
|
+
|
|
3906
|
+
def list_configs(
|
|
3907
|
+
self,
|
|
3908
|
+
component_id: str,
|
|
3909
|
+
include_config: bool = False,
|
|
3910
|
+
) -> List[Dict[str, Any]]:
|
|
3911
|
+
"""List all config versions for a component.
|
|
3912
|
+
|
|
3913
|
+
Args:
|
|
3914
|
+
component_id: The component ID.
|
|
3915
|
+
include_config: If True, include full config blob. Otherwise just metadata.
|
|
3916
|
+
|
|
3917
|
+
Returns:
|
|
3918
|
+
List of config dictionaries, newest first.
|
|
3919
|
+
Returns empty list if component not found or deleted.
|
|
3920
|
+
"""
|
|
3921
|
+
try:
|
|
3922
|
+
configs_table = self._get_table(table_type="component_configs")
|
|
3923
|
+
components_table = self._get_table(table_type="components")
|
|
3924
|
+
|
|
3925
|
+
if configs_table is None or components_table is None:
|
|
3926
|
+
return []
|
|
3927
|
+
|
|
3928
|
+
with self.Session() as sess:
|
|
3929
|
+
# Verify component exists and is not deleted
|
|
3930
|
+
exists = sess.execute(
|
|
3931
|
+
select(components_table.c.component_id).where(
|
|
3932
|
+
components_table.c.component_id == component_id,
|
|
3933
|
+
components_table.c.deleted_at.is_(None),
|
|
3934
|
+
)
|
|
3935
|
+
).scalar_one_or_none()
|
|
3936
|
+
|
|
3937
|
+
if exists is None:
|
|
3938
|
+
return []
|
|
3939
|
+
|
|
3940
|
+
# Select columns based on include_config flag
|
|
3941
|
+
if include_config:
|
|
3942
|
+
stmt = select(configs_table)
|
|
3943
|
+
else:
|
|
3944
|
+
stmt = select(
|
|
3945
|
+
configs_table.c.component_id,
|
|
3946
|
+
configs_table.c.version,
|
|
3947
|
+
configs_table.c.label,
|
|
3948
|
+
configs_table.c.stage,
|
|
3949
|
+
configs_table.c.notes,
|
|
3950
|
+
configs_table.c.created_at,
|
|
3951
|
+
configs_table.c.updated_at,
|
|
3952
|
+
)
|
|
3953
|
+
|
|
3954
|
+
stmt = stmt.where(configs_table.c.component_id == component_id).order_by(configs_table.c.version.desc())
|
|
3955
|
+
|
|
3956
|
+
results = sess.execute(stmt).mappings().all()
|
|
3957
|
+
return [dict(row) for row in results]
|
|
3958
|
+
|
|
3959
|
+
except Exception as e:
|
|
3960
|
+
log_error(f"Error listing configs: {e}")
|
|
3961
|
+
raise
|
|
3962
|
+
|
|
3963
|
+
def set_current_version(
|
|
3964
|
+
self,
|
|
3965
|
+
component_id: str,
|
|
3966
|
+
version: int,
|
|
3967
|
+
) -> bool:
|
|
3968
|
+
"""Set a specific published version as current.
|
|
3969
|
+
|
|
3970
|
+
Only published configs can be set as current. This is used for
|
|
3971
|
+
rollback scenarios where you want to switch to a previous
|
|
3972
|
+
published version.
|
|
3973
|
+
|
|
3974
|
+
Args:
|
|
3975
|
+
component_id: The component ID.
|
|
3976
|
+
version: The version to set as current (must be published).
|
|
3977
|
+
|
|
3978
|
+
Returns:
|
|
3979
|
+
True if successful, False if component or version not found.
|
|
3980
|
+
|
|
3981
|
+
Raises:
|
|
3982
|
+
ValueError: If attempting to set a draft config as current.
|
|
3983
|
+
"""
|
|
3984
|
+
try:
|
|
3985
|
+
configs_table = self._get_table(table_type="component_configs")
|
|
3986
|
+
components_table = self._get_table(table_type="components")
|
|
3987
|
+
|
|
3988
|
+
if configs_table is None or components_table is None:
|
|
3989
|
+
return False
|
|
3990
|
+
|
|
3991
|
+
with self.Session() as sess, sess.begin():
|
|
3992
|
+
# Verify component exists and is not deleted
|
|
3993
|
+
component_exists = sess.execute(
|
|
3994
|
+
select(components_table.c.component_id).where(
|
|
3995
|
+
components_table.c.component_id == component_id,
|
|
3996
|
+
components_table.c.deleted_at.is_(None),
|
|
3997
|
+
)
|
|
3998
|
+
).scalar_one_or_none()
|
|
3999
|
+
|
|
4000
|
+
if component_exists is None:
|
|
4001
|
+
return False
|
|
4002
|
+
|
|
4003
|
+
# Verify version exists and get stage
|
|
4004
|
+
stage = sess.execute(
|
|
4005
|
+
select(configs_table.c.stage).where(
|
|
4006
|
+
configs_table.c.component_id == component_id,
|
|
4007
|
+
configs_table.c.version == version,
|
|
4008
|
+
)
|
|
4009
|
+
).scalar_one_or_none()
|
|
4010
|
+
|
|
4011
|
+
if stage is None:
|
|
4012
|
+
return False
|
|
4013
|
+
|
|
4014
|
+
# Only published configs can be set as current
|
|
4015
|
+
if stage != "published":
|
|
4016
|
+
raise ValueError(
|
|
4017
|
+
f"Cannot set draft config {component_id} v{version} as current. "
|
|
4018
|
+
"Only published configs can be current."
|
|
4019
|
+
)
|
|
4020
|
+
|
|
4021
|
+
# Update pointer
|
|
4022
|
+
result = sess.execute(
|
|
4023
|
+
components_table.update()
|
|
4024
|
+
.where(components_table.c.component_id == component_id)
|
|
4025
|
+
.values(current_version=version, updated_at=int(time.time()))
|
|
4026
|
+
)
|
|
4027
|
+
|
|
4028
|
+
if result.rowcount == 0:
|
|
4029
|
+
return False
|
|
4030
|
+
|
|
4031
|
+
log_debug(f"Set {component_id} current version to {version}")
|
|
4032
|
+
return True
|
|
4033
|
+
|
|
4034
|
+
except Exception as e:
|
|
4035
|
+
log_error(f"Error setting current version: {e}")
|
|
4036
|
+
raise
|
|
4037
|
+
|
|
4038
|
+
# --- Component Links ---
|
|
4039
|
+
def get_links(
|
|
4040
|
+
self,
|
|
4041
|
+
component_id: str,
|
|
4042
|
+
version: int,
|
|
4043
|
+
link_kind: Optional[str] = None,
|
|
4044
|
+
) -> List[Dict[str, Any]]:
|
|
4045
|
+
"""Get links for a config version.
|
|
4046
|
+
|
|
4047
|
+
Args:
|
|
4048
|
+
component_id: The component ID.
|
|
4049
|
+
version: The config version.
|
|
4050
|
+
link_kind: Optional filter by link kind (member|step).
|
|
4051
|
+
|
|
4052
|
+
Returns:
|
|
4053
|
+
List of link dictionaries, ordered by position.
|
|
4054
|
+
"""
|
|
4055
|
+
try:
|
|
4056
|
+
table = self._get_table(table_type="component_links")
|
|
4057
|
+
if table is None:
|
|
4058
|
+
return []
|
|
4059
|
+
|
|
4060
|
+
with self.Session() as sess:
|
|
4061
|
+
stmt = (
|
|
4062
|
+
select(table)
|
|
4063
|
+
.where(
|
|
4064
|
+
table.c.parent_component_id == component_id,
|
|
4065
|
+
table.c.parent_version == version,
|
|
4066
|
+
)
|
|
4067
|
+
.order_by(table.c.position)
|
|
4068
|
+
)
|
|
4069
|
+
if link_kind is not None:
|
|
4070
|
+
stmt = stmt.where(table.c.link_kind == link_kind)
|
|
4071
|
+
|
|
4072
|
+
rows = sess.execute(stmt).mappings().all()
|
|
4073
|
+
return [dict(r) for r in rows]
|
|
4074
|
+
|
|
4075
|
+
except Exception as e:
|
|
4076
|
+
log_error(f"Error getting links: {e}")
|
|
4077
|
+
raise
|
|
4078
|
+
|
|
4079
|
+
def get_dependents(
|
|
4080
|
+
self,
|
|
4081
|
+
component_id: str,
|
|
4082
|
+
version: Optional[int] = None,
|
|
4083
|
+
) -> List[Dict[str, Any]]:
|
|
4084
|
+
"""Find all components that reference this component.
|
|
4085
|
+
|
|
4086
|
+
Args:
|
|
4087
|
+
component_id: The component ID to find dependents of.
|
|
4088
|
+
version: Optional specific version. If None, finds links to any version.
|
|
4089
|
+
|
|
4090
|
+
Returns:
|
|
4091
|
+
List of link dictionaries showing what depends on this component.
|
|
4092
|
+
"""
|
|
4093
|
+
try:
|
|
4094
|
+
table = self._get_table(table_type="component_links")
|
|
4095
|
+
if table is None:
|
|
4096
|
+
return []
|
|
4097
|
+
|
|
4098
|
+
with self.Session() as sess:
|
|
4099
|
+
stmt = select(table).where(table.c.child_component_id == component_id)
|
|
4100
|
+
if version is not None:
|
|
4101
|
+
stmt = stmt.where(table.c.child_version == version)
|
|
4102
|
+
|
|
4103
|
+
rows = sess.execute(stmt).mappings().all()
|
|
4104
|
+
return [dict(r) for r in rows]
|
|
4105
|
+
|
|
4106
|
+
except Exception as e:
|
|
4107
|
+
log_error(f"Error getting dependents: {e}")
|
|
4108
|
+
raise
|
|
4109
|
+
|
|
4110
|
+
def _resolve_version(
|
|
4111
|
+
self,
|
|
4112
|
+
component_id: str,
|
|
4113
|
+
version: Optional[int],
|
|
4114
|
+
) -> Optional[int]:
|
|
4115
|
+
"""Resolve a version number, handling None as 'current'.
|
|
4116
|
+
|
|
4117
|
+
Args:
|
|
4118
|
+
component_id: The component ID.
|
|
4119
|
+
version: Version number or None for current.
|
|
4120
|
+
|
|
4121
|
+
Returns:
|
|
4122
|
+
Resolved version number, or None if component missing/deleted or no current.
|
|
4123
|
+
"""
|
|
4124
|
+
if version is not None:
|
|
4125
|
+
return version
|
|
4126
|
+
|
|
4127
|
+
try:
|
|
4128
|
+
components_table = self._get_table(table_type="components")
|
|
4129
|
+
if components_table is None:
|
|
4130
|
+
return None
|
|
4131
|
+
|
|
4132
|
+
with self.Session() as sess:
|
|
4133
|
+
return sess.execute(
|
|
4134
|
+
select(components_table.c.current_version).where(
|
|
4135
|
+
components_table.c.component_id == component_id,
|
|
4136
|
+
components_table.c.deleted_at.is_(None),
|
|
4137
|
+
)
|
|
4138
|
+
).scalar_one_or_none()
|
|
4139
|
+
|
|
4140
|
+
except Exception as e:
|
|
4141
|
+
log_error(f"Error resolving version: {e}")
|
|
4142
|
+
raise
|
|
4143
|
+
|
|
4144
|
+
def load_component_graph(
|
|
4145
|
+
self,
|
|
4146
|
+
component_id: str,
|
|
4147
|
+
version: Optional[int] = None,
|
|
4148
|
+
label: Optional[str] = None,
|
|
4149
|
+
*,
|
|
4150
|
+
_visited: Optional[Set[Tuple[str, int]]] = None,
|
|
4151
|
+
_max_depth: int = 50,
|
|
4152
|
+
) -> Optional[Dict[str, Any]]:
|
|
4153
|
+
"""Load a component with its full resolved graph.
|
|
4154
|
+
|
|
4155
|
+
Handles cycles by returning a stub with cycle_detected=True.
|
|
4156
|
+
Has a max depth guard to prevent stack overflow.
|
|
4157
|
+
|
|
4158
|
+
Args:
|
|
4159
|
+
component_id: The component ID.
|
|
4160
|
+
version: Specific version or None for current.
|
|
4161
|
+
label: Optional label of the component.
|
|
4162
|
+
_visited: Internal cycle tracking (do not pass).
|
|
4163
|
+
_max_depth: Internal depth limit (do not pass).
|
|
4164
|
+
|
|
4165
|
+
Returns:
|
|
4166
|
+
Dictionary with component, config, children, and resolved_versions.
|
|
4167
|
+
Returns None if component not found or depth exceeded.
|
|
4168
|
+
"""
|
|
4169
|
+
try:
|
|
4170
|
+
if _max_depth <= 0:
|
|
4171
|
+
return None
|
|
4172
|
+
|
|
4173
|
+
component = self.get_component(component_id)
|
|
4174
|
+
if component is None:
|
|
4175
|
+
return None
|
|
4176
|
+
|
|
4177
|
+
resolved_version = self._resolve_version(component_id, version)
|
|
4178
|
+
if resolved_version is None:
|
|
4179
|
+
return None
|
|
4180
|
+
|
|
4181
|
+
# Cycle detection
|
|
4182
|
+
if _visited is None:
|
|
4183
|
+
_visited = set()
|
|
4184
|
+
|
|
4185
|
+
node_key = (component_id, resolved_version)
|
|
4186
|
+
if node_key in _visited:
|
|
4187
|
+
return {
|
|
4188
|
+
"component": component,
|
|
4189
|
+
"config": self.get_config(component_id, version=resolved_version),
|
|
4190
|
+
"children": [],
|
|
4191
|
+
"resolved_versions": {component_id: resolved_version},
|
|
4192
|
+
"cycle_detected": True,
|
|
4193
|
+
}
|
|
4194
|
+
_visited.add(node_key)
|
|
4195
|
+
|
|
4196
|
+
config = self.get_config(component_id, version=resolved_version)
|
|
4197
|
+
if config is None:
|
|
4198
|
+
return None
|
|
4199
|
+
|
|
4200
|
+
links = self.get_links(component_id, resolved_version)
|
|
4201
|
+
|
|
4202
|
+
children: List[Dict[str, Any]] = []
|
|
4203
|
+
resolved_versions: Dict[str, Optional[int]] = {component_id: resolved_version}
|
|
4204
|
+
|
|
4205
|
+
for link in links:
|
|
4206
|
+
child_id = link["child_component_id"]
|
|
4207
|
+
child_ver = link.get("child_version")
|
|
4208
|
+
|
|
4209
|
+
resolved_child_ver = self._resolve_version(child_id, child_ver)
|
|
4210
|
+
resolved_versions[child_id] = resolved_child_ver
|
|
4211
|
+
|
|
4212
|
+
if resolved_child_ver is None:
|
|
4213
|
+
children.append(
|
|
4214
|
+
{
|
|
4215
|
+
"link": link,
|
|
4216
|
+
"graph": None,
|
|
4217
|
+
"error": "child_version_unresolvable",
|
|
4218
|
+
}
|
|
4219
|
+
)
|
|
4220
|
+
continue
|
|
4221
|
+
|
|
4222
|
+
child_graph = self.load_component_graph(
|
|
4223
|
+
child_id,
|
|
4224
|
+
version=resolved_child_ver,
|
|
4225
|
+
_visited=_visited,
|
|
4226
|
+
_max_depth=_max_depth - 1,
|
|
4227
|
+
)
|
|
4228
|
+
|
|
4229
|
+
if child_graph:
|
|
4230
|
+
resolved_versions.update(child_graph.get("resolved_versions", {}))
|
|
4231
|
+
|
|
4232
|
+
children.append({"link": link, "graph": child_graph})
|
|
4233
|
+
|
|
4234
|
+
return {
|
|
4235
|
+
"component": component,
|
|
4236
|
+
"config": config,
|
|
4237
|
+
"children": children,
|
|
4238
|
+
"resolved_versions": resolved_versions,
|
|
4239
|
+
}
|
|
4240
|
+
|
|
4241
|
+
except Exception as e:
|
|
4242
|
+
log_error(f"Error loading component graph: {e}")
|
|
4243
|
+
raise
|
|
4244
|
+
|
|
3041
4245
|
# -- Learning methods --
|
|
3042
4246
|
def get_learning(
|
|
3043
4247
|
self,
|