Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion python-sdk/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -349,7 +349,7 @@ async def create_graph():

- `graph_name` (str): Name of the graph to create/update
- `graph_nodes` (list[GraphNodeModel]): List of graph node models defining the workflow (beta)
- `secrets` (dict[str, str]): Key/value secrets available to all nodes
- `secrets` (dict[str, str], optional): Key/value secrets available to all nodes. Omit it for a graph that needs none. The state manager replaces stored secrets on every upsert, so omitting it when updating a graph that has secrets clears them
- `retry_policy` (RetryPolicyModel | None): Optional retry policy configuration (beta)
- `store_config` (StoreConfigModel | None): Graph-level store configuration (beta)
- `triggers` (list[CronTrigger] | None): Optional list of cron triggers for automatic graph execution (beta: SDK version 0.0.3b1)
Expand Down
11 changes: 8 additions & 3 deletions python-sdk/exospherehost/statemanager.py
Original file line number Diff line number Diff line change
Expand Up @@ -125,7 +125,7 @@ async def get_graph(self, graph_name: str):
raise Exception(f"Failed to get graph: {response.status} {await response.text()}")
return await response.json()

async def upsert_graph(self, graph_name: str, graph_nodes: list[GraphNodeModel], secrets: dict[str, str], retry_policy: RetryPolicyModel | None = None, store_config: StoreConfigModel | None = None, triggers: list[CronTrigger] | None = None, validation_timeout: int = 60, polling_interval: int = 1):
async def upsert_graph(self, graph_name: str, graph_nodes: list[GraphNodeModel], secrets: dict[str, str] | None = None, retry_policy: RetryPolicyModel | None = None, store_config: StoreConfigModel | None = None, triggers: list[CronTrigger] | None = None, validation_timeout: int = 60, polling_interval: int = 1):
"""
Create or update a graph definition.

Expand All @@ -139,7 +139,11 @@ async def upsert_graph(self, graph_name: str, graph_nodes: list[GraphNodeModel],
Args:
graph_name (str): Graph identifier.
graph_nodes (list[GraphNodeModel]): List of graph node models defining the workflow.
secrets (dict[str, str]): Secrets available to all nodes.
secrets (dict[str, str] | None): Secrets available to all nodes. Optional: a graph
that needs none can omit it. The state manager replaces a graph's stored
secrets on every upsert, so omitting (or passing an empty mapping) when
updating a graph that already has secrets clears them; pass the full
mapping to keep them. The mapping you pass is copied, never mutated.
retry_policy (RetryPolicyModel | None): Optional per-node retry policy configuration.
store_config (StoreConfigModel | None): Beta configuration for the
graph-level store (schema is subject to change).
Expand All @@ -160,7 +164,8 @@ async def upsert_graph(self, graph_name: str, graph_nodes: list[GraphNodeModel],
"x-api-key": self._key
}
body = {
"secrets": secrets,
# The request model requires the field; an omitted argument is an empty mapping.
"secrets": dict(secrets) if secrets else {},
"nodes": [node.model_dump() for node in graph_nodes]
}

Expand Down
80 changes: 80 additions & 0 deletions python-sdk/tests/test_statemanager_optional_secrets.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
"""
Regression for https://github.com/FailproofAI/runtime/issues/631.

A graph that needs no secrets should be creatable without the caller passing an empty
mapping. The state manager's upsert request requires the `secrets` field, and
`GraphTemplate.set_secrets` replaces the stored secrets on every upsert, so the SDK sends
an empty mapping when the argument is omitted and never mutates a caller's own mapping.
"""
import pytest
from unittest.mock import AsyncMock, MagicMock, patch

from exospherehost.models import GraphNodeModel
from exospherehost.statemanager import StateManager


NODES = [GraphNodeModel(node_name="n", namespace="demo", identifier="n", inputs={}, next_nodes=None, unites=None)]


def _session(status=201):
session = AsyncMock()
response = AsyncMock()
response.status = status
response.json = AsyncMock(return_value={"name": "g", "validation_status": "VALID"})
context = AsyncMock()
context.__aenter__.return_value = response
context.__aexit__.return_value = None
session.put = MagicMock(return_value=context)
session.__aenter__.return_value = session
session.__aexit__.return_value = None
return session


def _sent_body(session):
return session.put.call_args.kwargs["json"]


@pytest.mark.asyncio
async def test_secrets_can_be_omitted_and_an_empty_mapping_is_sent():
session = _session()
with patch("exospherehost.statemanager.aiohttp.ClientSession", return_value=session):
sm = StateManager("demo", state_manager_uri="http://sm", key="k")
result = await sm.upsert_graph("g", NODES)
assert result["validation_status"] == "VALID"
assert _sent_body(session)["secrets"] == {}


@pytest.mark.asyncio
async def test_explicit_secrets_are_sent_unchanged_and_the_caller_mapping_is_not_mutated():
session = _session()
secrets = {"TOKEN": "synthetic-value"}
snapshot = dict(secrets)
with patch("exospherehost.statemanager.aiohttp.ClientSession", return_value=session):
sm = StateManager("demo", state_manager_uri="http://sm", key="k")
await sm.upsert_graph("g", NODES, secrets)
assert _sent_body(session)["secrets"] == snapshot
assert _sent_body(session)["secrets"] is not secrets
assert secrets == snapshot


@pytest.mark.asyncio
async def test_repeated_calls_without_secrets_share_no_mutable_default():
session = _session()
with patch("exospherehost.statemanager.aiohttp.ClientSession", return_value=session):
sm = StateManager("demo", state_manager_uri="http://sm", key="k")
await sm.upsert_graph("g", NODES)
first = _sent_body(session)["secrets"]
first["LEAKED"] = "x"
await sm.upsert_graph("g", NODES)
second = _sent_body(session)["secrets"]
assert second == {}
assert second is not first


@pytest.mark.asyncio
async def test_positional_secrets_still_bind_to_the_third_parameter():
session = _session()
with patch("exospherehost.statemanager.aiohttp.ClientSession", return_value=session):
sm = StateManager("demo", state_manager_uri="http://sm", key="k")
await sm.upsert_graph("g", NODES, {"A": "1"}, None, None, None, 5, 1)
assert _sent_body(session)["secrets"] == {"A": "1"}