Skip to content

Commit 7d629cb

Browse files
Completed Graph Verification (#173)
* completed graph verification, testing pending * Update state-manager/app/tasks/verify_graph.py Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> * resolving issues by @gemini-code-assistant and @coderabbitai --------- Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
1 parent 0a0a228 commit 7d629cb

5 files changed

Lines changed: 250 additions & 21 deletions

File tree

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
from app.singletons.logs_manager import LogsManager
2+
from app.models.graph_models import UpsertGraphTemplateResponse
3+
from app.models.db.graph_template_model import GraphTemplate
4+
from fastapi import HTTPException, status
5+
6+
logger = LogsManager().get_logger()
7+
8+
9+
async def get_graph_template(namespace_name: str, graph_name: str, x_exosphere_request_id: str) -> UpsertGraphTemplateResponse:
10+
try:
11+
graph_template = await GraphTemplate.find_one(
12+
GraphTemplate.name == graph_name,
13+
GraphTemplate.namespace == namespace_name
14+
)
15+
16+
if not graph_template:
17+
logger.error(
18+
"Graph template not found",
19+
graph_name=graph_name,
20+
namespace_name=namespace_name,
21+
x_exosphere_request_id=x_exosphere_request_id,
22+
)
23+
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"Graph template {graph_name} not found in namespace {namespace_name}")
24+
25+
logger.info(
26+
"Graph template retrieved",
27+
graph_name=graph_name,
28+
namespace_name=namespace_name,
29+
x_exosphere_request_id=x_exosphere_request_id,
30+
)
31+
32+
return UpsertGraphTemplateResponse(
33+
nodes=graph_template.nodes,
34+
validation_status=graph_template.validation_status,
35+
validation_errors=graph_template.validation_errors,
36+
secrets={secret_name: True for secret_name in graph_template.secrets.keys()},
37+
created_at=graph_template.created_at,
38+
updated_at=graph_template.updated_at,
39+
)
40+
except Exception as e:
41+
logger.error(
42+
"Error retrieving graph template",
43+
error=e,
44+
graph_name=graph_name,
45+
namespace_name=namespace_name,
46+
x_exosphere_request_id=x_exosphere_request_id,
47+
)
48+
raise

state-manager/app/routes.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020

2121
from .models.graph_models import UpsertGraphTemplateRequest, UpsertGraphTemplateResponse
2222
from .controller.upsert_graph_template import upsert_graph_template as upsert_graph_template_controller
23+
from .controller.get_graph_template import get_graph_template as get_graph_template_controller
2324

2425
from .models.register_nodes_request import RegisterNodesRequestModel
2526
from .models.register_nodes_response import RegisterNodesResponseModel
@@ -132,6 +133,25 @@ async def upsert_graph_template(namespace_name: str, graph_name: str, body: Upse
132133
return await upsert_graph_template_controller(namespace_name, graph_name, body, x_exosphere_request_id, background_tasks)
133134

134135

136+
@router.get(
137+
"/graph/{graph_name}",
138+
response_model=UpsertGraphTemplateResponse,
139+
status_code=status.HTTP_200_OK,
140+
response_description="Graph template retrieved successfully",
141+
tags=["graph"]
142+
)
143+
async def get_graph_template(namespace_name: str, graph_name: str, request: Request, api_key: str = Depends(check_api_key)):
144+
x_exosphere_request_id = getattr(request.state, "x_exosphere_request_id", str(uuid4()))
145+
146+
if api_key:
147+
logger.info(f"API key is valid for namespace {namespace_name}", x_exosphere_request_id=x_exosphere_request_id)
148+
else:
149+
logger.error(f"API key is invalid for namespace {namespace_name}", x_exosphere_request_id=x_exosphere_request_id)
150+
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid API key")
151+
152+
return await get_graph_template_controller(namespace_name, graph_name, x_exosphere_request_id)
153+
154+
135155
@router.put(
136156
"/nodes/",
137157
response_model=RegisterNodesResponseModel,

state-manager/app/tasks/verify_graph.py

Lines changed: 166 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@
33
from app.models.db.registered_node import RegisteredNode
44
from app.singletons.logs_manager import LogsManager
55
from beanie.operators import In
6+
from json_schema_to_pydantic import create_model
7+
from collections import deque
68

79
logger = LogsManager().get_logger()
810

@@ -16,26 +18,11 @@ async def verify_nodes_namespace(nodes: list[NodeTemplate], graph_namespace: str
1618
if node.namespace != graph_namespace and node.namespace != "exospherehost":
1719
errors.append(f"Node {node.identifier} has invalid namespace '{node.namespace}'. Must match graph namespace '{graph_namespace}' or use universal namespace 'exospherehost'")
1820

19-
async def verify_node_exists(nodes: list[NodeTemplate], graph_namespace: str, errors: list[str]):
20-
graph_namespace_node_names = [
21-
node.node_name for node in nodes if node.namespace == graph_namespace
22-
]
23-
graph_namespace_database_nodes = await RegisteredNode.find(
24-
In(RegisteredNode.name, graph_namespace_node_names),
25-
RegisteredNode.namespace == graph_namespace
26-
).to_list()
27-
exospherehost_node_names = [
28-
node.node_name for node in nodes if node.namespace == "exospherehost"
29-
]
30-
exospherehost_database_nodes = await RegisteredNode.find(
31-
In(RegisteredNode.name, exospherehost_node_names),
32-
RegisteredNode.namespace == "exospherehost"
33-
).to_list()
34-
35-
template_nodes = set([(node.node_name, node.namespace) for node in nodes])
36-
database_nodes = set([(node.name, node.namespace) for node in graph_namespace_database_nodes + exospherehost_database_nodes])
21+
async def verify_node_exists(nodes: list[NodeTemplate], database_nodes: list[RegisteredNode], errors: list[str]):
22+
template_nodes_set = set([(node.node_name, node.namespace) for node in nodes])
23+
database_nodes_set = set([(node.name, node.namespace) for node in database_nodes])
3724

38-
nodes_not_found = template_nodes - database_nodes
25+
nodes_not_found = template_nodes_set - database_nodes_set
3926

4027
for node in nodes_not_found:
4128
errors.append(f"Node {node[0]} in namespace {node[1]} does not exist.")
@@ -68,20 +55,179 @@ async def verify_node_identifiers(nodes: list[NodeTemplate], errors: list[str]):
6855
if next_node not in valid_identifiers:
6956
errors.append(f"Node {node.node_name} in namespace {node.namespace} has a next node {next_node} that does not exist in the graph")
7057

58+
async def verify_secrets(graph_template: GraphTemplate, database_nodes: list[RegisteredNode], errors: list[str]):
59+
required_secrets_set = set()
60+
61+
for node in database_nodes:
62+
if node.secrets is None:
63+
continue
64+
for secret in node.secrets:
65+
required_secrets_set.add(secret)
66+
67+
present_secrets_set = set()
68+
for secret_name in graph_template.secrets.keys():
69+
present_secrets_set.add(secret_name)
70+
71+
missing_secrets_set = required_secrets_set - present_secrets_set
72+
73+
for secret_name in missing_secrets_set:
74+
errors.append(f"Secret {secret_name} is required but not present in the graph template")
75+
76+
77+
async def get_database_nodes(nodes: list[NodeTemplate], graph_namespace: str):
78+
graph_namespace_node_names = [
79+
node.node_name for node in nodes if node.namespace == graph_namespace
80+
]
81+
graph_namespace_database_nodes = await RegisteredNode.find(
82+
In(RegisteredNode.name, graph_namespace_node_names),
83+
RegisteredNode.namespace == graph_namespace
84+
).to_list()
85+
exospherehost_node_names = [
86+
node.node_name for node in nodes if node.namespace == "exospherehost"
87+
]
88+
exospherehost_database_nodes = await RegisteredNode.find(
89+
In(RegisteredNode.name, exospherehost_node_names),
90+
RegisteredNode.namespace == "exospherehost"
91+
).to_list()
92+
return graph_namespace_database_nodes + exospherehost_database_nodes
93+
94+
95+
async def verify_inputs(graph_nodes: list[NodeTemplate], database_nodes: list[RegisteredNode], dependencies_graph: dict[str, set[str]], errors: list[str]):
96+
look_up_table = {}
97+
for node in graph_nodes:
98+
look_up_table[node.identifier] = {"graph_node": node}
99+
100+
for database_node in database_nodes:
101+
if database_node.name == node.node_name and database_node.namespace == node.namespace:
102+
look_up_table[node.identifier]["database_node"] = database_node
103+
break
104+
105+
for node in graph_nodes:
106+
try:
107+
model_class = create_model(look_up_table[node.identifier]["database_node"].inputs_schema)
108+
109+
for field_name, field_info in model_class.model_fields.items():
110+
if field_info.annotation is not str:
111+
errors.append(f"{node.node_name}.Inputs field '{field_name}' must be of type str, got {field_info.annotation}")
112+
continue
113+
114+
if field_name not in look_up_table[node.identifier]["graph_node"].inputs.keys():
115+
errors.append(f"{node.node_name}.Inputs field '{field_name}' not found in graph template")
116+
continue
117+
118+
# get ${{ identifier.outputs.field_name }} objects from the string
119+
splits = look_up_table[node.identifier]["graph_node"].inputs[field_name].split("${{")
120+
for split in splits[1:]:
121+
if "}}" in split:
122+
123+
identifier = None
124+
field = None
125+
126+
syntax_string = split.split("}}")[0].strip()
127+
128+
if syntax_string.startswith("identifier.") and len(syntax_string.split(".")) == 3:
129+
identifier = syntax_string.split(".")[1].strip()
130+
field = syntax_string.split(".")[2].strip()
131+
else:
132+
errors.append(f"{node.node_name}.Inputs field '{field_name}' references field {syntax_string} which is not a valid output field")
133+
continue
134+
135+
if identifier is None or field is None:
136+
errors.append(f"{node.node_name}.Inputs field '{field_name}' references field {syntax_string} which is not a valid output field")
137+
continue
138+
139+
if identifier not in dependencies_graph[node.identifier]:
140+
errors.append(f"{node.node_name}.Inputs field '{field_name}' references node {identifier} which is not a dependency of {node.identifier}")
141+
continue
142+
143+
output_model_class = create_model(look_up_table[identifier]["database_node"].outputs_schema)
144+
if field not in output_model_class.model_fields.keys():
145+
errors.append(f"{node.node_name}.Inputs field '{field_name}' references field {field} of node {identifier} which is not a valid output field")
146+
continue
147+
148+
except Exception as e:
149+
errors.append(f"Error creating input model for node {node.identifier}: {str(e)}")
150+
151+
async def build_dependencies_graph(graph_nodes: list[NodeTemplate]):
152+
dependency_graph = {}
153+
for node in graph_nodes:
154+
dependency_graph[node.identifier] = set()
155+
if node.next_nodes is None:
156+
continue
157+
for next_node in node.next_nodes:
158+
dependency_graph[next_node].add(node.identifier)
159+
dependency_graph[next_node] = dependency_graph[next_node] | dependency_graph[node.identifier]
160+
return dependency_graph
161+
162+
async def verify_topology(graph_nodes: list[NodeTemplate], errors: list[str]):
163+
# verify that the graph is a tree
164+
# verify that the graph is connected
165+
dependencies = {}
166+
identifier_to_node = {}
167+
visited = {}
168+
169+
for node in graph_nodes:
170+
if node.identifier in dependencies.keys():
171+
errors.append(f"Multiple identifier {node.identifier} incorrect topology")
172+
return
173+
dependencies[node.identifier] = set()
174+
identifier_to_node[node.identifier] = node
175+
visited[node.identifier] = False
176+
177+
# verify that there exists only one root node
178+
for node in graph_nodes:
179+
if node.next_nodes is None:
180+
continue
181+
for next_node in node.next_nodes:
182+
dependencies[next_node].add(node.identifier)
183+
184+
# verify that there exists only one root node
185+
root_nodes = [node for node in graph_nodes if len(dependencies[node.identifier]) == 0]
186+
if len(root_nodes) != 1:
187+
errors.append(f"Graph has {len(root_nodes)} root nodes, expected 1")
188+
return
189+
190+
# verify that the graph is a tree
191+
to_visit = deque([root_nodes[0].identifier])
192+
193+
while len(to_visit) > 0:
194+
current_node = to_visit.popleft()
195+
visited[current_node] = True
196+
197+
if identifier_to_node[current_node].next_nodes is None:
198+
continue
199+
200+
for next_node in identifier_to_node[current_node].next_nodes:
201+
if visited[next_node]:
202+
errors.append(f"Graph is not a tree at {current_node} -> {next_node}")
203+
else:
204+
to_visit.append(next_node)
205+
206+
for identifier, visited_value in visited.items():
207+
if not visited_value:
208+
errors.append(f"Graph is not connected at {identifier}")
209+
71210
async def verify_graph(graph_template: GraphTemplate):
72211
try:
73212
errors = []
213+
database_nodes = await get_database_nodes(graph_template.nodes, graph_template.namespace)
214+
74215
await verify_nodes_names(graph_template.nodes, errors)
75216
await verify_nodes_namespace(graph_template.nodes, graph_template.namespace, errors)
76-
await verify_node_exists(graph_template.nodes, graph_template.namespace, errors)
217+
await verify_node_exists(graph_template.nodes, database_nodes, errors)
77218
await verify_node_identifiers(graph_template.nodes, errors)
219+
await verify_secrets(graph_template, database_nodes, errors)
220+
await verify_topology(graph_template.nodes, errors)
78221

79222
if errors:
80223
graph_template.validation_status = GraphTemplateValidationStatus.INVALID
81224
graph_template.validation_errors = errors
82225
await graph_template.save()
83226
return
84227

228+
dependencies_graph = await build_dependencies_graph(graph_template.nodes)
229+
await verify_inputs(graph_template.nodes, database_nodes, dependencies_graph, errors)
230+
85231
graph_template.validation_status = GraphTemplateValidationStatus.VALID
86232
graph_template.validation_errors = None
87233
await graph_template.save()

state-manager/pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ dependencies = [
88
"beanie>=2.0.0",
99
"cryptography>=45.0.5",
1010
"fastapi>=0.116.1",
11+
"json-schema-to-pydantic>=0.4.1",
1112
"python-dotenv>=1.1.1",
1213
"structlog>=25.4.0",
1314
"uvicorn>=0.35.0",

state-manager/uv.lock

Lines changed: 15 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)